使用JAX-RS和Jersey进行基于REST令牌的身份验证的最佳实践

Dev*_*s85 437 java authentication rest jax-rs jersey-2.0

我正在寻找一种在Jersey中启用基于令牌的身份验证的方法.我试图不使用任何特定的框架.那可能吗?

我的计划是:用户注册我的Web服务,我的Web服务生成令牌,将其发送到客户端,客户端将保留它.然后,对于每个请求,客户端将发送令牌而不是用户名和密码.

我在考虑为每个请求使用自定义过滤器,@PreAuthorize("hasRole('ROLE')") 但我只是认为这会导致很多请求数据库检查令牌是否有效.

或者不创建过滤器并在每个请求中放置一个参数令牌?这样每个API首先检查令牌,然后执行一些东西来检索资源.

cas*_*lin 1335

基于令牌的身份验证的工作原理

在基于令牌的身份验证中,客户端为称为令牌的数据交换硬凭证(例如用户名和密码).对于每个请求,客户端不会发送硬凭证,而是将令牌发送到服务器以执行身份验证然后授权.

简而言之,基于令牌的身份验证方案遵循以下步骤:

  1. 客户端将其凭据(用户名和密码)发送到服务器.
  2. 服务器验证凭据,如果它们有效,则为用户生成令牌.
  3. 服务器将先前生成的令牌与用户标识符和到期日期一起存储在一些存储器中.
  4. 服务器将生成的令牌发送到客户端.
  5. 客户端在每个请求中将令牌发送到服务器.
  6. 每个请求中的服务器从传入请求中提取令牌.使用令牌,服务器查找用户详细信息以执行身份验证.
    • 如果令牌有效,则服务器接受该请求.
    • 如果令牌无效,则服务器拒绝该请求.
  7. 一旦执行了身份验证,服务器就会执行授权.
  8. 服务器可以提供端点来刷新令牌.

注意:如果服务器已发出签名令牌(例如JWT,允许您执行无状态身份验证),则不需要执行步骤3 .

使用JAX-RS 2.0(Jersey,RESTEasy和Apache CXF)可以做些什么

此解决方案仅使用JAX-RS 2.0 API,避免任何特定于供应商的解决方案.因此,它应该适用于JAX-RS 2.0实现,例如Jersey,RESTEasyApache CXF.

值得一提的是,如果您使用基于令牌的身份验证,则不依赖于servlet容器提供的标准Java EE Web应用程序安全机制,并且可以通过应用程序的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
    }
}
Run Code Online (Sandbox Code Playgroud)

如果在验证凭据时抛出任何异常,403将返回状态为(Forbidden)的响应.

如果成功验证凭据,200将返回状态为(OK)的响应,并且已发出的令牌将在响应有效负载中发送到客户端.客户端必须在每个请求中将令牌发送到服务器.

在使用时application/x-www-form-urlencoded,客户端必须在请求有效负载中以以下格式发送凭据:

username=admin&password=123456
Run Code Online (Sandbox Code Playgroud)

而不是形式参数,可以将用户名和密码包装到类中:

public class Credentials implements Serializable {

    private String username;
    private String password;

    // Getters and setters omitted
}
Run Code Online (Sandbox Code Playgroud)

然后将其作为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
}
Run Code Online (Sandbox Code Playgroud)

使用此方法,客户端必须在请求的有效负载中以以下格式发送凭据:

{
  "username": "admin",
  "password": "123456"
}
Run Code Online (Sandbox Code Playgroud)

从请求中提取令牌并验证它

客户端应该Authorization在请求的标准HTTP 头中发送令牌.例如:

Authorization: Bearer <token-goes-here>
Run Code Online (Sandbox Code Playgroud)

标准HTTP标头的名称很不幸,因为它带有身份验证信息,而不是授权.但是,它是用于将凭据发送到服务器的标准HTTP标头.

JAX-RS提供@NameBinding了一个元注释,用于创建其他注释以将过滤器和拦截器绑定到资源类和方法.定义@Secured注释如下:

@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }
Run Code Online (Sandbox Code Playgroud)

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
    }
}
Run Code Online (Sandbox Code Playgroud)

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
        ...
    }
}
Run Code Online (Sandbox Code Playgroud)

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;
    }
});
Run Code Online (Sandbox Code Playgroud)

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;
Run Code Online (Sandbox Code Playgroud)

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) {
    ...
}
Run Code Online (Sandbox Code Playgroud)

