Keamanan REST dengan JWT menggunakan Java dan Keamanan Musim Semi

Diterbitkan: 2022-03-11

Keamanan

Keamanan adalah musuh kenyamanan, dan sebaliknya. Pernyataan ini berlaku untuk sistem apa pun, virtual atau nyata, mulai dari pintu masuk fisik rumah hingga platform perbankan web. Insinyur terus-menerus berusaha menemukan keseimbangan yang tepat untuk kasus penggunaan yang diberikan, condong ke satu sisi atau sisi lainnya. Biasanya, ketika ancaman baru muncul, kita bergerak menuju keamanan dan menjauh dari kenyamanan. Kemudian, kami melihat apakah kami dapat memulihkan beberapa kenyamanan yang hilang tanpa mengurangi keamanan terlalu banyak. Apalagi lingkaran setan ini berlangsung selamanya.

tutorial keamanan musim semi: Ilustrasi keamanan vs. kenyamanan

Keamanan adalah musuh kenyamanan, dan sebaliknya.
Menciak

Mari kita coba memeriksa status keamanan REST hari ini, menggunakan tutorial keamanan Spring langsung untuk mendemonstrasikannya.

Layanan REST (yang merupakan singkatan dari Representational State Transfer) dimulai sebagai pendekatan yang sangat disederhanakan untuk Layanan Web yang memiliki spesifikasi besar dan format yang rumit, seperti WSDL untuk mendeskripsikan layanan, atau SOAP untuk menentukan format pesan. Di REST, kami tidak memilikinya. Kami dapat menggambarkan layanan REST dalam file teks biasa dan menggunakan format pesan apa pun yang kami inginkan, seperti JSON, XML atau bahkan teks biasa lagi. Pendekatan yang disederhanakan juga diterapkan pada keamanan layanan REST; tidak ada standar yang ditentukan yang memaksakan cara tertentu untuk mengautentikasi pengguna.

Meskipun layanan REST tidak memiliki banyak spesifikasi, yang penting adalah kurangnya status. Itu berarti server tidak menyimpan status klien apa pun, dengan sesi sebagai contoh yang baik. Dengan demikian, server membalas setiap permintaan seolah-olah itu adalah permintaan pertama yang dibuat klien. Namun, hingga saat ini, banyak implementasi masih menggunakan otentikasi berbasis cookie, yang diwarisi dari desain arsitektur situs web standar. Pendekatan stateless dari REST membuat cookie sesi tidak sesuai dari sudut pandang keamanan, tetapi bagaimanapun, mereka masih digunakan secara luas. Selain mengabaikan keadaan tanpa kewarganegaraan yang diperlukan, pendekatan yang disederhanakan muncul sebagai pertukaran keamanan yang diharapkan. Dibandingkan dengan standar WS-Security yang digunakan untuk Layanan Web, membuat dan menggunakan layanan REST jauh lebih mudah, oleh karena itu kenyamanan sangat diutamakan. Trade-off adalah keamanan yang cukup tipis; pembajakan sesi dan pemalsuan permintaan lintas situs (XSRF) adalah masalah keamanan yang paling umum.

Dalam mencoba untuk menyingkirkan sesi klien dari server, beberapa metode lain kadang-kadang digunakan, seperti otentikasi HTTP Dasar atau Intisari. Keduanya menggunakan header Authorization untuk mengirimkan kredensial pengguna, dengan beberapa pengkodean (HTTP Basic) atau enkripsi (HTTP Digest) ditambahkan. Tentu saja, mereka membawa kelemahan yang sama yang ditemukan di situs web: HTTP Basic harus digunakan melalui HTTPS karena nama pengguna dan kata sandi dikirim dalam pengkodean base64 yang mudah dibalik, dan HTTP Digest memaksa penggunaan hashing MD5 usang yang terbukti tidak aman.

Terakhir, beberapa implementasi menggunakan token arbitrer untuk mengotentikasi klien. Opsi ini tampaknya menjadi yang terbaik yang kami miliki, untuk saat ini. Jika diterapkan dengan benar, ini akan memperbaiki semua masalah keamanan HTTP Basic, HTTP Digest, atau cookie sesi, mudah digunakan, dan mengikuti pola stateless.

