如何使用JAX-RS和Jersey实现基于REST令牌的身份验证

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

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

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

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

396605 次浏览

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

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

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

  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实现一起工作,例如泽西岛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
}
}

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

如果凭据成功验证,将返回状态为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 (Unauthorized)的响应。否则,请求将继续到资源方法。

保护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方法允许您从bean(而不是JAX-RS资源和提供程序)获取经过身份验证的用户。

支持基于角色的授权

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

发行令牌

令牌可以是:

  • 不透明的:除了值本身(像一个随机字符串),没有显示任何细节
  • 包含:包含令牌本身的细节(如JWT)。

详情如下:

作为标记的随机字符串

可以通过生成一个随机字符串并将其与用户标识符和过期日期一起持久化到数据库来发布令牌。关于如何在Java中生成随机字符串的一个好例子可以看到在这里。你还可以用:

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

JWT (JSON Web令牌)

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

它是一个自包含的令牌,它允许你在索赔中存储详细信息。这些声明存储在令牌有效负载中,令牌有效负载是一个编码为Base64的JSON。以下是在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连接的顶部,以防止中间人攻击
  • 关于令牌的更多信息,请查看Information Security中的这个问题
  • 在本文中你会发现一些关于基于令牌的身份验证的有用信息。

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

为什么另一个答案?我试图通过添加关于如何支持JSR-250注释的细节来扩展我之前的答案。然而,原来的答案变成了太长,超过了的最大长度30,000个字符。因此,我将整个授权细节转移到这个答案上,使另一个答案专注于执行身份验证和发出令牌。


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

除了在另一个回答中显示的身份验证流之外,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 (Forbidden)终止。

要了解执行请求的用户,请参见我之前的回答。你可以从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,则不需要编写自己的过滤器,只需使用现有的实现即可。