with tutorial restful jax example español bearer based java rest authentication jax-rs jersey-2.0

java - tutorial - Las mejores prácticas para la autenticación basada en token REST con JAX-RS y Jersey



jersey token authentication example (2)

Esta respuesta tiene que ver con la autorización y es un complemento de mi respuesta anterior sobre autenticación

¿Por qué otra respuesta? Intenté ampliar mi respuesta anterior agregando detalles sobre cómo admitir las anotaciones JSR-250. Sin embargo, la respuesta original se convirtió en el camino demasiado largo y excedió la longitud máxima de 30,000 caracteres . Así que moví todos los detalles de la autorización a esta respuesta, manteniendo la otra respuesta centrada en realizar la autenticación y emitir tokens.

Apoyo a la autorización basada en roles con la anotación @Secured

Además del flujo de autenticación que se muestra en la otra respuesta , la autorización basada en roles se puede admitir en los puntos finales REST.

Cree una enumeración y defina los roles según sus necesidades:

public enum Role { ROLE_1, ROLE_2, ROLE_3 }

Cambie la anotación de enlace de nombre @Secured creada antes para admitir roles:

@NameBinding @Retention(RUNTIME) @Target({TYPE, METHOD}) public @interface Secured { Role[] value() default {}; }

Y luego anote las clases de recursos y métodos con @Secured para realizar la autorización. Las anotaciones del método anularán las anotaciones de la clase:

@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 ... } }

Cree un filtro con la prioridad de AUTHORIZATION , que se ejecuta después del filtro de prioridad de AUTHENTICATION definido previamente.

Se puede usar ResourceInfo para obtener el Method recurso y la Class recurso que manejará la solicitud y luego extraerá las anotaciones @Secured :

@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 el usuario no tiene permiso para ejecutar la operación, la solicitud se cancela con un 403 (Prohibido).

Para conocer al usuario que está realizando la solicitud, vea mi respuesta anterior . Puede obtenerlo desde SecurityContext (que ya debería estar configurado en ContainerRequestFilter.filter(ContainerRequestContext) ) o inyectarlo usando CDI, dependiendo del enfoque que elija.

Si una anotación @Secured no tiene roles declarados, puede asumir que todos los usuarios autenticados pueden acceder a ese punto final, sin tener en cuenta los roles que tienen los usuarios.

Apoyo a la autorización basada en roles con anotaciones JSR-250

Como alternativa a la definición de los roles en la anotación @Secured como se muestra arriba, puede considerar anotaciones JSR-250 como @RolesAllowed , @PermitAll y @DenyAll .

JAX-RS no admite dichas anotaciones de fábrica, pero se puede lograr con un filtro. Aquí hay algunas consideraciones que debe tener en cuenta si desea admitirlas todas:

Por lo tanto, un filtro de autorización que verifica las anotaciones JSR-250 podría ser como:

@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."); } }

Nota: la implementación anterior se basa en la función Jersey RolesAllowedDynamicFeature . Si usa Jersey, no necesita escribir su propio filtro, solo use la implementación existente.

Estoy buscando una manera de habilitar la autenticación basada en token en Jersey. Estoy tratando de no usar ningún marco en particular. ¿Es eso posible?

Mi plan es: un usuario se registra en mi servicio web, mi servicio web genera un token, lo envía al cliente y el cliente lo retendrá. Luego, el cliente, para cada solicitud, enviará el token en lugar del nombre de usuario y la contraseña.

Estaba pensando en usar un filtro personalizado para cada solicitud y @PreAuthorize("hasRole(''ROLE'')") pero pensé que esto causa muchas solicitudes a la base de datos para verificar si el token es válido.

¿O no crear filtro y en cada solicitud poner un token param? De modo que cada API primero verifica el token y luego ejecuta algo para recuperar recursos.


Cómo funciona la autenticación basada en token

En la autenticación basada en token, el cliente intercambia credenciales duras (como el nombre de usuario y la contraseña) por un dato llamado token . Para cada solicitud, en lugar de enviar las credenciales duras, el cliente enviará el token al servidor para realizar la autenticación y luego la autorización.

