Spring Webflux Security - авторизованная конечная точка на основе сертификата клиента

Вопрос о Spring Security с Webflux.

У меня есть веб-приложение SpringBoot Webflux с Spring Security. В том же приложении также включен SSL-сервер с хранилищем ключей и хранилищем доверенных сертификатов для двустороннего SSL, mTLS.

На этом этапе клиенты, пытающиеся запросить конечные точки из моего приложения, терпят неудачу, если у них нет правильного сертификата клиента, это здорово! Ничего не сделано на уровне приложения, просто настроили хранилище ключей и хранилище доверенных сертификатов, потрясающе.

Вопрос: Можно ли дополнительно разрешить доступ к конкретной конечной точке на основе самого сертификата клиента?

Под этим я подразумеваю, что, возможно, с Spring Security клиент client1 с действующим сертификатом клиента, который хочет запросить / endpointA, сможет получить к нему доступ, если сертификат имеет правильный CN. Но client2 будет отклонен в запросе / endpointA, если client2 имеет неправильный CN.

И наоборот, клиент A, имеющий неправильный CN, не сможет запросить / endpointB, доступный только для client2, у которого будет хороший CN client2.

И, конечно, если client3 имеет неправильный CN для / endpointA и / endpointB, client3 не сможет запросить ни один из них (но у него есть действующий сертификат клиента).

Можно ли привести пример с Spring Webflux (не MVC), пожалуйста? Наконец, возможно ли это? Как? (фрагмент кода будет отличным).

Спасибо


person PatPatPat    schedule 29.09.2020    source источник


Ответы (1)


Да, это возможно. Вы можете даже дополнительно защитить свое веб-приложение, проверив поле CN сертификата и заблокировав его, если у него нет правильного имени. Я не уверен, возможно ли это с помощью Spring Security из коробки, но я знаю, что это возможно с AOP с помощью AspectJ. Таким образом, вы можете перехватить запрос после успешного подтверждения ssl и до того, как он попадет в ваш контроллер. Я бы определенно посоветовал прочитать эту статью: Введение в 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();
        }
    }

}

С приведенной выше конфигурацией клиент с разрешенным общим именем получит код состояния 200 с приветственным сообщением, а другие клиенты получат код состояния 400 с сообщением: Этот сертификат недействителен. Вы можете использовать указанные выше параметры со следующей дополнительной библиотекой:

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

Пример проекта можно найти здесь: GitHub - Mutual-TLS-SSL

Примеры фрагментов кода можно найти здесь:

=============== обновление 1 #

Я обнаружил, что имя CN также можно проверить только с помощью пружинной защиты. См. Подробное объяснение с примерами здесь: 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 #

Я как-то упустил суть, это должно быть неблокирующим способом. Реактивный поток похож на пример, представленный в первом обновлении выше. Следующая конфигурация поможет вам:

@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 с проверкой общего имени

