Spring Webflux Security-基于客户端证书的授权端点

问题描述

关于使用Webflux的Spring Security的问题。

我有一个带有Spring Security的SpringBoot Webflux Web应用程序。同一应用还启用了SSL服务器,并通过密钥库和信任库启用了两种方式的SSL,即mTLS。

在这一点上,如果尝试从我的应用请求端点的客户端没有正确的客户端证书,则会失败,这太好了!在应用程序层上什么也没做,只需配置密钥库和信任库就可以了。

问题:是否可以根据客户端证书本身进一步授权谁可以访问特定端点?

我的意思是,也许对于Spring Security,带有有效客户机证书的客户机client1想要请求/ endpointA即可在证书具有正确的CN的情况下访问它。但是,如果client2的CN错误,则client2将拒绝请求/ endpointA。

反之亦然,具有错误CN的客户端A将无法请求/ endpointB,仅对具有良好client2 CN的client2可用。

当然,如果client3的/ endpointA和/ endpointB的CN不正确,则client3将无法请求其中的任何一个(但他具有有效的客户证书)。

是否可以通过Spring Webflux提供示例(不是MVC)? 最后,如果可能的话?怎么样? (代码段很棒)。

谢谢

解决方法

是的,这是可能的。您甚至可以通过验证证书的CN字段来进一步保护Web应用程序的安全,并在证书名称不正确的情况下将其阻止。我不确定Spring Security是否可以立即使用,但是我知道通过使用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");
    }

}

现在,您需要为注释提供一个实现。实际的类将拦截请求并验证证书内容。

@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();
        }
    }

}

使用上述配置,具有允许的通用名称的客户端将通过hello消息获得200状态代码,而其他客户端将通过消息获得400状态代码:此证书无效。您可以将以上选项与以下其他库一起使用:

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

可以在此处找到示例项目:GitHub - Mutual-TLS-SSL

示例代码段可在以下位置找到:

===============更新1#

我发现CN名称也可以仅通过spring安全性来验证。请参见此处的示例的详细说明:https://www.baeldung.com/x-509-authentication-in-spring-security#2-spring-security-configuration

首先,您需要告诉spring拦截每个请求,通过使用自己的逻辑重写configure方法来授权和认证,请参见下面的示例。它将提取公用名字段并将其视为“用户名”,并将通过UserDetailsS​​ervice检查用户是否已知。您的控制器也需要加上@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#

我莫名其妙地错了一点,那就是应该以无阻碍的方式进行。反应流有点类似于以上第一次更新中提供的示例。以下配置将为您解决问题:

@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