OAuth2.0协议原理与Spring Security完整实现

最近在开发一个需要第三方登录的Spring Boot项目,需要实现类似“使用GitHub登录”、“使用微信登录”的功能。OAuth2.0作为目前最流行的授权框架,完美解决了第三方授权的问题。本文将从协议规范到代码实现,详细剖析OAuth2.0的工作原理。

OAuth2.0协议规定

OAuth2.0是一个关于授权的开放标准,允许用户授权第三方应用访问其在服务提供商上存储的特定资源,而无需将用户名和密码提供给第三方应用。

核心角色

OAuth2.0定义了四个核心角色:

  • Resource Owner(资源所有者):通常是用户,拥有受保护资源的实体
  • Client(客户端):需要访问用户资源的第三方应用
  • Resource Server(资源服务器):存储受保护资源的服务器
  • Authorization Server(授权服务器):验证用户身份并颁发访问令牌的服务器

授权模式

OAuth2.0定义了四种授权模式(Grant Type):

授权码模式(Authorization Code)

最完整、最安全的授权模式,适用于有后端的Web应用:

text
     +----------+
     | Resource |
     |   Owner  |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier      +---------------+
     |         -+----A-- & Redirection URI ---->|               |
     |  User-   |                                 | Authorization |
     |  Agent  -+----B-- User authenticates --->|     Server    |
     |          |                                 |               |
     |         -+----C-- Authorization Code ---<|               |
     +-|----|---+                                 +---------------+
       |    |                                         ^      v
      (A)  (C)                                        |      |
       |    |                                         |      |
       ^    v                                         |      |
     +---------+                                      |      |
     |         |>---D-- Authorization Code ---------'      |
     |  Client |          & Redirection URI                  |
     |         |                                             |
     |         |<---E----- Access Token -------------------'
     +---------+       (w/ Optional Refresh Token)

隐式模式(Implicit)

简化模式,适用于纯前端应用,但安全性较低(已不推荐使用):

text
     +----------+
     | Resource |
     |  Owner   |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier     +---------------+
     |         -+----A-- & Redirection URI --->|               |
     |  User-   |                                | Authorization |
     |  Agent  -|----B-- User authenticates -->|     Server    |
     |          |                                |               |
     |          |<---C--- Redirection URI ----<|               |
     |          |          with Access Token     +---------------+
     |          |            in Fragment
     |          |                                +---------------+
     |          |----D--- Redirection URI ---->|   Web-Hosted  |
     |          |          without Fragment      |     Client    |
     |          |                                |    Resource   |
     |     (F)  |<---E------- Script ---------<|               |
     |          |                                +---------------+
     +-|--------+
       |    |
      (A)  (G) Access Token
       |    |
       ^    v
     +---------+
     |         |
     |  Client |
     |         |
     +---------+

密码模式(Resource Owner Password Credentials)

用户直接把用户名密码给客户端,适用于高度信任的应用:

text
     +----------+
     | Resource |
     |  Owner   |
     +----------+
          v
          |    Resource Owner
         (A) Password Credentials
          |
          v
     +---------+                                  +---------------+
     |         |>--B---- Resource Owner ------->|               |
     |         |         Password Credentials     | Authorization |
     | Client  |                                  |     Server    |
     |         |<--C---- Access Token ---------<|               |
     |         |    (w/ Optional Refresh Token)   |               |
     +---------+                                  +---------------+

客户端模式(Client Credentials)

客户端以自己的名义请求访问令牌,适用于没有用户参与的场景:

text
     +---------+                                  +---------------+
     |         |                                  |               |
     |         |>--A- Client Authentication --->| Authorization |
     | Client  |                                  |     Server    |
     |         |<--B---- Access Token ---------<|               |
     |         |                                  |               |
     +---------+                                  +---------------+

Token类型

OAuth2.0定义了两种Token:

  • Access Token(访问令牌):用于访问受保护资源的凭证,有效期较短
  • Refresh Token(刷新令牌):用于获取新的Access Token,有效期较长

Spring Security OAuth2.0服务端实现

引入依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
    <version>1.1.1</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

授权服务器配置

