做后端的朋友大概率都被问过这个问题:公司里七八个后台系统,能不能让用户只登录一次?这就是SSO 单点登录(Single Sign On)要解决的事。它本质上不是某个框架或者某个中间件,而是一套"身份认证外包"的架构约定——把"你是谁"这件事从每个业务系统里抽出来,交给一个专门的认证中心统一回答,业务系统只负责拿着认证中心给的凭证去确认"这个人确实登录过了"。听起来简单,但真正落地时会碰到一堆细节:票据怎么设计、Cookie 挂在哪个域、跨域怎么办、单点登出怎么广播、集群部署下会话存哪里。我前后参与过三套不同规模的统一认证体系,从最早的手写票据校验,到后来用标准协议栈,踩过的坑能写满一整面白板。这篇就把 SSO 单点登录从头到尾拆开讲:它的核心原理、协议选型、最小可用实现、安全检查清单,以及那些文档里不会写但线上一定会遇到的排查经验。不管你是刚接手统一登录模块的新人,还是正在评估要不要自建认证中心的老手,应该都能从里面拿到可以直接抄的东西。
1. 先搞清楚:SSO 到底解决了谁的痛点
1.1 从一个真实的用户投诉说起
某公司内部有四套系统:工单、报表、审批、文档。四套系统各自维护一张用户表,各自有一套登录页。表面上看没什么问题,直到运维同学统计了一次数据:同一个员工在四套系统里有四个密码,其中三个是同一个,另一个是半年前改密码时忘了同步的。更麻烦的是入职和离职——新人来了要开四个账号,走一个人要记得去四个地方禁用。有一次离职流程漏了一套系统,账号挂了三个月才被发现。
这不是管理疏忽,是架构问题。只要身份认证逻辑分散在各个业务系统里,用户数据就一定会发散,权限回收就一定会漏。SSO 单点登录的第一个价值就在这里:把用户、密码、会话这些东西收敛到一个地方管理,业务系统不再持有密码,只持有"认证结论"。回收权限时只需要在认证中心点一下,所有系统同时失效。
第二个价值是体验。用户登录一次,切到另一个系统时不需要再输密码,浏览器直接跳过去就是已登录状态。这个体验背后是一整套重定向加票据校验的机制,后面会详细拆。
第三个价值,也是最容易被忽略的:审计。所有登录行为都经过认证中心,登录时间、IP、设备、失败次数全部集中记录。出了安全事件,你能查到的是一条完整链路,而不是四个系统里四份格式不同的日志。
1.2 SSO 的三个核心角色与一次登录的完整链路
不管用什么协议,SSO 的骨架永远是三个角色:用户浏览器、业务系统(很多协议文档里叫 SP,Service Provider)、认证中心(叫 IdP,Identity Provider,或者 CAS 里的 CAS Server)。关键点在于:浏览器和业务系统之间、浏览器和认证中心之间都有交互,但业务系统和认证中心之间必须有一次服务端到服务端的直接校验。这一步是整个 SSO 安全性的基石,少了它,任何人伪造一个 URL 参数就能冒充登录。
一次完整的未登录访问,链路是这样的:
- 用户访问业务系统的某个页面,业务系统检查自己的局部会话,发现没有,说明未登录。
- 业务系统返回 302,把浏览器重定向到认证中心,URL 上带两个关键参数:
service(或者redirect_uri,告诉认证中心"校验完把我送回哪")和state(一个随机串,防 CSRF)。 - 浏览器带着认证中心的 Cookie 去请求认证中心。认证中心看这个 Cookie 对应的全局会话是否存在:
- 存在且未过期,直接生成一张一次性票据,302 回
service?ticket=xxx。 - 不存在,展示登录页,用户输账号密码,验证通过后建立全局会话(写 Cookie),再生成票据回跳。
- 存在且未过期,直接生成一张一次性票据,302 回
- 浏览器带着
ticket回到业务系统。业务系统在后端拿这张 ticket 去认证中心的校验接口换取用户身份。 - 认证中心校验 ticket 合法且未被使用过,返回用户标识(可能还有昵称、角色等属性),同时把这张 ticket 标记为已消费。
- 业务系统拿到用户标识,建立自己的局部会话(写自己的 Cookie 或者发自己的 Token),然后把浏览器重定向到最初访问的干净 URL——注意要去掉 URL 上的 ticket 参数,避免被复制传播。
这套流程看着有六步,实际耗时通常在几百毫秒。它的精妙之处在于:票据只在浏览器上"路过"一次,真正决定身份的是第 5 步的服务端校验。即使有人从浏览器地址栏里抄走了 ticket,只要业务系统已经校验过一次,第二次就会因为"已消费"而失败。
1.3 全局会话和局部会话:理解 SSO 的关键分水岭
很多人写 SSO 写不下去,是因为没把这两个会话分清楚。
全局会话挂在认证中心的域名下,代表"这个浏览器在认证中心登录过"。它的载体通常是一个 HttpOnly 的 Cookie,里面存 session id 或者加密后的令牌。全局会话的生命周期决定了一次登录能用多久,它是单点登录体验的来源——只要全局会话还在,你访问第二个系统时,认证中心认出你来,直接发票据,不用再输密码。
局部会话挂在各个业务系统自己的域名下,代表"这个浏览器在这个系统里登录过"。它可以是 Session,也可以是 JWT。局部会话的作用是避免每次请求都往认证中心跑——毕竟用户点一个按钮就要跳一次认证中心,性能扛不住,认证中心也会成为单点故障。
两者的失效关系是不对称的:全局会话失效,局部会话不一定立刻失效(除非做了单点登出广播);局部会话失效,通常触发一次重新走认证中心的流程,如果全局会话还在,用户无感知地重新登录。这个不对称性是很多"登出没生效"问题的根源,第 3 章会展开讲。
还有一个细节值得说:局部会话的有效期应该短于全局会话。我一般设成全局 8 小时、局部 2 小时。这样用户早上登录,中午局部会话过期,但因为全局会话还在,后台自动刷新一次,用户完全感觉不到。如果反过来设,用户就会频繁被踢回登录页,体验很差。
2. 技术选型:CAS、OAuth2、OIDC、SAML 到底怎么挑
2.1 四套协议的血缘关系和适用边界
这几个名词经常被混着说,实际定位差别很大。
CAS(Central Authentication Service)是专门为 SSO 设计的协议,流程就是上面 1.2 描述的那套,票据叫 ST(Service Ticket),全局会话票据叫 TGC。它的优点是目标极其纯粹——就是为了做单点登录,语义清晰,客户端库成熟,接入成本低。缺点是它只解决认证,不解决授权;而且早期版本的票据校验是 XML 格式,现在 3.0 之后支持 JSON。
SAML 2.0是企业级的老牌方案,用 XML 做断言(Assertion),断言里可以带用户属性、角色、权限,天然适合跨组织的身份联邦。它的接入成本明显高——XML 签名、加密、元数据交换,配置一堆证书,调试起来很痛苦。但如果你要对接的是外部合作方的系统,或者对方是传统的企业软件,SAML 往往是唯一选择。
OAuth 2.0经常被当成 SSO 协议用,这是个概念错误。OAuth 2.0 是授权协议,它回答的是"这个应用能不能代表用户去访问某个资源",而不是"这个人是谁"。授权码模式下拿到的 access_token 只是访问凭证,本身不包含身份信息。很多系统硬把它当认证用,结果就是"用微信登录"这类场景里出现了不少安全问题。
OIDC(OpenID Connect)才是 OAuth 2.0 之上补的认证层。它在授权码流程里加了一个id_token,这是一个 JWT,标准字段里包含sub(用户唯一标识)、iss(签发方)、aud(接收方)、exp(过期时间)、nonce。有了 id_token,你才有了可信的身份来源。要做现代化的统一认证,OIDC 是目前最合理的选择,尤其是有移动端、有前后端分离场景的时候。
2.2 一个容易踩的坑:把 OAuth2 当认证协议用
这个坑值得单独说,因为我见过不止一个项目栽在上面。
典型错误流程是这样的:业务系统引导用户去授权服务器,拿到 access_token,然后业务系统拿着这个 token 去调用某个"获取用户信息"接口,拿到用户 ID,建立会话。
问题在哪?access_token 是发给"客户端应用"的,不是发给"用户"的。在标准 OAuth 2.0 里,客户端应用可能是一个后端服务,它拿到的 token 代表的是"这个应用被授权了",而不是"这个用户是谁"。更严重的是,如果攻击者能用自己开发的第三方应用拿到一个 access_token,然后把这个 token 塞给你的回调接口,你的系统就会认错人。这类攻击有个名字,叫"令牌替换"。
OIDC 从设计上堵住了这个漏洞:id_token的aud字段限定了这张身份令牌是发给哪个客户端的,你的系统校验时必须检查aud是不是自己。同时iss必须是你信任的签发方,签名必须用对方的公钥验过。少了这三项检查中的任何一项,身份就可能被伪造。
所以记住一句话:认证用 OIDC,授权用 OAuth 2.0,别混。如果对方的系统只提供 OAuth 2.0 接口,那你在调"获取用户信息"之前,必须确保这个 access_token 是通过你自己的 client_id 拿到的,并且最好再加一层校验。
2.3 我的选型判断表
实际做决策的时候,我一般按这几个维度过一遍:
| 维度 | CAS | SAML 2.0 | OAuth 2.0 | OIDC |
|---|---|---|---|---|
| 协议定位 | 纯认证 | 认证 + 属性联邦 | 授权 | 认证(基于授权码流程) |
| 身份凭证 | ST 票据 | XML 断言 | access_token(不含身份) | id_token(JWT) |
| 接入复杂度 | 低 | 高 | 中 | 中 |
| 移动端友好度 | 一般 | 差 | 好 | 好 |
| 前后端分离友好度 | 一般 | 差 | 好 | 好 |
| 适合场景 | 内部系统统一登录 | 跨组织联邦、老系统对接 | 开放平台给第三方授权 | 现代统一认证、多端登录 |
选型的实际判断逻辑是:内部系统,优先 CAS 或 OIDC;有移动端或前后端分离,直接上 OIDC;要对接外部组织,看对方支持什么,SAML 该上还得上。别为了"先进"去选一套团队驾驭不了的协议,认证中心是基础设施,稳定压倒一切。
3. 手撸一个最小可用 SSO:从票据设计到回调校验
3.1 认证中心的核心接口设计
不管底层用什么协议,认证中心至少要暴露三个接口。我按最小可用的粒度列一下:
GET /authorize:接收service(回调地址)和state,判断全局会话,决定是展示登录页还是直接发票据。POST /login:账号密码校验,成功后写全局会话 Cookie,再 302 回service?ticket=xxx&state=xxx。POST /validate(服务端调用):接收ticket和service,返回用户身份或者失败原因。GET /logout:清除全局会话,可选地通知各业务系统。
票据的生成我一般用随机串,不用 JWT。原因很简单:票据必须能被主动作废,而自包含的 JWT 作废需要额外维护黑名单,反而更麻烦。用一个 32 字节的随机串存进 Redis,key 是票据,value 是用户 ID 加回调地址,设置 60 秒过期,用完即删。60 秒足够完成一次重定向,超时未用自动清理,不会堆积。
import secrets, json, time def issue_ticket(redis_client, user_id, service): ticket = secrets.token_urlsafe(32) payload = { "uid": user_id, "service": service, "iat": int(time.time()), } # 60 秒有效期,一次性消费 redis_client.setex(f"sso:ticket:{ticket}", 60, json.dumps(payload)) return ticket def validate_ticket(redis_client, ticket, service): key = f"sso:ticket:{ticket}" # 用 pipeline 保证读+删的原子性,防止并发重复消费 pipe = redis_client.pipeline() pipe.get(key) pipe.delete(key) raw, _ = pipe.execute() if not raw: return None, "ticket_not_found" data = json.loads(raw) if data["service"] != service: return None, "service_mismatch" return data["uid"], None这段代码有两个细节是踩过坑才加上的。
第一个是service必须在校验时再检查一遍。如果只在发放时记录、校验时不比对,攻击者可以拿一张发给 A 系统的票据去 B 系统的回调地址换身份——这叫"票据重定向攻击"。比对时要注意,service字符串必须是完全相等,包括大小写、末尾斜杠、URL 编码。线上最常见的票据校验失败就是这个原因:申请票据时传的是https://a.example.com/callback,校验时从数据库里读出来的是https://a.example.com/callback/,多了一个斜杠,直接 mismatch。
第二个是get和delete必须放在一个 pipeline 里。如果分开写,两个请求同时拿同一张票据来校验,可能都读到值、都判断通过,造成一次票据换两个会话。虽然概率低,但在有重放攻击的场景下这是致命的。
3.2 票据的生成与校验:为什么必须一次性
一次性这个要求,本质上是把票据从"凭证"降级成"信使"。它只负责把"认证中心已经认过这个人"这个消息送到业务系统,送完就销毁。
设想一下如果不是一次性会怎样:用户在网吧登录,票据被浏览器历史记录或者某个中间代理记录下来。攻击者拿到票据,直接请求业务系统的callback?ticket=xxx,如果票据还能用第二遍,攻击者就拿到了一个合法会话。而票据的有效期哪怕只有 60 秒,在自动化工具面前也绰绰有余。
除了"用完即删",还有几个加固点值得加:
绑定客户端指纹。发放票据时,把请求的 User-Agent 哈希后一起存进去,校验时比对。UA 变了就拒绝。这个能挡住一部分票据被复制到另一台机器使用的情况,但要注意 UA 本身可伪造,只是增加成本,不是绝对防护。
限制校验来源 IP。业务系统的校验请求来自服务端,如果业务系统部署在固定的内网段,可以要求校验请求必须来自这个网段。这样即使票据泄露,攻击者在外部也没法完成校验——因为他没有能力伪造业务系统后端的请求。
失败即作废。票据校验失败时(比如 service 不匹配),不要只是返回错误,应该立刻把这张票据删掉。防止攻击者用一张票据反复试探不同的 service 参数。
注意事项:票据的存储介质必须支持原子性的"读并删除"。用数据库的话,别用
SELECT再DELETE,用DELETE ... RETURNING(PG)或者UPDATE ... SET used=1 WHERE used=0判断影响行数。用 Redis 就是上面的 pipeline 或者 Lua 脚本。这一点在并发压测时特别容易暴露。
3.3 业务系统的接入改造:三行代码背后的四次跳转
业务系统这边看起来很简单,接入的核心逻辑大概就是三件事:拦截未登录请求、跳认证中心、处理回调。但实际改造时,这四类代码要动:
第一处是登录拦截器。所有需要登录的路由都要过一遍检查,没会话就跳转。这里要注意排除回调路径本身,否则回调请求也会被拦截,直接死循环。同时要保留原始访问地址,通常放在service参数里一起传过去,登录成功后跳回来。
// 拦截器伪代码 function authGuard(req, res, next) { if (req.session && req.session.userId) return next(); // 回调路径与静态资源必须放行,否则死循环 if (req.path.startsWith('/sso/callback') || req.path.startsWith('/public')) { return next(); } const state = crypto.randomBytes(16).toString('hex'); req.session.ssoState = state; // 存起来,回调时比对 const service = encodeURIComponent(SITE_ORIGIN + '/sso/callback'); res.redirect(`${IDP_ORIGIN}/authorize?service=${service}&state=${state}`); }第二处是回调处理。拿到ticket和state,先比对state和会话里存的是否一致,不一致直接拒绝(这是防 CSRF 的关键)。然后后端发起 HTTP 请求去认证中心校验票据,拿到用户 ID 后建立局部会话,最后 302 到干净的业务页面。
第三处是登出。清掉局部会话,然后跳到认证中心的登出地址,把全局会话也清掉。这里要带一个service参数,让认证中心登出后跳回业务系统首页,否则用户会停在认证中心的白页面上。
第四处是 Token 或会话的透传。如果业务系统之间有内部调用,要把当前用户身份传下去。常见做法是网关统一注入用户标识头,不要把 Session 直接共享给所有系统。
我在实际项目里会给这块加一个"登录态自检"的日志:每次拦截器判定未登录时,把请求路径、当前 Cookie 是否存在、跳转目标打一条日志。这条日志在排查"明明登录了还一直跳"的问题时价值极高,因为你能一眼看出是 Cookie 没带上,还是拦截器判断逻辑写错了。
3.4 单点登出的实现难点
单点登出(SLO)是 SSO 里最容易被低估的部分,也是我见过最多项目"假装做了"的部分。
为什么难?因为认证中心要通知的对象是多个不同域下的业务系统,而认证中心没有办法主动往用户浏览器写别人的 Cookie。它只能通过浏览器做跳转,或者通过后端做服务间调用。
主流做法有两种:
第一种是后端广播。认证中心维护一份业务系统回调地址清单,用户登出时,认证中心并行地向每个业务系统的POST /sso/logout-backchannel发请求,业务系统收到后清掉服务端 Session。这个方案的好处是不依赖浏览器,即时性好;坏处是需要维护清单,而且如果业务系统是纯前端会话(JWT 存在 localStorage),服务端清 Session 也没用。
第二种是前端跳转链。认证中心登出后依次 302 到各个业务系统的登出地址,逐个清 Cookie,最后跳回首页。这个方案能清掉浏览器端的 Cookie,但用户要看到一连串跳转,体验差,而且任何一个环节超时都会卡住。
我的实际做法通常是混合加降级:认证中心广播通知清服务端会话,同时把该用户在认证中心的全局会话标记为失效;业务系统的局部会话有效期设得比较短(比如 2 小时),配合"关键操作前重新校验全局会话"的策略。这样即使某个系统的登出通知丢了,最坏情况也是两小时内自然过期,不会无限期挂着。
对安全性要求高的系统(比如涉及资金的),我会额外加一层:敏感操作前强制向认证中心做一次在线校验(相当于 OIDC 里的prompt=login或者 CAS 的renew=true)。虽然多一次网络请求,但这层保险值。
4. 会话与跨域:SSO 落地时最容易被低估的部分
4.1 Cookie 的 Domain 与 Path 到底怎么设
Cookie 的作用域规则是 SSO 实施中最基础也最容易出错的知识点。
认证中心的全局会话 Cookie,要让所有子域的请求都能带上,Domain必须设成父域,比如.example.com(注意前面的点,虽然现代浏览器对前导点不敏感,但显式写上更清晰)。Path设成/,这样所有路径都能带上。
但这里有个前提:认证中心和业务系统必须是同一个父域下的不同子域,比如sso.example.com和app1.example.com。如果业务系统在完全不同的域名下,Cookie 是无论如何都共享不了的——这是浏览器的同源策略,绕不过去。这种情况下只能走完整的重定向流程,每次都要跳认证中心确认全局会话。
还有一个坑:不要在父域 Cookie 里存业务系统的局部会话。很多人图省事,把用户 ID 直接写进.example.com的 Cookie,结果所有子域都能读到,一旦某个子域有 XSS 漏洞,全站的会话就都泄露了。局部会话必须严格限定在自己的子域。
HttpOnly、Secure、SameSite这三个属性是标配:HttpOnly防止 JS 读取,Secure要求 HTTPS,SameSite我一般设Lax——既能防止大部分 CSRF,又不影响顶层的重定向跳转。如果你的流程里有跨站 POST 回调,可能得设None,但那样必须配Secure,而且安全性会打折扣。
4.2 SameSite 与前后端分离下的 Token 存放
前后端分离之后,SSO 的会话存储变成一个选择题:Session Cookie 还是 Token。
如果是 Session Cookie,前端要配withCredentials: true,后端 CORS 要配Access-Control-Allow-Credentials: true和具体的Access-Control-Allow-Origin(不能是*,否则浏览器直接拒绝)。这套配置我见过太多人写错,典型的报错就是控制台一行红字:The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'。
如果是 Token,就要选存放位置。localStorage方便但 XSS 能读走;sessionStorage同理但关掉标签页就没;内存里最安全但刷新就丢。我现在的做法是:短期 access_token 放内存,长期 refresh_token 放 HttpOnly Cookie。刷新时用 Cookie 去换新的 access_token,即使 XSS 拿到了内存里的 token,有效期也只有十几分钟。
要注意,认证中心发的 Cookie 和业务系统自己发的 Token 是两套东西,别混。认证中心的 Cookie 只用于在认证中心域内判断全局会话,业务系统的 Token 只用于自己的接口鉴权。两者之间的桥梁就是那张一次性的 ticket。
提示:如果用户反馈"登录后刷新页面就掉线",八成是 access_token 存在内存里、刷新就丢了,而 refresh 流程没做对。先检查 refresh_token 的 Cookie 是不是被
SameSite=Strict拦掉了——跨站跳转回来的时候,Strict的 Cookie 不会被发送。
4.3 集群部署下的会话一致性
单机跑通不难,上了集群就开始出问题。最常见的是:用户在 A 节点登录,下一个请求被负载均衡打到 B 节点,B 节点找不到 Session,判定未登录,又跳回认证中心。
解决办法按推荐程度排序:
无状态 Token 优先。局部会话用签名 JWT,服务端不存状态,任何节点都能验签。代价是无法主动作废,需要配合短有效期加黑名单。
集中式会话存储。Session 存 Redis,所有节点共享。这个方案改造成本低,很多框架有现成集成。要注意 Redis 的可用性——Redis 挂了就等于全站掉线,所以要么做集群,要么加本地缓存兜底。
会话粘滞。负载均衡按 Cookie 把同一用户固定到同一节点。这个方案最省事但最脆弱,节点一挂用户就掉线,扩缩容时也会打乱粘滞关系。我不推荐作为主方案。
认证中心这边的全局会话同理,也必须集群共享。而且认证中心往往是整个体系的单点,它的可用性要求比业务系统更高。我一般会给它配独立的高可用集群,并且在业务系统侧做降级:认证中心短暂不可用时,允许已登录用户继续使用现有会话,只是新登录暂停。这样至少不会因为认证中心抖一下导致全站瘫痪。
5. 安全加固:票据、重定向与令牌的攻防细节
5.1 重定向地址白名单:最容易被忽略的高危点
这一条我要单独拎出来说,因为它是 SSO 里出现频率最高的漏洞,没有之一。
认证中心的/authorize接口接收一个service参数,然后无条件跳过去。如果这个参数不做校验,攻击者可以构造service=https://evil.example.net/steal,诱导用户点击一个看起来指向认证中心的链接。用户登录后,认证中心把票据发到攻击者的域名,攻击者拿到票据就能换到用户的身份。这叫开放重定向(Open Redirect)。
修复方式只有一个:白名单。认证中心维护一份已注册的业务系统回调地址清单,service参数必须与之完全匹配(或者按域名前缀匹配,但要小心子域劫持)。不接受任何动态传入的地址。
匹配的时候有几个细节:
- 用精确匹配,不要用
startsWith。https://app.example.com.evil.net是能以https://app.example.com开头的。 - 如果必须支持子域通配,要严格校验域名分隔符,别让
app.example.com.attacker.com混进来。 - 回调地址的协议必须是 HTTPS,不允许 HTTP。
- 回调地址不允许带
#片段,否则容易被塞进二次跳转。
我在项目里会把这个白名单做成配置项,接入新系统要人工审核后加进去,而不是让业务方自己传。多一道流程,少一堆事故。
5.2 防重放、防伪造、时钟漂移
除了票据一次性,还有几个攻击面要防。
防重放主要靠state和nonce。state在发起请求时生成、存进会话、回调时比对,防止攻击者构造一个回调请求让用户被动登录到攻击者的账号(登录 CSRF)。nonce在 OIDC 里作用类似,它会嵌入 id_token,验签时比对,防止 id_token 被重放。
防伪造靠签名。如果身份令牌是 JWT,必须验签,而且必须显式指定允许的算法。JWT 有一个经典漏洞叫alg=none:攻击者把 header 里的算法改成none,把签名部分删掉,有些实现会直接跳过验签。防御方法很简单,验签时白名单指定RS256,其他一律拒绝。
时钟漂移是线上最隐蔽的问题。JWT 的exp校验依赖服务器时间,如果认证中心和业务系统的服务器时间差了几分钟,就会出现"刚签发就过期"或者"过期了还能用"的诡异现象。我的经验是:JWT 校验时给 30 到 60 秒的时钟容差(leeway),同时所有机器都配 NTP 同步。曾经有个项目因为一台机器的时间慢了 5 分钟,导致每逢请求打到那台机器就报"token 已过期",排查了整整一个下午。
还有一个容易被忽略的:票据和令牌的日志。千万不要把完整的 ticket 或 token 打进访问日志。日志系统往往权限管控松,一旦泄露就是批量会话失守。要打就打前 6 位加后 4 位,中间脱敏。回调 URL 里的 ticket 参数,在建立会话后应该立刻从浏览器地址栏里用history.replaceState替换掉,避免用户复制分享。
5.3 Token 存储位置的取舍
这一节把上一章的问题再往安全侧推一步,做一个明确的对比:
| 存储位置 | XSS 风险 | CSRF 风险 | 刷新丢失 | 跨标签页共享 |
|---|---|---|---|---|
| localStorage | 高(JS 可读) | 低 | 不丢 | 共享 |
| sessionStorage | 高 | 低 | 关标签即丢 | 不共享 |
| 内存变量 | 低 | 低 | 丢失 | 不共享 |
| HttpOnly Cookie | 低(JS 不可读) | 中(需 SameSite + CSRF Token) | 不丢 | 共享 |
没有绝对安全的方案,只有风险权衡。我的默认组合是:access_token 放内存 + refresh_token 放 HttpOnly Cookie(SameSite=Lax,Path 限定到刷新接口)。这样 XSS 拿不到长期凭证,CSRF 也因为 Path 限定和 SameSite 被压到很低。
另外提醒一句:Cookie 的Path限定到一个具体接口(比如/api/auth/refresh)是个很实用的技巧。这样浏览器只在调用刷新接口时才带上这个 Cookie,其他接口的请求里根本没有它,被日志记录、被 Referer 带出去的概率都大幅下降。
6. 常见问题速查与排查实录
6.1 重定向死循环:从现象倒推原因
死循环的表现是浏览器地址栏疯狂跳转,最后报ERR_TOO_MANY_REDIRECTS。原因基本集中在三类:
拦截器把回调路径也拦了。用户带着 ticket 回到/sso/callback,拦截器发现没有局部会话,又把他跳去认证中心;认证中心发现全局会话在,又发票据回来;如此往复。修法就是在拦截器的白名单里加上回调路径。
全局会话没建立成功。认证中心判定用户未登录,展示登录页,用户登录成功,写 Cookie,跳回来;业务系统校验票据,但因为它自己没存住会话(比如 Session 写失败、Redis 连不上),下次请求又判定未登录。这种情况下日志里会看到"票据校验成功"和"判定未登录"反复交替,很好定位。
Cookie 域配置错误。认证中心把 Cookie 写在了sso.example.com而不是.example.com,业务系统这边的请求根本带不上这个 Cookie,认证中心永远认为你没登录。排查方法很直接:打开浏览器开发者工具,看认证中心那次请求的 Request Headers 里有没有Cookie。没有,就是域的问题。
6.2 登录成功却仍然跳回登录页:票据校验失败的四种典型
这个是第二高频的问题。用户明明在认证中心登录成功了,跳回业务系统却又被踢回登录页。日志里通常能看到票据校验返回了失败。原因我遇到过这几种:
service参数不一致。前面提过,申请时是https://a.example.com/callback,校验时变成了带末尾斜杠的版本,或者大小写不同,或者经过了一次 URL 编码导致%2F和/不等价。唯一的解法是比对前做归一化:统一转小写(域名部分)、去掉末尾斜杠、用同一套编码规则解码后再比。我一般会在认证中心侧提供一个"调试模式",把收到的service和注册的service原样打印出来,一眼就能看出差异。
票据已经过期。有效期设 60 秒,如果业务系统在校验前做了耗时操作(比如查数据库、调第三方接口),或者用户网络慢,票据可能在到达校验接口时已经过期。修法要么延长有效期(不建议超过 5 分钟),要么把耗时操作移到校验之后。
跨域请求被拦。业务系统后端调认证中心的校验接口,如果两者在不同网络区域,可能被防火墙或者安全组拦掉。表现是校验接口超时,业务系统只能判定失败。检查方法是在业务系统那台机器上直接 curl 一下校验接口。
Redis 键被误删或者序列化不兼容。票据存在 Redis 里,如果两个服务用的序列化方式不同(一个用 JSON,一个用 JDK 序列化),读出来就是乱码或者反序列化异常,表现为"票据存在但解析失败"。统一序列化方式是必须的。
6.3 问题速查表
把上面这些整理成一张表,出问题的时候按现象查,比从头捋流程快得多。
| 现象 | 最可能的原因 | 快速验证方式 |
|---|---|---|
| 无限重定向 | 回调路径被拦截 / 全局会话 Cookie 未写入 | 看请求头里有没有 Cookie,看拦截器白名单 |
| 登录后又跳登录页 | service 不匹配 / 票据过期 / 校验接口不通 | 打印 service 原文比对,curl 校验接口 |
| 部分用户掉线,部分正常 | 集群会话不共享 / 某节点时钟漂移 | 对比掉线用户命中的节点,检查 NTP |
| 登出了一个系统,其他还在 | 未做单点登出广播 / 广播超时 | 看认证中心登出日志里各业务系统的响应 |
| 跨域接口报 CORS 错误 | Allow-Origin 用了*/ 未开 Credentials | 看响应头是否是具体域名 |
| 刷新页面掉登录态 | access_token 只在内存 / refresh Cookie 被 SameSite 拦 | 看刷新请求是否带上了 Cookie |
| 提示 token 无效但刚签发 | 算法白名单不匹配 / 时钟漂移 | 检查alg配置和服务端时间 |
6.4 几个只有踩过才知道的实操经验
先做全链路日志,再调流程。认证中心、业务系统、网关三处的日志要用同一个traceId串起来。SSO 的问题横跨多个系统,没有统一的追踪 ID,你根本不知道一次失败的登录是在哪一跳断的。这笔投入在项目初期看起来多余,出第一次线上问题时就知道值了。
接入新系统时先跑通"未登录 → 登录 → 回跳"这一条最短路径,别一上来就搞单点登出和属性同步。最短路径跑通,说明域名、Cookie、回调地址、票据校验这几个基础环节都是对的,后面的问题就都是增量问题。
测试环境一定要模拟跨域和 HTTPS。很多 SSO 的坑只在 HTTPS 下出现(SecureCookie 在 HTTP 下不写),只在跨域下出现(CORS 配置)。本地用localhost一切正常,上了测试环境全军覆没,这个场景我经历太多次了。建议本地就用两个不同子域的 hosts 映射来开发,从一开始就暴露问题。
给票据和令牌加监控指标。票据校验成功率、平均校验耗时、失败原因分布,这三个指标挂在监控面板上。校验成功率突然从 99.9% 掉到 95%,说明某个环节出问题了,主动发现比等用户投诉强。我曾经靠这个指标提前发现了一次 Redis 内存打满导致票据写入失败的事故。
别把认证中心做成"什么都能干"。我见过一些设计,认证中心除了登录还兼做用户管理、权限分配、消息推送,最后变成一个谁都改不动、谁都不敢重启的巨石。认证中心的职责应该收敛到三件事:验证身份、发凭证、管会话。其他的都拆出去,它越简单,可用性越高。
最后说个容易被忽略的细节:回调地址里的 ticket 参数一定要在建立会话后立刻从前端路由里清掉。用history.replaceState一行代码的事,但不做的话,用户一旦把这个 URL 复制发到群里,虽然票据大概率已经失效,但日志里会留下一堆诡异的校验失败,排查时很干扰判断。这种小细节,做的时候觉得无所谓,出问题时才知道麻烦。