一、引言:HTTP无状态性与身份验证的必要性

在当今数字化时代,Web应用已成为人们生活中不可或缺的一部分。无论是在线购物、社交媒体,还是企业内部的管理系统,Web应用都承载着大量的用户数据和业务逻辑。然而,在Web应用的背后,HTTP协议的无状态特性给身份验证带来了挑战,同时也凸显了身份验证在保障用户安全和系统稳定中的重要性。

HTTP协议的无状态特性

​ HTTP(超文本传输协议)是Web应用的基础通信协议。它允许客户端(通常是浏览器)向服务器发送请求,并从服务器获取响应。然而,HTTP协议本身是无状态的,这意味着每个HTTP请求都是独立的,服务器不会保存请求之间的任何状态信息。换句话说,服务器不会“记住”之前的请求,每个请求都被视为一个新的、独立的交互。

​ 这种无状态特性虽然简化了协议的设计,但也带来了问题。例如,当用户登录一个Web应用后,服务器需要在后续的请求中识别该用户的身份,以便提供个性化的服务和访问控制。如果没有一种机制来管理这种状态,服务器将无法区分不同的用户,也无法保证用户数据的安全。

身份验证的重要性

在Web应用中,身份验证是确保用户安全和系统稳定的关键环节。身份验证的目的是验证用户的身份,确保只有合法的用户才能访问受保护的资源。通过身份验证,Web应用可以:

  • 保护用户隐私:防止未经授权的用户访问用户的个人信息和敏感数据。
  • 保障数据安全:确保只有经过授权的用户才能对数据进行读取、修改或删除操作。
  • 提供个性化服务:根据用户的身份和偏好,提供定制化的用户体验。
  • 防止恶意攻击:阻止恶意用户通过非法手段访问系统,保护系统的完整性和可用性。

在HTTP无状态的环境下,Web应用需要一种机制来在多个请求之间保持用户的身份状态。这就引出了身份验证的各种技术方案,包括Cookie、Session和Token等。这些技术各有优缺点,适用于不同的应用场景。在接下来的章节中,我们将深入探讨这些身份验证方案的工作原理、优缺点以及它们在现代Web应用中的应用。

二、Cookie与Session:传统身份验证方案

(一)Cookie

工作原理

Cookie是一种存储在客户端(通常是浏览器)的小型文本文件,用于在客户端和服务器之间传递信息。当用户访问一个Web应用时,服务器可以通过HTTP响应头设置Cookie,浏览器会自动将这些Cookie存储在本地。在后续的请求中,浏览器会自动将这些Cookie发送回服务器,服务器可以通过解析Cookie来识别用户的身份。

例如,当用户登录一个Web应用时,服务器会生成一个包含用户身份信息的Cookie,并将其发送给客户端。客户端在后续的请求中会自动将这个Cookie发送回服务器,服务器通过解析Cookie中的信息来确认用户的身份。