java
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .oidc(Customizer.withDefaults()); // 启用OpenID Connect 1.0

        http
            .exceptionHandling((exceptions) -> exceptions
                .defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                )
            )
            .oauth2ResourceServer((resourceServer) -> resourceServer
                .jwt(Customizer.withDefaults()));

        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(formLogin -> formLogin
                .loginPage("/login")
            );

        return http.build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("web-client")
            .clientSecret("{noop}secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .redirectUri("http://127.0.0.1:8080/login/oauth2/code/web-client")
            .redirectUri("http://127.0.0.1:8080/authorized")
            .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("message.read")
            .scope("message.write")
            .clientSettings(ClientSettings.builder()
                .requireAuthorizationConsent(true)
                .requireProofKey(false)
                .build())
            .tokenSettings(TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(5))
                .refreshTokenTimeToLive(Duration.ofMinutes(60))
                .reuseRefreshTokens(false)
                .build())
            .build();

        RegisteredClient mobileClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("mobile-client")
            .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("com.example.app://authorized")
            .scope("message.read")
            .clientSettings(ClientSettings.builder()
                .requireAuthorizationConsent(false)
                .requireProofKey(true) // 移动端使用PKCE
                .build())
            .build();

        return new InMemoryRegisteredClientRepository(webClient, mobileClient);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

        RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build();

        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

    private static KeyPair generateRsaKey() {
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            return keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("http://localhost:9000")
            .build();
    }
}

自定义用户认证

java
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));

        return org.springframework.security.core.userdetails.User.builder()
            .username(user.getUsername())
            .password(user.getPassword())
            .authorities(user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
                .collect(Collectors.toList()))
            .build();
    }
}

@Component
public class CustomOAuth2TokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

    @Override
    public void customize(JwtEncodingContext context) {
        if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
            // 自定义ID Token
            Authentication principal = context.getPrincipal();
            Set<String> authorities = principal.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toSet());

            context.getClaims().claim("authorities", authorities);
            context.getClaims().claim("user_id", getUserId(principal));
        }

        if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
            // 自定义Access Token
            Authentication principal = context.getPrincipal();
            Set<String> scopes = context.getRegisteredClient().getScopes();

            Set<String> authorizedScopes = principal.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .filter(authority -> authority.startsWith("SCOPE_"))
                .map(authority -> authority.substring(6))
                .filter(scopes::contains)
                .collect(Collectors.toSet());

            context.getClaims().claim("scopes", authorizedScopes);
        }
    }

    private Long getUserId(Authentication authentication) {
        // 从认证信息中提取用户ID
        if (authentication.getPrincipal() instanceof CustomUserDetails) {
            return ((CustomUserDetails) authentication.getPrincipal()).getUserId();
        }
        return null;
    }
}

资源服务器配置

java
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").authenticated()
            )
            .oauth2ResourceServer((oauth2) -> oauth2
                .jwt((jwt) -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );

        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_");
        grantedAuthoritiesConverter.setAuthoritiesClaimName("scopes");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri("http://localhost:9000/oauth2/jwks").build();
    }
}

自定义授权端点

java
@Controller
public class AuthorizationConsentController {

    @Autowired
    private RegisteredClientRepository registeredClientRepository;

    @GetMapping("/oauth2/consent")
    public String consent(Principal principal, Model model,
                         @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
                         @RequestParam(OAuth2ParameterNames.SCOPE) String scope,
                         @RequestParam(OAuth2ParameterNames.STATE) String state) {

        RegisteredClient registeredClient = registeredClientRepository.findByClientId(clientId);

        Set<String> scopesToApprove = new HashSet<>();
        Set<String> previouslyApprovedScopes = new HashSet<>();

        Set<String> requestedScopes = new HashSet<>(Arrays.asList(scope.split(" ")));
        Set<String> authorizedScopes = getAuthorizedScopes(principal, registeredClient);

        for (String requestedScope : requestedScopes) {
            if (authorizedScopes.contains(requestedScope)) {
                previouslyApprovedScopes.add(requestedScope);
            } else {
                scopesToApprove.add(requestedScope);
            }
        }

        model.addAttribute("clientId", clientId);
        model.addAttribute("clientName", registeredClient.getClientName());
        model.addAttribute("state", state);
        model.addAttribute("scopes", scopesToApprove);
        model.addAttribute("previouslyApprovedScopes", previouslyApprovedScopes);
        model.addAttribute("principalName", principal.getName());

        return "consent";
    }

    private Set<String> getAuthorizedScopes(Principal principal, RegisteredClient registeredClient) {
        // 查询用户已授权的范围
        // 实际应该从数据库查询
        return new HashSet<>();
    }
}

