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