You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

CustomBearerTokenAuthenticationEntryPoint在无效Audience声明时未触发

JWT声明验证失败时自定义AuthenticationEntryPoint不触发的问题

我在处理JWT验证器中声明(Claim)不符合预期的异常场景,已经配置了Spring Security安全配置、自定义JWT声明验证器CustomJwtClaimValidator和自定义BearerTokenAuthenticationEntryPoint。现在发现:

  • 请求不带Token时,自定义入口点的commence方法能正常调用;
  • 携带包含无效Audience等声明的Token时,该方法从未被触发。

相关代码

安全配置代码

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);


    @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
    private String jwkseturi;
    
    @Value("${oauth2.claim.iss}")
    private String iss;
    
    @Value("${oauth2.claim.aud}")
    private String aud;
    
        
    @Bean
    public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
        // @formatter:off

        // ignore plugin requests here. They don't need redirect for login
        http.requestMatcher((request) -> !request.getRequestURI().startsWith("/plugin/api"))
        .oauth2Login(oauth2 -> oauth2
        // Optional: Customize user service if needed, though DefaultOAuth2UserService is used by default
        //.userInfoEndpoint(userInfo -> userInfo.userService(new CustomOAuth2UserService())))
        .userInfoEndpoint().oidcUserService(oidcUserService()))
        //.oauth2Login(Customizer.withDefaults())        
        // allow public access to the home page
        .authorizeHttpRequests(mather -> mather.antMatchers("/","/error","/login/oauth2/code/*").permitAll())
        .authorizeHttpRequests(matcher -> matcher.anyRequest().authenticated())
        .exceptionHandling(customizer -> customizer.accessDeniedPage("/no-access"));

        // @formatter:on
        return http.build();
    }

    
    @Bean
    @ConditionalOnProperty(name = "token.type", havingValue = "jwt")
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        // @formatter:off
           http
           .exceptionHandling(exception->exception.authenticationEntryPoint(new CustomBearerTokenAuthenticationEntryPoint()))
           .authorizeHttpRequests((authorizeRequests) ->
                                authorizeRequests.antMatchers("/plugin/api/**")
                               .authenticated()
                        )
//                        .oauth2ResourceServer(oauth2 -> oauth2
//                                .opaqueToken(Customizer.withDefaults())
//                                );
                        .oauth2ResourceServer((oauth2ResourceServer) ->
                                oauth2ResourceServer
                                        .authenticationEntryPoint(new CustomBearerTokenAuthenticationEntryPoint())
                                        .jwt((jwt) ->
                                                jwt
                                                .decoder(jwtEncryptionDecoder())
                                        )
                        );
           
        // @formatter:on
        return http.build();
    }

    @Bean
    @ConditionalOnProperty(name = "token.type", havingValue = "jwt")
    JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder  = NimbusJwtDecoder.withJwkSetUri(this.jwkseturi).build();
        return jwtDecoder;
    }
    
    @Bean
    JwtDecoder jwtEncryptionDecoder() {

        List<OAuth2TokenValidator<Jwt>> jwtValidators = new ArrayList<OAuth2TokenValidator<Jwt>>();
        Map<String,String> claims = new HashMap<String,String>();
        claims.put("aud", aud);
        claims.put("iss",iss);
        CustomJwtClaimValidator customValidator = new CustomJwtClaimValidator(claims);
        jwtValidators.add(customValidator);
        return new CustomJwtDecoder(jwtValidators);
    }
              
    @Bean
    @ConditionalOnProperty(name = "token.type", havingValue = "opaque")
    public SecurityFilterChain opaqueFilterChain(HttpSecurity http) throws Exception {
        // @formatter:off
           http
           .authorizeHttpRequests((authorizeRequests) ->
                                authorizeRequests.antMatchers("/plugin/api/**")
                               .authenticated()
                        )
           .oauth2ResourceServer(oauth2 -> oauth2
                   .opaqueToken(Customizer.withDefaults())
                               );
            
           
        // @formatter:on
        return http.build();
    }
    
    OidcUserService oidcUserService() {
        log.info("OidcUserService bean");
        OidcUserService userService = new OidcUserService();
        userService.setOauth2UserService(new CustomOAuth2UserService(jwtDecoder()));
        return userService;
    }    
    
}

自定义验证器代码

public class CustomJwtClaimValidator implements OAuth2TokenValidator<Jwt> {
    private final Map<String,String> claim2value;
    private static final Logger log = LoggerFactory.getLogger(CustomJwtDecoder.class);

    class BreakException extends RuntimeException {};

