DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Nermin Karapandzic
Nermin Karapandzic

Posted on

Spring security - multiple authentication providers

Spring Security's architecture and the documentation can be a bit hard to digest, it took me a lot of time to even start feeling like I understand it, and what I found is that there are many misleading third party copy paste tutorials which don't follow the intended patterns from spring security and can cause you more trouble than good. So my advice is to stick to the official docs. There is a lot, and I feel like a lot of it needs to be updated, since who the f*** uses xml configuration in 2022, but there are also good, well explained parts.

Get yourself familiar about the filters and filter chains part because I won't be talking about that here, but instead I will focus on the authentication part.

Here is a docs link if you want to see detailed explanation of the authentication architecture.

There are 3 main things we should think about

  • AbstractAuthenticationProcessingFilter - abstract class
  • Authentication - Interface
  • AuthenticationProvider - Interface

When the request comes in it goes through a list of filters, we want to place a filter at a certain spot which will be responsible for authenticating the user. When creating this filter we should extend AbstractAuthenticationProcessingFilter. When we do that there is one method we need to implement Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)

What we want to do inside this method is to try and create our specific Authentication implementation and delegate the authentication to the AuthenticationManager.

You might say "I have no clue what you just said" and that's ok, keep reading.

First, AuthenticationManager, here's what the docs say:

AuthenticationManager is the API that defines how Spring Security’s Filters perform authentication. The Authentication that is returned is then set on the SecurityContextHolder by the controller (i.e. Spring Security’s Filterss) that invoked the AuthenticationManager. If you are not integrating with Spring Security’s Filterss you can set the SecurityContextHolder directly and are not required to use an AuthenticationManager.

While the implementation of AuthenticationManager could be anything, the most common implementation is ProviderManager.

As you've read, the Authentication that is returned from the AuthenticationManager.authenticate() is set on the SecurityContextHolder, you should check what this means also on the official documentation as I won't be explaining in details, but in short SecurityContextHolder is where the Authentication (authenticated user) information is stored.

But lets track back a bit, I said that we should create a specific Authentication inside AbstractAuthenticationProcessingFilter.attemptAuthentication(), and why is that?
Well the implementation of the AuthenticationManager which you're likely to use unless you want to loose your hair and implement your own, is a ProviderManager.
This is important because ProviderManager can have multiple AuthenticationProvider's.
When some type of Authentication is sent to ProviderManager it checks if it has a provider which knows how to authenticate that specific type of Authentication.
It does so with the boolean supports(Class<?> authentication) method

If the Authentication is supported then the Authentication AuthenticationProvider.authenticate(Authentication authenticaiton) method will be called.

Here is where we should either return Authentication or throw AuthenticationException

Let's see an example, maybe it will make more sense.
We want to create 2 Authentication implementations.

  • AdminAuthentication - Will authenticate based on jwt token
  • PluginAuthentication - Will authenticate based on plugin id and secret

We should also have 2 different filters handling each Authentication type.

  • AdminAuthenticationFilter
  • PluginAuthenticationFilter

Below you can see the most important part of Spring security's
AbstractAuthenticationProcessingFilter

...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (!this.requiresAuthentication(request, response)) {
            chain.doFilter(request, response);
        } else {
            try {
                Authentication authenticationResult = this.attemptAuthentication(request, response);
                if (authenticationResult == null) {
                    return;
                }

                this.sessionStrategy.onAuthentication(authenticationResult, request, response);
                if (this.continueChainBeforeSuccessfulAuthentication) {
                    chain.doFilter(request, response);
                }

                this.successfulAuthentication(request, response, chain, authenticationResult);
            } catch (InternalAuthenticationServiceException var5) {
                this.logger.error("An internal error occurred while trying to authenticate the user.", var5);
                this.unsuccessfulAuthentication(request, response, var5);
            } catch (AuthenticationException var6) {
                this.unsuccessfulAuthentication(request, response, var6);
            }

        }
    }
    ...
Enter fullscreen mode Exit fullscreen mode

First it checks if the request requires authentication, there is a setter you can use to set requiresAuthenticationRequestMatcher which will be checked against the request.

