RefreshToken & AccessToken
解决了既要安全,又要用户体验的问题
背景
我们知道,token 作为用户登陆凭证,广泛用于校验用户身份以及认证。一般常见的 token 有 jwt(Json Web Token)等,由于其无状态性而被广泛使用。而jwt通常只会有一个token,如果只有一个token,会出现什么问题?
首先是安全与用户体验问题。如果token的有效期设置过长,一旦被窃取,若服务端不能做出有效措施(这通常情况下是达不到的,正是由于其无状态性),可能会造成巨大危害!
如果token有效期设置的很短,那用户体验就会很差,毕竟谁也不想在浏览过程中被打断,更别提还要重新登陆了。
这里介绍一种常见的双 Token 认证方式:RefreshToken + AccessToken。
详细
首先要区分两个维度:AccessToken / RefreshToken 是按“用途”划分的,JWT 是按“格式”划分的。二者不是同一层概念。
AccessToken 一般译作“访问令牌”,主要用于访问业务接口,可以被后端拦截器校验。它的有效期通常较短,比如 15 分钟。
RefreshToken 一般译作“刷新令牌”,它不用于访问普通业务接口,而是专门用于换取新的 AccessToken。它的有效期通常较长,比如 7 天甚至更久。
在这个项目里,AccessToken 使用的是 JWT;RefreshToken 则不是 JWT,而是 tokenId.secret 这种不透明字符串。
AccessToken 和 RefreshToken 的一套组合拳如下:
正常访问时,请求接口 → 带 AccessToken → 验证通过 → 返回数据;
当 AccessToken 过期时,请求接口 → AccessToken 过期 → 返回 401,于是前端调用 /refresh,返回 LoginVO 与新的RefreshToken,其中 LoginVO 包含了新的 AccessToken,而 RefreshToken 则塞在 Cookie 里,并且RefreshToken续期 x 天,这里的 x 由后端开发者自己管理。
那如果RefreshToken也过期了呢?不好意思,只有重新登陆了,不过总比天天登陆好……
这里为什么要返回一个新的RefreshToken?此处先不提,留到后文解答。
基本概念说完了,来看一下在后端相关代码中,这套系统是如何运行的。
application.yml
首先来看一下application.yml的配置。
study-blog:
jwt:
secret: ${JWT_SECRET:xxx}
access-expire-seconds: ${JWT_ACCESS_EXPIRE_SECONDS:900}
refresh-expire-seconds: ${JWT_REFRESH_EXPIRE_SECONDS:604800}
issuer: ${JWT_ISSUER:study-blog}
refresh-cookie-name: ${JWT_REFRESH_COOKIE_NAME:xxx_xxx_xxx}
refresh-cookie-path: ${JWT_REFRESH_COOKIE_PATH:/xxx/xxx/xx}
refresh-cookie-secure: ${JWT_REFRESH_COOKIE_SECURE:true}
refresh-cookie-same-site: ${JWT_REFRESH_COOKIE_SAME_SITE:Lax}
字段解释:
secret:JWT 的签名密钥,用于 AccessToken 的签发和验签
access-expire-seconds:AccessToken 的过期时间
refresh-expire-seconds:RefreshToken 的过期时间
issuer:JWT 的签发者标识。在当前项目中它会被写入 AccessToken,但解析时并没有显式校验它
refresh-cookie-name:RefreshToken 对应的 Cookie 名称
refresh-cookie-path:Cookie 生效路径,浏览器只有在匹配该路径时才会自动携带它
refresh-cookie-secure:是否只允许在 HTTPS 下传输
refresh-cookie-same-site:Cookie 的跨站策略。Strict 最严格,Lax 是常见默认值,None 需要配合 HTTPS 使用
SpringBootApplication
@SpringBootApplication
@MapperScan("com.xxx.mapper")
@EnableConfigurationProperties({
JwtProperties.class,
XXX.class
})
public class StudyBlogApplication {
public static void main(String[] args) {
SpringApplication.run(StudyBlogApplication.class, args);
}
}
@EnableConfigurationProperties(JwtProperties.class)中涉及到JwtProperties.class,接下来看看JwtProperties.class
JwtProperties.class
@Data
@ConfigurationProperties(prefix = "study-blog.jwt")
public class JwtProperties {
private String secret;
private Long accessExpireSeconds;
private Long refreshExpireSeconds;
private String issuer;
private String refreshCookieName;
private String refreshCookiePath;
private Boolean refreshCookieSecure;
private String refreshCookieSameSite;
}
这里的prefix = "study-blog.jwt"对应了yml中的
study-blog:
jwt:
secret:
……
@EnableConfigurationProperties 跟 @Value 类似,都能读取配置,但侧重点不同:@Value 更适合读取单个配置项,@EnableConfigurationProperties 更适合把一组相关配置绑定成一个配置类。
接下来,我们从 Interceptor → Controller → Service → Mapper 这条链来讲解详细的业务逻辑。
Interceptor
HTTP请求打到Controller之前,会先由拦截器进行拦截,然后进行一系列神秘(复杂)活动。
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
private final SysUserMapper sysUserMapper;
private final SysUserRefreshTokenMapper sysUserRefreshTokenMapper;
public AuthInterceptor(JwtUtil jwtUtil,
SysUserMapper sysUserMapper,
SysUserRefreshTokenMapper sysUserRefreshTokenMapper) {
this.jwtUtil = jwtUtil;
this.sysUserMapper = sysUserMapper;
this.sysUserRefreshTokenMapper = sysUserRefreshTokenMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return true;
}
String token = RequestUtil.getBearerToken(request);
if (token == null || token.isBlank()) {
throw new UnauthorizedException("Access token is missing.");
}
try {
Claims claims = jwtUtil.parseAccessToken(token);
Long userId = claims.get("userId", Long.class);
String sessionId = claims.get("sessionId", String.class);
SysUser sysUser = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getId, userId)
.eq(SysUser::getStatus, 1)
.last("limit 1"));
if (sysUser == null) {
throw new UnauthorizedException("The current user does not exist or is disabled.");
}
Long activeSessionCount = sysUserRefreshTokenMapper.selectCount(new LambdaQueryWrapper<SysUserRefreshToken>()
.eq(SysUserRefreshToken::getUserId, userId)
.eq(SysUserRefreshToken::getFamilyId, sessionId)
.isNull(SysUserRefreshToken::getRevokedAt)
.gt(SysUserRefreshToken::getExpiresAt, LocalDateTime.now()));
if (activeSessionCount == null || activeSessionCount == 0) {
throw new UnauthorizedException("The login session has expired.");
}
LoginUserContext.set(new UserSession(sysUser.getId(), sysUser.getUsername(), sysUser.getRole()));
return true;
} catch (ExpiredJwtException ex) {
throw new UnauthorizedException("Access token expired.");
} catch (JwtException | IllegalArgumentException ex) {
throw new UnauthorizedException("Access token is invalid.");
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
LoginUserContext.clear();
}
}
作为一个合格的拦截器,必须要实现HandlerInterceptor接口,并重写接口中的方法:preHandler,postHandler,afterCompletion;不过幸好这几个方法都是default,可以选择性实现。
preHandle作用域是Controller之前,它的作用是作登录校验、权限控制、参数校验与拦截非法请求。
首先判断HttpMethod.OPTIONS.matches(request.getMethod()),这一步的意思是当浏览器发出跨域请求,直接通过,不拦截;随后从request中获取token,此时获取到的token毫无疑问是 AccessToken,接下来就会对AccessToken进行一系列校验:
-
获取token中的Payload:
Claims claims = jwtUtil.parseAccessToken(token);Claims 是一种特殊的类,其本质相当于一个Map<String, Object>,这从其用处也可以看出;jwt的格式为Header.Payload.Signature,中间的Payload解码后效果如下:{ "userId": 1, "sessionId": "abc123", "exp": 1710000000 }这部分就是 Claims 啦;然后从 Claims 中获取到 userId 和 sessionId,这里 sessionId = familyId, 由于 RefreshToken 是“家族式”的(Token Family),因此需要一个 familyId 来表示整个token家族
-
获取
SysUser对象:通过userId构建MyBatisPlus查询语句,查询到一个管理员对象;加上校验,下一步就是最关键的登录会话校验了。(注意:token本身的校验已经在parseAccessToken()里校验过了,activeSessionCount查询是校验token对应的会话!) -
获取聚合查询结果:这里查的是activeSession的数量,也就是当前有效会话的数量,如果不存在或数量为0,报错;最后将用户会话对象塞进
LoginUserContext中,便于当前线程获取当前用户信息;下面详细讲解一下查询条件:eq(SysUserRefreshToken::getUserId, userId):限定必须是当前用户的 sessioneq(SysUserRefreshToken::getFamilyId, sessionId):限定必须是当前token家族,定位“这一批登录会话”isNull(SysUserRefreshToken::getRevokedAt):限定没有被撤销(没被强制下线),如果用户退出:revoke_at != null,这里就查不到gt(SysUserRefreshToken::getExpiresAt, LocalDateTime.now())):限定refreshtoken当前没有过期
afterCompletion作用域是整个请求完全结束之后,其作用就简单多了:用来清理当前线程中的用户会话对象,这是因为 Tomcat 线程是复用的,为了避免线程污染,要及时清理。
这里的 session 是什么意思?
这里的 session 不是传统的 HttpSession(JSESSIONID 那种),而是自定义的服务器会话状态(逻辑 Session)。注意是逻辑 Session!本文中提到的 familyId 功能上等同于传统意义上的 sessionId,会话数据存放在数据库而不是服务器内存。
Controller
Controller中的方法有很多,我们着重介绍/refresh即可。
@Tag(name = "Admin Auth")
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
@Autowired
xxx xxx;
@Operation(summary = "Refresh access token")
@PostMapping("/refresh")
public Result<LoginVO> refresh(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
try {
String refreshToken = RequestUtil.getCookieValue(httpServletRequest, jwtProperties.getRefreshCookieName());
AuthSessionResult sessionResult = authService.refresh(
refreshToken,
RequestUtil.getClientIp(httpServletRequest),
httpServletRequest.getHeader("User-Agent"));
refreshTokenCookieManager.writeRefreshToken(httpServletResponse, sessionResult.refreshToken());
return Result.success(sessionResult.loginInfo());
} catch (RuntimeException ex) {
refreshTokenCookieManager.clearRefreshToken(httpServletResponse);
throw ex;
}
}
}
老规矩,Controller上写注解@RestController,先注册为Bean(@Controller)再将方法返回值直接作为http响应体返回(@ResponseBody),而不是去找页面;然后定义访问路径:@RequestMapping("/api/admin/auth"),之后我们来看看具体的refresh方法是个什么名堂。
先从入参讲起吧,HttpServletRequest和HttpServletResponse:
HttpServletRequest用来读取请求信息,这里主要用作获取Cookies,获取客户端ip(记录登陆设备),获取User-Agent(用于识别浏览器/设备类型)HttpServletResponse用来写回响应,将新的refreshToken写回到Cookie
然后我们来看整个流程。首先是从Cookie拿旧的refreshToken,拿到后调用authService的refresh方法,得到一个AuthSessionResult类型的返回值;这个AuthSessionResult类代码如下:
package com.xxx.xxx;
public record AuthSessionResult(LoginVO loginInfo, String refreshToken) {
}
这里的 record 可以自动生成构造器、访问器方法,以及 equals、hashCode、toString,很适合用来表达这种简单、不可变的数据载体;它不会生成 setter。
拿到新的 AuthSessionResult 对象后,将新的 refreshToken 写入 response,并返回 LoginVO(包含了新的accessToken),如果有异常,就清除Cookie(为什么要清除Cookie?refreshToken 已经失效 / 被攻击 / 不合法,需要重新登陆),并抛出异常。
为什么 RefreshToken 不像 AccessToken 一样“返回给前端”,而是写进 Cookie?
这个问题等同于“为什么不直接返回 AuthSessionResult?”主要是为了安全性考虑;如果 RefreshToken 直接返回给前端,一旦被攻击~(至于怎么被攻击的,这里就不详细展开了,因为我也不会)~,相当于丢失了命根子,黑客可以拿着这个 RefreshToken 无限刷新登陆状态,相当于永不下线;并且写入Cookie浏览器会自动带上Cookie: refresh_token=xxx,不用手动管理了;而 AccessToken 本来有效期就短,盗了就盗了,大不了使整个 token family 都失效,这时候拿什么 token 请求也没用了(拦截器会校验 session 状态);严谨来说:虽然 AccessToken 本身无法被服务端直接撤销,但可以通过撤销其所属的 token family,并在请求时校验服务端 session 状态,实现对 AccessToken 的“逻辑失效”,从而达到强制下线的效果。
Service
Service层的方法更多了,这里挑重要的讲。
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
xxx xxx;
@Override
public AuthSessionResult refresh(String refreshToken, String ipAddress, String userAgent) {
RefreshTokenUtil.RefreshTokenValue parsedToken = RefreshTokenUtil.parse(refreshToken);
if (parsedToken == null) {
throw new UnauthorizedException("Refresh token is missing or invalid.");
}
SysUserRefreshToken storedToken = sysUserRefreshTokenMapper.selectOne(new LambdaQueryWrapper<SysUserRefreshToken>()
.eq(SysUserRefreshToken::getTokenId, parsedToken.tokenId())
.last("limit 1"));
if (storedToken == null) {
throw new UnauthorizedException("Refresh token is invalid.");
}
LocalDateTime now = LocalDateTime.now();
if (storedToken.getRevokedAt() != null) {
revokeTokenFamily(storedToken.getFamilyId(), "TOKEN_REUSE_DETECTED");
throw new UnauthorizedException("Refresh token has already been used. Please log in again.");
}
if (storedToken.getExpiresAt() == null || storedToken.getExpiresAt().isBefore(now)) {
revokeStoredToken(storedToken.getId(), "EXPIRED", null, now);
throw new UnauthorizedException("Refresh token has expired. Please log in again.");
}
String expectedHash = RefreshTokenUtil.hashSecret(parsedToken.secret());
if (!Objects.equals(expectedHash, storedToken.getTokenHash())) {
revokeTokenFamily(storedToken.getFamilyId(), "TOKEN_REUSE_DETECTED");
throw new UnauthorizedException("Refresh token is invalid.");
}
SysUser user = requireActiveUser(storedToken.getUserId());
RefreshTokenUtil.RefreshTokenValue nextRefreshToken = RefreshTokenUtil.generate();
insertRefreshToken(user.getId(), storedToken.getFamilyId(), nextRefreshToken, ipAddress, userAgent);
revokeStoredToken(storedToken.getId(), "ROTATED", nextRefreshToken.tokenId(), now);
return buildSessionResult(user, storedToken.getFamilyId(), nextRefreshToken.rawValue());
}
}
首先对 refreshToken 进行解析,这里要说明:refreshToken 的结构不同于一般jwt,由 tokenId + secret 组成,通过 tokenId 可以快速查表,从而查出整个 token family;如果连 parsedToken 都解析不出来则直接抛异常。然后通过 tokenId 在数据库中查询出SysUserRefreshToken类,如果为null依旧抛出常;接着去查这个 token 是否已经被使用,一般来说,一个 refreshToken 只会被使用一次,如果被重复使用,那说明这个 token 很可能已经被盗了,此时就要使整个 token family 失效!因此调用revokeTokenFamily()方法,并传入 familyId 和失效原因:TOKEN_REUSE_DETECTED;随后校验当前 token 是否过期,如果过期了,同样抛出异常。然后校验 token 是否被篡改,通过比较数据库中的 secret 哈希值来判断;然后检验用户是否合法,如果requireActiveUser()查出来的用户为null,则直接在方法内抛出异常;最后也是最重要的:更新token,先生成RefreshTokenUtil.RefreshTokenValue对象,该对象由 tokenId 和 secret 组成,随后插入到同一个 family 下(同一个 familyId ),并且废掉上一次 token,记录下新的替代 token,最后返回新的会话结果,即新的 loginVO 和 refreshToken。
Mapper
来看看数据库结构。
DROP TABLE IF EXISTS sys_user_refresh_token;
CREATE TABLE sys_user_refresh_token (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
family_id VARCHAR(64) NOT NULL,
token_hash VARCHAR(128) NOT NULL,
expires_at DATETIME NOT NULL,
last_used_at DATETIME DEFAULT NULL,
revoked_at DATETIME DEFAULT NULL,
revoke_reason VARCHAR(64) DEFAULT NULL,
replaced_by_token_id VARCHAR(64) DEFAULT NULL,
user_agent VARCHAR(255) DEFAULT NULL,
ip_address VARCHAR(64) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
is_deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_sys_user_refresh_token_token_id (token_id),
KEY idx_sys_user_refresh_token_family (family_id),
KEY idx_sys_user_refresh_token_user_expires (user_id, expires_at),
KEY idx_sys_user_refresh_token_revoked (revoked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
可以清晰地看到,整张表有 familyId,tokenId(refreshTokenId)等一系列字段,没有 accessToken,这也体现了 accessToken 作为纯正 jwt 的无状态特点。
总结
整体的详细流程如下:
- 登录时,后端先生成一个新的 familyId,然后插入一条 refresh token 记录,再返回一个 AccessToken。这时返回给前端的是 JWT,refresh token 则写进 HttpOnly Cookie。
- 每次访问受保护接口时,拦截器不仅验证 JWT 签名和过期时间,还会去数据库检查:这个 userId + familyId(sessionId) 是否还存在“未撤销且未过期”的 refresh token 记录。这说明整个 Token 模式不是“纯无状态 JWT”,而是“JWT + 数据库会话状态”。
- 刷新时,后端先把 cookie 里的 refresh token 拆成 tokenId.secret,按 tokenId 找库里的记录,再检查 4 件事:是否存在、是否已撤销、是否过期、secret 的哈希是否匹配。
- 如果校验通过,就生成一个新的 refresh token,但 familyId 不变;然后把旧 token 标记为 ROTATED,并记下 replacedByTokenId,最后再签发一个新的 access token。也就是:
登录: family F1 → RT1 + AT1(sessionId=F1)
刷新一次: family F1 → RT2 + AT2(sessionId=F1)
同时 RT1.revokedAt=now, revokeReason=ROTATED, replacedByTokenId=RT2.tokenId
如果旧的 refresh token 被再次拿来刷新,系统会认为发生了 token reuse,直接把整个 family 全部撤销,并抛出TOKEN_REUSE_DETECTED。这样这一条链上的所有 refresh token 都失效,用户必须重新登录。
整体的简要流程如下:
前端用 AccessToken 访问接口,若返回 401,则调用 /refresh;若当前 RefreshToken 仍有效,后端会在同一个 TokenFamily 里签发新的 RefreshToken 和 AccessToken,并把新 RefreshToken 的过期时间重置为“当前时间后一周”;如果 RefreshToken 已过期或 family 被吊销,则不能再刷新,只能重新登录,这时会创建新的 TokenFamily。
AccessToken 和 RefreshToken 的轮换方式
RefreshToken 是严格轮换的一次性 token:每 refresh 一次就换新,旧的立刻作废。
AccessToken 则不是“一次性轮换”,而是“重新签发一个新的 JWT”。关键点是:旧的 access token 不会因为 refresh 成功就立刻失效,因为它们都还指向同一个 familyId。只要这个 family 还活着,旧 access token 在自己的过期时间内通常仍可用。这个项目里 access token 的默认过期是 900 秒,refresh token 是 7 天。
目前存在的主要问题
这套系统的问题是目前没有做绝对最长生命周期,即虽然做了sliding window,却没有做 absolute lifetime,refreshtoken可以无限续期;若想要30天后强制下线,可以设置一个最长存活时期为30天,这样就能保证token family的整体定期下线。