En pocas palabras, un esquema de autenticación basado en tokens sigue estos pasos:

  1. El cliente envía sus credenciales (nombre de usuario y contraseña) al servidor.
  2. El servidor autentica las credenciales y, si son válidas, genera un token para el usuario.
  3. El servidor almacena el token generado anteriormente en algún almacenamiento junto con el identificador de usuario y una fecha de caducidad.
  4. El servidor envía el token generado al cliente.
  5. El cliente envía el token al servidor en cada solicitud.
  6. El servidor, en cada solicitud, extrae el token de la solicitud entrante. Con el token, el servidor busca los detalles del usuario para realizar la autenticación.
    • Si el token es válido, el servidor acepta la solicitud.
    • Si el token no es válido, el servidor rechaza la solicitud.
  7. Una vez realizada la autenticación, el servidor realiza la autorización.
  8. El servidor puede proporcionar un punto final para actualizar tokens.

Nota: el paso 3 no es necesario si el servidor ha emitido un token firmado (como JWT, que le permite realizar una autenticación sin estado ).

Qué puede hacer con JAX-RS 2.0 (Jersey, RESTEasy y Apache CXF)

Esta solución utiliza solo la API JAX-RS 2.0, evitando cualquier solución específica del proveedor . Por lo tanto, debería funcionar con implementaciones JAX-RS 2.0, como Jersey , RESTEasy y Apache CXF .

Vale la pena mencionar que si está utilizando la autenticación basada en token, no está confiando en los mecanismos de seguridad de la aplicación web Java EE estándar ofrecidos por el contenedor de servlets y configurables a través del descriptor web.xml la aplicación. Es una autenticación personalizada.

Autenticar a un usuario con su nombre de usuario y contraseña y emitir un token

Cree un método de recursos JAX-RS que reciba y valide las credenciales (nombre de usuario y contraseña) y emita un token para el usuario:

@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 se producen excepciones al validar las credenciales, se devolverá una respuesta con el estado 403 (Prohibido).

Si las credenciales se validan correctamente, se devolverá una respuesta con el estado 200 (OK) y el token emitido se enviará al cliente en la carga útil de la respuesta. El cliente debe enviar el token al servidor en cada solicitud.

Al consumir application/x-www-form-urlencoded , el cliente debe enviar las credenciales en el siguiente formato en la carga útil de solicitud:

username=admin&password=123456

En lugar de los parámetros de formulario, es posible incluir el nombre de usuario y la contraseña en una clase:

public class Credentials implements Serializable { private String username; private String password; // Getters and setters omitted }

Y luego consumirlo como 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 }

Usando este enfoque, el cliente debe enviar las credenciales en el siguiente formato en la carga útil de la solicitud:

{ "username": "admin", "password": "123456" }

Extraer el token de la solicitud y validarlo.

El cliente debe enviar el token en el encabezado de Authorization HTTP estándar de la solicitud. Por ejemplo:

Authorization: Bearer <token-goes-here>

El nombre del encabezado HTTP estándar es desafortunado porque lleva información de autenticación , no autorización . Sin embargo, es el encabezado HTTP estándar para enviar credenciales al servidor.

JAX-RS proporciona @NameBinding , una meta-anotación utilizada para crear otras anotaciones para unir filtros e interceptores a clases de recursos y métodos. Defina una anotación @Secured como sigue:

@NameBinding @Retention(RUNTIME) @Target({TYPE, METHOD}) public @interface Secured { }

La anotación de enlace de nombre definida anteriormente se usará para decorar una clase de filtro, que implementa ContainerRequestFilter , lo que le permite interceptar la solicitud antes de que sea manejada por un método de recurso. El ContainerRequestContext se puede usar para acceder a los encabezados de solicitud HTTP y luego extraer el 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 } }

Si ocurre algún problema durante la validación del token, se devolverá una respuesta con el estado 401 (No autorizado). De lo contrario, la solicitud procederá a un método de recurso.

Asegurando sus puntos finales REST

Para vincular el filtro de autenticación a los métodos de recursos o clases de recursos, @Secured anotaciones con la anotación @Secured creada anteriormente. Para los métodos y / o clases que están anotados, se ejecutará el filtro. Esto significa que dichos puntos finales solo se alcanzarán si la solicitud se realiza con un token válido.