代码展示

  • 要在Spring Boot中设置cookie

    @PostMapping("/login")
    public String login(HttpServletResponse response){
    	// 创建一个cookie对象
    	Cookie cookie = new Cookie("username", "love__me");
    	// 将cookie对象加到响应中
    	response.addCookie(cookie);
    	return "login success!";
    }
    
  • 获cookie

    Spring框架提供@CookieValue注释来获取HTTP cookie的值,此注解可直接用在控制器方法参数中。如果没有设置默认值,并且没有找到名称为username的Cookie,Spring将抛出java.lang.IllegalStateException异常

    @GetMapping("/getName")
    public String getName(@CookieValue(value = "username", defaultValue = "default") String username){
    	return "your name is " + username;
    }
    
    @GetMapping("/all-cookies")
    public String allCookies(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        if(cookies != null){
            return Arrays.stream(cookies)
                    .map(c->c.getName() + "=" + c.getValue())
                    .collect(Collectors.joining(", "));
        }
        return "No cookies!";
    }
    
  • 为Cookie设置过期时间

    如果没有为cookie指定过期时间,则其生命周期将持续到Session过期为止。这样的cookie称为会话cookie。会话cookie保持活动状态,直到用户关闭其浏览器或清除其cookie。但是可以覆盖此默认行为,使用类的setMaxAge()方法设置cookie的过期时间

    cookie.setMaxAge(7 * 24 * 60 * 60); // 7天过期
    
  • 安全的Cookie

    安全的cookie是仅可以通过加密的HTTPS连接发送到服务器的cookie。无法通过未加密的HTTP连接将cookie发送到服务器。也就是说,如果设置了setSecure(true),该Cookie将无法在Http连接中传输,只能是Https连接中传输

    cookie.setSecure(true);  //Https 安全cookie
    
  • HttpOnly cookie

    HttpOnly cookie用于防止跨站点脚本(XSS)攻击,也就是说设置了[Http Only](https://so.csdn.net/so/search?q=Http Only&spm=1001.2101.3001.7020)的Cookie不能通过JavaScript的Document.cookieAPI访问,仅能在服务端由服务器程序访问

    cookie.setHttpOnly(true);  //不能被js访问的Cookie
    
  • 删除cookie

    要删除Cookie,需要将Max-Age设置为0,并且将Cookie的值设置为null。不要将Max-Age指令值设置为-1负数。否则,浏览器会将其视为会话cookie

    // 将Cookie的值设置为null
    Cookie cookie = new Cookie("username", null);
    //将`Max-Age`设置为0
    cookie.setMaxAge(0);
     
    response.addCookie(cookie);
    

局限性

尽管Cookie在身份验证中发挥了重要作用,但它也存在一些明显的局限性:

  • 安全性问题:Cookie存储在客户端,容易受到XSS(跨站脚本攻击)和CSRF(跨站请求伪造)攻击。攻击者可以通过恶意脚本窃取Cookie,或者利用Cookie发起伪造的请求。
  • 存储容量限制:Cookie的大小通常限制在4KB以内,这限制了可以存储在Cookie中的信息量。
  • 跨域支持问题:Cookie受同源策略限制,只能在设置它们的域内使用。这使得跨域身份验证变得复杂,需要额外的机制来支持。
  • 依赖客户端:如果用户禁用了Cookie,或者使用了无痕浏览模式,服务器将无法通过Cookie识别用户的身份。

(二)Session

实现方式

Session是一种服务器端的机制,用于在多个请求之间保持用户的状态。当用户登录时,服务器会创建一个Session对象,并生成一个唯一的Session ID。这个Session ID通常存储在Cookie中,客户端在后续的请求中会将这个Cookie发送回服务器,服务器通过Session ID来查找和恢复用户的会话状态。

例如,当用户登录一个Web应用时,服务器会创建一个Session对象,并将用户的身份信息存储在Session中。服务器会生成一个Session ID,并将其存储在Cookie中发送给客户端。客户端在后续的请求中会自动将这个Cookie发送回服务器,服务器通过Session ID来查找和恢复用户的会话状态。

代码展示

@RestController
@RequestMapping("session")
public class SessionController {

    @GetMapping("/login")
    public String login(HttpSession session) {
        session.setAttribute("username", "张三");
        // 单独设置当前Session的超时时间为10分钟(600秒)
        // 覆盖全局配置,仅对当前Session生效
        session.setMaxInactiveInterval(600); 
        return "login";
    }

    @GetMapping("/home")
    public String home(HttpSession session) {
        String username = (String) session.getAttribute("username");
        System.out.println("当前登录用户:" + username);
        return "home";
    }
}

Session 创建、Session ID 存储到 Cookie、Session 数据存储到服务器本地内存这三个过程,都是由Servlet 容器(如 Spring Boot 默认内嵌的 Tomcat) 自动完成的,开发者无需手动干预。

具体过程拆解:

  1. Session 的创建当客户端第一次请求/login接口时,代码中通过HttpSession session参数获取 Session 对象。此时若客户端尚未创建过 Session,Servlet 容器(Tomcat)会自动生成一个新的 Session 对象,并为其分配唯一的Session ID(如123456789ABCDEF)。

  2. Session ID 存储到 Cookie容器创建 Session 后,会自动通过响应头Set-CookieSession ID发送给客户端,格式类似:Set-Cookie: JSESSIONID=123456789ABCDEF; Path=/; HttpOnly客户端(浏览器)会自动将这个JSESSIONID(Session ID 的默认键名)存储到本地 Cookie 中。

    后续客户端请求/home等接口时,会自动通过请求头Cookie 携带JSESSIONID,服务器通过这个 ID 即可找到对应的 Session。

  3. Session 数据存储到服务器本地内存通过session.setAttribute("username", "张三")存入的数据,会被 Servlet 容器自动存储在服务器的本地内存中(默认行为),并与Session ID关联。容器会负责管理这些内存中的 Session(比如超时后自动销毁),开发者无需手动操作存储位置。

注意

  1. 访问静态资源时不会创建 session

  2. 服务器会把长时间没有活动的 session 从服务器内存中清除,此时 session 便失效。Tomcat 中 session的默认失效时间为 20分钟

    # application.properties 示例
    # 设置为30分钟(推荐显式加单位,避免歧义)
    server.servlet.session.timeout=30m
    
    # 其他示例:
    # server.servlet.session.timeout=1800s  # 30分钟(1800秒)
    # server.servlet.session.timeout=0.5h   # 0.5小时(30分钟)
    
  3. Tomcat 7以上的版本中默认禁止客户端脚本读取session Id,需要在context.xml中设置useHttpOnly=”false”,开启权限

    // sring boot中采用下面这种方式
    server.servlet.session.cookie.http-only=false
    
  4. 通常,Session标识会存储在Cookie中,并随每个请求一起发送到服务器。这种方式称为基于Cookie的Session。但也可以通过其他方式来传递Session标识,例如将它包含在URL参数中,这称为基于URL的Session

缺陷分析

尽管Session机制在身份验证中非常有效,但它也存在一些明显的缺陷:

  • 服务器资源占用:Session信息存储在服务器端,随着用户数量的增加,服务器需要存储大量的会话信息,这会占用大量的内存资源,增加服务器的负担。
  • 扩展性问题:在分布式系统中,Session信息需要在多个服务器之间共享,这增加了系统的复杂性和管理难度。常见的解决方案包括使用共享存储(如Redis)来存储Session信息,但这会增加系统的延迟。
  • 单点故障风险:如果服务器发生故障,存储在服务器上的Session信息可能会丢失,导致用户需要重新登录。虽然可以通过冗余和备份机制来降低这种风险,但这会增加系统的复杂性和成本。
  • 依赖Cookie:Session机制依赖Cookie来传递Session ID,因此它也继承了Cookie的局限性,如安全性问题和跨域支持问题。

三、Token:现代无状态身份验证

Cookie和Session是两种传统的身份验证方案,它们在Web应用的早期发展中发挥了重要作用。然而,随着Web应用的复杂性和规模的增加,它们的局限性逐渐显现。Cookie存在安全性问题、存储容量限制和跨域支持问题,而Session存在服务器资源占用问题、扩展性问题和单点故障风险。在现代Web应用中,尤其是分布式系统和微服务架构中,需要一种更高效、更安全的身份验证方案。这引出了Token机制,尤其是JWT(JSON Web Token)的出现,它在很大程度上解决了Cookie和Session的局限性,成为现代Web应用中首选的身份验证方案。

(一)Token优势

无状态性

Token机制的核心优势之一是无状态性。在Token机制中,服务器不需要存储用户的会话信息,每个Token都包含了用户的身份信息和必要的声明。这意味着服务器在验证用户身份时,不需要查询数据库或内存中的会话信息,从而大大减少了服务器的存储负担和查询延迟。无状态性使得Token机制特别适合分布式系统和微服务架构,因为每个服务都可以独立地验证Token,而不需要共享会话信息。

自包含性

Token是自包含的,这意味着Token本身包含了所有必要的用户身份信息和声明。这些信息通常以JSON格式存储在Token的Payload部分。自包含性使得Token在传输过程中携带了所有必要的信息,服务器可以通过解析Token来获取用户的身份信息,而不需要额外的查询。这不仅提高了系统的效率,还增强了系统的可扩展性。

易于分布式部署

由于Token的无状态性和自包含性,它特别适合分布式系统和微服务架构。在分布式系统中,多个服务可以独立地验证Token,而不需要共享会话信息。这使得系统更加灵活,易于扩展和维护。此外,Token可以通过HTTP请求头传输,使得跨域身份验证变得简单和安全

(二)JWT简介

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间以JSON对象安全地传递信息。JWT的信息可以被验证和信任,因为它是数字签名的。JWT通常由三部分组成,每部分之间用点(.)分隔:

  • Header(头部):Header通常包含两部分:Token的类型(通常是JWT)和签名算法(如HS256、RS256等)。例如:

    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  • Payload(负载):Payload包含声明(Claims),这些声明是关于实体(通常是用户)和其他数据的声明。声明分为三种类型:注册声明(如iss、exp等)、公共声明(由开发者自定义)和私有声明(由开发者自定义)。例如:

    {
      "sub": "1234567890",
      "name": "John Doe",
      "iat": 1516239022,
      "exp": 1516239082
    }
    
  • Signature(签名):Signature用于验证Token的完整性和防止篡改。签名是通过Header和Payload的Base64 URL编码后的字符串,使用指定的算法和密钥生成的。例如:

    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      secret
    )
    

