Spring Security ด้วย JWT สำหรับ REST API

เผยแพร่แล้ว: 2022-03-11

Spring ถือเป็นเฟรมเวิร์กที่เชื่อถือได้ในระบบนิเวศของ Java และใช้กันอย่างแพร่หลาย การอ้างถึง Spring เป็นเฟรมเวิร์กนั้นไม่ถูกต้องอีกต่อไป เนื่องจากเป็นคำศัพท์ในร่มที่ครอบคลุมเฟรมเวิร์กต่างๆ หนึ่งในเฟรมเวิร์กเหล่านี้คือ Spring Security ซึ่งเป็นเฟรมเวิร์กการพิสูจน์ตัวตนและการอนุญาตที่ทรงพลังและปรับแต่งได้ ถือเป็นมาตรฐานโดยพฤตินัยสำหรับการรักษาความปลอดภัยแอปพลิเคชันที่ใช้สปริง

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

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

การกำหนดคำศัพท์

ก่อนเจาะลึกรายละเอียดทางเทคนิค ฉันต้องการกำหนดคำศัพท์ที่ใช้ในบริบท Spring Security อย่างชัดเจน เพื่อให้แน่ใจว่าเราทุกคนพูดภาษาเดียวกัน

นี่คือข้อกำหนดที่เราต้องแก้ไข:

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

การสร้างแอปพลิเคชันสปริงพื้นฐาน

ก่อนย้ายไปยังการกำหนดค่าของเฟรมเวิร์ก Spring Security เรามาสร้างเว็บแอปพลิเคชัน Spring พื้นฐานกันก่อน สำหรับสิ่งนี้ เราสามารถใช้ Spring Initializr และสร้างโครงการเทมเพลตได้ สำหรับเว็บแอปพลิเคชันอย่างง่าย การพึ่งพาเฟรมเวิร์กเว็บของ Spring ก็เพียงพอแล้ว:

 <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>

เมื่อเราสร้างโปรเจ็กต์แล้ว เราสามารถเพิ่มคอนโทรลเลอร์ REST อย่างง่ายได้ดังนี้:

 @RestController @RequestMapping("hello") public class HelloRestController { @GetMapping("user") public String helloUser() { return "Hello User"; } @GetMapping("admin") public String helloAdmin() { return "Hello Admin"; } }

หลังจากนี้ หากเราสร้างและเรียกใช้โครงการ เราสามารถเข้าถึง URL ต่อไปนี้ในเว็บเบราว์เซอร์:

  • http://localhost:8080/hello/user จะส่งคืนสตริง Hello User
  • http://localhost:8080/hello/admin จะส่งคืนสตริง Hello Admin

ตอนนี้ เราสามารถเพิ่มเฟรมเวิร์ก Spring Security ให้กับโปรเจ็กต์ของเรา และเราสามารถทำได้โดยเพิ่มการพึ่งพาต่อไปนี้ในไฟล์ pom.xml ของเรา:

 <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies>

การเพิ่มการพึ่งพาเฟรมเวิร์ก Spring อื่นๆ จะไม่มีผลในทันทีกับแอปพลิเคชัน จนกว่าเราจะจัดเตรียมการกำหนดค่าที่เกี่ยวข้อง แต่ Spring Security นั้นแตกต่างกันตรงที่มีผลทันที และมักจะทำให้ผู้ใช้ใหม่สับสน หลังจากเพิ่มเข้าไปแล้ว หากเราสร้างและเรียกใช้โครงการใหม่ จากนั้นพยายามเข้าถึงหนึ่งใน URL ดังกล่าวแทนที่จะดูผลลัพธ์ เราจะถูกเปลี่ยนเส้นทางไปที่ http://localhost:8080/login นี่เป็นพฤติกรรมเริ่มต้นเนื่องจากเฟรมเวิร์ก Spring Security ต้องมีการตรวจสอบสิทธิ์นอกกรอบสำหรับ URL ทั้งหมด

ในการผ่านการตรวจสอบสิทธิ์ เราสามารถใช้ชื่อ user เริ่มต้นและค้นหารหัสผ่านที่สร้างขึ้นโดยอัตโนมัติในคอนโซลของเรา:

 Using generated security password: 1fc15145-dfee-4bec-a009-e32ca21c77ce

โปรดจำไว้ว่ารหัสผ่านจะเปลี่ยนทุกครั้งที่เราเรียกใช้แอปพลิเคชันอีกครั้ง หากเราต้องการเปลี่ยนการทำงานนี้และทำให้รหัสผ่านคงที่ เราสามารถเพิ่มการกำหนดค่าต่อไปนี้ในไฟล์ application.properties ของเรา:

 spring.security.user.password=Test12345_