OAuth2.0客户端实现

客户端配置

java
@Configuration
@EnableWebSecurity
public class OAuth2ClientConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/", "/error", "/webjars/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2Login -> oauth2Login
                .loginPage("/oauth2/authorization/web-client")
                .successHandler(oAuth2AuthenticationSuccessHandler())
                .failureHandler(oAuth2AuthenticationFailureHandler())
            )
            .oauth2Client(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
        return new OAuth2AuthenticationSuccessHandler();
    }

    @Bean
    public OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() {
        return new OAuth2AuthenticationFailureHandler();
    }
}

// 自定义成功处理器
public class OAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    @Autowired
    private UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                      HttpServletResponse response,
                                      Authentication authentication) throws IOException, ServletException {

        OAuth2AuthenticationToken oAuth2Token = (OAuth2AuthenticationToken) authentication;
        OAuth2User oAuth2User = oAuth2Token.getPrincipal();

        // 获取用户信息
        String email = oAuth2User.getAttribute("email");
        String name = oAuth2User.getAttribute("name");
        String picture = oAuth2User.getAttribute("picture");

        // 创建或更新本地用户
        User localUser = userService.findOrCreateUser(email, name, picture);

        // 更新认证信息
        CustomOAuth2User customOAuth2User = new CustomOAuth2User(
            oAuth2User.getAuthorities(),
            oAuth2User.getAttributes(),
            "name",
            localUser.getId()
        );

        OAuth2AuthenticationToken newAuth = new OAuth2AuthenticationToken(
            customOAuth2User,
            customOAuth2User.getAuthorities(),
            oAuth2Token.getAuthorizedClientRegistrationId()
        );

        SecurityContextHolder.getContext().setAuthentication(newAuth);

        super.onAuthenticationSuccess(request, response, authentication);
    }
}

客户端配置文件

yaml
spring:
  security:
    oauth2:
      client:
        registration:
          web-client:
            client-id: web-client
            client-secret: secret
            scope: openid, profile, message.read, message.write
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            client-name: Web Client

          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: read:user, user:email

          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email

        provider:
          web-client:
            authorization-uri: http://localhost:9000/oauth2/authorize
            token-uri: http://localhost:9000/oauth2/token
            jwk-set-uri: http://localhost:9000/oauth2/jwks
            user-info-uri: http://localhost:9000/userinfo
            user-name-attribute: sub

使用RestTemplate访问受保护资源

java
@Service
public class OAuth2ResourceService {

    @Autowired
    private OAuth2AuthorizedClientService authorizedClientService;

    @Autowired
    private RestTemplateBuilder restTemplateBuilder;

    public String getProtectedResource(OAuth2AuthenticationToken authentication) {
        // 获取访问令牌
        OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient(
            authentication.getAuthorizedClientRegistrationId(),
            authentication.getName()
        );

        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();

        // 使用访问令牌调用API
        RestTemplate restTemplate = restTemplateBuilder.build();

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken.getTokenValue());

        HttpEntity<String> entity = new HttpEntity<>(headers);

        ResponseEntity<String> response = restTemplate.exchange(
            "http://localhost:8080/api/messages",
            HttpMethod.GET,
            entity,
            String.class
        );

        return response.getBody();
    }

    // 自动刷新令牌
    public OAuth2AccessToken refreshTokenIfExpired(OAuth2AuthorizedClient authorizedClient) {
        if (isTokenExpired(authorizedClient.getAccessToken())) {
            OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
            if (refreshToken != null) {
                return refreshAccessToken(authorizedClient, refreshToken);
            }
        }
        return authorizedClient.getAccessToken();
    }

    private boolean isTokenExpired(OAuth2AccessToken accessToken) {
        return accessToken.getExpiresAt() != null &&
               Instant.now().isAfter(accessToken.getExpiresAt());
    }

    private OAuth2AccessToken refreshAccessToken(OAuth2AuthorizedClient authorizedClient,
                                                OAuth2RefreshToken refreshToken) {
        ClientRegistration clientRegistration = authorizedClient.getClientRegistration();

        OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest =
            new OAuth2RefreshTokenGrantRequest(
                clientRegistration,
                authorizedClient.getAccessToken(),
                refreshToken
            );

        DefaultRefreshTokenTokenResponseClient tokenResponseClient =
            new DefaultRefreshTokenTokenResponseClient();

        OAuth2AccessTokenResponse tokenResponse =
            tokenResponseClient.getTokenResponse(refreshTokenGrantRequest);

        return tokenResponse.getAccessToken();
    }
}

