前端单点登录(SSO)方案思考

1,960 阅读13分钟

什么是单点登录

单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

SSO一般都需要一个独立的认证中心(passport),子系统的登录均得通过passport,子系统本身将不参与登录操作,当一个系统成功登录以后,passport将会颁发一个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被passport授权以后,会建立一个局部会话,在一定时间内可以无需再次向passport发起认证。

举个例子,比如淘宝、天猫都属于阿里旗下的产品,当用户登录淘宝后,再打开天猫,系统便自动帮用户登录了天猫,这种现象背后就是用单点登录实现的。再比如百度贴吧和百度地图是百度公司旗下的两个不同的应用系统,如果用户在百度贴吧登录过之后,当他访问百度地图时无需再次登录,那么就说明百度贴吧和百度地图之间实现了单点登录。

SSO 机制实现流程

用户首次访问时,需要在认证中心登录:

 SSO 机制实现流程

  1. 用户访问网站 a.com 下的 pageA 页面。
  2. 由于没有登录,则会重定向到认证中心,并带上回调地址 www.sso.com?return_uri=a.com/pageA,以便登录后直接进入对应页面。
  3. 用户在认证中心输入账号密码,提交登录申请。
  4. 认证中心验证账号密码是否有效,通过后创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌,sso认证中心带着令牌跳转会最初的请求地址,即重定向回 a.com?ticket=123,并带上了令牌(授权码) ticket,并将认证中心 sso.com 的登录态写入 Cookie
  5. a.com 服务器中,拿着 令牌ticket 向认证中心确认令牌 ticket是否真实有效。
  6. 验证成功后,服务器将登录信息写入 Cookie(此时客户端有 2 个 Cookie 分别存有 a.comsso.com 的登录态)。
  7. a.com使用该令牌创建与用户的局部会话,返回受保护资源
全局会话和局部会话

用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心,全局会话与局部会话有如下约束关系

  • 局部会话存在,全局会话一定存在
  • 全局会话存在,局部会话不一定存在
  • 全局会话销毁,局部会话必须销毁
在认证中心登录完成之后,继续访问 a.com 下的其他页面:

SSO 机制实现流程 这个时候,由于 a.com 存在已登录的 Cookie 信息,所以服务器端直接认证成功。

如果认证中心登录完成之后,访问 b.com 下的页面:

SSO 机制实现流程 这个时候,由于认证中心存在之前登录过的 Cookie,所以也不用再次输入账号密码,直接返回第 4 步,下发 ticketb.com 即可。大概流程就是:

  1. 所谓的同平台下的另一个子系统的用户访问b.com的受保护资源

  2. b.com发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数

  3. sso认证中心发现用户已登录(a.com已经登录过了),跳转回b.com的地址,并附上令牌

  4. b.com拿到令牌,去sso认证中心校验令牌是否有效

  5. sso认证中心校验令牌,返回有效,注册b.com

  6. b.com使用该令牌创建与用户的局部会话,返回受保护资源

SSO 机制实现方式

单点登录主要有三种实现方式(同域SSO不用设置独立的 SSO 服务器,因为业务后台服务器本身就足以承担 SSO 的职能。):

  1. 父域 Cookie,和同域SSO不同在于,服务器在返回 cookie 的时候,要把cookie 的 domain 设置为其父域。
  2. 认证中心
  3. LocalStorage 跨域

一般情况下,用户的登录状态是记录在 Session 中的,要实现共享登录状态,就要先共享 Session,但是由于不同的应用系统有着不同的域名,尽管 Session 共享了,但是由于 SessionId 是往往保存在浏览器 Cookie 中的,因此存在作用域的限制,无法跨域名传递,也就是说当用户在 a.com 中登录后,Session Id 仅在浏览器访问 a.com 时才会自动在请求头中携带,而当浏览器访问 b.com 时,Session Id 是不会被带过去的。实现单点登录的关键在于,如何让 Session Id(或 Token)在多个域中共享。

1. 父域 Cookie

Cookie 的作用域由 domain 属性和 path 属性共同决定。domain 属性的有效值为当前域或其父域的域名/IP地址,在 Tomcat 中,domain 属性默认为当前域的域名/IP地址。path 属性的有效值是以“/”开头的路径,在 Tomcat 中,path 属性默认为当前 Web 应用的上下文路径。

如果将 Cookiedomain 属性设置为当前域的父域,那么就认为它是父域 CookieCookie 有一个特点,即父域中的 Cookie 被子域所共享,也就是说,子域会自动继承父域中的 Cookie

利用 Cookie 的这个特点,可以将 Session Id(或 Token)保存到父域中就可以了。我们只需要将 Cookiedomain 属性设置为父域的域名(主域名),同时将 Cookiepath 属性设置为根路径,这样所有的子域应用就都可以访问到这个 Cookie 了。不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.baidu.com 和 map.baidu.com,它们都建立在 baidu.com 这个主域名之下,那么它们就可以通过这种方式来实现单点登录。

