提醒一下
双Token机制并没有从根本上解决安全性的问题,本文章只是提供一个思路,具体是否选择请大家仔细斟酌考虑,笔者水平有限,非常抱歉对你造成不好的体验。
token有效期设置问题
最近在做用户认证模块的后端功能开发,之前就有一个问题困扰了我好久,就是如何设置token
的过期时间,前端在申请后端登录接口成功之后,会返回一个token
值,存储在用户端本地,用户要访问后端的其他接口必须通过请求头带上这个token
值,但是这个token
的有效期应该设置为多少?
- 如果设置的太短,比如1小时,那么用户一小时之后。再访问其他接口,需要再次重新登录,对用户的体验极差
- 如果设置为一个星期,那么在这个时间内
-
-
- 一旦
token
泄露,攻击者可长期冒充用户身份,直到token
过期,服务端无法限制其访问用户数据 - 虽然可以依赖黑名单机制,但会增加系统复杂度,还要进行系统监测
- 如果在这段时间恶意用户利用未过期的条款持续调用后端API将会导致资源耗尽或产生巨额费用
- 一旦
-
所以有没有两者都兼顾的方案呢?
双token无感刷新方案
传统的token
方案要么频繁要求用户重新登录,要么面临长期有效的安全风险
但是双token
无感刷新机制,通过组合设计,在保证安全性的情况下,实现无感知的认证续期
核心设计
access_token
:访问令牌,有效期一般设置为15~30分钟,主要用于对后端请求API的交互refresh_token
:刷新令牌,一般设置为一个星期到一个月,主要用于获取新的access_token
大致的执行流程如下
用户登录之后,后端返回access_token
和refresh_token
响应给前端,前端将两个token
存储在用户本地
在用户端发起前端请求,访问后端接口,在请求头中携带上access_token
前端会对access_token
的过期时间进行检测,当access_token
过期前一分钟,前端通过refresh_token
向后端发起请求,后端判断refresh_token是否有效,有效则重新获取新的access_token
,返回给前端替换掉之前的access_token
存储在用户本地,无效则要求用户重新认证
这样的话对于用户而言token
的刷新是无感知的,不会影响用户体验,只有当refresh_token
失效之后,才需要用户重新进行登录认证,同时,后端可以通过对用户refresh_token
的管理来限制用户对后端接口的请求,大大提高了安全性
有了这个思路,写代码就简单了
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private JwtUtils jwtUtils;
// token过期时间
private static final Integer TOKEN_EXPIRE_DAYS =5;
// token续期时间
private static final Integer TOKEN_RENEWAL_MINUTE =15;
@Override
public boolean verify(String refresh_token) {
Long uid = jwtUtils.getUidOrNull(refresh_token);
if (Objects.isNull(uid)) {
return false;
}
String key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN,uid);
String realToken = RedisUtils.getStr(key);
return Objects.equals(refresh_token, realToken);
}
@Override
public void renewalTokenIfNecessary(String refresh_token) {
Long uid = jwtUtils.getUidOrNull(refresh_token);
if (Objects.isNull(uid)) {
return;
}
String refresh_key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN, uid);
long expireSeconds = RedisUtils.getExpire(refresh_key, TimeUnit.SECONDS);
if (expireSeconds == -2) { // key不存在,refresh_token已过期
return;
}
String access_key = RedisKey.getKey(RedisKey.USER_ACCESS_TOKEN, uid);
RedisUtils.expire(access_key, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
}
@Override
@Transactional(rollbackFor = Exception.class)
@RedissonLock(key = "#uid")
public LoginTokenResponse login(Long uid) {
String refresh_key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN, uid);
String access_key = RedisKey.getKey(RedisKey.USER_ACCESS_TOKEN, uid);
String refresh_token = RedisUtils.getStr(refresh_key);
String access_token;
if (StrUtil.isNotBlank(refresh_token)) { //刷新令牌不为空
access_token = jwtUtils.createToken(uid);
RedisUtils.set(access_key, access_token, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
return LoginTokenResponse.builder()
.refresh_token(refresh_token).access_token(access_token)
.build();
}
refresh_token = jwtUtils.createToken(uid);
RedisUtils.set(refresh_key, refresh_token, TOKEN_EXPIRE_DAYS, TimeUnit.DAYS);
access_token = jwtUtils.createToken(uid);
RedisUtils.set(access_key, access_token, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
return LoginTokenResponse.builder()
.refresh_token(refresh_token).access_token(access_token)
.build();
}
}}
注意事项
- 安全存储Refresh Token时,优先使用HttpOnly+Secure Cookie而非LocalStorage
- 在颁发新Access Token时,重置旧Token的生存周期(滑动过期)而非简单续期
- 针对高敏感操作(如支付、改密),建议强制二次认证以突破Token机制的限制
安全问题
双Token机制并没有从根本上解决安全性的问题,它只是尝试通过改进设计,优化用户体验,全面的安全策略需要多层防护,分别针对不同类型的威胁和风险,而不仅仅依赖于Token的管理方式或数量
安全是一个持续对抗的过程,关键在于提高攻击者的成本,而非追求绝对防御。
"完美的认证方案不存在,但聪明的权衡永远存在。"
本笔者水平有限,望各位海涵
如果文章中有不对的地方,欢迎大家指正。