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

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

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

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

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

基于令牌的身份validation如何工作

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

简而言之,基于令牌的authenticationscheme遵循以下步骤:

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

注意:如果服务器发出了一个签名令牌(如JWT,允许您执行无状态validation),则不需要执行步骤3。

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

该解决scheme仅使用JAX-RS 2.0 API, 避免了任何供应商特定的解决scheme 。 因此,它应该可以与JAX-RS 2.0实现一起工作,比如Jersey , RESTEasy和Apache CXF 。

值得一提的是,如果您使用基于令牌的身份validation,则不依赖于由servlet容器提供的标准Java EE Web应用程序安全机制,并可以通过应用程序的web.xml描述符进行configuration。 这是一个自定义身份validation。

使用用户名和密码对用户进行身份validation并发出令牌

创build一个接收和validation凭证(用户名和密码)并为用户颁发令牌的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 } } 

如果在validation凭证时引发任何exception,则将返回状态为403 (禁止)的响应。

如果证书被成功validation,则将返回状态200 (OK)的响应,并将响应有效载荷中发送的令牌发送到客户端。 客户端必须在每个请求中将令牌发送到服务器。

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

 username=admin&password=123456 

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

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

从请求中提取令牌并进行validation

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

 Authorization: Bearer <token-goes-here> 

标准HTTP头的名字是不幸的,因为它携带了authentication信息,而不是授权 。 但是,这是将证书发送到服务器的标准HTTP头。

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

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

上面定义的名称绑定注释将用于修饰实现ContainerRequestFilter的filter类,允许您在请求被资源方法处理之前拦截该请求。 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 } } 

如果在令牌validation期间发生任何问题,将返回状态为401 (未授权)的响应。 否则,请求将转到资源方法。

保护您的REST端点

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

如果某些方法或类不需要validation,则不要注释它们:

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

在上面显示的例子中,filter将仅为 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 new Principal() { @Override public String getName() { 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(上下文和dependency injection)

如果出于某种原因,您不想重写SecurityContext ,则可以使用CDI(上下文和dependency injection),它提供了有用的function,如事件和生产者。

创build一个CDI限定符:

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

在上面创build的AuthenticationFilter ,注入一个用@AuthenticatedUser注释的Event

 @Inject @AuthenticatedUser Event<String> userAuthenticatedEvent; 

如果validation成功,则激发传递用户名作为参数的事件(请记住,该令牌是为用户发出的,令牌将用于查找用户标识符):

 userAuthenticatedEvent.fire(username); 

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

创build一个CDI bean来处理authentication事件,find一个具有通讯用户名的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字段生成一个User实例,可以将其注入容器pipe理的Bean,如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,允许你在filter和你的bean之间共享数据。 如果您不想使用事件,则可以修改筛选器以将经过身份validation的用户存储在请求作用域bean中,然后从JAX-RS资源类中读取它。

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

支持基于angular色的授权

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

发放令牌

令牌可以是:

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

见下面的细节:

随机string作为标记

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

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

JWT(JSON Web令牌)

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

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

  • iss :签发令牌的主体。
  • sub :JWT的主体。
  • exp :令牌的到期date。
  • nbf :令牌开始被接受处理的时间。
  • iat :代币发行的时间。
  • jti :令牌的唯一标识符。

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

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

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

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

使用JWT

有几个Java库可以发布和validationJWT令牌,如:

  • jjwt
  • Java的智威汤逊
  • jose4j

要find一些与JWT合作的优秀资源,请查看http://jwt.io

使用JWT处理令牌刷新

接受有效的(和未到期的)令牌用于更新。 客户在exp索赔中指定的到期日之前更新令牌的责任。

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

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

  • refreshLimit :指示可以刷新令牌的次数。
  • refreshCount :表示令牌刷新了多less次。

所以只有在满足以下条件的情况下才刷新令牌:

  • 令牌没有过期( exp >= now )。
  • 令牌刷新的次数less于令牌刷新次数( refreshCount < refreshLimit )。

当刷新令牌时:

  • 更新到期date( exp = now + some-amount-of-time )。
  • 增加令牌刷新的次数( refreshCount++ )。

另外,为了保持茶点数量的轨迹,你可以有一个声明,指出绝对过期date (其工作非常类似于上述refreshLimit声明)。 在绝对有效期之前 ,任何数量的点心是可以接受的。

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

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

使用JWT处理令牌撤销

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

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

为了安全起见,当用户更改密码时撤销所有令牌。

附加信息

  • 您决定使用哪种types的身份validation并不重要。 请始终在HTTPS连接的顶部执行此操作,以防止中间人攻击 。
  • 从信息安全看这个问题关于令牌的更多信息。
  • 在本文中,您将find一些有关基于令牌的身份validation的有用信息。

这个答案全部是关于授权 ,这是我以前关于authentication的 答案的补充

为什么还有其他答案 我试图通过添加关于如何支持JSR-250注释的细节来扩展我以前的答案。 但原来的答案太长了 ,超过了三万字的最大长度 。 所以我把整个授权细节移到这个答案上,而另一个答案则集中在执行validation和发放令牌上。


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

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

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

 public enum Role { ROLE_1, ROLE_2, ROLE_3 } 

@Secured创build的@Secured名称绑定注释更改为支持angular色:

 @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优先级创build一个filter,该filter在之前定义的AUTHENTICATION优先级filter之后执行。

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注释没有声明任何angular色,则可以假定所有已通过身份validation的用户都可以访问该端点,而不考虑用户具有的angular色。

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

如上所示,也可以在@Secured注释中定义angular色,也可以考虑JSR-250注释,例如@RolesAllowed@PermitAll@DenyAll

JAX-RS不支持这种开箱即用的注释,但可以通过filter来实现。 如果您想要支持所有这些,请注意以下几点:

  • @DenyAll方法优先于@RolesAllowed@PermitAll类。
  • @RolesAllowed方法优先于@PermitAll在类上。
  • @PermitAll方法优先于@RolesAllowed类。
  • @DenyAll不能附加到类。
  • @RolesAllowed上的类优先于@PermitAll在类上。

因此,检查JSR-250注释的授权filter可能如下所示:

 @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,则不需要编写自己的filter,只需使用现有的实现即可。