Spring Authorization Server实战 (一) 构建符合OAuth2.1规范的授权服务器
1. 为什么需要OAuth2.1授权服务器?
三年前我接手一个电商项目时,曾用Spring Security OAuth搭建过授权服务。当时最头疼的就是要处理各种安全漏洞补丁,比如CSRF攻击、授权码拦截等问题。现在终于有了更好的选择——基于OAuth2.1规范的Spring Authorization Server。
OAuth2.1并不是完全推翻重来的新协议,而是对OAuth2.0的"安全加固版"。它主要做了三件事:
- 移除了已被证明不安全的隐式授权模式和密码模式
- 强制要求公共客户端必须使用PKCE扩展流程
- 整合了多年来社区积累的多个安全扩展规范
举个实际例子:以前用OAuth2.0做微信登录时,前端可能直接拿到access_token。这种隐式授权方式就像把家门钥匙挂在门把手上,现在OAuth2.1彻底禁止了这种做法。
2. 快速搭建授权服务器
2.1 项目初始化
首先用Spring Initializr创建项目,关键依赖选择:
- Spring Web
- Spring Security
- OAuth2 Authorization Server(注意不是老旧的spring-security-oauth2)
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version>1.0.0</version> </dependency>建议直接用最新稳定版,我在1.0.0-M1版本踩过一个坑:JWT生成时缺少必要的claims字段,导致移动端鉴权失败。
2.2 基础配置类
创建授权服务器配置的核心是AuthorizationServerConfigurer接口。建议按以下结构组织代码:
@Configuration public class AuthServerConfig { @Bean @Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); return http.formLogin(Customizer.withDefaults()).build(); } @Bean public RegisteredClientRepository registeredClientRepository() { // 客户端配置见下一节 } @Bean public JWKSource<SecurityContext> jwkSource() { // JWT密钥配置 } }这里有个实用技巧:在开发环境可以用InMemoryRegisteredClientRepository快速验证流程,生产环境一定要换成JdbcRegisteredClientRepository。
3. 客户端注册与安全配置
3.1 注册客户端信息
OAuth2.1要求每个客户端必须明确指定授权类型。以下是支持授权码模式+PKCE的客户端配置示例:
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-app") .clientSecret("{noop}secret") // 生产环境要用加密存储 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/web-app") .scope("read") .scope("write") .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(true) .requireProofKey(true) // 强制PKCE .build()) .build();特别注意这几个参数:
requireProofKey(true):开启PKCE保护clientSecret的{noop}前缀表示不加密(仅限测试)redirectUri必须完全匹配,包括末尾的"/"
3.2 PKCE实现原理
PKCE(Proof Key for Code Exchange)是防御授权码拦截攻击的利器。它的工作流程就像特工接头:
- 客户端先生成一个随机码
code_verifier(好比暗号) - 对其哈希处理得到
code_challenge(像加密后的暗号) - 授权时发送
code_challenge - 换取token时出示原始
code_verifier
服务端会验证两者是否匹配:
// 自动实现的验证逻辑 if (!codeVerifier.equals(codeChallenge)) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); }4. 令牌生成与管理
4.1 JWT令牌配置
生产环境强烈建议使用JWT格式的令牌,配置方法:
@Bean public JWKSource<SecurityContext> jwkSource() { RSAKey rsaKey = generateRsa(); JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } private static RSAKey generateRsa() { KeyPair keyPair = KeyGeneratorUtils.generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); return new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); }我曾经遇到过JWT有效期不生效的问题,后来发现需要额外配置:
@Bean public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() { return context -> { if (context.getTokenType().getValue().equals("access_token")) { context.getClaims().claim("test", "测试自定义声明"); Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(Duration.ofHours(1)); context.getClaims() .issuedAt(issuedAt) .expiresAt(expiresAt); } }; }4.2 令牌存储策略
默认的内存存储(InMemoryOAuth2AuthorizationService)只适合测试。生产方案有:
- Redis存储:高性能但需要处理序列化
- JDBC存储:稳定但要注意索引优化
- JWT自包含:无状态但无法提前撤销
这里给出Jdbc存储的配置示例:
@Bean public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); } @Bean public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); }5. 实战:完整的授权码流程
5.1 发起授权请求
前端需要构造如下格式的URL(注意response_type固定为code):
http://localhost:9000/oauth2/authorize? response_type=code &client_id=web-app &redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web-app &scope=read &state=some_state &code_challenge=生成的challenge &code_challenge_method=S256调试时常见问题:
- 缺少
code_challenge参数会返回invalid_request错误 redirect_uri不匹配会直接拒绝请求
5.2 换取访问令牌
获取code后,用POST请求交换token:
curl -X POST \ http://localhost:9000/oauth2/token \ -H 'Authorization: Basic d2ViLWFwcDpzZWNyZXQ=' \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=authorization_code &code=收到的code &redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web-app &code_verifier=原始的verifier'成功的响应示例:
{ "access_token": "eyJ...", "refresh_token": "eyJ...", "scope": "read", "token_type": "Bearer", "expires_in": 3600 }5.3 资源服务器验证
资源服务器配置验证:
@Configuration @EnableWebSecurity public class ResourceServerConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); return http.build(); } }验证过程中常见的403错误排查:
- 检查JWT签名是否匹配
- 确认
issuer-uri配置正确 - 验证时钟偏差是否在允许范围内
6. 生产环境注意事项
6.1 安全加固建议
根据OWASP建议,还需要额外配置:
- 强制HTTPS
- 设置CSP安全头
- 启用JWT签名算法白名单
- 配置合理的令牌有效期
关键的安全头配置示例:
http.headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'") ) .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .maxAgeInSeconds(31536000) ) );6.2 性能优化技巧
高并发场景下的优化经验:
- 使用连接池管理数据库连接
- 对JWT签名验证结果做缓存
- 合理设置HTTP keep-alive
- 监控令牌签发耗时指标
我曾用JMeter压测发现的问题:
- 默认配置下每秒只能处理约800个令牌请求
- 添加Redis缓存后提升到2800+
- 优化JWT签名算法后达到5000+