    public CustomJwtClaimValidator(Map<String,String> claim2value) {
        this.claim2value = claim2value;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        
        AtomicBoolean result = new AtomicBoolean(true);
        var Wrapper = new Object() { OAuth2Error error = null;};
        try {
        claim2value.forEach((key,value) -> {
        log.info(" claim key: " + key + "  value: " + jwt.getAudience().get(0));    
        if (jwt.hasClaim(key) && (key.equals("aud") ? jwt.getAudience().get(0):jwt.getClaimAsString(key)).equals(value)) {
            //
        } else {
            
            if (key.equals("aud"))
            {
              Wrapper.error = new OAuth2Error(OAuth2ErrorCodes.ACCESS_DENIED,
                    "Audience is not among the configured ones", null);
            }else
            {
                Wrapper.error = new OAuth2Error(OAuth2ErrorCodes.ACCESS_DENIED,
                        "The '" + key + "' claim is missing or does not match the expected value.", null);  
            }
            result.set(false);
            throw new BreakException();
            
        }});
        }catch(BreakException e) {
                        
        }
        
        return  (result.get() ? OAuth2TokenValidatorResult.success():OAuth2TokenValidatorResult.failure(Wrapper.error));
    }

}

自定义认证入口点代码

public class CustomBearerTokenAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper = new ObjectMapper();
    private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);

    
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        // Log the exception for debugging purposes
        log.info("+++++++++++++++++++++++++++++++ Bearer Token Authentication Failed: " + authException.getMessage());
        authException.printStackTrace();

        // Default to a generic unauthorized message
        HttpStatus status = HttpStatus.UNAUTHORIZED;
        String errorMessage = "Unauthorized: Authentication required.";
        String errorCode = "unauthorized";

        // Try to be more specific based on the exception type
        if (authException instanceof AuthenticationServiceException) {
            // This is often thrown for internal issues like JwkSetUri unreachable, or
            // for underlying JwtException issues like claims mismatch.
            Throwable cause = authException.getCause().getCause();
            if (cause instanceof JwtException) {
                // Specific handling for JWT related issues
                if (cause instanceof BadJwtException) {
                    errorMessage = "Invalid or malformed JWT token.";
                    errorCode = "invalid_token";
                } else if (cause.getMessage() != null && cause.getMessage().contains("Jwt expired")) {
                    errorMessage = "JWT token has expired.";
                    errorCode = "expired_token";
                } else if (cause.getMessage() != null && cause.getMessage().contains("Invalid issuer")) {
                    errorMessage = "Invalid token issuer.";
                    errorCode = "invalid_issuer";
                } else if (cause.getMessage() != null && cause.getMessage().contains("Audience is not among the configured ones")) {
                    errorMessage = "Invalid token audience.";
                    errorCode = "invalid_audience";
                } else {
                    errorMessage = "Authentication service error: " + cause.getMessage();
                    errorCode = "authentication_service_error";
                }
            } else {
                errorMessage = "Authentication service error: " + authException.getMessage();
                errorCode = "authentication_service_error";
            }
        } else if (authException instanceof org.springframework.security.authentication.InsufficientAuthenticationException) {
             // This can happen if the token is valid but doesn't have required scopes/authorities for the resource
            errorMessage = "Insufficient authentication for the requested resource.";
            errorCode = "insufficient_authentication";
        }
        // You can add more specific exception handling here if needed
        // e.g., for different types of AuthenticationException

        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        Map<String, Object> errorDetails = new HashMap<>();
        errorDetails.put("timestamp", System.currentTimeMillis());
        errorDetails.put("status", status.value());
        errorDetails.put("error", status.getReasonPhrase());
        errorDetails.put("message", errorMessage);
        errorDetails.put("code", errorCode); // Custom error code for client
        errorDetails.put("path", request.getRequestURI());

        // For BearerToken errors, you might also want to include WWW-Authenticate header
        // The default BearerTokenAuthenticationEntryPoint does this.
        // You can extract the BearerTokenError from the authException if it's available.
        // For JwtException, the specific BearerTokenError might not be directly in authException.
        // If you want to replicate the WWW-Authenticate header with error details:
        if (authException.getCause() instanceof JwtException) {
            // Example:
            // BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INVALID_TOKEN, errorMessage);
            // defaultEntryPoint.commence(request, response, new InsufficientAuthenticationException(error.getDescription(), error));
            // This can get complex. Often, just returning JSON is preferred for APIs.
        }

        objectMapper.writeValue(response.getWriter(), errorDetails);
    }
}

问题原因及修复方案

