JWT 身份验证:从 Cookie/Session 到双 Token + 黑名单落地
HTTP协议的无状态特性为Web应用开发带来挑战,身份验证技术应运而生,确保用户安全与系统稳定。传统方案包括Cookie与Session:Cookie存储在客户端,通过文本信息传递身份数据,但易受XSS/CSRF攻击且容量受限;Session则基于服务器存储状态,通过Session ID识别用户,但其服务器依赖性导致扩展性不足。两种方案各具优缺点,开发者需结合实际场景选择,后续章节将进一步探讨To
一、引言: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) 自动完成的,开发者无需手动干预。
具体过程拆解:
-
Session 的创建当客户端第一次请求
/login接口时,代码中通过HttpSession session参数获取 Session 对象。此时若客户端尚未创建过 Session,Servlet 容器(Tomcat)会自动生成一个新的 Session 对象,并为其分配唯一的Session ID(如123456789ABCDEF)。 -
Session ID 存储到 Cookie容器创建 Session 后,会自动通过响应头
Set-Cookie将Session ID发送给客户端,格式类似:Set-Cookie: JSESSIONID=123456789ABCDEF; Path=/; HttpOnly客户端(浏览器)会自动将这个JSESSIONID(Session ID 的默认键名)存储到本地 Cookie 中。后续客户端请求
/home等接口时,会自动通过请求头Cookie携带JSESSIONID,服务器通过这个 ID 即可找到对应的 Session。 -
Session 数据存储到服务器本地内存通过
session.setAttribute("username", "张三")存入的数据,会被 Servlet 容器自动存储在服务器的本地内存中(默认行为),并与Session ID关联。容器会负责管理这些内存中的 Session(比如超时后自动销毁),开发者无需手动操作存储位置。
注意
-
访问静态资源时不会创建 session
-
服务器会把长时间没有活动的 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分钟) -
Tomcat 7以上的版本中默认禁止客户端脚本读取session Id,需要在context.xml中设置useHttpOnly=”false”,开启权限
// sring boot中采用下面这种方式 server.servlet.session.cookie.http-only=false -
通常,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的工作原理可以分为以下几个步骤:
-
生成Token:当用户登录时,服务器会生成一个JWT,将用户的身份信息和其他必要的声明存储在Token的Payload部分。服务器使用指定的算法和密钥对Token进行签名,生成最终的JWT。
-
发送Token:服务器将生成的JWT发送给客户端,客户端通常将Token存储在本地存储(如localStorage或sessionStorage)中。
-
验证Token:在后续的请求中,客户端将JWT放在HTTP请求头中发送给服务器。服务器接收到Token后,会解析Token的Header和Payload,并使用相同的算法和密钥对Token进行签名验证。如果验证通过,服务器会认为用户是合法的,并处理请求。
-
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:accessToken和refreshToken。accessToken用于请求认证,refreshToken用于在accessToken即将过期时由客户端携带refreshToken主动向服务端发出请求,获取新的accessToken。
优点
- 用户体验好:用户不需要频繁重新登录,因为
refreshToken可以在accessToken过期时用于获取新的accessToken,从而延长用户的登录状态。 - 平滑的会话管理:用户在使用应用时,不会因为Token过期而中断操作,提升了整体的用户体验。
- 安全性高
- Token分层保护:
accessToken用于日常请求认证,refreshToken用于获取新的accessToken。即使accessToken被盗用,攻击者也无法利用refreshToken获取新的accessToken,因为**refreshToken通常有更严格的保护措施**。 refreshToken的安全性增强:refreshToken通常具有更长的有效期,但可以通过限制其使用频率、次数和设备绑定等方式增强安全性。例如,refreshToken可以与用户的设备指纹绑定,只有在授权设备上才能使用。- 快速撤销:如果发现
refreshToken被盗用,可以立即撤销该refreshToken,防止进一步的滥用。
- Token分层保护:
缺点
- 实现复杂性增加:需要管理
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的基本配置,包括accessToken和refreshToken。 -
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策略,登录成功后,之后的每次请求都需要accessToken和refreshToken@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); } -
模拟登出接口,登出接口将
accessToken和refreshToken添加到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); }说明:
-
刷新接口用于生成新的
accessToken和refreshToken。 -
旧的Token被加入黑名单,防止重复使用。
-
五、进阶与展望
-
进阶
可以将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 } } -
展望
上述的策略与解决办法不仅适用于单体架构,同样也适用于微服务架构。在微服务架构中,每个服务可能需要独立管理自己的Token策略,而策略模式可以很好地支持这种需求。通过策略模式封装Token的生命周期管理,不仅可以提高代码的复用性和可维护性,还可以在微服务架构中实现灵活、安全、可扩展的Token管理。这种模式适用于多种架构,能够满足不同场景下的Token管理需求。
更多推荐
所有评论(0)