You can also initialize this when creating an instance of the filter, there are 4 different types of constructor you can choose from, one has defaultFilterProcessesUrl passed as string which will be translated into requiresAuthenticationRequestMatcher as RequestMatcher, and the others also offer you possibility to pass RequestMatcher directly.

Next authenticationResult is checked, if it's null it just returns which will not continue the filterChain, it will go backwards, executing the code after each filters doFilter() method.

Then there's this part with the session, which I'm not interested in since I'm not using sessions, and there's a property you can set which would allow the filterChain to continue before completing authentication, which I don't know why would be useful.

And finally we have a call to successfullAuthentication() and some catch blocks where you can see AuthenticationException is handled, hence we throw that to call AbstractAuthenticationProcessingFilter.unsuccessfulAuthentication()

Here are the default successfulAuthentication and unsuccessfulAuthentication methods of AbstractAuthenticationProcessingFilter

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authResult);
        SecurityContextHolder.setContext(context);
        if (this.logger.isDebugEnabled()) {
            this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
        }

        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }
Enter fullscreen mode Exit fullscreen mode

    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        SecurityContextHolder.clearContext();
        this.logger.trace("Failed to process authentication request", failed);
        this.logger.trace("Cleared SecurityContextHolder");
        this.logger.trace("Handling authentication failure");
        this.rememberMeServices.loginFail(request, response);
        this.failureHandler.onAuthenticationFailure(request, response, failed);
    }
Enter fullscreen mode Exit fullscreen mode

You will later see we override the successfulAuthentication() and we also register our own NoRedirectSuccessHandler.

You can also override unsuccessfulAuthentication, but in my case it's fine, it only sends a 401 response.

If we strip all these details what we're trying to do, is send some authentication implementation from some filter to a custom provider which will then authenticate or not. ProviderManager and the AbstractAuthenticationProcessingFilter take care of most of the work but you can easily configure and override any parts you want.

There's a lot more details but I don't want to copy all the source code of all spring security's classes here.
It's important to understand this architecture and how things are connected, then you can easily debug these classes and see exactly what's happening.

Let's see the complete implementation, without any Spring Security managed code.

AdminAuthenticationFilter:

public class AdminAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public AdminAuthenticationFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        super(defaultFilterProcessesUrl, authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        String token = request.getHeader("Authorization");

        if(token == null || !token.startsWith("Bearer ")){
            throw new AuthenticationServiceException("Token not provided or is invalid");
        }

        return getAuthenticationManager().authenticate(new PawshopeAdminAuthentication(token.substring(7)));
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        super.successfulAuthentication(request, response, chain, authResult);
        chain.doFilter(request, response);
    }
}
Enter fullscreen mode Exit fullscreen mode

PluginAuthenticationFilter

public class PluginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public PluginAuthenticationFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        super(defaultFilterProcessesUrl, authenticationManager);
    }

    protected PluginAuthenticationFilter(String defaultFilterProcessesUrl) {
        super(defaultFilterProcessesUrl);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        PluginAuthentication authentication;
        String pluginId = request.getHeader("PLUGIN_ID");
        String secret = request.getHeader("PLUGIN_SECRET");

        if((pluginId == null || pluginId.isBlank()) && (secret == null || secret.isBlank()))
            throw new AuthenticationServiceException("Invalid credentials provided");

        authentication = new PluginAuthentication(secret, pluginId);
        return getAuthenticationManager().authenticate(authentication);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        super.successfulAuthentication(request, response, chain, authResult);
        chain.doFilter(request, response);
    }
}
Enter fullscreen mode Exit fullscreen mode

AdminAuthentication

public class AdminAuthentication implements Authentication {

    private AuthenticatedAdmin principal;
    private boolean isAuthenticated;
    private List<SimpleGrantedAuthority> authorities;

    public PawshopeAdminAuthentication(String token){
        this.token = token;
    }

    public PawshopeAdminAuthentication(AuthenticatedAdmin principal, boolean isAuthenticated){
        this.principal = principal;
        this.isAuthenticated = isAuthenticated;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getDetails() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public boolean isAuthenticated() {
        return this.isAuthenticated;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        this.isAuthenticated = isAuthenticated;
    }

    @Override
    public String getName() {
        return this.principal.getUsername();
    }
}
Enter fullscreen mode Exit fullscreen mode

PluginAuthentication

public class PluginAuthentication implements Authentication {