Namun, dengan token sewenang-wenang seperti itu, ada sedikit standar yang terlibat. Setiap penyedia layanan memiliki idenya sendiri tentang apa yang harus dimasukkan ke dalam token, dan bagaimana cara menyandikan atau mengenkripsinya. Mengkonsumsi layanan dari penyedia yang berbeda memerlukan waktu penyiapan tambahan, hanya untuk beradaptasi dengan format token tertentu yang digunakan. Metode lain, di sisi lain (cookie sesi, HTTP Basic, dan HTTP Digest) dikenal baik oleh pengembang, dan hampir semua browser di semua perangkat bekerja dengannya di luar kotak. Kerangka kerja dan bahasa siap untuk metode ini, memiliki fungsi bawaan untuk menangani masing-masing dengan mulus.

Otentikasi JWT

JWT (disingkat dari JSON Web Token) adalah standarisasi yang hilang untuk menggunakan token untuk mengautentikasi di web secara umum, tidak hanya untuk layanan REST. Saat ini, dalam status draft sebagai RFC 7519. Ini kuat dan dapat membawa banyak informasi, tetapi masih mudah digunakan meskipun ukurannya relatif kecil. Seperti token lainnya, JWT dapat digunakan untuk meneruskan identitas pengguna yang diautentikasi antara penyedia identitas dan penyedia layanan (yang belum tentu merupakan sistem yang sama). Itu juga dapat membawa semua klaim pengguna, seperti data otorisasi, sehingga penyedia layanan tidak perlu masuk ke database atau sistem eksternal untuk memverifikasi peran pengguna dan izin untuk setiap permintaan; bahwa data diekstraksi dari token.

Berikut adalah bagaimana keamanan JWT dirancang untuk bekerja:

Ilustrasi aliran java JWT

  • Klien masuk dengan mengirimkan kredensial mereka ke penyedia identitas.
  • Penyedia identitas memverifikasi kredensial; jika semuanya baik-baik saja, ia mengambil data pengguna, menghasilkan JWT yang berisi detail pengguna dan izin yang akan digunakan untuk mengakses layanan, dan juga menetapkan kedaluwarsa pada JWT (yang mungkin tidak terbatas).
  • Penyedia identitas menandatangani, dan jika diperlukan, mengenkripsi JWT dan mengirimkannya ke klien sebagai tanggapan atas permintaan awal dengan kredensial.
  • Klien menyimpan JWT untuk waktu yang terbatas atau tidak terbatas, tergantung pada kedaluwarsa yang ditetapkan oleh penyedia identitas.
  • Klien mengirimkan JWT yang disimpan dalam header Otorisasi untuk setiap permintaan ke penyedia layanan.
  • Untuk setiap permintaan, penyedia layanan mengambil JWT dari header Authorization dan mendekripsinya, jika diperlukan, memvalidasi tanda tangan, dan jika semuanya OK, mengekstrak data dan izin pengguna. Berdasarkan data ini saja, dan sekali lagi tanpa melihat rincian lebih lanjut dalam database atau menghubungi penyedia identitas, dapat menerima atau menolak permintaan klien. Satu-satunya persyaratan adalah identitas dan penyedia layanan memiliki perjanjian enkripsi sehingga layanan dapat memverifikasi tanda tangan atau bahkan mendekripsi identitas mana yang dienkripsi.

Aliran ini memungkinkan fleksibilitas yang luar biasa sambil tetap menjaga keamanan dan mudah dikembangkan. Dengan menggunakan pendekatan ini, mudah untuk menambahkan node server baru ke cluster penyedia layanan, menginisialisasi mereka hanya dengan kemampuan untuk memverifikasi tanda tangan dan mendekripsi token dengan memberikan mereka kunci rahasia bersama. Tidak diperlukan replikasi sesi, sinkronisasi database, atau komunikasi antar-simpul. REST dalam kemuliaan penuhnya.

Perbedaan utama antara JWT dan token arbitrer lainnya adalah standarisasi konten token. Pendekatan lain yang disarankan adalah mengirim token JWT di header Authorization menggunakan skema Bearer. Isi header akan terlihat seperti ini:

 Authorization: Bearer <token>

Implementasi Keamanan REST

Agar layanan REST berfungsi seperti yang diharapkan, kami memerlukan pendekatan otorisasi yang sedikit berbeda dibandingkan dengan situs web multi-halaman klasik.

