OAuth2 与 JWT
Spring Boot 3.x 通过 Spring Security + spring-boot-starter-oauth2-resource-server 和 spring-boot-starter-oauth2-client 提供完整的 OAuth2 / JWT 支持。本文覆盖资源服务器(JWT 校验)、OAuth2 登录(第三方授权)和自定义 JWT 签发三个核心场景。
核心概念速览
OAuth2 角色
├── Resource Owner — 用户(数据拥有者)
├── Client — 应用(请求访问数据)
├── Authorization Server — 授权服务器(颁发 Token)
└── Resource Server — 资源服务器(校验 Token,保护 API)
常用授权模式
├── Authorization Code(授权码)— Web 应用登录,最安全
├── Client Credentials — 服务间调用,无用户参与
└── Refresh Token — 静默刷新访问令牌
一、资源服务器(JWT 校验)
最常见场景:前端或第三方持有 JWT,后端校验并保护 API。
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>配置(使用授权服务器公钥自动验签)
spring:
security:
oauth2:
resourceserver:
jwt:
# 方式 1:从授权服务器 JWKS 端点自动获取公钥
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
# 方式 2:本地公钥文件(自签发场景)
# public-key-location: classpath:keys/public.pem
issuer-uri: https://auth.example.comSecurity 配置
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启方法级注解 @PreAuthorize
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // REST API 无需 CSRF
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())));
return http.build();
}
// 将 JWT Claims 映射为 Spring Security 权限
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter authConverter = new JwtGrantedAuthoritiesConverter();
authConverter.setAuthorityPrefix("ROLE_"); // 角色前缀
authConverter.setAuthoritiesClaimName("roles"); // JWT 中的角色字段
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authConverter);
// 从 JWT sub 字段提取用户名
converter.setPrincipalClaimName("sub");
return converter;
}
}方法级权限控制
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
@GetMapping
@PreAuthorize("hasRole('USER')")
public List<OrderDto> listMyOrders(Authentication auth) {
String userId = auth.getName(); // JWT sub
return orderService.findByUser(userId);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @orderSecurityService.isOwner(#id, authentication)")
public void deleteOrder(@PathVariable Long id) {
orderService.delete(id);
}
@GetMapping("/stats")
@PreAuthorize("hasAuthority('order:stats:read')")
public StatsDto getStats() {
return orderService.getStats();
}
}
@Service
public class OrderSecurityService {
private final OrderRepository orderRepo;
public boolean isOwner(Long orderId, Authentication auth) {
return orderRepo.findById(orderId)
.map(o -> o.getUserId().toString().equals(auth.getName()))
.orElse(false);
}
}二、自定义 JWT 签发(无独立授权服务器)
适合自建认证服务的场景(不依赖 Keycloak / Auth0 等)。
依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>JWT 工具类
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}") // 至少 256-bit 的随机字符串
private String secretKey;
@Value("${app.jwt.access-token-ttl:900}") // 15 分钟
private long accessTokenTtlSeconds;
@Value("${app.jwt.refresh-token-ttl:604800}") // 7 天
private long refreshTokenTtlSeconds;
private SecretKey signingKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
public String generateAccessToken(UserDetails user) {
return Jwts.builder()
.subject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenTtlSeconds * 1000))
.signWith(signingKey())
.compact();
}
public String generateRefreshToken(String username) {
return Jwts.builder()
.subject(username)
.claim("type", "refresh")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + refreshTokenTtlSeconds * 1000))
.signWith(signingKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(signingKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public String extractUsername(String token) {
return parseToken(token).getSubject();
}
public boolean isTokenValid(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}JWT 过滤器
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = header.substring(7);
if (!tokenProvider.isTokenValid(token)) {
chain.doFilter(request, response);
return;
}
String username = tokenProvider.extractUsername(token);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
// 放行不需要 Token 的路径,避免无效解析
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return path.startsWith("/api/auth/") || path.startsWith("/actuator/");
}
}认证接口
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final JwtTokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest req) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
UserDetails user = (UserDetails) auth.getPrincipal();
String accessToken = tokenProvider.generateAccessToken(user);
String refreshToken = refreshTokenService.create(user.getUsername());
return new TokenResponse(accessToken, refreshToken,
"Bearer", 900L);
}
@PostMapping("/refresh")
public TokenResponse refresh(@RequestBody RefreshRequest req) {
String username = refreshTokenService.validate(req.refreshToken());
UserDetails user = userDetailsService.loadUserByUsername(username);
String newAccessToken = tokenProvider.generateAccessToken(user);
return new TokenResponse(newAccessToken, req.refreshToken(), "Bearer", 900L);
}
@PostMapping("/logout")
public void logout(@RequestBody RefreshRequest req) {
refreshTokenService.revoke(req.refreshToken());
}
}
public record TokenResponse(
String accessToken,
String refreshToken,
String tokenType,
Long expiresIn
) { }Refresh Token 服务(Redis 存储)
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final StringRedisTemplate redis;
private static final Duration TTL = Duration.ofDays(7);
public String create(String username) {
String token = UUID.randomUUID().toString();
// Key: refresh_token:{token} Value: username
redis.opsForValue().set("refresh_token:" + token, username, TTL);
return token;
}
public String validate(String token) {
String username = redis.opsForValue().get("refresh_token:" + token);
if (username == null) {
throw new BusinessException("Refresh Token 已过期或不存在");
}
return username;
}
public void revoke(String token) {
redis.delete("refresh_token:" + token);
}
}三、OAuth2 社交登录(第三方授权码模式)
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>配置
spring:
security:
oauth2:
client:
registration:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: user:email, read:user
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: profile, emailSecurity 配置(OAuth2 登录 + JWT 签发)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService;
private final JwtTokenProvider tokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login**", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(ui -> ui.userService(oAuth2UserService))
.successHandler(oAuth2SuccessHandler()));
return http.build();
}
@Bean
public AuthenticationSuccessHandler oAuth2SuccessHandler() {
return (request, response, authentication) -> {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
// 查找或创建本地用户
String email = oAuth2User.getAttribute("email");
UserDetails user = userService.findOrCreateByEmail(email, oAuth2User);
// 签发本地 JWT
String token = tokenProvider.generateAccessToken(user);
// 重定向到前端,携带 Token
response.sendRedirect("https://app.example.com/callback?token=" + token);
};
}
}
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepo;
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(request);
String provider = request.getClientRegistration().getRegistrationId(); // "github"/"google"
String providerId = oAuth2User.getName();
String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name");
// 查找或创建用户
userRepo.findByProviderAndProviderId(provider, providerId)
.orElseGet(() -> userRepo.save(User.ofOAuth2(provider, providerId, email, name)));
return oAuth2User;
}
}四、服务间调用(Client Credentials)
spring:
security:
oauth2:
client:
registration:
order-service:
client-id: order-service
client-secret: ${ORDER_SERVICE_SECRET}
authorization-grant-type: client_credentials
scope: inventory:read
provider:
order-service:
token-uri: https://auth.example.com/oauth2/token@Configuration
public class WebClientConfig {
@Bean
public WebClient inventoryWebClient(
OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2.setDefaultClientRegistrationId("order-service");
return WebClient.builder()
.baseUrl("https://inventory-service.internal")
.apply(oauth2.oauth2Configuration())
.build();
}
}
// 调用时自动附加 Bearer Token
@Service
@RequiredArgsConstructor
public class InventoryClient {
private final WebClient inventoryWebClient;
public StockDto checkStock(String sku) {
return inventoryWebClient.get()
.uri("/api/stocks/{sku}", sku)
.retrieve()
.bodyToMono(StockDto.class)
.block();
}
}五、Token 安全最佳实践
| 实践 | 说明 |
|---|---|
| Access Token 短有效期 | 15 分钟内,减少泄露危害 |
| Refresh Token 存 Redis | 支持主动吊销(logout/撤权) |
| HTTPS Only | 禁止明文传输 Token |
| 不在 URL 中传 Token | 防止日志泄露;用 Authorization Header |
| JWT 不存敏感数据 | payload 仅 Base64 编码,非加密 |
| 签名密钥不硬编码 | 使用环境变量或 Vault 注入 |
| 使用 RS256 签名 | 授权服务器私钥签名,资源服务器公钥验签,密钥不共享 |
| Token 轮换(Rotation) | 每次使用 Refresh Token 后颁发新的,旧的立即失效 |
密码加密详见 密码加密;Session 管理详见 Session管理。