WebClient集成OAuth2

java
@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);

        oauth2Client.setDefaultClientRegistrationId("web-client");

        return WebClient.builder()
            .baseUrl("http://localhost:8080")
            .filter(oauth2Client)
            .build();
    }

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                .authorizationCode()
                .refreshToken()
                .clientCredentials()
                .password()
                .build();

        DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                clientRegistrationRepository,
                authorizedClientRepository
            );

        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }
}

@Service
public class WebClientService {

    @Autowired
    private WebClient webClient;

    public Mono<String> getResource() {
        return webClient
            .get()
            .uri("/api/messages")
            .attributes(ServerOAuth2AuthorizedClientExchangeFilterFunction
                .clientRegistrationId("web-client"))
            .retrieve()
            .bodyToMono(String.class);
    }

    // 使用不同的客户端
    public Mono<String> getResourceWithDifferentClient(String clientRegistrationId) {
        return webClient
            .get()
            .uri("/api/data")
            .attributes(ServerOAuth2AuthorizedClientExchangeFilterFunction
                .clientRegistrationId(clientRegistrationId))
            .retrieve()
            .bodyToMono(String.class);
    }
}

安全性考虑

PKCE(Proof Key for Code Exchange)

PKCE用于增强公共客户端(如移动应用、SPA)的安全性:

java
@Component
public class PKCEValidator {

    public String generateCodeVerifier() {
        SecureRandom secureRandom = new SecureRandom();
        byte[] codeVerifier = new byte[32];
        secureRandom.nextBytes(codeVerifier);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
    }

    public String generateCodeChallenge(String codeVerifier) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
            return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    public boolean verifyCodeChallenge(String codeVerifier, String codeChallenge,
                                      String codeChallengeMethod) {
        if ("S256".equals(codeChallengeMethod)) {
            String computedChallenge = generateCodeChallenge(codeVerifier);
            return computedChallenge.equals(codeChallenge);
        } else if ("plain".equals(codeChallengeMethod)) {
            return codeVerifier.equals(codeChallenge);
        }
        return false;
    }
}

// 在授权服务器中验证PKCE
@Component
public class PKCEAuthorizationCodeTokenGranter {

    @Autowired
    private PKCEValidator pkceValidator;

    public OAuth2AccessToken grant(String authorizationCode, String codeVerifier) {
        // 从存储中获取之前保存的code_challenge
        AuthorizationCodeDetails codeDetails = getAuthorizationCodeDetails(authorizationCode);

        if (codeDetails.getCodeChallenge() != null) {
            // 验证PKCE
            if (!pkceValidator.verifyCodeChallenge(codeVerifier,
                                                   codeDetails.getCodeChallenge(),
                                                   codeDetails.getCodeChallengeMethod())) {
                throw new OAuth2AuthenticationException("Invalid code_verifier");
            }
        }

        // 继续正常的令牌颁发流程
        return issueAccessToken(codeDetails);
    }
}

防止授权码拦截攻击

java
@Component
public class AuthorizationCodeSecurityEnhancer {

    private final Map<String, AuthorizationCodeMetadata> codeMetadataStore = new ConcurrentHashMap<>();

    public String generateSecureAuthorizationCode(String clientId, String redirectUri) {
        String code = generateRandomCode();

        AuthorizationCodeMetadata metadata = new AuthorizationCodeMetadata();
        metadata.setClientId(clientId);
        metadata.setRedirectUri(redirectUri);
        metadata.setIssuedAt(Instant.now());
        metadata.setExpiresAt(Instant.now().plusSeconds(60)); // 1分钟有效期
        metadata.setUsed(false);

        codeMetadataStore.put(code, metadata);

        // 定时清理过期的授权码
        scheduleCodeCleanup(code, 60);

        return code;
    }

