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


459

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

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

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

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

Answers:


1387

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

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

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

  1. 客户端将其凭据(用户名和密码)发送到服务器。
  2. 服务器对凭据进行身份验证,如果凭据有效,则为用户生成令牌。
  3. 服务器将先前生成的令牌以及用户标识符和到期日期存储在某个存储器中。
  4. 服务器将生成的令牌发送给客户端。
  5. 客户端在每个请求中将令牌发送到服务器。
  6. 服务器在每个请求中均从传入请求中提取令牌。服务器使用令牌来查找用户详细信息以执行身份验证。
    • 如果令牌有效,则服务器接受请求。
    • 如果令牌无效,则服务器拒绝该请求。
  7. 执行身份验证后,服务器将执行授权。
  8. 服务器可以提供端点以刷新令牌。

注意:如果服务器已发布签名令牌(例如,JWT,它允许您执行无状态,则不需要执行步骤3身份验证)。

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

从请求中提取令牌并对其进行验证

客户端应Authorization在请求的标准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;
    }
});

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

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

@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上面创建的中,Event添加注释@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注解:

确保@ProducesAuthenticatedUserProducerbean中使用CDI 批注。

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

与覆盖的方法相比SecurityContext,CDI方法使您可以从JAX-RS资源和提供程序之外的bean中获取经过身份验证的用户。

支持基于角色的授权

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

发行代币

令牌可以是:

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

请参阅以下详细信息:

随机字符串作为令牌

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

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

JWT(JSON Web令牌)

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

这是一个自包含的令牌,可让您将详细信息存储在Claims中。这些声明存储在令牌有效载荷中,该有效载荷是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连接的顶部执行此操作,以防止中间人攻击
  • 请查看信息安全中的此问题,以获取有关令牌的更多信息。
  • 在本文中,您将找到有关基于令牌的身份验证的一些有用信息。

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如何?
scottysseus

3
@scottyseus基于令牌的身份验证通过服务器记住已颁发的令牌的方式进行。您可以使用JWT令牌进行无状态身份验证。
卡西莫林

如何发送哈希密码而不是普通密码(使用服务器生成的随机数哈希)?是否会提高安全级别(例如,不使用https时)?在中间人的情况下-他将能够劫持一个会话,但至少他不会得到密码
丹尼斯Itskovich

15
我不敢相信这不在官方文档中。
Daniel M.

2
@grep在REST中,服务器端没有会话。因此,会话状态在客户端进行管理。
卡西莫林

98

这个答案全是关于授权的,它是我以前关于身份验证的答案的补充

为什么还要另一个答案?我试图通过添加有关如何支持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(禁止)。

要了解执行请求的用户,请参阅我以前的回答。您可以从中获取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存储库?
Daniel Ferreira Castro

6
@DanielFerreiraCastro当然。在这里看看。
cassiomolin

是否有什么好方法可以验证来自授权用户的请求,并且该用户可以更改数据,因为他“拥有”该数据(例如,黑客无法使用其令牌来更改另一个用户的名称)?我知道我可以在每个端点检查user_id== token.userId或类似的东西,但这很重复。
mFeinstein

@mFeinstein一个答案肯定需要比我在注释中输入更多的字符。只是为了给您一些指导,您可以寻找行级安全性
卡西莫林

当我搜索行级安全性时,我可以在数据库上看到很多主题,然后将其作为一个新问题打开。
mFeinstein
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.