更好的阅读体验 \huge{\color{red}{更好的阅读体验}} 更好的阅读体验
问题分析
以往的项目大部分解决方案为单 token:
- 用户登录后,服务端颁发 jwt 令牌作为 token 返回
- 每次请求,前端携带 token 访问,服务端解析 token 进行校验和鉴权
存在的问题:
- 有效期设置问题:有效期设置需要对时间做平衡,不能太短也不能太长
- 续期问题:一旦过期,用户必须重新登录,很难做无感刷新
- 无状态问题:token 是无状态的,单 token 颁发后服务端无法主动使其失效
原理解析
这里引入双 token 机制:
- accesstoken:时间较短,一般为 5 分钟或者更短
- refreshtoken:时间较长,一般为 1 到 3 天
登录过程:
- 用户携带用户名和密码登录
- 服务端为其颁发 accesstoken 和 refreshtoken
三验证环节:
- 一验证:前端请求携带 accesstoken,验证是否过期,不过期放行,过期则进入第二个验证环节
- 二验证:前端请求携带 refreshtoken,验证是否过期,不过期进入第三个验证环节,过期则要求用户重新登录
- 三验证:在 redis 种验证 refreshtoken 是否存在,存在则颁发新的 accesstoken 和 refreshtoken 返回前端更新,将原来的 refreshtoken 删除,再把新的 refreshtoken 存入 redis
该机制的 uml 图如下:
最佳实践
生成 token
基于 springcache 来操作 redis,利用 md5 算法对 token 进行加密,防止其作为键的后缀存入时过长,导致”大key“的问题出现
public class commonredisconstants {
public static class rediskey {
/**
* refreshtoken 前缀
*/
public static final string refresh_token_prefix = "refresh_token_prefix_%s";
}
}
@resource
private stringredistemplate stringredistemplate;
// 生成 accesstoken
private string createaccesstoken(map<string, object> claims) {
// 这里是利用 jjwt 编写的工具类方法,读者可以自行实现相关工具类
return jwtutils.generateaccesstoken(claims);
}
// 生成 refreshtoken 并存入 redis
private string createrefreshtoken(map<string, object> claims) {
string refreshtoken = jwtutils.generaterefreshtoken(claims);
// rediskey 的形式为固定前缀+md5转换的token
string rediskey = string.format(commonredisconstants.rediskey.refresh_token_prefix, md5util.generatemd5str(refreshtoken));
// 设置有效期为 3 days
this.stringredistemplate.opsforvalue().set(rediskey, refreshtoken, duration.ofdays(3l));
return refreshtoken;
}
校验 token
基于自定义注解和 spring aop 实现校验 token,并将解析后的信息存储到上下文
自定义的注解:
@target(elementtype.method)
@retention(retentionpolicy.runtime)
@documented
public @interface currentuser {
}
aop 切面:
@aspect
@component
@slf4j
public class currentuseraspect {
private final httpservletrequest request;
public currentuseraspect(httpservletrequest request) {
this.request = request;
}
@before("@annotation(currentuser)")
public void setusercontext(currentuser currentuser) {
string token = request.getheader("authorization");
if (token != null) {
try {
// 这里是利用 jjwt 编写的工具类方法,读者可以自行实现相关工具类
claims claims = jwtutils.parsetoken(token);
// 这里是利用 threadlocal 存储用户信息到上下文,读者可以自行实现相关工具类
usercontextutil.set(claims);
} catch (exception e) {
// token 解析失败后的逻辑
}
} else {
// 请求头未携带 token 的逻辑
}
}
// 方法执行完后释放资源,防止内存泄漏
@after("@annotation(currentuser)")
public void clearusercontext(currentuser currentuser) {
usercontextutil.clear();
}
}
刷新 token
前端调用刷新 token 后,服务端返回新的 accesstoken 和 refreshtoken:
@data
@allargsconstructor
public class adminloginvo {
private string accesstoken;
private string refreshtoken;
}
public adminloginvo refreshlogin(string refreshtoken) {
// 校验 token 是否有效
boolean isvalidated = jwtutils.validatetoken(refreshtoken);
if (!isvalidated) {
// token
}
/**
* 校验 redis 里的 refreshtoken 是否失效
* 未失效:将 redis 里的 refreshtoken 删除,重新颁发新的 accesstoken 和 refreshtoken
* 已失效:重新登录
*/
string rediskey = string.format(commonredisconstants.rediskey.refresh_token_prefix, md5util.generatemd5str(jwtutils.predecodetoken(refreshtoken)));
boolean haskey = this.stringredistemplate.haskey(rediskey);
if (objectutil.notequal(haskey, boolean.true)) {
// 原 token 过期或已经使用过的逻辑
}
// 删除原 token
this.stringredistemplate.delete(rediskey);
// 颁发新的 accesstoken 和 refreshtoken
claims claims = jwtutils.parsetoken(refreshtoken);
string accesstoken = createaccesstoken(claims);
refreshtoken = createrefreshtoken(claims);
return new adminloginvo(accesstoken, refreshtoken);
}
发表评论