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

原文 : 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
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容