(三)工作原理

JWT的工作原理可以分为以下几个步骤:

  1. 生成Token:当用户登录时,服务器会生成一个JWT,将用户的身份信息和其他必要的声明存储在Token的Payload部分。服务器使用指定的算法和密钥对Token进行签名,生成最终的JWT。

  2. 发送Token:服务器将生成的JWT发送给客户端,客户端通常将Token存储在本地存储(如localStorage或sessionStorage)中。

  3. 验证Token:在后续的请求中,客户端将JWT放在HTTP请求头中发送给服务器。服务器接收到Token后,会解析Token的Header和Payload,并使用相同的算法和密钥对Token进行签名验证。如果验证通过,服务器会认为用户是合法的,并处理请求。

  4. Token过期处理:JWT通常包含一个过期时间(exp)声明,服务器在验证Token时会检查Token是否过期。如果Token过期,服务器会拒绝请求,客户端需要重新登录或刷新Token。

四、实战:基于Spring Boot的JWT身份验证实现

JWT(JSON Web Token)是实现身份验证的常用方法。Spring Boot提供了强大的支持,使得在Spring Boot项目中实现JWT身份验证变得相对简单。本节将详细介绍单Token(accessToken)双Token(refreshToken + accessToken)的实现方式,并探讨白名单黑名单两种策略的优缺点。最后,我们将利用Hutool包展示双Token + 黑名单的实现方式。

