Keamanan Spring Webflux - titik akhir resmi berdasarkan sertifikat klien

Pertanyaan tentang Keamanan Musim Semi dengan Webflux.

Saya memiliki aplikasi web SpringBoot Webflux dengan Spring Security. Aplikasi yang sama juga mengaktifkan Server SSL dengan keystore dan truststore untuk SSL dua arah, mTLS.

Pada titik ini, klien yang mencoba meminta titik akhir dari aplikasi saya gagal jika mereka tidak memiliki sertifikat klien yang benar, ini bagus! Tidak ada yang dilakukan pada lapisan aplikasi, hanya mengonfigurasi keystore dan truststore, luar biasa.

Pertanyaan: Apakah mungkin untuk mengotorisasi lebih lanjut siapa yang dapat mengakses titik akhir tertentu berdasarkan sertifikat klien itu sendiri?

Maksud saya, mungkin dengan Spring Security, klien client1 datang dengan sertifikat klien yang valid ingin meminta /endpointA akan dapat mengaksesnya jika sertifikat memiliki CN yang benar. Namun klien2 akan ditolak untuk meminta /endpointA jika klien2 memiliki CN yang salah.

Begitu pula sebaliknya, klien A yang memiliki CN yang salah tidak akan dapat meminta /endpointB, hanya tersedia untuk klien2 yang memiliki CN klien2 yang baik.

Dan tentu saja, jika klien3 memiliki CN yang salah untuk /endpointA dan /endpointB, klien3 tidak akan dapat meminta salah satu dari itu (tetapi ia memiliki sertifikat klien yang valid).

Bisakah saya memberikan contoh dengan Spring Webflux, (bukan MVC)? Akhirnya, apakah ini mungkin? Bagaimana? (cuplikan kode akan bagus).

Terima kasih


person PatPatPat    schedule 29.09.2020    source sumber


Jawaban (1)


Ya, ini mungkin. Anda bahkan dapat lebih mengamankan aplikasi web Anda dengan memvalidasi bidang CN pada sertifikat dan memblokirnya jika nama sertifikat tersebut tidak benar. Saya tidak yakin apakah hal ini dapat dilakukan dengan Spring Security, tetapi saya tahu hal ini dapat dilakukan dengan AOP dengan menggunakan AspectJ. Dengan cara ini Anda dapat mencegat permintaan setelah jabat tangan SSL berhasil dan sebelum permintaan tersebut masuk ke pengontrol Anda. Saya sangat menyarankan untuk membaca artikel ini: Pengantar AspectJ karena ini akan membantu Anda memahami konsep dasar dari perpustakaan.

Yang bisa Anda lakukan adalah membuat anotasi, misalnya: ExtraCertificateValidations yang dapat mengambil daftar nama umum yang diperbolehkan dan tidak diperbolehkan. Lihat di bawah untuk implementasinya. Dengan cara ini Anda dapat memutuskan pada setiap pengontrol CN mana yang ingin Anda izinkan dan tidak izinkan.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AdditionalCertificateValidations {

    String[] allowedCommonNames()       default {};
    String[] notAllowedCommonNames()    default {};

}

Kata penutup, Anda dapat memberi anotasi pada pengontrol Anda dengan anotasi di atas dan menentukan nama umum:

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

}

Sekarang Anda perlu menyediakan implementasi untuk anotasi tersebut. Kelas sebenarnya yang akan mencegat permintaan dan juga memvalidasi konten sertifikat.

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

}

Dengan konfigurasi di atas klien dengan nama umum yang diperbolehkan akan mendapatkan kode status 200 dengan pesan hello dan klien lain akan mendapatkan kode status 400 dengan pesan: Sertifikat ini tidak valid. Anda dapat menggunakan opsi di atas dengan perpustakaan tambahan berikut:

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

Contoh proyek dapat ditemukan di sini: GitHub - Mutual-TLS-SSL

Cuplikan kode contoh dapat ditemukan di sini:

=============== perbarui 1#

Saya menemukan bahwa nama CN juga dapat divalidasi hanya dengan keamanan pegas. Lihat penjelasan detailnya beserta contohnya di sini: https://www.baeldung.com/x-509-authentication-in-spring-security#2-spring-security-configuration

Pertama, Anda perlu memberi tahu pegas untuk mencegat setiap permintaan, mengotorisasi, dan mengautentikasi dengan mengganti metode configure dengan logika Anda sendiri, lihat contoh di bawah. Ini akan mengekstrak bidang nama umum dan memperlakukannya sebagai Nama Pengguna dan akan memeriksa dengan UserDetailsService apakah pengguna tersebut dikenal. Pengontrol Anda juga perlu dianotasi dengan @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!");
            }
        };
    }
}

=============== perbarui 2#

Saya entah bagaimana melewatkan poin yang seharusnya dilakukan dengan cara yang tidak menghalangi. Aliran reaktif agak mirip dengan contoh yang diberikan pada pembaruan pertama di atas. Konfigurasi berikut akan membantu Anda:

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

Saya membuat contoh implementasi yang berfungsi berdasarkan masukan di atas, lihat di sini untuk detailnya: GitHub - Keamanan pegas dengan validasi nama umum