核心问题

  1. 自定义JwtDecoder未正确触发异常:你自定义的CustomJwtDecoder没有继承Spring Security的NimbusJwtDecoder,也没有将验证失败的OAuth2TokenValidatorResult转化为AuthenticationException。默认解码器会在验证失败时抛出JwtValidationException,进而触发AuthenticationEntryPoint,但你的实现只是返回失败结果,没有抛出异常。
  2. 重复配置AuthenticationEntryPoint:在apiFilterChain中同时通过exceptionHandling()和oauth2ResourceServer()设置入口点,可能导致逻辑冲突。

修复步骤

1. 重构JwtDecoder,基于默认实现添加自定义验证器

不要自己实现JwtDecoder,而是在NimbusJwtDecoder基础上添加自定义验证器:

@Bean
@ConditionalOnProperty(name = "token.type", havingValue = "jwt")
JwtDecoder jwtEncryptionDecoder() {
    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(this.jwkseturi).build();
    
    Map<String,String> claims = new HashMap<>();
    claims.put("aud", aud);
    claims.put("iss", iss);
    CustomJwtClaimValidator customValidator = new CustomJwtClaimValidator(claims);
    
    // 组合默认验证器(包含过期、签名等校验)和自定义验证器
    OAuth2TokenValidator<Jwt> validator = OAuth2TokenValidator.composite(
            JwtValidators.createDefaultWithIssuer(iss),
            customValidator
    );
    
    jwtDecoder.setJwtValidator(validator);
    return jwtDecoder;
}

2. 简化SecurityFilterChain配置

只在oauth2ResourceServer下配置一次AuthenticationEntryPoint即可:

@Bean
@ConditionalOnProperty(name = "token.type", havingValue = "jwt")
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorizeRequests ->
            authorizeRequests.antMatchers("/plugin/api/**").authenticated()
        )
        .oauth2ResourceServer(oauth2ResourceServer ->
            oauth2ResourceServer
                .authenticationEntryPoint(new CustomBearerTokenAuthenticationEntryPoint())
                .jwt(jwt -> jwt.decoder(jwtEncryptionDecoder()))
        );
    return http.build();
}

3. 优化自定义验证器的错误返回

将错误码改为INVALID_TOKEN(符合RFC规范),便于后续异常识别:

@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
    List<OAuth2Error> errors = new ArrayList<>();
    
    // 校验Audience
    if (!jwt.getAudience().contains(aud)) {
        errors.add(new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN,
            "Audience is not among the configured ones", null));
    }
    
    // 校验Issuer
    if (!iss.equals(jwt.getIssuer())) {
        errors.add(new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN,
            "Issuer does not match the expected value", null));
    }
    
    return errors.isEmpty() ? OAuth2TokenValidatorResult.success() : OAuth2TokenValidatorResult.failure(errors);
}

4. 调整自定义入口点的异常处理逻辑

直接处理JwtValidationException,简化异常判断:

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
                     AuthenticationException authException) throws IOException, ServletException {

    log.info("Bearer Token Authentication Failed: " + authException.getMessage());
    
    HttpStatus status = HttpStatus.UNAUTHORIZED;
    String errorMessage = "Unauthorized: Authentication required.";
    String errorCode = "unauthorized";

    // 处理JWT验证异常
    if (authException.getCause() instanceof JwtValidationException) {
        JwtValidationException jwtException = (JwtValidationException) authException.getCause();
        OAuth2Error error = jwtException.getErrors().get(0);
        errorMessage = error.getDescription();
        errorCode = error.getErrorCode();
    } 
    // 处理令牌格式/缺失异常
    else if (authException instanceof BearerTokenAuthenticationTokenException) {
        BearerTokenError tokenError = ((BearerTokenAuthenticationTokenException) authException).getBearerTokenError();
        errorMessage = tokenError.getDescription();
        errorCode = tokenError.getErrorCode();
    }
    // 处理权限不足
    else if (authException instanceof InsufficientAuthenticationException) {
        errorMessage = "Insufficient authentication for the requested resource.";
        errorCode = "insufficient_authentication";
    }

    response.setStatus(status.value());
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setCharacterEncoding("UTF-8");

    Map<String, Object> errorDetails = new HashMap<>();
    errorDetails.put("timestamp", System.currentTimeMillis());
    errorDetails.put("status", status.value());
    errorDetails.put("error", status.getReasonPhrase());
    errorDetails.put("message", errorMessage);
    errorDetails.put("code", errorCode);
    errorDetails.put("path", request.getRequestURI());

    objectMapper.writeValue(response.getWriter(), errorDetails);
}

内容的提问来源于stack exchange,提问作者sarang73

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.12 15:55:58