Come funziona l'autenticazione basata su token
Nell'autenticazione basata su token, il client scambia credenziali rigide (come nome utente e password) con un pezzo di dati chiamato token . Per ogni richiesta, invece di inviare le credenziali effettive, il client invierà il token al server per eseguire l'autenticazione e quindi l'autorizzazione.
In poche parole, uno schema di autenticazione basato su token segue questi passaggi:
- Il client invia le proprie credenziali (nome utente e password) al server.
- Il server autentica le credenziali e, se sono valide, genera un token per l'utente.
- Il server memorizza il token generato in precedenza in un certo spazio di archiviazione insieme all'identificatore utente e una data di scadenza.
- Il server invia il token generato al client.
- Il client invia il token al server in ogni richiesta.
- Il server, in ogni richiesta, estrae il token dalla richiesta in arrivo. Con il token, il server cerca i dettagli dell'utente per eseguire l'autenticazione.
- Se il token è valido, il server accetta la richiesta.
- Se il token non è valido, il server rifiuta la richiesta.
- Una volta eseguita l'autenticazione, il server esegue l'autorizzazione.
- Il server può fornire un endpoint per aggiornare i token.
Nota: il passaggio 3 non è necessario se il server ha emesso un token firmato (come JWT, che consente di eseguire l' autenticazione senza stato ).
Cosa puoi fare con JAX-RS 2.0 (Jersey, RESTEasy e Apache CXF)
Questa soluzione utilizza solo l'API JAX-RS 2.0, evitando qualsiasi soluzione specifica del fornitore . Quindi, dovrebbe funzionare con le implementazioni JAX-RS 2.0, come Jersey , RESTEasy e Apache CXF .
Vale la pena ricordare che se si utilizza l'autenticazione basata su token, non si fa affidamento sui meccanismi di sicurezza delle applicazioni Web Java EE standard offerti dal contenitore servlet e configurabili tramite il web.xml
descrittore dell'applicazione . È un'autenticazione personalizzata.
Autenticazione di un utente con nome utente e password ed emissione di un token
Creare un metodo di risorsa JAX-RS che riceve e convalida le credenziali (nome utente e password) ed emette un token per l'utente:
@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
}
}
Se vengono generate eccezioni durante la convalida delle credenziali, 403
verrà restituita una risposta con lo stato (Non consentito).
Se le credenziali vengono convalidate correttamente, 200
verrà restituita una risposta con lo stato (OK) e il token emesso verrà inviato al client nel payload della risposta. Il client deve inviare il token al server in ogni richiesta.
Quando consuma application/x-www-form-urlencoded
, il client deve inviare le credenziali nel seguente formato nel payload della richiesta:
username=admin&password=123456
Invece di parametri param, è possibile racchiudere il nome utente e la password in una classe:
public class Credentials implements Serializable {
private String username;
private String password;
// Getters and setters omitted
}
E poi consumalo come 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
}
Utilizzando questo approccio, il client deve inviare le credenziali nel seguente formato nel payload della richiesta:
{
"username": "admin",
"password": "123456"
}
Estrarre il token dalla richiesta e convalidarlo
Il client deve inviare il token Authorization
nell'intestazione HTTP standard della richiesta. Per esempio:
Authorization: Bearer <token-goes-here>
Il nome dell'intestazione HTTP standard è sfortunato perché contiene informazioni di autenticazione , non di autorizzazione . Tuttavia, è l'intestazione HTTP standard per l'invio di credenziali al server.
JAX-RS fornisce @NameBinding
una meta-annotazione utilizzata per creare altre annotazioni per associare filtri e intercettori a classi e metodi delle risorse. Definire @Secured
un'annotazione come segue:
@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }
L'annotazione di associazione dei nomi sopra definita verrà utilizzata per decorare una classe di filtro, che implementa ContainerRequestFilter
, consentendo di intercettare la richiesta prima che venga gestita da un metodo di risorsa. È ContainerRequestContext
possibile utilizzare per accedere alle intestazioni della richiesta HTTP e quindi estrarre il 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
}
}
Se si verificano problemi durante la convalida del token, 401
verrà restituita una risposta con lo stato (Non autorizzato). Altrimenti la richiesta passerà a un metodo di risorsa.
Protezione degli endpoint REST
Per associare il filtro di autenticazione a metodi o classi di risorse, annotarli con l' @Secured
annotazione creata sopra. Per i metodi e / o le classi che sono annotati, il filtro verrà eseguito. Significa che tali endpoint saranno raggiunti solo se la richiesta viene eseguita con un token valido.
Se alcuni metodi o classi non necessitano di autenticazione, semplicemente non annotarli:
@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
...
}
}
Nell'esempio mostrato sopra, il filtro verrà eseguito solo per il mySecuredMethod(Long)
metodo perché è annotato con @Secured
.
Identificazione dell'utente corrente
È molto probabile che dovrai conoscere nuovamente l'utente che sta eseguendo la richiesta tramite l'API REST. I seguenti approcci possono essere utilizzati per raggiungerlo:
Sostituzione del contesto di sicurezza della richiesta corrente
Nel tuo ContainerRequestFilter.filter(ContainerRequestContext)
metodo, è SecurityContext
possibile impostare una nuova istanza per la richiesta corrente. Quindi sovrascrivere il SecurityContext.getUserPrincipal()
, restituendo Principal
un'istanza:
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;
}
});
Utilizzare il token per cercare l'identificatore utente (nome utente), che sarà il Principal
nome.
Iniettare SecurityContext
in qualsiasi classe di risorse JAX-RS:
@Context
SecurityContext securityContext;
Lo stesso può essere fatto in un metodo di risorsa JAX-RS:
@GET
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id,
@Context SecurityContext securityContext) {
...
}
E poi ottieni il Principal
:
Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();
Utilizzo di CDI (Iniezione di contesto e dipendenza)
Se, per qualche motivo, non si desidera ignorare SecurityContext
, è possibile utilizzare CDI (Context and Dependency Injection), che fornisce funzionalità utili come eventi e produttori.
Creare un qualificatore CDI:
@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
Nel AuthenticationFilter
creato sopra, iniettare un Event
annotato con @AuthenticatedUser
:
@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
Se l'autenticazione ha esito positivo, attiva l'evento che passa il nome utente come parametro (ricorda, il token viene emesso per un utente e il token verrà utilizzato per cercare l'identificatore utente):
userAuthenticatedEvent.fire(username);
È molto probabile che ci sia una classe che rappresenta un utente nella tua applicazione. Chiamiamo questa classe User
.
Creare un bean CDI per gestire l'evento di autenticazione, trovare User
un'istanza con il nome utente corrispondente e assegnarlo al authenticatedUser
campo del produttore:
@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
}
}
Il authenticatedUser
campo produce User
un'istanza che può essere iniettata in bean gestiti dal contenitore, come servizi JAX-RS, bean CDI, servlet ed EJB. Utilizzare il seguente pezzo di codice per iniettare User
un'istanza (in realtà, è un proxy CDI):
@Inject
@AuthenticatedUser
User authenticatedUser;
Notare che l' @Produces
annotazione CDI è diversa dall'annotazione JAX-RS @Produces
:
Assicurati di utilizzare l' @Produces
annotazione CDI nel tuo AuthenticatedUserProducer
bean.
La chiave qui è il bean annotato con @RequestScoped
, che consente di condividere dati tra filtri e bean. Se non si desidera utilizzare gli eventi, è possibile modificare il filtro per archiviare l'utente autenticato in un bean con ambito richiesta e quindi leggerlo dalle classi di risorse JAX-RS.
Rispetto all'approccio che ha la precedenza su SecurityContext
, l'approccio CDI consente di ottenere l'utente autenticato da bean diversi dalle risorse e dai provider JAX-RS.
Supportare l'autorizzazione basata sui ruoli
Per ulteriori informazioni su come supportare l'autorizzazione basata sul ruolo, consultare la mia altra risposta .
Token di emissione
Un token può essere:
- Opaco: non rivela dettagli diversi dal valore stesso (come una stringa casuale)
- Autosufficiente: contiene dettagli sul token stesso (come JWT).
Vedi i dettagli di seguito:
Stringa casuale come token
Un token può essere emesso generando una stringa casuale e conservandola in un database insieme all'identificatore utente e alla data di scadenza. Un buon esempio di come generare una stringa casuale in Java può essere visto qui . Puoi anche usare:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT (token Web JSON)
JWT (JSON Web Token) è un metodo standard per rappresentare i reclami in modo sicuro tra due parti ed è definito dalla RFC 7519 .
È un token autonomo e ti consente di memorizzare i dettagli nelle attestazioni . Queste attestazioni sono archiviate nel payload del token che è un codice JSON codificato come Base64 . Ecco alcuni reclami registrati nell'RFC 7519 e il loro significato (leggi l'RFC completo per ulteriori dettagli):
iss
: Principale che ha emesso il token.
sub
: Preside che è l'oggetto del JWT.
exp
: Data di scadenza del token.
nbf
: Ora in cui il token inizierà ad essere accettato per l'elaborazione.
iat
: Ora in cui è stato emesso il token.
jti
: Identificatore univoco per il token.
Tenere presente che non è necessario memorizzare nel token dati sensibili, come password.
Il payload può essere letto dal client e l'integrità del token può essere facilmente verificata verificandone la firma sul server. La firma è ciò che impedisce al token di essere manomesso.
Non sarà necessario persistere i token JWT se non è necessario seguirli. Tuttavia, persistendo nei token, avrai la possibilità di invalidare e revocare l'accesso ad essi. Per tenere traccia dei token JWT, invece di mantenere l'intero token sul server, è possibile mantenere l'identificatore token ( jti
reclamo) insieme ad alcuni altri dettagli come l'utente per cui è stato emesso il token, la data di scadenza, ecc.
Quando i token persistono, prendere sempre in considerazione la rimozione di quelli vecchi per impedire la crescita indefinita del database.
Utilizzando JWT
Esistono alcune librerie Java per emettere e validare token JWT come:
Per trovare altre fantastiche risorse per lavorare con JWT, dai un'occhiata a http://jwt.io .
Gestione della revoca di token con JWT
Se si desidera revocare i token, è necessario tenerne traccia. Non è necessario memorizzare l'intero token sul lato server, memorizzare solo l'identificatore del token (che deve essere univoco) e alcuni metadati se necessario. Per l'identificatore token è possibile utilizzare UUID .
Il jti
reclamo deve essere utilizzato per memorizzare l'identificatore token sul token. Quando si convalida il token, assicurarsi che non sia stato revocato verificando il valore del jti
reclamo rispetto agli identificatori di token presenti sul lato server.
Per motivi di sicurezza, revoca tutti i token per un utente quando cambiano la password.
Informazioni aggiuntive
- Non importa quale tipo di autenticazione decidi di utilizzare. Fallo sempre sulla cima di una connessione HTTPS per prevenire l' attacco man-in-the-middle .
- Dai un'occhiata a questa domanda di Sicurezza delle informazioni per ulteriori informazioni sui token.
- In questo articolo troverai alcune informazioni utili sull'autenticazione basata su 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.
Com'è questo RESTful?