Spring Security ด้วย JWT สำหรับ REST API
เผยแพร่แล้ว: 2022-03-11Spring ถือเป็นเฟรมเวิร์กที่เชื่อถือได้ในระบบนิเวศของ 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
เราจะขยายอแด็ปเตอร์นี้และแทนที่ฟังก์ชันทั้งสองเพื่อ:
- กำหนดค่าตัวจัดการการตรวจสอบสิทธิ์ด้วยผู้ให้บริการที่ถูกต้อง
- กำหนดค่าการรักษาความปลอดภัยเว็บ (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 ตัวอย่างของฉัน