前言
我的项目一用户登录时基于JWT
登录的,如果有对JWT
不了解的同学可以看我之前总结的这篇文章!
下面我来结合项目中的代码总结一下项目中登录流程的实现
登录
login入口
生成token
private String generateToken(LoginUserDTO detail) {
// 2.2.生成access-token
String token = jwtTool.createToken(detail);
// 2.3.生成refresh-token,将refresh-token的JTI 保存到Redis
String refreshToken = jwtTool.createRefreshToken(detail);
// 2.4.将refresh-token写入用户cookie,并设置HttpOnly为true
int maxAge = BooleanUtils.isTrue(detail.getRememberMe()) ?
(int) JwtConstants.JWT_REMEMBER_ME_TTL.toSeconds() : -1;
WebUtils.cookieBuilder()
.name(detail.getRoleId() == 2 ? JwtConstants.REFRESH_HEADER : JwtConstants.ADMIN_REFRESH_HEADER)
.value(refreshToken)
.maxAge(maxAge)
.httpOnly(true)
.build();
return token;
}
为什么是双token
单token的局限性
在用户端和管理员端,登录成功后会生成jwt的token,前端将此token保存起来,当请求后端服务时,在请求头中携带此token,服务端需要对token进行校验以及鉴权操作,这种模式就是【单token模式】。
该模式存在什么问题吗?
-
其实是有问题的,主要是token有效期设置长短的问题,
如果设置的比较短,用户会频繁的登录
; -
如果设置的
比较长,会不太安全
,因为token一旦被黑客截取的话,就可以通过此token与服务端进行交互了。 -
另外一方面,
token是无状态
的,也就是说,服务端一旦颁发了token就无法让其失效(除非过了有效期),这样的话,如果我们检测到token异常也无法使其失效,所以这也是无状态token存在的问题。
为了解决此问题,我们将采用 【双token三验证】 的解决方案来解决此问题。
双token三验证
实现这个方案主要解决的问题如下:
token有效期长不安全
-
登录成功后,生成2个token,分别是:
access_token
、refresh_token
,前者有效期短(如:5分钟),后者的有效期长(如:24小时) -
正常请求后端服务时,携带access_token,如果发现access_token失效,就通过refresh_token到后台服务中换取新的access_token和refresh_token,这个可以理解为
token的续签
-
以此往复,直至
refresh_token
过期,需要用户重新登录
token的无状态性
-
为了使token有状态,也就是后端可以控制其提前失效,需要将refresh_token设计成只能使用一次
-
需要将
refresh_token
存储到redis中,并且要设置过期时间 -
这样的话,服务端如果检测到用户token有安全隐患(如:异地登录),只需要将
refresh_token
失效即可
实现流程
双token是如何生成的?
/**
* 创建 access-token
*
* @param userDTO 用户信息
* @return access-token
*/
public String createToken(LoginUserDTO userDTO) {
// 1.生成jws
return JWT.create()
.setPayload(JwtConstants.PAYLOAD_USER_KEY, userDTO)
.setExpiresAt(new Date(System.currentTimeMillis() + JWT_TOKEN_TTL.toMillis()))
.setSigner(jwtSigner)
.sign();
}
/**
* 创建刷新token,并将token的JTI记录到Redis中
*
* @param userDetail 用户信息
* @return 刷新token
*/
public String createRefreshToken(LoginUserDTO userDetail) {
// 1.生成 JTI
String jti = UUID.randomUUID().toString(true);
// 2.生成jwt
// 2.1.如果是记住我,则有效期7天,否则30分钟
Duration ttl = BooleanUtils.isTrue(userDetail.getRememberMe()) ?
JwtConstants.JWT_REMEMBER_ME_TTL : JWT_REFRESH_TTL;
// 2.2.生成token
String token = JWT.create()
.setJWTId(jti)
.setPayload(JwtConstants.PAYLOAD_USER_KEY, userDetail)
.setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
.setSigner(jwtSigner)
.sign();
// 3.缓存jti,有效期与token一致,过期或删除JTI后,对应的refresh-token失效
stringRedisTemplate.opsForValue()
.set(JwtConstants.JWT_REDIS_KEY_PREFIX + userDetail.getUserId(), jti, ttl);
return token;
}
生成好双token并且设置好过期时间
刷新token的代码实现
/**
* 解析刷新token
*
* @param refreshToken 刷新token
* @return 解析刷新token得到的用户信息
*/
public LoginUserDTO parseRefreshToken(String refreshToken) {
// 1.校验token是否为空
AssertUtils.isNotNull(refreshToken, AuthErrorInfo.Msg.INVALID_TOKEN);
// 2.校验并解析jwt
JWT jwt;
try {
jwt = JWT.of(refreshToken).setSigner(jwtSigner);
} catch (Exception e) {
throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN, e);
}
// 2.校验jwt是否有效
if (!jwt.verify()) {
// 验证失败
throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
}
// 3.校验是否过期
try {
JWTValidator.of(jwt).validateDate();
} catch (ValidateException e) {
throw new BadRequestException(400, AuthErrorInfo.Msg.EXPIRED_TOKEN);
}
// 4.数据格式校验
Object userPayload = jwt.getPayload(JwtConstants.PAYLOAD_USER_KEY);
Object jtiPayload = jwt.getPayload(JwtConstants.PAYLOAD_JTI_KEY);
if (jtiPayload == null || userPayload == null) {
// 数据为空
throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
}
// 5.数据解析
LoginUserDTO userDTO;
try {
userDTO = ((JSONObject) userPayload).toBean(LoginUserDTO.class);
} catch (RuntimeException e) {
// 数据格式有误
throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
}
// 6.JTI校验
String jti = stringRedisTemplate.opsForValue().get(JwtConstants.JWT_REDIS_KEY_PREFIX + userDTO.getUserId());
if (!StringUtils.equals(jti, jtiPayload.toString())) {
// jti不一致
throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
}
return userDTO;
}
/**
* 清理刷新refresh-token的jti,本质是refresh-token作废
*/
public void cleanJtiCache() {
stringRedisTemplate.delete(JwtConstants.JWT_REDIS_KEY_PREFIX + UserContext.getUser());
}
refreshToken存储到Redis中
token解析
public R<LoginUserDTO> parseToken(String token) {
// 1.校验token是否为空
if(StringUtils.isBlank(token)){
return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
}
JWT jwt = null;
try {
jwt = JWT.of(token).setSigner(jwtSignerHolder.getJwtSigner());
} catch (Exception e) {
return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
}
// 2.校验jwt是否有效
if (!jwt.verify()) {
// 验证失败,返回空
return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
}
// 3.校验是否过期
try {
JWTValidator.of(jwt).validateDate();
} catch (ValidateException e) {
return R.error(EXPIRED_TOKEN_CODE, EXPIRED_TOKEN);
}
// 4.数据格式校验
Object userPayload = jwt.getPayload(PAYLOAD_USER_KEY);
if (userPayload == null) {
// 数据为空
return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN_PAYLOAD);
}
// 5.数据解析
LoginUserDTO userDTO;
try {
userDTO = ((JSONObject)userPayload).toBean(LoginUserDTO.class);
} catch (RuntimeException e) {
// token格式有误
return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN_PAYLOAD);
}
// 6.返回
return R.ok(userDTO);
}
公钥私钥
@Data
@Slf4j
public class JwtSignerHolder {
private volatile JWTSigner jwtSigner;
private DiscoveryClient discoveryClient;
public JwtSignerHolder(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}
private final ExecutorService ses = new ThreadPoolExecutor(
1,
1,
10,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
r -> new Thread(r, "AuthFetchJwkThread")
);
@PostConstruct
public void init(){
// 尝试获取jwk秘钥
ses.submit(new MarkedRunnable(new JwkTask(discoveryClient)));
}
public void shutdown(){
ses.shutdown();
log.debug("销毁加载秘钥线程 AuthFetchJwkThread");
}
public static void sleep(long time){
try {
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
class JwkTask implements Runnable{
private final DiscoveryClient discoveryClient;
public JwkTask(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}
@Override
public void run() {
while (jwtSigner == null) {
try {
log.info("尝试加载auth服务地址");
List<ServiceInstance> instances = discoveryClient.getInstances("auth-service");
if(CollUtils.isEmpty(instances)){
log.error("加载auth服务地址失败,原因:数据为空");
sleep(10000);
continue;
}
ServiceInstance instance = instances.get(0);
String jwkUri = String.format("http://%s:%d/jwks", instance.getHost(), instance.getPort());
log.info("加载auth服务地址成功,{}", jwkUri);
log.info("尝试加载jwk秘钥");
// 请求获取jwk
String result = HttpUtil.get(jwkUri, StandardCharsets.UTF_8);
if(result == null){
log.error("加载jwk秘钥失败,原因:数据为空");
sleep(10000);
continue;
}
// 解析
PublicKey publicKey = KeyUtil.generatePublicKey(
AsymmetricAlgorithm.RSA_ECB_PKCS1.getValue(),
SecureUtil.decode(result)
);
jwtSigner = JWTSignerUtil.createSigner(JwtConstants.JWT_ALGORITHM, publicKey);
log.info("加载jwk秘钥成功!");
} catch (Exception e) {
log.error("加载jwk秘钥失败,原因:{}", e.getMessage());
sleep(10000);
}
}
// 关闭线程池
shutdown();
}
}
}
代码解析
这段代码定义了一个用于处理 JWT 签名的类 `JwtSignerHolder`,它在初始化时从服务中获取 JWK(JSON Web Key)秘钥,并且提供了关闭线程池和休眠的方法。以下是对代码中每个变量和方法的详细解释:
1. 变量和字段
-
private volatile JWTSigner jwtSigner;
jwtSigner
是一个JWTSigner
对象,用于签署 JWT。使用volatile
关键字确保线程间的可见性和及时更新。
-
private DiscoveryClient discoveryClient;
discoveryClient
是一个用于服务发现的客户端,通常用于从服务注册中心(如 Eureka)获取服务实例信息。
-
private final ExecutorService ses
ses
是一个ExecutorService
实例,用于异步执行任务。这里使用的是ThreadPoolExecutor
,配置了线程池的参数。
2. 方法
-
public JwtSignerHolder(DiscoveryClient discoveryClient)
- 构造函数,接收一个
DiscoveryClient
对象,并初始化类中的discoveryClient
字段。
- 构造函数,接收一个
-
@PostConstruct public void init()
@PostConstruct
注解的方法在构造函数之后被调用。这里的init
方法提交了一个JwkTask
任务到线程池中,开始加载 JWK 秘钥。
-
public void shutdown()
- 关闭线程池
ses
。调用shutdown
方法将停止接受新任务,并关闭现有的任务。使用log.debug
记录关闭线程池的日志。
- 关闭线程池
-
public static void sleep(long time)
- 使当前线程休眠指定的时间(以毫秒为单位)。这是一个静态方法,用于在任务执行中暂停一定时间,以避免过于频繁地请求服务。
3. 内部类和方法
class JwkTask implements Runnable
-
JwkTask
是一个实现了Runnable
接口的内部类,负责从服务中获取 JWK 秘钥的任务。 -
private final DiscoveryClient discoveryClient
- 这是
JwkTask
类中的字段,用于保存DiscoveryClient
实例。
- 这是
-
public JwkTask(DiscoveryClient discoveryClient)
- 构造函数,接收
DiscoveryClient
实例并初始化内部字段。
- 构造函数,接收
-
@Override public void run()
- 实现
Runnable
接口的run
方法,主要逻辑是:- 不断尝试获取
auth-service
服务实例。 - 从服务实例中构建 JWK URI。
- 请求 JWK 数据并解析。
- 将 JWK 转换为
PublicKey
对象。 - 创建
JWTSigner
实例。 - 记录日志并处理异常。
- 成功加载秘钥后,关闭线程池。
- 不断尝试获取
- 实现
-
4. 其他工具和库
log
是一个@Slf4j
注解生成的日志记录器,使用log.info
、log.error
、log.debug
等方法记录日志。HttpUtil.get(String url, Charset charset)
是一个用于发送 HTTP GET 请求的工具方法,返回响应结果。KeyUtil.generatePublicKey(...)
和JWTSignerUtil.createSigner(...)
是工具方法,用于生成公钥和创建 JWT 签名器。SecureUtil.decode(String data)
是一个解码工具方法,将编码的数据转换为字节数组。
总结来说,这段代码的核心功能是从服务中动态获取 JWK 秘钥并创建 JWT 签名器,同时处理可能发生的异常并记录相关日志。
扩展一:在这个类中,JwkTask
类是如何在获取到正确的JWK秘钥后决定关闭线程池的?
在 JwtSignerHolder
类中,JwkTask
类的 run
方法负责从服务中获取 JWK 秘钥并创建 JWTSigner
实例。以下是详细解释如何在获取到正确的 JWK 秘钥后决定关闭线程池:
1. JwkTask
类的 run
方法
@Override
public void run() {
while (jwtSigner == null) {
try {
log.info("尝试加载auth服务地址");
List<ServiceInstance> instances = discoveryClient.getInstances("auth-service");
if(CollUtils.isEmpty(instances)){
log.error("加载auth服务地址失败,原因:数据为空");
sleep(10000);
continue;
}
ServiceInstance instance = instances.get(0);
String jwkUri = String.format("http://%s:%d/jwks", instance.getHost(), instance.getPort());
log.info("加载auth服务地址成功,{}", jwkUri);
log.info("尝试加载jwk秘钥");
// 请求获取jwk
String result = HttpUtil.get(jwkUri, StandardCharsets.UTF_8);
if(result == null){
log.error("加载jwk秘钥失败,原因:数据为空");
sleep(10000);
continue;
}
// 解析
PublicKey publicKey = KeyUtil.generatePublicKey(
AsymmetricAlgorithm.RSA_ECB_PKCS1.getValue(),
SecureUtil.decode(result)
);
jwtSigner = JWTSignerUtil.createSigner(JwtConstants.JWT_ALGORITHM, publicKey);
log.info("加载jwk秘钥成功!");
} catch (Exception e) {
log.error("加载jwk秘钥失败,原因:{}", e.getMessage());
sleep(10000);
}
}
// 关闭线程池
shutdown();
}
2. 详细解释
-
循环直到
jwtSigner
不为null
while (jwtSigner == null)
:这个循环会一直运行,直到jwtSigner
被成功初始化(即不为null
)。这是为了确保在 JWK 秘钥成功加载之前,线程不会退出。
-
获取服务实例
List<ServiceInstance> instances = discoveryClient.getInstances("auth-service")
:尝试从DiscoveryClient
获取auth-service
服务实例列表。- 如果服务实例为空,记录错误日志并休眠 10 秒,然后继续尝试获取服务实例。
-
构建 JWK URI
String jwkUri = String.format("http://%s:%d/jwks", instance.getHost(), instance.getPort())
:使用服务实例的主机和端口构建 JWK 的 URI。
-
请求 JWK 秘钥
String result = HttpUtil.get(jwkUri, StandardCharsets.UTF_8)
:发送 HTTP GET 请求以获取 JWK 数据。- 如果获取的数据为空,记录错误日志并休眠 10 秒,然后继续尝试获取 JWK 秘钥。
-
解析 JWK 秘钥
PublicKey publicKey = KeyUtil.generatePublicKey(...)
:将 JWK 数据解析为PublicKey
对象。jwtSigner = JWTSignerUtil.createSigner(JwtConstants.JWT_ALGORITHM, publicKey)
:使用解析出的公钥创建JWTSigner
实例,并将其赋值给jwtSigner
。
-
成功加载 JWK 秘钥
- 一旦
jwtSigner
被成功初始化,记录成功日志,并退出循环。
- 一旦
-
关闭线程池
shutdown()
:在成功初始化jwtSigner
后,调用shutdown
方法来关闭线程池。这是因为任务已经完成,不再需要继续运行线程池中的线程。
3. shutdown
方法
public void shutdown(){
ses.shutdown();
log.debug("销毁加载秘钥线程 AuthFetchJwkThread");
}
-
关闭线程池
ses.shutdown()
:调用shutdown
方法来停止线程池接受新任务,并尝试完成当前正在执行的任务。这里的线程池是单线程的,因此它会在JwkTask
任务完成后关闭。
-
记录日志
log.debug("销毁加载秘钥线程 AuthFetchJwkThread")
:记录关闭线程池的调试信息。
总结
在 JwkTask
类的 run
方法中,当 jwtSigner
成功初始化后,线程会退出循环。此时,调用 shutdown
方法关闭线程池,释放资源。这个设计确保了当 JWK 秘钥加载成功后,不再需要继续运行线程池,从而提高了资源的利用效率。
扩展(二):在JwtSignerHolder类中,使用线程池进行异步任务处理的主要目的是什么?
异步加载 JWK 秘钥
非阻塞操作:使用线程池来异步加载 JWK 秘钥可以避免在主线程中进行阻塞操作。加载秘钥的过程可能涉及网络请求、解析等耗时操作,如果在主线程中进行这些操作,会影响整个应用的响应性和性能。
后台处理:通过将 JWK 秘钥加载任务放在一个单独的线程中,可以使主线程继续进行其他操作,而不被加载秘钥的过程阻塞。这确保了应用的其他部分(如处理用户请求)能够继续正常工作。
以上就是我对我项目中登录模块的鉴权总结。其中也有用户权限设计模块。稍后继续总结先参考之前的一篇文章; 后台管理系统的权限控制设计
谢谢大家!
评论( 0 )