(一)单Token与双Token

单Token(accessToken)

单Token机制中,客户端在登录成功后只获得一个Token(通常称为accessToken),该Token用于后续的请求认证。当Token过期时,客户端需要重新登录以获取新的Token。

优点

  • 简单:实现相对简单,易于理解和维护。

缺点

  • 用户体验差:Token过期时,用户需要重新登录,用户体验不佳。(可以结合白名单策略做到无感)
  • 安全性问题:如果Token被盗用,攻击者可以在Token有效期内滥用Token。

其它

  • 应用场景:适用于一些对用户登录状态持续性要求不高的场景,例如一些简单的查询类服务,用户登录后短时间内完成操作即可,无需长时间保持登录状态。
  • Token过期提醒:在Token即将过期时,提前向用户发送提醒,引导用户及时重新登录,减少因Token过期导致的用户体验问题。

双Token(refreshToken + accessToken)

双Token机制中,客户端在登录成功后会获得两个Token:accessTokenrefreshToken。accessToken用于请求认证,refreshToken用于在accessToken即将过期时由客户端携带refreshToken主动向服务端发出请求,获取新的accessToken。

优点

  • 用户体验好:用户不需要频繁重新登录,因为refreshToken可以在accessToken过期时用于获取新的accessToken,从而延长用户的登录状态。
  • 平滑的会话管理:用户在使用应用时,不会因为Token过期而中断操作,提升了整体的用户体验。
  • 安全性高
    • Token分层保护accessToken用于日常请求认证,refreshToken用于获取新的accessToken。即使accessToken被盗用,攻击者也无法利用refreshToken获取新的accessToken,因为**refreshToken通常有更严格的保护措施**。
    • refreshToken的安全性增强refreshToken通常具有更长的有效期,但可以通过限制其使用频率、次数和设备绑定等方式增强安全性。例如,refreshToken可以与用户的设备指纹绑定,只有在授权设备上才能使用。
    • 快速撤销:如果发现refreshToken被盗用,可以立即撤销该refreshToken,防止进一步的滥用。

