Keamanan Musim Semi dengan JWT untuk REST API
Diterbitkan: 2022-03-11Musim semi dianggap sebagai kerangka kerja tepercaya dalam ekosistem Java dan banyak digunakan. Tidak lagi valid untuk merujuk ke Spring sebagai kerangka kerja, karena ini lebih merupakan istilah umum yang mencakup berbagai kerangka kerja. Salah satu kerangka kerja ini adalah Keamanan Musim Semi, yang merupakan kerangka kerja otentikasi dan otorisasi yang kuat dan dapat disesuaikan. Ini dianggap sebagai standar de facto untuk mengamankan aplikasi berbasis Spring.
Terlepas dari popularitasnya, saya harus mengakui bahwa ketika datang ke aplikasi satu halaman, itu tidak sederhana dan mudah untuk dikonfigurasi. Saya menduga alasannya adalah bahwa itu dimulai lebih sebagai kerangka kerja berorientasi aplikasi MVC, di mana rendering halaman web terjadi di sisi server dan komunikasi berbasis sesi.
Jika bagian belakang didasarkan pada Java dan Spring, masuk akal untuk menggunakan Spring Security untuk otentikasi/otorisasi dan mengonfigurasinya untuk komunikasi stateless. Meskipun ada banyak artikel yang menjelaskan bagaimana ini dilakukan, bagi saya, masih frustasi untuk mengaturnya untuk pertama kalinya, dan saya harus membaca dan merangkum informasi dari berbagai sumber. Itu sebabnya saya memutuskan untuk menulis artikel ini, di mana saya akan mencoba meringkas dan mencakup semua detail halus yang diperlukan dan kelemahan yang mungkin Anda temui selama proses konfigurasi.
Mendefinisikan Terminologi
Sebelum masuk ke detail teknis, saya ingin secara eksplisit mendefinisikan terminologi yang digunakan dalam konteks Keamanan Musim Semi hanya untuk memastikan bahwa kita semua berbicara dalam bahasa yang sama.
Ini adalah istilah yang perlu kita tangani:
- Otentikasi mengacu pada proses verifikasi identitas pengguna, berdasarkan kredensial yang diberikan. Contoh umum adalah memasukkan nama pengguna dan kata sandi saat Anda masuk ke situs web. Anda dapat menganggapnya sebagai jawaban atas pertanyaan Siapa Anda? .
- Otorisasi mengacu pada proses menentukan apakah pengguna memiliki izin yang tepat untuk melakukan tindakan tertentu atau membaca data tertentu, dengan asumsi bahwa pengguna berhasil diautentikasi. Anda dapat menganggapnya sebagai jawaban atas pertanyaan Dapatkah pengguna melakukan/membaca ini? .
- Prinsip mengacu pada pengguna yang saat ini diautentikasi.
- Otoritas yang diberikan mengacu pada izin dari pengguna yang diautentikasi.
- Peran mengacu pada sekelompok izin dari pengguna yang diautentikasi.
Membuat Aplikasi Pegas Dasar
Sebelum pindah ke konfigurasi kerangka Spring Security, mari kita buat aplikasi web dasar Spring. Untuk ini, kita dapat menggunakan Spring Initializr dan menghasilkan proyek template. Untuk aplikasi web sederhana, hanya ketergantungan kerangka kerja web Spring yang cukup:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>
Setelah kami membuat proyek, kami dapat menambahkan pengontrol REST sederhana ke dalamnya sebagai berikut:
@RestController @RequestMapping("hello") public class HelloRestController { @GetMapping("user") public String helloUser() { return "Hello User"; } @GetMapping("admin") public String helloAdmin() { return "Hello Admin"; } }
Setelah ini, jika kita membangun dan menjalankan proyek, kita dapat mengakses URL berikut di browser web:
-
http://localhost:8080/hello/user
akan mengembalikan stringHello User
. -
http://localhost:8080/hello/admin
akan mengembalikan stringHello Admin
.
Sekarang, kita dapat menambahkan kerangka kerja Keamanan Musim Semi ke proyek kita, dan kita dapat melakukannya dengan menambahkan ketergantungan berikut ke file pom.xml
kita:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies>
Menambahkan dependensi kerangka kerja Spring lainnya biasanya tidak memiliki efek langsung pada aplikasi sampai kami menyediakan konfigurasi yang sesuai, tetapi Keamanan Musim Semi berbeda karena memiliki efek langsung, dan ini biasanya membingungkan pengguna baru. Setelah menambahkannya, jika kita membangun kembali dan menjalankan proyek dan kemudian mencoba mengakses salah satu URL yang disebutkan di atas alih-alih melihat hasilnya, kita akan diarahkan ke http://localhost:8080/login
. Ini adalah perilaku default karena kerangka kerja Keamanan Musim Semi memerlukan otentikasi di luar kotak untuk semua URL.
Untuk melewati otentikasi, kami dapat menggunakan nama user
default dan menemukan kata sandi yang dibuat secara otomatis di konsol kami:
Using generated security password: 1fc15145-dfee-4bec-a009-e32ca21c77ce
Harap diingat bahwa kata sandi berubah setiap kali kami menjalankan kembali aplikasi. Jika kita ingin mengubah perilaku ini dan membuat kata sandi menjadi statis, kita dapat menambahkan konfigurasi berikut ke file application.properties
kita:
spring.security.user.password=Test12345_
Sekarang, jika kita memasukkan kredensial di formulir login, kita akan diarahkan kembali ke URL kita dan kita akan melihat hasil yang benar. Harap dicatat bahwa proses otentikasi out-of-the-box berbasis sesi, dan jika kita ingin logout, kita dapat mengakses URL berikut: http://localhost:8080/logout
Perilaku out-of-the-box ini mungkin berguna untuk aplikasi web MVC klasik di mana kami memiliki otentikasi berbasis sesi, tetapi dalam kasus aplikasi satu halaman, biasanya tidak berguna karena dalam kebanyakan kasus penggunaan, kami memiliki sisi klien rendering dan otentikasi stateless berbasis JWT. Dalam hal ini, kita harus banyak menyesuaikan kerangka kerja Keamanan Musim Semi, yang akan kita lakukan di sisa artikel.
Sebagai contoh, kami akan mengimplementasikan aplikasi web toko buku klasik dan membuat back end yang akan menyediakan CRUD API untuk membuat penulis dan buku plus API untuk manajemen dan otentikasi pengguna.
Ikhtisar Arsitektur Keamanan Musim Semi
Sebelum kita mulai menyesuaikan konfigurasi, pertama-tama mari kita bahas bagaimana otentikasi Spring Security bekerja di belakang layar.
Diagram berikut menyajikan alur dan menunjukkan bagaimana permintaan otentikasi diproses:
Arsitektur Keamanan Musim Semi

