1. 什么是JWT
OAuth2.0 体系中令牌分为两类,分别是透明令牌、不透明令牌。
不透明令牌则是令牌本身不存储任何信息,比如一串UUID,因此资源服务拿到这个令牌必须调调用认证授权服务的接口进行令牌的校验,高并发的情况下延迟很高,性能很低。
透明令牌本身就存储这部分用户信息,比如JWT,资源服务可以调用自身的服务对该令牌进行校验解析,不必调用认证服务的接口去校验令牌。
JWT 有三部分组成,分别是头部、载荷、签名,如下:
1. Header(头部)
Header 包含两部分信息:
- typ: 表示令牌类型,通常是 “JWT”。
- alg: 表示使用的签名算法,例如 HS256(HMAC-SHA256)或 RS256(RSA-SHA256)。
{
"alg": "HS256",
"typ": "JWT"
}
这个头部数据会被 Base64Url 编码。
2. Payload(负载)
Payload 是存放实际需要传输的信息(声明,claims)的部分。声明分为三种类型:
- Registered Claims:预定义的标准声明,非强制。常见的声明包括:
- iss(Issuer):签发人
- sub(Subject):主题
- aud(Audience):受众
- exp(Expiration):过期时间
- iat(Issued At):签发时间
- Public Claims:自定义的声明,但需要避免冲突,通常使用 URI 作为命名空间。
- Private Claims:应用中定义的声明,仅供双方使用。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"exp": 1618474796
}
负载部分同样会经过 Base64Url 编码。
3. Signature(签名)
Signature 是用来验证消息在传输过程中是否被篡改的部分。它由以下三部分组成:
- 编码后的头部
- 编码后的负载
- 一个密钥(用于对称加密,或使用私钥/公钥对,非对称加密)
在 JWT 的签名部分中,密钥本身并不包含在签名中。签名使用密钥进行生成,但密钥本身不会直接嵌入到 JWT 令牌中。这是出于安全考虑,因为密钥是用来验证令牌合法性的关键,而不是传递给客户端的。
将上述三部分通过指定算法进行签名计算。
生成签名的公式:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
签名的生成使用了密钥(例如一个 secret key 或 private key),但这个密钥仅用于服务器端的签名生成和验证,客户端永远不会看到这个密钥。
- 如果使用对称加密(如 HS256),同一个密钥用于签名和验证。
- 如果使用非对称加密(如 RS256),私钥用于签名,公钥用于验证。
- 生成的签名将与头部和负载部分一起传输,用于验证令牌的完整性。
签名的作用:
- 验证数据的完整性:确保 JWT 在传输过程中未被篡改。客户端发送的 JWT 如果使用对称密钥加密的话,可以在服务器端使用同样的密钥验证,如果使用非对称密钥加密的话,客户端使用公钥进行解密验证。
- 确认发送者的身份:签名表明令牌确实是由持有密钥的实体生成的,防止伪造。
4. JWT 结构示例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT 的优势在于它是自包含的,包含了验证和信息传递所需的所有内容,不需要服务器在每个请求时进行存储,因此非常适合分布式系统中的身份认证。
5. 使用阿里云kms对JWT进行签名(非对称方式)
在阿里云 KMS 控制台上创建一个密钥,记录下密钥 ID。
私钥用于服务器端签名,公钥用于客户端验证签名。私钥通常不会被直接获取,而是在签名操作中使用 KMS API。
1. 使用 KMS 进行 JWT 签名
@Service
public class KmsJwtService {
private final String regionId = "YOUR_REGION_ID";
private final String accessKeyId = "YOUR_ACCESS_KEY_ID";
private final String accessKeySecret = "YOUR_ACCESS_KEY_SECRET";
private final String keyId = "YOUR_KMS_KEY_ID"; // KMS 密钥 ID
private DefaultAcsClient client;
public KmsJwtService() {
DefaultProfile profile = DefaultProfile.getProfile(regionId, accessKeyId, accessKeySecret);
client = new DefaultAcsClient(profile);
}
public String createToken(String subject) throws ClientException {
// JWT 内容
String jwtContent = Jwts.builder()
.setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1 小时有效期
.compact();
// 使用 KMS 对 JWT 内容进行签名
SignRequest signRequest = new SignRequest();
signRequest.setKeyId(keyId);
signRequest.setMessage(jwtContent.getBytes());
signRequest.setSigningAlgorithm("SM2"); // 或 "RSA"
SignResponse signResponse = client.getAcsResponse(signRequest);
return jwtContent + "." + signResponse.getSignature(); // 返回 JWT + 签名
}
public boolean validateToken(String token) throws ClientException {
String[] parts = token.split("\\.");
if (parts.length != 2) {
return false; // JWT 格式不正确
}
String jwtHeaderAndPayload = parts[0]; // 头部和载荷
String signature = parts[1]; // 签名部分
// 使用阿里云 KMS 验证签名
VerifyRequest verifyRequest = new VerifyRequest();
verifyRequest.setKeyId(keyId); // KMS 密钥 ID
verifyRequest.setMessage(jwtHeaderAndPayload.getBytes()); // 待验证的内容
verifyRequest.setSignature(Base64.getDecoder().decode(signature)); // 解码后的签名
verifyRequest.setSigningAlgorithm("RSA"); // 签名算法,可根据实际情况设置
// 调用 KMS API
VerifyResponse verifyResponse = client.getAcsResponse(verifyRequest);
return verifyResponse.getIsValid(); // 返回验证结果
}
}
公钥和私钥管理:使用 KMS 时,通常不直接获取私钥,签名时通过 API 调用 KMS 进行。
签名算法:选择合适的非对称算法(如 RSA 或 SM2),并根据需要配置 KMS。
安全性:确保密钥和 API 的安全配置,避免泄露。
在阿里云 KMS 中,公钥的使用与验签过程是通过 KMS API 的 VerifyRequest 来实现的,而不是直接在代码中传递公钥。这是因为阿里云 KMS 设计成一种安全的密钥管理服务,签名和验签的过程是通过调用 KMS API 完成的,因此在上面的验签代码中并没有涉及到公钥的获取,虽然在验签的代码中并没有直接引用公钥,但 KMS 会内部处理密钥的使用,这种设计确保了密钥不会直接暴露,增强了安全性。
使用了阿里云,就没有必要再导出公钥了。
验签过程说明
JWT 结构:JWT 由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。验证时,您需要使用头部和载荷的信息生成待验证的内容。
获取待验证内容:在 validateToken 方法中,首先将 JWT 分成三部分,提取出前两部分组成待验证的内容(jwtContent)。
使用 KMS 验证签名:
- VerifyRequest 使用待验证内容和签名部分(解码后)来调用 KMS。
- KMS 会根据密钥 ID 和传入的待验证内容、签名来验证签名的有效性。
6. JwtAccessTokenConverter 令牌增强类
因为在生产环境中使用阿里云 KMS 进行 JWT 的签名和验签,那么 JwtAccessTokenConverter 在这种场景下的使用就不是必需的。
原因如下:
1. 私钥管理:
- 在阿里云 KMS 中,私钥是安全存储的,无法直接访问或导出。因此,不能使用 JwtAccessTokenConverter 直接处理私钥。
2. 验签过程:
- 验签操作通过 KMS 提供的 API 完成,您只需发送待验证的内容和签名,而不需要手动处理公钥。
3. 简化流程:
- 通过 KMS 的 API,您可以直接进行签名和验签,避免了使用 JwtAccessTokenConverter 带来的复杂性。这意味着您可以使用更简洁的方式实现 JWT 的签名和验证逻辑。
总结
在使用阿里云 KMS 进行 JWT 签名和验签的情况下,您可以省略 JwtAccessTokenConverter,而是直接调用 KMS API 来完成这些操作。这样不仅减少了代码复杂性,还提高了安全性,因为密钥管理由 KMS 负责。
2. JWT 的存储
在JWT(JSON Web Token)架构中,通常不需要将ID Token和Access Token存储在Redis中,原因如下:
JWT是自包含的:它们包含了必要的信息(如sub字段中的用户ID、exp过期时间、权限范围等)。通过JWT的签名机制,服务器可以在不存储Token的情况下验证它的有效性。
Token的无状态性:JWT在无状态分布式系统中非常有用,服务器只需验证Token签名,无需在后端数据库或缓存中存储Token。
1. 什么时候需要存储在redis中
尽管JWT是无状态的,有些情况下出于安全、控制、审计或撤销Token的需求,可能需要将ID Token和Access Token存储在Redis中。以下是一些场景:
- Token撤销机制:如果系统需要在特定情况下(如用户注销、异常活动检测等)主动撤销某些Token,则可以在Redis中存储Token,并在需要时将其从Redis中删除或标记为无效。JWT本身是无状态的,一旦签发,直到过期前都有效,因此无法通过无状态的方式主动撤销。如果使用Redis存储Token,可以控制其生命周期。
- 黑名单或Token失效控制:为了实现更细粒度的Token控制(如在用户修改密码或注销后立即失效Token),可以通过Redis维护一个黑名单或失效列表。如果某个Token被标记为无效,服务器可以在解析Token时检查Redis来判断其有效性。
- Refresh Token旋转机制:虽然Access Token和ID Token一般不需要存储,但如果启用了Refresh Token旋转(每次使用Refresh Token刷新时生成新的Token),可能需要在Redis中存储旧的Access Token和ID Token以防止其被继续使用,尤其是当希望在生成新的Token后让旧Token失效时。
2. ID Token和Access Token存储的潜在问题
- 增加服务器负担:JWT的无状态性让服务器端免去了管理Token状态的负担。如果将Token存储在Redis中,会增加额外的管理复杂性和资源开销。
- 安全性问题:存储Token可能会引入额外的安全风险,尤其是在没有合适的加密和保护机制时,Token存储可能会成为攻击目标。
- 性能影响:频繁查询Redis来验证Token的状态可能影响性能,尤其是在高并发的系统中。
5. 实际应用中的建议
- 无状态实现优先:在大多数情况下,利用JWT的无状态特性就足够了,无需将Access Token和ID Token存储在Redis中。只要保证签名正确、Token未过期,服务器可以无状态地处理认证和授权。
- 引入状态控制的场景:在有特殊需求的场景下(如Token撤销、黑名单、主动失效等),可以考虑将Token存储在Redis中,并搭配其他机制(如RefreshToken的轮换机制、Token的撤销机制)来增强安全性。
因此,除非你有特定的安全需求需要控制Token的状态或失效,否则不建议将JWT的Access Token和ID Token存储在Redis中,尽量利用JWT的无状态设计来减少系统的复杂度。
6. Refresh Token的存储
在JWT(JSON Web Token)中,Refresh Token放在Redis中保存是一个非常常见且推荐的做法。
1. 使用Redis存储Refresh Token的优点
高性能:
Redis是一个内存数据库,读写速度非常快,适合频繁的Token验证和更新操作。
在高并发场景下,Redis能够快速响应请求,提高系统的整体性能。
自动过期管理:
Redis支持设置键的过期时间,可以轻松地管理Refresh Token的生命周期。
当Refresh Token过期时,Redis会自动将其删除,减轻开发者手动管理过期Token的负担。
灵活性和可扩展性:
Redis可以水平扩展,适合分布式微服务架构,可以方便地在不同的微服务实例间共享Token信息。
支持高可用性和数据持久化,可以在服务器重启后保留Token。
支持Token撤销:
可以方便地实现Token的撤销机制,例如当用户注销时,直接从Redis中删除对应的Refresh Token。
2. 使用Redis存储Refresh Token的最佳实践
安全存储:
Refresh Token应通过HTTPS传输,确保在网络传输过程中的安全性。
在Redis中存储Refresh Token时,尽量避免将其明文存储,可以考虑使用加密算法进行加密后存储。
Token过期时间:
根据业务需求合理设置Refresh Token的过期时间(例如7天、30天等),并在Redis中设置相应的过期时间。
Token轮换:
每次使用Refresh Token成功刷新Access Token时,生成一个新的Refresh Token并更新Redis中的记录,以实现Token轮换机制。
访问控制:
确保对Redis的访问进行适当的控制,限制只有经过授权的服务或用户才能访问和操作Refresh Token。
监控与审计:
监控Redis的使用情况,定期审计Token的使用和访问记录,以发现潜在的安全问题。
3. 实际应用流程
生成Refresh Token:用户登录后,服务器生成Refresh Token,并将其存储在Redis中,设置相应的过期时间。
验证Refresh Token:当Access Token过期时,客户端使用Refresh Token向服务器请求新的Access Token。
更新Redis中的Refresh Token:如果Refresh Token有效,服务器生成新的Access Token和(可选的)新的Refresh Token,并更新Redis中的记录。
自动过期管理:Redis自动管理Refresh Token的过期,避免过期Token的滥用。
在分布式系统中,将JWT的Refresh Token存储在Redis中是一个高效且安全的做法。这种方式能够充分利用Redis的高性能、自动过期和灵活性,帮助开发者更好地管理Token的生命周期和用户会话。确保在实现中遵循安全最佳实践,可以显著提高系统的安全性和可靠性。
这里需要注意的是:Refresh Token本身是有过期时间字段的,在判断其是否过期的时候需要进行双重判断,先判断redis中是否过期,然后再判断本身是否过期。
3. 密码重置
1. 根据邮箱做密码重置
1. 用户请求重置密码
用户输入邮箱:提供一个界面,让用户输入他们的注册邮箱地址以请求重置密码。
发送重置请求:接收到请求后,生成一个唯一的重置密码令牌(JWT token,使用阿里云非对称加密),并将其存储在redis中(指定过期时间)。
2. 发送重置邮件
构建重置链接:使用生成的令牌创建一个重置链接,链接中包含token,例如 https://yourapp.com/reset-password?token=your_generated_token。
发送邮件:通过邮件服务将重置链接发送到用户的邮箱,邮件中应包含重置链接和相关说明。
3. 用户点击重置链接
验证令牌:用户点击链接后,系统根据token查找redis,验证其有效性和是否过期。
重置页面:如果token有效,呈现重置密码的页面,要求用户输入新密码。
4. 更新密码
接收新密码:用户输入新密码并提交表单。
验证和加密:对新密码进行验证(如长度、复杂度),然后使用加密算法(如BCrypt)加密新密码。
更新数据库:将新密码更新到用户的记录中,并清除redis中已使用的token。
5. 反馈用户
成功通知:重置密码成功后,向用户发送确认消息(可以是邮件或在界面上显示)。
安全措施:跳转到登录页面,在成功重置密码后要求用户重新登录,以确保安全性。
2. 根据电话号码做密码重置
1. 用户请求密码重置
用户在登录页面或者密码找回页面输入其电话号码,点击“忘记密码”按钮。
2. 验证电话号码
微服务通过电话号验证用户是否存在。可以通过查询用户服务数据库来确认该电话号码是否与某个用户相关联。
3. 发送验证码
如果电话号码验证通过,系统会生成一个验证码,并通过阿里云短信服务发送给用户。
验证码生成:可以使用随机数生成器来创建一个6位或更多位数的验证码,并将其与用户记录绑定,放在redis中并指定过期时间,此举可以控制发送验证码的频率,如果在redis数据库中查询到有对应用户的验证,则不必再发短信,直到验证码过期。
4. 用户输入验证码
用户收到验证码后,在前端页面输入验证码。前端将验证码和电话号码提交给后端微服务进行验证。
验证码验证服务:验证服务检查用户输入的验证码是否与发送的验证码匹配,验证是否过期,通过后,删除验证码。
5. 重置密码
验证码验证成功后,允许用户输入新密码,并将新密码保存到数据库。
密码更新服务:负责加密新密码(如使用 Bcrypt 或其他加密算法),并将加密后的密码更新到用户表中。
4. 处理用户登出、修改密码、注销问题
1. 用户登出时的 Token 处理
在用户登出时,通常需要立即撤销或失效当前的所有 Token,确保用户无法继续使用旧的 Token 访问系统。
处理 Access Token
JWT Access Token 一旦发放,在未到期前无法主动撤销。因此,为了在用户登出时阻止 Access Token 继续使用,可以使用黑名单机制:
黑名单机制:在 Redis 中维护一个黑名单,当用户登出时,将其当前的 Access Token 的 jti(Token ID)或用户 ID 存储在黑名单中,每次请求时都验证该 Token 是否在黑名单中。
设置过期时间:黑名单中的 Token 过期时间应设置为与 Access Token 的有效期一致。
public void logout(String token) {
String tokenId = jwtService.getTokenId(token);
redisService.set("blacklist:" + tokenId, "true", tokenExpirationTime);
}
public boolean isTokenBlacklisted(String token) {
String tokenId = jwtService.getTokenId(token);
return redisService.exists("blacklist:" + tokenId);
}
处理 Refresh Token
删除 Refresh Token:Refresh Token 一般存储在 Redis 中,在用户登出时,直接从 Redis 中删除该 Refresh Token,防止用户利用它刷新新的 Access Token。
public void deleteRefreshToken(String userId) {
redisService.delete("refreshToken:" + userId);
}
处理 ID Token
ID Token 一般不需要特殊处理:ID Token 主要用于用户认证,并且通常在用户登录时短期有效。ID Token 通常不会用于进一步的授权,所以登出时不需要特别处理它,它会在到期时自动失效。
OAuth2.0 提供商的登出 API
如果使用第三方 OAuth2.0 提供商(如 Keycloak、Okta、Auth0),可以调用其提供的登出 API 来撤销 Access Token 和 Refresh Token。
2. 修改密码时的 Token 处理
当用户修改密码时,需要确保旧的 Token 失效,以避免旧的 Token 在密码修改后还能继续使用,特别是 Access Token 和 Refresh Token。
处理 Access Token
强制失效所有当前登录的 Access Token:可以将所有与该用户相关的 Access Token 加入黑名单,或者在密码修改后刷新用户的权限和状态。
获取当前用户的所有 Active Token(可以通过 Redis 来存储与用户相关的 Token 列表)。
将所有这些 Token 加入黑名单。
public void invalidateUserTokens(String userId) {
List<String> tokenIds = redisService.getUserTokens(userId);
tokenIds.forEach(tokenId -> redisService.set("blacklist:" + tokenId, "true", tokenExpirationTime));
}
处理 Refresh Token
删除所有与该用户相关的 Refresh Token:确保密码修改后无法使用旧的 Refresh Token 生成新的 Access Token。可以在 Redis 中查询与该用户相关的 Refresh Token,并逐一删除。
public void deleteUserRefreshTokens(String userId) {
redisService.delete("refreshToken:" + userId);
}
重新登录或强制刷新 Token
修改密码后,可以要求用户重新登录,生成新的 ID Token、Access Token 和 Refresh Token,确保安全性。
3. 用户注销时的 Token 处理
用户注销(Deactivation)通常意味着用户账户永久关闭或禁用,这不同于登出,它要求销毁或撤销与用户相关的所有认证凭证。
处理 Access Token
使所有与该用户相关的 Access Token 失效:使用黑名单机制或者直接从 Redis 中删除与用户相关的所有 Access Token。
处理 Refresh Token
删除与用户相关的所有 Refresh Token:确保用户注销后,任何旧的 Refresh Token 都无法用于刷新新的 Access Token。
ID Token 不需要处理
用户注销后,ID Token 会失效,因此通常不需要特别处理它。
彻底清理会话和相关数据
删除用户的所有会话信息:在 Redis 或数据库中存储的任何与用户相关的会话、角色、权限缓存等信息都应被删除,确保用户注销后无法继续访问系统。
public void deactivateUser(String userId) {
// 删除所有与用户相关的 Refresh Token
deleteUserRefreshTokens(userId);
// 使 Access Token 失效
invalidateUserTokens(userId);
// 清理会话数据
redisService.delete("userSession:" + userId);
}
5. 无缝刷新JWT Token
在 Spring Cloud 微服务架构中,使用 JWT 进行用户认证时,Access Token 通常设置较短的有效期,而 Refresh Token 则用于在 Access Token 过期时生成新的 Token,以保证用户不需要重新登录。要实现 Access Token 和 ID Token 的无缝刷新,并确保客户端无感知,可以遵循以下流程和策略。
要实现无缝刷新 Access Token 和 ID Token 的功能,关键点在于:
1. 自动刷新 Token:当 Access Token 快要过期时,服务器端自动使用 Refresh Token 刷新,并重新生成新的 Access Token 和 ID Token。
2. 客户端无感知:客户端无需主动刷新 Token,而是在发送请求时,系统自动判断 Access Token 的有效性,必要时自动刷新 Token。
3. 最小化刷新延迟:确保在每个 API 请求中,如果 Access Token 即将过期或已过期,系统会透明地处理 Token 刷新并将新的 Access Token 返回给客户端。
1. 自动刷新 Token 的机制
在系统微服务的网关或拦截器中实现 Token 的自动刷新逻辑。
1. 在网关或过滤器中检查 Access Token 是否快要过期
- 每次客户端发起请求时,先通过一个拦截器或过滤器来检查 Access Token 的有效性。解析 JWT 中的过期时间(exp 字段),判断是否快要过期或已经过期。
- 如果 Access Token 还有几分钟才过期,则继续使用,不做任何操作。
- 如果 Access Token 已经过期或接近过期,则使用 Refresh Token 刷新。
public class JwtTokenFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = getTokenFromRequest(request);
if (token != null && tokenService.isTokenValid(token)) {
// 检查是否需要刷新 Access Token
if (tokenService.isTokenNearExpiry(token)) {
String refreshToken = getRefreshTokenFromRequest(request);
if (refreshToken != null && tokenService.isRefreshTokenValid(refreshToken)) {
String newAccessToken = tokenService.refreshAccessToken(refreshToken);
response.setHeader("Authorization", "Bearer " + newAccessToken);
}
}
}
filterChain.doFilter(request, response);
}
}
2. Token 刷新逻辑
- 当检测到 Access Token 即将过期时,系统使用 Refresh Token 发起刷新请求,生成新的 Access Token 和 ID Token。
- 刷新后的 Access Token 和 ID Token 应返回到客户端,客户端透明地接收新的 Token,并在后续请求中自动使用。
public class TokenService {
public String refreshAccessToken(String refreshToken) {
// 使用 refresh token 调用认证服务器刷新 token
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "refresh_token");
body.add("refresh_token", refreshToken);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);
// 调用认证服务器进行 token 刷新
ResponseEntity<Map> response = restTemplate.exchange("https://auth-server.com/oauth/token", HttpMethod.POST, entity, Map.class);
Map<String, Object> tokenData = response.getBody();
String newAccessToken = (String) tokenData.get("access_token");
// 更新ID Token,如果存在的话
String newIdToken = (String) tokenData.get("id_token");
// 返回新的 access token 或其他 token 数据
return newAccessToken;
}
public boolean isTokenNearExpiry(String token) {
Claims claims = getTokenClaims(token);
Date expiration = claims.getExpiration();
Date now = new Date();
return expiration.getTime() - now.getTime() <= 5 * 60 * 1000; // Access Token 在5分钟内过期
}
public Claims getTokenClaims(String token) {
// 使用 JWT 解析 token
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
}
3. 更新 Token 到客户端
在 Token 刷新成功后,将新的 Access Token 和 ID Token 返回到客户端。通常可以通过 HTTP 头中的 Authorization 返回新的 Access Token,而客户端无需感知 Token 已被刷新。同时更新存储在 redis 中的refresh Token。
4. 刷新失败时处理逻辑
如果 Refresh Token 失效(例如过期或被注销),后端应返回适当的错误码给客户端,提示用户重新登录。
if (!tokenService.isRefreshTokenValid(refreshToken)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Refresh Token expired, please login again.");
return;
}
6. 令牌管理服务配置
JWT令牌常用的存储有两种方式:JWT本身存储和redis存储,不同的存储方式要实现令牌刷新、撤销和权限动态变更功能,方式不一样。
1. JWT 自身携带状态的方案
1. 令牌刷新机制
由于 JWT 是有时间限制的,通常在令牌过期后无法再使用。在这种情况下,使用 Refresh Token 来生成新的 Access Token。流程如下:
- 在用户登录时生成 Access Token 和 Refresh Token,并将它们返回给客户端。
- 当 Access Token 过期时,客户端使用 Refresh Token 请求新的 Access Token。
- 可以通过 JWT 中的 exp(过期时间)来控制 Access Token 和 Refresh Token 的有效期。
2. 令牌撤销机制
由于 JWT 是无状态的,不依赖服务器状态来进行验证,一旦 JWT 被签发,服务器默认无法强制撤销某个令牌。因此,为了实现撤销机制,可以引入 黑名单 概念,将撤销的令牌加入到黑名单中。在验证 JWT 时,除了验证签名,还需要检查该令牌是否在黑名单中。
- 将撤销的 JWT 记录在服务器中(如Redis)。
- 在验证令牌时检查该令牌是否在黑名单中。
3. 权限动态变更
由于 JWT 中的权限是嵌入在令牌中的,如果用户的权限发生了变化,需要一种方式来确保新的权限能够生效。通常有两种方法来处理权限变更:
- 强制刷新令牌:当用户权限发生变更时,强制要求用户重新获取新的令牌。
- 外部权限检查:不依赖 JWT 中的权限信息,而是在每次请求时,从数据库或缓存中动态查询用户权限。
@Service
public class JwtTokenProvider {
public Authentication getAuthentication(String token) {
String username = getUsernameFromToken(token);
// 每次请求时,从数据库动态获取用户权限
List<String> roles = userService.getRoles(username);
return new UsernamePasswordAuthenticationToken(username, null, getAuthorities(roles));
}
private List<GrantedAuthority> getAuthorities(List<String> roles) {
return roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
}
2. Redis 存储方案
在 Redis 中存储 JWT 相关信息时,可以将 JWT 的状态、权限信息等都存储在 Redis 中。这种方案允许在令牌生命周期内灵活处理令牌的刷新、撤销和权限变更。
1. 令牌刷新机制(Refresh Token)
使用 Redis 来存储 Refresh Token 信息,当客户端使用 Refresh Token 请求新的 Access Token 时,检查 Redis 中是否存在有效的 Refresh Token。
实现代码:
- 登录时将 Refresh Token 存储到 Redis 中,并设置其过期时间。
- 当用户请求刷新 Access Token 时,检查 Redis 中的 Refresh Token。
@Service
public class JwtTokenProvider {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void storeRefreshToken(String username, String refreshToken, long expirationTime) {
redisTemplate.opsForValue().set(username + ":refreshToken", refreshToken, expirationTime, TimeUnit.MILLISECONDS);
}
public boolean validateRefreshToken(String username, String refreshToken) {
String storedToken = redisTemplate.opsForValue().get(username + ":refreshToken");
return refreshToken.equals(storedToken);
}
}
2. 令牌撤销机制(Revoke Token)
在使用 Redis 时,您可以将需要撤销的 Access Token 或 Refresh Token 的唯一标识(如 jti,即 JWT ID)存储到 Redis 中。每次验证 JWT 时,检查 Redis 中是否有该令牌已撤销的记录。
实现代码:
- 将撤销的令牌加入 Redis,并设置过期时间。
- 在每次请求时检查 Redis 中是否存在该令牌。
@Service
public class JwtTokenProvider {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void revokeToken(String jti, long expirationTime) {
redisTemplate.opsForValue().set("blacklist:" + jti, "revoked", expirationTime, TimeUnit.MILLISECONDS);
}
public boolean isTokenRevoked(String jti) {
return redisTemplate.hasKey("blacklist:" + jti);
}
public boolean validateToken(String token) {
String jti = getJtiFromToken(token);
return !isTokenRevoked(jti) && validateSignature(token) && !isTokenExpired(token);
}
}
3. 权限动态变更
如果使用 Redis 来存储权限信息,您可以在用户权限变更时,动态更新 Redis 中存储的权限数据。每次请求时,通过 Redis 获取最新的权限,而不是依赖 JWT 中的权限。
实现代码:
@Service
public class JwtTokenProvider {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public Authentication getAuthentication(String token) {
String username = getUsernameFromToken(token);
// 从 Redis 中获取用户最新权限
List<String> roles = getRolesFromRedis(username);
return new UsernamePasswordAuthenticationToken(username, null, getAuthorities(roles));
}
private List<String> getRolesFromRedis(String username) {
String roles = redisTemplate.opsForValue().get(username + ":roles");
return Arrays.asList(roles.split(","));
}
}
总结
- JWT 自身存储状态:这种方式实现简单,依赖 JWT 本身的信息。对于无状态的服务,这种方案是较为常见的实现方式。缺点是撤销令牌、动态权限管理等功能需要通过额外机制(如黑名单)实现。
- Redis 存储方案:通过 Redis 存储 Refresh Token、撤销信息和权限,可以更加灵活地实现令牌管理、权限动态变更等功能。这种方式适合需要较高安全性和灵活管理的场景。