令牌认证是如何工作的(翻译)
原文 : How token-based authentication works
令牌认证机制的工作原理
客户端发送一个“硬凭证”(例如用户名和密码)到服务器,服务器返回一段数据作为令牌,之后客户端与服务端之间通讯的时候则会以令牌代替硬凭证。这就是基于令牌的认证机制。
简单来说,基于令牌的认证机制流程如下:
- 客户端发送凭证(例如用户名和密码)到服务器。
- 服务器验证凭证是否有效,并生成一个令牌。
- 服务器把令牌连同用户信息和令牌有效期存储起来。
- 服务器发送生成好的令牌到客户端。
- 在接下来的每次请求中,客户端都会发送令牌到服务器
- 服务器会从请求中取出令牌,并根据令牌作鉴权操作
- 如果令牌有效,服务器接受请求。
- 如果令牌无效,服务器拒绝请求。
- 服务器可能会提供一个接口去刷新过期的令牌
你可以利用 JAX-RS 2.0 干些什么(Jersey, RESTEasy 和 Apache CXF)
下面的示例只使用了 JAX-RS 2.0 的API,没有用到其他的框架。所以能够在 Jersey、RESTEasy 和 Apache CXF 等 JAX-RS 2.0 实现中正常工作。
需要特别提醒的是,如果你要用基于令牌的认证机制,你将不依赖任何由 Servlet 容器提供的标准 Java EE Web 应用安全机制。
通过用户名和密码认证用户并颁发令牌
创建一个用于验证凭证(用户名和密码)并生成用户令牌的方法:
@Path("/authentication")
public class AuthenticationEndpoint {
@POST
@Produces("application/json")
@Consumes("application/x-www-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.UNAUTHORIZED).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
}
}
如果在验证凭证的时候有任何异常抛出,会返回 401 UNAUTHORIZED
状态码。
如果成功验证凭证,将返回 200 OK
状态码并返回处理好的令牌给客户端。客户端必须在每次请求的时候发送令牌。
你希望客户端用如下格式发送凭证的话:
username=admin&password=123456
你可以用一个类来包装一下用户名和密码,毕竟直接用表单可能比较麻烦:
public class Credentials implements Serializable {
private String username;
private String password;
// Getters and setters omitted
}
或者使用 JSON :
@POST
@Produces("application/json")
@Consumes("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 头里的这个名字是不对的,因为它存储的是认证信息(authentication)而不是授权(authorization)。
JAX-RS 提供一个叫 @NameBinding
的元注解来给拦截器和过滤器创建命名绑定注解。
@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }
@Secured
将会用来标记在实现了 ContainerRequestFilter
的类(过滤器)上以处理请求。 ContainerRequestContext
可以帮你把令牌从 HTTP 请求中拿出来。
@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// Get the HTTP Authorization header from the request
String authorizationHeader =
requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
// Check if the HTTP Authorization header is present and formatted correctly
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
throw new NotAuthorizedException("Authorization header must be provided");
}
// Extract the token from the HTTP Authorization header
String token = authorizationHeader.substring("Bearer".length()).trim();
try {
// Validate the token
validateToken(token);
} catch (Exception e) {
requestContext.abortWith(
Response.status(Response.Status.UNAUTHORIZED).build());
}
}
private void validateToken(String token) throws Exception {
// Check if it was issued by the server and if it's not expired
// Throw an Exception if the token is invalid
}
}
如果在验证令牌的时候有任何异常抛出,会返回 401 UNAUTHORIZED
状态码。
如果验证成功,则会调用被请求的方法。
给 RESTful 接口增加安全措施
把之前写好的 @Secure
注解打在你的方法或者类上,就能把过滤器绑定上去了。被打上注解的类或者方法都会触发过滤器,也就是说这些接口只有在通过了鉴权之后才能被执行。
如果有些方法或者类不需要鉴权,不打注解就行了。
@Path("/")
public class MyEndpoint {
@GET
@Path("{id}")
@Produces("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("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)
被调用的时候触发(因为打了注解嘛)。
验证当前用户
你很有可能会需要知道是哪个用户在请求你的 RESTful 接口,接下来的方法会比较有用:
重载 SecurityContext
通过使用 ContainerRequestFilter.filter(ContainerRequestContext)
这个方法,你可以给当前请求设置新的安全上下文(Secure Context)。
重载 SecurityContext.getUserPrincipal()
,返回一个 Principal
实例。
Principal
的名字(name)就是令牌所对应的用户名(usrename)。当你验证令牌的时候会需要它。
requestContext.setSecurityContext(new SecurityContext() {
@Override
public Principal getUserPrincipal() {
return new Principal() {
@Override
public String getName() {
return username;
}
};
}
@Override
public boolean isUserInRole(String role) {
return true;
}
@Override
public boolean isSecure() {
return false;
}
@Override
public String getAuthenticationScheme() {
return null;
}
});
注入 SecurityContext
的代理到 REST 接口类里。
@Context
SecurityContext securityContext;
在方法里做也是可以的。
@GET
@Secured
@Path("{id}")
@Produces("application/json")
public Response myMethod(@PathParam("id") Long id,
@Context SecurityContext securityContext) {
...
}
获取 Principal
Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();
使用 CDI (Context and Dependency Injection)
如果因为某些原因你不想重载 SecurityContext
的话,你可以使用 CDI ,它能提供很多诸如事件和提供者(producers)。
创建一个 CDI 限定符用来处理认证事件以及把已认证的用户注入到 bean 里。
@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
在 AuthenticationFilter
里注入一个 Event
@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
当认证用户的时候,以用户名作为参数去触发事件(注意,令牌必须已经关联到用户,并且能通过令牌查出用户名)
userAuthenticatedEvent.fire(username);
一般来说在应用里会有一个 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 beans、servlet 以及 EJBs)
@Inject
@AuthenticatedUser
User authenticatedUser;
要注意 CDI @Produces
注解和 JAX-RS 的 @Produces
注解是不同的
- CDI : javax.enterprise.inject.Produces
- JAX-RS : java.ws.rs.Produces
支持基于角色的权限认证
除了认证,你还可以让你的 RESTful API 支持基于角色的权限认证(RBAC)。
创建一个枚举,并根据你的需求定义一些角色:
public enum Role {
ROLE_1,
ROLE_2,
ROLE_3
}
针对 RBAC 改变一下 @Secured 注解:
@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured {
Role[] value() default {};
}
给方法打上注解,这样就能实现 RBAC 了。
注意 @Secured 注解可以在类以及方法上使用。接下来的例子演示一下方法上的注解覆盖掉类上的注解的情况:
@Path("/example")
@Secured({Role.ROLE_1})
public class MyEndpoint {
@GET
@Path("{id}")
@Produces("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("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
优先级创建一个过滤器,它会在先前定义的过滤器之后执行。
ResourceInfo
可以用来获取到匹配请求 URL 的 类 以及 方法 ,并且把注解提取出来。
@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
注解,则所有的令牌通过了检查的用户都能够调用这个方法,无论这个用户拥有什么角色。
如何生成令牌
令牌可以是不透明的,它不会显示除值本身以外的任何细节(如随机字符串),也可以是自包含的(如JSON Web Token)。
随机字符串
可以通过生成一个随机字符串,并把它连同有效期、关联的用户储存到数据库。下面这个使用 Java 生成随机字符串的例子就比较好:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
Json Web Token (JWT)
JSON Web Token (JWT) 是 RFC 7519 定义的,用于在双方之间安全地传递信息的标准方法。它不仅只是自包含的令牌,而且它还是一个载体,允许你储存用户标识、有效期以及其他信息(除了密码)。 JWT 是一段用 Base64 编码的 JSON。
这个载体能够被客户端读取,且可以让服务器方便地通过签名校验令牌的有效性。
如果你不需要跟踪令牌,那就不需要存储 JWT 令牌。当然,储存 JWT 令牌可以让你控制令牌的失效与重新颁发。如果既想跟踪 JWT 令牌,又不想存储它们,你可以存储令牌标识( jti 信息)和一些元数据(令牌颁发给哪个用户,有效期等等)。
有用于颁发以及校验 JWT 令牌的 Java 库(例如 这个 以及 这个 )。如果需要找 JWT 相关的资源,可以访问 http://jwt.io。
你的应用可以提供用于重新颁发令牌的功能,建议在用户重置密码之后重新颁发令牌。
记得删除旧的令牌,不要让它们一直占用数据库空间。
一些建议
- 不管你用的是哪一类型的认证方式,切记要使用 HTTPS ,以防中间人攻击。
- 关于信息安全的更多内容,请查阅 这个 问题。
- 在 这篇文章 里,你可以找到一些与基于令牌的认证机制相关的内容。
- Apache DeltaSpike 提供如 security module 之类的可用于保护 REST 应用的轻量级的 CDI 扩展。
- 对 OAuth 2.0 协议的 Java 实现感兴趣?你可以看看 Apache Oltu project 。