4

I noticed that my ResponseEntityExceptionHandler does not work with exceptions thrown by Spring Security in my Spring Boot application.

However, I need a way to catch InvalidGrantException which seems to get thrown when a user account is still disabled.

The use case is simple: If a user is currently disabled, I want to throw an appropriate error to my client s.t. it can display a message accordingly.

Right now the response by Spring Security is

{
  error: "invalid_grant", 
  error_description: "User is disabled"
}

I saw this question but for some reason my AuthFailureHandler is not getting invoked:

@Configuration
@EnableResourceServer
public class ResourceServer extends ResourceServerConfigurerAdapter {

    @Component
    public class AuthFailureHandler implements AuthenticationEntryPoint {

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

            // Never reached ..
            System.out.println("Hello World!");
        }
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()                        
            .authenticationEntryPoint(customAuthEntryPoint());;
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.authenticationEntryPoint(customAuthEntryPoint());
    }

    @Bean
    public AuthenticationEntryPoint customAuthEntryPoint(){
        return new AuthFailureHandler();
    }

}

Any idea what I am missing?


Configuration Code

Here is my OAuth2Configuration which also contains a ResourceServerConfiguraerAdapter which is supposed to handle the exception

@Configuration
@EnableAuthorizationServer
@EnableResourceServer
public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter {

    private final TokenStore tokenStore;

    private final JwtAccessTokenConverter accessTokenConverter;

    private final AuthenticationManager authenticationManager;

    @Autowired
    public OAuth2Configuration(TokenStore tokenStore, JwtAccessTokenConverter accessTokenConverter, AuthenticationManager authenticationManager) {
        this.tokenStore = tokenStore;
        this.accessTokenConverter = accessTokenConverter;
        this.authenticationManager = authenticationManager;
    }

    @Value("${security.jwt.client-id}")
    private String clientId;

    @Value("${security.jwt.client-secret}")
    private String clientSecret;

    @Value("${security.jwt.scope-read}")
    private String scopeRead;

    @Value("${security.jwt.scope-write}")
    private String scopeWrite;

    @Value("${security.jwt.resource-ids}")
    private String resourceIds;

    private final static String WEBHOOK_ENDPOINTS = "/r/api/*/webhooks/**";

    private final static String PUBLIC_ENDPOINTS = "/r/api/*/public/**";

    @Override
    public void configure(ClientDetailsServiceConfigurer configurer) throws Exception {
        configurer
                .inMemory()
                .withClient(clientId)
                .secret(clientSecret)
                .scopes(scopeRead, scopeWrite)
                .resourceIds(resourceIds)
                .accessTokenValiditySeconds(60*60*24*7 * 2) // Access tokens last two weeks
                .refreshTokenValiditySeconds(60*60*24*7 * 12) // Refresh tokens last 12 weeks
                //.accessTokenValiditySeconds(5)
                //.refreshTokenValiditySeconds(10)
                .authorizedGrantTypes("password", "refresh_token"); //, "client_credentials");
    }


    /**
     * Since there are currently multiply clients, we map the OAuth2 endpoints under /api
     * to avoid conflicts with @{@link ForwardController}
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        enhancerChain.setTokenEnhancers(Collections.singletonList(accessTokenConverter));

        endpoints.tokenStore(tokenStore)

                // Create path mappings to avoid conflicts with forwarding controller

                .pathMapping("/oauth/authorize", "/api/v1/oauth/authorize")
                .pathMapping("/oauth/check_token", "/api/v1/oauth/check_token")
                .pathMapping("/oauth/confirm_access", "/api/v1/oauth/confirm_access")
                .pathMapping("/oauth/error", "/api/v1/oauth/error")
                .pathMapping("/oauth/token", "/api/v1/oauth/token")

                .accessTokenConverter(accessTokenConverter)
                .tokenEnhancer(enhancerChain)
                .reuseRefreshTokens(false)
                .authenticationManager(authenticationManager);
    }

    /**
     * Forwarding controller.
     *
     * This controller manages forwarding in particular for the static web clients. Since there are multiple
     * clients, this controller will map <i>any</i> GET request to the root /* to one of the clients.
     *
     * If no match was found, the default redirect goes to /web/index.html
     *
     */
    @Controller
    public class ForwardController {

        @RequestMapping(value = "/sitemap.xml", method = RequestMethod.GET)
        public String redirectSitemapXml(HttpServletRequest request) {
            return "forward:/a/web/assets/sitemap.xml";
        }

        @RequestMapping(value = "/robots.txt", method = RequestMethod.GET)
        public String redirectRobotTxt(HttpServletRequest request) {
            return "forward:/a/web/assets/robots.txt";
        }

        @RequestMapping(value = "/*", method = RequestMethod.GET)
        public String redirectRoot(HttpServletRequest request) {
            return "forward:/a/web/index.html";
        }

        @RequestMapping(value = "/a/**/{path:[^.]*}", method = RequestMethod.GET)
        public String redirectClients(HttpServletRequest request) {

            String requestURI = request.getRequestURI();

            if (requestURI.startsWith("/a/admin/")) {
                return "forward:/a/admin/index.html";
            }

            if (requestURI.startsWith("/a/swagger/")) {
                return "forward:/a/swagger/swagger-ui.html#/";
            }

            return "forward:/a/web/index.html";
        }

    }

    @Configuration
    @EnableResourceServer
    public class ResourceServer extends ResourceServerConfigurerAdapter {

        @Override
        public void configure(HttpSecurity http) throws Exception {

            // @formatter:off
            http
                    .requiresChannel()
                        /* Require HTTPS evereywhere*/
                        .antMatchers("/**")
                            .requiresSecure()
                    .and()
                        .exceptionHandling()
                    .and()
                        /* Permit all requests towards the public api as well as webhook endpoints. */
                        .authorizeRequests()
                            .antMatchers(PUBLIC_ENDPOINTS, WEBHOOK_ENDPOINTS)
                            .permitAll()
                        /* Required for ForwardController */
                        .antMatchers(HttpMethod.GET, "/*")
                            .permitAll()
                        .antMatchers("/r/api/**")
                            .authenticated();
            // @formatter:on
        }

        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            resources
                    .resourceId(resourceIds)
                    .authenticationEntryPoint(customAuthEntryPoint());
        }

        @Component
        public class AuthFailureHandler implements AuthenticationEntryPoint {

            @Override
            public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                System.out.println("Hello");
                // FIXME We need to return HTTP 401 (s.t. the client knows what's going on) but for some reason it's not working as intended!
                throw authException;
            }
        }

        @Bean
        public AuthenticationEntryPoint customAuthEntryPoint(){
            return new AuthFailureHandler();
        }

    }

}

