Spring Webflux Security - จุดสิ้นสุดที่ได้รับอนุญาตตามใบรับรองไคลเอ็นต์

คำถามเกี่ยวกับ Spring Security กับ Webflux

ฉันมีเว็บแอป SpringBoot Webflux พร้อม Spring Security แอปเดียวกันนี้ยังเปิดใช้งานเซิร์ฟเวอร์ SSL ด้วยที่เก็บคีย์และที่เก็บที่เชื่อถือได้สำหรับ SSL สองทาง, mTLS

ณ จุดนี้ ลูกค้าที่พยายามขออุปกรณ์ปลายทางจากแอปของฉันกำลังล้มเหลวหากไม่มีใบรับรองไคลเอ็นต์ที่ถูกต้อง เยี่ยมมาก! ไม่มีอะไรทำบนเลเยอร์แอป แค่กำหนดค่าที่เก็บคีย์และที่เก็บที่เชื่อถือได้ น่าทึ่งมาก

คำถาม: เป็นไปได้หรือไม่ที่จะอนุญาตเพิ่มเติมว่าใครสามารถเข้าถึงตำแหน่งข้อมูลเฉพาะโดยอิงจากใบรับรองไคลเอ็นต์นั้นเอง

โดยที่ฉันหมายถึงบางทีด้วย Spring Security ไคลเอนต์ไคลเอนต์ 1 ที่มาพร้อมกับใบรับรองไคลเอนต์ที่ถูกต้องต้องการขอ /endpointA จะสามารถเข้าถึงได้หากใบรับรองมี CN ที่ถูกต้อง แต่ไคลเอนต์ 2 จะถูกปฏิเสธไม่ให้ร้องขอ /endpointA หากไคลเอนต์ 2 มี CN ผิด

ในทางกลับกัน ลูกค้า A ที่มี CN ไม่ถูกต้องจะไม่สามารถขอ /endpointB ได้ ใช้ได้เฉพาะกับลูกค้า 2 ที่จะมีลูกค้า CN ที่ดีเท่านั้น

และแน่นอน หากไคลเอนต์ 3 มี CN ที่ไม่ถูกต้องสำหรับทั้ง /endpointA และ /endpointB ลูกค้า 3 จะไม่สามารถร้องขอสิ่งใด ๆ เหล่านั้นได้ (แต่เขามีใบรับรองไคลเอนต์ที่ถูกต้อง)

เป็นไปได้ไหมที่จะยกตัวอย่างกับ Spring Webflux (ไม่ใช่ MVC) โปรด สุดท้ายนี้ถ้าเป็นไปได้ล่ะ? ยังไง? (ข้อมูลโค้ดจะดีมาก)

ขอบคุณ


person PatPatPat    schedule 29.09.2020    source แหล่งที่มา


คำตอบ (1)


ใช่สิ่งนี้เป็นไปได้ คุณยังสามารถรักษาความปลอดภัยแอปพลิเคชันเว็บของคุณเพิ่มเติมได้ด้วยการตรวจสอบฟิลด์ CN ของใบรับรอง และบล็อกใบรับรองหากไม่มีชื่อที่ถูกต้อง ฉันไม่แน่ใจว่าจะเป็นไปได้หรือไม่เมื่อใช้ Spring Security ทันที แต่ฉันรู้ว่า AOP สามารถทำได้โดยใช้ AspectJ ด้วยวิธีนี้ คุณสามารถสกัดกั้นคำขอได้หลังจากการแฮนด์เชค SSL สำเร็จ และก่อนที่จะเข้าสู่คอนโทรลเลอร์ของคุณ ฉันขอแนะนำให้อ่านบทความนี้อย่างแน่นอน: ข้อมูลเบื้องต้นเกี่ยวกับ AspectJ เนื่องจากจะช่วยให้คุณเข้าใจแนวคิดพื้นฐาน ของห้องสมุด

สิ่งที่คุณสามารถทำได้คือสร้างคำอธิบายประกอบ เช่น ExtraCertificateValidations ซึ่งสามารถรับรายการชื่อสามัญที่ได้รับอนุญาตและไม่ได้รับอนุญาต ดูด้านล่างสำหรับการนำไปใช้งาน ด้วยวิธีนี้ คุณสามารถตัดสินใจเลือกคอนโทรลเลอร์ทุกตัวได้ว่า CN ใดที่คุณต้องการอนุญาตและไม่อนุญาต

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

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

}

Afterwords คุณสามารถใส่คำอธิบายประกอบคอนโทรลเลอร์ของคุณด้วยคำอธิบายประกอบข้างต้นและระบุชื่อทั่วไป:

