Вопрос-ответ

How to implement REST token-based authentication with JAX-RS and Jersey

Как реализовать аутентификацию на основе токенов REST с помощью JAX-RS и Jersey

Я ищу способ включить аутентификацию на основе токенов в Джерси. Я стараюсь не использовать какой-либо конкретный фреймворк. Возможно ли это?

Мой план таков: пользователь регистрируется в моем веб-сервисе, мой веб-сервис генерирует токен, отправляет его клиенту, и клиент сохраняет его. Тогда клиент для каждого запроса будет отправлять токен вместо имени пользователя и пароля.

Я думал использовать пользовательский фильтр для каждого запроса и @PreAuthorize("hasRole('ROLE')"), но я просто подумал, что это вызывает множество запросов к базе данных, чтобы проверить, действителен ли токен.

Или не создавать filter и в каждом запросе указывать токен param? Чтобы каждый API сначала проверял токен, а после выполнял что-то для извлечения ресурса.

Переведено автоматически
Ответ 1

Как работает аутентификация на основе токенов

При аутентификации на основе токенов клиент обменивает жесткие учетные данные (такие как имя пользователя и пароль) на часть данных, называемую токеном. Для каждого запроса вместо отправки жестких учетных данных клиент отправляет токен на сервер для выполнения аутентификации, а затем авторизации.

В нескольких словах, схема аутентификации, основанная на токенах, состоит из следующих шагов:


  1. Клиент отправляет свои учетные данные (имя пользователя и пароль) на сервер.

  2. Сервер проверяет подлинность учетных данных и, если они действительны, генерирует токен для пользователя.

  3. Сервер хранит ранее сгенерированный токен в некотором хранилище вместе с идентификатором пользователя и датой истечения срока действия.

  4. Сервер отправляет сгенерированный токен клиенту.

  5. Клиент отправляет токен на сервер в каждом запросе.

  6. Сервер в каждом запросе извлекает токен из входящего запроса. С помощью токена сервер просматривает данные пользователя для выполнения аутентификации.

    • Если токен действителен, сервер принимает запрос.

    • Если токен недействителен, сервер отклоняет запрос.



  7. После выполнения аутентификации сервер выполняет авторизацию.

  8. Сервер может предоставить конечную точку для обновления токенов.

Что можно сделать с JAX-RS 2.0 (Jersey, RESTEasy и Apache CXF)

Это решение использует только JAX-RS 2.0 API, избегая каких-либо решений, специфичных для конкретного поставщика. Итак, это должно работать с реализациями JAX-RS 2.0, такими как Джерси, RESTEasy и Apache CXF.

Стоит упомянуть, что если вы используете аутентификацию на основе токенов, вы не полагаетесь на стандартные механизмы безопасности веб-приложений Java EE, предлагаемые контейнером сервлетов и настраиваемые через web.xml дескриптор приложения. Это пользовательская аутентификация.

Аутентификация пользователя с помощью имени пользователя и пароля и выдача токена

Создайте ресурсный метод JAX-RS, который получает и проверяет учетные данные (имя пользователя и пароль) и выдает токен для пользователя:

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

Если при проверке учетных данных возникают какие-либо исключения, будет возвращен ответ со статусом 403 (Запрещено).

If the credentials are successfully validated, a response with the status 200 (OK) will be returned and the issued token will be sent to the client in the response payload. The client must send the token to the server in every request.

When consuming application/x-www-form-urlencoded, the client must send the credentials in the following format in the request payload:

username=admin&password=123456

Instead of form params, it's possible to wrap the username and the password into a class:

public class Credentials implements Serializable {

private String username;
private String password;

// Getters and setters omitted
}

And then consume it as 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
}

Using this approach, the client must to send the credentials in the following format in the payload of the request:

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

Extracting the token from the request and validating it

The client should send the token in the standard HTTP Authorization header of the request. For example:

Authorization: Bearer <token-goes-here>

The name of the standard HTTP header is unfortunate because it carries authentication information, not authorization. However, it's the standard HTTP header for sending credentials to the server.

JAX-RS provides @NameBinding, a meta-annotation used to create other annotations to bind filters and interceptors to resource classes and methods. Define a @Secured annotation as following:

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

The above defined name-binding annotation will be used to decorate a filter class, which implements ContainerRequestFilter, allowing you to intercept the request before it be handled by a resource method. The ContainerRequestContext can be used to access the HTTP request headers and then extract the 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
}
}

If any problems happen during the token validation, a response with the status 401 (Unauthorized) will be returned. Otherwise the request will proceed to a resource method.

Securing your REST endpoints

To bind the authentication filter to resource methods or resource classes, annotate them with the @Secured annotation created above. For the methods and/or classes that are annotated, the filter will be executed. It means that such endpoints will only be reached if the request is performed with a valid token.

If some methods or classes do not need authentication, simply do not annotate them:

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

In the example shown above, the filter will be executed only for the mySecuredMethod(Long) method because it's annotated with @Secured.

Identifying the current user

It's very likely that you will need to know the user who is performing the request agains your REST API. The following approaches can be used to achieve it:

Overriding the security context of the current request

Within your ContainerRequestFilter.filter(ContainerRequestContext) method, a new SecurityContext instance can be set for the current request. Then override the SecurityContext.getUserPrincipal(), returning a 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;
}
});

Use the token to look up the user identifier (username), which will be the Principal's name.

Inject the SecurityContext in any JAX-RS resource class:

@Context
SecurityContext securityContext;

The same can be done in a JAX-RS resource method:

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

And then get the Principal:

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

Using CDI (Context and Dependency Injection)

If, for some reason, you don't want to override the SecurityContext, you can use CDI (Context and Dependency Injection), which provides useful features such as events and producers.

