【问题标题】:How to implement REST token-based authentication with JAX-RS and Jersey如何使用 JAX-RS 和 Jersey 实现基于 REST 令牌的身份验证
【发布时间】:2015-01-02 19:38:51
【问题描述】:

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

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

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

或者不创建过滤器并在每个请求中放置一个参数令牌?这样每个 API 都会先检查令牌,然后执行某些操作以检索资源。

【问题讨论】:

    标签: java rest authentication jax-rs jersey-2.0


    【解决方案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 实现,例如 JerseyRESTEasyApache 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
        }
    }
    

    如果在验证凭据时引发任何异常,将返回状态为403(禁止)的响应。

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

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

    username=admin&password=123456
    

    可以将用户名和密码包装到一个类中,而不是表单参数:

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

    然后将其作为 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
    }
    

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

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

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

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

    Authorization: Bearer <token-goes-here>
    

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

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

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

    上面定义的名称绑定注解将用于装饰一个过滤器类,它实现了ContainerRequestFilter,允许您在请求被资源方法处理之前拦截它。 ContainerRequestContext 可用于访问 HTTP 请求标头,然后提取令牌:

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

    如果令牌验证过程中出现任何问题,将返回状态为401(未授权)的响应。否则请求将继续到资源方法。

    保护您的 REST 端点

    要将身份验证过滤器绑定到资源方法或资源类,请使用上面创建的@Secured 注释对其进行注释。对于被注释的方法和/或类,将执行过滤器。这意味着只有在使用有效令牌执行请求时才能到达此类端点。

    如果某些方法或类不需要身份验证,只需不要对其进行注释:

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

    在上面显示的示例中,过滤器将针对mySecuredMethod(Long) 方法执行,因为它带有@Secured 注释。

    识别当前用户

    您很可能需要知道对您的 REST API 执行请求的用户。可以使用以下方法来实现:

    覆盖当前请求的安全上下文

    在您的ContainerRequestFilter.filter(ContainerRequestContext) 方法中,可以为当前请求设置一个新的SecurityContext 实例。然后覆盖SecurityContext.getUserPrincipal(),返回一个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;
        }
    });
    

    使用令牌查找用户标识符(用户名),这将是Principal 的名称。

    在任何 JAX-RS 资源类中注入 SecurityContext

    @Context
    SecurityContext securityContext;
    

    同样可以在 JAX-RS 资源方法中完成:

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

    然后得到Principal:

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

    使用 CDI(上下文和依赖注入)

    如果由于某种原因您不想覆盖 SecurityContext,您可以使用 CDI(上下文和依赖注入),它提供了有用的功能,例如事件和生产者。

    创建一个 CDI 限定符:

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

    在您上面创建的AuthenticationFilter 中,注入一个带有@AuthenticatedUser 注释的Event

    @Inject
    @AuthenticatedUser
    Event<String> userAuthenticatedEvent;
    

    如果身份验证成功,则触发将用户名作为参数传递的事件(记住,令牌是为用户颁发的,令牌将用于查找用户标识符):

    userAuthenticatedEvent.fire(username);
    

    很可能有一个类代表您的应用程序中的用户。让我们称这个类为User

    创建一个 CDI bean 来处理身份验证事件,找到一个具有对应用户名的 User 实例并将其分配给 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
        }
    }
    

    authenticatedUser 字段生成一个User 实例,该实例可以注入到容器管理的 bean 中,例如 JAX-RS 服务、CDI bean、servlet 和 EJB。使用下面这段代码注入一个User实例(其实就是一个CDI代理):

    @Inject
    @AuthenticatedUser
    User authenticatedUser;
    

    请注意,CDI @Produces 注释与 JAX-RS @Produces 注释不同

    确保在 AuthenticatedUserProducer bean 中使用 CDI @Produces 注释。

    这里的关键是带有@RequestScoped 注释的bean,允许您在过滤器和bean 之间共享数据。如果您不想使用事件,您可以修改过滤器以将经过身份验证的用户存储在请求范围的 bean 中,然后从您的 JAX-RS 资源类中读取它。

    与覆盖SecurityContext 的方法相比,CDI 方法允许您从 JAX-RS 资源和提供者以外的 bean 获取经过身份验证的用户。

    支持基于角色的授权

    如何支持基于角色的授权,请参考我的另一个answer

    发行代币

    令牌可以是:

    • 不透明:除了值本身之外不显示任何细节(如随机字符串)
    • 自包含:包含有关令牌本身的详细信息(如 JWT)。

    详见下文:

    随机字符串作为记号

    可以通过生成随机字符串并将其与用户标识符和到期日期一起保存到数据库来颁发令牌。可以看到here 是如何在 Java 中生成随机字符串的一个很好的例子。你也可以使用:

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

    JWT(JSON 网络令牌)

    JWT(JSON Web Token)是一种在两方之间安全地表示声明的标准方法,由RFC 7519 定义。

    它是一个独立的令牌,它使您能够在 声明 中存储详细信息。这些声明存储在令牌有效负载中,它是一个 JSON 编码为Base64。以下是在RFC 7519 中注册的一些声明及其含义(请阅读完整的 RFC 了解更多详细信息):

    • iss:颁发令牌的委托人。
    • sub:JWT 的主体。
    • exp:令牌的到期日期。
    • nbf:令牌开始被接受处理的时间。
    • iat:令牌的发行时间。
    • jti:令牌的唯一标识符。

    请注意,您不得在令牌中存储敏感数据,例如密码。

    客户端可以读取有效负载,并且可以通过在服务器上验证其签名来轻松检查令牌的完整性。签名是防止令牌被篡改的原因。

    如果您不需要跟踪 JWT 令牌,则无需保留它们。尽管如此,通过持久化令牌,您将有可能使它们失效并撤销它们的访问权限。要跟踪 JWT 令牌,而不是将整个令牌保存在服务器上,您可以保存令牌标识符(jti 声明)以及其他一些详细信息,例如您为其颁发令牌的用户、到期日期等.

    在持久化令牌时,请始终考虑删除旧令牌,以防止您的数据库无限增长。

    使用 JWT

    有一些 Java 库可以发布和验证 JWT 令牌,例如:

    要查找与 JWT 一起使用的其他出色资源,请查看 http://jwt.io

    使用 JWT 处理令牌撤销

    如果您想撤销令牌,您必须跟踪它们。您不需要将整个令牌存储在服务器端,只存储令牌标识符(必须是唯一的)和一些元数据(如果需要)。对于令牌标识符,您可以使用 UUID

    jti 声明应用于将令牌标识符存储在令牌上。验证令牌时,请根据您在服务器端拥有的令牌标识符检查 jti 声明的值,确保它没有被撤销。

    出于安全考虑,请在用户更改密码时撤销所有令牌。

    其他信息

    • 您决定使用哪种类型的身份验证并不重要。 始终在 HTTPS 连接顶部执行此操作,以防止 man-in-the-middle attack
    • 请查看来自 Information Security 的 this question,了解有关令牌的更多信息。
    • In this article 你会发现一些关于基于令牌的身份验证的有用信息。

    【讨论】:

    • 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. RESTful 怎么样?
    • @scottyseus 基于令牌的身份验证通过服务器如何记住它发出的令牌来工作。您可以使用 JWT 令牌进行无状态身份验证。
    • 我不敢相信这不在官方文档中。
    • @grep 在 REST 中,服务器端没有会话。因此,会话状态在客户端进行管理。
    • @cassiomolin 我想用球衣测试框架测试你的解决方案。我为依赖项创建了一个 AbstractBinder,但我仍然无法运行它。找不到原始注入的成员。你有什么建议吗?
    【解决方案2】:

    这个答案都是关于授权,它是my previous answer关于身份验证

    的补充

    为什么另一个答案?我试图通过添加有关如何支持 JSR-250 注释的详细信息来扩展我之前的答案。然而,原来的答案变成了太长,超过了maximum length of 30,000 characters。所以我将整个授权细节移到这个答案,让另一个答案专注于执行身份验证和颁发令牌。


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

    除了其他answer 中显示的身份验证流程外,REST 端点还支持基于角色的授权。

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

    public enum Role {
        ROLE_1,
        ROLE_2,
        ROLE_3
    }
    

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

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

    然后用@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
            ...
        }
    }
    

    创建一个优先级为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
        }
    }
    

    如果用户没有执行该操作的权限,则该请求会以403(禁止)中止。

    要了解执行请求的用户,请参阅my previous answer。您可以从 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.");
        }
    }
    

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

    【讨论】:

    • 有没有提供这种优雅解决方案的 github 存储库?
    • @DanielFerreiraCastro 当然。看看here
    • 是否有任何好的方法来验证请求来自授权用户并且用户可以更改数据,因为他“拥有”数据(例如,黑客不能使用他的令牌来更改另一个用户的名称)?我知道我可以在每个端点检查 user_id == token.userId 或类似的东西,但这是非常重复的。
    • @mFeinstein 对此的回答肯定需要比我在 cmets 中输入的字符更多。只是为了给您一些指导,您可以寻找行级安全性
    • 当我搜索行级安全性时,我可以看到很多关于数据库的主题,然后我将把它作为一个新问题打开
    猜你喜欢
    • 2016-06-08
    • 2015-06-30
    • 1970-01-01
    • 2012-02-10
    • 1970-01-01
    • 1970-01-01
    • 2015-04-20
    • 1970-01-01
    • 2016-07-16
    相关资源
    最近更新 更多