做后端开发的都懂,登录认证是项目的基础防线,而Token认证又是前后端分离架构下的首选方案。但单Token方案的痛点太明显------要么Token有效期设短,用户频繁重新登录,体验拉胯;要么设长,Token泄露后风险极高。
之前做SpringBoot3项目时,试过自己手写JWT双Token逻辑,处理过期刷新、Token校验、异常拦截一套下来,冗余代码一大堆,还踩了不少分布式部署下的缓存一致性坑。后来换成Sa-Token框架,发现它原生支持双Token(AccessToken + RefreshToken)模式,几行配置+简单编码就能落地,省了不少事。
这篇博客不搞虚的,不堆砌概念,全程以实战为主,结合我实际开发中踩过的坑,手把手教大家用SpringBoot3整合Sa-Token实现双Token登录认证,新手也能直接照搬用,老鸟也能避坑提速。
先说明下环境:SpringBoot 3.2.2 + Sa-Token 1.42.0(最新稳定版,适配SpringBoot3) + Redis 7.2(分布式部署必备,单机可省略但建议部署) + JDK 17(SpringBoot3最低要求)。
一、为什么要用双Token?(实际开发痛点出发)
很多新手会问,单Token不够用吗?举两个实际开发中的场景,你就懂了:
-
场景1:用户登录后,Token有效期设1小时,1小时后用户正在操作,突然提示重新登录,体验极差;如果设7天,Token被泄露,别人能长时间盗用用户账号,安全风险高。
-
场景2:分布式项目中,单Token存在缓存同步问题,一旦某个节点Token失效,其他节点无法及时感知,导致用户明明已登出,却还能短暂访问接口。
双Token方案正好解决这两个痛点,核心逻辑很简单(不用死记概念,理解就行):
-
AccessToken(访问令牌):短期有效(比如30分钟),专门用来访问业务接口,过期后无法访问接口,但不用用户重新登录。
-
RefreshToken(刷新令牌):长期有效(比如7天),专门用来刷新AccessToken,不参与业务接口访问,即使泄露,没有AccessToken也无法操作业务,且可快速注销。
补充一句:Sa-Token的双Token不是简单生成两个JWT,而是内置了Token生命周期管理、刷新机制、缓存同步,比自己手写JWT双Token靠谱多了,还能避免重复造轮子。
二、前期准备(依赖+配置,避坑重点)
这一步是基础,也是最容易踩坑的地方,尤其是SpringBoot3和Sa-Token的版本适配,还有Redis的配置,新手一定要仔细看。
2.1 引入依赖(pom.xml)
SpringBoot3整合Sa-Token,必须引入专门适配SpringBoot3的starter,不能用SpringBoot2的starter(否则会报"未能获取有效的上下文处理器"错误),具体依赖如下,直接复制即可:
<!-- SpringBoot3 Web依赖(基础必备) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Sa-Token SpringBoot3 starter(核心依赖,适配SpringBoot3) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.42.0</version>
</dependency>
<!-- Sa-Token 整合Redis(分布式部署必备,单机可省略,但建议加上) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-dao-redis</artifactId>
<version>1.42.0</version>
</dependency>
<!-- Redis依赖(SpringBoot3默认适配) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 工具类依赖(简化代码,可选但推荐) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.28</version>
</dependency>
避坑提示1:Sa-Token的starter和dao-redis版本必须一致,否则会出现依赖冲突;1.42.0是目前最新稳定版,支持双Token反查、密钥轮换等实用特性,建议直接用这个版本。
避坑提示2:如果是微服务项目,Sa-Token的starter不要放到父工程pom.xml中,要单独放到每个子服务中,否则会出现启动时获取不到Web上下文的错误。
2.2 配置文件(application.yml)
配置文件主要分三部分:Sa-Token核心配置、Redis配置、Web配置,注释写得很详细,直接复制修改Redis地址即可:
server:
port: 8080 # 项目端口,可自定义
# Spring Redis配置(分布式部署必备,单机可省略但建议配置)
spring:
redis:
host: 127.0.0.1 # Redis地址,本地默认127.0.0.1
port: 6379 # Redis端口,默认6379
password: 123456 # 你的Redis密码,没有则留空
database: 0 # Redis数据库索引,默认0
timeout: 10000ms # 连接超时时间
# Sa-Token核心配置(重点,双Token相关配置)
sa-token:
# 1. 基础配置
token-name: satoken # Token在请求头中的名称(默认satoken,可自定义,比如Authorization)
token-style: uuid # Token生成风格(uuid更安全,可选:uuid、simple-uuid、random-32等)
is-concurrent: true # 是否允许同一账号多地同时登录(true允许,false新登录挤掉旧登录)
# 2. 双Token配置(核心中的核心)
access-token-timeout: 1800 # AccessToken有效期,单位:秒(30分钟,可自定义)
refresh-token-timeout: 604800 # RefreshToken有效期,单位:秒(7天,可自定义)
refresh-token-single: true # 是否同一账号只生成一个RefreshToken(true更安全,避免多刷新令牌泄露)
# 3. Redis配置(整合Redis,分布式部署必备)
redis-model: standalone # Redis模式(单机:standalone,集群:cluster,哨兵:sentinel)
redis-host: ${spring.redis.host} # 复用Spring Redis配置,不用重复写
redis-port: ${spring.redis.port}
redis-password: ${spring.redis.password}
redis-database: ${spring.redis.database}
# 4. 异常配置(可选,自定义异常提示,更友好)
not-login-message: 请先登录!
token-expire-message: 访问令牌已过期,请刷新令牌!
refresh-token-expire-message: 刷新令牌已过期,请重新登录!
避坑提示3:access-token-timeout和refresh-token-timeout的单位是秒,不是毫秒,新手容易写错,导致Token有效期不符合预期(比如设1800000,以为是30分钟,实际是500小时)。
避坑提示4:如果不配置Redis,Sa-Token默认用内存存储Token,单机环境没问题,但分布式环境会出现Token不共享的问题,建议不管是单机还是分布式,都配置Redis,避免后续扩展麻烦。
三、核心代码实现(实战为主,复制即用)
核心逻辑分4步:用户登录生成双Token、接口访问拦截校验Token、刷新AccessToken、用户登出销毁双Token。代码都经过实际测试,没有冗余,新手可以直接复制到项目中,替换自己的业务逻辑即可。
3.1 实体类(User.java)
简单的用户实体类,对应数据库用户表,这里只写核心字段,实际项目中可根据需求扩展:
package com.example.doubleToken.entity;
import lombok.Data;
/**
* 用户实体类(对应数据库用户表)
* 实际项目中可根据需求扩展字段,比如手机号、邮箱、角色等
*/
@Data
public class User {
/**
* 用户ID(主键)
*/
private Long id;
/**
* 用户名(登录账号)
*/
private String username;
/**
* 密码(数据库中建议加密存储,比如用BCrypt加密)
*/
private String password;
/**
* 用户名(展示用,可选)
*/
private String nickname;
}
3.2 登录接口(生成双Token)
用户登录时,校验用户名密码正确后,用Sa-Token的StpUtil.loginWithRefreshToken()方法生成双Token,该方法会自动处理Token的生命周期和缓存,比自己手写简单太多。
package com.example.doubleToken.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import com.example.doubleToken.entity.User;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 登录相关接口(核心:生成双Token)
*/
@RestController
@RequestMapping("/auth")
public class AuthController {
/**
* 用户登录(生成双Token)
* @param user 登录参数(username + password)
* @return 双Token + 用户信息
*/
@PostMapping("/login")
public SaResult login(@RequestBody User user) {
// 1. 校验用户名密码(实际项目中,这里要从数据库查询用户,比对密码,建议加密比对)
// 这里模拟校验,实际项目中替换为自己的业务逻辑
if (!"admin".equals(user.getUsername()) || !"123456".equals(user.getPassword())) {
return SaResult.error("用户名或密码错误");
}
// 2. 生成双Token(核心代码,Sa-Token原生支持)
// 参数1:用户ID(唯一标识,建议用数据库主键)
// 参数2:登录设备类型(可选,比如pc、app、wechat,用于同端互斥登录)
StpUtil.loginWithRefreshToken(user.getId(), "pc");
// 3. 获取生成的双Token(返回给前端,前端保存)
String accessToken = StpUtil.getTokenValue(); // AccessToken
String refreshToken = StpUtil.getRefreshTokenValue(); // RefreshToken
// 4. 组装返回结果(返回Token + 用户信息,前端按需保存)
Map<String, Object> data = new HashMap<>();
data.put("accessToken", accessToken);
data.put("refreshToken", refreshToken);
data.put("tokenType", "Bearer"); // 规范写法,前端请求时拼接在Token前
data.put("expiresIn", StpUtil.getTokenTimeout()); // AccessToken剩余有效期(秒)
data.put("user", user); // 用户信息,实际项目中可返回脱敏后的信息
return SaResult.ok("登录成功").setData(data);
}
}
重点说明:StpUtil.loginWithRefreshToken()方法会自动完成3件事:生成AccessToken和RefreshToken、将Token信息存入Redis、绑定用户ID和Token的关联关系,不用我们手动处理任何缓存逻辑。
避坑提示5:登录时的密码校验,实际项目中一定要加密比对(比如用BCrypt加密),不要明文存储密码,也不要明文比对,否则会有严重的安全风险。
3.3 Token校验拦截器(全局拦截,无需手动校验)
Sa-Token提供了全局拦截器,我们只需简单配置,就能实现"未登录拦截""Token过期拦截",不用在每个接口中手动写校验代码,极大简化开发。
package com.example.doubleToken.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Sa-Token全局拦截器配置(校验Token,未登录/Token过期拦截)
*/
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
/**
* 注册Sa-Token拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册全局拦截器,拦截所有接口
registry.addInterceptor(new SaInterceptor(handle -> {
// 校验AccessToken是否有效(核心校验逻辑,一行代码搞定)
StpUtil.checkLogin();
// 可选:校验Token的设备类型(防止不同设备混用Token,提升安全性)
// StpUtil.checkLoginType("pc");
}))
// 拦截所有接口
.addPathPatterns("/**")
// 排除不需要拦截的接口(登录、刷新Token、登出接口,必须排除)
.excludePathPatterns(
"/auth/login",
"/auth/refreshToken",
"/auth/logout"
);
}
}
说明:StpUtil.checkLogin()方法会自动校验请求头中的AccessToken是否有效、是否过期,如果无效或过期,会自动抛出对应的异常(NotLoginException、TokenExpireException),并返回我们在配置文件中自定义的异常提示。
避坑提示6:一定要排除登录、刷新Token、登出接口,否则会出现"自己拦截自己"的问题,导致用户无法登录、无法刷新Token。
3.4 刷新AccessToken接口
当AccessToken过期后,前端携带RefreshToken请求该接口,生成新的AccessToken(RefreshToken不变,除非RefreshToken也过期),实现用户无感知续期,不用重新登录。
package com.example.doubleToken.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 刷新Token接口(核心:AccessToken过期后,用RefreshToken续期)
*/
@RestController
@RequestMapping("/auth")
public class RefreshTokenController {
/**
* 刷新AccessToken
* @param refreshToken 前端传入的RefreshToken
* @return 新的AccessToken
*/
@PostMapping("/refreshToken")
public SaResult refreshToken(@RequestParam String refreshToken) {
try {
// 1. 校验RefreshToken是否有效,并刷新AccessToken(核心代码)
// 该方法会自动校验RefreshToken,如果有效,生成新的AccessToken并返回
StpUtil.refreshAccessToken(refreshToken);
// 2. 获取新的AccessToken和剩余有效期
String newAccessToken = StpUtil.getTokenValue();
long expiresIn = StpUtil.getTokenTimeout();
// 3. 组装返回结果
Map<String, Object> data = new HashMap<>();
data.put("accessToken", newAccessToken);
data.put("refreshToken", refreshToken); // RefreshToken不变,除非过期
data.put("expiresIn", expiresIn);
return SaResult.ok("Token刷新成功").setData(data);
} catch (Exception e) {
// 异常说明:RefreshToken过期、无效,都会进入这里,提示用户重新登录
return SaResult.error("刷新Token失败,请重新登录").setCode(401);
}
}
}
重点说明:StpUtil.refreshAccessToken()方法会自动处理3件事:校验RefreshToken的有效性、生成新的AccessToken、更新Redis中Token的有效期,RefreshToken本身的有效期不会变,直到它自己过期。
3.5 登出接口(销毁双Token)
用户登出时,需要同时销毁AccessToken和RefreshToken,避免Token被复用,Sa-Token提供了StpUtil.logout()方法,一键销毁双Token,不用手动操作Redis。
package com.example.doubleToken.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 登出接口(核心:销毁双Token)
*/
@RestController
@RequestMapping("/auth")
public class LogoutController {
/**
* 用户登出(销毁双Token)
* @return 登出结果
*/
@PostMapping("/logout")
public SaResult logout() {
// 核心代码:一键销毁当前用户的AccessToken和RefreshToken
// 会自动删除Redis中的Token信息,解除用户与Token的关联
StpUtil.logout();
return SaResult.ok("登出成功");
}
}
避坑提示7:登出时一定要用StpUtil.logout()方法,不要自己手动删除Redis中的Token,因为Sa-Token会在Redis中存储多个与Token相关的键值对,手动删除容易删不干净,导致Token残留,出现安全风险。
3.6 测试接口(验证Token校验)
写一个简单的测试接口,验证Token校验是否生效,登录后才能访问,未登录或Token过期则被拦截:
package com.example.doubleToken.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 测试接口(验证Token校验是否生效)
*/
@RestController
@RequestMapping("/test")
public class TestController {
/**
* 测试接口(需要登录才能访问)
* @return 测试结果
*/
@GetMapping("/hello")
public SaResult hello() {
// 获取当前登录用户ID(登录后才能获取,未登录会被拦截)
Long userId = StpUtil.getLoginIdAsLong();
return SaResult.ok("访问成功").setData("当前登录用户ID:" + userId);
}
}
四、实际测试(Postman测试,验证全流程)
代码写完后,用Postman测试全流程,确保每个步骤都能正常运行,测试步骤如下(新手可照做):
4.1 测试登录接口(生成双Token)
-
请求地址:POST http://localhost:8080/auth/login
-
请求体(JSON):{"username":"admin","password":"123456"}
-
预期结果:返回登录成功,包含accessToken、refreshToken、用户信息。
4.2 测试测试接口(未登录拦截)
-
请求地址:GET http://localhost:8080/test/hello
-
请求头:不携带任何Token
-
预期结果:返回"请先登录!",状态码401。
4.3 测试测试接口(登录后访问)
-
请求地址:GET http://localhost:8080/test/hello
-
请求头:Authorization: Bearer 你的accessToken(或satoken: 你的accessToken,对应配置文件中的token-name)
-
预期结果:返回"访问成功",包含当前登录用户ID。
4.4 测试刷新Token接口(AccessToken过期后)
-
先修改配置文件中的access-token-timeout为10(10秒过期),重启项目,重新登录获取双Token。
-
等待10秒,让AccessToken过期,再用过期的AccessToken访问测试接口,预期返回"访问令牌已过期,请刷新令牌!"。
-
请求地址:POST http://localhost:8080/auth/refreshToken
-
请求参数:refreshToken=你的refreshToken
-
预期结果:返回"Token刷新成功",包含新的accessToken。
-
用新的accessToken访问测试接口,预期能正常访问。
4.5 测试登出接口
-
请求地址:POST http://localhost:8080/auth/logout
-
请求头:携带有效的accessToken
-
预期结果:返回"登出成功"。
-
用登出后的accessToken访问测试接口,预期返回"请先登录!"。
五、实际开发中的避坑总结(重中之重)
这部分是我实际开发中踩过的坑,整理出来,帮大家少走弯路,每一个坑都对应实际场景,新手一定要牢记:
-
坑1:SpringBoot3引入了SpringBoot2的Sa-Token starter,导致启动报错"未能获取有效的上下文处理器"。解决方案:必须引入sa-token-spring-boot3-starter,版本与dao-redis一致。
-
坑2:微服务项目中,将Sa-Token starter放到父工程,导致子服务启动报错。解决方案:将Sa-Token starter单独放到每个子服务的pom.xml中。
-
坑3:Token有效期配置错误,把秒当成毫秒,导致Token有效期不符合预期。解决方案:记住sa-token配置中,timeout的单位是秒。
-
坑4:拦截器没有排除登录、刷新Token接口,导致用户无法登录。解决方案:在拦截器配置中,excludePathPatterns排除相关接口。
-
坑5:登出时手动删除Redis中的Token,导致Token残留。解决方案:统一用StpUtil.logout()方法销毁双Token。
-
坑6:在非Web上下文(比如@Async方法、定时任务)中调用StpUtil.getLoginId(),导致报错"非web上下文无法获取HttpServletRequest"。解决方案:避免在非Web上下文调用需要获取Token的API,或手动传递用户ID。
-
坑7:密码明文存储、明文比对,存在安全风险。解决方案:用BCrypt等加密算法加密存储密码,比对时解密比对。
六、总结与扩展
到这里,SpringBoot3 + Sa-Token双Token登录认证的实战就完成了,整个流程下来,代码量很少,大部分逻辑都由Sa-Token框架封装完成,不用我们手动处理Token生成、缓存、过期、刷新等繁琐逻辑,极大提升了开发效率。
对比自己手写JWT双Token,Sa-Token的优势很明显:原生支持双Token模式、内置Redis缓存、分布式部署友好、API简洁、异常处理完善,还支持同端互斥登录、Token反查、密钥轮换等实用特性,适合各种规模的SpringBoot项目。
扩展建议(实际项目中可按需扩展):
-
密码加密:用BCrypt加密存储密码,替换示例中的明文校验逻辑。
-
异常统一处理:自定义全局异常处理器,统一返回异常格式,更友好。
-
Token前缀:配置token-name为Authorization,前端请求时拼接Bearer前缀,符合RESTful规范。
-
同端互斥登录:在登录时指定设备类型,配置is-concurrent为false,实现同一账号同端只能登录一次。
-
Token反查:利用Sa-Token 1.42.0的新特性,实现根据用户ID反查Token,用于强制用户登出等场景。
最后说一句:技术选型没有最好的,只有最适合的。Sa-Token轻量、简洁、易用,对于大部分SpringBoot项目来说,完全能满足登录认证的需求,尤其是双Token模式,落地简单,避坑后几乎没有线上问题。
如果大家在实际使用中遇到其他坑,欢迎在评论区交流,一起避坑,一起进步!