基于令牌的身份验证如何工作
在基于令牌的身份验证中,客户端交换硬凭证(例如用户名和密码)以获取称为令牌的数据。对于每个请求,客户端不会发送硬凭证,而是将令牌发送到服务器以执行身份验证然后授权。
简而言之,基于令牌的身份验证方案遵循以下步骤:
- 客户端将其凭据(用户名和密码)发送到服务器。
- 服务器对凭据进行身份验证,如果它们有效,则为用户生成令牌。
- 服务器将先前生成的令牌连同用户标识符和到期日期一起存储在某个存储中。
- 服务器将生成的令牌发送给客户端。
- 客户端在每个请求中将令牌发送到服务器。
- 服务器在每个请求中从传入请求中提取令牌。使用令牌,服务器查找用户详细信息以执行身份验证。
- 如果令牌有效,则服务器接受请求。
- 如果令牌无效,服务器拒绝请求。
- 一旦执行了身份验证,服务器就会执行授权。
- 服务器可以提供一个端点来刷新令牌。
您可以使用 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(禁止)的响应。
如果凭据成功验证,将返回状态为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(未授权)的响应。否则请求将继续到资源方法。
保护您的 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 方法允许您从 JAX-RS 资源和提供者以外的 bean 获取经过身份验证的用户。
支持基于角色的授权
如何支持基于角色的授权,请参考我的另一个answer。
发行代币
令牌可以是:
-
不透明:除了值本身之外不显示任何细节(如随机字符串)
-
自包含:包含有关令牌本身的详细信息(如 JWT)。
详见下文:
随机字符串作为记号
可以通过生成随机字符串并将其与用户标识符和到期日期一起保存到数据库来颁发令牌。可以看到here 是如何在 Java 中生成随机字符串的一个很好的例子。你也可以使用:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT(JSON 网络令牌)
JWT(JSON Web Token)是一种在两方之间安全地表示声明的标准方法,由RFC 7519 定义。
它是一个独立的令牌,它使您能够在 声明 中存储详细信息。这些声明存储在令牌有效负载中,它是一个 JSON 编码为Base64。以下是在RFC 7519 中注册的一些声明及其含义(请阅读完整的 RFC 了解更多详细信息):
请注意,您不得在令牌中存储敏感数据,例如密码。
客户端可以读取有效负载,并且可以通过在服务器上验证其签名来轻松检查令牌的完整性。签名是防止令牌被篡改的原因。
如果您不需要跟踪 JWT 令牌,则无需保留它们。尽管如此,通过持久化令牌,您将有可能使它们失效并撤销它们的访问权限。要跟踪 JWT 令牌,而不是将整个令牌保存在服务器上,您可以保存令牌标识符(jti 声明)以及其他一些详细信息,例如您为其颁发令牌的用户、到期日期等.
在持久化令牌时,请始终考虑删除旧令牌,以防止您的数据库无限增长。
使用 JWT
有一些 Java 库可以发布和验证 JWT 令牌,例如:
要查找与 JWT 一起使用的其他出色资源,请查看 http://jwt.io。
使用 JWT 处理令牌撤销
如果您想撤销令牌,您必须跟踪它们。您不需要将整个令牌存储在服务器端,只存储令牌标识符(必须是唯一的)和一些元数据(如果需要)。对于令牌标识符,您可以使用 UUID。
jti 声明应用于将令牌标识符存储在令牌上。验证令牌时,请根据您在服务器端拥有的令牌标识符检查 jti 声明的值,确保它没有被撤销。
出于安全考虑,请在用户更改密码时撤销所有令牌。
其他信息