Sa-Token ——从登录到鉴权,比 JWT 省一个数量级的代码
项目里接 Sa-Token 的时候发现这玩意儿用起来太省事了,把以前手写 JWT + 拦截器那一大坨全干掉了。这篇文章从 Sa-Token 怎么解决 JWT 的痛点讲起,到整合 Redis 后 token 存哪、权限怎么查,结合项目实际代码一次捋清楚。
前置阅读:如果你还不了解 JWT 以及它有什么问题,可以先看 JWT 学习笔记——从代码入手,拆原理、聊缺陷。
Sa-Token 是什么
简单说:一个把 token 存到 Redis 里的权限框架。你的代码只需要调 StpUtil.login(userId),剩下的 token 生成、存储、校验、踢人、权限——全帮你干了。
跟 JWT 最大的区别:Sa-Token 的 token 存在 Redis 里。所以你能随时踢人、冻结、查在线、改权限——服务端掌握主动权。
项目里怎么引入的
依赖
项目是 Spring Boot 3 + Spring Cloud Gateway 微服务架构。auth 模块负责登录,gateway 模块负责统一鉴权。两边的 starter 不一样,注意区分。
父 pom 统一管版本:
<!-- 父 pom.xml -->
<properties>
<sa-token.version>1.45.0</sa-token.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Servlet 环境用这个(auth 模块) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
<!-- WebFlux/Gateway 环境用这个(gateway 模块) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
<!-- 整合 Redis,用 Jackson 序列化 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>${sa-token.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
auth 模块(Servlet):
<!-- idle-store-auth/pom.xml -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<!-- Redis 整合 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
</dependency>
gateway 模块(WebFlux / Reactor):
<!-- idle-store-gateway/pom.xml -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
</dependency>
⚠️ 坑点:Servlet 环境和 WebFlux 环境的 starter 不一样。auth 模块用
sa-token-spring-boot3-starter,gateway 模块用sa-token-reactor-spring-boot3-starter。搞反了依赖不报错,但功能不生效。
sa-token-redis-jackson 这个包干了什么?其实就是把 Sa-Token 默认的内存存储换成了 Redis,序列化用 Jackson。加上它就自动生效,不用额外配置——Sa-Token 会自动发现 RedisTemplate 并接管存储。
配置文件
auth 和 gateway 两边配置一样(网关需要鉴权所以要配一份):
############## Sa-Token 配置 ##############
sa-token:
# token 名称(同时也是 Cookie 名称)
token-name: Authorization
# token 前缀
token-prefix: Bearer
# token 有效期(单位:秒)30 天
timeout: 2592000
# token 最低活跃频率,超过此时间没访问就冻结,-1 永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录
is-concurrent: true
# 多人登录同一账号时,是否共用一个 token
is-share: false
# token 生成风格:random-128 位随机字符串
token-style: random-128
# 是否打印操作日志
is-log: true
每个配置项的含义:
| 配置 | 含义 | 项目里用的值 | 为什么 |
|---|---|---|---|
token-name |
前端 Header 里传的字段名 | Authorization |
跟 RESTful 规范一致 |
token-prefix |
token 前面的前缀 | Bearer |
标准写法:Authorization: Bearer xxxxx |
timeout |
登录有效期 | 2592000(30天) |
小程序端长期登录,不用反复授权 |
is-concurrent |
允许多端同时登录 | true |
用户可以手机和电脑同时在线 |
is-share |
多端登录共用同一 token | false |
每端独立 token,方便单独踢下线 |
token-style |
token 生成算法 | random-128 |
128 位随机字符串,比 UUID 安全且紧凑 |
is-concurrent 和 is-share 到底怎么影响行为的
项目里配了 is-concurrent: true + is-share: false。这两个组合决定了多端登录时的行为。拆开看。
场景:同一个 userId=10001,先后在手机和电脑上登录。
四种组合的区别:
| is-concurrent | is-share | 效果 |
|---|---|---|
true |
true |
多端可同时登录,共用一个 token |
true |
false |
多端可同时登录,各自独立 token。踢一端不影响另一端 |
false |
true |
只允许一个设备在线,共用 token。新登录顶掉旧登录 |
false |
false |
只允许一个设备在线,各自 token。新登录顶掉旧登录 |
项目选 true + false:用户可以手机和电脑同时在线,每端独立 token。StpUtil.logout() 退出只退当前设备,不会把另一个设备也踢了。
登录——一行代码就完事了
auth 服务里的 Controller
@RestController
@RequestMapping
public class UserController {
@Resource
private UserService userService;
@PostMapping("/mini/login")
public Response<MiniLoginRespVO> miniLoginAndRegister(
@Validated @RequestBody MiniLoginReqVO miniLoginReqVO) {
return userService.miniLoginAndRegister(miniLoginReqVO);
}
@PostMapping("/admin/login")
public Response<AdminLoginRespVO> adminLogin(
@Validated @RequestBody AdminLoginReqVO adminLoginReqVO) {
return userService.adminLogin(adminLoginReqVO);
}
@PostMapping("/admin/logout")
public Response<?> logout() {
return userService.logout();
}
}
Service——核心就两行
@Override
public Response<MiniLoginRespVO> miniLoginAndRegister(MiniLoginReqVO req) {
String phone = req.getPhone();
// 查数据库,用户不存在就注册
UserDO user = userDOMapper.selectByPhone(phone);
if (Objects.isNull(user)) {
user = registerUser(phone);
}
// ******************** 核心就这两行 ********************
StpUtil.login(user.getUserId()); // ① 登录
String token = StpUtil.getTokenInfo().tokenValue; // ② 拿 token
// ****************************************************
// 把 token 和用户信息返回给前端
return Response.success(MiniLoginRespVO.builder()
.userId(user.getUserId())
.token(token)
.phone(phone)
.build());
}
StpUtil.login(userId) 这一行干了什么?
那后续请求来了,Sa-Token 怎么知道是谁?
所以 StpUtil.checkLogin() 背后就是:读 Header → 去 Redis 查 token → 验证 → 拿到 userId。全程自动,你一行都不用写。
登出——一行代码,Redis 里的东西全清掉
@Override
public Response<?> logout() {
StpUtil.logout(); // 退出当前登录
return Response.success();
}
就这么一行。但这一行背后干了什么?
跟 JWT 对比一下登出这件事:
JWT 登出:
服务端不存 token → 没法"删"token → 登出只能靠客户端自己把 token 扔了
→ 但 token 本身还有效!任何人捡到它还能用!
→ 想真正让它失效?建黑名单表
Sa-Token 登出:
Redis 里直接删 → token 瞬间失效 → 下次有人拿这个 token 来 → 查 Redis 查不到 → 未登录
logout() 和 logout(userId) 的区别
| 方法 | 干了什么 | Redis 操作 | 什么时候用 |
|---|---|---|---|
StpUtil.logout() |
踢当前这一端 | 只删当前请求带的这个 token | 用户自己点退出 |
StpUtil.logout(userId) |
踢这个用户所有端 | 把这个 userId 的所有 token 全删了 | 管理员强制踢人、用户改密码后踢掉所有设备 |
用项目配置(is-concurrent: true + is-share: false)举个具体例子:
用户 10001 同时在手机和电脑登录了:
token_A(手机)──┐
├── 都属于 userId=10001
token_B(电脑)──┘
手机点退出 → StpUtil.logout()
→ Redis 里只删 token_A
→ 电脑上的 token_B 仍然有效 ✅
管理员踢人 → StpUtil.logout(10001)
→ Redis 里 token_A 和 token_B 全删
→ 手机和电脑都掉线
项目里的登出 Controller
@PostMapping("/admin/logout")
@ApiOperationLog(description = "管理员登出")
public Response<?> logout() {
return userService.logout();
}
注意这个接口在网关里做了角色校验:
// SaTokenConfigure 里的配置
SaRouter.match("/auth/admin/logout",
r -> StpUtil.checkRole("ROLE_COMMUNITY_ADMIN"));
也就是说:只有管理员角色才能调这个登出接口。普通用户的登出可以另外加一个接口,不做角色限制。
Token 存在 Redis 的哪里
加了 sa-token-redis-jackson 依赖后,token 就不再存内存了,全进 Redis。你可以直接连 Redis 看:

我这里面因为设置多账号登陆不用同一个 token,所以两个地方登陆,生成的是两个 token
跟 JWT 的本质区别就在这:JWT 的 token 里直接编码了所有信息,服务端没存它,管不了。Sa-Token 把 token 存 Redis,随时能查、能删、能冻结——主动权在服务端。
网关怎么鉴权——拦截 + 角色校验
项目用 Spring Cloud Gateway 做统一入口,鉴权也放在网关层。这样请求还没到下游服务就已经被校验了。
全局过滤器
@Configuration
public class SaTokenConfigure {
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// ① 拦截所有请求
.addInclude("/**")
// ② 排除的不拦截
.addExclude("/favicon.ico")
// ③ 鉴权逻辑:每次请求进来都会执行
.setAuth(obj -> {
// 登录校验:除了登录、发验证码接口,其他都要求登录
SaRouter.match("/**")
.notMatch(SaHttpMethod.OPTIONS)
.notMatch("/auth/mini/login")
.notMatch("/auth/admin/login")
.notMatch("/auth/verification/code/send")
.check(r -> StpUtil.checkLogin());
// 角色校验:管理端登出接口,要求管理员角色
SaRouter.match("/auth/admin/logout",
r -> StpUtil.checkRole("ROLE_COMMUNITY_ADMIN"));
})
// ④ 鉴权失败时的异常处理
.setError(e -> {
if (e instanceof NotLoginException) {
throw new NotLoginException(e.getMessage(), null, null);
} else if (e instanceof NotPermissionException
|| e instanceof NotRoleException) {
throw new NotPermissionException(e.getMessage());
} else {
throw new RuntimeException(e.getMessage());
}
});
}
}
执行的流程:
全局异常处理
网关是 WebFlux 的,异常处理跟 Servlet 不一样,需要实现 ErrorWebExceptionHandler:
@Component
@Slf4j
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Resource
private ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
Response<?> result;
if (ex instanceof NotLoginException) {
response.setStatusCode(HttpStatus.UNAUTHORIZED); // 401
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(),
ex.getMessage());
} else if (ex instanceof NotPermissionException) {
response.setStatusCode(HttpStatus.UNAUTHORIZED); // 401
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(),
ResponseCodeEnum.UNAUTHORIZED.getErrorMessage());
} else {
result = Response.fail(ResponseCodeEnum.SYSTEM_ERROR); // 500
}
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return response.writeWith(Mono.fromSupplier(() -> {
DataBufferFactory bufferFactory = response.bufferFactory();
try {
return bufferFactory.wrap(objectMapper.writeValueAsBytes(result));
} catch (Exception e) {
return bufferFactory.wrap(new byte[0]);
}
}));
}
}
把 userId 透传给下游
网关鉴权通过后,下游服务怎么知道当前登录的是谁?在每个请求的 Header 里塞一个 userId:
@Component
@Order(-99) // 优先级很高,在其他过滤器之前执行
public class AddUserId2HeaderFilter implements GlobalFilter {
private static final String HEADER_USER_ID = "userId";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 从 SaToken 上下文里拿到当前登录的 userId
return SaReactorHolder.sync(() -> {
try {
return StpUtil.getLoginIdAsLong();
} catch (Exception e) {
return Optional.<Long>empty();
}
}).flatMap(userIdOptional -> {
if (userIdOptional.isEmpty()) {
return chain.filter(exchange); // 没登录,直接放
}
Long userId = userIdOptional.get();
// 往 Header 里加 userId,下游服务直接取
ServerWebExchange newExchange = exchange.mutate()
.request(builder -> builder.headers(headers -> {
headers.remove(HEADER_USER_ID); // 防止前端伪造
headers.add("userId", String.valueOf(userId));
}))
.build();
return chain.filter(newExchange);
});
}
}
这样下游的 auth 服务就能直接从 Header 拿 userId,不用再解析 token:
// auth 服务里自己写的 Filter
String userId = request.getHeader("userId");
UserContext.setUser(Long.parseLong(userId));
优化思路
上面这套写法已经完全能跑了。这里聊两个后续可以优化的方向,不是必须改,只是当你项目规模上来了可以参考。
优化一:setError 直接返回结果,不走 re-throw
当前 setError 里是 re-throw 异常,交给全局 GlobalExceptionHandler 统一渲染 JSON。这条链路没毛病,但 Sa-Token 其实支持更短的写法——setError 直接返回 SaResult,框架自己就能渲染 JSON 响应:
// 当前写法:re-throw → GlobalExceptionHandler 渲染
.setError(e -> {
if (e instanceof NotLoginException) {
throw new NotLoginException(e.getMessage(), null, null);
}
// ...
});
// 更短的写法:setError 直接返回结果
.setError(e -> {
if (e instanceof NotLoginException) {
return SaResult.error(e.getMessage());
}
if (e instanceof NotPermissionException || e instanceof NotRoleException) {
return SaResult.error("无此权限");
}
return SaResult.error("系统繁忙");
});
两种都行。用 re-throw + GlobalExceptionHandler 的好处是能让所有异常(包括 Sa-Token 之外的)走同一套 JSON 格式。用 return SaResult 的好处是少一层间接调用,GlobalExceptionHandler 只管真正的未知异常,职责更单一。
优化二:路由规则从代码抽到配置文件
现在白名单和角色规则是硬编码的:
.notMatch("/auth/mini/login")
.notMatch("/auth/admin/login")
.notMatch("/auth/verification/code/send")
项目小的时候完全够用。等后面接了订单服务、商品服务、用户服务……SaTokenConfigure 会越来越长。到时候可以抽到 yaml 里:
# 自定义配置
gateway:
auth:
white-list:
- /auth/mini/login
- /auth/admin/login
- /auth/verification/code/send
role-rules:
ROLE_COMMUNITY_ADMIN:
- /auth/admin/logout
- /auth/admin/**
然后 SaTokenConfigure 用 @ConfigurationProperties 读进来,循环注册。新增接口只改配置,不用动代码。
权限怎么查——从数据库同步到 Redis
整体流程
启动时:把 RBAC 数据从数据库刷到 Redis
项目里用 ApplicationRunner 在启动的时候跑一次:
@Component
@RequiredArgsConstructor
public class PushRolePermissions2RedisRunner implements ApplicationRunner {
private final RoleDOMapper roleDOMapper;
private final RolePermissionDOMapper rolePermissionDOMapper;
private final PermissionDOMapper permissionDOMapper;
private final RedisTemplate<String, Object> redisTemplate;
// 分布式锁标记——防止集群多个节点同时刷
private static final String PUSH_PERMISSION_FLAG = "push.permission.flag";
@Override
public void run(ApplicationArguments args) {
// 用 setIfAbsent 做分布式锁,一天内只刷一次
boolean canPushed = redisTemplate.opsForValue()
.setIfAbsent(PUSH_PERMISSION_FLAG, "1", 1, TimeUnit.DAYS);
if (!canPushed) {
return; // 别的节点已经刷过了
}
// 1. 查所有启用的角色
List<RoleDO> roles = roleDOMapper.selectEnabledList();
List<Long> roleIds = roles.stream().map(RoleDO::getId).toList();
// 2. 查这些角色对应的权限关系
List<RolePermissionDO> rolePerms = rolePermissionDOMapper.selectByRoleIds(roleIds);
// 3. 组装:角色 key → 该角色的权限 key 列表
// 比如 "ROLE_COMMUNITY_ADMIN" → ["goods:create", "order:delete"]
Map<String, List<String>> rolePermMap = /* 组装逻辑 */;
// 4. 写入 Redis
rolePermMap.forEach((roleKey, permissions) -> {
String key = RedisKeyConstants.buildRolePermissionsKey(roleKey);
// key: "role:permissions:ROLE_COMMUNITY_ADMIN"
// value: ["goods:create", "order:delete", ...]
redisTemplate.opsForValue().set(key, permissions);
});
}
}
运行时:StpInterfaceImpl 从 Redis 查权限
网关每次做 checkRole() 或 checkPermission() 时,Sa-Token 会调 StpInterface 的实现类去拿当前用户的角色和权限:
@Component
@RequiredArgsConstructor
@Slf4j
public class StpInterfaceImpl implements StpInterface {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
/**
* 返回此 loginId 拥有的权限列表
* Sa-Token 每次做 checkPermission() 时会自动调这个方法
*/
@Override
@SneakyThrows
public List<String> getPermissionList(Object loginId, String loginType) {
log.info("## 获取用户权限列表, loginId: {}", loginId);
Long userId = Long.valueOf(loginId.toString());
// 1. 从 Redis 查该用户的角色列表
// key: "user:roles:10001" → value: ["ROLE_COMMUNITY_ADMIN"]
String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
String userRolesValue = redisTemplate.opsForValue().get(userRolesKey).toString();
if (StringUtils.isBlank(userRolesValue)) {
return Collections.emptyList();
}
// JSON 字符串 → List<String>
// "[\"ROLE_COMMUNITY_ADMIN\"]" → ["ROLE_COMMUNITY_ADMIN"]
List<String> userRoleKeys = objectMapper.readValue(userRolesValue,
new TypeReference<List<String>>() {});
if (CollUtil.isEmpty(userRoleKeys)) {
return Collections.emptyList();
}
// 2. 根据角色,批量查每个角色的权限列表
// "role:permissions:ROLE_COMMUNITY_ADMIN" → ["goods:create", "order:delete", ...]
List<String> rolePermKeys = userRoleKeys.stream()
.map(RedisKeyConstants::buildRolePermissionsKey)
.toList();
// 3. 用 multiGet 批量查 Redis,一次网络往返拿完所有角色的权限
List<String> permValues = redisTemplate.opsForValue()
.multiGet(rolePermKeys)
.stream()
.map(Object::toString)
.toList();
if (CollUtil.isEmpty(permValues)) {
return Collections.emptyList();
}
// 4. 合并所有角色的权限到一个集合里
List<String> allPermissions = new ArrayList<>();
for (String jsonValue : permValues) {
List<String> rolePermissions = objectMapper.readValue(jsonValue,
new TypeReference<List<String>>() {});
allPermissions.addAll(rolePermissions);
}
return allPermissions;
}
/**
* 返回此 loginId 拥有的角色列表
* Sa-Token 每次做 checkRole() 时会自动调这个方法
*/
@Override
@SneakyThrows
public List<String> getRoleList(Object loginId, String loginType) {
log.info("## 获取用户角色列表, loginId: {}", loginId);
Long userId = Long.valueOf(loginId.toString());
String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
List<String> userRolesValue = Convert.toList(String.class,
redisTemplate.opsForValue().get(userRolesKey));
if (CollUtil.isEmpty(userRolesValue)) {
return Collections.emptyList();
}
return userRolesValue;
}
}
这两个方法的调用时机:网关里写了 StpUtil.checkRole("ROLE_COMMUNITY_ADMIN") 时,Sa-Token 会先调 getRoleList() 拿到用户的所有角色,再判断目标角色在不在里面。checkPermission() 同理,调 getPermissionList()。
为什么要用 multiGet?一个用户可能有多个角色,每个角色的权限存在不同的 Redis key 里。multiGet 一次网络往返把所有 key 查回来,比循环 get 少 N-1 次网络开销。
StpInterface 是 Sa-Token 留给你的扩展点。你不用写拦截器、不用写注解,只要实现这两个方法,框架在鉴权的时候自动来调。
用户注册时写入角色
private UserDO registerUser(String phone) {
// ...插入 user 表...
// ...插入 user_role 关联表...
// 往 Redis 写入用户的角色列表
// key: "user:roles:10001" → value: ["ROLE_COMMUNITY_USER"]
List<String> roles = Lists.newArrayList(roleDO.getRoleKey());
String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
redisTemplate.opsForValue().set(userRolesKey, roles);
}
涉及到的 Redis Key
| Key 格式 | 示例 | 谁写的 | 什么时候写 |
|---|---|---|---|
Authorization:token:xxx |
Authorization:token:a1b2c3... |
Sa-Token 框架 | StpUtil.login() 时自动写 |
Authorization:login:token:userId |
Authorization:login:token:10001 |
Sa-Token 框架 | StpUtil.login() 时自动写 |
Authorization:session:userId |
Authorization:session:10001 |
Sa-Token 框架 | StpUtil.login() 时自动创建 |
user:roles:userId |
user:roles:10001 |
业务代码 | 注册时写入 |
role:permissions:roleKey |
role:permissions:ROLE_COMMUNITY_ADMIN |
PushRolePermissions2RedisRunner |
项目启动时一次性同步 |
常用的 API 速查
项目里用到和可能会用到的:
登录 & 登出
// 登录——返回 token
StpUtil.login(userId);
String token = StpUtil.getTokenInfo().tokenValue;
// 登出——踢当前 token
StpUtil.logout();
// 登出——踢这个用户所有 token(所有设备都掉线)
StpUtil.logout(userId);
// 踢人下线(让指定 userId 的 token 全部失效)
StpUtil.kickout(userId);
// 封号(禁用指定 userId,所有 token 都不可用)
StpUtil.disable(userId, 3600); // 封 1 小时
StpUtil.untieDisable(userId); // 解封
获取当前用户
// 拿到当前登录用户的 userId
long userId = StpUtil.getLoginIdAsLong();
// 判断是否登录
boolean isLogin = StpUtil.isLogin();
鉴权
// 判断角色
StpUtil.hasRole("ROLE_COMMUNITY_ADMIN"); // true / false
StpUtil.checkRole("ROLE_COMMUNITY_ADMIN"); // false 时抛 NotRoleException
// 判断权限
StpUtil.hasPermission("goods:create"); // true / false
StpUtil.checkPermission("goods:create"); // false 时抛 NotPermissionException
完整流程串一遍
更多推荐




所有评论(0)