ตอนนี้ หากเราป้อนข้อมูลประจำตัวในแบบฟอร์มการเข้าสู่ระบบ เราจะถูกเปลี่ยนเส้นทางกลับไปที่ URL ของเรา และเราจะเห็นผลลัพธ์ที่ถูกต้อง โปรดทราบว่ากระบวนการตรวจสอบสิทธิ์แบบสำเร็จรูปเป็นแบบเซสชัน และหากเราต้องการออกจากระบบ เราสามารถเข้าถึง URL ต่อไปนี้: http://localhost:8080/logout

ลักษณะการทำงานแบบสำเร็จรูปนี้อาจมีประโยชน์สำหรับเว็บแอปพลิเคชัน MVC แบบคลาสสิกที่เรามีการตรวจสอบสิทธิ์ตามเซสชัน แต่ในกรณีของแอปพลิเคชันแบบหน้าเดียว มักไม่มีประโยชน์เพราะในกรณีการใช้งานส่วนใหญ่ เรามีฝั่งไคลเอ็นต์ การเรนเดอร์และการพิสูจน์ตัวตนแบบไร้สัญชาติตาม JWT ในกรณีนี้ เราจะต้องปรับแต่งเฟรมเวิร์ก Spring Security อย่างมาก ซึ่งเราจะทำในส่วนที่เหลือของบทความ

ตัวอย่างเช่น เราจะใช้เว็บแอปพลิเคชันร้านหนังสือแบบคลาสสิก และสร้างส่วนหลังที่จะจัดเตรียม CRUD API เพื่อสร้างผู้แต่งและหนังสือ รวมทั้ง API สำหรับการจัดการผู้ใช้และการตรวจสอบสิทธิ์

ภาพรวมสถาปัตยกรรมความปลอดภัยของสปริง

ก่อนที่เราจะเริ่มปรับแต่งการกำหนดค่า มาคุยกันก่อนว่าการรับรองความถูกต้องของ Spring Security ทำงานอย่างไรเบื้องหลัง

ไดอะแกรมต่อไปนี้แสดงโฟลว์และแสดงวิธีประมวลผลคำขอการรับรองความถูกต้อง:

สถาปัตยกรรมความปลอดภัยสปริง

สถาปัตยกรรมความปลอดภัยสปริง

ตอนนี้ เรามาแบ่งไดอะแกรมนี้ออกเป็นส่วนประกอบและอภิปรายแยกกัน

ห่วงโซ่ตัวกรองความปลอดภัยสปริง

เมื่อคุณเพิ่มเฟรมเวิร์ก Spring Security ให้กับแอปพลิเคชันของคุณ เฟรมเวิร์กจะลงทะเบียนกลุ่มตัวกรองที่สกัดกั้นคำขอที่เข้ามาทั้งหมดโดยอัตโนมัติ ห่วงโซ่นี้ประกอบด้วยตัวกรองต่างๆ และแต่ละตัวจะจัดการกับกรณีการใช้งานเฉพาะ

ตัวอย่างเช่น:

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

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

 spring.security.filter.order=10

เมื่อเราเพิ่มการกำหนดค่านี้ลงในไฟล์ application.properties เราจะมีพื้นที่สำหรับตัวกรองที่กำหนดเอง 10 ตัวที่ด้านหน้าตัวกรอง Spring Security

AuthenticationManager

คุณสามารถคิดว่า AuthenticationManager เป็นผู้ประสานงานที่คุณสามารถลงทะเบียนผู้ให้บริการได้หลายราย และจะส่งคำขอตรวจสอบสิทธิ์ไปยังผู้ให้บริการที่ถูกต้องตามประเภทคำขอ

AuthenticationProvider

AuthenticationProvider ประมวลผลการพิสูจน์ตัวตนบางประเภท อินเทอร์เฟซแสดงเพียงสองฟังก์ชัน:

  • authenticate ดำเนินการตรวจสอบกับคำขอ
  • supports ตรวจสอบว่าผู้ให้บริการรายนี้สนับสนุนประเภทการตรวจสอบสิทธิ์ที่ระบุหรือไม่

การใช้งานอินเทอร์เฟซที่สำคัญอย่างหนึ่งที่เราใช้ในโครงการตัวอย่างของเราคือ DaoAuthenticationProvider ซึ่งดึงรายละเอียดผู้ใช้จาก UserDetailsService

รายละเอียดผู้ใช้บริการ

