移动端 App 接入统一认证,最怕的不是写代码,而是 Token 在半路被别人捡走。我去年做社区 App 后端时,把密码模式换成了 Spring Cloud Gateway 做网关、OAuth2.1 规范思路下的授权码 + PKCE 这套组合,前后踩了不少坑。今天把这些思考和实践整理出来,给正在给自家 App 对接认证的团队做参考。不管你负责网关、资源服务还是客户端,看完至少能少踩几个 PKCE、Token 校验、回调拦截相关的坑。
1. 为什么移动端对接偏偏要纠结 PKCE
1.1 移动端的 OAuth 和 Web 端有本质差异
很多后端同学第一次写移动端登录时,会下意识把 Web 端 OAuth 的玩法搬过来:前端页面上跳转授权页,拿到授权码之后让后端去换 Token,甚至直接把client_secret塞进 App 的代码里。这套东西在 Web 端勉强能跑,放到移动端就是定时炸弹。
原因很直接:Web 端的授权码模式默认认为客户端可以安全保存client_secret,但 App 不是这样的客户端。APK 和 IPA 安装到用户设备上,代码经过逆向工具基本等于裸奔。你把client_secret写在代码里、放在配置文件里、藏在原生库里,最终都会被翻出来。而移动端的 OAuth 场景里,客户端本身就是“不可信”的,不能靠 secret 证明身份。
所以 OAuth2.1 草案里把所有需要客户端保密的模式全部砍掉或弱化,推荐的方向就是:客户端只带client_id,把code_verifier这个随机数留在本地,用 PKCE 证明“拿到授权码的人就是发起授权的人”。这个思路和 Web 端完全不同,必须单独拉通。
1.2 Token 泄露的实际路径
我接手的项目之前有一堆“看似安全”的设计:Access Token 三小时有效、Refresh Token 永久有效、Authorization Header 一路透传到每个微服务。结果做安全评审的时候,排查出来至少四条泄露路径。
第一是回调 URL 被截获。授权码模式下,授权服务器会把code拼在redirect_uri后面,这个 URI 如果用的自定义 scheme,比如myapp://callback?code=xxx,同设备上的恶意 App 可以注册同一个 scheme 把你回调地址抢走。第二是日志。网关和授权服务器如果默认打印请求参数,code、Token 全部会落盘,再被日志平台聚合,泄露面一下就大了。第三是客户端存储。Token 随手写在 SharedPreferences 或 UserDefaults 里,设备备份、root、越狱、逆向之后全都能读出来。第四是让client_secret进了 App 包,反编译之后直接拿去调用 Token 接口。
PKCE 解决的是第一类问题中“授权码被截获后无法兑换”的关键环节,但后面三类问题需要架构和规范配合,不是加一个参数就能解决。
1.3 OAuth2.1 到底改了点什么
OAuth2.1 相比 2.0 最核心的变化是:隐式授权和密码模式不再推荐,客户端凭据只允许 confidential client 使用,授权码模式必须配合 PKCE,回调地址必须精确匹配,刷新令牌的使用条件也被收紧。
如果你只想记住一个结论,那就是:移动端 App 现在不应该再走password模式,也不应该把client_secret放进包里,而是应该用“授权码 + PKCE + 短期 Access Token + 轮换的 Refresh Token”这套组合。Spring 生态里虽然底层还叫 OAuth2.0,但 Spring Authorization Server 已经原生支持requireProofKey这种配置,把授权模式收窄之后,实现效果已经完全对齐 OAuth2.1 的要求。
| 维度 | 旧 OAuth2.0 常见做法 | OAuth2.1 / 推荐做法 |
|---|---|---|
| 授权模式 | 密码模式、隐式模式 | 授权码 + PKCE |
| 客户端身份 | App 携带 client_secret | 只有 client_id,Public Client |
| 授权码保护 | 仅靠 state | PKCE 绑定 code_verifier |
| Access Token 有效期 | 数小时甚至更长 | 10~15 分钟 |
| Refresh Token | 长期固定不换 | 短期 + 每次刷新轮换 |
2. 整体架构与方案选型
2.1 组件分工
我最终落地的架构很简单:移动端 App、Spring Cloud Gateway、授权服务器、若干资源服务。App 不直接打业务服务,所有请求都经过网关。
授权服务器负责用户登录、授权码签发、Token 签发,我直接用 Spring Authorization Server 搭建。网关承担两件事:第一,统一校验 Access Token 的签名和有效期;第二,把原始请求路由到下游服务,并向下游传递经过解析的用户信息。资源服务不再关心 Token 怎么校验,只认网关传过来的内部 Header,这样下游服务代码会干净很多,也避免每个服务都去维护一份 JWK 缓存。
这个方案的本质是把安全边界收拢到网关这一层。否则十个微服务就是十个安全入口,任何一个人的 JWT 解码配置漏了或者时钟同步出问题,整个链路的安全性都会被拉低。
2.2 授权码 + PKCE 的完整令牌交换流程
理解 PKCE 最好的方式是把完整流程在脑子里过一遍。
App 启动登录后,先在本地生成一个随机字符串code_verifier,长度通常在 64 字节左右。然后对code_verifier做 SHA-256 哈希,再进行 Base64URL 编码,得到code_challenge。App 带着client_id、redirect_uri、state、code_challenge、code_challenge_method=S256这些参数跳转到授权服务器的登录授权页。
用户完成登录后,授权服务器记下这个授权码和code_challenge之间的绑定关系,然后把code回跳到 App 的redirect_uri。这时候即使恶意 App 截获了这个code,它因为没有原始的code_verifier,去 Token 端点兑换时会校验失败。只有真正常握code_verifier的 App 才能兑换成功。
兑换 Token 这一步必须用 POST 请求打到 Token 端点,参数里带code、redirect_uri、client_id、code_verifier。授权服务器重新对code_verifier做 SHA-256,比对是否等于之前记录的code_challenge,相等才返回 Access Token 和 Refresh Token。
2.3 为什么网关要做资源服务器,而不是只当转发代理
我见过一些项目,网关只做路由转发,把 Authorization Header 原封不动转发给下游,下游自己接一个 Spring Security Resource Server 校验。这种模式没有错,但会把风险点摊到每个服务头上。
网关统一做资源服务器最大的好处是:校验逻辑只需要维护一份。JWT 解码器、JWK 缓存、时钟偏移、算法白名单这些配置,在网关里配一次,下游服务可以完全不感知 OAuth 的存在。网关校验通过之后,把解析出来的用户 ID、租户、角色等放进去内部 Header,再清理掉外部原始 Header,下游服务只需要信任网关。
坏处也有:网关变成关键路径节点,它在高并发下要承担额外的签名校验 CPU 开销,而且如果 JWK 配置不好,可能成为单点故障。所以要做本地缓存和合理的健康检查,不要把jwk-set-uri指向一个不稳定地址。
3. 搭建授权服务器:把 OAuth2.1 风格落进 Spring 配置
3.1 Spring Authorization Server 基础配置
Spring Security 的旧 OAuth 项目已经停止维护,现在做授权服务器基本都用 Spring Authorization Server。它的核心思路是用SecurityFilterChain组合出授权端点、Token 端点、JWK 端点,并允许你自定义客户端仓库和 Token 策略。
我用的最简配置骨架如下:
@Configuration @EnableWebSecurity public class AuthorizationServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer configurer = new OAuth2AuthorizationServerConfigurer(); http .securityMatcher(configurer.getEndpointsMatcher()) .with(configurer, oauth2 -> {}) .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); } }这里securityMatcher很重要,它确保只有/oauth2/authorize、/oauth2/token、/oauth2/jwks这些授权服务器端点走这个 SecurityFilterChain,其他请求再走默认的登录链。生产环境我会把客户端注册表换成 JDBC 存储,避免重启丢配置。
3.2 注册移动端客户端:Public Client 的 proofKey 配置
移动端 App 是标准的 Public Client,注册时必须设置ClientAuthenticationMethod.NONE,同时开启requireProofKey(true)。这样授权服务器收到没有client_secret的 Token 请求时不会报错,而且强制要求授权码请求里必须带code_challenge。
@Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient mobileApp = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("mobile-app") .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("myapp://oauth/callback") .scope("api.read") .scope("openid") .requireProofKey(true) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(15)) .refreshTokenTimeToLive(Duration.ofDays(30)) .reuseRefreshTokens(false) .build()) .build(); return new InMemoryRegisteredClientRepository(mobileApp); }这里两个最容易忽略的点:第一,redirectUri必须和 App 实际配置的回调地址一字不差,Spring Authorization Server 默认不允许模糊匹配,多一个斜杠都不行;第二,不要给这个客户端配置任何clientSecret,否则就不是 Public Client 了。
3.3 Token 策略:短期 Access Token 与刷新令牌轮换
Token 过期时间不能拍脑袋。Access Token 我习惯设 10 到 15 分钟,太短会导致用户体验频繁弹登录,太长则泄露后的影响窗口过大。Refresh Token 设 30 天,并开启reuseRefreshTokens(false),意思是每次刷新后旧的 Refresh Token 立即失效。
这个设计有个安全收益:如果攻击者偷到了 Refresh Token,正常用户刷新一次后,攻击者手里的旧 Token 就会变废。你可以在授权服务器里记录 Refresh Token 的使用记录,一旦发现同一个 Token 被使用两次,就判定疑似泄露并把整个会话吊销。实现上其实就是在 Refresh Token 刷新回调里加一段审计逻辑,但不做这件事的话,长期有效的 Refresh Token 等于给攻击者留了个持久后门。
JWT 签名我建议用 RSA 密钥对,授权服务器通过/oauth2/jwks发布公钥,网关用公钥验签。不要用对称密钥到处分发,一旦泄露,所有 Token 都能被伪造。
4. Spring Cloud Gateway 侧的安全接入与转发
4.1 资源服务器依赖与最小配置
Spring Cloud Gateway 底层是 WebFlux,所以安全配置要用SecurityWebFilterChain。依赖上只需要加spring-boot-starter-oauth2-resource-server和spring-boot-starter-security。
spring: security: oauth2: resourceserver: jwt: issuer-uri: http://auth-server:9000 jwk-set-uri: http://auth-server:9000/oauth2/jwks配置issuer-uri后,Spring Security 会校验 JWT 里的iss是否匹配,避免别人拿另一个授权服务器签发的 Token 来打你。显式配置jwk-set-uri是为了让网关不去做一次自动发现,直接固定从授权服务器拉取公钥集合。生产环境我不会让网关在启动阶段依赖授权服务器的 DNS,本地会放一个经过签名的公钥缓存,优先用本地公钥验签,拉不到再回源。
网关的安全过滤链这样写:
@Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { return http .csrf(ServerHttpSecurity.CsrfSpec::disable) .authorizeExchange(exchanges -> exchanges .pathMatchers("/actuator/health").permitAll() .anyExchange().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); }csrf(disable)对纯 API 网关是合理的,因为 App 不依赖浏览器 Cookie 做会话。但要小心,如果之后有 Web 管理页面也走网关,CSRF 就要分开处理。
4.2 JWT 解码与自定义用户信息提取
默认 JWT 解码器会把 JWT 里的scope转成SCOPE_xxx权限,但很多时候我还需要从 Token 里提取用户 ID、租户 ID,再传给下游。这一步我写了个全局过滤器。
@Component public class UserHeaderGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return exchange.getPrincipal() .cast(Jwt.class) .map(jwt -> { ServerHttpRequest request = exchange.getRequest().mutate() .header("X-User-Id", jwt.getSubject()) .header("X-Tenant-Id", jwt.getClaimAsString("tenant_id")) .build(); return exchange.mutate().request(request).build(); }) .flatMap(chain::filter); } @Override public int getOrder() { return -1; } }这里有个安全细节:凡是下游要使用的内部 Header,比如X-User-Id、X-Tenant-Id,网关必须先把外部客户端带来的同名 Header 清掉,再填充从 JWT 里解析出来的值。否则攻击者直接伪造一个X-User-Id就能越过鉴权,等于把安全口子敞开了。我在网关路由配置里会加一句RemoveRequestHeader=X-User-Id,确保进来的原始请求不会携带这个头。
4.3 转发下游时的 Header 清理与 Token Relay 选型
有人会纠结要不要把 Authorization Header 继续透传给下游。我的建议分两种情况。
如果下游服务是纯粹的内部服务,不处理业务用户语义,那网关验完签之后就不应该继续把 Bearer Token 往下游传。外部 Token 一旦进入内网服务,就变成一个需要保护的敏感数据对象,日志、链路追踪、代码里任何一次打印都可能泄露。此时下游只认X-User-Id这类内部 Header,安全边界很清晰。
如果下游服务确实需要拿到原始 JWT 做细粒度权限判断,那么网关转发的 Authorization Header 必须保持原样。Spring Cloud Gateway 默认不会动它,所以不需要额外配置。唯一要注意的是,链路追踪和访问日志要提前做脱敏,不能把 Authorization Header 打印到日志平台。
另外,如果网关本身还要做 OAuth2 Client 登录态中继,比如页面应用用网关做代理登录,才需要引入TokenRelayGatewayFilterFactory。移动端直接用 Access Token 访问网关的场景用不上它,别为了一个用不到的功能引入一堆 OAuth2 Client 依赖。
4.4 CORS 与移动端 Deep Link 回跳配置
很多同事上来就问网关 CORS 怎么配,其实原生 App 的 HTTP 请求不受浏览器同源策略限制,CORS 主要影响的是 Web 页面和部分 WebView 场景。真正需要重点关心的是回调地址redirect_uri。
授权服务器里注册的redirectUri必须精确对应 App 的 deep link。自定义 scheme(例如myapp://oauth/callback)有一个已知风险:Android 上如果有另一个恶意应用注册了相同的 scheme,系统弹出选择框,或者恶意应用直接抢占回调,code就可能被截获。所以现在更推荐用 HTTPS 回调地址,配合系统级的应用关联链接,例如 Android 的 App Links 或 iOS 的 Universal Links,让系统确认这个 HTTPS 地址确实属于你的 App。
如果你因为团队现状只能先用自定义 scheme,也千万别慌,PKCE 就是兜底方案。攻击者即使截获了code,因为手里没有code_verifier,无法完成 Token 兑换,Token 不会真正泄露。我说的“防止 Token 泄露”在这一步体现得最明显。
5. 移动端 App 接入实战:PKCE 流程与安全存储
5.1 PKCE 参数生成规则与校验逻辑
服务端配置做得再好,客户端 PKCE 生成错了同样白搭。code_verifier必须是高熵随机串,字符范围限制在[A-Z]、[a-z]、[0-9]、-、.、_、~,长度 43 到 128。我用 64 字节,既满足安全强度又留有余量。
fun generatePkcePair(): PkcePair { val verifierChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" val verifier = buildString { repeat(64) { append(verifierChars[SecureRandom.nextInt(verifierChars.length)]) } } val challenge = Base64.getUrlEncoder().withoutPadding() .encodeToString( MessageDigest.getInstance("SHA-256") .digest(verifier.toByteArray(Charsets.US_ASCII)) ) return PkcePair(verifier, challenge) }这里有两个容易踩的坑。一是 Base64 编码必须用 URL 安全的变体,并且去掉 padding,也就是把+、/、=都处理掉,否则code_challenge会和授权服务器计算出来的值不一致。二是有些 HTTP 库会自动对 form 参数做 URL Encode,由于code_verifier本身只包含无保留字符,安全字符不会被二次转义,但如果你的随机串生成逻辑里混进了其他字符就会出问题。我建议生成逻辑严格限定字符集,后面所有环节都会省心。
5.2 从系统浏览器到授权服务器的跳转与回调
移动端 OAuth 登录不应该用 WebView,而应该用系统浏览器,或者 iOS 的 ASWebAuthenticationSession、Android 的 Custom Tabs。WebView 的问题在于它无法安全共享登录会话,又容易被注入脚本,安全边界不可控。
跳转时除了code_challenge,必须带上state参数。这个参数是防 CSRF 的,回调回来后一定要校验它和发起时保存的值一致,否则没法确认这次回调是不是你发起的授权请求触发的。
https://auth-server/oauth2/authorize ?response_type=code &client_id=mobile-app &redirect_uri=myapp%3A%2F%2Foauth%2Fcallback &scope=api.read &state=8x2k... &code_challenge=E9M... &code_challenge_method=S256拿到code之后,Token 兑换必须在 App 内完成,不能把code转发给后端服务,更不能让code出现在 URL 里。这个过程的常见错误是把code_verifier传到服务器,然后让服务器用它去换 Token,这等于把 PKCE 的安全凭据又传到了另一个不可信环境。PKCE 的设计初衷就是让code_verifier只存在于当前 App 进程里。
5.3 Token 在 App 端的安全存储与刷新策略
Access Token 和 Refresh Token 的存储要求不同。Access Token 有效期短,我建议只保存在内存里,App 重启后如果内存中没有 Access Token,可以直接用 Refresh Token 静默刷新。Refresh Token 必须放系统安全存储,Android 用 Keystore 加 EncryptedSharedPreferences,iOS 用 Keychain,千万不能平铺在 SharedPreferences 或 UserDefaults。
刷新逻辑要处理并发问题。比如某个页面同时发出三个请求,网关全部返回 401,App 这时候如果并发发起三次刷新请求,授权服务器很可能返回错误,因为开启了 Refresh Token 轮换后,旧 Token 只能成功使用一次。我的做法是在网络拦截器里加一个异步锁,401 触发刷新后,其他请求先等待同一个刷新任务完成,再把新的 Token 重新放回请求头重试。刷新失败时统一跳登录,而不是把一个页面拉起三个登录页。
6. 常见问题与排查实录
6.1 PKCE 校验失败:code_verifier 与 challenge 不匹配
这个报错多半是编码问题,而不是随机数问题。我在测试环境见过最典型的情况是服务端日志提示invalid_grant: code_verifier mismatch,然后排查一圈发现客户端生成code_challenge时用的是普通 Base64,不是 URL 安全的 Base64;还有一次是code_challenge_method漏传了,默认走了plain,和明文 challenge 自然对不上。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| code_verifier mismatch | Base64 编码带 padding 或 +/ | 改用 UrlEncoder.withoutPadding |
| invalid_grant | code_verifier 字符集不符 | 严格限制 43-128 位且只含安全字符 |
| 授权服务器不校验 PKCE | 客户端注册少了 requireProofKey | 检查 RegisteredClient 配置 |
| Token 端点返回 unauthorized_client | 请求带了 client_secret | Public Client 不能带 secret |
6.2 JWT 签名不信任 / Issuer 不匹配
网关报An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature,基本是 JWK 获取不到或者验签公钥不匹配。最常见原因是授权服务器使用的签名密钥和网关配置的jwk-set-uri指向的密钥集合不一致。
还有一种是内网环境和公网环境使用的issuer-uri不一样。授权服务器签发的 JWT 的iss字段是它自己的外部地址,如果网关配置的issuer-uri恰好是内网地址,两者不一致会直接拒绝。我建议 JWT 的issuer从配置中心统一发,网关和授权服务器都用同一个值,不要各自手写。
6.3 Access Token 过期导致网关 401 的处理
网关返回 401 时不会告诉你具体是签名错误还是过期,客户端只能统一按“访问凭证失效”处理。刷新完 Token 后,原始请求必须自动重放,用户无感知才算合格。
还有一个坑是网关层面的错误响应可能会被压缩或包装,导致移动端拦截器拿不到 401 状态码。我习惯在网关加一个全局异常过滤器,把AuthenticationException统一映射成401,响应体固定为{"code":"UNAUTHORIZED","message":"token expired"},这样客户端可以非常确定地去触发刷新流程,而不是靠字符串匹配去猜。
6.4 从日志和第三方统计工具里抹掉 Token
Token 泄露很多时候不是攻击者截获,而是自己人把日志打出来的。Spring Cloud Gateway 默认的访问日志如果不特殊处理,Authorization头不会直接打印,但如果你在业务日志里打印了请求 Headers,或者在网关 Filter 里打印了 exchange 的请求信息,Token 就会跟业务日志一起进 ELK。
我推进的硬性要求是三条:Token 请求必须走 POST,授权码不能出现在 URL query 里;网关和授权服务器日志组件统一配置脱敏过滤器,凡是Authorization、code、token这类字段一律打星号;第三方统计 SDK 全部关闭 request headers 采集。只要这三条不出漏洞,运维侧基本不会主动泄露 Token。
7. 写在最后的实操体会与建议
7.1 上线前逐项对照检查清单
每次接新 App,我都会拉一份检查清单,环境不同但项目不变。你把下面这些项目过一遍,比反复读文档有用得多。
client_id与授权服务器注册一致,不带 client_secretredirect_uri与 App deep link 精确一致- 授权服务器客户端开启
requireProofKey(true) - 移动端生成
code_verifier使用安全随机源和 URL 安全 Base64 - Token 交换请求里携带
code_verifier,并且不使用 GET - Access Token 有效期不超过 15 分钟
- Refresh Token 开启轮换,
reuseRefreshTokens(false) - 网关配置
jwk-set-uri,并去掉客户端伪造的内部 Header - 网关和授权服务器日志对 Authorization 与 code 做脱敏
- App 把 Refresh Token 放入系统安全存储,Access Token 只在内存
7.2 一个容易被忽略的坑:授权服务器的登录会话
很多团队把授权服务器搭好之后,在 Postman 里试流程一切正常,但一上手机就发现用户授权页反复弹出登录框,或者授权码换 Token 时偶尔报 session 缺失。这是因为授权服务器的/oauth2/authorize默认需要用户登录态,App 用系统浏览器打开授权页后,如果之前没有登录过,用户输入账号密码会种下 Session Cookie。
问题出在客户端清理上。用户在授权页登录后,App 拿到 Token 不代表授权服务器那边的 Session 要留着,如果不主动清除,下一次用户退出登录再重新登录,系统浏览器可能还是旧 Session,导致用户点击“切换账号”实际上切不过去。ASWebAuthenticationSession 可以配置为临时会话,关闭后自动清 Cookie;Android 端则需要你自己在登录完成后清理 Cookie 并关闭 Custom Tab 的本地会话。这个细节不处理,安全上不一定出问题,但体验上很致命。
7.3 后续可以扩展的方向
如果你把上面这套跑顺了,下一步可以考虑给 Refresh Token 加设备绑定和吊销机制。比如在授权服务器里记录 Refresh Token 对应的设备 ID,同一个 Refresh Token 出现在另一台设备上就直接吊销整个用户会话。再进一步还可以给 Refresh Token 加生物识别保护,App 每次静默刷新前要求用户验证指纹或面容,敏感业务场景很值得做。
最后说一个我自己的习惯:每次上线前,我都会拿抓包工具走一遍完整的授权码流程,确认回调 URL 里没有 code、Token 请求里没有 client_secret、网关日志里没有 Authorization 头。这三个地方守住,Token 泄露的风险就已经降掉一大半。安全不是一个参数能解决的,但把这些关键点位固定成流程,移动端接 OAuth 就不会变成一场事故。