Sekarang, mari kita urai diagram ini menjadi komponen-komponen dan bahas masing-masing secara terpisah.
Rantai Filter Keamanan Musim Semi
Saat Anda menambahkan kerangka kerja Keamanan Musim Semi ke aplikasi Anda, itu secara otomatis mendaftarkan rantai filter yang memotong semua permintaan yang masuk. Rantai ini terdiri dari berbagai filter, dan masing-masing filter menangani kasus penggunaan tertentu.
Sebagai contoh:
- Periksa apakah URL yang diminta dapat diakses publik, berdasarkan konfigurasi.
- Dalam kasus otentikasi berbasis sesi, periksa apakah pengguna sudah diautentikasi di sesi saat ini.
- Periksa apakah pengguna berwenang untuk melakukan tindakan yang diminta, dan seterusnya.
Satu detail penting yang ingin saya sebutkan adalah bahwa filter Spring Security terdaftar dengan urutan terendah dan merupakan filter pertama yang dipanggil. Untuk beberapa kasus penggunaan, jika Anda ingin menempatkan filter khusus di depannya, Anda perlu menambahkan bantalan ke pesanannya. Ini dapat dilakukan dengan konfigurasi berikut:
spring.security.filter.order=10
Setelah kami menambahkan konfigurasi ini ke file application.properties
kami, kami akan memiliki ruang untuk 10 filter khusus di depan filter Spring Security.
Manajer Otentikasi
Anda dapat menganggap AuthenticationManager
sebagai koordinator tempat Anda dapat mendaftarkan beberapa penyedia, dan berdasarkan jenis permintaan, itu akan mengirimkan permintaan otentikasi ke penyedia yang benar.
Penyedia Otentikasi
AuthenticationProvider
memproses jenis otentikasi tertentu. Antarmukanya hanya memperlihatkan dua fungsi:
-
authenticate
melakukan otentikasi dengan permintaan. -
supports
pemeriksaan apakah penyedia ini mendukung jenis otentikasi yang ditunjukkan.
Salah satu implementasi penting dari antarmuka yang kami gunakan dalam proyek sampel kami adalah DaoAuthenticationProvider
, yang mengambil detail pengguna dari UserDetailsService
.
Layanan Detail Pengguna
UserDetailsService
digambarkan sebagai antarmuka inti yang memuat data khusus pengguna dalam dokumentasi Spring.
Dalam kebanyakan kasus penggunaan, penyedia otentikasi mengekstrak informasi identitas pengguna berdasarkan kredensial dari database dan kemudian melakukan validasi. Karena kasus penggunaan ini sangat umum, pengembang Spring memutuskan untuk mengekstraknya sebagai antarmuka terpisah, yang memperlihatkan fungsi tunggal:
-
loadUserByUsername
menerima nama pengguna sebagai parameter dan mengembalikan objek identitas pengguna.
Otentikasi Menggunakan JWT dengan Spring Security
Setelah membahas internal kerangka kerja Keamanan Musim Semi, mari konfigurasikan untuk otentikasi stateless dengan token JWT.
Untuk menyesuaikan Spring Security, kita memerlukan kelas konfigurasi yang dianotasi dengan anotasi @EnableWebSecurity
di classpath kita. Selain itu, untuk menyederhanakan proses penyesuaian, framework menampilkan kelas WebSecurityConfigurerAdapter
. Kami akan memperluas adaptor ini dan mengesampingkan kedua fungsinya untuk:
- Konfigurasikan manajer otentikasi dengan penyedia yang benar
- Konfigurasikan keamanan web (URL publik, URL pribadi, otorisasi, dll.)
@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 } }
Dalam aplikasi sampel kami, kami menyimpan identitas pengguna dalam database MongoDB, dalam koleksi users
. Identitas ini dipetakan oleh entitas User
, dan operasi CRUD mereka ditentukan oleh repositori Data Spring UserRepo
.
Sekarang, ketika kami menerima permintaan otentikasi, kami perlu mengambil identitas yang benar dari database menggunakan kredensial yang disediakan dan kemudian memverifikasinya. Untuk ini, kita memerlukan implementasi antarmuka UserDetailsService
, yang didefinisikan sebagai berikut:
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
Di sini, kita dapat melihat bahwa diperlukan untuk mengembalikan objek yang mengimplementasikan antarmuka UserDetails
, dan entitas User
kita mengimplementasikannya (untuk detail implementasi, silakan lihat repositori proyek sampel). Mempertimbangkan fakta bahwa itu hanya memperlihatkan prototipe fungsi tunggal, kita dapat memperlakukannya sebagai antarmuka fungsional dan menyediakan implementasi sebagai ekspresi lambda.
@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 }
Di sini, panggilan fungsi auth.userDetailsService
akan memulai instance DaoAuthenticationProvider
menggunakan implementasi antarmuka UserDetailsService
dan mendaftarkannya di manajer otentikasi.
Bersama dengan penyedia autentikasi, kita perlu mengonfigurasi pengelola autentikasi dengan skema penyandian sandi yang benar yang akan digunakan untuk verifikasi kredensial. Untuk ini, kita perlu mengekspos implementasi yang disukai dari antarmuka PasswordEncoder
sebagai kacang.
Dalam proyek sampel kami, kami akan menggunakan algoritma hashing kata sandi 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 }
Setelah mengonfigurasi manajer otentikasi, sekarang kita perlu mengonfigurasi keamanan web. Kami menerapkan REST API dan memerlukan autentikasi stateless dengan token JWT; oleh karena itu, kita perlu mengatur opsi berikut:
- Aktifkan CORS dan nonaktifkan CSRF.
- Setel manajemen sesi ke stateless.
- Setel penangan pengecualian permintaan yang tidak sah.
- Tetapkan izin pada titik akhir.
- Tambahkan filter token JWT.
Konfigurasi ini diimplementasikan sebagai berikut:
@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); } }
Harap perhatikan bahwa kami menambahkan JwtTokenFilter
sebelum Spring Security internal UsernamePasswordAuthenticationFilter
. Kami melakukan ini karena kami memerlukan akses ke identitas pengguna pada saat ini untuk melakukan otentikasi/otorisasi, dan ekstraksinya terjadi di dalam filter token JWT berdasarkan token JWT yang disediakan. Ini diimplementasikan sebagai berikut:
@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); } }
Sebelum mengimplementasikan fungsi API login kami, kami perlu melakukan satu langkah lagi - kami memerlukan akses ke manajer otentikasi. Secara default, itu tidak dapat diakses publik, dan kita perlu secara eksplisit mengeksposnya sebagai kacang di kelas konfigurasi kita.

