Menggunakan Spring Boot untuk Perlindungan OAuth2 dan JWT REST
Diterbitkan: 2022-03-11Artikel ini adalah panduan tentang cara menyiapkan implementasi sisi server dari JSON Web Token (JWT) - kerangka kerja otorisasi OAuth2 menggunakan Spring Boot dan Maven.
Pemahaman awal tentang OAuth2 direkomendasikan dan dapat diperoleh dengan membaca draf yang ditautkan di atas atau mencari informasi berguna di web seperti ini atau ini.
OAuth2 adalah kerangka kerja otorisasi yang menggantikannya dengan versi pertama OAuth, dibuat pada tahun 2006. Ini mendefinisikan aliran otorisasi antara klien dan satu atau lebih layanan HTTP untuk mendapatkan akses ke sumber daya yang dilindungi.
OAuth2 mendefinisikan peran sisi server berikut:
- Pemilik Sumber Daya: Layanan yang bertanggung jawab untuk mengontrol akses sumber daya
- Server Sumber Daya: Layanan yang benar-benar memasok sumber daya
- Server Otorisasi: Layanan yang menangani proses otorisasi yang bertindak sebagai perantara antara klien dan pemilik sumber daya
JSON Web Token, atau JWT, adalah spesifikasi untuk representasi klaim yang akan ditransfer antara dua pihak. Klaim dikodekan sebagai objek JSON yang digunakan sebagai muatan struktur terenkripsi, memungkinkan klaim ditandatangani atau dienkripsi secara digital.
Struktur yang berisi dapat berupa JSON Web Signature (JWS) atau JSON Web Encryption (JWE).
JWT dapat dipilih sebagai format untuk token akses dan penyegaran yang digunakan di dalam protokol OAuth2.
OAuth2 dan JWT mendapatkan popularitas besar selama beberapa tahun terakhir karena fitur berikut:
- Menyediakan sistem otorisasi stateless untuk protokol REST stateless
- Sangat cocok dengan arsitektur layanan mikro di mana beberapa server sumber daya dapat berbagi satu server otorisasi
- Konten token mudah dikelola di sisi klien karena format JSON
Namun, OAuth2 dan JWT tidak selalu merupakan pilihan terbaik jika pertimbangan berikut penting untuk proyek:
- Protokol tanpa kewarganegaraan tidak mengizinkan pencabutan akses di sisi server
- Masa pakai yang tetap untuk token menambah kerumitan tambahan untuk mengelola sesi yang berjalan lama tanpa mengorbankan keamanan (misalnya token penyegaran)
- Persyaratan untuk penyimpanan yang aman untuk token di sisi klien
Alur Protokol yang Diharapkan
Sementara salah satu fitur utama OAuth2 adalah pengenalan lapisan otorisasi untuk memisahkan proses otorisasi dari pemilik sumber daya, demi kesederhanaan, hasil artikel ini adalah pembuatan aplikasi tunggal yang meniru semua pemilik sumber daya , server otorisasi , dan peran server sumber daya . Karena itu, komunikasi hanya akan mengalir antara dua entitas, server dan klien.
Penyederhanaan ini akan membantu untuk fokus pada tujuan artikel, yaitu pengaturan sistem seperti itu di lingkungan Boot Musim Semi.
Aliran yang disederhanakan dijelaskan di bawah ini:
- Permintaan otorisasi dikirim dari klien ke server (bertindak sebagai pemilik sumber daya) menggunakan pemberian otorisasi kata sandi
- Token akses dikembalikan ke klien (bersama dengan token penyegaran)
- Token akses kemudian dikirim dari klien ke server (bertindak sebagai server sumber daya) pada setiap permintaan untuk akses sumber daya yang dilindungi
- Server merespons dengan sumber daya terlindungi yang diperlukan
Keamanan Musim Semi dan Boot Musim Semi
Pertama-tama, pengenalan singkat tentang tumpukan teknologi yang dipilih untuk proyek ini.
Alat manajemen proyek pilihan adalah Maven, tetapi karena kesederhanaan proyek, seharusnya tidak sulit untuk beralih ke alat lain seperti Gradle.
Dalam kelanjutan artikel, kami fokus pada aspek Keamanan Musim Semi saja, tetapi semua kutipan kode diambil dari aplikasi sisi server yang berfungsi penuh yang kode sumbernya tersedia di repositori publik bersama dengan klien yang menggunakan sumber daya REST-nya.
Spring Security adalah kerangka kerja yang menyediakan layanan keamanan yang hampir deklaratif untuk aplikasi berbasis Spring. Akarnya adalah dari awal pertama Musim Semi dan diatur sebagai satu set modul karena tingginya jumlah teknologi keamanan yang berbeda yang tercakup.
Mari kita lihat sekilas arsitektur Spring Security (panduan yang lebih rinci dapat ditemukan di sini).
Keamanan sebagian besar tentang otentikasi , yaitu verifikasi identitas, dan otorisasi , pemberian hak akses ke sumber daya.
Keamanan pegas mendukung sejumlah besar model otentikasi, baik yang disediakan oleh pihak ketiga atau diimplementasikan secara asli. Sebuah daftar dapat ditemukan di sini.
Mengenai otorisasi, tiga bidang utama diidentifikasi:
- Otorisasi permintaan web
- Otorisasi tingkat metode
- Akses ke otorisasi instance objek domain
Autentikasi
Antarmuka dasar adalah AuthenticationManager
yang bertanggung jawab untuk menyediakan metode otentikasi. UserDetailsService
adalah antarmuka yang terkait dengan pengumpulan informasi pengguna, yang dapat langsung diimplementasikan atau digunakan secara internal dalam kasus metode JDBC atau LDAP standar.
Otorisasi
Antarmuka utamanya adalah AccessDecisionManager
; implementasi mana untuk ketiga area yang tercantum di atas didelegasikan ke rantai AccessDecisionVoter
. Setiap instance dari antarmuka yang terakhir mewakili asosiasi antara Authentication
(identitas pengguna, bernama prinsipal), sumber daya dan kumpulan ConfigAttribute
, seperangkat aturan yang menjelaskan bagaimana pemilik sumber daya mengizinkan akses ke sumber daya itu sendiri, mungkin melalui penggunaan peran pengguna.
Keamanan untuk aplikasi web diimplementasikan menggunakan elemen dasar yang dijelaskan di atas dalam rantai filter servlet, dan kelas WebSecurityConfigurerAdapter
diekspos sebagai cara deklaratif untuk mengekspresikan aturan akses sumber daya.
Keamanan metode pertama kali diaktifkan dengan adanya @EnableGlobalMethodSecurity(securedEnabled = true)
, lalu dengan menggunakan serangkaian anotasi khusus untuk diterapkan ke setiap metode yang akan dilindungi seperti @Secured
, @PreAuthorize
, dan @PostAuthorize
.
Spring Boot menambahkan semua ini kumpulan konfigurasi aplikasi berpendirian dan perpustakaan pihak ketiga untuk memudahkan pengembangan sambil mempertahankan standar kualitas tinggi.
JWT OAuth2 dengan Spring Boot
Sekarang mari beralih ke masalah awal untuk menyiapkan aplikasi yang mengimplementasikan OAuth2 dan JWT dengan Spring Boot.
Sementara beberapa perpustakaan OAuth2 sisi server ada di dunia Java (daftar dapat ditemukan di sini), implementasi berbasis pegas adalah pilihan alami karena kami berharap untuk menemukannya terintegrasi dengan baik ke dalam arsitektur Keamanan Musim Semi dan oleh karena itu menghindari kebutuhan untuk menangani banyak detail tingkat rendah untuk penggunaannya.
Semua dependensi perpustakaan terkait keamanan ditangani oleh Maven dengan bantuan Spring Boot, yang merupakan satu-satunya komponen yang memerlukan versi eksplisit di dalam file konfigurasi maven pom.xml (yaitu versi perpustakaan secara otomatis disimpulkan oleh Maven memilih yang paling mutakhir versi yang kompatibel dengan versi Spring Boot yang dimasukkan).
Temukan di bawah kutipan dari file konfigurasi maven pom.xml yang berisi dependensi yang terkait dengan keamanan Spring Boot:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.0.RELEASE</version> </dependency>
Aplikasi bertindak sebagai server otorisasi/pemilik sumber daya OAuth2 dan sebagai server sumber daya.
Sumber daya yang dilindungi (sebagai server sumber daya) diterbitkan di bawah jalur /api/ , sementara jalur otentikasi (sebagai pemilik sumber daya/server otorisasi) dipetakan ke /oauth/token , mengikuti default yang diusulkan.
Struktur aplikasi:
- paket
security
yang berisi konfigurasi keamanan - paket
errors
yang berisi penanganan kesalahan -
users
, paketglee
untuk sumber daya REST, termasuk model, repositori, dan pengontrol
Paragraf berikutnya membahas konfigurasi untuk masing-masing dari tiga peran OAuth2 yang disebutkan di atas. Kelas terkait ada di dalam paket security
:
-
OAuthConfiguration
, memperluasAuthorizationServerConfigurerAdapter
-
ResourceServerConfiguration
, memperluasResourceServerConfigurerAdapter
-
ServerSecurityConfig
, memperluasWebSecurityConfigurerAdapter
-
UserService
, mengimplementasikanUserDetailsService
Pengaturan untuk Pemilik Sumber Daya dan Server Otorisasi
Perilaku server otorisasi diaktifkan dengan adanya anotasi @EnableAuthorizationServer
. Konfigurasinya digabungkan dengan yang terkait dengan perilaku pemilik sumber daya dan keduanya terdapat dalam kelas AuthorizationServerConfigurerAdapter
.
Konfigurasi yang diterapkan di sini terkait dengan:
- Akses klien (menggunakan
ClientDetailsServiceConfigurer
)- Pemilihan penggunaan dalam memori atau penyimpanan berbasis JDBC untuk detail klien dengan metode
inMemory
ataujdbc
- Otentikasi dasar klien menggunakan atribut
clientId
danclientSecret
(dikodekan dengan kacangPasswordEncoder
yang dipilih) - Waktu validitas untuk mengakses dan menyegarkan token menggunakan atribut
accessTokenValiditySeconds
danrefreshTokenValiditySeconds
- Jenis hibah
authorizedGrantTypes
menggunakan atribut AuthorGrantTypes - Mendefinisikan cakupan akses dengan metode
scopes
- Identifikasi sumber daya klien yang dapat diakses
- Pemilihan penggunaan dalam memori atau penyimpanan berbasis JDBC untuk detail klien dengan metode
- Titik akhir server otorisasi (menggunakan
AuthorizationServerEndpointsConfigurer
)- Tentukan penggunaan token JWT dengan
accessTokenConverter
- Tentukan penggunaan antarmuka
UserDetailsService
danAuthenticationManager
untuk melakukan otentikasi (sebagai pemilik sumber daya)
- Tentukan penggunaan token JWT dengan
package net.reliqs.gleeometer.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; @Configuration @EnableAuthorizationServer public class OAuthConfiguration extends AuthorizationServerConfigurerAdapter { private final AuthenticationManager authenticationManager; private final PasswordEncoder passwordEncoder; private final UserDetailsService userService; @Value("${jwt.clientId:glee-o-meter}") private String clientId; @Value("${jwt.client-secret:secret}") private String clientSecret; @Value("${jwt.signing-key:123}") private String jwtSigningKey; @Value("${jwt.accessTokenValidititySeconds:43200}") // 12 hours private int accessTokenValiditySeconds; @Value("${jwt.authorizedGrantTypes:password,authorization_code,refresh_token}") private String[] authorizedGrantTypes; @Value("${jwt.refreshTokenValiditySeconds:2592000}") // 30 days private int refreshTokenValiditySeconds; public OAuthConfiguration(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, UserDetailsService userService) { this.authenticationManager = authenticationManager; this.passwordEncoder = passwordEncoder; this.userService = userService; } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(clientId) .secret(passwordEncoder.encode(clientSecret)) .accessTokenValiditySeconds(accessTokenValiditySeconds) .refreshTokenValiditySeconds(refreshTokenValiditySeconds) .authorizedGrantTypes(authorizedGrantTypes) .scopes("read", "write") .resourceIds("api"); } @Override public void configure(final AuthorizationServerEndpointsConfigurer endpoints) { endpoints .accessTokenConverter(accessTokenConverter()) .userDetailsService(userService) .authenticationManager(authenticationManager); } @Bean JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); return converter; } }
Bagian selanjutnya menjelaskan konfigurasi untuk diterapkan ke server sumber daya.

Pengaturan untuk Server Sumber Daya
Perilaku server sumber daya diaktifkan oleh penggunaan anotasi @EnableResourceServer
dan konfigurasinya terdapat dalam kelas ResourceServerConfiguration
.
Satu-satunya konfigurasi yang diperlukan di sini adalah definisi identifikasi sumber daya agar sesuai dengan akses klien yang ditentukan di kelas sebelumnya.
package net.reliqs.gleeometer.security; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; @Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId("api"); } }
Elemen konfigurasi terakhir adalah tentang definisi keamanan aplikasi web.
Pengaturan Keamanan Web
Konfigurasi keamanan web pegas terdapat dalam kelas ServerSecurityConfig
, diaktifkan dengan menggunakan anotasi @EnableWebSecurity
. @EnableGlobalMethodSecurity
mengizinkan untuk menentukan keamanan pada tingkat metode. proxyTargetClass
diatur agar ini berfungsi untuk metode RestController
, karena pengontrol biasanya adalah kelas, tidak mengimplementasikan antarmuka apa pun.
Ini mendefinisikan yang berikut:
- Penyedia otentikasi yang akan digunakan, mendefinisikan penyedia
authenticationProvider
kacang - Encoder kata sandi yang akan digunakan, mendefinisikan bean
passwordEncoder
- Kacang manajer otentikasi
- Konfigurasi keamanan untuk jalur yang dipublikasikan menggunakan
HttpSecurity
- Penggunaan
AuthenticationEntryPoint
khusus untuk menangani pesan kesalahan di luar penangan kesalahan Spring REST standarResponseEntityExceptionHandler
package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.errors.CustomAccessDeniedHandler; import net.reliqs.gleeometer.errors.CustomAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final UserDetailsService userDetailsService; public ServerSecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint, @Qualifier("userService") UserDetailsService userDetailsService) { this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; this.userDetailsService = userDetailsService; } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setPasswordEncoder(passwordEncoder()); provider.setUserDetailsService(userDetailsService); return provider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/signin/**").permitAll() .antMatchers("/api/glee/**").hasAnyAuthority("ADMIN", "USER") .antMatchers("/api/users/**").hasAuthority("ADMIN") .antMatchers("/api/**").authenticated() .anyRequest().authenticated() .and().exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(new CustomAccessDeniedHandler()); } }
Ekstrak kode di bawah ini adalah tentang implementasi antarmuka UserDetailsService
untuk memberikan otentikasi pemilik sumber daya.
package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.users.User; import net.reliqs.gleeometer.users.UserRepository; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class UserService implements UserDetailsService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository = repository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = repository.findByEmail(username).orElseThrow(() -> new RuntimeException("User not found: " + username)); GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole().name()); return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Arrays.asList(authority)); } }
Bagian selanjutnya adalah tentang deskripsi implementasi pengontrol REST untuk melihat bagaimana batasan keamanan dipetakan.
Pengontrol REST
Di dalam pengontrol REST kita dapat menemukan dua cara untuk menerapkan kontrol akses untuk setiap metode sumber daya:
- Menggunakan instance
OAuth2Authentication
diteruskan oleh Spring sebagai parameter - Menggunakan anotasi
@PreAuthorize
atau@PostAuthorize
package net.reliqs.gleeometer.users; import lombok.extern.slf4j.Slf4j; import net.reliqs.gleeometer.errors.EntityNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.ConstraintViolationException; import javax.validation.Valid; import javax.validation.constraints.Size; import java.util.HashSet; @RestController @RequestMapping("/api/users") @Slf4j @Validated class UserController { private final UserRepository repository; private final PasswordEncoder passwordEncoder; UserController(UserRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; this.passwordEncoder = passwordEncoder; } @GetMapping Page<User> all(@PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmail(auth, pageable); } return repository.findAll(pageable); } @GetMapping("/search") Page<User> search(@RequestParam String email, Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmailContainsAndEmail(email, auth, pageable); } return repository.findByEmailContains(email, pageable); } @GetMapping("/findByEmail") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == #email)") User findByEmail(@RequestParam String email, OAuth2Authentication authentication) { return repository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException(User.class, "email", email)); } @GetMapping("/{id}") @PostAuthorize("!hasAuthority('USER') || (returnObject != null && returnObject.email == authentication.principal)") User one(@PathVariable Long id) { return repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); } @PutMapping("/{id}") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void update(@PathVariable Long id, @Valid @RequestBody User res) { User u = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); res.setPassword(u.getPassword()); res.setGlee(u.getGlee()); repository.save(res); } @PostMapping @PreAuthorize("!hasAuthority('USER')") User create(@Valid @RequestBody User res) { return repository.save(res); } @DeleteMapping("/{id}") @PreAuthorize("!hasAuthority('USER')") void delete(@PathVariable Long id) { if (repository.existsById(id)) { repository.deleteById(id); } else { throw new EntityNotFoundException(User.class, "id", id.toString()); } } @PutMapping("/{id}/changePassword") @PreAuthorize("!hasAuthority('USER') || (#oldPassword != null && !#oldPassword.isEmpty() && authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void changePassword(@PathVariable Long id, @RequestParam(required = false) String oldPassword, @Valid @Size(min = 3) @RequestParam String newPassword) { User user = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); if (oldPassword == null || oldPassword.isEmpty() || passwordEncoder.matches(oldPassword, user.getPassword())) { user.setPassword(passwordEncoder.encode(newPassword)); repository.save(user); } else { throw new ConstraintViolationException("old password doesn't match", new HashSet<>()); } } }
Kesimpulan
Spring Security dan Spring Boot mengizinkan untuk dengan cepat menyiapkan server otorisasi/otentikasi OAuth2 lengkap dengan cara yang hampir deklaratif. Penyiapan dapat dipersingkat lebih lanjut dengan mengonfigurasi properti klien OAuth2 langsung dari file application.properties/yml
, seperti yang dijelaskan dalam tutorial ini.
Semua kode sumber tersedia di repositori GitHub ini: spring-glee-o-meter. Klien Angular yang menggunakan sumber daya yang dipublikasikan dapat ditemukan di repositori GitHub ini: glee-o-meter.