@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 - ความปลอดภัยของสปริงพร้อมการตรวจสอบชื่อทั่วไป

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 ดูเหมือนจะแตกต่างอย่างสิ้นเชิงจาก non-Webflux เป็น Webflux/Reactive - person PatPatPat; 01.10.2020
comment
อ๊ะ ฉันพลาดไปว่าคำถามหลักของคุณเกี่ยวกับปฏิกิริยาและเว็บฟลักซ์ ฉันได้เพิ่มการอัปเดตครั้งที่สองในคำตอบของฉัน คุณช่วยลองมันแล้วแจ้งให้เราทราบว่ามันเหมาะกับคุณหรือไม่? - person Hakan54; 01.10.2020
comment
นี่เป็นสิ่งที่มีแนวโน้มดีอย่างแน่นอน ขอบคุณ! อย่างไรก็ตาม ฉันได้รับข้อผิดพลาดด้านล่างในวิธี MapReactiveUserDetailsService mapReactiveUserDetailsService() ได้โปรดให้คำแนะนำฉัน? - person PatPatPat; 05.10.2020
comment
@Bean สาธารณะ MapReactiveUserDetailsService mapReactiveUserDetailsService() { UserDetails bob = User.withUsername(bob).build(); ส่งคืน MapReactiveUserDetailsService ใหม่ (บ๊อบ); } - person PatPatPat; 05.10.2020
comment
เกิดจาก: java.lang.IllegalArgumentException: ไม่สามารถส่งค่า null หรือค่าว่างไปยัง Constructor ที่ 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) ~[classes/: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] ระงับ: reactor.core.publisher.FluxOnAssembly $OnAssemblyException: - person PatPatPat; 15.10.2020
comment
แม้ว่าฉันจะเห็นอย่างถูกต้อง .w.a.p.x.SubjectDnX509PrincipalExtractor : Subject DN is (ทุกอย่างถูกต้อง) และ .w.a.p.x.SubjectDnX509PrincipalExtractor : Extracted Principal name is (สิ่งที่ถูกต้อง) - person PatPatPat; 15.10.2020
comment
ใช่ ถูกต้องแล้วที่คุณพบข้อยกเว้นนั้น ดูเหมือนว่า UserDetailsRepositoryReactiveAuthenticationManager ไม่สามารถจัดการใบรับรองได้ พยายามแปลงใบรับรองเป็นสตริงและพยายามถือเป็นรหัสผ่าน ฉันได้อัปเดตคำตอบเริ่มต้นแล้ว และเพื่อให้แน่ใจว่าใช้งานได้ในครั้งนี้ ฉันยังได้สร้างโปรเจ็กต์ตัวอย่างที่มีเซิร์ฟเวอร์และไคลเอนต์ที่มีการตรวจสอบความถูกต้องร่วมกัน ดูรายละเอียดที่นี่เพื่อเป็นข้อมูลอ้างอิงสำหรับคุณ: github.com/Hakky54 /spring-security-with-common-name-validation - person Hakan54; 16.10.2020
comment
ฉันคิดว่ามันใช้งานได้ อย่างน้อยข้อผิดพลาดก็หายไป (และขอบคุณมาก ฉันได้เรียนรู้ถึงต้นตอของข้อผิดพลาดแล้ว) อย่างไรก็ตาม ในตัวอย่างของคุณ ฉันไม่เห็นว่าจะมีการตรวจสอบจริงที่ไหน เช่น ฉันต้องการอนุญาตให้ผู้ที่มี CN=บางอย่าง แต่จะปฏิเสธผู้ที่มี CN=ไม่ดี นอกจากนี้ยังเป็นไปได้ที่จะบรรลุผลเช่นเดียวกันโดยไม่ต้องมีคลาสผู้รับมอบสิทธิ์ CertificateCommonNameAuthenticationManager เพื่อให้มีโซลูชันที่เหมาะสมทั้งหมดในที่เดียว - person PatPatPat; 16.10.2020
comment
ใช่แล้ว แม้ว่าคำตอบของฉันจะเป็นกลอุบาย แต่ก็ทำให้ฉันง่ายขึ้นมาก ไม่จำเป็นต้องมี AuthenticationManager แบบกำหนดเอง ขอบคุณสำหรับการอ้างอิงเอกสารประกอบของ Spring ฉันได้อัปเดตคำตอบเริ่มต้นแล้ว หวังว่ามันจะเป็นประโยชน์สำหรับผู้อื่นเช่นกัน - person Hakan54; 16.10.2020
comment
เราจะอ่านหลักการได้อย่างไร (ชื่อผู้ใช้ที่แยกจากใบรับรอง) ในตัวกรองหลังจากการรับรองความถูกต้อง x509 สำเร็จ - person Siju Suresh; 20.01.2021