    public void validateAuthorizationCode(String code, String clientId, String redirectUri) {
        AuthorizationCodeMetadata metadata = codeMetadataStore.get(code);

        if (metadata == null) {
            throw new InvalidAuthorizationCodeException("授权码不存在");
        }

        if (metadata.isUsed()) {
            // 授权码已被使用,可能存在攻击,撤销所有相关令牌
            revokeAllTokensForAuthorizationCode(code);
            throw new InvalidAuthorizationCodeException("授权码已被使用");
        }

        if (Instant.now().isAfter(metadata.getExpiresAt())) {
            throw new InvalidAuthorizationCodeException("授权码已过期");
        }

        if (!metadata.getClientId().equals(clientId)) {
            throw new InvalidAuthorizationCodeException("客户端ID不匹配");
        }

        if (!metadata.getRedirectUri().equals(redirectUri)) {
            throw new InvalidAuthorizationCodeException("重定向URI不匹配");
        }

        // 标记为已使用
        metadata.setUsed(true);
    }

    private String generateRandomCode() {
        return UUID.randomUUID().toString();
    }

    private void scheduleCodeCleanup(String code, long delaySeconds) {
        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
        executor.schedule(() -> codeMetadataStore.remove(code), delaySeconds, TimeUnit.SECONDS);
    }

    @Data
    private static class AuthorizationCodeMetadata {
        private String clientId;
        private String redirectUri;
        private Instant issuedAt;
        private Instant expiresAt;
        private boolean used;
    }
}

Token安全存储

java
@Service
public class SecureTokenStore {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String ACCESS_TOKEN_PREFIX = "access_token:";
    private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";

    // 使用加密存储敏感令牌
    public void storeAccessToken(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        String tokenKey = extractTokenKey(accessToken.getValue());
        String encryptedToken = encrypt(accessToken.getValue());

        OAuth2AccessTokenEntity entity = new OAuth2AccessTokenEntity();
        entity.setTokenValue(encryptedToken);
        entity.setTokenType(accessToken.getTokenType());
        entity.setScopes(accessToken.getScopes());
        entity.setExpiresAt(accessToken.getExpiresAt());
        entity.setAuthentication(serializeAuthentication(authentication));

        redisTemplate.opsForValue().set(
            ACCESS_TOKEN_PREFIX + tokenKey,
            JsonUtils.toJson(entity),
            accessToken.getExpiresAt().toEpochMilli() - System.currentTimeMillis(),
            TimeUnit.MILLISECONDS
        );
    }

    public OAuth2AccessToken readAccessToken(String tokenValue) {
        String tokenKey = extractTokenKey(tokenValue);
        String json = redisTemplate.opsForValue().get(ACCESS_TOKEN_PREFIX + tokenKey);

        if (json == null) {
            return null;
        }

        OAuth2AccessTokenEntity entity = JsonUtils.fromJson(json, OAuth2AccessTokenEntity.class);

        // 验证令牌
        String decryptedToken = decrypt(entity.getTokenValue());
        if (!decryptedToken.equals(tokenValue)) {
            throw new InvalidTokenException("令牌验证失败");
        }

        return new OAuth2AccessToken(
            entity.getTokenType(),
            tokenValue,
            entity.getIssuedAt(),
            entity.getExpiresAt(),
            entity.getScopes()
        );
    }

    // Token撤销
    public void revokeToken(String tokenValue) {
        String tokenKey = extractTokenKey(tokenValue);
        redisTemplate.delete(ACCESS_TOKEN_PREFIX + tokenKey);

        // 记录撤销的令牌,防止重放攻击
        recordRevokedToken(tokenValue);
    }

    private void recordRevokedToken(String tokenValue) {
        String tokenKey = extractTokenKey(tokenValue);
        redisTemplate.opsForSet().add("revoked_tokens", tokenKey);
        // 设置过期时间为令牌的原始过期时间
        redisTemplate.expire("revoked_tokens", 24, TimeUnit.HOURS);
    }

    public boolean isTokenRevoked(String tokenValue) {
        String tokenKey = extractTokenKey(tokenValue);
        return redisTemplate.opsForSet().isMember("revoked_tokens", tokenKey);
    }

    private String extractTokenKey(String value) {
        MessageDigest digest;
        try {
            digest = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("MD5算法不可用");
        }

        byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
        return String.format("%032x", new BigInteger(1, bytes));
    }

    private String encrypt(String value) {
        // 实现加密逻辑
        // 这里应该使用AES等对称加密算法
        return Base64.getEncoder().encodeToString(value.getBytes());
    }

    private String decrypt(String encryptedValue) {
        // 实现解密逻辑
        return new String(Base64.getDecoder().decode(encryptedValue));
    }
}