总结:虽然我们可以使用 Cookie + Session 的方式完成了登录验证,但是这种方式也存在一些问题:

  1. 这种实现方式比较简单,但不支持跨主域名。
  2. 由于服务器端需要对接大量的客户端,也就需要存放大量的 SessionId,这样会导致服务器压力过大。
  3. 如果服务器端是一个集群,为了同步登录态,需要将 SessionId 同步到每一台机器上,无形中增加了服务器端维护成本。
  4. 由于 SessionId 存放在 Cookie 中,所以无法避免 CSRF 攻击。

2. 认证中心

我们可以部署一个认证中心,认证中心就是一个专门负责处理登录请求的独立的 Web 服务,相当于一个sso服务器。

用户统一在认证中心进行登录,登录成功后,认证中心记录用户的登录状态,并将 Token 写入 Cookie。(注意这个 Cookie 是认证中心的,应用系统是访问不到的)

应用系统检查当前请求有没有 Token,如果没有,说明用户在当前系统中尚未登录,那么就将页面跳转至认证中心进行登录。由于这个操作会将认证中心的 Cookie 自动带过去,因此,认证中心能够根据 Cookie 知道用户是否已经登录过了。如果认证中心发现用户尚未登录,则返回登录页面,等待用户登录,如果发现用户已经登录过了,就不会让用户再次登录了,而是会跳转回目标 URL ,并在跳转前生成一个 Token,拼接在目标 URL 的后面,回传给目标应用系统。

应用系统拿到 Token 之后,还需要向认证中心确认下 Token 的合法性,防止用户伪造。确认无误后,应用系统记录用户的登录状态,并将 Token 写入 Cookie,然后给本次访问放行。(这个 Cookie 是当前应用系统的,其他应用系统是访问不到的)当用户再次访问当前应用系统时,就会自动带上这个 Token,应用系统验证 Token 发现用户已登录,于是就不会有认证中心什么事了。

总结:现在前端很多都是使用jwtToken进行加密,所以此种实现方式相对复杂,支持跨域,扩展性好,是单点登录的标准做法。 这里展开讲一下,作为一个前端,主要对这种方式的过程进行一些简单的记录说明: 由于权限问题,前端须保证用户首次访问(既没有token)的时候,尽量不看到页面,因此我们须在渲染页面之前有一层判断。通过HOC高阶组件来判断是否有token,比如判断pathname里面是否包含sso-login等字段来返回不同的页面展示,类似于下面这样:

function Layout({ children, pathname }) {//Layout为高阶组件
  return ['/sso-login'].includes(pathname) ? children : <App init={store.init}></App>;
})

另外,还要根据网络请求后返回的code判断token是否过期,下方例子是react的写法。

Tip:react - 跳转时参数带上当前url

路由页面