Alih-alih memicu proses otentikasi dengan mengarahkan ulang ke halaman login ketika klien meminta sumber daya yang aman, server REST mengotentikasi semua permintaan menggunakan data yang tersedia dalam permintaan itu sendiri, dalam hal ini token JWT. Jika otentikasi seperti itu gagal, pengalihan tidak masuk akal. REST API hanya mengirimkan respons kode HTTP 401 (Tidak Sah) dan klien harus tahu apa yang harus dilakukan; misalnya, browser akan menampilkan div dinamis untuk memungkinkan pengguna memberikan nama pengguna dan kata sandi.

Di sisi lain, setelah otentikasi berhasil di situs web multi-halaman klasik, pengguna dialihkan dengan menggunakan kode HTTP 301 (Dipindahkan secara permanen), biasanya ke halaman beranda atau, lebih baik lagi, ke halaman yang awalnya diminta oleh pengguna yang memicu proses otentikasi. Dengan REST, sekali lagi ini tidak masuk akal. Alih-alih, kami hanya akan melanjutkan eksekusi permintaan seolah-olah sumber daya tidak diamankan sama sekali, mengembalikan kode HTTP 200 (OK) dan badan respons yang diharapkan.

Contoh Keamanan Musim Semi

Spring REST Security dengan JWT dan Java

Sekarang, mari kita lihat bagaimana kita bisa mengimplementasikan API REST berbasis token JWT menggunakan Java dan Spring, sambil mencoba menggunakan kembali perilaku default Spring Security di mana kita bisa.

Seperti yang diharapkan, kerangka kerja Spring Security hadir dengan banyak kelas plug-in siap pakai yang menangani mekanisme otorisasi "lama": cookie sesi, HTTP Basic, dan HTTP Digest. Namun, itu tidak memiliki dukungan asli untuk JWT, dan kita perlu mengotori tangan kita untuk membuatnya berfungsi. Untuk gambaran umum yang lebih rinci, Anda harus berkonsultasi dengan dokumentasi Keamanan Musim Semi resmi.

Sekarang, mari kita mulai dengan definisi filter Keamanan Musim Semi yang biasa di web.xml :

 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

Perhatikan bahwa nama filter Spring Security harus sama persis dengan springSecurityFilterChain agar sisa konfigurasi Spring dapat bekerja di luar kotak.

Berikutnya adalah deklarasi XML dari kacang Spring yang terkait dengan keamanan. Untuk menyederhanakan XML, kami akan menyetel namespace default ke security dengan menambahkan xmlns="http://www.springframework.org/schema/security" ke elemen XML root. XML lainnya terlihat seperti ini:

 <global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
  • (1) Di baris ini, kami mengaktifkan @PreFilter , @PreAuthorize , @PostFilter , @PostAuthorize anotasi pada kacang musim semi apa pun dalam konteks.
  • (2) Kami mendefinisikan titik akhir login dan pendaftaran untuk melewati keamanan; bahkan "anonim" harus dapat melakukan dua operasi ini.
  • (3) Selanjutnya, kami mendefinisikan rantai filter yang diterapkan ke semua permintaan sambil menambahkan dua konfigurasi penting: Referensi titik masuk dan menyetel pembuatan sesi ke stateless (kami tidak ingin sesi dibuat untuk tujuan keamanan karena kami menggunakan token untuk setiap permintaan) .
  • (4) Kami tidak memerlukan perlindungan csrf karena token kami kebal terhadapnya.
  • (5) Selanjutnya, kami memasang filter otentikasi khusus kami di dalam rantai filter yang telah ditentukan Spring, tepat sebelum filter login formulir.
  • (6) Kacang ini adalah deklarasi filter otentikasi kami; karena ini memperluas AbstractAuthenticationProcessingFilter Spring, kita perlu mendeklarasikannya dalam XML untuk menyambungkan propertinya (kawat otomatis tidak berfungsi di sini). Nanti akan kami jelaskan apa fungsi filter tersebut.
  • (7) Pengendali sukses default AbstractAuthenticationProcessingFilter tidak cukup baik untuk tujuan REST karena mengarahkan pengguna ke halaman sukses; itu sebabnya kami mengatur kami sendiri di sini.
  • (8) Deklarasi penyedia yang dibuat oleh authenticationManager digunakan oleh filter kami untuk mengotentikasi pengguna.

Sekarang mari kita lihat bagaimana kita mengimplementasikan kelas-kelas tertentu yang dideklarasikan dalam XML di atas. Perhatikan bahwa Spring akan mengirimkannya untuk kita. Kita mulai dari yang paling sederhana.

