Je cherche un moyen d'activer l'authentification par jeton à Jersey. J'essaie de ne pas utiliser de cadre particulier. Est-ce possible?
Mon plan est le suivant: un utilisateur s'inscrit pour mon service Web, ce dernier génère un jeton, l'envoie au client, qui le conservera. Ensuite, le client, pour chaque demande, enverra le jeton à la place du nom d'utilisateur et du mot de passe.
Je pensais utiliser un filtre personnalisé pour chaque demande et @PreAuthorize("hasRole('ROLE')")
, mais je pensais simplement que cela provoquait de nombreuses demandes à la base de données pour vérifier si le jeton était valide.
Ou ne pas créer de filtre et dans chaque demande mettre un jeton param? Alors que chaque API vérifie d'abord le jeton et exécute ensuite quelque chose pour récupérer la ressource.
Dans l'authentification par jeton, le client échange identifiants matériels (tels que nom d'utilisateur et mot de passe) contre une donnée appelée jeton . Pour chaque demande, au lieu d'envoyer les informations d'identification matérielles, le client envoie le jeton au serveur pour effectuer l'authentification, puis l'autorisation.
En quelques mots, un schéma d’authentification basé sur des jetons se déroule comme suit:
Remarque: L'étape 3 n'est pas obligatoire si le serveur a émis un jeton signé (tel que JWT, qui vous permet d'effectuer sans état authentification).
Cette solution utilise uniquement l'API JAX-RS 2.0, évitant toute solution spécifique du fournisseur . Donc, il devrait fonctionner avec les implémentations JAX-RS 2.0, telles que Jersey , RESTEasy et Apache CXF .
Il vaut la peine de mentionner que si vous utilisez une authentification par jeton, vous ne comptez pas sur les mécanismes de sécurité d'application Web Java EE standard proposés par le conteneur de servlets et configurables à l'aide du descripteur _web.xml
_ de l'application. C'est une authentification personnalisée.
Créez une méthode de ressource JAX-RS qui reçoit et valide les informations d'identification (nom d'utilisateur et mot de passe) et émettez un jeton pour l'utilisateur:
_@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
}
}
_
Si des exceptions sont générées lors de la validation des informations d'identification, une réponse avec le statut _403
_ (Interdit) sera renvoyée.
Si les informations d'identification sont validées avec succès, une réponse portant le statut _200
_ (OK) sera renvoyée et le jeton émis sera envoyé au client dans le contenu de la réponse. Le client doit envoyer le jeton au serveur à chaque demande.
Lorsqu'il consomme _application/x-www-form-urlencoded
_, le client doit envoyer les informations d'identification au format suivant dans le contenu de la demande:
_username=admin&password=123456
_
Au lieu de paramètres de formulaire, il est possible de regrouper le nom d'utilisateur et le mot de passe dans une classe:
_public class Credentials implements Serializable {
private String username;
private String password;
// Getters and setters omitted
}
_
Et puis consommez-le comme 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
}
_
À l'aide de cette approche, le client doit envoyer les informations d'identification au format suivant dans la charge utile de la demande:
_{
"username": "admin",
"password": "123456"
}
_
Le client doit envoyer le jeton dans l'en-tête HTTP Authorization
standard de la demande. Par exemple:
_Authorization: Bearer <token-goes-here>
_
Le nom de l'en-tête HTTP standard est regrettable car il porte authentication information, pas authorisation . Cependant, il s'agit de l'en-tête HTTP standard pour l'envoi des informations d'identification au serveur.
JAX-RS fournit @NameBinding
, une méta-annotation utilisée pour créer d'autres annotations permettant de lier des filtres et des intercepteurs à des classes de ressources et à des méthodes. Définissez une annotation _@Secured
_ comme suit:
_@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }
_
L'annotation de liaison de nom définie ci-dessus sera utilisée pour décorer une classe de filtre, qui implémente ContainerRequestFilter
, vous permettant d'intercepter la demande avant qu'elle ne soit gérée par une méthode de ressource. La ContainerRequestContext
peut être utilisée pour accéder aux en-têtes de requête HTTP, puis extraire le jeton:
_@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
}
}
_
Si des problèmes surviennent lors de la validation du jeton, une réponse avec le statut _401
_ (Non autorisé) sera renvoyée. Sinon, la demande passera à une méthode de ressource.
Pour lier le filtre d'authentification aux méthodes de ressources ou aux classes de ressources, annotez-les avec l'annotation _@Secured
_ créée ci-dessus. Pour les méthodes et/ou les classes annotées, le filtre sera exécuté. Cela signifie que ces points de terminaison seront seulement == si la demande est exécutée avec un jeton valide.
Si certaines méthodes ou classes ne nécessitent pas d'authentification, ne les annotez pas:
_@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
...
}
}
_
Dans l'exemple ci-dessus, le filtre sera exécuté seulement == pour la méthode mySecuredMethod(Long)
car elle est annotée avec _@Secured
_.
Il est très probable que vous ayez besoin de connaître l'utilisateur qui exécute la requête contre votre API REST. Les approches suivantes peuvent être utilisées pour y parvenir:
Dans votre méthode ContainerRequestFilter.filter(ContainerRequestContext)
, une nouvelle instance SecurityContext
peut être définie pour la demande en cours. Remplacez ensuite le SecurityContext.getUserPrincipal()
en renvoyant une instance Principal
:
_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;
}
});
_
Utilisez le jeton pour rechercher l'identifiant de l'utilisateur (nom d'utilisateur), qui sera le nom Principal
.
Injectez le SecurityContext
dans n’importe quelle classe de ressources JAX-RS:
_@Context
SecurityContext securityContext;
_
La même chose peut être faite dans une méthode de ressource JAX-RS:
_@GET
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id,
@Context SecurityContext securityContext) {
...
}
_
Et obtenez ensuite le Principal
:
_Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();
_
Si, pour une raison quelconque, vous ne souhaitez pas remplacer la SecurityContext
, vous pouvez utiliser CDI (Context and Dependency Injection), qui fournit des fonctionnalités utiles telles que des événements et des producteurs.
Créez un qualificatif CDI:
_@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
_
Dans votre AuthenticationFilter
créé ci-dessus, injectez une Event
annotée avec _@AuthenticatedUser
_:
_@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
_
Si l'authentification réussit, déclenchez l'événement en passant le nom d'utilisateur en paramètre (rappelez-vous, le jeton est émis pour un utilisateur et le jeton sera utilisé pour rechercher l'identifiant de l'utilisateur):
_userAuthenticatedEvent.fire(username);
_
Il est très probable qu'une classe représente un utilisateur dans votre application. Appelons cette classe User
.
Créez un bean CDI pour gérer l'événement d'authentification, recherchez une instance User
avec le nom d'utilisateur correspondant et affectez-la au champ producteur authenticatedUser
:
_@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
}
}
_
Le champ authenticatedUser
produit une instance User
qui peut être injectée dans des beans gérés par conteneur, tels que les services JAX-RS, les beans CDI, les servlets et les EJB. Utilisez le code suivant pour injecter une instance User
(en fait, c'est un proxy CDI):
_@Inject
@AuthenticatedUser
User authenticatedUser;
_
Notez que l'annotation CDI @Produces
est different de JAX-RS @Produces
annotation:
javax.enterprise.inject.Produces
javax.ws.rs.Produces
Assurez-vous d'utiliser l'annotation CDI @Produces
dans votre bean AuthenticatedUserProducer
.
La clé ici est le bean annoté avec @RequestScoped
, vous permettant de partager des données entre les filtres et vos beans. Si vous ne souhaitez pas utiliser d'événements, vous pouvez modifier le filtre pour stocker l'utilisateur authentifié dans un bean périmé de requête, puis le lire à partir de vos classes de ressources JAX-RS.
Par rapport à l'approche qui remplace la SecurityContext
, l'approche CDI vous permet d'obtenir l'utilisateur authentifié à partir de beans autres que les ressources et les fournisseurs JAX-RS.
Veuillez vous référer à mon autre réponse pour savoir comment prendre en charge l'autorisation basée sur les rôles.
Un jeton peut être:
Voir les détails ci-dessous:
Un jeton peut être émis en générant une chaîne aléatoire et en la conservant dans une base de données avec l'identifiant de l'utilisateur et une date d'expiration. Un bon exemple de la façon de générer une chaîne aléatoire dans Java peut être vu ici . Vous pouvez également utiliser:
_Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
_
JWT (jeton Web JSON) est une méthode standard permettant de représenter des revendications de manière sécurisée entre deux parties. Elle est définie par le RFC 7519 .
C'est un jeton autonome qui vous permet de stocker des détails dans Claims . Ces revendications sont stockées dans la charge de jeton qui est un JSON codé en tant que Base64 . Voici quelques revendications enregistrées dans la RFC 7519 et leur signification (lisez la RFC complète pour plus de détails):
iss
: principal qui a émis le jeton.sub
: Principal faisant l’objet du JWT.exp
: Date d'expiration du jeton.nbf
: Heure à laquelle le jeton commencera à être accepté pour traitement.iat
: Heure à laquelle le jeton a été émis.jti
: Identifiant unique pour le jeton.Sachez que vous ne devez pas stocker de données sensibles, telles que des mots de passe, dans le jeton.
La charge utile peut être lue par le client et l'intégrité du jeton peut être facilement vérifiée en vérifiant sa signature sur le serveur. La signature est ce qui empêche le jeton d'être falsifié.
Vous n'aurez pas besoin de conserver les jetons JWT si vous n'avez pas besoin de les suivre. Bien que, en persistant les jetons, vous aurez la possibilité d’invalider et de révoquer leur accès. Pour garder la trace des jetons JWT, au lieu de conserver l'intégralité du jeton sur le serveur, vous pouvez conserver l'identifiant du jeton ( jti
), ainsi que d'autres détails, tels que l'utilisateur auquel vous avez émis l'élément. jeton pour, la date d'expiration, etc.
Lors de la persistance de jetons, envisagez toujours de supprimer les anciens afin d'éviter que votre base de données ne se développe indéfiniment.
Il existe quelques bibliothèques Java pour émettre et valider des jetons JWT tels que:
Pour trouver d’autres ressources intéressantes pour travailler avec JWT, consultez http://jwt.io .
Acceptez seulement jetons valides (et non expirés) pour le rafraîchissement. Il incombe au client d'actualiser les jetons avant la date d'expiration indiquée dans la revendication exp
.
Vous devez empêcher les jetons d'être actualisés indéfiniment. Voir ci-dessous quelques approches que vous pourriez envisager.
Vous pouvez garder la trace du rafraîchissement du jeton en ajoutant deux revendications à votre jeton (les noms des revendications sont à vous):
refreshLimit
: Indique combien de fois le jeton peut être actualisé.refreshCount
: Indique combien de fois le jeton a été actualisé.Donc, actualisez le jeton uniquement si les conditions suivantes sont remplies:
exp >= now
_).refreshCount < refreshLimit
_).Et lors de l'actualisation du jeton:
exp = now + some-amount-of-time
_).refreshCount++
_).Au lieu de garder la trace du nombre de mises à jour, vous pouvez avoir une revendication qui indique date d'expiration absolue (qui fonctionne à peu près de la même manière que la revendication refreshLimit
décrite ci-dessus). Avant le date d'expiration absolue , tout nombre de rafraîchissements est acceptable.
Une autre approche consiste à émettre un jeton d'actualisation distinct de longue durée utilisé pour émettre des jetons JWT de courte durée.
La meilleure approche dépend de vos besoins.
Si vous souhaitez révoquer des jetons, vous devez en garder la trace. Vous n'avez pas besoin de stocker l'intégralité du jeton côté serveur, vous ne stockez que l'identificateur de jeton (qui doit être unique) et des métadonnées si nécessaire. Pour l'identifiant de jeton, vous pouvez utiliser UUID .
La revendication jti
doit être utilisée pour stocker l'identificateur de jeton sur le jeton. Lors de la validation du jeton, assurez-vous qu'il n'a pas été révoqué en vérifiant la valeur de la revendication jti
par rapport aux identificateurs de jeton que vous avez côté serveur.
Pour des raisons de sécurité, révoquez tous les jetons d'un utilisateur lorsqu'ils modifient leur mot de passe.
Cette réponse est tout au sujet de autorisation et c'est un complément de ma réponse précédente à propos de authentification
Pourquoi une autre réponse? J'ai tenté d'élargir ma réponse précédente en ajoutant des détails sur la manière de prendre en charge JSR- 250 annotations. Cependant, la réponse initiale est devenue la voie trop longue et a dépassé la longueur maximale de 30 000 caractères . J'ai donc déplacé tous les détails de l'autorisation vers cette réponse, l'autre réponse étant centrée sur l'authentification et l'émission de jetons.
@Secured
_Outre le flux d'authentification indiqué dans l'autre réponse , l'autorisation basée sur les rôles peut être prise en charge dans les noeuds finaux REST.
Créez une énumération et définissez les rôles en fonction de vos besoins:
_public enum Role {
ROLE_1,
ROLE_2,
ROLE_3
}
_
Modifiez l'annotation de liaison de nom _@Secured
_ créée précédemment pour prendre en charge les rôles:
_@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured {
Role[] value() default {};
}
_
Et annotez ensuite les classes de ressources et les méthodes avec _@Secured
_ pour effectuer l'autorisation. Les annotations de méthode remplaceront les annotations de classe:
_@Path("/example")
@Secured({Role.ROLE_1})
public class ExampleResource {
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id) {
// This method is not annotated with @Secured
// But it's declared within a class annotated with @Secured({Role.ROLE_1})
// So it only can be executed by the users who have the ROLE_1 role
...
}
@DELETE
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
@Secured({Role.ROLE_1, Role.ROLE_2})
public Response myOtherMethod(@PathParam("id") Long id) {
// This method is annotated with @Secured({Role.ROLE_1, Role.ROLE_2})
// The method annotation overrides the class annotation
// So it only can be executed by the users who have the ROLE_1 or ROLE_2 roles
...
}
}
_
Créez un filtre avec la priorité AUTHORIZATION
, qui est exécuté après le filtre de priorité AUTHENTICATION
défini précédemment.
Le ResourceInfo
peut être utilisé pour obtenir la ressource Method
et la ressource Class
qui gérera la demande et extraira ensuite les annotations _@Secured
_ leur:
_@Secured
@Provider
@Priority(Priorities.AUTHORIZATION)
public class AuthorizationFilter implements ContainerRequestFilter {
@Context
private ResourceInfo resourceInfo;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// Get the resource class which matches with the requested URL
// Extract the roles declared by it
Class<?> resourceClass = resourceInfo.getResourceClass();
List<Role> classRoles = extractRoles(resourceClass);
// Get the resource method which matches with the requested URL
// Extract the roles declared by it
Method resourceMethod = resourceInfo.getResourceMethod();
List<Role> methodRoles = extractRoles(resourceMethod);
try {
// Check if the user is allowed to execute the method
// The method annotations override the class annotations
if (methodRoles.isEmpty()) {
checkPermissions(classRoles);
} else {
checkPermissions(methodRoles);
}
} catch (Exception e) {
requestContext.abortWith(
Response.status(Response.Status.FORBIDDEN).build());
}
}
// Extract the roles from the annotated element
private List<Role> extractRoles(AnnotatedElement annotatedElement) {
if (annotatedElement == null) {
return new ArrayList<Role>();
} else {
Secured secured = annotatedElement.getAnnotation(Secured.class);
if (secured == null) {
return new ArrayList<Role>();
} else {
Role[] allowedRoles = secured.value();
return Arrays.asList(allowedRoles);
}
}
}
private void checkPermissions(List<Role> allowedRoles) throws Exception {
// Check if the user contains one of the allowed roles
// Throw an Exception if the user has not permission to execute the method
}
}
_
Si l'utilisateur n'a pas l'autorisation d'exécuter l'opération, la demande est abandonnée avec un _403
_ (Forbidden).
Pour connaître l'utilisateur qui exécute la demande, voir ma réponse précédente . Vous pouvez l'obtenir à partir du SecurityContext
(qui devrait déjà être défini dans le ContainerRequestContext
) ou l'injecter à l'aide de CDI, en fonction de l'approche choisie.
Si une annotation _@Secured
_ n'a aucun rôle déclaré, vous pouvez supposer que tous les utilisateurs authentifiés peuvent accéder à ce noeud final, sans tenir compte de leurs rôles.
Au lieu de définir les rôles dans l'annotation _@Secured
_ comme indiqué ci-dessus, vous pouvez envisager des annotations JSR-250 telles que @RolesAllowed
, @PermitAll
= et @DenyAll
.
JAX-RS ne prend pas en charge de telles annotations prêtes à l'emploi, mais cela pourrait être réalisé avec un filtre. Voici quelques points à prendre en compte si vous souhaitez les prendre en charge:
@DenyAll
sur la méthode est prioritaire sur @RolesAllowed
et @PermitAll
sur la classe.@RolesAllowed
sur la méthode est prioritaire sur @PermitAll
sur la classe.@PermitAll
sur la méthode est prioritaire sur @RolesAllowed
sur la classe.@DenyAll
ne peut pas être attaché à des classes.@RolesAllowed
sur la classe a la priorité sur @PermitAll
sur la classe.Ainsi, un filtre d'autorisation qui vérifie les annotations JSR-250 pourrait ressembler à:
_@Provider
@Priority(Priorities.AUTHORIZATION)
public class AuthorizationFilter implements ContainerRequestFilter {
@Context
private ResourceInfo resourceInfo;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
Method method = resourceInfo.getResourceMethod();
// @DenyAll on the method takes precedence over @RolesAllowed and @PermitAll
if (method.isAnnotationPresent(DenyAll.class)) {
refuseRequest();
}
// @RolesAllowed on the method takes precedence over @PermitAll
RolesAllowed rolesAllowed = method.getAnnotation(RolesAllowed.class);
if (rolesAllowed != null) {
performAuthorization(rolesAllowed.value(), requestContext);
return;
}
// @PermitAll on the method takes precedence over @RolesAllowed on the class
if (method.isAnnotationPresent(PermitAll.class)) {
// Do nothing
return;
}
// @DenyAll can't be attached to classes
// @RolesAllowed on the class takes precedence over @PermitAll on the class
rolesAllowed =
resourceInfo.getResourceClass().getAnnotation(RolesAllowed.class);
if (rolesAllowed != null) {
performAuthorization(rolesAllowed.value(), requestContext);
}
// @PermitAll on the class
if (resourceInfo.getResourceClass().isAnnotationPresent(PermitAll.class)) {
// Do nothing
return;
}
// Authentication is required for non-annotated methods
if (!isAuthenticated(requestContext)) {
refuseRequest();
}
}
/**
* Perform authorization based on roles.
*
* @param rolesAllowed
* @param requestContext
*/
private void performAuthorization(String[] rolesAllowed,
ContainerRequestContext requestContext) {
if (rolesAllowed.length > 0 && !isAuthenticated(requestContext)) {
refuseRequest();
}
for (final String role : rolesAllowed) {
if (requestContext.getSecurityContext().isUserInRole(role)) {
return;
}
}
refuseRequest();
}
/**
* Check if the user is authenticated.
*
* @param requestContext
* @return
*/
private boolean isAuthenticated(final ContainerRequestContext requestContext) {
// Return true if the user is authenticated or false otherwise
// An implementation could be like:
// return requestContext.getSecurityContext().getUserPrincipal() != null;
}
/**
* Refuse the request.
*/
private void refuseRequest() {
throw new AccessDeniedException(
"You don't have permissions to perform this action.");
}
}
_
Remarque: L'implémentation ci-dessus est basée sur Jersey RolesAllowedDynamicFeature
. Si vous utilisez Jersey, vous n'avez pas besoin d'écrire votre propre filtre, utilisez simplement l'implémentation existante.