androidspringspring-securityresttemplatespring-security-rest

Spring Boot/Security - can I use X509 Certificate as an extra layer in authentication?


I am building an Android App which communicates with my REST API that is protected by Spring Security.

Since the Android App is "public" and no keys etc is secure I want to create diffrent obstacles and make things complicated to protect my API as much as possible.

One way in which I would like to add more security is to make sure that the one calling my API has a certificate. I don't want to create thousands of certificates in my APIs trust-store so I just want to make sure that the caller have one single certificate that I hid away in a keystore in my Android app.

In the examples I have found it seems like a "normal" X509Certificate authentication in Spring Security requires a unique certificate for every user and then this certificate replaces Basic auth or JWT auth. I would like to have individual client JWT tokens but make sure that every call brings my ONE Android App certificate to make (more) sure that someone is calling my API from my Android app.

Is this possible or is it just not what it is for?

When you create a RestTemplate you can configure it with a keystore and trust-store so in that end it should be easy. But as for protecting my REST API it seems more difficult since I want both certificate + JWT token or Basic auth.

I am not using XML configuration for my securityconfig. I instead extend WebSecurityConfigurerAdapter. It would be great if this was configurable in the configure(HttpSecurity http) method, but I'm thinking that maybe I could achieve this in a OncePerRequestFilter somehow? Perhaps configure a filter before my JwtAuthFilter?

Edit: In all the examples I have found for configuration of spring security they always seems to use the certificate as an authentication. I just want to configure so that when someone call example.com/api/** it checks so that the certificate is approved by my custom trust store (so that I "know" it is probably a call from my app) but if someone call example.com/website it should use the default java trust store.

If someone call example.com/api/** I would like my server to

  1. check certificate and kill the connection if the certificate is not approved in my custom truststore.
  2. If certificate is ok, establish https (or move on if I can't kill the connection before it have already established https-connection) to user auth with Basic-/JWT-authentication.

Solution

  • I think I figured it out. Here is how I configured it and it seems to work.

    The "/**" endpoint is the website which should work with any browser without any specific certificate, but it requires Admin authority (you need to login as admin).

    The "/api/**" and "/connect/**" endpoints require the correct certificate, the correct API-key and valid Basic- or JWT-token authentification.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.authorizeRequests()
                .antMatchers("/**").hasRole("ADMIN")
            .and()
                .formLogin()
                    .loginPage("/loginForm")
                    .loginProcessingUrl("/authenticateTheUser")
                    .permitAll()
            .and()
                .logout()
                    .permitAll().and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.ALWAYS);
        
        http.requestMatchers()
                .antMatchers("/connect/**","/api/**")
            .and()
                .addFilterBefore(new APIKeyFilter(null), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtAuthorizationFilter(), BasicAuthenticationFilter.class)
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .httpBasic()
                .authenticationEntryPoint(authenticationEntryPoint)
            .and()
                .authorizeRequests()
                .antMatchers("/connect/**").hasAnyRole("MASTER,APPCALLER,NEGOTIATOR,MEMBER")
                .antMatchers("/api/**").hasAnyRole("MASTER,MEMBER,ANONYMOUS");
        
    }
    

    The ApiKeyFilter class is the one that check the api-key and also make sure that the certificate used in the call is approved in my server trust-store. The api-key check is all that I had to configure, the extended X509AuthenticationFilter will automatically check the request certificate. My ApiKeyFilter looks like this:

        public class APIKeyFilter extends X509AuthenticationFilter {
        
        private String principalRequestHeader = "x-api-key";
        
        private String apiKey = "XXXX";
        
        public APIKeyFilter(String principalRequestHeader) {
            if (principalRequestHeader != null) {
                this.principalRequestHeader = principalRequestHeader;
            }
            setAuthenticationManager(new AuthenticationManager() {
                @Override
                public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                    if(authentication.getPrincipal() == null) {
                        throw new BadCredentialsException("Access Denied.");
                    }
                    String rApiKey = (String) authentication.getPrincipal();
                    if (authentication.getPrincipal() != null && apiKey.equals(rApiKey)) {
                        return authentication;
                    } else {
                        throw new BadCredentialsException("Access Denied.");
                    }
                }
            });
        }
        
        @Override
        protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
            return request.getHeader(principalRequestHeader);
        }
        
        @Override
        protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
            X509Certificate[] certificates = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate");
            if (certificates != null && certificates.length > 0) {
                return certificates[0].getSubjectDN();
            }
            return super.getPreAuthenticatedCredentials(request);
        } 
    }
    

    Cred goes to these resources that helped me put things together:

    Spring Boot - require api key AND x509, but not for all endpoints

    spring security http antMatcher with multiple paths