缺点

  • 实现复杂性增加:需要管理refreshToken的生成、存储、更新和撤销逻辑,增加了开发和维护的复杂性。
  • 存储需求增加:需要在服务器端存储refreshToken,通常使用数据库或缓存(如Redis),增加了存储需求和管理复杂性。
  • refreshToken泄露风险:如果refreshToken被盗用,攻击者可以在较长时间内获取新的accessToken,从而持续访问系统,尽管可以通过设备绑定等措施增强安全性,但仍然存在一定的风险。

其它

  • 应用场景:广泛应用于需要长时间保持用户登录状态的场景,如在线办公系统、社交平台等,用户在登录后可以在较长时间内持续进行操作,无需频繁重新登录。

  • refreshToken的安全性:refreshToken通常具有较长的有效期,因此其安全性至关重要。可以对refreshToken进行加密存储,并且限制refreshToken的使用频率和次数,防止refreshToken被恶意利用。

(二)白名单与黑名单

白名单策略

白名单策略中,服务器将有效的Token存储在Redis中,每次请求时验证Token是否在白名单中。如果Token有效,服务器会更新Token的有效期。

优点

  • 实时性高:可以实时验证Token的有效性,快速响应Token的撤销或更新。
  • 安全性高:可以快速撤销Token,增强系统的安全性。

缺点

  • 状态化:虽然Token本身是无状态的,但白名单机制使得Token验证变得有状态,需要在服务器端存储Token信息,违背了JWT的无状态设计。
  • 性能问题:每次请求都需要查询Redis,增加了请求的延迟。

其它

  • 应用场景:适用于对Token实时性要求较高的场景,如金融交易系统、在线支付系统等,需要实时验证Token的有效性,确保交易的安全性。
  • 缓存机制:为了减少对Redis的查询压力,可以引入缓存机制。将频繁访问的Token缓存到内存中,减少对Redis的查询次数,提高系统的性能。

黑名单策略

黑名单策略中,服务器将过期或注销的Token存储在Redis中,每次请求时验证Token是否过期,签名是否正确,是否在黑名单中。如果Token在黑名单中,请求将被拒绝。

优点

  • 无状态:Token验证保持无状态,符合JWT的设计初衷。

  • 安全性高:可以有效防止Token被重复使用,即使Token被盗用,攻击者也无法再次使用该Token。

  • 存储需求减少:Redis中只需要存储过期或注销的Token,减少了存储需求。

缺点

  • 复杂性增加:需要管理黑名单的更新和查询逻辑,增加了实现的复杂

其它

  • 应用场景:适用于对Token无状态要求较高的场景,如分布式系统、微服务架构等,Token验证保持无状态,符合JWT的设计初衷,便于系统的扩展和维护。

(三)代码实践

通常我会将单Token与白名单组合,双Token与黑名单组合,代码通过Hutool包演示双Token与黑名单,其中refreshToken的安全性还比较低

单Token与白名单

  • 用户体验:良好。通过白名单机制,服务器可以实时更新Token的有效期,用户无需频繁重新登录,保持了较好的用户体验。
  • 适用场景
    • 对实时性要求高的系统:如金融交易、在线支付等,需要快速验证Token的有效性,白名单机制可以快速响应Token的撤销或更新。
    • 对安全性要求高的系统:如企业内部敏感信息管理系统、医疗信息系统等,白名单机制可以快速撤销Token,防止Token被盗用。

双Token与黑名单

  • 用户体验:良好。用户在accessToken过期时,可以通过refreshToken自动获取新的accessToken,无需重新登录,大大提升了用户体验。
  • 适用场景
    • 对用户体验要求高的系统:如在线办公系统、社交平台等,用户需要长时间保持登录状态,双Token机制可以避免用户频繁重新登录。
    • 对Token无状态要求高的系统:如分布式系统、微服务架构等,Token验证保持无状态,符合JWT的设计初衷,便于系统的扩展和维护。

双Token与黑名单代码实现,并且展示多客户端需要多Token策略的解决方式**(采用策略模式)**