person Hakan54    schedule 30.09.2020
comment
Ini jawaban yang sangat bagus, saya sangat ingin menerimanya. Tapi apakah ada cara untuk melakukannya murni dengan Spring-Security? - person PatPatPat; 01.10.2020
comment
Saya pikir tidak ada cara untuk melakukannya murni dengan keamanan Spring tetapi saya menemukan caranya. Ada artikel bagus yang menjelaskan dengan tepat bagaimana melakukannya. Saya menambahkan tautan ke jawaban awal saya. Saya juga memposting contoh konfigurasi, silakan lihat - person Hakan54; 01.10.2020
comment
Artikel baeldung.com/ adalah artikel yang bagus, tidak diragukan lagi. Saya juga menemukan artikel ini. Namun, kontennya sangat terkait dengan Spring-Security-nonWebflux. Saya mengharapkan beberapa petunjuk dengan versi Reactive Spring Security, memastikan tidak ada yang menghalangi. Kumpulan API yang disediakan oleh Spring Security tampaknya sangat berbeda dari non-Webflux hingga Webflux/Reaktif - person PatPatPat; 01.10.2020
comment
Ups, entah bagaimana saya melewatkan pertanyaan utama Anda tentang reaktif dan webflux. Saya telah menambahkan pembaruan kedua pada jawaban saya. Bisakah Anda mencobanya dan beri tahu saya jika itu berhasil untuk Anda? - person Hakan54; 01.10.2020
comment
Ini jelas menjanjikan, terima kasih! Namun, saya mendapatkan kesalahan di bawah ini pada metode MapReactiveUserDetailsService mapReactiveUserDetailsService(). Dapatkah Anda memberi saran? - person PatPatPat; 05.10.2020
comment
@Bean public MapReactiveUserDetailsService mapReactiveUserDetailsService() { UserDetails bob = User.withUsername(bob).build(); kembalikan MapReactiveUserDetailsService(bob); } - person PatPatPat; 05.10.2020
comment
Disebabkan oleh: java.lang.IllegalArgumentException: Tidak dapat meneruskan nilai nol atau kosong ke konstruktor di org.springframework.security.core.userdetails.User.‹init›(User.java:113) ~[spring-security-core-5.3. 4.RELEASE.jar:5.3.4.RELEASE] di org.springframework.security.core.userdetails.User$UserBuilder.build(User.java:535) ~[spring-security-core-5.3.4.RELEASE.jar :5.3.4.RELEASE] di a.X509AuthenticationServer.mapReactiveUserDetailsService(X509AuthenticationServer.java:67) ~[classes/:na] - person PatPatPat; 05.10.2020
comment
Beberapa bidang yang diperlukan untuk pengguna tidak ada. Saya telah memperbarui jawaban saya. Bisakah Anda mencobanya? - person Hakan54; 05.10.2020
comment
Saya memberi suara positif pada jawabannya. Saya yakin itu benar. Mungkin hanya saya, saya mendapatkan: - person PatPatPat; 15.10.2020
comment
java.lang.ClassCastException: kelas sun.security.x509.X509CertImpl tidak dapat dilemparkan ke kelas java.lang.String (sun.security.x509.X509CertImpl dan java.lang.String ada dalam modul java.base loader 'bootstrap') di org.springframework.security.authentication.AbstractUserDetailsReactiveAuthenticationManager.authenticate(AbstractUserDetailsReactiveAuthenticationManager.java:99) ~[spring-security-core-5.3.4.RELEASE.jar:5.3.4.RELEASE] Disembunyikan: reaktor.core.publisher.FluxOnAssembly $OnAssemblyException: - person PatPatPat; 15.10.2020
comment
Meskipun saya melihat dengan benar .w.a.p.x.SubjectDnX509PrincipalExtractor : Subject DN adalah (semuanya benar) dan .w.a.p.x.SubjectDnX509PrincipalExtractor : Extracted Principal name adalah (yang benar) - person PatPatPat; 15.10.2020
comment
Ya, memang benar Anda mengalami pengecualian itu. Sepertinya UserDetailsRepositoryReactiveAuthenticationManager tidak dapat menangani sertifikat. Ia mencoba memasukkan sertifikat ke dalam String dan mencoba memperlakukannya sebagai kata sandi. Saya telah memperbarui jawaban awal saya, dan untuk memastikannya berfungsi kali ini saya juga membuat proyek contoh yang berisi server dan klien dengan otentikasi bersama. Lihat di sini untuk detailnya sebagai referensi bagi Anda: github.com/Hakky54 /spring-keamanan-dengan-validasi-nama-umum - person Hakan54; 16.10.2020
comment
Saya pikir ini berhasil, setidaknya kesalahannya hilang. (Dan terima kasih banyak, saya sebenarnya mengetahui akar penyebab kesalahan tersebut). Namun, dalam sampel Anda, saya tidak melihat di mana terdapat pemeriksaan sebenarnya, yaitu saya ingin mengizinkan orang dengan CN=sesuatu, tetapi akan menolak mereka yang memiliki CN=buruk. Selain itu, dimungkinkan untuk mencapai hal yang sama tanpa kelas delegasi CertificateCommonNameAuthenticationManager untuk mendapatkan solusi yang cocok untuk semua di satu tempat? - person PatPatPat; 16.10.2020
comment
Ah ya, meskipun jawaban saya berhasil, namun bisa lebih disederhanakan. AuthenticationManager khusus tidak diperlukan. Terima kasih telah mereferensikan dokumentasi musim semi, saya memperbarui jawaban awal saya. Semoga bermanfaat juga bagi orang lain - person Hakan54; 16.10.2020
comment
Bagaimana kita bisa membaca kepala sekolah (nama pengguna yang diambil dari sertifikat) di Filter setelah Otentikasi x509 berhasil - person Siju Suresh; 20.01.2021