如何使用Spring Authorization Server认证第一方移动应用?
核心结论
Spring Authorization Server严格遵循OAuth 2.1规范,已弃用password授权类型(该类型因直接暴露用户凭证给客户端,存在安全风险)。针对第一方原生移动应用,推荐采用Authorization Code + PKCE的认证流程,这是OAuth 2.1专为原生/单页应用设计的安全方案。
实现思路
- 配置Spring Authorization Server支持Authorization Code授权类型并强制启用PKCE
- 自定义移动端专属认证端点,让APP直接提交用户名密码完成认证,绕过默认的表单登录页
- 移动端按照PKCE流程生成挑战参数,通过自定义端点获取授权码后,再换取访问令牌
具体实现步骤
1. 配置客户端与授权服务器
首先注册移动端客户端,开启PKCE支持:
@Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient mobileClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("mobile-app-client") .clientSecret("{noop}mobile-secret") // 第一方APP可简化密钥校验,或直接省略(依赖PKCE保障安全) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("com.yourmobileapp://oauth/callback") // 原生APP的自定义跳转URI .scope("read") .scope("write") .clientSettings(ClientSettings.builder().requireProofKey(true).build()) // 强制开启PKCE .build(); return new InMemoryRegisteredClientRepository(mobileClient); }
配置授权服务器专属的安全过滤器链:
@Bean @Order(1) SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .authorizationEndpoint(authEndpoint -> authEndpoint .authorizationResponseHandler(new OAuth2AuthorizationCodeResponseHandler()) ); // 配置资源服务器(JWT模式) http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); }
2. 自定义移动端认证端点
创建一个REST接口,接收移动端的用户名密码、PKCE参数,完成认证后返回授权码:
@RestController @RequestMapping("/login") public class MobileAuthController { private final AuthenticationManager authenticationManager; private final AuthorizationCodeGenerator authorizationCodeGenerator; private final RegisteredClientRepository registeredClientRepository; private final OAuth2AuthorizationService authorizationService; // 构造函数注入依赖 public MobileAuthController(AuthenticationManager authenticationManager, AuthorizationCodeGenerator authorizationCodeGenerator, RegisteredClientRepository registeredClientRepository, OAuth2AuthorizationService authorizationService) { this.authenticationManager = authenticationManager; this.authorizationCodeGenerator = authorizationCodeGenerator; this.registeredClientRepository = registeredClientRepository; this.authorizationService = authorizationService; } @PostMapping("/mobile") public ResponseEntity<Map<String, String>> mobileLogin( @RequestParam String username, @RequestParam String password, @RequestParam String clientId, @RequestParam String redirectUri, @RequestParam String codeChallenge, @RequestParam String codeChallengeMethod) { // 1. 校验用户名密码 UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password); Authentication authentication = authenticationManager.authenticate(authToken); SecurityContextHolder.getContext().setAuthentication(authentication); // 2. 校验客户端合法性 RegisteredClient registeredClient = registeredClientRepository.findByClientId(clientId); if (registeredClient == null || !registeredClient.getRedirectUris().contains(redirectUri)) { return ResponseEntity.badRequest().body(Map.of("error", "无效的客户端或跳转URI")); } // 3. 生成并保存授权码 OAuth2AuthorizationCodeRequest authCodeRequest = OAuth2AuthorizationCodeRequest.withRegisteredClient(registeredClient) .principal(authentication) .redirectUri(redirectUri) .scopes(registeredClient.getScopes()) .codeChallenge(codeChallenge) .codeChallengeMethod(codeChallengeMethod) .build(); String authorizationCode = authorizationCodeGenerator.generate(); OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) .principalName(authentication.getName()) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .attribute(OAuth2AuthorizationCodeRequest.class.getName(), authCodeRequest) .token(OAuth2Token.withTokenValue(authorizationCode) .tokenType(OAuth2TokenType.AUTHORIZATION_CODE) .issuedAt(Instant.now()) .expiresAt(Instant.now().plus(registeredClient.getAuthorizationCodeSettings().getTokenTimeToLive())) .build()) .build(); authorizationService.save(authorization); // 4. 返回授权码 return ResponseEntity.ok(Map.of("code", authorizationCode)); } }
3. 调整默认安全过滤器链
允许移动端认证端点的匿名访问:
@Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/login/mobile").permitAll() .anyRequest().authenticated() ) .formLogin(withDefaults()); // 保留网页端表单登录(若不需要可移除) return http.build(); }
4. 移动端PKCE流程实现(伪代码)
// 生成PKCE参数 fun generatePkceParams(): Pair<String, String> { val codeVerifier = RandomStringUtils.randomAlphanumeric(64) val codeChallenge = codeVerifier.sha256().encodeBase64Url().trimEnd('=') return codeVerifier to codeChallenge } // 步骤1:调用自定义登录接口获取授权码 val (codeVerifier, codeChallenge) = generatePkceParams() val loginResponse = HttpClient.post("https://your-server.com/login/mobile") .formBody(mapOf( "username" to "user123", "password" to "password123", "clientId" to "mobile-app-client", "redirectUri" to "com.yourmobileapp://oauth/callback", "codeChallenge" to codeChallenge, "codeChallengeMethod" to "S256" )) val authorizationCode = loginResponse.body()["code"] as String // 步骤2:用授权码换取访问令牌 val tokenResponse = HttpClient.post("https://your-server.com/oauth2/token") .formBody(mapOf( "grant_type" to "authorization_code", "code" to authorizationCode, "redirect_uri" to "com.yourmobileapp://oauth/callback", "client_id" to "mobile-app-client", "client_secret" to "mobile-secret", "code_verifier" to codeVerifier )) val accessToken = tokenResponse.body()["access_token"] as String
关键说明
- PKCE通过
code_verifier和code_challenge机制,即使授权码被窃取,攻击者也无法换取令牌,保障了原生应用的安全性 - 自定义端点既满足了移动端直接提交用户名密码的需求,又符合OAuth 2.1规范,避免了
password授权的安全隐患 - 若移动端偏好JSON请求体,可将接口参数改为
@RequestBody接收JSON格式的登录请求
内容的提问来源于stack exchange,提问作者bcode
相关产品推荐
相关产品推荐