首先是整个Token策略模式的代码

  • 抽象策略(Strategy)

    @Data
    public abstract class AbstractTokenConfig {
    
        // 访问令牌
        protected final TokenProp accessToken = new TokenProp(TimeUnit.MINUTES);
        // 刷新令牌
        protected final TokenProp refreshToken = new TokenProp(TimeUnit.HOURS);
    
        @Data
        public static class TokenProp {
    
            public TokenProp(TimeUnit timeUnit) {
                this.timeUnit = timeUnit;
            }
    
            private Long expireTime;
            private final TimeUnit timeUnit;
    
            // 秘钥
            private String secret;
    
            // 负载key的前缀
            private String payloadKeyPrefix;
    
            // 获取过期时间, 毫秒
            public Long getExpireTimeMillis() {
                return timeUnit.toMillis(expireTime);
            }
        }
    
        // token策略
        public abstract String getTokenStrategy();
    }
    

    说明

    • AbstractTokenConfig 是一个抽象类,定义了Token的基本配置,包括accessTokenrefreshToken

    • TokenProp 是一个内部类,用于存储Token的属性,如过期时间、秘钥和负载前缀。

    • getTokenStrategy 是一个抽象方法,用于返回具体的Token策略名称。

  • 具体策略(ConcreteStrategy)

    @EqualsAndHashCode(callSuper = true)
    @Data
    @Component
    @ConfigurationProperties(prefix = "jwt.token.customer")
    public class CustomerTokenConfig extends AbstractTokenConfig {
        @Override
        public String getTokenStrategy() {
            return "customer";
        }
    }
    
    @EqualsAndHashCode(callSuper = true)
    @Data
    @Component
    @ConfigurationProperties(prefix = "jwt.token.employee")
    public class EmployeeTokenConfig extends AbstractTokenConfig {
    
        @Override
        public String getTokenStrategy() {
            return "employee";
        }
    }
    

    说明:

    • CustomerTokenConfig 和 EmployeeTokenConfig 是具体的Token配置类,分别对应客户和员工的Token策略。
    • 使用 @ConfigurationProperties 注解,从配置文件中加载对应的Token配置。
    jwt:
        token:
            employee:
                access-token:
                    expire-time: 60   #分
                    secret: qwertyuijhgfdsawerty12345
                    payload-key-prefix: e
                refresh-token:
                    expire-time: 12   #时
                    secret: qwertyuijhgfdsawerty12345
                    payload-key-prefix: e
            customer:
                access-token:
                    expire-time: 60   #分
                    secret: qwertyuijhgfdsawerty12345
                    payload-key-prefix: c
                refresh-token:
                    expire-time: 12   #时
                    secret: qwertyuijhgfdsawerty12345
                    payload-key-prefix: c
    

    说明

    • 配置文件中定义了不同角色(员工和客户)的Token配置,包括过期时间、秘钥和负载前缀。
  • 环境类(Context)

    @Component
    public class TokenConfigManager {
    
        // key: 前端携带的请求头(也是策略名称),value: 令牌配置
        private final Map<String, AbstractTokenConfig> tokenConfigs;
    
        public TokenConfigManager(List<AbstractTokenConfig> tokenConfigs) {
            this.tokenConfigs = tokenConfigs.stream()
                    .collect(Collectors
                            .toMap(AbstractTokenConfig::getTokenStrategy, c -> c));
        }
    
        public AbstractTokenConfig getTokenConfig(String tokenStrategy) {
            AbstractTokenConfig tokenConfig = tokenConfigs.get(tokenStrategy);
            if (tokenConfig == null) {
                throw new ServiceException("No token strategy found for: " + tokenStrategy);
            }
            return tokenConfig;
        }
    
    }
    

    说明

    • TokenConfigManager 是一个管理类,用于根据Token策略名称获取对应的Token配置。
    • 使用 @Component 注解,将 TokenConfigManager 注册为Spring Bean。
  • 模拟登录接口,客户端在每次访问时都需要携带X-Token-Strategy作为请求头标识所使用的Token策略,登录成功后,之后的每次请求都需要accessTokenrefreshToken

    @GetMapping("/login")
    Public Map<String, Object> login(@RequestParam String username, HttpServletRequest request){
       	// 准备token: access_token和refresh_token
       	String tokenStrategy = request.getHeader("X-Token-Strategy");  // customer或者是employee
       	AbstractTokenConfig tokenConfig = tokenConfigManager.getTokenConfig(tokenStrategy);
        // access_token
        AbstractTokenConfig.TokenProp accessTokenProp = tokenConfig.getAccessToken();
        String keyPrefix = accessTokenProp.getPayloadKeyPrefix();
        long accessExpireTime = accessTokenProp.getExpireTimeMillis();
        Map<String, Object> accessPayload = Map.of(keyPrefix + "username", username,
                                                   "exp", Instant.now().plusMillis(accessExpireTime).getEpochSecond(),
                                                   "jti", UUID.randomUUID().toString()
                                                  );
        byte[] accessSecret = accessTokenProp.getSecret().getBytes(StandardCharsets.UTF_8);
        String accessToken = JWTUtil.createToken(accessPayload, accessSecret);
        // refresh_token
        AbstractTokenConfig.TokenProp refreshTokenProp = tokenConfig.getAccessToken();
        Map<String, Object> refreshPayload = Map.of("exp", Instant.now().plusMillis(refreshExpireTime).getEpochSecond(),
                                                   "jti", UUID.randomUUID().toString()
                                                  );
        byte[] refershSecret = refershTokenProp.getSecret().getBytes(StandardCharsets.UTF_8);
        String refreshToken = JWTUtil.createToken(accessPayload, accessSecret);
        // 返回结果
        return Map.of("accessToken", accessToken,
                     "refreshToken", refreshToken);
    }
    
  • 模拟登出接口,登出接口将 accessTokenrefreshToken 添加到Redis黑名单中,防止Token被重复使用。

    @PostMapping("/logout")
    public String logout(HttpServletRequest request){
        String accessToken = request.getHeader("X-Access-Token");
        String refeshToken = request.getHeader("X-Refresh-Token");
        addBlackList(accessToken);
        addBlackList(refreshToken);
        return "logout success!";
    }
    
    /**
    * 添加黑名单
    *
    * @param token 令牌
    */
    public void addBlackList(String token) {
        // 解析Token,获取jti与剩余有效期
        String jti = (String) JWTUtil.parseToken(token).getPayload("jti");
        Object expObj = JWTUtil.parseToken(token).getPayload("exp");
        long exp = Convert.toLong(expObj);
        long remainingSec = exp - Instant.now().getEpochSecond();
        // 将jti存入Redis黑名单设置TTL
         String jtiKey = "invalidation:" + jti;
         stringRedisTemplate.opsForValue().set(jtiKey, "1", remainingSec, TimeUnit.SECONDS);
    }
    
  • 正常api请求过滤器

    @Component
    @RequiredArgsConstructor
    public class GatewayAuthenticationFilter extends OncePerRequestFilter {
        
        private final TokenConfigManager tokenConfigManager;
        
        /**
         * 跳过验证的接口: 不需要token验证
         */
        private static final Set<String> SKIP_AUTH = Set.of(...);
        
        /**
        * Token策略白名单,可整合到配置文件中
        */
        private static final Set<String> TOKEN_STRATEGY = Set.of(...);
        
        /**
         * 判断请求路径是否需要过滤
         *
         * @param requestURI 请求路径
         * @return true: 不需要过滤,允许通过
         */
        private boolean allowedPath(String requestURI) {
            for (String allowedPath : SKIP_AUTH) {
                if (PATH_MATCHER.match(allowedPath, requestURI)) {
                    return true;                         // 匹配成功
                }
            }
            return false;
        }
        
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            // 不需要token验证
            if (allowedPath(path)) {
                return chain.filter(exchange);
            }
            // 从请求头获取Token策略
            String tokenStrategy = request.getHeader("X-Token-Strategy");
            if (tokenStrategy == null || !TOKEN_STRATEGY.contains(tokenStrategy)){
                response.getWriter().write("未携带对应的Token策略");
            }
            
            // 只检查access_token就可以了
            // 检查jwt签名和过期时间
            AbstractTokenConfig tokenConfig = tokenConfigManager.getTokenConfig(tokenStrategy);
            String accessToken = request.getHeadler("X-Access-Token");
            String secret = tokenConfig.getAccessToken().getSecret();
            try {
                JWTUtil.verify(token, secret.getBytes());  //Hutool会自动校验 exp 字段, 单位秒
            } catch (Exception e) {
                response.getWriter().write("认证状态已失效");
            }
            // 查询redis中的黑名单
            JSONObject payloads = JWTUtil.parseToken(accessToken).getPayloads();
            boolean containsJTI = payloads.containsKey("jti");
            String jtiKey = "invalidation:" + payloads.getStr("jti");
            if (!containsJTI || stringRedisTemplate.opsForValue().get(jtiKey) != null){
                response.getWriter().write("认证状态已失效");
            }
    
            filterChain.doFilter(request, response);
        }
    }
    

    说明

    • 过滤器用于验证每个请求的 accessToken

    • 如果请求路径在跳过验证的列表中,则直接放行。

    • 验证Token的签名和过期时间,并检查Token是否在Redis黑名单中。

  • 模拟Token刷新接口

    /**
    * 刷新令牌
    *
    * @param refreshToken 刷新令牌
    * @param accessToken  访问令牌
    * @return 新的访问令牌和刷新令牌
    */
    @PostMapping("/refresh")
    public Map<String, Object> refreshToken(
            HttpServletRequest request,
            @RequestHeader(HttpHeader.REFRESH_TOKEN)
            String refreshToken,
            @RequestHeader(HttpHeader.ACCESS_TOKEN)
            String accessToken) {
       String tokenStrategy = request.getHeader("X-Token-Strategy");
            AbstractTokenConfig tokenConfig = tokenConfigManager.getTokenConfig(tokenStrategy);
            // 验证Refresh Token是否有效(Access Token在过滤器中已经校验)
            // 这里与过滤器逻辑相同,不再展示
    
            // 生成一套新的Access Token和Refresh Token
            // 这里也与前面的登录接口创建两个Token的逻辑相同,不再展示
    
            // 旧的Refresh Token 和 Access Token加入黑名单(防止被重复使用)
            addBlackList(accessToken);
            addBlackList(refreshToken);
    
            // 返回新的Access Token和Refresh Token
            return Map.of("accessToken", newAccessToken,
                    "accessToken", newRefreshToken);
    }
    

    说明

    • 刷新接口用于生成新的 accessTokenrefreshToken

    • 旧的Token被加入黑名单,防止重复使用。

