REST令牌的最佳做法

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

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

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

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


基于令牌的身份验证如何工作

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

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

  • 客户端将其凭据(用户名和密码)发送到服务器。
  • 服务器验证凭据,如果它们有效,则为用户生成令牌。
  • 服务器将先前生成的令牌存储在某个存储中,并附带用户标识和过期日期。
  • 服务器将生成的令牌发送给客户端。
  • 客户端在每个请求中将令牌发送到服务器。
  • 服务器在每个请求中从传入的请求中提取令牌。 通过令牌,服务器查找用户详细信息以执行认证。
  • 如果令牌有效,则服务器接受该请求。
  • 如果令牌无效,则服务器拒绝该请求。
  • 一旦完成认证,服务器执行授权。
  • 服务器可以提供一个端点来刷新令牌。
  • 注意:如果服务器发布了一个签名令牌(如JWT,允许您执行无状态验证),则不需要执行步骤3。

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

    该解决方案仅使用JAX-RS 2.0 API,避免了任何供应商特定的解决方案。 因此,它应该与JAX-RS 2.0实现一起工作,比如Jersey,RESTEasy和Apache 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 producer字段:

    @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字段生成一个可以注入容器管理bean的User实例,如JAX-RS服务,CDI bean,servlet和EJB。 使用下面的一段代码注入一个User实例(实际上,它是一个CDI代理):

    @Inject
    @AuthenticatedUser
    User authenticatedUser;
    

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

  • CDI: javax.enterprise.inject.Produces
  • JAX-RS: javax.ws.rs.Produces
  • 确保在AuthenticatedUserProducer bean中使用CDI @Produces注释。

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

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

    支持基于角色的授权

    有关如何支持基于角色的授权的详细信息,请参阅我的其他答案。

    发放令牌

    令牌可以是:

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

    随机字符串作为标记

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

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

    JWT(JSON Web令牌)

    JWT(JSON Web Token)是用于在两方之间安全地声明索赔的标准方法,由RFC 7519定义。

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

  • iss :颁发令牌的委托人。
  • sub :JWT的主体。
  • exp :令牌的到期日期。
  • nbf :令牌开始被接受进行处理的时间。
  • iat :令牌发布的时间。
  • jti :令牌的唯一标识符。
  • 请注意,您不得将敏感数据(如密码)存储在令牌中。

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

    如果您不需要跟踪JWT令牌,则无需持续使用JWT令牌。 尽管如此,通过坚持令牌,您将有可能使无效和撤销访问权限。 为了保持JWT令牌的轨迹,您可以将令牌标识符( jti声明)与其他一些细节(例如您为令牌发布的用户,截止日期等)一起保留,而不是将整个令牌保留在服务器上。

    当持久化令牌时,总是考虑删除旧的令牌,以防止数据库无限增长。

    使用JWT

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

  • jjwt
  • Java的智威汤逊
  • jose4j
  • 要找到一些与JWT合作的优秀资源,请查看http://jwt.io。

    使用JWT处理令牌刷新

    只接受有效的(和未到期的)令牌用于更新。 在exp声明中指出的到期日期之前刷新令牌是客户的责任。

    您应该防止令牌无限期地刷新。 请参阅以下几种您可以考虑的方法。

    您可以通过向令牌添加两个声明(声明名称由您决定)来保持令牌更新的轨道:

  • refreshLimit :表示可以刷新令牌的次数。
  • refreshCount :表示令牌刷新了多少次。
  • 所以只有在满足以下条件的情况下刷新标记:

  • 令牌未过期( exp >= now )。
  • 令牌刷新的次数少于令牌刷新次数( refreshCount < refreshLimit )。
  • 刷新令牌时:

  • 更新到期日期( exp = now + some-amount-of-time )。
  • 增加令牌刷新次数( refreshCount++ )。
  • 或者为了保持茶点数量的轨迹,您可以有一个声明表明绝对过期日期(与上述refreshLimit声明非常相似)。 在绝对有效期之前,任何数量的点心都是可以接受的。

    另一种方法是发布一个单独的长期刷新令牌,用于发布短期JWT令牌。

    最好的方法取决于你的要求。

    使用JWT处理令牌撤销

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

    jti声明应该用于在令牌上存储令牌标识符。 验证令牌时,通过检查服务器端令牌标识符的jti声明值,确保它未被撤消。

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

    附加信息

  • 您决定使用哪种类型的认证无关紧要。 始终在HTTPS连接的顶部执行此操作,以防止中间人攻击。
  • 从Information Security中查看此问题以获取有关令牌的更多信息。
  • 在本文中,您将找到有关基于令牌的身份验证的一些有用信息。

  • 这个答案完全是关于授权 ,它是我以前关于认证的答案的补充

    为什么还有其他答案 我试图通过添加关于如何支持JSR-250注释的细节来扩展我以前的答案。 然而,最初的答案变得太长了,超过了30,000个字符的最大长度。 所以我把整个授权细节移到了这个答案上,而另一个答案则集中在执行认证和发放代币上。


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

    除了另一个答案中显示的认证流程之外,REST端点中还可以支持基于角色的授权。

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

    public enum Role {
        ROLE_1,
        ROLE_2,
        ROLE_3
    }
    

    @Secured创建的@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 (禁止)中止。

    要知道执行请求的用户,请参阅我的以前的答案。 你可以从SecurityContext (它应该已经在ContainerRequestContext设置)中获取它,或者使用CDI注入它,具体取决于你所使用的方法。

    如果@Secured注释没有声明任何角色,则可以假定所有已通过身份验证的用户都可以访问该端点,而不考虑用户具有的角色。

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

    @Secured上面所示的在@Secured注释中定义角色,您可以考虑JSR-250注释,如@RolesAllowed@PermitAll@DenyAll

    JAX-RS不支持这种开箱即用的注释,但它可以通过过滤器来实现。 如果你想支持所有这些,请记住以下几点:

  • @DenyAll方法优先于类的@RolesAllowed@PermitAll
  • @RolesAllowed方法优先于@PermitAll类。
  • @PermitAll上所述方法优先于@RolesAllowed上的类。
  • @DenyAll不能附加到类。
  • @RolesAllowed在类上优先于@PermitAll
  • 因此,检查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,则不需要编写自己的过滤器,只需使用现有的实施。

    链接地址: http://www.djcxy.com/p/3747.html

    上一篇: Best practice for REST token

    下一篇: JWT (JSON Web Token) automatic prolongation of expiration