跳至正文
来两杯美式
返回

OAuth 登录安全:PKCE、state 与 nonce 各防什么攻击

By 来两杯美式
发布于

最近把一个内部管理后台的登录体系推倒重来:原先是一个跑了好几年的自研 JWT 鉴权组件,前端拿着 IAM 回调的 code 交给后端换用户、再由后端签发自定义 token;改造后统一接入 Keycloak,后端用标准的 Spring Security oauth2Login 做 OIDC 机密客户端,会话放进 Redis,新旧两套前端都只是同源代理到后端。

改造过程中被问得最多的是三个问题:

  1. 后端已经有客户端密钥了,为什么还要开 PKCE?
  2. state 到底是什么,它和 PKCE 有什么区别?
  3. 登录要来回跳好几次,后端多节点部署时这些临时数据存哪?

这篇把这些问题连同背后的攻击场景一次讲清楚。

一、目标架构

浏览器 ──同源──> 前端(老 Vue / 新 Next.js)
                    │  /api/** 反向代理(保留 Host)
                    ▼
               后端(Spring Security,OIDC 机密客户端)
                 │            │
                 │            └── Redis(Spring Session,存会话)
                 ▼
             Keycloak(OIDC Provider)

几个关键决定:

二、先把授权码流程过一遍

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 的目标不是登录成你,而是让你登录成攻击者:

  1. 攻击者 A 用自己的账号在 Keycloak 登录,在回调前拦下浏览器,拿到一个属于 A 的回调链接 .../login/oauth2/code/keycloak?code=CODE_A。
  2. A 把这个链接诱导受害者 V 打开(钓鱼链接、藏在 <img src> 里都行)。
  3. 没有 state 时,后端把 CODE_A 换成 token,V 的浏览器就悄悄登录成了 A 的账号。
  4. V 之后在系统里录入的数据、上传的文件、绑定的配置,全部落在 A 的账号下,A 随时可以查看。

4.2 state 怎么挡住它

state 用完即删,一个值只能用一次。Spring Security 的 oauth2Login 默认就生成并校验 state,不需要手写。

五、PKCE:机密客户端为什么也要开

5.1 常见误解

PKCE 是给 SPA、移动端这种“存不住密钥”的公开客户端用的;我们后端有客户端密钥,不需要。

这句话在 2015 年 RFC 7636 刚发布时大体成立,但现代安全最佳实践已经把 PKCE 扩展到所有类型的 OAuth 客户端。RFC 9700 要求公开客户端使用 PKCE,并明确建议机密客户端也使用;OAuth 2.1 草案同样延续了这一方向。 原因是客户端密钥和 PKCE 防的不是同一种攻击。

5.2 客户端密钥能防什么、不能防什么

第二种就是授权码注入(Authorization Code Injection)。

5.3 授权码注入的完整过程

  1. 受害者 V 正常登录。Keycloak 签发授权码 CODE_V,它出现在回调 URL 里。

  2. CODE_V 通过某种渠道泄露给攻击者 A:

    • 浏览器历史、同步到云端的历史记录;
    • 反向代理、网关、APM 的访问日志;
    • 回调页加载第三方资源时带出的 Referer;
    • 恶意浏览器插件、被感染的终端。
  3. A 在自己的浏览器里正常点“登录”。后端为 A 的会话生成合法的 state_A、verifier_A。

  4. A 在 Keycloak 随便登录或直接拦截,把自己回调请求里的 code 换成 CODE_V,state 保留自己合法的 state_A:

    GET /login/oauth2/code/keycloak?code=CODE_V&state=state_A
  5. 后端校验 state——通过,因为 state_A 确实存在 A 的会话里。

  6. 后端带着客户端密钥去换 token——密钥也没问题,因为发请求的就是后端本身。

  7. 没有 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 有两种方法:

只用 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 类似:

  1. 发起登录时生成随机 nonce,存会话,随授权请求发给 Keycloak。
  2. Keycloak 把它原样写进 ID Token 的 nonce 声明里。
  3. 后端校验 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-可额外检测注入绑定到本次授权请求
客户端密钥-只防“外人自己换”-

一句话:

这四道防线并非彼此孤立: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

部署注意:

也可以把授权请求加密后存进 Cookie 来避免服务端存储,但那属于自定义实现,要自己处理加密、大小、过期。既然登录态本身就要放 Redis,授权请求顺带放进去是最省心的标准做法。

九、登录之后:会话本身的安全

PKCE、state、nonce 只保护“登录那一刻”。登录成功后,浏览器靠一个会话 Cookie 访问后端,这段同样有攻击面。

属性取值作用
HttpOnly是前端脚本读不到,XSS 偷不走会话
SameSiteLax第三方站点发起的 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 作废。这一条默认开启,不要关。

SameSite=Lax 能挡住大部分跨站 POST,但不是全部(比如同站的其他子域被攻破)。对写操作仍然要 CSRF Token。前后端分离下用 Cookie-to-Header 模式:

攻击者的站点读不到我们域下的 Cookie,也就构造不出正确的请求头。axios 只需配置 Cookie 名即可自动带头:

axios.defaults.xsrfCookieName = "ADMIN_CONSOLE_XSRF";

9.4 退出必须是带 CSRF 的 POST

如果 GET /logout 就能退出,任何站点放一个 <img src="https://admin.example.com/api/logout"> 就能把用户踢下线。退出应当:

  1. 前端用表单 POST 到后端 /logout,携带 CSRF Token;
  2. 后端销毁会话;
  3. 后端跳转到 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,前端拿到一堆看不懂的东西。正确做法:

十、跳转地址:两处开放重定向

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:

十一、顺带一提:自研鉴权的隐患

这次改造的起点,是在老的自研 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 authenticationOn(机密客户端)
Standard flowOn;Implicit flow、Direct access grants 关闭
Valid redirect URIs逐条精确填写,不用通配符
Valid post logout redirect URIs逐条精确填写
PKCE Code Challenge MethodS256
自定义声明(如工号)用 Mapper 写进 ID Token 或 userinfo

十三、检查清单

参考


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
›查看系列全部文章
  1. 01.T3 Stack 开发规范
  2. 02.Next.js 并行路由完全指南
  3. 03.Web 会话与身份验证完整指南
  4. 04.Auth Guard 与 tRPC 中间件
  5. 05.T3 Stack 会话认证指南
  6. 06.Next.js 子域名、反向代理与分享链路最佳实践
  7. 07.CORS 与 Web 安全实战白皮书
  8. 08.Web 多主题架构最佳实践
  9. 09.Vercel AI SDK 自定义 OpenAI 模型接入
  10. 10.AI 驱动动态看板架构指南
  11. 11.VitePress 动态渲染全栈落地方案
  12. 12.OAuth 登录安全:PKCE、state 与 nonce 各防什么攻击

下一篇
RAG 回答清单问题总漏项?topK 截断与同文档切片补全(附核心代码)