五、进阶与展望

  1. 进阶

    可以将Token的生命周期封装到一个工具类中,这样可以提高代码的复用性和可维护性。

    @Component
    @RequiredArgsConstructor
    public class TokenOperateUtil {
    
        private final StringRedisTemplate stringRedisTemplate;
    
        /**
         * 创建访问令牌
         *
         * @param tokenConfig 令牌配置
         * @return 访问令牌
         */
        public String createRefreshToken(AbstractTokenConfig tokenConfig) {
            // TODO
        }
        
        /**
         * 创建刷新令牌
         *
         * @param tokenConfig 令牌配置
         * @return 刷新令牌
         */
        public String createRefreshToken(AbstractTokenConfig tokenConfig) {
            // TODO
        }
    
        /**
         * 验证jwt签名和过期时间
         *
         * @param token 令牌
         * @param tokenConfig 令牌配置
         * @return true: 验证成功
         */
        public boolean checkTokenSignatureAndExpireTime(String token, AbstractTokenConfig tokenConfig) {
                // TODO
        }
    
        /**
         * 添加黑名单
         *
         * @param token 令牌
         */
        public void addBlackList(String token) {
            // TODO
        }
    
        /**
         * 检查令牌是否在黑名单中
         *
         * @param token 令牌
         * @return true: 在黑名单中
         */
        public boolean checkBlackList(String token) {
            // TODO
        }
    }
    
  2. 展望

    上述的策略与解决办法不仅适用于单体架构,同样也适用于微服务架构。在微服务架构中,每个服务可能需要独立管理自己的Token策略,而策略模式可以很好地支持这种需求。通过策略模式封装Token的生命周期管理,不仅可以提高代码的复用性和可维护性,还可以在微服务架构中实现灵活、安全、可扩展的Token管理。这种模式适用于多种架构,能够满足不同场景下的Token管理需求。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