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

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

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

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


当前回答

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

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

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

The client sends their credentials (username and password) to the server. The server authenticates the credentials and, if they are valid, generate a token for the user. 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. The client sends the token to the server in each request. The server, in each request, extracts the token from the incoming request. With the token, the server looks up the user details to perform authentication. If the token is valid, the server accepts the request. If the token is invalid, the server refuses the request. Once the authentication has been performed, the server performs authorization. The server can provide an endpoint to refresh tokens.

使用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 (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: 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;
    }
});

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

在任何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 = securityContext.getUserPrincipal();
String username = principal.getName();

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

如果出于某种原因,你不想重写SecurityContext,你可以使用CDI (Context and Dependency Injection,上下文和依赖注入),它提供了一些有用的特性,比如事件和生产者。

创建一个CDI限定符:

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

在上面创建的AuthenticationFilter中,注入一个带有@AuthenticatedUser注解的事件:

@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注释:

CDI: javax.enterprise.inject.Produces jax - rs: javax.ws.rs.Produces

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

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

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

支持基于角色的授权

关于如何支持基于角色的授权,请参考我的其他回答。

发行令牌

令牌可以是:

Opaque:只显示值本身(像一个随机字符串) 自包含:包含令牌本身的细节(如JWT)。

详情如下:

作为标记的随机字符串

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

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:令牌的过期日期。 nbf:令牌开始接受处理的时间。 iat:发行令牌的时间。 jti:令牌的唯一标识符。

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

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

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

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

使用JWT

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

jjwt java-jwt jose4j

要找到与JWT合作的其他优秀资源,请访问http://jwt.io。

使用JWT处理令牌撤销

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

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

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

额外的信息

您决定使用哪种类型的身份验证并不重要。始终在HTTPS连接的顶部进行此操作,以防止中间人攻击。 查看Information Security中的这个问题,了解关于令牌的更多信息。 在本文中,您将发现一些关于基于令牌的身份验证的有用信息。

其他回答

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

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

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

The client sends their credentials (username and password) to the server. The server authenticates the credentials and, if they are valid, generate a token for the user. 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. The client sends the token to the server in each request. The server, in each request, extracts the token from the incoming request. With the token, the server looks up the user details to perform authentication. If the token is valid, the server accepts the request. If the token is invalid, the server refuses the request. Once the authentication has been performed, the server performs authorization. The server can provide an endpoint to refresh tokens.

使用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 (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: 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;
    }
});

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

在任何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 = securityContext.getUserPrincipal();
String username = principal.getName();

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

如果出于某种原因,你不想重写SecurityContext,你可以使用CDI (Context and Dependency Injection,上下文和依赖注入),它提供了一些有用的特性,比如事件和生产者。

创建一个CDI限定符:

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

在上面创建的AuthenticationFilter中,注入一个带有@AuthenticatedUser注解的事件:

@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注释:

CDI: javax.enterprise.inject.Produces jax - rs: javax.ws.rs.Produces

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

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

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

支持基于角色的授权

关于如何支持基于角色的授权,请参考我的其他回答。

发行令牌

令牌可以是:

Opaque:只显示值本身(像一个随机字符串) 自包含:包含令牌本身的细节(如JWT)。

详情如下:

作为标记的随机字符串

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

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:令牌的过期日期。 nbf:令牌开始接受处理的时间。 iat:发行令牌的时间。 jti:令牌的唯一标识符。

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

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

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

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

使用JWT

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

jjwt java-jwt jose4j

要找到与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 name绑定注释以支持角色:

@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可以用来获取处理请求的资源方法和资源类,然后从它们中提取@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注释中定义角色之外,您还可以考虑使用@RolesAllowed、@PermitAll和@DenyAll这样的JSR-250注释。

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