Hal ini dapat dilakukan sebagai berikut:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
Dan sekarang, kami siap untuk mengimplementasikan fungsi API login kami:
@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(); } } }
Di sini, kami memverifikasi kredensial yang diberikan menggunakan manajer otentikasi, dan jika berhasil, kami membuat token JWT dan mengembalikannya sebagai header respons bersama dengan informasi identitas pengguna di badan respons.
Otorisasi dengan Keamanan Musim Semi
Di bagian sebelumnya, kami menyiapkan proses otentikasi dan mengonfigurasi URL publik/pribadi. Ini mungkin cukup untuk aplikasi sederhana, tetapi untuk sebagian besar kasus penggunaan di dunia nyata, kami selalu membutuhkan kebijakan akses berbasis peran untuk pengguna kami. Dalam bab ini, kami akan membahas masalah ini dan menyiapkan skema otorisasi berbasis peran menggunakan kerangka kerja Keamanan Musim Semi.
Dalam aplikasi sampel kami, kami telah mendefinisikan tiga peran berikut:
-
USER_ADMIN
memungkinkan kita untuk mengelola pengguna aplikasi. -
AUTHOR_ADMIN
memungkinkan kami mengelola penulis. -
BOOK_ADMIN
memungkinkan kami mengelola buku.
Sekarang, kita perlu menerapkannya ke URL yang sesuai:
-
api/public
dapat diakses oleh publik. -
api/admin/user
dapat mengakses pengguna dengan peranUSER_ADMIN
. -
api/author
dapat mengakses pengguna dengan peranAUTHOR_ADMIN
. -
api/book
dapat mengakses pengguna dengan peranBOOK_ADMIN
.
Kerangka kerja Keamanan Musim Semi memberi kami dua opsi untuk menyiapkan skema otorisasi:
- Konfigurasi berbasis URL
- Konfigurasi berbasis anotasi
Pertama, mari kita lihat cara kerja konfigurasi berbasis URL. Ini dapat diterapkan pada konfigurasi keamanan web sebagai berikut:
@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 }
Seperti yang Anda lihat, pendekatan ini sederhana dan lugas, tetapi memiliki satu kelemahan. Skema otorisasi dalam aplikasi kita bisa rumit, dan jika kita mendefinisikan semua aturan di satu tempat, itu akan menjadi sangat besar, rumit, dan sulit dibaca. Karena itu, saya biasanya lebih suka menggunakan konfigurasi berbasis anotasi.
Kerangka kerja Keamanan Musim Semi mendefinisikan anotasi berikut untuk keamanan web:
-
@PreAuthorize
mendukung Spring Expression Language dan digunakan untuk menyediakan kontrol akses berbasis ekspresi sebelum menjalankan metode. -
@PostAuthorize
mendukung Spring Expression Language dan digunakan untuk menyediakan kontrol akses berbasis ekspresi setelah menjalankan metode (menyediakan kemampuan untuk mengakses hasil metode). -
@PreFilter
mendukung Bahasa Ekspresi Musim Semi dan digunakan untuk memfilter koleksi atau larik sebelum menjalankan metode berdasarkan aturan keamanan khusus yang kami tetapkan. -
@PostFilter
mendukung Bahasa Ekspresi Musim Semi dan digunakan untuk memfilter koleksi atau larik yang dikembalikan setelah menjalankan metode berdasarkan aturan keamanan khusus yang kami tetapkan (menyediakan kemampuan untuk mengakses hasil metode). -
@Secured
tidak mendukung Bahasa Ekspresi Musim Semi dan digunakan untuk menentukan daftar peran pada suatu metode. -
@RolesAllowed
tidak mendukung Bahasa Ekspresi Musim Semi dan merupakan anotasi setara JSR 250 dari anotasi@Secured
.
Anotasi ini dinonaktifkan secara default dan dapat diaktifkan di aplikasi kami sebagai berikut:
@EnableWebSecurity @EnableGlobalMethodSecurity( securedEnabled = true, jsr250Enabled = true, prePostEnabled = true ) public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity }
securedEnabled = true
mengaktifkan anotasi @Secured
.
jsr250Enabled = true
mengaktifkan anotasi @RolesAllowed
.
prePostEnabled = true
mengaktifkan @PreAuthorize
, @PostAuthorize
, @PreFilter
, @PostFilter
.
Setelah mengaktifkannya, kami dapat menerapkan kebijakan akses berbasis peran pada titik akhir API kami seperti ini:
@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() { } }
Harap dicatat bahwa anotasi keamanan dapat diberikan pada tingkat kelas dan tingkat metode.
Contoh yang didemonstrasikan sederhana dan tidak mewakili skenario dunia nyata, tetapi Spring Security menyediakan serangkaian anotasi yang kaya, dan Anda dapat menangani skema otorisasi yang kompleks jika Anda memilih untuk menggunakannya.
Awalan Default Nama Peran
Dalam subbagian terpisah ini, saya ingin menekankan satu detail halus lagi yang membingungkan banyak pengguna baru.
Kerangka kerja Keamanan Musim Semi membedakan dua istilah:
-
Authority
mewakili izin individu. -
Role
mewakili sekelompok izin.
Keduanya dapat direpresentasikan dengan satu antarmuka yang disebut GrantedAuthority
dan kemudian diperiksa dengan Spring Expression Language di dalam anotasi Spring Security sebagai berikut:
-
Authority
: @PreAuthorize("hasAuthority('EDIT_BOOK')") -
Role
: @PreAuthorize(“hasRole('BOOK_ADMIN')”)
Untuk membuat perbedaan antara dua istilah ini lebih eksplisit, kerangka kerja Keamanan Musim Semi menambahkan awalan ROLE_
ke nama peran secara default. Jadi, alih-alih memeriksa peran bernama BOOK_ADMIN
, itu akan memeriksa ROLE_BOOK_ADMIN
.
Secara pribadi, saya menemukan perilaku ini membingungkan dan lebih suka menonaktifkannya di aplikasi saya. Itu dapat dinonaktifkan di dalam konfigurasi Spring Security sebagai berikut:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Bean GrantedAuthorityDefaults grantedAuthorityDefaults() { return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix } }
Pengujian dengan Keamanan Musim Semi
Untuk menguji titik akhir kami dengan pengujian unit atau integrasi saat menggunakan kerangka kerja Keamanan Musim Semi, kami perlu menambahkan ketergantungan spring-security-test
bersama dengan pengujian spring-boot-starter-test
. File build pom.xml
kami akan terlihat seperti ini:
<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>
Ketergantungan ini memberi kami akses ke beberapa anotasi yang dapat digunakan untuk menambahkan konteks keamanan ke fungsi pengujian kami.
Anotasi ini adalah:
-
@WithMockUser
dapat ditambahkan ke metode pengujian untuk meniru berjalan dengan pengguna yang diejek. -
@WithUserDetails
dapat ditambahkan ke metode pengujian untuk meniru berjalan denganUserDetails
dikembalikan dariUserDetailsService
. -
@WithAnonymousUser
dapat ditambahkan ke metode pengujian untuk meniru berjalan dengan pengguna anonim. Ini berguna ketika pengguna ingin menjalankan sebagian besar pengujian sebagai pengguna tertentu dan mengganti beberapa metode menjadi anonim. -
@WithSecurityContext
menentukanSecurityContext
apa yang akan digunakan, dan ketiga anotasi yang dijelaskan di atas didasarkan padanya. Jika kita memiliki kasus penggunaan tertentu, kita dapat membuat anotasi kita sendiri yang menggunakan@WithSecurityContext
untuk membuatSecurityContext
yang kita inginkan. Diskusinya berada di luar cakupan artikel kami, dan silakan merujuk ke dokumentasi Spring Security untuk detail lebih lanjut.
Cara termudah untuk menjalankan pengujian dengan pengguna tertentu adalah dengan menggunakan anotasi @WithMockUser
. Kami dapat membuat pengguna tiruan dengannya dan menjalankan tes sebagai berikut:
@Test @WithMockUser(username="[email protected]", roles={"USER_ADMIN"}) public void test() { // Details omitted for brevity }
Pendekatan ini memiliki beberapa kelemahan, meskipun. Pertama, pengguna tiruan tidak ada, dan jika Anda menjalankan uji integrasi, yang kemudian menanyakan informasi pengguna dari database, pengujian akan gagal. Kedua, pengguna tiruan adalah turunan dari kelas org.springframework.security.core.userdetails.User
, yang merupakan implementasi internal antarmuka UserDetails
kerangka kerja Spring, dan jika kita memiliki implementasi sendiri, ini dapat menyebabkan konflik nanti, selama eksekusi tes.
Jika kelemahan sebelumnya adalah pemblokir untuk aplikasi kita, maka anotasi @WithUserDetails
adalah caranya. Ini digunakan ketika kami memiliki implementasi UserDetailsService
dan UserDetails
kustom. Ini mengasumsikan bahwa pengguna ada, jadi kita harus membuat baris aktual dalam database atau menyediakan instance tiruan UserDetailsService
sebelum menjalankan pengujian.
Ini adalah bagaimana kita dapat menggunakan anotasi ini:
@Test @WithUserDetails("[email protected]") public void test() { // Details omitted for brevity }
Ini adalah anotasi yang lebih disukai dalam pengujian integrasi proyek sampel kami karena kami memiliki implementasi khusus dari antarmuka yang disebutkan di atas.
Menggunakan @WithAnonymousUser
memungkinkan berjalan sebagai pengguna anonim. Ini sangat nyaman ketika Anda ingin menjalankan sebagian besar pengujian dengan pengguna tertentu tetapi beberapa pengujian sebagai pengguna anonim. Misalnya, berikut ini akan menjalankan test case test1 dan test2 dengan pengguna tiruan dan test3 dengan pengguna anonim:
@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 } }
Membungkus
Pada akhirnya, saya ingin menyebutkan bahwa kerangka kerja Keamanan Musim Semi mungkin tidak akan memenangkan kontes kecantikan apa pun dan pasti memiliki kurva pembelajaran yang curam. Saya telah menemui banyak situasi di mana itu diganti dengan beberapa solusi buatan sendiri karena kompleksitas konfigurasi awalnya. Tetapi begitu pengembang memahami internalnya dan berhasil mengatur konfigurasi awal, itu menjadi relatif mudah digunakan.
Dalam artikel ini, saya mencoba mendemonstrasikan semua detail halus dari konfigurasi, dan saya harap Anda akan menemukan contoh yang berguna. Untuk contoh kode lengkap, silakan merujuk ke repositori Git dari contoh proyek Spring Security saya.