Create a CDI qualifier:

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

In your AuthenticationFilter created above, inject an Event annotated with @AuthenticatedUser:

@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;

If the authentication succeeds, fire the event passing the username as parameter (remember, the token is issued for a user and the token will be used to look up the user identifier):

userAuthenticatedEvent.fire(username);

It's very likely that there's a class that represents a user in your application. Let's call this class User.

Create a CDI bean to handle the authentication event, find a User instance with the correspondent username and assign it to the authenticatedUser producer field:

@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 field produces a User instance that can be injected into container managed beans, such as JAX-RS services, CDI beans, servlets and EJBs. Use the following piece of code to inject a User instance (in fact, it's a CDI proxy):

@Inject
@AuthenticatedUser
User authenticatedUser;

Note that the CDI @Produces annotation is different from the JAX-RS @Produces annotation:

Be sure you use the CDI @Produces annotation in your AuthenticatedUserProducer bean.

The key here is the bean annotated with @RequestScoped, allowing you to share data between filters and your beans. If you don't wan't to use events, you can modify the filter to store the authenticated user in a request scoped bean and then read it from your JAX-RS resource classes.

Compared to the approach that overrides the SecurityContext, the CDI approach allows you to get the authenticated user from beans other than JAX-RS resources and providers.

Supporting role-based authorization

Please refer to my other answer for details on how to support role-based authorization.

Issuing tokens

A token can be:


  • Opaque: Reveals no details other than the value itself (like a random string)

  • Self-contained: Contains details about the token itself (like JWT).

See details below:

Random string as token

A token can be issued by generating a random string and persisting it to a database along with the user identifier and an expiration date. A good example of how to generate a random string in Java can be seen here. You also could use:

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

JWT (JSON Web Token)

JWT (JSON Web Token) is a standard method for representing claims securely between two parties and is defined by the RFC 7519.

It's a self-contained token and it enables you to store details in claims. These claims are stored in the token payload which is a JSON encoded as Base64. Here are some claims registered in the RFC 7519 and what they mean (read the full RFC for further details):


  • iss: Principal that issued the token.

  • sub: Principal that is the subject of the JWT.

  • exp: Expiration date for the token.

  • nbf: Time on which the token will start to be accepted for processing.

  • iat: Time on which the token was issued.

  • jti: Unique identifier for the token.

Be aware that you must not store sensitive data, such as passwords, in the token.

The payload can be read by the client and the integrity of the token can be easily checked by verifying its signature on the server. The signature is what prevents the token from being tampered with.

You won't need to persist JWT tokens if you don't need to track them. Althought, by persisting the tokens, you will have the possibility of invalidating and revoking the access of them. To keep the track of JWT tokens, instead of persisting the whole token on the server, you could persist the token identifier (jti claim) along with some other details such as the user you issued the token for, the expiration date, etc.

When persisting tokens, always consider removing the old ones in order to prevent your database from growing indefinitely.

Using JWT

There are a few Java libraries to issue and validate JWT tokens such as:

To find some other great resources to work with JWT, have a look at http://jwt.io.

Handling token revocation with JWT

If you want to revoke tokens, you must keep the track of them. You don't need to store the whole token on server side, store only the token identifier (that must be unique) and some metadata if you need. For the token identifier you could use UUID.

The jti claim should be used to store the token identifier on the token. When validating the token, ensure that it has not been revoked by checking the value of the jti claim against the token identifiers you have on server side.

For security purposes, revoke all the tokens for a user when they change their password.

Additional information


  • It doesn't matter which type of authentication you decide to use. Always do it on the top of a HTTPS connection to prevent the man-in-the-middle attack.

  • Take a look at this question from Information Security for more information about tokens.

  • In this article you will find some useful information about token-based authentication.

Ответ 2

This answer is all about authorization and it is a complement of my previous answer about authentication


Why another answer? I attempted to expand my previous answer by adding details on how to support JSR-250 annotations. However the original answer became the way too long and exceeded the maximum length of 30,000 characters. So I moved the whole authorization details to this answer, keeping the other answer focused on performing authentication and issuing tokens.



Supporting role-based authorization with the @Secured annotation

Besides authentication flow shown in the other answer, role-based authorization can be supported in the REST endpoints.

Create an enumeration and define the roles according to your needs:

public enum Role {
ROLE_1,
ROLE_2,
ROLE_3
}

Change the @Secured name binding annotation created before to support roles:

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

And then annotate the resource classes and methods with @Secured to perform the authorization. The method annotations will override the class annotations:

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

Create a filter with the AUTHORIZATION priority, which is executed after the AUTHENTICATION priority filter defined previously.

The ResourceInfo can be used to get the resource Method and resource Class that will handle the request and then extract the @Secured annotations from them:

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

If the user has no permission to execute the operation, the request is aborted with a 403 (Forbidden).

To know the user who is performing the request, see my previous answer. You can get it from the SecurityContext (which should be already set in the ContainerRequestContext) or inject it using CDI, depending on the approach you go for.

If a @Secured annotation has no roles declared, you can assume all authenticated users can access that endpoint, disregarding the roles the users have.

Supporting role-based authorization with JSR-250 annotations

Alternatively to defining the roles in the @Secured annotation as shown above, you could consider JSR-250 annotations such as @RolesAllowed, @PermitAll and @DenyAll.

JAX-RS doesn't support such annotations out-of-the-box, but it could be achieved with a filter. Here are a few considerations to keep in mind if you want to support all of them:

So an authorization filter that checks JSR-250 annotations could be like:

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

Note: The above implementation is based on the Jersey RolesAllowedDynamicFeature. If you use Jersey, you don't need to write your own filter, just use the existing implementation.

java rest