0

Question about Spring Security with Webflux.

I have a SpringBoot Webflux web app with Spring Security. The same app also have SSL Server enabled with keystore and truststore for two way SSL, mTLS.

At this point, already, clients trying to request endpoints from my app are failing if they do not have the correct client certificate, this is great! Nothing done on the app layer, just configuring the keystore and truststore, amazing.

Question: Is it possible to further authorize who can access a particular endpoint based on the client certificate itself?

By that I mean, maybe with Spring Security, a client client1 coming with a valid client certificate want to request /endpointA will be able to access it if the certificate has a correct CN. But client2 will be rejected to request /endpointA if client2 has the wrong CN.

Vice versa, client A who has the wrong CN will not be able to request /endpointB, only available to client2 that will have the good client2 CN.

And of course, if client3 has the incorrect CN for both /endpointA and /endpointB, client3 will not be able to request any of those (but he has a valid client certificate).

Would it be possible to provide example with Spring Webflux, (not MVC) please? Finally, if this possible? How? (code snippet will be great).

Thank you

Hakan54
  • 3,121
  • 1
  • 23
  • 37
PatPanda
  • 3,644
  • 9
  • 58
  • 154

1 Answers1

3

Yes this is possible. You can even further secure your web application by validating the CN field of a certificate and block it if it doesnt has the correct name. I am not sure if this is possible with Spring Security out of the box, but I know it is possible with AOP by using AspectJ. In this way you can intercept the request after a succesfull ssl handshake and before it enters your controller. I would definitely advise to read this article: Intro to AspectJ as it would help you to understand the basic concept of the library.

What you can do is create an annotation, for example: AdditionalCertificateValidations which can take a list of allowed and not allowed common names. See below for an implementation. In this way you can decide on every controller which CN you want to allow and not allow.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AdditionalCertificateValidations {

    String[] allowedCommonNames()       default {};
    String[] notAllowedCommonNames()    default {};

}

Afterwords you can annotate your controller with the above annotation and specify the common names:

@Controller
public class HelloWorldController {

    @AdditionalCertificateValidations(allowedCommonNames = {"my-common-name-a", "my-common-name-b"}, notAllowedCommonNames = {"my-common-name-c"})
    @GetMapping(value = "/api/hello", produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("Hello");
    }

}

Now you need to provide an implementation for the annotation. The actual class which will intercept the request and also validate the certificate content.

@Aspect
@Configuration
@EnableAspectJAutoProxy
public class AdditionalCertificateValidationsAspect {

    private static final String KEY_CERTIFICATE_ATTRIBUTE = "javax.servlet.request.X509Certificate";
    private static final Pattern COMMON_NAME_PATTERN = Pattern.compile("(?<=CN=)(.*?)(?=,)");

    @Around("@annotation(certificateValidations)")
    public Object validate(ProceedingJoinPoint joinPoint,
                           AdditionalCertificateValidations certificateValidations) throws Throwable {

        List<String> allowedCommonNames = Arrays.asList(certificateValidations.allowedCommonNames());
        List<String> notAllowedCommonNames = Arrays.asList(certificateValidations.notAllowedCommonNames());

        Optional<String> allowedCommonName = getCommonNameFromCertificate()
                .filter(commonName -> allowedCommonNames.isEmpty() || allowedCommonNames.contains(commonName))
                .filter(commonName -> notAllowedCommonNames.isEmpty() || !notAllowedCommonNames.contains(commonName));

        if (allowedCommonName.isPresent()) {
            return joinPoint.proceed();
        } else {
            return ResponseEntity.badRequest().body("This certificate is not a valid one");
        }
    }

    private Optional<String> getCommonNameFromCertificate() {
        return getCertificatesFromRequest()
                .map(Arrays::stream)
                .flatMap(Stream::findFirst)
                .map(X509Certificate::getSubjectX500Principal)
                .map(X500Principal::getName)
                .flatMap(this::getCommonName);
    }

    private Optional<X509Certificate[]> getCertificatesFromRequest() {
        return Optional.ofNullable((X509Certificate[]) ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
                .getRequest()
                .getAttribute(KEY_CERTIFICATE_ATTRIBUTE));
    }

    private Optional<String> getCommonName(String subjectDistinguishedName) {
        Matcher matcher = COMMON_NAME_PATTERN.matcher(subjectDistinguishedName);

        if (matcher.find()) {
            return Optional.of(matcher.group());
        } else {
            return Optional.empty();
        }
    }

}

With the above configuration a client with the allowed common name will get a 200 status code with the hello message and other clients will get a 400 status code with the message: This certificate is not a valid one. You can use the above options with the following additional library:

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

The example project can be found here: GitHub - Java Tutorials

The example code snippets can be found here:

=============== update 1#

I discovered that the CN name can also be validate with only spring security. See for detailed explanation with examples here: https://www.baeldung.com/x-509-authentication-in-spring-security#2-spring-security-configuration

First you need to tell spring to intercept every request, authorise and authenticate by overriding the configure method with your own logic, see below for an example. It will extract the common name field and treat it as a "User Name" and it will check with the UserDetailsService if the user is known. Your controller also needs to be annotated with @PreAuthorize("hasAuthority('ROLE_USER')")

@SpringBootApplication
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class X509AuthenticationServer extends WebSecurityConfigurerAdapter {
    ...
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
          .and()
          .x509()
            .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
            .userDetailsService(userDetailsService());
    }
 
    @Bean
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                if (username.equals("Bob")) {
                    return new User(username, "", 
                      AuthorityUtils
                        .commaSeparatedStringToAuthorityList("ROLE_USER"));
                }
                throw new UsernameNotFoundException("User not found!");
            }
        };
    }
}

