授权码流
这一课你将能做到
- 按顺序说出授权码流(Authorization Code Flow)的五个环节:跳转 authorize → 用户登录并同意 → callback 带一次性 code → 后端用 code 换 token → 拿 Bearer token 调资源 API
- 解释 state 参数如何防 CSRF,并写出带校验逻辑的 callback 处理代码
- 背出四种授权模式的差异与现状,说清为什么新系统一律选授权码(+PKCE)
为什么要学
你迟早会问:既然最后要的是 access_token,为什么不一步到位,让授权服务器直接把 token 发回来?
因为浏览器是公共信道。如果 token 出现在 URL 里,它会流进浏览器历史、服务器访问日志、Referer 头、甚至浏览器插件的眼睛里。授权码流的聪明之处在于:让浏览器只经手一枚 一次性、几分钟内作废、离开 secret 或 PKCE 就毫无用处 的 code;真正的 token 走 后端与授权服务器之间的服务端到服务端请求,全程不出现在前端。
几乎所有的「GitHub / Google / 微信登录」按钮、Spring OAuth2 Client 的默认流程,跑的都是这条路。把这一课的五步吃透,后面 PKCE、OIDC、Spring Client 都只是在这条主干上加零件。
面试关系:「四种授权模式的区别」「为什么授权码最安全」是后端高频题,本课给到能直接背诵的程度。
先建立图像
浏览器 你的后端(Client) 授权服务器(AS) 资源服务器(RS)
│ ① GET /oauth/start(后端拼 authorize URL)│ │
│ ──────────────────────────────────────► │ │
│ ◄──── 302 Location: AS 的授权页 ─────────│ │
│ │ │
│ ② 浏览器打开授权页,用户登录 + 点「同意」 │ │
│ │ │
│ ◄──── 302 回 redirect_uri?code=xxx&state=yyy ────────────────── │
│ │ │
│ ③ GET /oauth/callback?code=xxx&state=yyy│ │
│ ──────────────────────────────────────► │ │
│ ④ POST /token(code + client_secret)
│ │ ─────────────────►│ │
│ │ ◄── access_token (+ refresh) ─────────│
│ │ ⑤ GET /user Authorization: Bearer xxx│
│ │ ─────────────────────────────────────►│
│ ◄── 302 回前端,带上本站 JWT ──│ │
authorize 请求的五个核心参数:
| 参数 | 人话 |
|---|
response_type=code | 我要的是授权码,别直接塞 token 给我 |
client_id | 哪个应用在申请(公开可见,不是机密) |
redirect_uri | 同意后精确跳回哪里(必须预登记) |
scope | 申请哪些权限 |
跟着做
步骤 1:手拼一条 authorize URL
GET https://github.com/login/oauth/authorize
?client_id=你的ClientID
&redirect_uri=http://localhost:8080/login/oauth2/code/github
&scope=read:user
&state=9f8c2b1e7d4a(加密安全随机串)
在浏览器打开它,应看到 GitHub 授权页。注意:此时还没有任何 token,浏览器只带了「申请意向」。
步骤 2:发起跳转前,把 state 存进服务端会话
// 中文注释:发起 OAuth 前生成 state 存入 session;callback 必须原样比对
@GetMapping("/oauth/start")
public void startOAuth(HttpSession session, HttpServletResponse response) throws IOException {
String state = UUID.randomUUID().toString().replace("-", "");
session.setAttribute("oauth_state", state);
String authorizeUrl = "https://github.com/login/oauth/authorize"
+ "?client_id=" + clientId
+ "&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)
+ "&scope=read:user"
+ "&state=" + state;
response.sendRedirect(authorizeUrl);
}
步骤 3:callback 里先验 state,再考虑换 token
// 中文注释:回调第一步永远是验 state;不匹配直接丢弃 code,绝不继续
@GetMapping("/oauth/callback")
public String callback(@RequestParam String code,
@RequestParam(required = false) String state,
HttpSession session) {
String expected = (String) session.getAttribute("oauth_state");
session.removeAttribute("oauth_state"); // 一次性,防重放
if (expected == null || state == null || !MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
state.getBytes(StandardCharsets.UTF_8))) {
throw new IllegalStateException("state 校验失败,疑似 CSRF,丢弃 code");
}
// 校验通过,才进入下一步:拿 code 去换 token(服务端行为)
return "redirect:/oauth/exchange?code=" + code;
}
用 MessageDigest.isEqual 做常量时间比较,比 equals 更严谨(防时序侧信道)。
步骤 4:curl 模拟换 token
# 中文注释:换 token 是服务端到服务端的 POST;secret 只放环境变量
curl -i -X POST https://github.com/login/oauth/access_token \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"client_id": "你的ClientID",
"client_secret": "从环境变量读取,绝不进前端",
"code": "callback里收到的一次性code",
"redirect_uri": "http://localhost:8080/login/oauth2/code/github"
}'
{
"access_token": "gho_xxxxxxxx",
"token_type": "bearer",
"scope": "read:user"
}
要点:redirect_uri 必须与 authorize 时字节级一致;code 是一次性的,第二次使用同一 code 必然报错。
步骤 5:拿 token 调资源
# 中文注释:资源服务器只认 Bearer 头,不认你站内 cookie
curl -i https://api.github.com/user \
-H "Authorization: Bearer gho_刚换到的token"
返回用户 JSON。401 = token 缺失/无效;403 = token 有效但 scope 不够(第 5 课细讲)。
步骤 6:对照 Spring OAuth2 Client 的默认路径
# 中文注释:Spring 把五步全部封装;先看懂流程再交给框架(第 7 课完整配置)
spring:
security:
oauth2:
client:
registration:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: read:user
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
浏览器访问 http://localhost:8080/oauth2/authorization/github 即步骤 1;回调落在 /login/oauth2/code/github,state 校验与换 token 由框架代劳。
步骤 7:四种授权模式对照(面试背诵卡)
| 模式 | token 如何到达 Client | 现状 | 适用 |
|---|
| Authorization Code | 浏览器拿 code,后端换 token | ✅ 标准答案 | 有后端的 Web 应用 |
| Auth Code + PKCE | 浏览器拿 code,客户端带 verifier 换 | ✅ 推荐 | SPA、移动 App |
| Implicit | token 直接放 URL fragment | ❌ 已废弃 | 无(历史遗留) |
| Resource Owner Password | 用户把密码交给 Client,Client 拿密码换 token | ❌ 已不建议 | 无(仅遗留内网) |
| Client Credentials | 应用用自身凭据换 token | ✅ 有效 | 纯机器对机器,无用户参与 |
讲清楚要点
- 浏览器只配经手 code,不配经手 token。code 一次性、短寿命、离开 secret/PKCE 无效;token 走后端信道。这是授权码流安全性的全部核心。
- state 防 CSRF:攻击者可诱导已登录用户的浏览器带着「攻击者的 code」访问你的 callback(login CSRF)。state 与会话绑定且一次性,回调时严格比对后立即删除。
- redirect_uri 要传两次:authorize 一次、换 token 一次,且必须完全一致。第二次是绑定「这张 code 只许这个回调方来换」,防止 code 被拿去别的 redirect 场景。
- code 换 token 必须带凭据:机密客户端带
client_secret;公共客户端带 code_verifier(下一课 PKCE)。
- token 响应里的
scope 字段是「实际批准值」:可能少于申请值(第 5 课),客户端必须以响应为准。
- Implicit 被废弃的原因:token 直接暴露在 URL fragment,会被历史/插件/Referer 收集,且没有 refresh、没有后端确认环节。OAuth 2.1 草案已把「授权码 + PKCE」定为浏览器与移动端唯一推荐。
你可能会懵的地方
-
code 也出现在 URL 里,它和「token 在 URL 里」有什么本质区别?
危险等级不同。code 一次性且短寿命,没有 secret/verifier 换不到 token,被截获损失≈0;Implicit 是把长效 token 直接放在 fragment 里,截获即拿到资源访问权。
-
state 存哪里?前端 localStorage 行不行?
优先存服务端 session(或 Redis,key 绑定会话标识)。放 localStorage 意味着 XSS 可读取篡改,削弱防护。
-
为什么登录后浏览器一直循环跳转?
多半是 callback 路径又被 Security 拦去「未认证跳转」,形成「callback → 登录 → callback」循环。把 callback 与授权入口 permitAll,见卡点急救。
-
换 token 时能把 client_secret 放 query 参数吗?
不能。规范要求放 POST body 或 Basic 认证头;query 会被日志与代理记录。secret 只应出现在加密信道内。
-
一个 code 换了两次 token,第二次报错,是不是 bug?
不是。code 设计为一次性,重放必须失败。若线上频繁出现,检查是否网络层自动重试导致重复提交。
错法对照
| 错法 | 问题 | 正解 |
|---|
| callback 收到 code 就在前端直接换 token | secret 暴露或退化为无保护交换 | 服务端换 token;SPA 走 PKCE |
| state 用固定字符串或不校验 | CSRF 防护形同虚设 | 每次生成随机 state,校验后立即作废 |
| redirect_uri 允许通配或随意追加 query | 开放重定向可把 code 送给攻击者 | 预注册精确 URI,全等匹配 |
| 把 code 写进访问日志长期保存 | 日志泄露 = code 泄露 | code 即换即弃,日志打码 |
| 继续实现 Implicit「图省事」 | token 暴露面过大 | 授权码 + PKCE |
卡点急救
- 现象:授权后报
redirect_uri_mismatch。三处逐字比对:authorize URL 里的、IdP 后台登记的、换 token 请求里的。最常见是尾部斜杠多/少、http 与 https 混用、localhost 与 127.0.0.1 混用。
- 现象:换 token 返回
invalid_grant。按概率排查:code 已被用过(一次性);code 已过期(通常 1-10 分钟);redirect_uri 与 authorize 时不一致;服务器时钟偏差。处理:重新走完整授权流程,不要重试旧 code。
- 现象:登录后无限循环重定向。callback 路径被 Security 拦截,未登录 → 又跳授权 → 又回 callback。解法:在 SecurityFilterChain 里把
/login/oauth2/code/**、/oauth/callback 等 callback 与授权入口 permitAll()。
面试常问
-
四种授权模式的区别与选用?
见步骤 7 表格。回答时强调:有后端选授权码;SPA/移动选授权码+PKCE;纯服务间调用选 Client Credentials;Implicit 与密码模式已废弃,并答出「为什么废弃」(token 暴露在 URL / 用户密码交给第三方)即可拿满分。
-
为什么授权码流是最安全的?
三点:① token 不经浏览器,走服务端信道;② code 一次性短寿命,泄露损失小;③ state + PKCE + 严格 redirect_uri 分别挡住 CSRF、code 拦截、开放重定向三类攻击。纵深防御。
-
state 校验为什么必须在换 token 之前?
CSRF 的目的就是让你的服务器替攻击者完成「绑定」动作。若先换 token 再校验,攻击者的 code 已经变成你 session 里的登录态,校验晚了就失去意义。正确顺序:验 state → 换 token → 建立本地会话。
自测三问
- 从点击按钮到拿到用户资料,写出五个环节的 HTTP 请求类型与关键参数。
- state 防什么攻击?校验不匹配时代码应该保留还是丢弃 code?
- 为什么换 token 请求里还要再传一次 redirect_uri?
小结一句话
授权码流:浏览器拿一次性 code,后端换 token,state 防 CSRF,redirect_uri 严格一致——四道锁共同构成「最安全模式」。
延伸