UserDetailsService อธิบายว่าเป็นอินเทอร์เฟซหลักที่โหลดข้อมูลเฉพาะผู้ใช้ในเอกสารประกอบของ Spring

ในกรณีการใช้งานส่วนใหญ่ ผู้ให้บริการรับรองความถูกต้องจะดึงข้อมูลประจำตัวผู้ใช้ตามข้อมูลประจำตัวจากฐานข้อมูล แล้วดำเนินการตรวจสอบความถูกต้อง เนื่องจากกรณีการใช้งานนี้พบเห็นได้ทั่วไป นักพัฒนา Spring จึงตัดสินใจแยกออกเป็นอินเทอร์เฟซแยกต่างหาก ซึ่งแสดงฟังก์ชันเดียว:

  • loadUserByUsername ยอมรับชื่อผู้ใช้เป็นพารามิเตอร์และส่งกลับวัตถุเอกลักษณ์ของผู้ใช้

การตรวจสอบสิทธิ์โดยใช้ JWT กับ Spring Security

หลังจากพูดคุยเกี่ยวกับเฟรมเวิร์กภายในของ Spring Security แล้ว เรามากำหนดค่าสำหรับการพิสูจน์ตัวตนแบบไร้สัญชาติด้วยโทเค็น JWT

ในการปรับแต่ง Spring Security เราจำเป็นต้องมีคลาสการกำหนดค่าที่มีคำอธิบายประกอบ @EnableWebSecurity ใน classpath ของเรา นอกจากนี้ เพื่อลดความซับซ้อนของกระบวนการปรับแต่งเอง เฟรมเวิร์กจะแสดงคลาส WebSecurityConfigurerAdapter เราจะขยายอแด็ปเตอร์นี้และแทนที่ฟังก์ชันทั้งสองเพื่อ:

  1. กำหนดค่าตัวจัดการการตรวจสอบสิทธิ์ด้วยผู้ให้บริการที่ถูกต้อง
  2. กำหนดค่าการรักษาความปลอดภัยเว็บ (URL สาธารณะ, URL ส่วนตัว, การอนุญาต ฯลฯ)
 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // TODO configure authentication manager } @Override protected void configure(HttpSecurity http) throws Exception { // TODO configure web security } }

ในแอปพลิเคชันตัวอย่างของเรา เราจัดเก็บข้อมูลประจำตัวผู้ใช้ในฐานข้อมูล MongoDB ในคอลเล็กชัน users ข้อมูลระบุตัวตนเหล่านี้ถูกแมปโดยเอนทิตี User และการดำเนินการ CRUD ถูกกำหนดโดยที่ UserRepo Spring Data

ตอนนี้ เมื่อเรายอมรับคำขอตรวจสอบสิทธิ์ เราจำเป็นต้องดึงข้อมูลประจำตัวที่ถูกต้องจากฐานข้อมูลโดยใช้ข้อมูลประจำตัวที่ให้มา จากนั้นจึงตรวจสอบ สำหรับสิ่งนี้ เราจำเป็นต้องมีการใช้งานอินเทอร์เฟซ UserDetailsService ซึ่งกำหนดไว้ดังนี้:

 public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }

