Cómo funciona la autenticación basada en token
En la autenticación basada en token, el cliente intercambia credenciales (como nombre de usuario y contraseña) por un dato llamado token . Para cada solicitud, en lugar de enviar las credenciales físicas, 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:
- El cliente envía sus credenciales (nombre de usuario y contraseña) al servidor.
- El servidor autentica las credenciales y, si son válidas, genera un token para el usuario.
- El servidor almacena el token generado previamente en algún almacenamiento junto con el identificador de usuario y una fecha de vencimiento.
- El servidor envía el token generado al cliente.
- El cliente envía el token al servidor en cada solicitud.
- 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.
- Una vez que se ha realizado la autenticación, el servidor realiza la autorización.
- El servidor puede proporcionar un punto final para actualizar los tokens.
Nota: El paso 3 no es obligatorio si el servidor ha emitido un token firmado (como JWT, que le permite realizar tareas sin estado autenticación ).
Qué puede hacer con JAX-RS 2.0 (Jersey, RESTEasy y Apache CXF)
Esta solución usa 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 autenticación basada en tokens, no está confiando en los mecanismos de seguridad de aplicaciones web Java EE estándar que ofrece el contenedor de servlets y que se puede configurar a través del web.xml
descriptor de 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 recurso 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 lanzan excepciones al validar las credenciales, 403
se devolverá una respuesta con el estado (Prohibido).
Si las credenciales se validan correctamente, 200
se devolverá una respuesta con el estado (OK) y el token emitido se enviará al cliente en la carga útil de 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 la solicitud:
username=admin&password=123456
En lugar de parámetros de formulario, es posible ajustar 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
}
Con 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 Authorization
encabezado HTTP estándar de la solicitud. Por ejemplo:
Authorization: Bearer <token-goes-here>
El nombre del encabezado HTTP estándar es lamentable porque contiene 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 metaanotación utilizada para crear otras anotaciones para unir filtros e interceptores a clases y métodos de recursos. Defina una @Secured
anotación de la siguiente manera:
@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 se implementa ContainerRequestFilter
, lo que le permite interceptar la solicitud antes de que sea manejada por un método de recurso. Se ContainerRequestContext
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 se produce algún problema durante la validación del token, 401
se devolverá una respuesta con el estado (No autorizado). De lo contrario, la solicitud procederá a un método de recurso.
Asegurar sus puntos finales REST
Para vincular el filtro de autenticación a métodos de recursos o clases de recursos, anótelos con la @Secured
anotación creada anteriormente. Para los métodos y / o clases que están anotados, se ejecutará el filtro. 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)
método porque está anotado con @Secured
.
Identificando al usuario actual
Es muy probable que necesite conocer al usuario que realiza la solicitud contra su API REST. Se pueden utilizar los siguientes enfoques para lograrlo:
Anular el contexto de seguridad de la solicitud actual
Dentro de su ContainerRequestFilter.filter(ContainerRequestContext)
método, SecurityContext
se puede establecer una nueva instancia para la solicitud actual. Luego anule el SecurityContext.getUserPrincipal()
, devolviendo una Principal
instancia:
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 Principal
nombre del usuario .
Inyecte el SecurityContext
en cualquier clase de recurso JAX-RS:
@Context
SecurityContext securityContext;
Lo mismo se puede hacer en un método de recurso JAX-RS:
@GET
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id,
@Context SecurityContext securityContext) {
...
}
Y luego obtener el Principal
:
Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();
Uso de CDI (inyección de contexto y dependencia)
Si, por algún motivo, no desea anular SecurityContext
, puede usar CDI (Inyección de contexto y dependencia), que proporciona características útiles como eventos y productores.
Cree 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 tiene éxito, 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 a esta clase User
.
Cree un bean CDI para manejar el evento de autenticación, busque una User
instancia con el nombre de usuario correspondiente y asígnelo al authenticatedUser
campo productor:
@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 authenticatedUser
campo produce una User
instancia que se puede inyectar en beans gestionados por contenedor, como servicios JAX-RS, beans CDI, servlets y EJB. Use el siguiente código para inyectar una User
instancia (de hecho, es un proxy CDI):
@Inject
@AuthenticatedUser
User authenticatedUser;
Tenga en cuenta que la @Produces
anotación CDI es diferente de la @Produces
anotación JAX-RS :
Asegúrese de usar la @Produces
anotación CDI en su AuthenticatedUserProducer
bean.
La clave aquí es el bean anotado con @RequestScoped
, lo que le permite compartir datos entre los filtros y sus beans. Si no desea utilizar 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 anula el SecurityContext
, el enfoque CDI le permite obtener el usuario autenticado de beans que no sean recursos y proveedores JAX-RS.
Apoyar la autorización basada en roles
Por favor, consulte mi otra respuesta para obtener detalles sobre cómo respaldar la autorización basada en roles.
Emitir tokens
Un token puede ser:
- Opaco: no revela más detalles que el valor en sí (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 persistiéndola en una base de datos junto con el identificador de usuario y una fecha de vencimiento. Aquí se puede ver un buen ejemplo de cómo generar una cadena aleatoria en Java . También puedes usar:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT (JSON Web Token)
JWT (JSON Web Token) es un método estándar para representar reclamos de forma segura entre dos partes y está definido por el RFC 7519 .
Es un token autónomo y le permite almacenar detalles en reclamaciones . Estos reclamos 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 alterado.
No necesitará persistir los tokens JWT si no necesita rastrearlos. Sin embargo, al persistir los tokens, tendrás la posibilidad de invalidar y revocar el acceso a ellos. Para realizar un seguimiento de los tokens JWT, en lugar de conservar todo el token en el servidor, puede conservar el identificador de token (jti
reclamo) junto con algunos otros detalles, como el usuario para el que emitió el token, la fecha de vencimiento, etc.
Cuando persista los tokens, siempre considere eliminar los viejos 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 otros recursos excelentes para trabajar con JWT, eche un vistazo a http://jwt.io .
Manejo de revocación de token con JWT
Si desea revocar tokens, debe mantener el seguimiento de ellos. No necesita almacenar todo el token en el lado del servidor, almacenar solo el identificador del token (que debe ser único) y algunos metadatos si es necesario. Para el identificador de token, puede usar UUID .
El jti
reclamo debe usarse para almacenar el identificador de token en el token. Al validar el token, asegúrese de que no se haya revocado comprobando el valor del jti
reclamo con los identificadores de token que tiene en el lado del servidor.
Por razones de seguridad, revoque todos los tokens para un usuario cuando cambien su contraseña.
Información Adicional
- No importa qué tipo de autenticación decida usar. Siempre hágalo 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 tokens.
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.
¿Cómo es esto RESTful?