Si algunos métodos o clases no necesitan autenticación, simplemente no los anote:

@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 ... } }

En el ejemplo que se muestra arriba, el filtro se ejecutará solo para el mySecuredMethod(Long) porque se anota con @Secured .

Identificando el usuario actual

Es muy probable que necesite conocer al usuario que está realizando la solicitud en su API REST. Se pueden utilizar los siguientes enfoques para lograrlo:

Anulando el contexto de seguridad de la solicitud actual

Dentro de su método ContainerRequestFilter.filter(ContainerRequestContext) , se puede configurar una nueva instancia de SecurityContext para la solicitud actual. Luego anule el SecurityContext.getUserPrincipal() , devolviendo una instancia 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; } });

Use el token para buscar el identificador de usuario (nombre de usuario), que será el nombre del Principal .

Inyecte el SecurityContext en cualquier clase de recurso JAX-RS:

@Context SecurityContext securityContext;

Lo mismo se puede hacer en un método de recursos JAX-RS:

@GET @Secured @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Response myMethod(@PathParam("id") Long id, @Context SecurityContext securityContext) { ... }

Y luego conseguir el Principal :

Principal principal = securityContext.getUserPrincipal(); String username = principal.getName();

Usando CDI (Contexto y inyección de dependencia)

Si, por alguna razón, no desea anular SecurityContext , puede usar CDI (inyección de contexto y dependencia), que proporciona funciones útiles como eventos y productores.

Crear un calificador CDI:

@Qualifier @Retention(RUNTIME) @Target({ METHOD, FIELD, PARAMETER }) public @interface AuthenticatedUser { }

En su AuthenticationFilter creado anteriormente, inyecte un Event anotado con @AuthenticatedUser :

@Inject @AuthenticatedUser Event<String> userAuthenticatedEvent;

Si la autenticación se realiza correctamente, active el evento pasando el nombre de usuario como parámetro (recuerde, el token se emite para un usuario y el token se usará para buscar el identificador de usuario):

userAuthenticatedEvent.fire(username);

Es muy probable que haya una clase que represente a un usuario en su aplicación. Llamemos User esta clase.

Cree un bean CDI para manejar el evento de autenticación, encuentre una instancia de User con el nombre de usuario correspondiente y asigne el campo al productor de usuario 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 } }

El campo authenticatedUser produce una instancia de User que se puede inyectar en beans administrados por contenedor, como servicios JAX-RS, beans CDI, servlets y EJB. Utilice el siguiente fragmento de código para inyectar una instancia de User (de hecho, es un proxy CDI):

@Inject @AuthenticatedUser User authenticatedUser;

Tenga en cuenta que la anotación CDI @Produces es diferente de la anotación JAX-RS @Produces :

Asegúrese de utilizar la anotación CDI @Produces en su bean AuthenticatedUserProducer .

La clave aquí es el bean anotado con @RequestScoped , que le permite compartir datos entre los filtros y sus beans. Si no quiere usar eventos, puede modificar el filtro para almacenar el usuario autenticado en un bean de ámbito de solicitud y luego leerlo desde sus clases de recursos JAX-RS.

En comparación con el enfoque que reemplaza a SecurityContext , el enfoque CDI le permite obtener al usuario autenticado de beans que no sean recursos y proveedores de JAX-RS.

Autorización basada en roles de soporte

Consulte mi otra answer para obtener detalles sobre cómo admitir la autorización basada en roles.

Emisión de tokens

Un token puede ser:

  • Opaco: no revela más detalles que el propio valor (como una cadena aleatoria)
  • Autocontenido: contiene detalles sobre el token en sí (como JWT).

Vea los detalles abajo:

Cadena aleatoria como token

Se puede emitir un token generando una cadena aleatoria y conservándola en una base de datos junto con el identificador de usuario y una fecha de caducidad. Un buen ejemplo de cómo generar una cadena aleatoria en Java se puede ver here . También podrías usar:

Random random = new SecureRandom(); String token = new BigInteger(130, random).toString(32);

JWT (token web JSON)