RestAuthenticationEntryPoint.java

 public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }

Seperti dijelaskan di atas, kelas ini hanya mengembalikan kode HTTP 401 (Tidak Sah) ketika otentikasi gagal, mengesampingkan pengalihan default Spring.

JwtAuthenticationSuccessHandler.java

 public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }

Penggantian sederhana ini menghapus perilaku default dari autentikasi yang berhasil (mengalihkan ke beranda atau halaman lain yang diminta pengguna). Jika Anda bertanya-tanya mengapa kami tidak perlu mengganti AuthenticationFailureHandler , itu karena implementasi default tidak akan mengarahkan ulang ke mana pun jika URL pengalihannya tidak disetel, jadi kami hanya menghindari pengaturan URL, yang sudah cukup baik.

JwtAuthenticationFilter.java

 public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }

Kelas ini adalah titik masuk dari proses otentikasi JWT kami; filter mengekstrak token JWT dari header permintaan dan mendelegasikan otentikasi ke AuthenticationManager yang disuntikkan. Jika token tidak ditemukan, pengecualian dilemparkan yang menghentikan permintaan dari pemrosesan. Kami juga memerlukan penggantian untuk otentikasi yang berhasil karena aliran Spring default akan menghentikan rantai filter dan melanjutkan dengan pengalihan. Ingatlah bahwa kita membutuhkan rantai untuk mengeksekusi sepenuhnya, termasuk menghasilkan respons, seperti yang dijelaskan di atas.

JwtAuthenticationProvider.java

 public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }

Di kelas ini, kami menggunakan AuthenticationManager default Spring, tetapi kami menyuntikkannya dengan AuthenticationProvider kami sendiri yang melakukan proses otentikasi yang sebenarnya. Untuk mengimplementasikan ini, kami memperluas AbstractUserDetailsAuthenticationProvider , yang mengharuskan kami hanya mengembalikan UserDetails berdasarkan permintaan otentikasi, dalam kasus kami, token JWT yang dibungkus dengan kelas JwtAuthenticationToken . Jika token tidak valid, kami melempar pengecualian. Namun, jika valid dan dekripsi oleh JwtUtil berhasil, kami mengekstrak detail pengguna (kita akan melihat persis bagaimana di kelas JwtUtil ), tanpa mengakses database sama sekali. Semua informasi tentang pengguna, termasuk perannya, terkandung dalam token itu sendiri.

JwtUtil.java

 public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }

Terakhir, kelas JwtUtil bertugas untuk mengurai token ke dalam objek User dan menghasilkan token dari objek User . Ini sangat mudah karena menggunakan perpustakaan jjwt untuk melakukan semua pekerjaan JWT. Dalam contoh kami, kami hanya menyimpan nama pengguna, ID pengguna, dan peran pengguna di token. Kami juga dapat menyimpan lebih banyak barang arbitrer dan menambahkan lebih banyak fitur keamanan, seperti masa berlaku token. Parsing token digunakan di AuthenticationProvider seperti yang ditunjukkan di atas. Metode generateToken() dipanggil dari layanan REST login dan pendaftaran, yang tidak aman dan tidak akan memicu pemeriksaan keamanan apa pun atau memerlukan token untuk ada dalam permintaan. Pada akhirnya, itu menghasilkan token yang akan dikembalikan ke klien, berdasarkan pengguna.

Kesimpulan

Meskipun pendekatan keamanan standar yang lama (cookie sesi, HTTP Basic, dan HTTP Digest) juga akan berfungsi dengan layanan REST, semuanya memiliki masalah yang sebaiknya dihindari dengan menggunakan standar yang lebih baik. JWT tiba tepat pada waktunya untuk menyelamatkan hari, dan yang paling penting sangat dekat untuk menjadi standar IETF.

Kekuatan utama JWT adalah menangani otentikasi pengguna dengan cara tanpa kewarganegaraan, dan karenanya dapat diskalakan, sambil menjaga semuanya tetap aman dengan standar kriptografi terkini. Menyimpan klaim (peran dan izin pengguna) dalam token itu sendiri menciptakan manfaat besar dalam arsitektur sistem terdistribusi di mana server yang mengeluarkan permintaan tidak memiliki akses ke sumber data otentikasi.