.config\routes.ts

  {
    path: '/',
    component: '@/layouts/SecurityLayout',
    routes: [
      {
        path: '/',
        component: '@/layouts/BasicLayout',
        routes: [
          {
            path: '/',
            redirect: '/appManage',
          },
          {
            path: '/appManage',
            name: 'appManage',
            component: './appList',
          },
          ...
  }
复制代码

高阶组件HOC验证是否有token

src\layouts\SecurityLayout.tsx

  render() {
    const { isReady } = this.state;
    const { children, loading } = this.props;
    const jwt = getToken();
    const isLogin = jwt && jwt.expireAt && jwt.expireAt > moment().unix();

    if ((!isLogin && loading) || !isReady) {
      return <PageLoading />;
    }
    if (!isLogin && window.location.pathname !== '/user/login') {
    //sso-login的情况
	const DOMAIN = 'https://sso.heiwangbatiancaishaonian.com'
    const { redirect } = getPageQuery();
    const queryString = stringify({
      redirect: redirect || window.location.href,
    });
    const service = `${window.location.origin}/user/login?${queryString}`;
    window.location.href = `${DOMAIN}/cas/login?service=${service}`;
    	return null;
    }
    return children;
  }
复制代码

在路由中添加多一级高阶组件SecurityLayout,如果通过验证才渲染路由中页面,否则跳转。

注意HOC判断中必须先判断当前页是否为/login

http拦截器

src\utils\request.ts


request.interceptors.response.use(async (response: Response) => {
  const data = await response.clone().json();
    
  if (data.respCode >= 40100 && data.respCode < 40199) {
    localStorage.clear();
    const ssoURL = 'https://sso.heiwangbatiancaishaonian.com/cas/login'
    const { redirect } = getPageQuery();
    const queryString = stringify({
      redirect: redirect || window.location.href,
    });
    const service = `${window.location.origin}/user/login?${queryString}`;
    window.location.href = `${ssoURL}?service=${service}`;
    ...
复制代码

返回拦截判断code,不成功就跳转,跳转时定义redirect字段以当前路径为参数拼入重定向链接

login组件

src\pages\user\login\index.tsx


const Login: React.FC<LoginProps> = props => {
  useEffect(() => {
    const { dispatch, location } = props;
    // node中querystring模块
    const queryString = stringify({
      redirect: location.query.redirect,
    });
    const service = `${window.location.origin}/user/login?${queryString}`;
    //往redux里存入ticket和service,并在src/services/login.ts里将ticket进行存储
    dispatch({
      type: 'login/login',
      payload: {
        ticket: location.query.ticket,
        service: service,
      },
    });
  }, []);

  return (
    <div className={styles.main}>
      <PageLoading />
    </div>
  );
};
复制代码

以当前路径拼接redirect为service的字段请求token。

Tip: 如果url上没有带ticket,前往某个系统(比如系统A)生成一个,系统A生成ticket后重定向到本页面,该例子中请求token的service参数有可能与sso的url参数中service值不同,这不是常规的操作,一般来说正确的请求参数必须与sso的url参数中service值一致,否则无法通过验证

3. LocalStorage 跨域

其实单点登录的关键在于,如何让 Session Id(或 Token)在多个域中共享。但是 Cookie 是不支持跨主域名的,而且浏览器对 Cookie 的跨域限制越来越严格。

在前后端分离的情况下,完全可以不使用 Cookie,我们可以选择将 Session Id (或 Token )保存到浏览器的 LocalStorage 中,让前端在每次向后端发送请求时,主动将 LocalStorage 的数据传递给服务端。这些都是由前端来控制的,后端需要做的仅仅是在用户登录成功后,将 Session Id (或 Token )放在响应体中传递给前端。

在这样的场景下,单点登录完全可以在前端实现。前端拿到 Session Id (或 Token )后,除了将它写入自己的 LocalStorage 中之外,还可以通过特殊手段将它写入多个其他域下的 LocalStorage 中,比如通过H5的新属性postMessage来实现跨域共享等等。

总结:此种实现方式完全由前端控制,几乎不需要后端参与,同样支持跨域。

实现思路:当用户访问公司某系统(如product.html)时,在product中会首先加载一个iframe,iframe中可以获取存储在localStorage中的token,如果没有取到或token过期,iframe中内部将把用户将重定向到登录页,用户在此页面登录,仍将去认证系统取得token并保存在iframe页面的localStorage,实现方案大概如下所示:

  • 实现方案

    前端拿到 Token 后,不仅要将它写入当前域下的 localStorage 中,还要通过 iframe + postMessage() 的方式将它写入多个信任的其他域下的 localStorage 中,从而实现登录状态的共享

    iframe_postMessage.jpeg

  • 实现代码

    Token 写入多个域名下的 localStorage

    const iframe = document.createElement('iframe')
    iframe.src = 'http://www.app.com/static/bridge.html'
    iframe.addEventListener('load', event => {
      iframe.contentWindow.postMessage(token, 'http://www.app.com/static/bridge.html')
    })
    document.body.append(iframe)
    复制代码
    

    iframe 加载的页面中绑定事件监听器,用来接收 Token 数据

    window.addEventListener('message', ({ data, origin, srouce }) => {
      localStorage.setItem('AUTH-TOKEN', data)
    })
    复制代码
    

    在各个应用系统请求的 Header 中携带 Token 令牌

    config.headers.common['Authorization'] = 'Bearer ' + token
    复制代码
    

Tip:Bearer 是 JWT 的认证头部信息,另外postMessage还是存在一定的兼容性问题,有兼容性要求的可以使用别的思路~

SSO 单点登录退出

目前我们已经完成了单点登录,在同一套认证中心的管理下,多个产品可以共享登录态。现在我们需要考虑退出了,即:在一个产品中退出了登录,怎么让其他的产品也都退出登录?

原理其实不难,可以在每一个产品在向认证中心验证 ticket(token) 时,其实可以顺带将自己的退出登录 api 发送到认证中心。

sso认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作。当某个产品 c.com 退出登录时:

  1. 清空 c.com 中的登录态 Cookie
  2. 请求认证中心 sso.com 中的退出 api
  3. 认证中心遍历下发过 ticket(token) 的所有产品,并调用对应的退出 api,销毁局部会话,完成退出。
  4. sso认证中心引导用户至登录页面

单点登录前端面试话术:

主要提出以下几个关键的点,其他的自己发挥:

  1. sso提供一个所有系统重定向认证登录页面,浏览器和sso服务建立关系,分发令牌
  2. 任意一个子系统进行登录,先验证未登录,登录过了就在认证中心存储对应的令牌(ticket或token);
  3. 不同的子系统只要有一个系统开始过了,皆可使用认证中心的令牌来进行面登录访问其他子系统;
  4. 高阶组件HOC验证是否有token令牌来进行sso登录页面及相关逻辑和展示正常页面;
  5. sso单点登录退出会清空全局会话进而清空所有的局部会话;