person Hakan54    schedule 30.09.2020
comment
Это очень хороший ответ, я очень хочу его принять. Но есть ли способ сделать это исключительно с помощью Spring-Security, пожалуйста? - person PatPatPat; 01.10.2020
comment
Я думал, что нет способа сделать это только с помощью Spring безопасности, но я нашел способ. Есть хорошая статья, в которой точно описано, как это сделать. Я добавил ссылку к своему первоначальному ответу. Я также разместил пример конфигурации, пожалуйста, посмотрите - person Hakan54; 01.10.2020
comment
Статья baeldung.com/, без сомнения, хорошая статья. Я тоже наткнулся на эту статью. Однако контент очень привязан к Spring-Security-nonWebflux. Я надеялся на некоторые указатели с версией Reactive Spring Security, чтобы убедиться, что ничего не блокирует. Наборы API, предоставляемые Spring Security, похоже, полностью отличаются от не-Webflux до Webflux / Reactive. - person PatPatPat; 01.10.2020
comment
Ой, я как-то пропустил, что ваш главный вопрос касается реактивного и webflux. Я добавил второе обновление к своему ответу. Не могли бы вы попробовать это и сообщить мне, работает ли это для вас? - person Hakan54; 01.10.2020
comment
Это определенно многообещающе, спасибо! Однако я получаю ошибку ниже в методе MapReactiveUserDetailsService mapReactiveUserDetailsService (). Могли бы вы, пожалуйста, посоветовать? - person PatPatPat; 05.10.2020
comment
@Bean public MapReactiveUserDetailsService mapReactiveUserDetailsService () {UserDetails bob = User.withUsername (bob) .build (); вернуть новый MapReactiveUserDetailsService (bob); } - person PatPatPat; 05.10.2020
comment
Вызвано: java.lang.IllegalArgumentException: невозможно передать нулевые или пустые значения конструктору в org.springframework.security.core.userdetails.User. ‹Init› (User.java:113) ~ [spring-security-core-5.3. 4.RELEASE.jar: 5.3.4.RELEASE] в org.springframework.security.core.userdetails.User $ UserBuilder.build (User.java:535) ~ [spring-security-core-5.3.4.RELEASE.jar : 5.3.4.RELEASE] на a.X509AuthenticationServer.mapReactiveUserDetailsService (X509AuthenticationServer.java:67) ~ [классы /: na] - person PatPatPat; 05.10.2020
comment
Отсутствовали некоторые обязательные поля для пользователя. Я обновил свой ответ. Не могли бы вы попробовать? - person Hakan54; 05.10.2020
comment
Я поддержал ответ. Я считаю, что это правильный ответ. Наверное, это только у меня, я получаю: - person PatPatPat; 15.10.2020
comment
java.lang.ClassCastException: класс sun.security.x509.X509CertImpl не может быть преобразован в класс java.lang.String (sun.security.x509.X509CertImpl и java.lang.String находятся в модуле java.base загрузчика bootstrap) в org.springframework.security.authentication.AbstractUserDetailsReactiveAuthenticationManager.authenticate (AbstractUserDetailsReactiveAuthenticationManager.java:99) ~ [spring -security-core-5.3.4.RELEASE.jar: 5.3.4.RELEASE] Подавлено: response.semblyx. $ OnAssemblyException: - person PatPatPat; 15.10.2020
comment
Хотя я правильно вижу .w.a.p.x.SubjectDnX509PrincipalExtractor: Subject DN (все правильно) и .w.a.p.x.SubjectDnX509PrincipalExtractor: извлеченное имя участника (правильная вещь) - person PatPatPat; 15.10.2020
comment
Да, это правильно, что вы столкнулись с этим исключением. Похоже, UserDetailsRepositoryReactiveAuthenticationManager не может обработать сертификат. Он попытался преобразовать сертификат в String и попытался обработать его как пароль. Я обновил свой первоначальный ответ и, чтобы убедиться, что он работает на этот раз, я также создал пример проекта, содержащего сервер и клиент с взаимной аутентификацией. Подробную информацию см. Здесь: github.com/Hakky54 / Spring-security-with-common-name-validation - person Hakan54; 16.10.2020
comment
Думаю работает, по крайней мере, ошибка исчезла. (И большое спасибо, я действительно узнал основную причину ошибки). Однако в вашем примере я не вижу, где находится фактическая проверка, т.е. я хочу разрешить людям с CN = something, но отклоню тех, у которых CN = bad. Кроме того, можно добиться того же без класса делегата CertificateCommonNameAuthenticationManager, чтобы иметь все в одном месте, хорошо подходящее решение, пожалуйста? - person PatPatPat; 16.10.2020
comment
Ах да, хотя мой ответ сработал, он мог быть намного проще. Пользовательский AuthenticationManager не нужен. Спасибо за ссылку на весеннюю документацию, я обновил свой первоначальный ответ. Надеюсь, это будет полезно и другим - person Hakan54; 16.10.2020
comment
Как мы можем прочитать принципала (имя пользователя, извлеченное из сертификата) в фильтрах после успешной аутентификации x509 - person Siju Suresh; 20.01.2021