JWT (JSON Web Token) es un método estándar para representar reclamaciones de forma segura entre dos partes y está definido por el RFC 7519 .

Es un token autocontenido y le permite almacenar detalles en reclamaciones . Estas reclamaciones se almacenan en la carga útil del token, que es un JSON codificado como Base64 . Aquí hay algunos reclamos registrados en el RFC 7519 y lo que significan (lea el RFC completo para más detalles):

  • iss : principal que emitió el token.
  • sub : principal que es el tema de la JWT.
  • exp : fecha de caducidad del token.
  • nbf : Hora en que el token comenzará a ser aceptado para su procesamiento.
  • iat : Hora en que se emitió el token.
  • jti : identificador único para el token.

Tenga en cuenta que no debe almacenar datos confidenciales, como contraseñas, en el token.

El cliente puede leer la carga útil y la integridad del token se puede verificar fácilmente verificando su firma en el servidor. La firma es lo que evita que el token sea manipulado.

No necesitarás conservar tokens de JWT si no necesitas rastrearlos. Aunque, al persistir los tokens, tendrá la posibilidad de invalidar y revocar el acceso a ellos. Para mantener el seguimiento de tokens JWT, en lugar de conservar todo el token en el servidor, puede conservar el identificador del token (reclamación jti ) junto con algunos otros detalles, como el usuario para el que emitió el token, la fecha de caducidad, etc.

Al conservar los tokens, siempre considere eliminar los antiguos para evitar que su base de datos crezca indefinidamente.

Usando JWT

Hay algunas bibliotecas de Java para emitir y validar tokens JWT como:

Para encontrar algunos otros recursos excelentes para trabajar con JWT, eche un vistazo a http://jwt.io .

Manejo de refresco de token con JWT

Acepte solo tokens válidos (y no vencidos) para refrescarse. Es responsabilidad del cliente actualizar los tokens antes de la fecha de vencimiento indicada en el reclamo de exp .

Debes evitar que los tokens se actualicen indefinidamente. Vea a continuación algunos enfoques que podría considerar.

Puede mantener un registro del refresco de token agregando dos reclamaciones a su token (los nombres de las reclamaciones son suyos):

  • refreshLimit : indica cuántas veces se puede actualizar el token.
  • refreshCount : indica cuántas veces se ha actualizado el token.

Solo actualice el token si se cumplen las siguientes condiciones:

  • El token no ha caducado ( exp >= now ).
  • El número de veces que el token se ha actualizado es menor que el número de veces que el token se puede actualizar ( refreshCount < refreshLimit ).

Y al refrescar el token:

  • Actualice la fecha de caducidad ( exp = now + some-amount-of-time ).
  • Aumente el número de veces que el token se ha actualizado ( refreshCount++ ).

Alternativamente, para mantener un registro de la cantidad de refrescos, puede tener un reclamo que indique la fecha de vencimiento absoluta (que funciona bastante similar al reclamo refreshLimit descrito anteriormente). Antes de la fecha de vencimiento absoluta , cualquier cantidad de refrescos es aceptable.

Otro enfoque consiste en emitir un token de actualización de larga duración que se use para emitir tokens JWT de corta duración.

El mejor enfoque depende de sus necesidades.

Manejo de revocación de token con JWT

Si desea revocar tokens, debe mantener el seguimiento de ellos. No es necesario almacenar todo el token en el lado del servidor, almacene solo el identificador del token (que debe ser único) y algunos metadatos si es necesario. Para el identificador de token podría usar UUID .

La reclamación jti debe utilizarse para almacenar el identificador de token en el token. Al validar el token, asegúrese de que no se haya revocado comprobando el valor de la reclamación jti contra los identificadores de token que tiene en el lado del servidor.

Por motivos de seguridad, revoque todos los tokens para un usuario cuando cambie su contraseña.

Información Adicional

  • No importa qué tipo de autenticación decida utilizar. Siempre hazlo en la parte superior de una conexión HTTPS para evitar el ataque del hombre en el medio .
  • Eche un vistazo a esta pregunta de Seguridad de la información para obtener más información sobre los tokens.
  • En este artículo encontrará información útil sobre la autenticación basada en token.