Spring Security OAuth2.1 实战:认证授权完整方案
作者:洛水石
阅读时间:约 14 分钟
关键词:OAuth2.1、Spring Security、JWT、认证、授权、SSO、PKCE
---
目录
- [引言:OAuth2.1 解决了什么问题?](#引言)
- [OAuth2.1 核心授权流程](#oauth21-核心授权流程)
- [Spring Authorization Server 搭建](#spring-authorization-server-搭建)
- [JWT 令牌生成与解析](#jwt-令牌生成与解析)
- [资源服务器保护 API](#资源服务器保护-api)
- [客户端接入实战](#客户端接入实战)
- [高级特性:PKCE、Refresh Token、SSO](#高级特性)
- [总结](#总结)
---
引言:OAuth2.1 解决了什么问题?
OAuth2.1 是 OAuth2.0 的安全增强版,主要变化:
- **废弃 Implicit 流程**(不安全)
- **废弃 Password 凭证流程**(不推荐)
- **强制 PKCE** 用于公共客户端
- **推荐 Authorization Code + PKCE** 作为标准流程
典型场景:
- **第三方登录**:微信/QQ/Github 授权登录你的应用
- **API 开放平台**:向第三方开发者开放接口,控制访问范围
- **微服务认证**:统一认证中心,各服务验证令牌
- **单点登录(SSO)**:登录一次,访问多个系统
---
OAuth2.1 核心授权流程
Authorization Code + PKCE 流程
┌─────────┐ ┌─────────────────┐
│ Client │──(1) 授权请求 + code_challenge ──→│ Authorization │
│ (浏览器) │ │ Server │
└─────────┘ └─────────────────┘
│ │
│←────────(2) 重定向到登录页 + scope ───────────│
│ │
│──(3) 用户登录并授权────────────────────────────→│
│ │
│←────────(4) 重定向回 Client + auth_code ──────│
│ │
│──(5) 请求令牌 + code + code_verifier ────────→│
│ │
│←────────(6) 返回 access_token + refresh_token─│
PKCE 作用: 防止授权码被截获后恶意换取令牌。
四种角色
|
角色 |
说明 |
示例 |
|
Resource Owner |
资源所有者 |
用户本人 |
|
Client |
第三方应用 |
你的网站/App |
|
Authorization Server |
授权服务器 |
Spring Authorization Server |
|
Resource Server |
资源服务器 |
你的 API 服务 |
|
Resource Owner |
资源所有者 |
用户本人 |
|
Client |
第三方应用 |
你的网站/App |
|
Authorization Server |
授权服务器 |
Spring Authorization Server |
|
Resource Server |
资源服务器 |
你的 API 服务 |
|
Client |
第三方应用 |
你的网站/App |
|
Authorization Server |
授权服务器 |
Spring Authorization Server |
|
Resource Server |
资源服务器 |
你的 API 服务 |
|
Authorization Server |
授权服务器 |
Spring Authorization Server |
|
Resource Server |
资源服务器 |
你的 API 服务 |
---
Spring Authorization Server 搭建
1. 依赖配置
<dependencies>
<!-- Spring Authorization Server -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
<!-- JWT 支持 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
</dependencies>
2. 授权服务器配置
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // 启用 OpenID Connect
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("web-client")
.clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://localhost:8080/login/oauth2/code/web-client")
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true) // 需要用户确认授权
.requireProofKey(true) // 强制 PKCE
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30))
.refreshTokenTimeToLive(Duration.ofDays(7))
.build())
.build();
return new InMemoryRegisteredClientRepository(client);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsaKey();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, context) -> jwkSelector.select(jwkSet);
}
private static RSAKey generateRsaKey() {
KeyPair keyPair = generateRsaKeyPair();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
private static KeyPair generateRsaKeyPair() {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
return keyGen.generateKeyPair();
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
}
3. 用户认证配置
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/error").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form.loginPage("/login"));
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("admin")
.password("{bcrypt}" + new BCryptPasswordEncoder().encode("123456"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
}
---
JWT 令牌生成与解析
JWT 结构
eyJhbGciOiJSUzI1NiJ9. ← Header (Base64URL)
eyJzdWIiOiJhZG1pbiJ9. ← Payload (Base64URL)
SflKxwRJSMeKKF2QT4fwpMe... ← Signature
自定义 JWT 内容
@Component
public class CustomJwtTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
@Override
public void customize(JwtEncodingContext context) {
JwtClaimsSet.Builder claims = context.getClaims();
// 添加自定义声明
Authentication authentication = context.getPrincipal();
if (authentication != null) {
claims.claim("user_id", ((User) authentication.getPrincipal()).getId());
claims.claim("username", authentication.getName());
claims.claim("permissions", getPermissions(authentication));
}
// 缩短令牌有效期
claims.expiresAt(Instant.now().plus(30, ChronoUnit.MINUTES));
}
private List<String> getPermissions(Authentication auth) {
return auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
}
}
JWT 解析与验证
@Component
public class JwtTokenProvider {
@Value("${jwt.public-key}")
private RSAPublicKey publicKey;
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("Token expired");
} catch (UnsupportedJwtException e) {
log.warn("Unsupported token");
} catch (MalformedJwtException e) {
log.warn("Malformed token");
}
return false;
}
}
---
资源服务器保护 API
1. 资源服务器配置
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix("ROLE_");
authoritiesConverter.setAuthoritiesClaimName("permissions");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
converter.setPrincipalClaimName("username");
return converter;
}
}
2. 方法级权限控制
@RestController
@RequestMapping("/api")
public class OrderController {
@GetMapping("/orders")
@PreAuthorize("hasAuthority('order:read')")
public List<Order> getOrders() {
return orderService.findAll();
}
@PostMapping("/orders")
@PreAuthorize("hasAuthority('order:write')")
public Order createOrder(@RequestBody Order order) {
return orderService.create(order);
}
@DeleteMapping("/orders/{id}")
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(@PathVariable Long id) {
orderService.delete(id);
}
}
---
客户端接入实战
1. Spring Boot 客户端
@Configuration
public class ClientConfig {
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(
ClientRegistration.withRegistrationId("web-client")
.clientId("web-client")
.clientSecret("secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://localhost:8080/login/oauth2/code/web-client")
.scope("read", "write", "openid")
.authorizationUri("http://localhost:9000/oauth2/authorize")
.tokenUri("http://localhost:9000/oauth2/token")
.userInfoUri("http://localhost:9000/userinfo")
.userNameAttributeName("sub")
.clientName("Web Client")
.build()
);
}
}
// application.yml
spring:
security:
oauth2:
client:
registration:
web-client:
client-id: web-client
client-secret: secret
scope: read,write,openid
2. 前端 Vue3 接入
// auth.js
const AUTH_URL = 'http://localhost:9000/oauth2/authorize';
const CLIENT_ID = 'web-client';
const REDIRECT_URI = 'http://localhost:5173/callback';
function generatePKCE() {
const codeVerifier = btoa(Math.random().toString()).substring(0, 43);
const codeChallenge = btoa(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { codeVerifier, codeChallenge };
}
export function login() {
const { codeVerifier, codeChallenge } = generatePKCE();
localStorage.setItem('code_verifier', codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'read write openid',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = ${AUTH_URL}?${params};
}
---
高级特性
1. Refresh Token 自动续期
@Service
public class TokenRefreshService {
public TokenResponse refreshToken(String refreshToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBasicAuth("web-client", "secret");
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "refresh_token");
body.add("refresh_token", refreshToken);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
return restTemplate.postForObject(
"http://localhost:9000/oauth2/token",
request,
TokenResponse.class
);
}
}
2. SSO 单点登录
@Configuration
public class SsoConfig {
@Bean
public SecurityFilterChain ssoFilterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserService())
)
);
return http.build();
}
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService() {
DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
return request -> {
OAuth2User user = delegate.loadUser(request);
// 同步用户到本地数据库
syncUserToLocal(user);
return user;
};
}
}
3. 令牌自省(Token Introspection)
@RestController
public class TokenInfoController {
@PostMapping("/oauth2/introspect")
public Map<String, Object> introspect(@RequestParam String token) {
// 验证令牌有效性,返回令牌信息
boolean active = tokenProvider.validateToken(token);
if (!active) {
return Map.of("active", false);
}
Claims claims = tokenProvider.parseToken(token);
return Map.of(
"active", true,
"sub", claims.getSubject(),
"exp", claims.getExpiration(),
"scope", claims.get("scope", String.class)
);
}
}
---
总结
OAuth2.1 使用 checklist
授权服务器:
- [ ] 使用 Authorization Code + PKCE 流程
- [ ] 配置 JWT 签名密钥(RSA 非对称加密)
- [ ] 设置合理的令牌过期时间(access_token 30min,refresh_token 7d)
- [ ] 启用用户授权确认页面
资源服务器:
- [ ] 验证 JWT 签名和过期时间
- [ ] 提取权限声明到 Spring Security Context
- [ ] 使用方法级注解控制接口权限
客户端:
- [ ] 安全存储 client_secret(服务端)
- [ ] 使用 PKCE 防止授权码截获
- [ ] 实现 refresh token 自动续期
一句话总结
**OAuth2.1 = Authorization Code + PKCE + JWT,授权服务器发证,资源服务器验票,客户端安全保管密钥。**
---
更多硬核技术文章每周更新。
配图1:OAuth2.1 Authorization Code + PKCE 流程

配图2:JWT 令牌结构与验证流程
— 作者:洛水石 | 认证授
权 | OAuth2.1实战 —
更多推荐


所有评论(0)