防止重放攻击

java
@Component
public class NonceValidator {

    private final Cache<String, Boolean> nonceCache = CacheBuilder.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();

    public String generateNonce() {
        return UUID.randomUUID().toString();
    }

    public void validateNonce(String nonce) {
        if (StringUtils.isEmpty(nonce)) {
            throw new InvalidNonceException("Nonce不能为空");
        }

        Boolean exists = nonceCache.getIfPresent(nonce);
        if (exists != null) {
            throw new InvalidNonceException("Nonce已被使用");
        }

        nonceCache.put(nonce, true);
    }
}

@RestController
@RequestMapping("/oauth2")
public class OAuth2EndpointController {

    @Autowired
    private NonceValidator nonceValidator;

    @PostMapping("/token")
    public OAuth2AccessToken issueToken(@RequestParam Map<String, String> parameters) {
        // 验证nonce防止重放攻击
        String nonce = parameters.get("nonce");
        nonceValidator.validateNonce(nonce);

        // 验证时间戳
        String timestamp = parameters.get("timestamp");
        validateTimestamp(timestamp);

        // 继续正常的令牌颁发流程
        return processTokenRequest(parameters);
    }

    private void validateTimestamp(String timestamp) {
        if (timestamp == null) {
            throw new InvalidRequestException("缺少时间戳");
        }

        long requestTime = Long.parseLong(timestamp);
        long currentTime = System.currentTimeMillis();

        // 允许5分钟的时间差
        if (Math.abs(currentTime - requestTime) > 5 * 60 * 1000) {
            throw new InvalidRequestException("请求已过期");
        }
    }
}

审计日志

java
@Component
@Slf4j
public class OAuth2AuditLogger {

    @Autowired
    private AuditLogRepository auditLogRepository;

    @EventListener
    public void handleAuthorizationSuccess(AuthorizationSuccessEvent event) {
        AuditLog auditLog = new AuditLog();
        auditLog.setEventType("AUTHORIZATION_SUCCESS");
        auditLog.setClientId(event.getClientId());
        auditLog.setUsername(event.getUsername());
        auditLog.setScopes(String.join(",", event.getScopes()));
        auditLog.setIpAddress(event.getIpAddress());
        auditLog.setTimestamp(Instant.now());

        auditLogRepository.save(auditLog);
        log.info("授权成功: clientId={}, username={}, scopes={}",
                event.getClientId(), event.getUsername(), event.getScopes());
    }

    @EventListener
    public void handleAuthorizationFailure(AuthorizationFailureEvent event) {
        AuditLog auditLog = new AuditLog();
        auditLog.setEventType("AUTHORIZATION_FAILURE");
        auditLog.setClientId(event.getClientId());
        auditLog.setUsername(event.getUsername());
        auditLog.setErrorCode(event.getErrorCode());
        auditLog.setErrorDescription(event.getErrorDescription());
        auditLog.setIpAddress(event.getIpAddress());
        auditLog.setTimestamp(Instant.now());

        auditLogRepository.save(auditLog);
        log.warn("授权失败: clientId={}, username={}, error={}",
                event.getClientId(), event.getUsername(), event.getErrorCode());

        // 检测异常行为
        detectAnomalies(event);
    }

    private void detectAnomalies(AuthorizationFailureEvent event) {
        // 检查短时间内的失败次数
        long recentFailures = auditLogRepository.countRecentFailures(
            event.getClientId(),
            event.getIpAddress(),
            Instant.now().minusSeconds(300) // 5分钟内
        );

        if (recentFailures > 5) {
            // 触发安全警报
            sendSecurityAlert(event);
            // 可以考虑临时封禁IP或客户端
            blockTemporarily(event.getIpAddress());
        }
    }

    private void sendSecurityAlert(AuthorizationFailureEvent event) {
        // 发送安全警报邮件或消息
        log.error("安全警报: 检测到异常授权尝试 - IP: {}, ClientId: {}",
                 event.getIpAddress(), event.getClientId());
    }

    private void blockTemporarily(String ipAddress) {
        // 实现IP临时封禁逻辑
        redisTemplate.opsForValue().set(
            "blocked_ip:" + ipAddress,
            "true",
            15,
            TimeUnit.MINUTES
        );
    }
}
DIY一个Markdown页面访问计数器