=============== update 2#

I somehow missed the point it should be in a non-blocking fashion. The reactive flow is kinda similar to the example provided within the first update above. The following configuration would do the trick for you:

@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
    return http
            .x509(Customizer.withDefaults())
            .authorizeExchange(exchanges -> exchanges.anyExchange().authenticated())
            .build();
}

@Bean
public MapReactiveUserDetailsService mapReactiveUserDetailsService() {
    UserDetails bob = User.withUsername("Bob")
            .authorities(new SimpleGrantedAuthority("ROLE_USER"))
            .password("")
            .build();

    return new MapReactiveUserDetailsService(bob);
}

I created a working example implementation based on the above input, see here for the details: GitHub - Spring security with common name validation

Hakan54
  • 3,121
  • 1
  • 23
  • 37
  • This is a very good answer, I really want to accept it. But is there a way to do it purely with Spring-Security please? – PatPanda Oct 01 '20 at 00:56
  • I thought there wasn't a way to do it purely with Spring security but I found a way. There is a nice article which describes exactly how to do it I added the link to my initial answer. I also posted example configuration, please have a look – Hakan54 Oct 01 '20 at 06:53
  • The article https://www.baeldung.com/x-509-authentication-in-spring-security#2-spring-security-configuration is a good article, no doubt. I came across this article as well. However, the content is very tied to Spring-Security-nonWebflux. I was hoping for some pointers with the Reactive Spring Security version, making sure nothing is blocking. The sets of API provided by Spring Security seems to be totally different from non-Webflux to Webflux/Reactive – PatPanda Oct 01 '20 at 07:01
  • Oeps, I somehow missed that your main question is regarding reactive and webflux. I have added a second update to my answer. Could you try that out and let me know if that works for you? – Hakan54 Oct 01 '20 at 08:22
  • This is definitely promising, thanks! However, I am getting below error on method MapReactiveUserDetailsService mapReactiveUserDetailsService(). Could you please advise? – PatPanda Oct 04 '20 at 21:31
  • @Bean public MapReactiveUserDetailsService mapReactiveUserDetailsService() { UserDetails bob = User.withUsername("bob").build(); return new MapReactiveUserDetailsService(bob); } – PatPanda Oct 04 '20 at 21:31
  • Caused by: java.lang.IllegalArgumentException: Cannot pass null or empty values to constructor at org.springframework.security.core.userdetails.User.(User.java:113) ~[spring-security-core-5.3.4.RELEASE.jar:5.3.4.RELEASE] at org.springframework.security.core.userdetails.User$UserBuilder.build(User.java:535) ~[spring-security-core-5.3.4.RELEASE.jar:5.3.4.RELEASE] at a.X509AuthenticationServer.mapReactiveUserDetailsService(X509AuthenticationServer.java:67) ~[classes/:na] – PatPanda Oct 04 '20 at 21:32
  • It was missing some required fields for the user. I have updated my answer. Could you give it a try? – Hakan54 Oct 05 '20 at 06:13
  • I upvoted the answer. I believe it is a correct one. It is probably just me, I am getting a: – PatPanda Oct 15 '20 at 07:47
  • java.lang.ClassCastException: class sun.security.x509.X509CertImpl cannot be cast to class java.lang.String (sun.security.x509.X509CertImpl and java.lang.String are in module java.base of loader 'bootstrap') at org.springframework.security.authentication.AbstractUserDetailsReactiveAuthenticationManager.authenticate(AbstractUserDetailsReactiveAuthenticationManager.java:99) ~[spring-security-core-5.3.4.RELEASE.jar:5.3.4.RELEASE] Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: – PatPanda Oct 15 '20 at 07:48
  • Although I am correctly seeing .w.a.p.x.SubjectDnX509PrincipalExtractor : Subject DN is (everything correct) and .w.a.p.x.SubjectDnX509PrincipalExtractor : Extracted Principal name is (the correct thing) – PatPanda Oct 15 '20 at 07:51
  • 1
    Yes it is correct that you encountered that exception. It looks like the `UserDetailsRepositoryReactiveAuthenticationManager` can't handle a certificate. It tried to cast a certificate into a String and tries to treat it as a password. I have updated my initial answer, and to make sure it works this time I also created an example project containing a server and a client with mutual authentication. See here for the details as a reference for you: https://github.com/Hakky54/spring-security-with-common-name-validation – Hakan54 Oct 15 '20 at 22:39
  • I think it is working, at least, the error disappeared. (And many thanks, I actually learned the root cause of the error). However, in your sample, I do not see where there is the actual check i.e. I want to allow people with a CN=something, but will reject those with CN=bad. Also, it is possible to achieve the same without the delegate class CertificateCommonNameAuthenticationManager to have an all in one place nicely fit solution please? – PatPanda Oct 16 '20 at 02:29
  • https://docs.spring.io/spring-security/site/docs/5.2.7.BUILD-SNAPSHOT/reference/html/reactive-x509.html – PatPanda Oct 16 '20 at 04:31
  • Ah yes, although my answer did the trick it could me much more simplified. The custom AuthenticationManager is not needed. Thank you for referencing the spring documentation, I updated my initial answer. Hopefully it would be also useful for others – Hakan54 Oct 16 '20 at 10:15
  • How can we read the principal (username extracted from certificate) in Filters after a x509 Authentication is successful – Siju Suresh Jan 20 '21 at 11:16