And then get the Principal:

Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();
Run Code Online (Sandbox Code Playgroud)

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 { }
Run Code Online (Sandbox Code Playgroud)

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

@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
Run Code Online (Sandbox Code Playgroud)

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);
Run Code Online (Sandbox Code Playgroud)

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
    }
}
Run Code Online (Sandbox Code Playgroud)

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;
Run Code Online (Sandbox Code Playgroud)

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);
Run Code Online (Sandbox Code Playgroud)

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 refreshment with JWT

Accept only valid (and non-expired) tokens for refreshment. It's responsability of the client to refresh the tokens before the expiration date indicated in the exp claim.

You should prevent the tokens from being refreshed indefinitely. See below a few approaches that you could consider.

You could keep the track of token refreshment by adding two claims to your token (the claim names are up to you):

  • refreshLimit: Indicates how many times the token can be refreshed.
  • refreshCount: Indicates how many times the token has been refreshed.

So only refresh the token if the following conditions are true:

  • The token is not expired (exp >= now).
  • The number of times that the token has been refreshed is less than the number of times that the token can be refreshed (refreshCount < refreshLimit).

And when refreshing the token:

  • Update the expiration date (exp = now + some-amount-of-time).
  • Increment the number of times that the token has been refreshed (refreshCount++).

Alternatively to keeping? the track of the number of refreshments, you could have a claim that indicates the absolute expiration date (which works pretty similar to the refreshLimit claim described above). Before the absolute expiration date, any number of refreshments is acceptable.

Another approach involves issuing a separate long-lived refresh token that is used to issue short-lived JWT tokens.

The best approach depends on your requirements.

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.

  • 我不敢相信这不在官方文档中. (12认同)
  • @scottyseus基于令牌的身份验证通过服务器记住已颁发的令牌的方式进行。您可以使用JWT令牌进行无状态身份验证。 (3认同)
  • @grep在REST中,服务器端没有会话。因此,会话状态在客户端进行管理。 (2认同)

cas*_*lin 86

这个答案都是关于授权的,它是我之前关于身份验证的答案的补充

为什么另一个答案 我试图通过添加有关如何支持JSR-250注释的详细信息来扩展我之前的答案.然而,最初的答案变得太长,超过了30,000个字符最大长度.所以我将整个授权细节移到了这个答案,另一个答案集中在执行身份验证和发布令牌.


使用@Secured注释支持基于角色的授权

除了另一个答案中显示的身份验证流程外,REST端点可以支持基于角色的授权.

创建枚举并根据您的需要定义角色:

public enum Role {
    ROLE_1,
    ROLE_2,
    ROLE_3
}
Run Code Online (Sandbox Code Playgroud)

更改@Secured之前创建的名称绑定注释以支持角色:

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

然后使用@Secured以执行授权来注释资源类和方法.方法注释将覆盖类注释:

@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
        ...
    }
}
Run Code Online (Sandbox Code Playgroud)

创建具有AUTHORIZATION优先级的过滤器,该AUTHENTICATION过滤器在先前定义的优先级过滤器之后执行.

ResourceInfo可用于获取资源Method和资源Class将处理请求,然后提取@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
    }
}
Run Code Online (Sandbox Code Playgroud)

如果用户无权执行操作,则使用403(禁止)中止请求.

要了解正在执行请求的用户,请参阅我之前的回答.您可以从SecurityContext(应该已经设置)中获取它ContainerRequestContext或使用CDI注入它,具体取决于您的方法.

如果@Secured注释未声明任何角色,则可以假定所有经过身份验证的用户都可以访问该端点,而忽略用户拥有的角色.

使用JSR-250注释支持基于角色的授权

另外,以确定在角色@Secured如上图所示的注释,你可以考虑JSR-250注解,如@RolesAllowed,@PermitAll@DenyAll.

JAX-RS不支持这种开箱即用的注释,但可以使用过滤器实现.如果您想支持所有这些,请记住以下几点注意事项:

因此,检查JSR-250注释的授权过滤器可能类似于:

@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.");
    }
}
Run Code Online (Sandbox Code Playgroud)

注意:以上实现基于Jersey RolesAllowedDynamicFeature.如果您使用Jersey,则无需编写自己的过滤器,只需使用现有实现即可.

  • @DanielFerreiraCastro当然.看看[这里](https://github.com/cassiomolin/jersey-jwt). (6认同)