最近在整理一套内部系统的权限改造,把“每个系统单独登录一次”的体验换成“登录一次,闭着眼睛跑所有系统”。这个需求听上去就是大家常说的 SSO,但真正落地的过程中,最让人头疼的不是“要不要做”,而是协议流程里一堆容易被忽略的细节。尤其当你使用的是 OAuth2 授权码模式时,前后端之间、浏览器和服务器之间,每一次跳转、每一个参数、每一段重定向,背后都有明确的约定和坑。这篇文章就把我从 SSO 登录到跨系统资源访问这条链路上总结出的完整经验拆开讲,适合正在做统一身份认证、对接第三方登录、或者打算从传统 session 改造到 token 体系的同学参考。
1. 从问题出发:为什么需要 SSO 与授权码模式
1.1 单点登录解决的是什么
先搞清楚一个本质问题:SSO(单一登录)要解决的并不是“让用户觉得登录很顺畅”,而是权限凭证的转移和复用。传统模式下,用户访问系统 A 时登录一次,拿到的是系统 A 的会话;访问系统 B 时又要登录一次,因为系统 B 无法验证系统 A 发的会话凭证。这带来两个后果:用户要记住 N 套账号密码,运维人员同样要为每个系统维护一份身份数据。
SSO 的做法是把“认证”这个动作抽出来,放到一个独立的认证中心。用户在认证中心完成身份校验后,认证中心签发一个凭证,其他业务系统只需要信任这个凭证即可。这样一套账号就能贯穿所有业务系统,运维也不用重复存储密码。
1.2 OAuth2 授权码模式在体系里的位置
很多人容易把 SSO 和 OAuth2 混在一起,其实它们解决的是不同层面的问题。SSO 关心的是“用户只需要认证一次”,OAuth2 关心的是“第三方应用如何安全地访问用户资源”。但在实际工程中,两者经常组合出现,授权码模式正是 OAuth2 里最常被用于构建 SSO 的授权流程。
授权码模式的核心逻辑是:客户端应用不直接拿到用户凭证,而是先通过浏览器把用户引导到授权服务器,用户完成认证后授权服务器把用户返还回来,并附带一个临时的“授权码”。客户端后端拿着授权码再次向授权服务器换取访问令牌。这里的关键点是,令牌从不经过浏览器,而是只在后端服务器之间传递,所以安全性远高于在浏览器里直接暴露令牌的方式。
1.3 为什么要选授权码而不是其它模式
OAuth2 协议族并不止授权码一种玩法。还有隐式模式、客户端凭证模式、密码模式等。我把它们的适用场景和风险整理成表,方便对比:
| 模式 | 令牌存放位置 | 适用场景 | 风险点 |
|---|---|---|---|
| 授权码模式 | 客户端后端 | Web 应用、SSO 集成 | 流程稍长,但安全性最高 |
| 隐式模式 | 浏览器前端 | 纯前端应用(已不推荐) | 令牌易被窃取,早已被业界淘汰 |
| 密码模式 | 客户端后端 | 客户端高度信任用户凭证 | 用户密码会流经第三方应用,风险很高 |
| 客户端凭证模式 | 客户端后端 | 服务端到服务端 | 没有用户概念,只适合机器身份 |
构建 SSO 时,客户端几乎都是 Web 应用,有完整的后端,所以授权码模式是技术上的最优解。它把敏感凭证控制在服务端,用户看到的只有浏览器里的跳转和最终的页面效果。我最初也想过直接用简单的方式做,但踩过一次令牌泄露的坑后,发现协议设计得“绕”一点是有道理的。
注意:在当下主流实践中,授权码模式通常还会搭配 PKCE(Proof Key for Code Exchange),即使客户端是公共客户端也能大幅提升安全性,这会在后文专门说。
2. 全链路交互拆解:一次登录背后发生了什么
2.1 起点:首次访问业务系统
想象一个真实场景:用户小明打开浏览器,输入业务系统 A 的地址。系统 A 判断当前会话未登录,于是生成一个跳转请求,把浏览器引导到认证中心的登录页。这一步的跳转 URL 长这样:
https://auth.example.com/authorize? response_type=code &client_id=web_client &redirect_uri=https://app-a.example.com/callback &state=abc123 &scope=user:read这里出现几个关键参数。client_id 是系统 A 在认证中心注册时的唯一标识;redirect_uri 是用户认证后要返回的地方;state 是客户端先生成的随机串,用于防止跨站请求伪造;scope 则是请求的权限范围。
我在实际项目中就遇到过团队不理解 state 意义、直接写死一个固定值的情况。这样做的后果是攻击者可以伪造登录响应,诱导客户端接受一个恶意构造的授权码。所以 state 必须一次性且不可预测。
2.2 授权码让渡:通过中间人拿到 code
用户被引导到认证中心后,在登录页输完账号密码。认证中心验证身份通过后,并不会直接给系统 A 发令牌,而是先做一个让人容易忽略的动作:让浏览器重定向回系统 A 的回调地址,并在地址后面带上一个参数 code,这就是授权码。此时浏览器地址栏会变成这样:
https://app-a.example.com/callback?code=tmp_code_001&state=abc123授权码的生命周期很短,默认通常是 1 到 5 分钟,而且是一次性的。它存在的意义就是“过一道手”,认证中心用它来确认“确实是这个用户在授权”,客户端后端再用它去换真正的令牌。这个设计把令牌和浏览器隔离开来,即使授权码在网络传输中被抓到,攻击者也无法在短时间内利用它拿到有效的令牌,因为它还需要客户端密钥或者 PKCE 验证。
在这个阶段,我经常被问到一个问题:为什么不能在重定向的时候直接把 token 放在 URL 里?答案很简单,URL 会被浏览器历史记录、服务器访问日志、第三方统计脚本等路径记录下来,token 一旦泄露几乎等于账号密码泄露。授权码只出现一次,即使泄露,影响面可控得多。
2.3 后端换 token:真正关键的一步
客户端后端接收到回调请求后,先校验 state 和回调参数,然后拿着授权码、client_id、client_secret、redirect_uri 等参数,向认证中心的令牌端点发起请求。这个请求不能再通过浏览器完成,必须由后端服务器发出。
POST https://auth.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=tmp_code_001 &redirect_uri=https://app-a.example.com/callback &client_id=web_client &client_secret=super_secret_key认证中心收到请求后,核对授权码是否有效、是否过期、是否与 client_id 绑定,同时验证 redirect_uri 是否与当初发起授权时一致。全部通过后,返回一个 JSON 响应,里面包含 access_token、refresh_token、expires_in 等字段。
这一步是整个链路中安全等级最高的环节,因为 client_secret 在这个请求中会被发送。如果这条通道不是 HTTPS,secret 就等于裸奔;如果 client_secret 被编译进前端代码,那它就不叫 secret。我见过有人为了图省事,在前端直接调用 token 接口,这是比隐式模式更危险的操作,相当于把钥匙放在门上让所有人自取。
2.4 带 token 访问资源系统
拿到 access_token 后,业务系统 A 后续请求就能带着它去访问受保护的资源。通常做法是在请求头加一个字段:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...资源服务器收到请求后,会验证 token 的签名、有效期、权限范围,验证通过后才返回数据。还有一个容易被忽略的点:跨系统资源访问时,业务系统 B 也需要验证这个 token。如果 B 不认识 A 从认证中心拿到的 token,就会出现“认证通过但授权失败”的尴尬局面。
所以在实际落地时,业务系统 B 要么接入统一的 token 校验逻辑,要么配置与认证中心相同的公钥来验签 JWT。我通常建议采用“资源服务器独立校验”的方式,系统 B 不依赖系统 A 的会话状态,只信任认证中心签发的 token,这样从架构上就避免了一个系统挂了影响另一个系统验证身份的情况。
2.5 全链路时序总览(文字展开)
把上面的过程按顺序整理一遍,读者可以对照自己的实现排查:
- 浏览器请求业务系统 A,A 检测未登录。
- A 构造 authorize URL,返回 302 让浏览器跳转到认证中心。
- 认证中心展示登录页,用户输入凭证。
- 认证中心校验成功,生成一次性授权码,302 跳转回 A 的 callback。
- 浏览器访问 callback 并携带 code 和 state。
- A 后端校验 state,然后向认证中心 token 端点发送 code 兑换 access_token。
- 认证中心返回 access_token、refresh_token、过期时间。
- A 拿到 token 后,后续请求携带 token 访问资源服务器或系统 B。
- 系统 B 校验 token 合法后返回资源数据。
这个链路中浏览器一共经历了 4 次跳转,后端之间有 2 次直接调用。理解这一点非常重要,因为很多问题就出在“浏览器跳转”和“后端直连”之间的语义混淆上。比如有人把 token 端点调用放在了前端,就会发现接口跨域调到崩溃;有人把 state 校验放在前端,却发现第三方可以通过修改 localStorage 绕过。
3. 核心组件与参数设计:把关键配置说透
3.1 授权服务器、资源服务器、客户端的边界
OAuth2 协议里有四个角色:资源所有者(用户)、客户端、授权服务器、资源服务器。实际项目中,客户端就是你的业务系统后端,授权服务器是统一的认证中心,资源服务器是提供业务数据的系统。搞清楚四者的边界,才能避免把逻辑写错地方。
我在初期设计时踩过一个典型的坑:把“校验 token”的逻辑同时写进了认证中心和业务系统,结果两边用的签名密钥不同,业务系统一直报 token 无效。后来才意识到,授权服务器负责发 token,资源服务器负责验 token,这两个动作可以复用同一套 JWT 密钥,但逻辑必须解耦。认证中心不该关心某个 token 能不能访问某份具体数据,资源服务器才关心。
3.2 redirect_uri 和 state:两个容易被忽略的细节
redirect_uri 在协议里承担的角色远超一个“回调地址”。认证中心必须校验它是否与客户端注册时登记的域名一致,否则恶意应用可以诱导用户把跳转发往攻击者控制的地址。这个校验放得严,整个流程才安全。
state 参数则是 CSRF 防线。攻击者可以伪造一个 authorize 请求链接,诱导用户点击,然后把用户带到一个带 code 的 callback,如果客户端不校验 state,攻击者就能用这个 code 换取用户信息授权。我在实际项目中用过一个简单但有效的方案:state 由客户端生成,存到短期 cookie 或内存缓存里,回调时对比一致才继续,否则直接拒绝。
3.3 scope 与 token 生命周期设计
scope 定义了授权范围,比如 user:read、user:write、order:view。最小化授权原则在这里很适用:客户端只需要读取用户资料,就没必要申请 user:write。scope 过大,一旦 token 泄露,攻击者能做的操作范围也大;scope 过小,可能在业务运行到某个功能时发现没权限,不得不重新授权,影响体验。
token 有效期也需要结合业务场景来定。我会把 access_token 的有效期控制在 1 小时以内,refresh_token 有效期控制在 7 天到 30 天。这个组合既保证在合法权益期内用户无需反复登录,又能让泄露的 token 在较短时间内自动失效。
3.4 refresh token 的使用策略
access_token 过期之后,客户端不能要求用户重新登录,而是应该用 refresh_token 换取新的 access_token。刷新的请求同样必须由后端发出:
POST https://auth.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=xxx &client_id=web_client &client_secret=super_secret_key这里有个经验之谈:refresh_token 一旦使用,就应该立即报废,换一个新的 refresh_token 返回给客户端。这叫刷新令牌轮换。如果不这样做,攻击者拿到一个 refresh_token 就能长期维持访问权限。启用轮换后还需要额外处理并发刷新的情况,比如两个请求同时用同一个 refresh_token 刷新时,旧 token 会被标记为已撤销,这时客户端需要重新走登录授权流程获取新的 token。
注意:refresh_token 不能下发给纯前端应用,因为浏览器无法安全存储长期有效的凭证。这是我反复强调的原则之一。
4. 手把手搭建一套最小可运行的授权码模式
4.1 准备三个服务和数据表
为了讲清楚完整流程,我建议你在本地跑三个假服务:认证中心、客户端应用、资源应用。三者的角色和端口如下:
| 服务名 | 端口 | 责任 |
|---|---|---|
| 认证中心 | 9000 | 提供登录页、authorize 端点、token 端点 |
| 客户端应用 | 8000 | 发起授权请求、兑换 token、回调处理 |
| 资源应用 | 8080 | 校验 token,返回用户订单等数据 |
认证中心里需要维护两张表:客户端注册表和授权码表。客户端注册表存 client_id、client_secret、redirect_uri 白名单;授权码表存 code、过期时间、是否已使用。这里我用简单的伪代码展示核心逻辑,实际项目换成真正的框架和数据库即可。
# 简化版授权服务器核心逻辑 def authorize(user, client_id, redirect_uri): client = get_client(client_id) if redirect_uri not in client["allowed_uris"]: return error("redirect_uri mismatch") code = generate_code() save_code(code, user_id=user.id, client_id=client_id, expires_in=300) redirect(f"{redirect_uri}?code={code}&state={state}")4.2 从浏览器到授权端:登录页与 redirect 流程实现
客户端应用可以先实现一个入口路由,比如用户访问首页时检查 session,没有 session 就用 302 跳转到认证中心的 authorize 链接。把 state 存到 cookie 或缓存里,然后跳转。这时的浏览器表现就是地址栏从 app-a.example.com 变成了 auth.example.com/login。
认证中心的登录页表单提交后,处理器校验用户名密码,成功后执行上面伪代码中的 authorize 逻辑,把浏览器重定向回客户端的 callback 地址。注意这个重定向一定用 302,不要用 301,否则浏览器或 CDN 可能会缓存跳转,导致后续登录流程不可控。
4.3 code 换 token 的后端接口实现
客户端应用的 callback 路由接收 code 和 state,先校验 state,然后发起 token 请求。这里我使用 requests 库直接模拟:
import requests code = request.args.get("code") state = request.args.get("state") expected_state = session.get("oauth_state") if state != expected_state: abort(403, "state mismatch") resp = requests.post( "http://auth.example.com:9000/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": "http://app-a.example.com:8000/callback", "client_id": "web_client", "client_secret": "super_secret_key", }, timeout=5, ) token_data = resp.json() session["access_token"] = token_data["access_token"] session["refresh_token"] = token_data["refresh_token"]这里有三个细节值得注意。第一,redirect_uri 必须和当初发起 authorize 时完全一致,包括协议、域名、端口、路径;第二,code 一旦兑换成功,认证中心应立即标记为已使用,避免同一个 code 被反复调用;第三,token 接口超时时间不能设太长,一般 3 到 5 秒足够,避免用户无谓等待。
4.4 在业务系统里校验 token 并访问跨系统资源
拿到 token 后,客户端想在资源应用里获取用户资料,直接带 Authorization 头调用资源接口即可。资源应用不必关心 token 是怎么来的,只需要验证签名和有效期。
# 资源应用侧 token 校验 import jwt def verify_token(token): public_key = load_public_key("auth_public.pem") payload = jwt.decode(token, public_key, algorithms=["RS256"]) return payload def get_orders(): token = request.headers.get("Authorization", "").replace("Bearer ", "") payload = verify_token(token) user_id = payload["sub"] return query_orders(user_id)在这个设计下,客户端应用可以继续访问资源应用,同时也能实现 SSO 效果:用户第一次访问系统 A 时完成整个授权码流程,后续访问系统 B 时,系统 B 用自己的客户端配置走同样的授权流程。如果每次跳转都让用户重新输密码,那体验就很差了,所以认证中心往往会种下一个统一的登录态 Cookie,用户在授权中心页面不再需要重新输入账号密码,授权流程自动化完成,这就是 SSO 的体验核心。
我将这个最小系统跑通过之后,再看实际工程的复杂度就清晰了很多。认证中心多配一套客户端、多配置几个回调域名,就能让更多系统接入。整个扩展的逻辑是加配置,而不是改协议。
5. 踩坑实录:常见问题与排查技巧
5.1 redirect_uri 不匹配和 HTTP 明文限制
我最经常遇到的报错就是 redirect_uri mismatch。这类问题表面看是“配置错了”,实际上大部分情况是客户端在某些环节把地址改写成了默认值。比如 nginx 没有透传 Host 头、应用启动在 8080 端口但外层加了 443 反向代理,导致应用在构造回调地址时用了内网端口。排查时先在认证中心侧查注册配置,再在客户端侧打印实际跳转的完整 URL,两边逐字符对比才有效。
还要提醒一点:现在的浏览器对混合内容限制很严格,如果认证中心是 HTTPS,而客户端回调地址是 HTTP,大多数浏览器会直接拦截跳转,甚至会在开发者工具里看到奇怪的 ERR_ 报错。本地调试时可以用 localhost 规避,但到了联调环境,请确保整条链路都是 HTTPS。
5.2 state 参数校验失败
state 校验失败通常有两个方向。一种是客户端没有保存 state,每次刷新页面都会生成新值,导致回调校验不通过;另一种是浏览器禁用了 Cookie,state 存不进 session,校验时拿不到期望值。排查建议是:把 state 存入带 HttpOnly 属性的短期 Cookie,在 callback 里同时从 Cookie 和 query 中取值对比,并保留足够的时间窗口。不要把 state 存 localStorage,那样容易遭受脚本注入窃取。
5.3 token 过期、刷新并发与 cookie 大小问题
token 过期后,资源接口会返回 401。客户端应当捕获 401,尝试用 refresh_token 换取新 token,重放一次原始请求。更稳妥的方式是在请求发出前先检查 token 的 exp 字段,提前刷新,避免请求积压导致并发刷新的问题。
并发刷新是我在真实环境中踩过的坑。几个请求同时发现 access_token 过期,各自拿着同一个 refresh_token 去刷新,如果认证中心没有对 token 使用做互斥处理,就会产生多个新 token,旧 token 被作废,导致一部分请求拿到的新 token 也无法使用。解决方案有两个:一是给刷新操作加分布式锁;二是采用 refresh_token 轮换策略,每次刷新后旧 token 立即失效,客户端再遇到 401 就必须重新走授权流程,而不是无脑重试刷新。
还有一个比较隐蔽的问题:如果业务系统较多,把所有 token 都存在 Cookie 里,很容易撑爆 Cookie 的 4KB 限制。我建议 token 只存在服务端 session 或内存缓存,Cookie 里只放一个 session id,而 token 跟着 session 走。
5.4 跨域与日志排查思路
当客户端前端应用和客户端后端不在同一域名时,很多人会被跨域问题折磨。要理解一个概念:授权码模式中,只有“浏览器跳转到认证中心”和“认证中心跳回来”这两个动作是跨域,其实它们都是整页跳转,不涉及 AJAX,所以不受 CORS 限制。真正受 CORS 限制的是前端直接调用 token 接口和资源接口,而规范设计要求这些调用全部发生在后端,所以正常实现根本不该遇到跨域问题。如果你在浏览器里看到跨域报错,第一反应应该是检查代码是否把令牌逻辑写到了前端。
排查这类协议问题,可以开启 HTTP 抓包工具从浏览器入口看跳转链,记录每一次 302 的 Location 头,再在后端日志里抓 token 接口的请求和响应。日志里要记得打上 request_id 和 user_id,方便同时关联前端行为和后端处理。
为了让你快速上手,我把常见的异常和排查动作整理成速查表:
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 跳转到回调地址后报 redirect_uri 错误 | 注册的白名单与实际不一致 | 逐字符比对 URL,注意协议和端口 |
| code 一直失效或重复使用报错 | 授权码被标记为已使用或超时 | 检查授权码生命周期,避免重复回调 |
| 资源接口返回 401 | access_token 过期或签名验签失败 | 检查 token 过期时间,核对公钥配置 |
| 刷新 token 后并发报错 | refresh_token 被并发使用 | 加锁或启用 token 轮换 |
| 浏览器控制台出现 CORS 报错 | 前端直接调用了令牌相关接口 | 改造为后端直连授权服务器 |
| 回调地址收到重复 code | 浏览器重放请求或用户刷新页面 | 幂等处理 code,二次使用直接失败 |
6. 进阶扩展建议:从授权码到完整 SSO 体系建设
6.1 PKCE 与移动端适配
前文提过 PKCE,这里展开说。授权码模式在经典 Web 应用中依赖 client_secret,但移动端 App 或单页应用没有能安全存放 secret 的位置。PKCE 的出现就是为了解决这个场景,客户端先生成一个 code_verifier,再算出 code_challenge 传给授权端点,换取 code 时带上 code_verifier,授权服务器校验两者匹配才发 token。简单理解就是“除了钥匙,还多了一道密码”。即使授权码被截获,没有 code_verifier 也换不到 token。
如果你在新项目中做移动端登录、小程序登录、桌面客户端登录,我都建议直接启用 PKCE,即使是常规 Web 应用也完全可以启用,它不要求客户端保存 secret,安全性却大幅提升。
6.2 令牌轮换与撤销
建设长期运行的 SSO 体系,令牌管理是躲不开的主题。除了 refresh_token 轮换,还需要考虑用户主动登出、密码修改、账号禁用这三个场景。用户登出时,应当通知认证中心撤销当前 token;密码修改后,所有旧的 refresh_token 和 access_token 都应当失效;账号被禁用后,资源服务器应实时校验 token 状态,避免已撤销 token 在有效期内继续可用。
具体实现不一定要让资源服务器每次请求都远程验证 token,那样性能会有压力。常见方案是 access_token 用 JWT 自校验,refresh_token 用服务端状态跟踪,在 token 中嵌入一个 jti 字段,资源服务器可以定期同步一个撤销列表,或者在关键路径上调用认证中心检查状态。
6.3 从授权码到完整 SSO 体系建设
授权码模式解决了“认证”和“授权”的信任问题,但要形成完整的 SSO 体验,还得配合会话管理。认证中心在用户完成登录后,自己也要生成一个登录态 Cookie,这样用户访问第二个系统时,认证中心发现已有登录态,就不再提示输入密码,而是直接询问是否同意授权,甚至可以通过预设策略跳过同意页。
这里对用户体验影响最大的参数是“是否需要用户确认授权”。在内部系统里,用户往往不关心授权粒度,反复弹确认页很烦,可以配置为“已授权客户端自动同意”;对于外部开放平台则必须保留确认页,这个是安全合规底线。
整套体系还可以继续延伸:对接第三方身份源、引入多因素认证、增加审计日志、实现权限动态变更后的 token 实时失效。每一点的复杂度都不低,但核心骨架仍然是授权码模式那套全链路交互。
最后说一点我个人的感受。最初我对协议的理解停留在“能用就行”,直到真的接手全链路联调,才意识到每一个参数、每一次跳转背后都有一套严密的安全假设。state 防的是伪造回调,secret 防的是随意换取,授权码隔离的是浏览器与令牌,redirect_uri 保证的是令牌不至于外泄到陌生域名。协议里的“绕”从来不是因为设计者喜欢复杂,而是为了给攻防留出空间。如果你正在做类似的项目,建议先把这篇文章里提到的时序在本地完整跑一遍,再用抓包工具观察认证中心和客户端之间的每一次交流,很多疑惑会在实践中自然解开。