一个 web 应用要为登陆用户提供服务, 通常是在 session 中添加一个 session key 字段, 通常是使用 cookie 来做. 应用通过 session key 检查用户是否登陆.

session key 通常应该是随机生成的, 不包含任何有效的用户信息, 并且在一段时间内 (至少大于一个 session key 的最大有效时间) 不会重复. 应用做登陆管理时, 通常使用 session key (或其转换形式以更好加密) 作为主键关联用户信息, 从而可以通过 session key 获取登录用户信息. 如果多个 session key 可以对应到同一个用户, 则该系统是允许重复登陆的, 否则一次新登录会踢掉上一次登陆. 应用通过 session key 检查用户是否已登陆, 这包含几个方面:

  1. session key 可能是用户随意伪造的, 服务器上根本不存在对应的 session key.
  2. session key 登陆时间可能已经过期,需要重新登录.
  3. 对于基于 cookie 存储和传递的 session key, 我们可能希望 session key 绑定到一个 User-Agent 的一次会话, 不可持久化或转移, 那么可能需要做一些安全加密校验. 通常许多系统没有做 (或做不到?) 这样的限制, 所以只要完整拷贝 cookie 就可以复用会话.
  4. 可能需要一些安全机制确保用户不可能 (或几乎不可能) 伪造出一个合法的 session key, 如添加一些额外的安全检查字段. 后续我们说 session key 时通常是指同时包含这些安全校验信息.

如果我们有几个系统都需要用户登陆信息, 那么用户登录检查和获取用户信息这一块可以抽出成一个独立的 login 系统, 提供一些公共组件和查询接口. 只要这些系统都在一个根域名下, login 系统把 cookie 写到根域名下, 所有系统便都能访问到.

应用系统页面可分为可选登陆和强制登陆. 对强制登录的页面, 用户使用 User-Agent 交互访问时, 可直接重定向到登录页面, 登录成功再跳转回来. 为了不丢信息, 只有 GET 请求才能做这个跳转. 登陆页面可由 login 系统统一提供. POST 请求或非交互访问可直接报错. POST 请求交互访问报错, 用户可能需要重写填写表单, 体验不友好, 前段开发可使用 ajax 异步提交表单和 js 跳转或刷新页面. 提交失败时可报错或页面弹出登陆框让用户即时登陆后重试. 对有验证码的表单, 提交表单失败但验证码已失效 (如已校验使用), 应刷新验证码并要求 (提示) 用户重新填写. 防止 "愚弄" 用户: 用户再次提交表单时报验证码失败。

登陆系统需要验证用户身份, 然后生成一个 session key, 记录下 session key 和用户信息, 然后将 session key 及相关安全校验信息 (如果有) 回传给 User-Agent. 验证用户身份, 传统是用户名密码, 现在还有扫码登录, openid 等方式. 登陆系统通常应该使用 https 协议保证安全。 其他系统通过 session key 向登陆系统查询登录用户信息, 登陆系统的这个查询服务通常只提供给内部系统, 不对外公开, 通常可采用内网 https 或内网 RPC 协议提供. 或使用安全的来源系统认证做限制。 session 校验和查询是一个非常频繁的行为, login 系统要保证性能很好, 可考虑将校验逻辑 (部分或全部) 挪到客户端, 检查和查询功能合二而一, 由一个接口提供, 错误码区别错误或成功取到用户信息. 每个系统可能根据自己的需要设计存储不同的用户信息, login 系统只返回基本 (公共) 用户信息, 如 userId, 其他信息由各系统自行维护.

因为 cookie 不会跨域传播, 跨域名的单点登录 SSO 需要特别设计. 我们把 login 系统根域名叫做主域名, 其他域名叫做合作域名. 主域名添加一个同步 cookie 页 sync (如可添加到 login 系统), 合作域名添加一个回写 cookie 页 pass, 可能是一个独立的系统, 相当于合作域名的 login 系统, 或合作域名 login 系统的一部分. 合作域名应用 app 检查到用户没有登录时, 可能是 cookie 没有同步. app 首先访问 sync 页面, 将 app 也作为回跳地址, sync 页如果检查到用户已登录, 则将 session key 及安全校验信息传递给 pass 页,并带上回跳地址. 如果未登录, 则要求用户登录, 并在用户登录后相同逻辑访问 pass 页. 简单说, sync 页是一个将 session key 传给 pass 页的强制登录页面. pass 也将 cookie 写回 User-Agent, 然后直接跳回应用 app 页面.

注意: 如果合作域名的登录检查逻辑出错了, 即用户在主域名检查已经登录, 但合作域名检查失败, 这里会产生一个死循环: app -> sync -> pass -> app. 虽然如果合作域名与主域名共用一套登录检查逻辑和 login 后台系统, 这基本上不可能发生. 但要考虑一下此问题的解决方案, app 如果检查到其是从 pass 回跳过来, 则即使登录检查失败, 也不要再访问 sync 了, 因为事实证明这没有什么卵用. 如何检查呢?一种方式是 pass 回跳 app 页面时在 url 上打标, 如在 url 末尾加 #%00pass=1 check 标, app 检查登录失败, 并且含有这个标, 则不访问 sync, 此时一种处理办法是跳到合作域名自己的登录页, 即合作域名也维护一个登录系统, 代码基本上可以复用主域名的登录系统. 为了避免附加的标影响系统, app 如果检查到这个标, 应该再继续处理前去掉这个标. 如检查登录成功, 可去掉这个标再回跳一次本地址. 这会导致多做一次登录检查, 但登录检查的性能应该很高 (通过客户端缓存等办法), 这不应该成为一个问题.

综上, 合作域名的强制登录页面登录检查逻辑大致梳理如下:

从 session 获取 session key, 从 login 查询用户信息
1. 用户已登录
	1.1 url 无 check 标, ok
	1.2 url 带 check 标, 重定向到去掉 check 标的 url.
2. 用户未登录
	2.1 url 无 check 标, 重定向到 sync 页, 传递 pass 页和 url 参数
	2.2 url 有 check 标, 去掉 check 标作为回跳地址, 重定向到自身域名 login 页

大致看了一下, 这套流程除了对 sync 页会不断跳到自己成死循环之外, 对主域名的其他页面也是适用的. 但 2.2 有两个问题, (1) 主域名没有 (不需要) pass 页, (2) 用户登录每次从 pass 页转一次是多余的浪费. 把 2.2 的逻辑适配一下, 对主域名去掉 pass 登录, 即可成为一套通用逻辑, 可抽取出几个配置项如下:

  1. 自身域名 login 页
  2. 是否使用 sync 页登录, 仅在为 true 时需要配置下面的 3, 4 项.
  3. sync 页地址
  4. pass 页地址