Cara kerja otentikasi berbasis token
Dalam otentikasi berbasis token, klien bertukar kredensial keras (seperti nama pengguna dan kata sandi) untuk sepotong data yang disebut token . Untuk setiap permintaan, alih-alih mengirim kredensial keras, klien akan mengirim token ke server untuk melakukan otentikasi dan kemudian otorisasi.
Dalam beberapa kata, skema otentikasi berdasarkan token ikuti langkah-langkah ini:
- Klien mengirimkan kredensial mereka (nama pengguna dan kata sandi) ke server.
- Server mengautentikasi kredensial dan, jika valid, buat token untuk pengguna.
- Server menyimpan token yang dibuat sebelumnya di beberapa penyimpanan bersama dengan pengenal pengguna dan tanggal kedaluwarsa.
- Server mengirimkan token yang dihasilkan ke klien.
- Klien mengirim token ke server di setiap permintaan.
- Server, dalam setiap permintaan, mengekstrak token dari permintaan yang masuk. Dengan token, server mencari detail pengguna untuk melakukan otentikasi.
- Jika token itu valid, server menerima permintaan.
- Jika token tidak valid, server menolak permintaan tersebut.
- Setelah otentikasi dilakukan, server melakukan otorisasi.
- Server dapat memberikan titik akhir untuk menyegarkan token.
Catatan: Langkah 3 tidak diperlukan jika server telah mengeluarkan token yang ditandatangani (seperti JWT, yang memungkinkan Anda untuk melakukan otentikasi stateless ).
Apa yang dapat Anda lakukan dengan JAX-RS 2.0 (Jersey, RESTEasy dan Apache CXF)
Solusi ini hanya menggunakan JAX-RS 2.0 API, menghindari solusi spesifik vendor . Jadi, itu harus bekerja dengan implementasi JAX-RS 2.0, seperti Jersey , RESTEasy dan Apache CXF .
Penting untuk menyebutkan bahwa jika Anda menggunakan otentikasi berbasis token, Anda tidak bergantung pada mekanisme keamanan aplikasi web Java EE standar yang ditawarkan oleh wadah servlet dan dapat dikonfigurasi melalui web.xml
deskriptor aplikasi . Ini otentikasi khusus.
Otentikasi pengguna dengan nama pengguna dan kata sandi dan mengeluarkan token
Buat metode sumber daya JAX-RS yang menerima dan memvalidasi kredensial (nama pengguna dan kata sandi) dan mengeluarkan token untuk pengguna:
@Path("/authentication")
public class AuthenticationEndpoint {
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response authenticateUser(@FormParam("username") String username,
@FormParam("password") String password) {
try {
// Authenticate the user using the credentials provided
authenticate(username, password);
// Issue a token for the user
String token = issueToken(username);
// Return the token on the response
return Response.ok(token).build();
} catch (Exception e) {
return Response.status(Response.Status.FORBIDDEN).build();
}
}
private void authenticate(String username, String password) throws Exception {
// Authenticate against a database, LDAP, file or whatever
// Throw an Exception if the credentials are invalid
}
private String issueToken(String username) {
// Issue a token (can be a random String persisted to a database or a JWT token)
// The issued token must be associated to a user
// Return the issued token
}
}
Jika ada pengecualian yang dilemparkan saat memvalidasi kredensial, respons dengan status 403
(Terlarang) akan dikembalikan.
Jika kredensial berhasil divalidasi, respons dengan status 200
(OK) akan dikembalikan dan token yang diterbitkan akan dikirim ke klien dalam muatan respons. Klien harus mengirim token ke server dalam setiap permintaan.
Saat mengkonsumsi application/x-www-form-urlencoded
, klien harus mengirim kredensial dalam format berikut dalam payload permintaan:
username=admin&password=123456
Alih-alih bentuk params, dimungkinkan untuk membungkus nama pengguna dan kata sandi ke dalam kelas:
public class Credentials implements Serializable {
private String username;
private String password;
// Getters and setters omitted
}
Dan kemudian mengkonsumsinya sebagai JSON:
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response authenticateUser(Credentials credentials) {
String username = credentials.getUsername();
String password = credentials.getPassword();
// Authenticate the user, issue a token and return a response
}
Dengan menggunakan pendekatan ini, klien harus mengirim kredensial dalam format berikut dalam payload permintaan:
{
"username": "admin",
"password": "123456"
}
Mengekstraksi token dari permintaan dan memvalidasinya
Klien harus mengirim token di Authorization
header HTTP standar permintaan. Sebagai contoh:
Authorization: Bearer <token-goes-here>
Nama header HTTP standar sangat disayangkan karena membawa informasi otentikasi , bukan otorisasi . Namun, itu adalah header HTTP standar untuk mengirim kredensial ke server.
JAX-RS menyediakan @NameBinding
, meta-anotasi yang digunakan untuk membuat anotasi lain untuk mengikat filter dan interseptor ke kelas dan metode sumber daya. Tetapkan @Secured
anotasi sebagai berikut:
@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }
Anotasi pengikat nama yang didefinisikan di atas akan digunakan untuk menghias kelas filter, yang mengimplementasikan ContainerRequestFilter
, memungkinkan Anda untuk mencegat permintaan sebelum ditangani oleh metode sumber daya. The ContainerRequestContext
dapat digunakan untuk mengakses header permintaan HTTP dan kemudian ekstrak token:
@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {
private static final String REALM = "example";
private static final String AUTHENTICATION_SCHEME = "Bearer";
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// Get the Authorization header from the request
String authorizationHeader =
requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
// Validate the Authorization header
if (!isTokenBasedAuthentication(authorizationHeader)) {
abortWithUnauthorized(requestContext);
return;
}
// Extract the token from the Authorization header
String token = authorizationHeader
.substring(AUTHENTICATION_SCHEME.length()).trim();
try {
// Validate the token
validateToken(token);
} catch (Exception e) {
abortWithUnauthorized(requestContext);
}
}
private boolean isTokenBasedAuthentication(String authorizationHeader) {
// Check if the Authorization header is valid
// It must not be null and must be prefixed with "Bearer" plus a whitespace
// The authentication scheme comparison must be case-insensitive
return authorizationHeader != null && authorizationHeader.toLowerCase()
.startsWith(AUTHENTICATION_SCHEME.toLowerCase() + " ");
}
private void abortWithUnauthorized(ContainerRequestContext requestContext) {
// Abort the filter chain with a 401 status code response
// The WWW-Authenticate header is sent along with the response
requestContext.abortWith(
Response.status(Response.Status.UNAUTHORIZED)
.header(HttpHeaders.WWW_AUTHENTICATE,
AUTHENTICATION_SCHEME + " realm=\"" + REALM + "\"")
.build());
}
private void validateToken(String token) throws Exception {
// Check if the token was issued by the server and if it's not expired
// Throw an Exception if the token is invalid
}
}
Jika ada masalah yang terjadi selama validasi token, respons dengan status 401
(Tidak Diotorisasi) akan dikembalikan. Kalau tidak, permintaan akan dilanjutkan ke metode sumber daya.
Mengamankan titik akhir REST Anda
Untuk mengikat filter otentikasi ke metode sumber daya atau kelas sumber daya, beri anotasi dengan @Secured
anotasi yang dibuat di atas. Untuk metode dan / atau kelas yang dianotasi, filter akan dieksekusi. Ini berarti bahwa titik akhir tersebut hanya akan tercapai jika permintaan dilakukan dengan token yang valid.
Jika beberapa metode atau kelas tidak memerlukan otentikasi, cukup jangan membubuhi keterangannya:
@Path("/example")
public class ExampleResource {
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myUnsecuredMethod(@PathParam("id") Long id) {
// This method is not annotated with @Secured
// The authentication filter won't be executed before invoking this method
...
}
@DELETE
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response mySecuredMethod(@PathParam("id") Long id) {
// This method is annotated with @Secured
// The authentication filter will be executed before invoking this method
// The HTTP request must be performed with a valid token
...
}
}
Dalam contoh yang ditunjukkan di atas, filter akan dieksekusi hanya untuk mySecuredMethod(Long)
metode karena itu dijelaskan dengan @Secured
.
Mengidentifikasi pengguna saat ini
Sangat mungkin bahwa Anda perlu mengetahui pengguna yang melakukan permintaan terhadap API REST Anda. Pendekatan berikut dapat digunakan untuk mencapainya:
Mengganti konteks keamanan permintaan saat ini
Dalam ContainerRequestFilter.filter(ContainerRequestContext)
metode Anda , SecurityContext
contoh baru dapat ditetapkan untuk permintaan saat ini. Kemudian timpa SecurityContext.getUserPrincipal()
, mengembalikan sebuah Principal
instance:
final SecurityContext currentSecurityContext = requestContext.getSecurityContext();
requestContext.setSecurityContext(new SecurityContext() {
@Override
public Principal getUserPrincipal() {
return () -> username;
}
@Override
public boolean isUserInRole(String role) {
return true;
}
@Override
public boolean isSecure() {
return currentSecurityContext.isSecure();
}
@Override
public String getAuthenticationScheme() {
return AUTHENTICATION_SCHEME;
}
});
Gunakan token untuk mencari pengidentifikasi pengguna (nama pengguna), yang akan menjadi Principal
nama.
Suntikkan SecurityContext
dalam kelas sumber daya JAX-RS:
@Context
SecurityContext securityContext;
Hal yang sama dapat dilakukan dalam metode sumber daya JAX-RS:
@GET
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id,
@Context SecurityContext securityContext) {
...
}
Dan kemudian dapatkan Principal
:
Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();
Menggunakan CDI (Injeksi Konteks dan Ketergantungan)
Jika, karena alasan tertentu, Anda tidak ingin mengesampingkannya SecurityContext
, Anda dapat menggunakan CDI (Context and Dependency Injection), yang menyediakan fitur berguna seperti acara dan produsen.
Buat kualifikasi CDI:
@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
Di yang Anda AuthenticationFilter
buat di atas, suntikkan Event
anotasi dengan @AuthenticatedUser
:
@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
Jika otentikasi berhasil, jalankan peristiwa yang melewati nama pengguna sebagai parameter (ingat, token dikeluarkan untuk pengguna dan token akan digunakan untuk mencari pengidentifikasi pengguna):
userAuthenticatedEvent.fire(username);
Sangat mungkin ada kelas yang mewakili pengguna di aplikasi Anda. Sebut kelas ini User
.
Buat kacang CDI untuk menangani acara otentikasi, cari User
contoh dengan nama pengguna koresponden dan tetapkan ke authenticatedUser
bidang produsen:
@RequestScoped
public class AuthenticatedUserProducer {
@Produces
@RequestScoped
@AuthenticatedUser
private User authenticatedUser;
public void handleAuthenticationEvent(@Observes @AuthenticatedUser String username) {
this.authenticatedUser = findUser(username);
}
private User findUser(String username) {
// Hit the the database or a service to find a user by its username and return it
// Return the User instance
}
}
The authenticatedUser
lapangan menghasilkan User
contoh yang dapat disuntikkan ke dalam wadah yang dikelola kacang-kacangan, seperti layanan JAX-RS, kacang CDI, servlets dan EJBs. Gunakan potongan kode berikut untuk menyuntikkan User
instance (sebenarnya, ini adalah proxy CDI):
@Inject
@AuthenticatedUser
User authenticatedUser;
Perhatikan bahwa @Produces
anotasi CDI berbeda dari @Produces
anotasi JAX-RS :
Pastikan Anda menggunakan @Produces
anotasi CDI dalam AuthenticatedUserProducer
kacang Anda .
Kuncinya di sini adalah kacang yang dianotasi @RequestScoped
, memungkinkan Anda untuk berbagi data antara filter dan kacang Anda. Jika Anda tidak ingin menggunakan acara, Anda dapat memodifikasi filter untuk menyimpan pengguna terotentikasi dalam kacang lingkup permintaan dan kemudian membacanya dari kelas sumber daya JAX-RS Anda.
Dibandingkan dengan pendekatan yang mengesampingkan SecurityContext
, pendekatan CDI memungkinkan Anda untuk mendapatkan pengguna terotentikasi dari kacang selain sumber daya dan penyedia JAX-RS.
Mendukung otorisasi berbasis peran
Silakan merujuk ke jawaban saya yang lain untuk perincian tentang bagaimana mendukung otorisasi berbasis peran.
Mengeluarkan token
Token dapat berupa:
- Buram: Tidak mengungkapkan detail selain dari nilai itu sendiri (seperti string acak)
- Mandiri: Berisi rincian tentang token itu sendiri (seperti JWT).
Lihat detail di bawah ini:
String acak sebagai token
Token dapat dikeluarkan dengan membuat string acak dan menahannya ke database bersama dengan pengenal pengguna dan tanggal kedaluwarsa. Contoh yang baik tentang cara membuat string acak di Jawa dapat dilihat di sini . Anda juga bisa menggunakan:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT (Token Web JSON)
JWT (JSON Web Token) adalah metode standar untuk mewakili klaim secara aman antara dua pihak dan didefinisikan oleh RFC 7519 .
Ini token yang berdiri sendiri dan memungkinkan Anda untuk menyimpan detail dalam klaim . Klaim ini disimpan dalam token payload yang merupakan JSON yang disandikan sebagai Base64 . Berikut adalah beberapa klaim yang terdaftar di RFC 7519 dan apa artinya (baca RFC lengkap untuk detail lebih lanjut):
iss
: Kepala sekolah yang menerbitkan token.
sub
: Kepala sekolah yang menjadi subjek JWT.
exp
: Tanggal kedaluwarsa untuk token.
nbf
: Waktu di mana token akan mulai diterima untuk diproses.
iat
: Waktu saat token diterbitkan.
jti
: Pengidentifikasi unik untuk token.
Ketahuilah bahwa Anda tidak boleh menyimpan data sensitif, seperti kata sandi, di token.
Payload dapat dibaca oleh klien dan integritas token dapat dengan mudah diperiksa dengan memverifikasi tanda tangannya di server. Tanda tangan inilah yang mencegah token untuk dirusak.
Anda tidak perlu mempertahankan token JWT jika Anda tidak perlu melacaknya. Meskipun demikian, dengan mempertahankan token, Anda akan memiliki kemungkinan membatalkan dan mencabut aksesnya. Untuk tetap melacak token JWT, alih-alih mempertahankan seluruh token di server, Anda dapat tetap menggunakan pengenal token ( jti
klaim) bersama dengan beberapa detail lainnya seperti pengguna yang Anda berikan token, tanggal kedaluwarsa, dll.
Saat mempertahankan token, selalu pertimbangkan menghapus yang lama untuk mencegah basis data Anda tumbuh tanpa batas.
Menggunakan JWT
Ada beberapa perpustakaan Java untuk menerbitkan dan memvalidasi token JWT seperti:
Untuk menemukan sumber daya hebat lainnya untuk bekerja dengan JWT, lihat di http://jwt.io .
Menangani pencabutan token dengan JWT
Jika Anda ingin mencabut token, Anda harus melacaknya. Anda tidak perlu menyimpan seluruh token di sisi server, hanya menyimpan pengenal token (yang harus unik) dan beberapa metadata jika diperlukan. Untuk pengenal token Anda dapat menggunakan UUID .
The jti
klaim harus digunakan untuk menyimpan identifier token token. Saat memvalidasi token, pastikan belum dibatalkan dengan memeriksa nilai jti
klaim terhadap pengenal token yang Anda miliki di sisi server.
Untuk tujuan keamanan, cabut semua token untuk pengguna saat mereka mengubah kata sandi mereka.
Informasi tambahan
- Tidak masalah jenis autentikasi apa yang Anda putuskan untuk digunakan. Selalu lakukan di atas koneksi HTTPS untuk mencegah serangan man-in-the-middle .
- Lihatlah pertanyaan ini dari Keamanan Informasi untuk informasi lebih lanjut tentang token.
- Pada artikel ini Anda akan menemukan beberapa informasi berguna tentang otentikasi berbasis token.
The server stores the previously generated token in some storage along with the user identifier and an expiration date. The server sends the generated token to the client.
Bagaimana ini tenang?