Spring Webflux安全性 – 基于客户端证书的授权端点

huangapple go评论55阅读模式
英文:

Spring Webflux Security - authorized endpoint based on client certificate

问题

关于Spring Security与Webflux的问题。

我有一个使用Spring Security的SpringBoot Webflux Web应用程序。同一应用程序还启用了SSL服务器,使用密钥库和信任库进行双向SSL,即mTLS。

到目前为止,如果客户端没有正确的客户端证书,尝试从我的应用程序请求端点的客户端请求将失败,这非常好!在应用程序层面没有做任何事情,只需配置密钥库和信任库,非常棒。

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

我的意思是,也许可以使用Spring Security,带有有效客户端证书的客户端client1希望请求/endpointA,如果证书具有正确的CN,那么它将能够访问它。但是如果client2的CN不正确,client2将被拒绝请求/endpointA。

反之,具有错误CN的client A将无法请求/endpointB,仅对具有正确CN的client2可用。

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

是否可以提供使用Spring Webflux的示例(而不是MVC)?最后,如果可能的话,如何实现?(代码片段将很有帮助)。

谢谢。

英文:

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

答案1

得分: 3

是的,这是可能的。您甚至可以通过验证证书的CN字段进一步保护您的Web应用程序,如果它的名称不正确,您可以阻止它。我不确定Spring Security是否可以直接实现这一点,但我知道您可以使用AspectJ来实现。通过这种方式,您可以在成功的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状态码,而其他客户端将获得带有消息“This certificate is not a valid one”的400状态码。您可以使用以下附加库进行上述选项:

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

示例项目在这里:GitHub - Java Tutorials

示例代码片段在这里:

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

我发现使用Spring Security也可以验证CN名称。详细的解释和示例可以在这里找到:在Spring Security中进行X.509认证

首先,您需要告诉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#

我不知何故忽略了它应该以非阻塞方式运行。响应式流与上述第一个更新中提供的示例类似。以下配置将为您完成:

@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

英文:

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 = {&quot;my-common-name-a&quot;, &quot;my-common-name-b&quot;}, notAllowedCommonNames = {&quot;my-common-name-c&quot;})
    @GetMapping(value = &quot;/api/hello&quot;, produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity&lt;String&gt; hello() {
        return ResponseEntity.ok(&quot;Hello&quot;);
    }

}

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 = &quot;javax.servlet.request.X509Certificate&quot;;
    private static final Pattern COMMON_NAME_PATTERN = Pattern.compile(&quot;(?&lt;=CN=)(.*?)(?=,)&quot;);

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

        List&lt;String&gt; allowedCommonNames = Arrays.asList(certificateValidations.allowedCommonNames());
        List&lt;String&gt; notAllowedCommonNames = Arrays.asList(certificateValidations.notAllowedCommonNames());

        Optional&lt;String&gt; allowedCommonName = getCommonNameFromCertificate()
                .filter(commonName -&gt; allowedCommonNames.isEmpty() || allowedCommonNames.contains(commonName))
                .filter(commonName -&gt; notAllowedCommonNames.isEmpty() || !notAllowedCommonNames.contains(commonName));

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

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

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

    private Optional&lt;String&gt; 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:

&lt;dependency&gt;
    &lt;groupId&gt;org.aspectj&lt;/groupId&gt;
    &lt;artifactId&gt;aspectjweaver&lt;/artifactId&gt;
&lt;/dependency&gt;

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(&quot;hasAuthority(&#39;ROLE_USER&#39;)&quot;)

@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(&quot;CN=(.*?)(?:,|$)&quot;)
            .userDetailsService(userDetailsService());
    }
 
    @Bean
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                if (username.equals(&quot;Bob&quot;)) {
                    return new User(username, &quot;&quot;, 
                      AuthorityUtils
                        .commaSeparatedStringToAuthorityList(&quot;ROLE_USER&quot;));
                }
                throw new UsernameNotFoundException(&quot;User not found!&quot;);
            }
        };
    }
}

=============== 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 -&gt; exchanges.anyExchange().authenticated())
            .build();
}

@Bean
public MapReactiveUserDetailsService mapReactiveUserDetailsService() {
    UserDetails bob = User.withUsername(&quot;Bob&quot;)
            .authorities(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;))
            .password(&quot;&quot;)
            .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

huangapple
  • 本文由 发表于 2020年9月30日 03:29:16
  • 转载请务必保留本文链接:https://go.coder-hub.com/64126371.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定