In addition, here is the WebSecurityConfigurerAdapter although I do not think this plays a role here:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${security.signing-key}")
    private String signingKey;

    private final UserDetailsService userDetailsService;

    private final DataSource dataSource;

    @Autowired
    public WebSecurityConfig(@Qualifier("appUserDetailsService") UserDetailsService userDetailsService, DataSource dataSource) {
        this.userDetailsService = userDetailsService;
        this.dataSource = dataSource;
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {

    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(signingKey);
        return converter;
    }

    /**
     * Using {@link JwtTokenStore} for JWT access tokens.
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource);
    }

    /**
     * Provide {@link DefaultTokenServices} using the {@link JwtTokenStore}.
     * @return
     */
    @Bean
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        defaultTokenServices.setSupportRefreshToken(true);
        return defaultTokenServices;
    }

    /**
     * We provide the AuthenticationManagerBuilder using our {@link UserDetailsService} and the {@link BCryptPasswordEncoder}.
     * @param auth
     * @throws Exception
     */
    @Autowired
    public void configureGlobalSecurity(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(this.userDetailsService)
                .passwordEncoder(passwordEncoder())
                .and()
                .authenticationProvider(daoAuthenticationProvider());
    }

    /**
     * Using {@link BCryptPasswordEncoder} for user-password encryption.
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * Provide {@link DaoAuthenticationProvider} for password encoding and set the {@link UserDetailsService}.
     * @return
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        daoAuthenticationProvider.setUserDetailsService(this.userDetailsService);
        return daoAuthenticationProvider;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requiresChannel()
                .requestMatchers(r -> r.getHeader("X-Forwarded-Proto") != null)
                .requiresSecure();
    }   

}
Stefan Falk
  • 23,898
  • 50
  • 191
  • 378
  • See if this helps? https://github.com/t-less/sampleRegistration/blob/058da2da86c0a49f85d15c428f2e5b5633978ec8/src/main/java/com/khov/sampleRegistration/login/CustomAuthenticationFailureHandler.java – Tarun Lalwani Jul 13 '19 at 15:43
  • could you please share a repo with the base code so that I can reproduce it? because I am not able to reproduce it. – Jonathan JOhx Jul 14 '19 at 02:54
  • @JonathanJohx I have added the relevant code to my question. This would be my entire OAuth2 and WebSecurity configuration. – Stefan Falk Jul 14 '19 at 05:22

2 Answers2

0

Create a custom class that extends ResponseEntityExceptionHandler, annotate it with @ControllerAdvice and handle OAuth2Exception (base class of InvalidGrantException).

@ExceptionHandler({OAuth2Exception.class})
public ResponseEntity<Object> handleOAuth2Exception(OAuth2Exception exception, WebRequest request) {
    LOGGER.debug("OAuth failed on request processing", exception);
    return this.handleExceptionInternal(exception, ErrorOutputDto.create(exception.getOAuth2ErrorCode(), exception.getMessage()), new HttpHeaders(), HttpStatus.valueOf(exception.getHttpErrorCode()), request);
}

Use HandlerExceptionResolverComposite to composite all exception resolvers in the system into one exception resolver. This overrides the corresponding bean defined in WebMvcConfigurationSupport class. Add list of exceptionResolvers, like DefaultErrorAttributes and ExceptionHandlerExceptionResolver (this enables AOP based exceptions handling by involving classes with @ControllerAdvice annotation such as custom class that we created that extends ResponseEntityExceptionHandler.

<bean id="handlerExceptionResolver" class="org.springframework.web.servlet.handler.HandlerExceptionResolverComposite">
<property name="exceptionResolvers">
    <list>
        <bean class="org.springframework.boot.web.servlet.error.DefaultErrorAttributes"/>

        <bean class="org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver">
            <property name="messageConverters">
                <list>
                    <ref bean="jackson2HttpMessageConverter" />
                </list>
            </property>
        </bean>
    </list>
</property>

Vijay Nandwana
  • 2,476
  • 4
  • 25
  • 42
0

Have you tried if defining a @ControllerAdvice specifying your InvalidGrantException works?

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler(InvalidGrantException.class)
    public ResponseEntity<CustomErrorMessageTO> handleInvalidGrant(
            InvalidGrantException invalidGrantException) {

        CustomErrorMessageTO customErrorMessageTO = new CustomErrorMessageTO("Not granted or whatsoever");

        return new ResponseEntity<>(customErrorMessageTO, HttpStatus.UNAUTHORIZED);
    }
}

class CustomErrorMessageTO {

    private String message;

    public CustomErrorMessageTO(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}
ValerioMC
  • 2,926
  • 13
  • 24