OAuth2 与 JWT

Spring Boot 3.x 通过 Spring Security + spring-boot-starter-oauth2-resource-serverspring-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.com

Security 配置

@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, email

Security 配置(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管理


相关链接