ในที่นี้ เราจะเห็นว่าจำเป็นต้องส่งคืนอ็อบเจ็กต์ที่ใช้อินเทอร์เฟซ UserDetails และเอนทิตี User ของเรานำไปใช้ (สำหรับรายละเอียดการใช้งาน โปรดดูที่เก็บของโปรเจ็กต์ตัวอย่าง) เมื่อพิจารณาจากข้อเท็จจริงที่ว่ามันแสดงเฉพาะต้นแบบฟังก์ชันเดียว เราสามารถถือว่ามันเป็นอินเทอร์เฟซที่ใช้งานได้และจัดเตรียมการใช้งานเป็นนิพจน์แลมบ์ดา

 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { private final UserRepo userRepo; public SecurityConfig(UserRepo userRepo) { this.userRepo = userRepo; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(username -> userRepo .findByUsername(username) .orElseThrow( () -> new UsernameNotFoundException( format("User: %s, not found", username) ) )); } // Details omitted for brevity }

ที่นี่ การเรียกใช้ฟังก์ชัน auth.userDetailsService จะเริ่มต้นอินสแตนซ์ DaoAuthenticationProvider โดยใช้การใช้งานอินเทอร์เฟซ UserDetailsService ของเราและลงทะเบียนในตัวจัดการการตรวจสอบสิทธิ์

เราต้องกำหนดค่าตัวจัดการการตรวจสอบสิทธิ์ด้วยสคีมาการเข้ารหัสรหัสผ่านที่ถูกต้องซึ่งจะใช้สำหรับการตรวจสอบข้อมูลประจำตัวร่วมกับผู้ให้บริการตรวจสอบสิทธิ์ สำหรับสิ่งนี้ เราจำเป็นต้องเปิดเผยการใช้งานที่ต้องการของอินเทอร์เฟซ PasswordEncoder เป็นถั่ว

ในโครงการตัวอย่างของเรา เราจะใช้อัลกอริธึมการแฮชรหัสผ่าน bcrypt

 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { private final UserRepo userRepo; public SecurityConfig(UserRepo userRepo) { this.userRepo = userRepo; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(username -> userRepo .findByUsername(username) .orElseThrow( () -> new UsernameNotFoundException( format("User: %s, not found", username) ) )); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // Details omitted for brevity }

หลังจากกำหนดค่าตัวจัดการการตรวจสอบสิทธิ์แล้ว ตอนนี้เราต้องกำหนดค่าความปลอดภัยของเว็บ เรากำลังใช้งาน REST API และต้องการการพิสูจน์ตัวตนแบบไร้สัญชาติด้วยโทเค็น JWT ดังนั้น เราจำเป็นต้องตั้งค่าตัวเลือกต่อไปนี้:

  • เปิดใช้งาน CORS และปิดใช้งาน CSRF
  • ตั้งค่าการจัดการเซสชันเป็นไร้สัญชาติ
  • ตั้งค่าตัวจัดการข้อยกเว้นคำขอที่ไม่ได้รับอนุญาต
  • ตั้งค่าการอนุญาตบนปลายทาง
  • เพิ่มตัวกรองโทเค็น JWT

การกำหนดค่านี้ดำเนินการดังนี้:

 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { private final UserRepo userRepo; private final JwtTokenFilter jwtTokenFilter; public SecurityConfig(UserRepo userRepo, JwtTokenFilter jwtTokenFilter) { this.userRepo = userRepo; this.jwtTokenFilter = jwtTokenFilter; } // Details omitted for brevity @Override protected void configure(HttpSecurity http) throws Exception { // Enable CORS and disable CSRF http = http.cors().and().csrf().disable(); // Set session management to stateless http = http .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and(); // Set unauthorized requests exception handler http = http .exceptionHandling() .authenticationEntryPoint( (request, response, ex) -> { response.sendError( HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage() ); } ) .and(); // Set permissions on endpoints http.authorizeRequests() // Our public endpoints .antMatchers("/api/public/**").permitAll() .antMatchers(HttpMethod.GET, "/api/author/**").permitAll() .antMatchers(HttpMethod.POST, "/api/author/search").permitAll() .antMatchers(HttpMethod.GET, "/api/book/**").permitAll() .antMatchers(HttpMethod.POST, "/api/book/search").permitAll() // Our private endpoints .anyRequest().authenticated(); // Add JWT token filter http.addFilterBefore( jwtTokenFilter, UsernamePasswordAuthenticationFilter.class ); } // Used by spring security if CORS is enabled. @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

โปรดทราบว่าเราได้เพิ่ม JwtTokenFilter ก่อน Spring Security Internal UsernamePasswordAuthenticationFilter เราทำสิ่งนี้เพราะเราต้องการเข้าถึงข้อมูลประจำตัวผู้ใช้ ณ จุดนี้เพื่อดำเนินการตรวจสอบสิทธิ์/ให้สิทธิ์ และการแยกข้อมูลเกิดขึ้นภายในตัวกรองโทเค็น JWT ตามโทเค็น JWT ที่ให้มา มีการดำเนินการดังนี้:

 @Component public class JwtTokenFilter extends OncePerRequestFilter { private final JwtTokenUtil jwtTokenUtil; private final UserRepo userRepo; public JwtTokenFilter(JwtTokenUtil jwtTokenUtil, UserRepo userRepo) { this.jwtTokenUtil = jwtTokenUtil; this.userRepo = userRepo; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // Get authorization header and validate final String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (isEmpty(header) || !header.startsWith("Bearer ")) { chain.doFilter(request, response); return; } // Get jwt token and validate final String token = header.split(" ")[1].trim(); if (!jwtTokenUtil.validate(token)) { chain.doFilter(request, response); return; } // Get user identity and set it on the spring security context UserDetails userDetails = userRepo .findByUsername(jwtTokenUtil.getUsername(token)) .orElse(null); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails == null ? List.of() : userDetails.getAuthorities() ); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authentication); chain.doFilter(request, response); } }

ก่อนที่จะใช้ฟังก์ชัน API สำหรับการเข้าสู่ระบบ เราต้องดูแลอีกขั้นตอนหนึ่ง - เราจำเป็นต้องเข้าถึงตัวจัดการการตรวจสอบสิทธิ์ โดยค่าเริ่มต้น มันไม่สามารถเข้าถึงได้แบบสาธารณะ และเราจำเป็นต้องเปิดเผยอย่างชัดเจนว่าเป็น bean ในคลาสการกำหนดค่าของเรา

สามารถทำได้ดังนี้:

 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }

และตอนนี้ เราก็พร้อมที่จะใช้ฟังก์ชัน API การเข้าสู่ระบบของเราแล้ว:

 @Api(tags = "Authentication") @RestController @RequestMapping(path = "api/public") public class AuthApi { private final AuthenticationManager authenticationManager; private final JwtTokenUtil jwtTokenUtil; private final UserViewMapper userViewMapper; public AuthApi(AuthenticationManager authenticationManager, JwtTokenUtil jwtTokenUtil, UserViewMapper userViewMapper) { this.authenticationManager = authenticationManager; this.jwtTokenUtil = jwtTokenUtil; this.userViewMapper = userViewMapper; } @PostMapping("login") public ResponseEntity<UserView> login(@RequestBody @Valid AuthRequest request) { try { Authentication authenticate = authenticationManager .authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); User user = (User) authenticate.getPrincipal(); return ResponseEntity.ok() .header( HttpHeaders.AUTHORIZATION, jwtTokenUtil.generateAccessToken(user) ) .body(userViewMapper.toUserView(user)); } catch (BadCredentialsException ex) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } } }

ที่นี่ เราตรวจสอบข้อมูลประจำตัวที่ให้ไว้โดยใช้ตัวจัดการการตรวจสอบสิทธิ์ และในกรณีที่สำเร็จ เราจะสร้างโทเค็น JWT และส่งคืนเป็นส่วนหัวของการตอบสนองพร้อมกับข้อมูลประจำตัวผู้ใช้ในเนื้อหาการตอบกลับ

การอนุญาตด้วย Spring Security

ในส่วนก่อนหน้านี้ เราได้ตั้งค่ากระบวนการตรวจสอบและกำหนดค่า URL สาธารณะ/ส่วนตัว นี่อาจเพียงพอสำหรับแอปพลิเคชันทั่วไป แต่สำหรับกรณีการใช้งานจริงส่วนใหญ่ เราต้องการนโยบายการเข้าถึงตามบทบาทสำหรับผู้ใช้ของเราเสมอ ในบทนี้ เราจะแก้ไขปัญหานี้และตั้งค่าสคีมาการอนุญาตตามบทบาทโดยใช้เฟรมเวิร์ก Spring Security

ในแอปพลิเคชันตัวอย่างของเรา เราได้กำหนดบทบาทสามประการต่อไปนี้:

  • USER_ADMIN อนุญาตให้เราจัดการผู้ใช้แอปพลิเคชัน
  • AUTHOR_ADMIN ช่วยให้เราสามารถจัดการผู้เขียนได้
  • BOOK_ADMIN ช่วยให้เราจัดการหนังสือได้

ตอนนี้ เราจำเป็นต้องนำไปใช้กับ URL ที่เกี่ยวข้อง:

  • api/public สามารถเข้าถึงได้โดยสาธารณะ
  • api/admin/user สามารถเข้าถึงผู้ใช้ที่มีบทบาท USER_ADMIN
  • api/author สามารถเข้าถึงผู้ใช้ที่มีบทบาท AUTHOR_ADMIN
  • api/book สามารถเข้าถึงผู้ใช้ที่มีบทบาท BOOK_ADMIN

กรอบงาน Spring Security ให้สองตัวเลือกแก่เราในการตั้งค่าสคีมาการอนุญาต:

  • การกำหนดค่าตาม URL
  • การกำหนดค่าตามคำอธิบายประกอบ

ขั้นแรก เรามาดูกันว่าการกำหนดค่าตาม URL ทำงานอย่างไร สามารถใช้กับการกำหนดค่าความปลอดภัยของเว็บได้ดังนี้:

 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Override protected void configure(HttpSecurity http) throws Exception { // Enable CORS and disable CSRF http = http.cors().and().csrf().disable(); // Set session management to stateless http = http .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and(); // Set unauthorized requests exception handler http = http .exceptionHandling() .authenticationEntryPoint( (request, response, ex) -> { response.sendError( HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage() ); } ) .and(); // Set permissions on endpoints http.authorizeRequests() // Our public endpoints .antMatchers("/api/public/**").permitAll() .antMatchers(HttpMethod.GET, "/api/author/**").permitAll() .antMatchers(HttpMethod.POST, "/api/author/search").permitAll() .antMatchers(HttpMethod.GET, "/api/book/**").permitAll() .antMatchers(HttpMethod.POST, "/api/book/search").permitAll() // Our private endpoints .antMatchers("/api/admin/user/**").hasRole(Role.USER_ADMIN) .antMatchers("/api/author/**").hasRole(Role.AUTHOR_ADMIN) .antMatchers("/api/book/**").hasRole(Role.BOOK_ADMIN) .anyRequest().authenticated(); // Add JWT token filter http.addFilterBefore( jwtTokenFilter, UsernamePasswordAuthenticationFilter.class ); } // Details omitted for brevity }

อย่างที่คุณเห็น วิธีการนี้เรียบง่ายและตรงไปตรงมา แต่มีข้อเสียอยู่อย่างหนึ่ง สคีมาการอนุญาตในแอปพลิเคชันของเราอาจซับซ้อน และหากเรากำหนดกฎทั้งหมดไว้ในที่เดียว กฎเกณฑ์นั้นจะใหญ่ ซับซ้อน และอ่านยาก ด้วยเหตุนี้ ฉันจึงมักจะชอบใช้การกำหนดค่าตามคำอธิบายประกอบ

กรอบงาน Spring Security กำหนดคำอธิบายประกอบต่อไปนี้สำหรับการรักษาความปลอดภัยเว็บ:

  • @PreAuthorize รองรับ Spring Expression Language และใช้เพื่อจัดเตรียมการควบคุมการเข้าถึงตามนิพจน์ ก่อน ดำเนินการเมธอด
  • @PostAuthorize รองรับ Spring Expression Language และใช้เพื่อจัดเตรียมการควบคุมการเข้าถึงตามนิพจน์ หลังจาก รันเมธอด (ให้ความสามารถในการเข้าถึงผลลัพธ์ของเมธอด)
  • @PreFilter รองรับ Spring Expression Language และใช้เพื่อกรองคอลเล็กชันหรืออาร์เรย์ ก่อน ดำเนินการเมธอดตามกฎความปลอดภัยแบบกำหนดเองที่เรากำหนด
  • @PostFilter รองรับ Spring Expression Language และใช้เพื่อกรองคอลเลกชันหรืออาร์เรย์ที่ส่งคืน หลังจาก รันเมธอดตามกฎความปลอดภัยแบบกำหนดเองที่เรากำหนด (ให้ความสามารถในการเข้าถึงผลลัพธ์ของเมธอด)
  • @Secured ไม่รองรับ Spring Expression Language และใช้เพื่อระบุรายการบทบาทในเมธอด
  • @RolesAllowed ไม่รองรับ Spring Expression Language และเป็นคำอธิบายประกอบที่เทียบเท่ากับ JSR 250 ของคำอธิบายประกอบ @Secured

คำอธิบายประกอบเหล่านี้ถูกปิดใช้งานโดยค่าเริ่มต้นและสามารถเปิดใช้งานในแอปพลิเคชันของเราได้ดังนี้:

 @EnableWebSecurity @EnableGlobalMethodSecurity( securedEnabled = true, jsr250Enabled = true, prePostEnabled = true ) public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity }


securedEnabled = true เปิดใช้งานคำอธิบายประกอบ @Secured
jsr250Enabled = true เปิดใช้งานคำอธิบายประกอบ @RolesAllowed
prePostEnabled = true เปิดใช้งานคำอธิบายประกอบ @PreAuthorize , @PostAuthorize , @PreFilter , @PostFilter

หลังจากเปิดใช้งาน เราสามารถบังคับใช้นโยบายการเข้าถึงตามบทบาทบนปลายทาง API ของเราได้ดังนี้:

 @Api(tags = "UserAdmin") @RestController @RequestMapping(path = "api/admin/user") @RolesAllowed(Role.USER_ADMIN) public class UserAdminApi { // Details omitted for brevity } @Api(tags = "Author") @RestController @RequestMapping(path = "api/author") public class AuthorApi { // Details omitted for brevity @RolesAllowed(Role.AUTHOR_ADMIN) @PostMapping public void create() { } @RolesAllowed(Role.AUTHOR_ADMIN) @PutMapping("{id}") public void edit() { } @RolesAllowed(Role.AUTHOR_ADMIN) @DeleteMapping("{id}") public void delete() { } @GetMapping("{id}") public void get() { } @GetMapping("{id}/book") public void getBooks() { } @PostMapping("search") public void search() { } } @Api(tags = "Book") @RestController @RequestMapping(path = "api/book") public class BookApi { // Details omitted for brevity @RolesAllowed(Role.BOOK_ADMIN) @PostMapping public BookView create() { } @RolesAllowed(Role.BOOK_ADMIN) @PutMapping("{id}") public void edit() { } @RolesAllowed(Role.BOOK_ADMIN) @DeleteMapping("{id}") public void delete() { } @GetMapping("{id}") public void get() { } @GetMapping("{id}/author") public void getAuthors() { } @PostMapping("search") public void search() { } }

โปรดทราบว่าสามารถให้คำอธิบายประกอบด้านความปลอดภัยทั้งในระดับคลาสและระดับเมธอด

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

ชื่อบทบาท คำนำหน้าเริ่มต้น

ในส่วนย่อยที่แยกออกมานี้ ฉันต้องการเน้นรายละเอียดปลีกย่อยอีกหนึ่งรายละเอียดที่สร้างความสับสนให้กับผู้ใช้ใหม่จำนวนมาก

กรอบงาน Spring Security แยกความแตกต่างสองคำ:

  • Authority แสดงถึงการอนุญาตส่วนบุคคล
  • Role แสดงถึงกลุ่มของสิทธิ์

ทั้งสองสามารถแสดงด้วยอินเทอร์เฟซเดียวที่เรียกว่า GrantedAuthority และตรวจสอบในภายหลังด้วย Spring Expression Language ภายในคำอธิบายประกอบ Spring Security ดังนี้:

  • Authority : @PreAuthorize(“hasAuthority('EDIT_BOOK')”)
  • Role : @PreAuthorize(“hasRole('BOOK_ADMIN')”)

เพื่อให้ความแตกต่างระหว่างคำสองคำนี้ชัดเจนยิ่งขึ้น กรอบงาน Spring Security จะเพิ่มคำนำหน้า ROLE_ ให้กับชื่อบทบาทโดยค่าเริ่มต้น ดังนั้น แทนที่จะตรวจสอบบทบาทที่ชื่อ BOOK_ADMIN จะตรวจสอบ ROLE_BOOK_ADMIN

โดยส่วนตัวแล้ว ฉันพบว่าพฤติกรรมนี้ทำให้เกิดความสับสนและต้องการปิดการใช้งานในแอปพลิเคชันของฉัน สามารถปิดใช้งานได้ภายในการกำหนดค่า Spring Security ดังต่อไปนี้:

 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Bean GrantedAuthorityDefaults grantedAuthorityDefaults() { return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix } }

ทดสอบกับ Spring Security

ในการทดสอบจุดปลายของเราด้วยหน่วยหรือการทดสอบการรวมเมื่อใช้เฟรมเวิร์ก Spring Security เราจำเป็นต้องเพิ่มการพึ่งพา spring-security-test พร้อมกับ spring-boot-starter-test ไฟล์ pom.xml ของเราจะมีลักษณะดังนี้:

 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency>

การพึ่งพานี้ทำให้เราเข้าถึงคำอธิบายประกอบบางอย่างที่สามารถใช้เพื่อเพิ่มบริบทความปลอดภัยให้กับฟังก์ชันการทดสอบของเรา

คำอธิบายประกอบเหล่านี้คือ:

  • @WithMockUser สามารถเพิ่มลงในวิธีการทดสอบเพื่อจำลองการทำงานกับผู้ใช้ที่เยาะเย้ย
  • สามารถเพิ่ม @WithUserDetails ลงในวิธีการทดสอบเพื่อจำลองการทำงานด้วย UserDetails ส่งคืนจาก UserDetailsService
  • สามารถเพิ่ม @WithAnonymousUser ลงในวิธีการทดสอบเพื่อจำลองการทำงานกับผู้ใช้ที่ไม่ระบุชื่อได้ สิ่งนี้มีประโยชน์เมื่อผู้ใช้ต้องการเรียกใช้การทดสอบส่วนใหญ่ในฐานะผู้ใช้เฉพาะและแทนที่วิธีการบางอย่างที่ไม่ระบุชื่อ
  • @WithSecurityContext กำหนดว่า SecurityContext ใดที่จะใช้ และคำอธิบายประกอบทั้งสามที่อธิบายข้างต้นนั้นอิงตามนั้น หากเรามีกรณีการใช้งานเฉพาะ เราสามารถสร้างคำอธิบายประกอบของเราเองที่ใช้ @WithSecurityContext เพื่อสร้าง SecurityContext ที่เราต้องการ การอภิปรายอยู่นอกขอบเขตของบทความของเรา และโปรดอ้างอิงเอกสาร Spring Security สำหรับรายละเอียดเพิ่มเติม

วิธีที่ง่ายที่สุดในการรันการทดสอบกับผู้ใช้เฉพาะคือการใช้คำอธิบายประกอบ @WithMockUser เราสามารถสร้างผู้ใช้จำลองและรันการทดสอบได้ดังนี้:

 @Test @WithMockUser(username="[email protected]", roles={"USER_ADMIN"}) public void test() { // Details omitted for brevity }

วิธีการนี้มีข้อเสียอยู่สองสามประการ ประการแรก ผู้ใช้จำลองไม่มีอยู่จริง และหากคุณเรียกใช้การทดสอบการรวม ซึ่งภายหลังสอบถามข้อมูลผู้ใช้จากฐานข้อมูล การทดสอบจะล้มเหลว ประการที่สอง ผู้ใช้จำลองคืออินสแตนซ์ของ org.springframework.security.core.userdetails.User ซึ่งเป็นการใช้งานภายในของเฟรมเวิร์ก Spring ของอินเทอร์เฟซ UserDetails และหากเรามีการใช้งานของเราเอง สิ่งนี้อาจทำให้เกิดข้อขัดแย้งได้ในภายหลัง ระหว่าง การทดสอบการดำเนินการ

หากข้อเสียก่อนหน้านี้เป็นตัวบล็อกสำหรับแอปพลิเคชันของเรา คำอธิบายประกอบ @WithUserDetails เป็นวิธีที่จะไป ใช้เมื่อเรามีการใช้งาน UserDetails และ UserDetailsService แบบกำหนดเอง โดยถือว่าผู้ใช้มีอยู่จริง ดังนั้นเราต้องสร้างแถวจริงในฐานข้อมูลหรือจัดเตรียมอินสแตนซ์จำลอง UserDetailsService ก่อนทำการทดสอบ

นี่คือวิธีที่เราใช้คำอธิบายประกอบนี้:

 @Test @WithUserDetails("[email protected]") public void test() { // Details omitted for brevity }

นี่เป็นคำอธิบายประกอบที่แนะนำในการทดสอบการรวมโปรเจ็กต์ตัวอย่างของเรา เนื่องจากเรามีการใช้งานอินเทอร์เฟซดังกล่าวแบบกำหนดเอง

การใช้ @WithAnonymousUser อนุญาตให้เรียกใช้ในฐานะผู้ใช้ที่ไม่ระบุชื่อ ซึ่งจะสะดวกเป็นพิเศษเมื่อคุณต้องการเรียกใช้การทดสอบส่วนใหญ่กับผู้ใช้เฉพาะราย แต่ทำการทดสอบสองสามรายการในฐานะผู้ใช้ที่ไม่ระบุชื่อ ตัวอย่างเช่น สิ่งต่อไปนี้จะเรียกใช้กรณีทดสอบ test1 และ test2 กับผู้ใช้จำลอง และ test3 กับผู้ใช้ที่ไม่ระบุชื่อ:

 @SpringBootTest @AutoConfigureMockMvc @WithMockUser public class WithUserClassLevelAuthenticationTests { @Test public void test1() { // Details omitted for brevity } @Test public void test2() { // Details omitted for brevity } @Test @WithAnonymousUser public void test3() throws Exception { // Details omitted for brevity } }

ห่อ

ในท้ายที่สุด ฉันอยากจะพูดถึงว่าเฟรมเวิร์ก Spring Security อาจจะไม่ชนะการประกวดความงามใดๆ และมีช่วงการเรียนรู้ที่สูงชันอย่างแน่นอน ฉันพบสถานการณ์มากมายที่มันถูกแทนที่ด้วยวิธีแก้ปัญหาพื้นบ้านเนื่องจากความซับซ้อนในการกำหนดค่าเริ่มต้น แต่เมื่อนักพัฒนาเข้าใจถึงระบบภายในและตั้งค่าคอนฟิกเริ่มต้นได้แล้ว มันก็จะใช้งานได้ค่อนข้างตรงไปตรงมา

ในบทความนี้ ฉันพยายามแสดงรายละเอียดที่ละเอียดอ่อนทั้งหมดของการกำหนดค่า และฉันหวังว่าคุณจะพบว่าตัวอย่างมีประโยชน์ สำหรับตัวอย่างโค้ดทั้งหมด โปรดดูที่ที่เก็บ Git ของโปรเจ็กต์ Spring Security ตัวอย่างของฉัน