Spring Authorization Server实战 (一) 构建符合OAuth2.1规范的授权码与PKCE流程
1. 为什么需要OAuth2.1和PKCE十年前我刚接触OAuth2.0时觉得这套协议简直完美解决了第三方应用授权问题。直到去年在项目中遇到真实的安全事件一个SPA应用因为使用传统授权码模式导致用户token被中间人截获。这才让我真正理解OAuth2.1引入PKCE的必要性。OAuth2.1最大的改进就是强制要求公共客户端比如单页应用必须使用PKCE流程。想象这样一个场景你在咖啡店用手机登录某个SPA应用黑客在同一个WiFi下可以轻松截获授权码。而PKCE就像给你的授权流程加了双重保险——客户端会先生成一段随机密码code_verifier再通过加密变换生成挑战码code_challenge最后用这两个密码组合验证身份。这样即使授权码被截获没有原始密码也换不到有效token。实测下来这套机制能有效防御三种常见攻击授权码拦截攻击最典型的中间人攻击授权码注入攻击伪造授权码客户端伪装攻击冒充合法客户端2. 五分钟搭建基础授权服务先来点实际的用Spring Authorization Server快速搭建一个最小化的授权服务。这里我推荐用Spring Boot 3.x Spring Security 6.x组合能获得最完整的OAuth2.1支持。Configuration EnableWebSecurity public class SecurityConfig { Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize - authorize .anyRequest().authenticated() ) .formLogin(withDefaults()); return http.build(); } Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(spa-client) .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // PKCE必须用公共客户端 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri(http://127.0.0.1:3000/callback) .scope(read) .clientSettings(ClientSettings.builder() .requireProofKey(true) // 强制开启PKCE .build()) .build(); return new InMemoryRegisteredClientRepository(client); } }这段配置做了三件关键事声明一个公共客户端clientAuthenticationMethod设为NONE强制要求PKCE验证requireProofKeytrue只允许授权码模式安全性最高启动应用后你会自动获得这些端点/oauth2/authorize授权端点/oauth2/token令牌端点/oauth2/jwksJWK Set端点3. PKCE流程的完整实现现在我们来深入PKCE的具体实现细节。整个流程就像一场精心设计的密码交换舞步我画了个时序图帮助理解前端准备阶段在浏览器中完成// 生成随机code_verifier43-128字符 const codeVerifier generateRandomString(); // 计算code_challengeSHA256哈希后Base64URL编码 const codeChallenge base64URLEncode(sha256(codeVerifier)); // 跳转到授权页时带上challenge参数 window.location http://auth-server/oauth2/authorize? response_typecode client_idspa-client redirect_urihttp://127.0.0.1:3000/callback code_challenge_methodS256 code_challenge${codeChallenge};后端验证阶段Spring自动处理// 框架内置的PKCE验证器会自动检查 // 1. 请求是否包含code_verifier // 2. code_verifier经过哈希后是否匹配最初的code_challenge // 3. 使用的哈希算法是否符合声明S256/plain // 手动验证示例了解原理用 String computedChallenge Base64.getUrlEncoder() .withoutPadding() .encodeToString( MessageDigest.getInstance(SHA-256) .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)) ); if (!computedChallenge.equals(originalChallenge)) { throw new InvalidGrantException(PKCE验证失败); }令牌请求示例POST /oauth2/token HTTP/1.1 Content-Type: application/x-www-form-urlencoded grant_typeauthorization_code codeAwYjZ8K6RZ... redirect_urihttp://127.0.0.1:3000/callback client_idspa-client code_verifierKLBdjsakdi... // 关键的安全凭证实际开发中遇到过两个坑浏览器端生成的code_verifier必须妥善存储建议用sessionStorageBase64URL编码和普通Base64的区别要去掉末尾等号4. 生产环境必备的安全加固基础流程跑通后还需要这些安全措施才能真正上线客户端配置要点.clientSettings(ClientSettings.builder() .requireProofKey(true) .requireAuthorizationConsent(true) // 强制用户确认授权 .tokenEndpointAuthenticationSigningAlgorithm(SignatureAlgorithm.RS256) // 签名算法 .build())服务器端安全策略# application.yml spring: security: oauth2: authorization-server: token: access-token-time-to-live: 15m # 短期token refresh-token-time-to-live: 7d # 刷新token authorization-code-time-to-live: 5m # 授权码有效期必须开启的安全特性HTTPS强制包括redirect_uri同源策略检查针对SPA令牌绑定Token Binding防CSRF令牌对授权端点最近帮一个客户做安全审计时发现他们没限制redirect_uri的白名单导致攻击者可以构造恶意回调地址窃取token。修复方案很简单.redirectUris(uris - uris .add(https://trusted-domain.com/callback) .add(http://localhost:3000/callback)) // 开发环境5. 调试技巧与常见问题遇到问题别急着查文档先用这些方法定位诊断工具组合开启Spring Security调试日志logging.level.org.springframework.securityDEBUG使用OAuth2调试器GET /oauth2/authorize?response_typecodeclient_idspa-client... # 观察返回的error参数如 # errorinvalid_requesterror_descriptionPKCErequired令牌端点错误代码速查表错误码可能原因解决方案invalid_grantPKCE验证失败检查code_verifier生成逻辑unauthorized_client客户端未注册核对redirect_uriinvalid_scope请求了未声明的scope检查客户端配置高频问题排查报错PKCE required但明明已经配置检查clientSettings.requireProofKey(true)是否设置确认客户端类型是PUBLIC不是CONFIDENTIAL授权码过期太快调整authorization-code-time-to-live参数确保前端在获取code后立即兑换token跨域问题CORS确保/auth和/token端点允许OPTIONS请求前端axios请求需要withCredentials: true最近遇到一个典型case客户的前端用React但没正确处理hash路由导致回调时丢失code参数。解决方案是在路由拦截器中增加特殊处理const query new URLSearchParams(window.location.hash.substr(1)); if (query.has(code)) { // 提取授权码的逻辑 }6. 进阶自定义令牌与声明默认的JWT令牌可能不满足业务需求比如需要添加用户部门信息。通过自定义令牌生成器可以实现Bean public OAuth2TokenGenerator? tokenGenerator(JwtEncoder jwtEncoder) { JwtGenerator jwtGenerator new JwtGenerator(jwtEncoder); jwtGenerator.setJwtCustomizer(tokenCustomizer()); return new DelegatingOAuth2TokenGenerator( jwtGenerator, new OAuth2AccessTokenGenerator(), new OAuth2RefreshTokenGenerator() ); } private JwtCustomizer tokenCustomizer() { return context - { if (context.getTokenType() OAuth2TokenType.ACCESS_TOKEN) { // 添加自定义声明 context.getClaims() .claim(department, dev) .claim(projects, List.of(projectA, projectB)); } }; }安全提示自定义声明要注意不要暴露敏感信息如密码哈希大数组可能导致令牌膨胀建议用短字段名涉及用户权限的声明需要服务端二次验证对于资源服务器的验证可以这样配置Bean SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize - authorize .requestMatchers(/projects/**).hasAuthority(SCOPE_projects) .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt .decoder(jwtDecoder()) .jwtAuthenticationConverter(customConverter()) ) ); return http.build(); } // 自定义声明转换器 private ConverterJwt, ? extends AbstractAuthenticationToken customConverter() { return jwt - { SetString scopes jwt.getClaim(scope); SetSimpleGrantedAuthority authorities scopes.stream() .map(scope - new SimpleGrantedAuthority(SCOPE_ scope)) .collect(Collectors.toSet()); // 添加自定义声明到权限中 if (jwt.hasClaim(department)) { authorities.add(new SimpleGrantedAuthority( DEPT_ jwt.getClaim(department))); } return new JwtAuthenticationToken(jwt, authorities); }; }7. 移动端适配要点对于移动应用需要特别注意几个关键点自定义URI Scheme处理!-- AndroidManifest.xml -- intent-filter action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schemeyourapp android:hostcallback / /intent-filteriOS的ASWebAuthenticationSessionlet session ASWebAuthenticationSession( url: authURL, callbackURLScheme: yourapp://callback) { callbackURL, error in // 处理回调 } session.prefersEphemeralWebBrowserSession true // 禁止共享cookies session.start()移动端安全存储方案Android用EncryptedSharedPreferencesiOS用Keychain Services跨平台方案建议使用React Native的react-native-community/async-storage配合加密插件实测中发现iOS 15对第三方cookie的限制会导致某些场景下session丢失。解决方案是在授权请求中显式传递state参数并在客户端实现状态管理// 生成state参数建议包含防重放随机数 const state generateStateWithNonce(); // 跳转时带上state authUrl state${encodeURIComponent(state)}; // 回调时验证state匹配 if (returnedState ! originalState) { throw new Error(State mismatch); }
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2422994.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!