Menggunakan Spring Boot untuk Perlindungan OAuth2 dan JWT REST

Diterbitkan: 2022-03-11

Artikel 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:

  1. Permintaan otorisasi dikirim dari klien ke server (bertindak sebagai pemilik sumber daya) menggunakan pemberian otorisasi kata sandi
  2. Token akses dikembalikan ke klien (bersama dengan token penyegaran)
  3. Token akses kemudian dikirim dari klien ke server (bertindak sebagai server sumber daya) pada setiap permintaan untuk akses sumber daya yang dilindungi
  4. Server merespons dengan sumber daya terlindungi yang diperlukan

Diagram alur otentikasi

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:

  1. Otorisasi permintaan web
  2. Otorisasi tingkat metode
  3. 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 , paket glee 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 , memperluas AuthorizationServerConfigurerAdapter
  • ResourceServerConfiguration , memperluas ResourceServerConfigurerAdapter
  • ServerSecurityConfig , memperluas WebSecurityConfigurerAdapter
  • UserService , mengimplementasikan UserDetailsService

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 atau jdbc
    • Otentikasi dasar klien menggunakan atribut clientId dan clientSecret (dikodekan dengan kacang PasswordEncoder yang dipilih)
    • Waktu validitas untuk mengakses dan menyegarkan token menggunakan atribut accessTokenValiditySeconds dan refreshTokenValiditySeconds
    • Jenis hibah authorizedGrantTypes menggunakan atribut AuthorGrantTypes
    • Mendefinisikan cakupan akses dengan metode scopes
    • Identifikasi sumber daya klien yang dapat diakses
  • Titik akhir server otorisasi (menggunakan AuthorizationServerEndpointsConfigurer )
    • Tentukan penggunaan token JWT dengan accessTokenConverter
    • Tentukan penggunaan antarmuka UserDetailsService dan AuthenticationManager untuk melakukan otentikasi (sebagai pemilik sumber daya)
 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 standar ResponseEntityExceptionHandler
 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.