最近把一个内部管理后台的登录体系推倒重来:原先是一个跑了好几年的自研 JWT 鉴权组件,前端拿着 IAM 回调的 code 交给后端换用户、再由后端签发自定义 token;改造后统一接入 Keycloak,后端用标准的 Spring Security oauth2Login 做 OIDC 机密客户端,会话放进 Redis,新旧两套前端都只是同源代理到后端。
改造过程中被问得最多的是三个问题:
- 后端已经有客户端密钥了,为什么还要开 PKCE?
state到底是什么,它和 PKCE 有什么区别?- 登录要来回跳好几次,后端多节点部署时这些临时数据存哪?
这篇把这些问题连同背后的攻击场景一次讲清楚。
一、目标架构
浏览器 ──同源──> 前端(老 Vue / 新 Next.js)
│ /api/** 反向代理(保留 Host)
▼
后端(Spring Security,OIDC 机密客户端)
│ │
│ └── Redis(Spring Session,存会话)
▼
Keycloak(OIDC Provider)
几个关键决定:
- 只有后端和 Keycloak 打交道。 授权码换 token、校验 ID Token、映射本地用户都在服务端完成,token 不落浏览器。
- 浏览器只持有一个 HttpOnly 会话 Cookie。 两套前端共用同一套后端会话。
- 会话放 Redis。 多节点无需粘性会话,发布重启不掉线。
- 全部用标准件。 Spring Security + Spring Session,不再维护自研鉴权。
二、先把授权码流程过一遍
OIDC 授权码流程(Authorization Code Flow)中,后端会被浏览器访问好几次,中间还要离开我们的站点去 Keycloak 登录:
① 浏览器 GET /auth/login?redirect=/home → 后端记下“登录完回哪”
② 浏览器 GET /oauth2/authorization/keycloak → 后端生成 state / nonce / code_verifier,存会话
302 到 Keycloak(带 state、nonce、code_challenge)
③ 浏览器 在 Keycloak 输入账号密码 → 不经过我们的后端
④ 浏览器 GET /login/oauth2/code/keycloak?code=…&state=…
→ 后端比对 state,用 code + 客户端密钥 + code_verifier 换 token
校验 ID Token(签名、iss、aud、exp、nonce),建立登录会话
⑤ 浏览器 302 回 /home
第 ② 步发往 Keycloak 的授权请求大致长这样(已脱敏):
https://sso.example.com/realms/demo/protocol/openid-connect/auth
?response_type=code
&client_id=admin-console
&scope=openid profile email
&redirect_uri=https://admin.example.com/api/login/oauth2/code/keycloak
&state=yieOWgFiVcjEycwK5wEobMmOgRoNokRZCX7173mEH1o=
&nonce=W5ErjClJJkG6M9zRGDK7uiDt1AUHCBfoKA04_mCA7KM
&code_challenge=Nb8PHiSVHjwJhEvJo34vxHMkRGvIBWwnYZ4KYFYwxRY
&code_challenge_method=S256
里面有三个与本次登录相关的值:state、nonce、code_challenge(PKCE)。其中 state、nonce、code_challenge 都会随请求经过浏览器,不是秘密;真正不能泄露的是只保存在客户端会话里的 code_verifier。它们看起来都在“防伪”,但校验位置和安全作用并不相同。
三、三个随机值,各管一段
| 参数 | 存在哪 | 在哪一步校验 | 回答的问题 | 主要防御 |
|---|---|---|---|---|
state | 发起登录的会话 | 回调到达时(④) | 这个回调请求是我发起的吗? | 登录 CSRF |
code_verifier(PKCE) | 发起登录的会话 | 用 code 换 token 时(④,Keycloak 校验) | 这个授权码是我申请的吗? | 授权码注入、授权码被窃后兑换 |
nonce | 与发起登录的会话绑定 | 校验 ID Token 时(④) | 这个 ID Token 属于本次授权请求吗? | 绑定 token 响应、检测授权码注入 |
这里反复出现的“会话”,指的是发起这次登录的那个浏览器在后端的服务端会话(由会话 Cookie 标识)。服务端在会话里保存用于校验的预期值和秘密材料,再把需要公开传输的参数放进授权请求;因此安全性来自“与会话绑定并在正确的位置校验”,而不是把这些参数本身藏起来。
四、state:防登录 CSRF
4.1 攻击是怎样的
登录 CSRF 的目标不是登录成你,而是让你登录成攻击者:
- 攻击者 A 用自己的账号在 Keycloak 登录,在回调前拦下浏览器,拿到一个属于 A 的回调链接
.../login/oauth2/code/keycloak?code=CODE_A。 - A 把这个链接诱导受害者 V 打开(钓鱼链接、藏在
<img src>里都行)。 - 没有
state时,后端把CODE_A换成 token,V 的浏览器就悄悄登录成了 A 的账号。 - V 之后在系统里录入的数据、上传的文件、绑定的配置,全部落在 A 的账号下,A 随时可以查看。
4.2 state 怎么挡住它
- 第 ② 步,后端为 V 的会话生成随机
state_V,存在 V 的会话里。 - A 的回调链接里带的是
state_A(存在 A 的会话里),或者干脆没有。 - 第 ④ 步,后端拿回调里的
state和 V 会话里的state_V比对,对不上直接拒绝。
state 用完即删,一个值只能用一次。Spring Security 的 oauth2Login 默认就生成并校验 state,不需要手写。
五、PKCE:机密客户端为什么也要开
5.1 常见误解
PKCE 是给 SPA、移动端这种“存不住密钥”的公开客户端用的;我们后端有客户端密钥,不需要。
这句话在 2015 年 RFC 7636 刚发布时大体成立,但现代安全最佳实践已经把 PKCE 扩展到所有类型的 OAuth 客户端。RFC 9700 要求公开客户端使用 PKCE,并明确建议机密客户端也使用;OAuth 2.1 草案同样延续了这一方向。 原因是客户端密钥和 PKCE 防的不是同一种攻击。
5.2 客户端密钥能防什么、不能防什么
- 能防:攻击者偷到授权码后,自己拿着授权码去 Keycloak 的 token 端点换 token——他没有客户端密钥,换不了。
- 不能防:攻击者把偷来的授权码塞回我们的后端,让我们自己的后端拿着密钥替他换。
第二种就是授权码注入(Authorization Code Injection)。
5.3 授权码注入的完整过程
-
受害者 V 正常登录。Keycloak 签发授权码
CODE_V,它出现在回调 URL 里。 -
CODE_V通过某种渠道泄露给攻击者 A:- 浏览器历史、同步到云端的历史记录;
- 反向代理、网关、APM 的访问日志;
- 回调页加载第三方资源时带出的
Referer; - 恶意浏览器插件、被感染的终端。
-
A 在自己的浏览器里正常点“登录”。后端为 A 的会话生成合法的
state_A、verifier_A。 -
A 在 Keycloak 随便登录或直接拦截,把自己回调请求里的
code换成CODE_V,state保留自己合法的state_A:GET /login/oauth2/code/keycloak?code=CODE_V&state=state_A -
后端校验
state——通过,因为state_A确实存在 A 的会话里。 -
后端带着客户端密钥去换 token——密钥也没问题,因为发请求的就是后端本身。
-
没有 PKCE 时,换出来的是 V 的身份,A 的会话登录成了 V。
这一路下来,state 和客户端密钥都没起作用:state 证明的是“这个回调属于 A 的会话”,这是真的;密钥证明的是“换 token 的是合法客户端”,这也是真的。缺的是一条约束:这个授权码必须是 A 的会话自己申请的。
5.4 PKCE 怎么补上这条约束
PKCE(Proof Key for Code Exchange)把授权码和发起登录的会话绑在一起:
发起登录: verifier = 随机 43~128 字符,存会话
challenge = BASE64URL(SHA256(verifier)) → 随授权请求发给 Keycloak
Keycloak: 签发授权码时,把 challenge 和这个 code 绑定保存
换 token: 后端提交 code + verifier
Keycloak: 校验 BASE64URL(SHA256(verifier)) == 当初绑定的 challenge
回到第 5.3 节:CODE_V 绑定的是 hash(verifier_V),而 A 的会话里只有 verifier_A。第 6 步换 token 时 Keycloak 一比对就拒绝,A 换不出任何东西。
5.5 S256 而不是 plain
PKCE 有两种方法:
plain:challenge = verifier。授权请求里的 challenge 一旦被看到(它就在 URL 里),verifier 也就泄露了,形同虚设。S256:challenge = SHA256(verifier)。看到 challenge 也反推不出 verifier。
只用 S256。
5.6 在 Keycloak 里强制 S256
Spring Security 开启 PKCE 后,每次授权请求都会带 code_challenge,Keycloak 看到就会校验。但如果 Keycloak 客户端没有强制要求,有人绕开我们的后端、用同一个 client_id 发起不带 PKCE 的请求,Keycloak 也会照常放行,这就是降级。
在 Keycloak 客户端 → Advanced → Proof Key for Code Exchange Code Challenge Method 选 S256,不带 PKCE 或使用 plain 的请求会被直接拒绝。
六、nonce:把 ID Token 绑定到本次授权请求
OIDC 在 OAuth 之上多了一个 ID Token(一个签名的 JWT,声明“这个人是谁”)。nonce 的流程和 state 类似:
- 发起登录时生成随机
nonce,存会话,随授权请求发给 Keycloak。 - Keycloak 把它原样写进 ID Token 的
nonce声明里。 - 后端校验 ID Token 时,要求
nonce与会话里的一致。
nonce 的核心作用不是泛泛地“防 JWT 重放”,而是把 ID Token 绑定到发起它的那次授权请求。在本文使用的纯授权码流程里,浏览器回调只携带 code,ID Token 是后端随后从 token endpoint 换回来的,攻击者通常没有一个可以直接塞入旧 ID Token 的入口。
它更实际的价值是检测授权码注入:如果攻击者把另一笔授权产生的 code 注入当前会话,换回来的 ID Token 会带着另一笔请求的 nonce,与当前会话保存的预期值不一致,客户端就会在使用任何 token 之前拒绝这次响应。RFC 9700 也把正确校验 nonce 列为 OIDC 机密客户端防御授权码注入的一种方案;本文仍同时启用 PKCE,让授权服务器在 token endpoint 就拒绝错误的 code 与 verifier 组合。
Spring Security 的 OidcAuthorizationCodeAuthenticationProvider 默认生成并校验 nonce。
七、一张表看清分工
| 防线 | 伪造的回调请求 | 伪造 / 注入的授权码 | 被替换的 token 响应 |
|---|---|---|---|
state | 挡住 | - | - |
| PKCE(S256) | - | 在 token 端点直接拒绝 | - |
nonce | - | 可额外检测注入 | 绑定到本次授权请求 |
| 客户端密钥 | - | 只防“外人自己换” | - |
一句话:
state保证这个回调请求是当前会话发起的;- PKCE 保证回调里的授权码是当前会话申请的;
nonce把换回来的 ID Token 与当前授权请求绑定,并可检测授权码注入;- 客户端密钥保证换 token 的是合法客户端。
这四道防线并非彼此孤立:PKCE 与 nonce 在“授权码注入”这一格上有所重叠,PKCE 由授权服务器在 token 端点拒绝错误组合,nonce 则在客户端使用 token 之前再确认一次 ID Token 的归属,两者叠加比只开一个更稳。
八、多节点部署:这些临时数据存在哪
8.1 问题
Spring Security 在第 ② 步把一个 OAuth2AuthorizationRequest(含 state、nonce、code_verifier、redirect_uri)存进 HttpSession,第 ④ 步再取出来比对,用完即删。
如果后端有多个节点,第 ② 步落在节点 A、第 ④ 步落在节点 B,而会话只存在 A 的内存里,B 就找不到 state,登录必然失败。传统解法是负载均衡做粘性会话(Sticky Session),但它让扩缩容、发布重启都变得脆弱。
8.2 用 Spring Session 把会话放进 Redis
引入 spring-session-data-redis 后,HttpSession 的读写透明地转到 Redis,浏览器只持有会话 ID:
spring:
session:
redis:
namespace: admin-console:session
server:
servlet:
session:
timeout: 8h
cookie:
name: ADMIN_CONSOLE_SESSION
http-only: true
same-site: lax
本地调试时在 Redis 里能直接看到登录中途的数据:
admin-console:session:sessions:575f523f-…
sessionAttr:SPRING_SECURITY_SAVED_REQUEST ← 第 ① 步:登录完回哪
sessionAttr:…HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
← 第 ② 步:state / nonce / verifier
部署注意:
- 同一环境的所有节点连同一个 Redis;
- 负载均衡不需要粘性会话;
- 会话按无操作超时自动过期,Redis 自动清理;
- 发布、重启节点不影响已登录用户。
也可以把授权请求加密后存进 Cookie 来避免服务端存储,但那属于自定义实现,要自己处理加密、大小、过期。既然登录态本身就要放 Redis,授权请求顺带放进去是最省心的标准做法。
九、登录之后:会话本身的安全
PKCE、state、nonce 只保护“登录那一刻”。登录成功后,浏览器靠一个会话 Cookie 访问后端,这段同样有攻击面。
9.1 会话 Cookie
| 属性 | 取值 | 作用 |
|---|---|---|
HttpOnly | 是 | 前端脚本读不到,XSS 偷不走会话 |
SameSite | Lax | 第三方站点发起的 POST 不带 Cookie,削弱 CSRF;顶层 GET 跳转仍带,保证回调能找到会话 |
Secure | 生产 HTTPS 下开启 | 只走 HTTPS |
Path | / | 前端页面路径和后端接口路径都能带上 |
| 名称 | 带业务前缀 | 同一主域下多个系统共存时,避免默认的 SESSION/JSESSIONID 互相覆盖 |
SameSite 必须是 Lax 而不是 Strict:第 ④ 步回调是从 Keycloak 的域名跳回来的跨站导航,Strict 会让回调请求不带会话 Cookie,后端找不到 state,登录失败。
9.2 会话固定攻击
攻击者先拿到一个未登录的会话 ID,设法让受害者用这个 ID 完成登录,然后攻击者就共享了已登录会话。Spring Security 在认证成功时默认更换会话 ID(changeSessionId),旧 ID 作废。这一条默认开启,不要关。
9.3 CSRF:双提交 Cookie
SameSite=Lax 能挡住大部分跨站 POST,但不是全部(比如同站的其他子域被攻破)。对写操作仍然要 CSRF Token。前后端分离下用 Cookie-to-Header 模式:
- 后端下发一个非 HttpOnly 的
XSRFCookie; - 前端读取它,在写请求里放进
X-XSRF-TOKEN请求头; - 后端比对请求头和 Cookie / 会话里的值。
攻击者的站点读不到我们域下的 Cookie,也就构造不出正确的请求头。axios 只需配置 Cookie 名即可自动带头:
axios.defaults.xsrfCookieName = "ADMIN_CONSOLE_XSRF";
9.4 退出必须是带 CSRF 的 POST
如果 GET /logout 就能退出,任何站点放一个 <img src="https://admin.example.com/api/logout"> 就能把用户踢下线。退出应当:
- 前端用表单 POST 到后端
/logout,携带 CSRF Token; - 后端销毁会话;
- 后端跳转到 Keycloak 的
end_session_endpoint(RP-Initiated Logout),带上id_token_hint和post_logout_redirect_uri,结束 Keycloak 侧的单点登录会话。
只做第 2 步不做第 3 步,用户点“退出”后再点“登录”会被 Keycloak 直接免密放行,看起来像没退出。
9.5 未登录统一返回 JSON
前端通过 Ajax 调接口时,后端如果返回 302 到登录页,浏览器会跟随重定向,最终把 Keycloak 登录页的 HTML 交给 axios,前端拿到一堆看不懂的东西。正确做法:
- 只有专门的登录入口(如
GET /auth/login)未登录时 302 去 Keycloak; - 其他接口未登录一律返回
{"code":"401"}JSON,由前端决定跳转到登录入口。
十、跳转地址:两处开放重定向
10.1 登录后回哪:redirect 参数
登录入口 GET /auth/login?redirect=/home 允许前端指定登录后回到哪个页面。如果不校验,?redirect=https://evil.com 就成了一个带着可信域名的钓鱼跳板。只接受站内相对路径:
private static String safeRedirect(String redirect) {
if (redirect == null
|| !redirect.startsWith("/")
|| redirect.startsWith("//")
|| redirect.contains("\\")) {
return "/";
}
return redirect;
}
//evil.com 是协议相对地址,浏览器会当成外站;/\evil.com 在部分浏览器里会被规范化成 //evil.com,都要拒绝。
10.2 授权码回哪:redirect_uri 精确匹配
Keycloak 客户端的 Valid redirect URIs 和 Valid post logout redirect URIs 不要用通配符,逐条写全:
https://admin.example.com/api/login/oauth2/code/keycloak
http://localhost:4001/api/login/oauth2/code/keycloak
http://localhost:3000/api/login/oauth2/code/keycloak
通配符(比如 https://admin.example.com/*)意味着站内任何一个能被利用的跳转点、任何一个会把 URL 带进 Referer 的页面,都可能成为授权码的泄露出口。
10.3 反向代理下回调地址要算对
后端用请求的 Host 拼 redirect_uri。经过前端开发代理或网关后,必须让后端看到浏览器访问的那个 Host:
- 开发代理保留原始 Host(如 webpack devServer
changeOrigin: false); - 网关转发
X-Forwarded-Host/X-Forwarded-Proto,后端开启server.forward-headers-strategy: native; - 同时确保后端只信任来自网关的
X-Forwarded-*,否则攻击者自己构造这些头就能篡改回调地址。
十一、顺带一提:自研鉴权的隐患
这次改造的起点,是在老的自研 JWT 组件里发现:它的签名密钥有一个按环境名拼接的内置默认值,配置里没覆盖就会直接使用。代码一旦被反编译,任何人都能用这个默认值签出一个“管理员”token。本地验证时,一个伪造的管理员 token 确实被接受了。
这不是哪个组件特别糟糕,而是自研安全组件的通病:
- 默认值为“开箱即用”而设,却在生产里被忘记覆盖;
- 只有少数人懂内部实现,审计和升级跟不上;
- 白名单、异常响应、跨域等细节散落在各处,行为不透明。
换成 Spring Security + Keycloak 后,签名密钥由 Keycloak 管理并轮换,后端只验签不签发;state、nonce、会话固定防护和 CSRF 都有框架默认支持,机密客户端的 PKCE 则按本文配置显式开启,并在 Keycloak 侧强制 S256。每项防护都可以对照规范逐条核验。安全组件最好的特性,是它足够标准,标准到出了问题你能搜到答案。
十二、关键配置
12.1 Spring Security(Spring Boot 3)
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository clients,
OidcUserService oidcUserService,
RequestMatcher permitted,
JsonAuthenticationHandler json) throws Exception {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(
clients, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce()); // PKCE S256
var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(clients);
logoutHandler.setPostLogoutRedirectUri("{baseScheme}://{baseHost}{basePort}/");
var csrfRepo = CookieCsrfTokenRepository.withHttpOnlyFalse();
csrfRepo.setCookieName("ADMIN_CONSOLE_XSRF");
csrfRepo.setCookiePath("/");
var csrfHandler = new CsrfTokenRequestAttributeHandler();
csrfHandler.setCsrfRequestAttributeName(null); // 立即加载令牌,确保首个 GET 就下发 XSRF Cookie
http.authorizeHttpRequests(a -> a
.requestMatchers(permitted).permitAll()
.anyRequest().authenticated())
.oauth2Login(o -> o
.authorizationEndpoint(e -> e.authorizationRequestResolver(resolver))
.userInfoEndpoint(u -> u.oidcUserService(oidcUserService)))
.logout(l -> l.logoutSuccessHandler(logoutHandler))
.csrf(c -> c.csrfTokenRepository(csrfRepo)
.csrfTokenRequestHandler(csrfHandler)
.ignoringRequestMatchers(permitted))
.exceptionHandling(e -> e
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/keycloak"),
antMatcher(HttpMethod.GET, "/auth/login"))
.defaultAuthenticationEntryPointFor(json, AnyRequestMatcher.INSTANCE)
.accessDeniedHandler(json));
return http.build();
}
state、nonce、会话固定防护不需要显式配置,它们是默认行为——不要为了“调通”去关掉它们。
12.2 OIDC 客户端注册
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: ${OIDC_CLIENT_ID}
client-secret: ${OIDC_CLIENT_SECRET} # 只从环境变量 / 密钥管理注入,不进仓库
scope: openid,profile,email
provider:
keycloak:
issuer-uri: ${OIDC_ISSUER} # 启动时自动拉取 discovery 文档和 JWKS
12.3 Keycloak 客户端清单
| 配置项 | 取值 |
|---|---|
| Client authentication | On(机密客户端) |
| Standard flow | On;Implicit flow、Direct access grants 关闭 |
| Valid redirect URIs | 逐条精确填写,不用通配符 |
| Valid post logout redirect URIs | 逐条精确填写 |
| PKCE Code Challenge Method | S256 |
| 自定义声明(如工号) | 用 Mapper 写进 ID Token 或 userinfo |
十三、检查清单
- 授权码流程,后端作为机密客户端,token 不落浏览器
- PKCE 开启且为 S256,Keycloak 侧强制 S256
-
state、nonce校验保持框架默认,未被关闭 - 回调地址、退出跳转地址精确匹配,无通配符
- 登录后
redirect参数只接受站内相对路径 - 会话存 Redis,多节点共享,无需粘性会话
- 会话 Cookie:HttpOnly、SameSite=Lax、生产 Secure、带业务前缀
- 认证成功更换会话 ID
- 写操作带 CSRF Token;退出为带 CSRF 的 POST,并走 RP-Initiated Logout
- 未登录的 Ajax 请求返回 JSON 401,而不是 302
- 只信任网关转发的
X-Forwarded-* - 客户端密钥只从环境变量或密钥管理注入
- 下线所有自研签发的旧 token,确认旧 token 已被拒绝
参考
- RFC 6749:The OAuth 2.0 Authorization Framework
- RFC 7636:Proof Key for Code Exchange by OAuth Public Clients
- RFC 9700:Best Current Practice for OAuth 2.0 Security
- OAuth 2.1(draft-ietf-oauth-v2-1)
- OpenID Connect Core 1.0、OpenID Connect RP-Initiated Logout 1.0
- Spring Security Reference:OAuth2 Login、CSRF、Session Management