我正在寻找一种在Jersey中启用基于令牌的身份验证的方法。我试图不使用任何特定的框架。这可能吗?
我的计划是:用户注册我的Web服务,我的Web服务生成令牌,将其发送到客户端,客户端将保留它。然后,对于每个请求,客户端将发送令牌而不是用户名和密码。
我在考虑为每个请求使用自定义过滤器@PreAuthorize("hasRole('ROLE')")
但我只是认为这会导致很多请求数据库检查令牌是否有效。
或者不创建过滤器并在每个请求中放置一个参数令牌?这样每个API首先检查令牌,然后执行一些东西来检索资源。
答案 0 :(得分:1274)
在基于令牌的身份验证中,客户端会为名为 token 的数据交换硬凭据(例如用户名和密码)。对于每个请求,客户端不会发送硬凭证,而是将令牌发送到服务器以执行身份验证然后授权。
简而言之,基于令牌的身份验证方案遵循以下步骤:
注意:如果服务器已发出签名令牌(例如JWT,允许您执行无状态身份验证),则不需要执行步骤3。功能
此解决方案仅使用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
(未授权)的响应。否则,请求将进入资源方法。
要将身份验证过滤器绑定到资源方法或资源类,请使用上面创建的@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();
如果由于某种原因,您不想覆盖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
注释不同:
请务必在AuthenticatedUserProducer
bean中使用CDI @Produces
注释。
这里的关键是用@RequestScoped
注释的bean,允许您在过滤器和bean之间共享数据。如果您不想使用事件,则可以修改过滤器以将经过身份验证的用户存储在请求范围的bean中,然后从JAX-RS资源类中读取它。
与覆盖SecurityContext
的方法相比,CDI方法允许您从JAX-RS资源和提供程序以外的bean中获取经过身份验证的用户。
有关如何支持基于角色的授权的详细信息,请参阅我的其他answer。
令牌可以是:
请参阅以下详细信息:
可以通过生成随机字符串并将其与用户标识符和到期日期一起保存到数据库来发出令牌。可以看到如何在Java中生成随机字符串的一个很好的示例here。你也可以使用:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT(JSON Web令牌)是一种在双方之间安全地表示声明的标准方法,由RFC 7519定义。
它是一个自包含的令牌,它使您能够在声明中存储详细信息。这些声明存储在令牌有效负载中,该有效负载是JSON编码为Base64。以下是在RFC 7519中注册的一些声明及其含义(请阅读完整的RFC以获取更多详细信息):
请注意,您不得在令牌中存储敏感数据,例如密码。
客户端可以读取有效负载,并且可以通过验证服务器上的签名来轻松检查令牌的完整性。签名是防止令牌被篡改的原因。
如果您不需要跟踪JWT令牌,则无需持久保存JWT令牌。尽管如此,通过持久存在令牌,您将有可能使其无效并撤销其访问权限。要跟踪JWT令牌,而不是将整个令牌保留在服务器上,您可以将令牌标识符(jti
声明)与其他一些详细信息(例如您为其颁发令牌的用户,到期日期)一起保留。等等。
持久令牌时,请始终考虑删除旧令牌,以防止数据库无限期增长。
有一些Java库可以发布和验证JWT令牌,例如:
要查找其他一些与JWT合作的优秀资源,请查看http://jwt.io。
仅接受 有效(和未过期)令牌以进行更新。客户有责任在exp
声明中指明的到期日期之前刷新令牌。
您应该防止令牌无限期刷新。请参阅以下几种您可以考虑的方法。
您可以通过向令牌添加两个声明来保留令牌更新的跟踪(声明名称取决于您):
refreshLimit
:表示可以刷新令牌的次数。refreshCount
:表示令牌已刷新的次数。因此,只有在满足以下条件时才刷新令牌:
exp >= now
)。refreshCount < refreshLimit
)。刷新令牌时:
exp = now + some-amount-of-time
)。refreshCount++
)。除了跟踪茶点数量之外,您还可以声明绝对到期日期(其效果与上述refreshLimit
声明非常相似)。在绝对到期日期之前,可以接受任意数量的茶点。
另一种方法是发出一个单独的长期刷新令牌,用于发出短命的JWT令牌。
最佳方法取决于您的要求。
如果要撤消令牌,则必须跟踪它们。您不需要在服务器端存储整个令牌,只存储令牌标识符(必须是唯一的)和一些元数据(如果需要)。对于令牌标识符,您可以使用UUID。
jti
声明应该用于在令牌上存储令牌标识符。验证令牌时,请通过检查服务器端的令牌标识符jti
声明的值来确保它未被撤销。
出于安全考虑,请在用户更改密码时撤消所有令牌。
答案 1 :(得分:74)
这个答案完全是关于授权的,它是my previous answer关于身份验证 的补充
为什么另一个答案?我试图通过添加有关如何支持JSR-250注释的详细信息来扩展我以前的答案。然而,最初的答案变成太长并超过maximum length of 30,000 characters的方式。所以我将整个授权细节移到了这个答案,另一个答案集中在执行身份验证和发放令牌。
@Secured
注释支持基于角色的授权除了另一个answer中显示的身份验证流程外,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
(禁止)中止。
要了解执行请求的用户,请参阅my previous answer。您可以从SecurityContext
(应该已经在ContainerRequestContext
中设置)获取它,或者使用CDI注入它,具体取决于您的方法。
如果@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.");
}
}
注意:上述实施基于泽西岛RolesAllowedDynamicFeature
。如果您使用Jersey,则不需要编写自己的过滤器,只需使用现有的实现。