美文网首页
令牌认证是如何工作的(翻译)

令牌认证是如何工作的(翻译)

作者: 阪本先生 | 来源:发表于2017-12-05 14:57 被阅读0次

    原文 : How token-based authentication works

    令牌认证机制的工作原理

    客户端发送一个“硬凭证”(例如用户名和密码)到服务器,服务器返回一段数据作为令牌,之后客户端与服务端之间通讯的时候则会以令牌代替硬凭证。这就是基于令牌的认证机制

    简单来说,基于令牌的认证机制流程如下:

    1. 客户端发送凭证(例如用户名和密码)到服务器。
    2. 服务器验证凭证是否有效,并生成一个令牌
    3. 服务器把令牌连同用户信息和令牌有效期存储起来。
    4. 服务器发送生成好的令牌到客户端。
    5. 在接下来的每次请求中,客户端都会发送令牌到服务器
    6. 服务器会从请求中取出令牌,并根据令牌作鉴权操作
      • 如果令牌有效,服务器接受请求。
      • 如果令牌无效,服务器拒绝请求。
    7. 服务器可能会提供一个接口去刷新过期的令牌

    你可以利用 JAX-RS 2.0 干些什么(Jersey, RESTEasy 和 Apache CXF)

    下面的示例只使用了 JAX-RS 2.0 的API,没有用到其他的框架。所以能够在 JerseyRESTEasyApache 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 注解是不同的

    支持基于角色的权限认证

    除了认证,你还可以让你的 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

    相关文章

      网友评论

          本文标题:令牌认证是如何工作的(翻译)

          本文链接:https://www.haomeiwen.com/subject/falcixtx.html