    @Getter
    @Setter
    private String secret;
    @Getter
    @Setter
    private String pluginId;
    private AuthenticatedPlugin principal;
    private boolean isAuthenticated;
    private List<SimpleGrantedAuthority> authorities;

    public PluginAuthentication(String secret, String pluginId) {
        this.secret = secret;
        this.pluginId = pluginId;
    }

    public PluginAuthentication(AuthenticatedPlugin principal, boolean isAuthenticated) {
        this.principal = principal;
        this.isAuthenticated = isAuthenticated;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getDetails() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }

    @Override
    public boolean isAuthenticated() {
        return isAuthenticated;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        this.isAuthenticated = isAuthenticated;
    }

    @Override
    public String getName() {
        return principal.getPluginId();
    }
}
Enter fullscreen mode Exit fullscreen mode

AdminAuthenticationProvider

public class AdminAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    TokenService tokenService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        PawshopeAdminAuthentication auth = (PawshopeAdminAuthentication) authentication;

        try {
            tokenService.validateToken(auth.getToken());
        }catch (Exception e){
            throw new AuthenticationServiceException("Invalid token signature");
        }

        var decoded = tokenService.decodeToken(auth.getToken());

        AuthenticatedAdmin authenticatedAdmin = new AuthenticatedAdmin();
        authenticatedAdmin.setUsername(decoded.getSubject());
        authenticatedAdmin.setRoles(decoded.getClaim("roles").asList(String.class));
        authenticatedAdmin.setUserId(decoded.getClaim("userId").asString());

        if(!authenticatedAdmin.getRoles().contains("ADMIN")){
            log.error("Authenticated user is not ADMIN, access denied, username: {}", authenticatedAdmin.getUsername());
            throw new AuthorizationServiceException("Unauthorized, missing authorities");
        }

        return new PawshopeAdminAuthentication(authenticatedAdmin, true);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(PawshopeAdminAuthentication.class);
    }
}
Enter fullscreen mode Exit fullscreen mode

PluginAuthenticationProvider


public class PluginAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    PluginRepository pluginRepository;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        PluginAuthentication credentials = (PluginAuthentication) authentication;

        if(credentials.getPluginId() == null || credentials.getPluginId().isBlank()){
            throw new AuthenticationServiceException("Invalid credentials");
        }

        var plugin = pluginRepository.findPluginByPluginId(UUID.fromString(credentials.getPluginId()));

        if(plugin.isEmpty()){
            log.error("Unauthorized, no plugin with id: {}", credentials.getPluginId());
            throw new AuthenticationServiceException("Invalid pluginId");
        }

        if(plugin.get().getSecret().equals(credentials.getSecret())){
            return new PluginAuthentication(new AuthenticatedPlugin(credentials.getPluginId()), true);
        }

        log.error("Unauthorized, invalid secret for plugin with id: {}", credentials.getPluginId());
        throw new AuthenticationServiceException("Wrong secret");
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(PluginAuthentication.class);
    }
}
Enter fullscreen mode Exit fullscreen mode

SecurityConfig

public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    PluginAuthenticationProvider provider;

    @Autowired
    AdminAuthenticationProvider adminAuthenticationProvider;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .authenticationProvider(provider)
                .authenticationProvider(adminAuthenticationProvider);
    }

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

        http = http.cors().and().csrf().disable();
        http.authenticationManager(authenticationManager());

        http = http
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and();

        http
                .anonymous()
                .and()
                .authorizeRequests()
                .antMatchers("/api/v1/anonymous/**").permitAll()
                .anyRequest().authenticated();

        http.addFilterBefore(pluginAuthenticationFilter(), LogoutFilter.class);
        http.addFilterBefore(adminAuthenticationFilter(), LogoutFilter.class);
    }

    @Bean
    public AdminAuthenticationFilter adminAuthenticationFilter() throws Exception {
        var filter = new AdminAuthenticationFilter("/api/v1/plugins/**", authenticationManager());
        filter.setAuthenticationSuccessHandler(successHandler());
        return filter;
    }

    @Bean
    public PluginAuthenticationFilter pluginAuthenticationFilter() throws Exception {
        var filter = new PluginAuthenticationFilter("/api/v1/wallet/**", authenticationManager());
        filter.setAuthenticationSuccessHandler(successHandler());
        return filter;
    }

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

    @Bean
    SimpleUrlAuthenticationSuccessHandler successHandler() {
        final SimpleUrlAuthenticationSuccessHandler successHandler = new SimpleUrlAuthenticationSuccessHandler();
        successHandler.setRedirectStrategy(new NoRedirectStrategy());
        return successHandler;
    }
}
Enter fullscreen mode Exit fullscreen mode

