Spring Webflux Security - 基于客户端证书的授权端点
Spring Webflux Security - authorized endpoint based on client certificate
关于 Spring Webflux 安全性的问题。
我有一个 SpringBoot Webflux 网络应用程序 Spring 安全。同一个应用程序还为两种方式的 SSL、mTLS 启用了带有密钥库和信任库的 SSL 服务器。
此时,如果客户端没有正确的客户端证书,尝试从我的应用程序请求端点的客户端已经失败,这太棒了!应用层什么都没做,只配置了keystore和truststore,厉害了。
问题:是否可以根据客户端证书本身进一步授权谁可以访问特定端点?
我的意思是,也许在 Spring 安全性的情况下,如果证书具有正确的 CN,则带有想要请求 /endpointA 的有效客户端证书的客户端 client1 将能够访问它。但是如果client2的CN错误,client2请求/endpointA会被拒绝。
反之亦然,CN 错误的客户端 A 将无法请求 /endpointB,仅可用于具有正确 CN 的客户端 2。
当然,如果 client3 的 /endpointA 和 /endpointB 的 CN 都不正确,client3 将无法请求其中任何一个(但他有有效的客户端证书)。
能否提供 Spring Webflux 示例(不是 MVC)?
最后,如果可能的话?如何? (代码片段会很棒)。
谢谢
是的,这是可能的。您甚至可以通过验证证书的 CN 字段来进一步保护您的 Web 应用程序,如果名称不正确则阻止它。我不确定开箱即用的 Spring 安全性是否可行,但我知道通过使用 AspectJ 可以实现 AOP。通过这种方式,您可以在成功的 ssl 握手之后和进入您的控制器之前拦截请求。我肯定会建议阅读这篇文章:Intro to AspectJ,因为它会帮助您理解库的基本概念。
您可以做的是创建一个注释,例如:AdditionalCertificateValidations,它可以获取允许和不允许的通用名称列表。请参阅下面的实现。通过这种方式,您可以决定每个控制器允许和不允许哪个 CN。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AdditionalCertificateValidations {
String[] allowedCommonNames() default {};
String[] notAllowedCommonNames() default {};
}
之后你可以用上面的注释来注释你的控制器并指定通用名称:
@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");
}
}
现在您需要提供注释的实现。实际的 class 将拦截请求并验证证书内容。
@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();
}
}
}
使用上述配置,具有允许的通用名称的客户端将获得带有问候消息的 200 状态代码,而其他客户端将获得带有消息的 400 状态代码:此证书无效。您可以将上述选项与以下附加库一起使用:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
示例项目可以在这里找到:GitHub - Java Tutorials
可在此处找到示例代码片段:
===============更新1#
我发现 CN 名称也可以仅通过 spring 安全性进行验证。有关示例的详细说明,请参见此处:https://www.baeldung.com/x-509-authentication-in-spring-security#2-spring-security-configuration
首先你需要告诉spring拦截每个请求,通过用你自己的逻辑重写configure
方法来授权和验证,见下面的例子。它将提取通用名称字段并将其视为“用户名”,如果用户已知,它将与 UserDetailsService 核对。您的控制器还需要用 @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!");
}
};
}
}
===============更新2#
我不知何故错过了应该以 non-blocking 方式出现的要点。反应流有点类似于上面第一次更新中提供的示例。以下配置将为您解决问题:
@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);
}
我根据上述输入创建了一个工作示例实现,详情请参见此处:GitHub - Spring security with common name validation
关于 Spring Webflux 安全性的问题。
我有一个 SpringBoot Webflux 网络应用程序 Spring 安全。同一个应用程序还为两种方式的 SSL、mTLS 启用了带有密钥库和信任库的 SSL 服务器。
此时,如果客户端没有正确的客户端证书,尝试从我的应用程序请求端点的客户端已经失败,这太棒了!应用层什么都没做,只配置了keystore和truststore,厉害了。
问题:是否可以根据客户端证书本身进一步授权谁可以访问特定端点?
我的意思是,也许在 Spring 安全性的情况下,如果证书具有正确的 CN,则带有想要请求 /endpointA 的有效客户端证书的客户端 client1 将能够访问它。但是如果client2的CN错误,client2请求/endpointA会被拒绝。
反之亦然,CN 错误的客户端 A 将无法请求 /endpointB,仅可用于具有正确 CN 的客户端 2。
当然,如果 client3 的 /endpointA 和 /endpointB 的 CN 都不正确,client3 将无法请求其中任何一个(但他有有效的客户端证书)。
能否提供 Spring Webflux 示例(不是 MVC)? 最后,如果可能的话?如何? (代码片段会很棒)。
谢谢
是的,这是可能的。您甚至可以通过验证证书的 CN 字段来进一步保护您的 Web 应用程序,如果名称不正确则阻止它。我不确定开箱即用的 Spring 安全性是否可行,但我知道通过使用 AspectJ 可以实现 AOP。通过这种方式,您可以在成功的 ssl 握手之后和进入您的控制器之前拦截请求。我肯定会建议阅读这篇文章:Intro to AspectJ,因为它会帮助您理解库的基本概念。
您可以做的是创建一个注释,例如:AdditionalCertificateValidations,它可以获取允许和不允许的通用名称列表。请参阅下面的实现。通过这种方式,您可以决定每个控制器允许和不允许哪个 CN。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AdditionalCertificateValidations {
String[] allowedCommonNames() default {};
String[] notAllowedCommonNames() default {};
}
之后你可以用上面的注释来注释你的控制器并指定通用名称:
@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");
}
}
现在您需要提供注释的实现。实际的 class 将拦截请求并验证证书内容。
@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();
}
}
}
使用上述配置,具有允许的通用名称的客户端将获得带有问候消息的 200 状态代码,而其他客户端将获得带有消息的 400 状态代码:此证书无效。您可以将上述选项与以下附加库一起使用:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
示例项目可以在这里找到:GitHub - Java Tutorials
可在此处找到示例代码片段:
===============更新1#
我发现 CN 名称也可以仅通过 spring 安全性进行验证。有关示例的详细说明,请参见此处:https://www.baeldung.com/x-509-authentication-in-spring-security#2-spring-security-configuration
首先你需要告诉spring拦截每个请求,通过用你自己的逻辑重写configure
方法来授权和验证,见下面的例子。它将提取通用名称字段并将其视为“用户名”,如果用户已知,它将与 UserDetailsService 核对。您的控制器还需要用 @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!");
}
};
}
}
===============更新2#
我不知何故错过了应该以 non-blocking 方式出现的要点。反应流有点类似于上面第一次更新中提供的示例。以下配置将为您解决问题:
@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);
}
我根据上述输入创建了一个工作示例实现,详情请参见此处:GitHub - Spring security with common name validation