NoRedirectStrategy

public class NoRedirectStrategy implements RedirectStrategy {

    @Override
    public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
        //Do nothing, no redirects in REST
    }
}
Enter fullscreen mode Exit fullscreen mode

And of course, to prove what I wrote works here are some tests :)

Testing wallet controller

@Test
    void getBalance_success() {
        var wallet = persistWallet();
        var response = template.exchange("/api/v1/anonymous/wallet/"+wallet.getShelterId()+"/balance", HttpMethod.GET,
                HttpEntity.EMPTY, String.class);
        var responseBody = gson.fromJson(response.getBody(), BalanceResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseBody.getAmount()).isEqualTo(new BigDecimal("10.00"));
    }

@Test
    void createTransaction_success() {
        var shelterWallet = persistWallet();
        var plugin = persistPlugin();

        var reqBody = new CreateTransactionRequest();
        reqBody.setShelterId(shelterWallet.getShelterId());
        reqBody.setAmount(BigDecimal.TEN);
        HttpHeaders headers = new HttpHeaders();
        headers.add("PLUGIN_ID", plugin.getPluginId().toString());
        headers.add("PLUGIN_SECRET", plugin.getSecret());
        HttpEntity<CreateTransactionRequest> req = new HttpEntity<>(reqBody, headers);

        var response = template.exchange("/api/v1/wallet/transactions", HttpMethod.POST, req, String.class);
        var responseBody = gson.fromJson(response.getBody(), TransactionResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }

    @Test
    void cancelLTransaction_401() {
        var response = template.exchange("/api/v1/wallet/transactions", HttpMethod.POST, HttpEntity.EMPTY, String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
Enter fullscreen mode Exit fullscreen mode

Testing plugin controller

@Test
    void createPlugin_success() {
        var reqBody = new CreatePluginRequest();
        reqBody.setName("Skrill");
        HttpHeaders headers = new HttpHeaders();
        //Real bearer from admin user created on authorization service
        headers.add("Authorization", "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuZXJtaW5rbyIsInJvbGVzIjpbIkFETUlOIl0sImlzcyI6InBhd3Nob3BlLWF1dGgiLCJleHAiOjE2NDg5MjAxMzQsInVzZXJJZCI6ImIxOGFmODhlLTZhMTMtNDBhYy1iYmM0LTFhNzg4ZjFmZGM3YSJ9.8QU8R6GDkLSWfRAnpvaWvVclCO9ZB8pXcLBx7aoDCao");
        HttpEntity<CreatePluginRequest> req = new HttpEntity<>(reqBody, headers);

        var response = template.exchange("/api/v1/plugins", HttpMethod.POST, req, String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }

    @Test
    void createPlugin_401() {
        var reqBody = new CreatePluginRequest();
        reqBody.setName("Skrill");
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "");
        HttpEntity<CreatePluginRequest> req = new HttpEntity<>(reqBody, headers);

        var response = template.exchange("/api/v1/plugins", HttpMethod.POST, req, String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
Enter fullscreen mode Exit fullscreen mode

For the sake of this not being 2000 lines, a lot of code is stripped, but the important stuff is there.

Top comments (0)

We are hiring! Do you want to be our Senior Platform Engineer? We're hiring for a Senior Platform Engineer and would love for you to apply.

Head here to learn more about who we're looking for.