每个后端开发者都曾为权限系统设计犯过难,从最初的 Session+Cookie,到后来的 JWT,再到现在各种框架层出不穷。无论项目大小,一个好用的权限框架能让开发效率翻倍,而 Sa-Token 正是这样一个解决方案。
1. Sa-Token 是什么
Sa-Token 是一个轻量级 Java 权限认证框架,主要解决"谁能访问什么资源"的问题。与 Spring Security 等框架相比,Sa-Token 的 API 设计更加简洁直观,上手成本低,但功能一点不弱。
graph LR
A[Sa-Token核心功能] --> B[登录认证]
A --> C[权限校验]
A --> D[SaSession管理]
A --> E[踢人下线]
A --> F[账号封禁]
A --> G[Redis集成]
A --> H[多账号体系]
A --> I[单点登录]
它提供了登录认证、权限校验、SaSession 管理、单点登录、JWT 集成等一系列功能,只需几行代码就能完成复杂的权限控制。
2. Sa-Token 的集成步骤
2.1 添加依赖
在 Maven 项目中添加依赖:
<!-- Sa-Token权限认证框架 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.34.0</version>
</dependency>
如果需要使用 Redis 存储 token,还需添加:
<!-- Sa-Token整合Redis (使用jackson序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.34.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
2.2 基础配置
在application.yml中添加 Sa-Token 配置:
# Sa-Token配置
sa-token:
# token名称 (同时也是cookie名称)
token-name: satoken
# token有效期,单位s 默认30天
timeout: 2592000
# token临时有效期 (指定时间内无操作就过期) 单位: 秒
activity-timeout: 1800
# 是否允许同一账号多端登录
is-concurrent: true
# 在多人登录同一账号时,是否共用一个token
is-share: false
# token风格
token-style: uuid
# 是否输出操作日志
is-log: false
2.3 配置拦截器
创建配置类实现接口拦截和权限校验:
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册Sa-Token拦截器,打开注解式鉴权功能
registry.addInterceptor(new SaInterceptor(handler -> {
// 登录验证 -- 拦截所有接口,并排除登录相关接口
// 注意:match匹配所有路径后,notMatch具有更高优先级,能确保排除路径不被拦截
SaRouter.match("/**")
.notMatch("/user/login", "/user/register", "/doc/**")
.check(r -> StpUtil.checkLogin());
// 角色验证 -- 拦截以/admin/开头的所有接口
SaRouter.match("/admin/**", r -> StpUtil.checkRole("admin"));
// 权限校验 -- 不同接口校验不同权限
SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
SaRouter.match("/admin/user/**", r -> StpUtil.checkPermission("user-manage"));
SaRouter.match("/admin/role/**", r -> StpUtil.checkPermission("role-manage"));
})).addPathPatterns("/**");
}
}
3. Sa-Token 基本用法
3.1 登录认证
实现用户登录鉴权,包含异常处理:
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@PostMapping("/login")
public Result login(@RequestBody LoginDTO loginDTO, HttpServletResponse response,
HttpServletRequest request) {
try {
// 防止频繁登录尝试,基于Redis实现限流
String limitKey = "login:limit:" + loginDTO.getUsername();
if (redisTemplate.hasKey(limitKey)) {
Long attemptCount = redisTemplate.opsForValue().increment(limitKey);
if (attemptCount > 5) {
return Result.error("登录尝试次数过多,请15分钟后再试");
}
} else {
// 设置计数器,15分钟过期
redisTemplate.opsForValue().set(limitKey, "1", 15, TimeUnit.MINUTES);
}
// 校验用户名密码
User user = userService.login(loginDTO.getUsername(), loginDTO.getPassword());
if (user != null) {
// 账号状态检查
if (user.getStatus() == 0) {
return Result.error("账号已被禁用,请联系管理员");
}
// 登录成功,记录用户id
StpUtil.login(user.getId());
// 将用户基本信息和部门ID存入SaSession,减少后续数据库查询
StpUtil.getSession().set("user", user);
StpUtil.getSession().set("deptId", user.getDeptId());
// 登录成功后,清除限制计数
redisTemplate.delete(limitKey);
// 安全存储Token (使用HttpOnly Cookie)
String tokenValue = StpUtil.getTokenValue();
Cookie cookie = new Cookie(StpUtil.getTokenName(), tokenValue);
cookie.setPath("/");
cookie.setHttpOnly(true);
// 在HTTPS环境下设置Secure标志
if (request.isSecure()) {
cookie.setSecure(true);
}
response.addCookie(cookie);
return Result.ok().message("登录成功");
}
return Result.error("用户名或密码错误");
} catch (AccountLockException e) {
// 自定义异常:账号被锁定
return Result.error("账号已被锁定,请联系管理员");
} catch (Exception e) {
log.error("登录异常", e);
return Result.error("登录失败,系统异常");
}
}
@GetMapping("/logout")
public Result logout(HttpServletResponse response) {
StpUtil.logout();
// 清除客户端Cookie
Cookie cookie = new Cookie(StpUtil.getTokenName(), null);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
return Result.ok().message("退出成功");
}
@GetMapping("/info")
public Result getUserInfo() {
// 获取当前登录用户id
Long userId = StpUtil.getLoginIdAsLong();
// 优先从SaSession中获取用户数据,减少数据库查询
User user = (User) StpUtil.getSession().get("user");
if (user == null) {
// SaSession中没有,则从数据库查询
user = userService.getById(userId);
// 存入SaSession便于下次使用
StpUtil.getSession().set("user", user);
}
return Result.ok().data("userInfo", user);
}
}
这个登录流程可以通过以下时序图理解:
3.2 全局异常处理
为了统一处理 Sa-Token 抛出的异常,添加全局异常处理器:
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理Sa-Token未登录异常
*/
@ExceptionHandler(NotLoginException.class)
public Result handleNotLoginException(NotLoginException e) {
// 根据不同的未登录类型返回不同的提示信息
String message = "";
if(e.getType().equals(NotLoginException.NOT_TOKEN)) {
message = "未提供Token";
} else if(e.getType().equals(NotLoginException.INVALID_TOKEN)) {
message = "Token无效";
} else if(e.getType().equals(NotLoginException.TOKEN_TIMEOUT)) {
message = "Token已过期";
} else if(e.getType().equals(NotLoginException.BE_REPLACED)) {
message = "账号已在其他设备登录";
} else if(e.getType().equals(NotLoginException.KICK_OUT)) {
message = "账号已被强制下线";
} else {
message = "当前会话未登录";
}
return Result.error(401, "未登录", message);
}
/**
* 处理Sa-Token权限不足异常
*/
@ExceptionHandler(NotPermissionException.class)
public Result handleNotPermissionException(NotPermissionException e) {
return Result.error(403, "权限不足", "无" + e.getPermission() + "权限");
}
/**
* 处理Sa-Token角色不足异常
*/
@ExceptionHandler(NotRoleException.class)
public Result handleNotRoleException(NotRoleException e) {
return Result.error(403, "角色不足", "无" + e.getRole() + "角色");
}
}
3.3 权限校验
Sa-Token 提供了多种权限校验方式:
3.3.1 基于角色的访问控制
// 判断当前用户是否拥有指定角色
boolean hasRole = StpUtil.hasRole("admin");
// 判断当前用户是否拥有指定角色(抛出异常)
StpUtil.checkRole("admin");
// 判断当前用户是否拥有指定角色(多个)
StpUtil.checkRoleOr("admin", "manager"); // 只要有其一即可
StpUtil.checkRoleAnd("admin", "manager"); // 必须全部拥有
3.3.2 基于权限的访问控制
// 判断当前用户是否拥有指定权限
boolean hasPerm = StpUtil.hasPermission("user:add");
// 判断当前用户是否拥有指定权限(抛出异常)
StpUtil.checkPermission("user:add");
// 判断当前用户是否拥有指定权限(多个)
StpUtil.checkPermissionOr("user:add", "user:edit"); // 只要有其一即可
StpUtil.checkPermissionAnd("user:add", "user:edit"); // 必须全部拥有
3.3 注解式鉴权
Sa-Token 的注解式鉴权让代码更加优雅,是对StpUtil相关方法的封装。注意注解的执行顺序在拦截器之后,适用于对接口进行细粒度权限控制:
@RestController
@RequestMapping("/admin")
public class AdminController {
// 登录认证:只有登录后才能访问
@SaCheckLogin
@GetMapping("/index")
public Result index() {
return Result.ok().message("管理员首页");
}
// 角色认证:必须具有admin角色才能访问
@SaCheckRole("admin")
@GetMapping("/users")
public Result userList() {
return Result.ok().data("userList", userService.list());
}
// 权限认证:必须具有user:add权限才能访问
@SaCheckPermission("user:add")
@PostMapping("/user")
public Result addUser(@RequestBody User user) {
userService.save(user);
return Result.ok().message("添加用户成功");
}
// 组合认证,必须具有所有权限才能访问
@SaCheckPermission({"user:update", "user:info"})
@PutMapping("/user")
public Result updateUser(@RequestBody User user) {
userService.updateById(user);
return Result.ok().message("更新用户成功");
}
}
4. Sa-Token 高级应用
4.1 会话管理
Sa-Token 的 SaSession 功能强大而灵活,能高效存储用户相关数据:
// 获取当前会话的SaSession对象
SaSession session = StpUtil.getSession();
// 在SaSession中存储数据
session.set("username", "张三");
session.set("age", 18);
// 从SaSession中取出数据
String username = session.getString("username"); // 取值并转String
int age = session.getInt("age"); // 取值并转int
// 删除SaSession中的数据
session.delete("age");
// 一次性获取多个数据并转换为对象
UserInfo userInfo = session.getModel("userInfo", UserInfo.class);
4.2 踢人下线与账号封禁
在管理系统中,经常需要处理强制用户下线等操作:
@RestController
@RequestMapping("/admin/user")
public class UserManageController {
// 踢指定用户下线
@SaCheckPermission("user:kick")
@PostMapping("/kick/{userId}")
public Result kick(@PathVariable Long userId) {
// 踢人下线
StpUtil.kickout(userId);
// 记录操作日志
logService.saveLog("用户管理", "踢下线", "用户ID:" + userId);
return Result.ok().message("用户已被踢下线");
}
// 封禁指定账号
@SaCheckPermission("user:disable")
@PostMapping("/disable/{userId}")
public Result disable(@PathVariable Long userId, Integer days) {
// 参数校验
if (days <= 0) {
return Result.error("封禁天数必须大于0");
}
// 封禁时间,单位:秒
long disableTime = days * 24 * 60 * 60;
StpUtil.disable(userId, disableTime);
// 记录操作日志
logService.saveLog("用户管理", "账号封禁", "用户ID:" + userId + ", 天数:" + days);
return Result.ok().message("账号已被封禁");
}
// 解除封禁
@SaCheckPermission("user:enable")
@PostMapping("/enable/{userId}")
public Result enable(@PathVariable Long userId) {
StpUtil.untieDisable(userId);
// 记录操作日志
logService.saveLog("用户管理", "解除封禁", "用户ID:" + userId);
return Result.ok().message("账号已解除封禁");
}
// 判断账号是否被封禁
@GetMapping("/isDisable/{userId}")
public Result isDisable(@PathVariable Long userId) {
boolean isDisable = StpUtil.isDisable(userId);
return Result.ok().data("isDisable", isDisable);
}
}
4.3 多账号体系
Sa-Token 支持多账号体系,可处理不同类型用户的认证。以下是非泛型工具类实现,专注于账号类型管理:
// 多账号体系基础工具类
public class StpAccountUtil {
// 账号类型对应的StpLogic
private final StpLogic stpLogic;
private final String accountType;
/**
* 创建指定账号类型的工具类
* @param accountType 账号类型标识
*/
public StpAccountUtil(String accountType) {
this.accountType = accountType;
stpLogic = new StpLogic(accountType);
// 可以在这里配置该账号类型的特殊设置
}
/**
* 为当前账号类型设置独立配置
* @param timeout token有效期(秒)
* @param tokenPrefix token前缀
* @param tokenStyle token风格
*/
public void setConfig(long timeout, String tokenPrefix, String tokenStyle) {
SaTokenConfig config = stpLogic.getConfig();
if (timeout > 0) {
config.setTimeout(timeout);
}
if (tokenPrefix != null) {
config.setTokenPrefix(tokenPrefix);
}
if (tokenStyle != null) {
config.setTokenStyle(tokenStyle);
}
}
// 登录方法
public void login(Object id) {
stpLogic.login(id);
}
// 登出方法
public void logout() {
stpLogic.logout();
}
// 检查是否登录
public void checkLogin() {
stpLogic.checkLogin();
}
// 获取当前登录ID
public Object getLoginId() {
return stpLogic.getLoginId();
}
// 获取当前会话的Session
public SaSession getSession() {
return stpLogic.getSession();
}
// 获取账号类型
public String getAccountType() {
return accountType;
}
// 获取底层StpLogic
public StpLogic getStpLogic() {
return stpLogic;
}
}
// 实际使用示例 - 管理员账号工具类
public class StpAdminUtil {
private static final StpAccountUtil helper = new StpAccountUtil("admin");
static {
// 管理员token有效期2小时,使用特殊前缀
helper.setConfig(7200, "ADMIN_", "simple-uuid");
}
// 封装静态方法以便调用
public static void login(Object id) {
helper.login(id);
}
public static void logout() {
helper.logout();
}
public static void checkLogin() {
helper.checkLogin();
}
// 更多方法...
}
// 使用方式
StpUtil.login(10001); // 默认账号登录
StpAdminUtil.login(20001); // 管理员账号登录
4.4 集成 Redis
在分布式环境中,通常需要使用 Redis 存储会话信息,包括 Redis 集群支持:
// 自定义Redis键值前缀和序列化方式
@Configuration
public class SaTokenRedisConfig {
@Bean
public SaTokenDao saTokenDao(RedissonClient redissonClient) {
// 使用Redisson客户端以支持Redis集群
return new SaTokenDaoRedisson(redissonClient,
// 自定义Redis前缀,避免Redis键冲突
"my-project:");
}
// 配置Redisson客户端(支持集群模式)
@Bean
public RedissonClient redissonClient() {
// 集群模式配置
Config config = new Config();
// 判断环境,选择适当的Redis连接方式
if ("prod".equals(environment.getActiveProfiles()[0])) {
// 生产环境使用集群配置
ClusterServersConfig clusterConfig = config.useClusterServers();
clusterConfig.addNodeAddress(
"redis://192.168.1.100:6379",
"redis://192.168.1.101:6379",
"redis://192.168.1.102:6379"
);
// 设置密码
clusterConfig.setPassword(environment.getProperty("redis.password"));
// 集群扫描间隔
clusterConfig.setScanInterval(2000);
} else {
// 开发环境使用单机模式
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
}
// 配置序列化,使用更高效的Kryo序列化
config.setCodec(new KryoCodec());
return Redisson.create(config);
}
}
当使用 Redis 作为存储方式时,Sa-Token 的数据流转如下:
graph TD
A[用户请求] --> B[拦截器验证Token]
B --> C{Redis中Token是否有效?}
C -->|有效| D[从Redis读取用户Session]
C -->|无效| E[拒绝请求并返回401]
D --> F[权限/角色校验]
F -->|校验通过| G[处理业务逻辑]
F -->|校验失败| H[拒绝请求并返回403]
G --> I[更新Token活跃时间]
I --> J[返回业务数据]
4.5 JWT 集成
如果项目需要使用 JWT,Sa-Token 也提供了完善的支持:
<!-- Sa-Token 整合 jwt -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-jwt</artifactId>
<version>1.34.0</version>
</dependency>
使用 JWT 的配置:
@Configuration
public class SaTokenJwtConfig {
// 注入JWT配置Bean,Sa-Token与JWT整合
@Bean
public StpLogic getStpLogicJwt() {
// 使用不创建Session的JWT模式
return new StpLogicJwtForSimple();
}
// 自定义JWT秘钥,不在配置文件中明文存储
@Bean
public SaJwtUtil getSaJwtUtil() {
SaJwtUtil saJwtUtil = SaJwtUtil.getInstance();
// 从环境变量中读取密钥,不要使用配置文件存储
String secretKey = System.getenv("JWT_SECRET_KEY");
if (secretKey == null || secretKey.isEmpty()) {
// 仅开发环境使用,生产环境必须设置环境变量
if (!"dev".equals(env.getActiveProfiles()[0])) {
throw new RuntimeException("生产环境必须设置JWT_SECRET_KEY环境变量");
}
secretKey = "开发环境测试密钥,生产环境必须更换";
}
saJwtUtil.setSecretKey(secretKey);
return saJwtUtil;
}
}
4.6 Token 续签与过期策略
Sa-Token 提供了两种 Token 有效期机制:固定有效期和临时有效期(滑动过期)。使用 Redisson 分布式锁确保并发环境下安全续签:
@RestController
@RequestMapping("/token")
public class TokenExampleController {
@Autowired
private RedissonClient redissonClient;
@GetMapping("/explain")
public Result explainTokenTimeout() {
// 获取当前配置
long timeout = SaManager.getConfig().getTimeout();
long activityTimeout = SaManager.getConfig().getActivityTimeout();
Map<String, Object> map = new HashMap<>();
map.put("timeout", timeout / 3600 + "小时");
map.put("activityTimeout", activityTimeout / 60 + "分钟");
// Token过期策略解释
String explanation = "Sa-Token有两种过期策略:\n" +
"1. 固定有效期: Token从创建开始计时,到达timeout后必定过期\n" +
"2. 临时有效期: Token在使用过程中,如果超过activityTimeout时间未操作,则Token过期";
map.put("explanation", explanation);
// 当前会话剩余有效期
if (StpUtil.isLogin()) {
map.put("tokenTimeout", StpUtil.getTokenTimeout() + "秒");
map.put("sessionTimeout", StpUtil.getSessionTimeout() + "秒");
}
return Result.ok().data(map);
}
/**
* 使用分布式锁实现Token续期,避免并发问题
* 适用于前后端分离且无法使用Cookie场景
*/
@PostMapping("/renew")
public Result renewToken() {
if (StpUtil.isLogin()) {
Long userId = StpUtil.getLoginIdAsLong();
// 定义锁名称,按用户ID区分
String lockKey = "token:renew:" + userId;
// 获取分布式锁
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,等待1秒,锁过期时间5秒
boolean isLocked = lock.tryLock(1, 5, TimeUnit.SECONDS);
if (isLocked) {
try {
// 检查是否临近过期(例如还有不到12小时过期)
long remainingTime = StpUtil.getTokenTimeout();
if (remainingTime < 43200) {
// 更新Token有效期 (createNewToken参数控制是否创建新Token)
StpUtil.renewTimeout(false);
return Result.ok().message("Token已续期").data("newTimeout", StpUtil.getTokenTimeout());
}
return Result.ok().message("Token有效期充足,无需续期");
} finally {
// 确保锁释放
lock.unlock();
}
} else {
return Result.ok().message("正在处理中,请稍后再试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.error("操作被中断");
} catch (Exception e) {
return Result.error("续期失败:" + e.getMessage());
}
}
return Result.error("未登录");
}
}
举个简单的例子说明两种过期机制:
- 固定有效期像护照,从颁发日期开始计时,无论你使用多少次出国旅行,到期日一到就必须更换
- 临时有效期像银行ATM操作,只要你持续进行交易,会话就会保持活跃,但如果几分钟不做任何操作,系统会自动结束会话并要求重新插卡输密码
4.7 单点登录
在企业级应用中,单点登录(SSO)是必不可少的功能:
<!-- Sa-Token 整合SSO -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-sso</artifactId>
<version>1.34.0</version>
</dependency>
SSO 服务端配置:
@Configuration
public class SaTokenSsoServerConfig {
@Bean
public SaSsoConfig getSaSsoConfig() {
return new SaSsoConfig()
// 配置:未登录时返回的地址
.setNotLoginUrl("/sso/login")
// 配置:ticket有效期 (单位: 秒),安全性较高的系统推荐使用短期ticket
.setTicketTimeout(180)
// 配置:所有允许的授权回调地址,确保仅信任域名能获取ticket
.setAllowUrl("http://client1.example.com/**,http://client2.example.com/**")
// 配置:是否打开单点注销功能
.setIsSlo(true)
// 配置:是否打开模式三(跨域模式)
.setIsHttp(true)
// 配:登录处理函数
.setDoLoginHandle((name, pwd) -> {
// 处理登录逻辑
if("admin".equals(name) && "123456".equals(pwd)) {
// 此处可以查询数据库验证用户
StpUtil.login(10001);
// 向Session中记录登录信息
StpUtil.getSession().set("userInfo", new UserInfo(10001, name));
return SaResult.ok("登录成功");
}
return SaResult.error("登录失败");
});
}
}
SSO 客户端配置:
@Configuration
public class SaTokenSsoClientConfig {
@Bean
public SaSsoConfig getSaSsoConfig() {
return new SaSsoConfig()
// 配置客户端域名
.setSsoClientUrl("http://client.example.com:8081")
// 配置SSO认证中心地址
.setSsoServerUrl("http://sso.example.com:8080")
// 配置ticket校验地址
.setCheckTicketUrl("/sso/checkTicket")
// 配置单点注销功能
.setIsSlo(true);
}
}
单点登录的实现流程如下(包含完整安全细节):
4.8 微服务架构集成
在微服务架构中,通常会在网关层统一处理认证,Sa-Token 提供了与 Spring Cloud Gateway 的整合方案:
<!-- Sa-Token整合Gateway -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot-starter</artifactId>
<version>1.34.0</version>
</dependency>
Gateway 网关配置:
@Configuration
public class SaTokenGatewayConfig {
// 注册全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**")
// 排除地址
.addExclude("/auth/login", "/auth/register", "/public/**")
// 设置认证函数
.setAuth(obj -> {
// 登录校验
SaRouter.match("/**",
r -> StpUtil.checkLogin());
// 权限校验等...
})
// 设置异常处理
.setError(e -> {
// 处理认证异常,返回与普通Web应用相同的格式
// 这确保网关层异常响应与普通控制器异常响应格式一致
if (e instanceof NotLoginException) {
NotLoginException nle = (NotLoginException) e;
return SaResult.code(401)
.message("未登录")
.setData("type", nle.getType())
.setData("msg", getNotLoginMessage(nle));
}
if (e instanceof NotPermissionException) {
NotPermissionException npe = (NotPermissionException) e;
return SaResult.code(403)
.message("无权限")
.setData("permission", npe.getPermission());
}
return SaResult.error(e.getMessage())
.setData("cause", e.getClass().getSimpleName());
});
}
// 保持与GlobalExceptionHandler中相同的异常消息逻辑
private String getNotLoginMessage(NotLoginException e) {
if(e.getType().equals(NotLoginException.NOT_TOKEN)) {
return "未提供Token";
} else if(e.getType().equals(NotLoginException.INVALID_TOKEN)) {
return "Token无效";
} else if(e.getType().equals(NotLoginException.TOKEN_TIMEOUT)) {
return "Token已过期";
} else if(e.getType().equals(NotLoginException.BE_REPLACED)) {
return "账号已在其他设备登录";
} else if(e.getType().equals(NotLoginException.KICK_OUT)) {
return "账号已被强制下线";
}
return "当前会话未登录";
}
}
用户信息传递到微服务,并增强审计日志:
@Configuration
public class GatewayConfig {
@Bean
public GlobalFilter customGlobalFilter() {
return (exchange, chain) -> {
ServerHttpRequest.Builder builder = exchange.getRequest().mutate();
// 获取Token信息
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (token != null && StpUtil.getTokenInfo().isLogin()) {
// 用户已登录,传递用户信息到下游服务
Long userId = StpUtil.getLoginIdAsLong();
String username = StpUtil.getSession().getString("username");
// 添加请求头传递用户信息
builder.header("X-User-Id", String.valueOf(userId));
builder.header("X-Username", username);
// 记录请求URI和方法,用于审计日志
String requestUri = exchange.getRequest().getURI().getPath();
String method = exchange.getRequest().getMethod().name();
// 标准化URI,去除路径变量,便于分析,例如/user/123 -> /user/{id}
String normalizedUri = normalizeUri(requestUri);
StpUtil.getSession().set("lastRequestUri", normalizedUri);
StpUtil.getSession().set("lastRequestMethod", method);
StpUtil.getSession().set("lastRequestTime", LocalDateTime.now().toString());
// 可选:传递权限信息
List<String> permissions = StpUtil.getPermissionList();
if (!permissions.isEmpty()) {
builder.header("X-Permissions", String.join(",", permissions));
}
}
// 构建新的请求
ServerHttpRequest newRequest = builder.build();
return chain.filter(exchange.mutate().request(newRequest).build());
};
}
/**
* 标准化URI,将路径变量替换为模板
* 例如:/user/123 -> /user/{id}
*/
private String normalizeUri(String uri) {
// 正则表达式匹配常见的ID模式
String normalized = uri.replaceAll("/\\d+", "/{id}");
// 处理UUID格式
normalized = normalized.replaceAll("/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", "/{uuid}");
return normalized;
}
}
微服务端获取用户信息:
@RestController
@RequestMapping("/api")
public class SampleController {
@GetMapping("/data")
public Result getData(HttpServletRequest request) {
// 从请求头获取网关传递的用户信息
String userId = request.getHeader("X-User-Id");
String username = request.getHeader("X-Username");
log.info("当前用户: {}, ID: {}", username, userId);
// 处理业务逻辑...
return Result.ok().data("message", "Hello, " + username);
}
}
5. 实际业务案例
5.1 RBAC 权限模型实现
基于角色的访问控制(RBAC)是企业应用中常用的权限模型:
// 实现自定义权限验证接口
@Component
public class StpInterfaceImpl implements StpInterface {
@Autowired
private SysUserService userService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 返回指定用户的权限列表
* 此方法在每次访问需要鉴权的接口时都会被调用,性能敏感
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 获取用户ID
Long userId = Long.valueOf(loginId.toString());
// 先尝试从Redis缓存中获取权限列表,提高性能
String permCacheKey = "perm:user:" + userId;
List<String> permList = (List<String>) redisTemplate.opsForValue().get(permCacheKey);
if (permList == null) {
// 缓存中没有,则从数据库查询
permList = userService.getUserPermissions(userId);
// 存入Redis缓存,设置适当的过期时间
redisTemplate.opsForValue().set(permCacheKey, permList, 30, TimeUnit.MINUTES);
}
return permList;
}
/**
* 返回指定用户的角色列表
* 此方法在每次访问需要角色认证的接口时都会被调用,性能敏感
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
Long userId = Long.valueOf(loginId.toString());
// 先尝试从Redis缓存中获取角色列表
String roleCacheKey = "role:user:" + userId;
List<String> roleList = (List<String>) redisTemplate.opsForValue().get(roleCacheKey);
if (roleList == null) {
// 缓存中没有,则从数据库查询
roleList = userService.getUserRoles(userId);
// 存入Redis缓存
redisTemplate.opsForValue().set(roleCacheKey, roleList, 30, TimeUnit.MINUTES);
}
return roleList;
}
/**
* 清除用户权限缓存
* 在用户权限变更时调用此方法
*/
public void clearUserPermissionCache(Long userId) {
String permCacheKey = "perm:user:" + userId;
String roleCacheKey = "role:user:" + userId;
redisTemplate.delete(permCacheKey);
redisTemplate.delete(roleCacheKey);
}
}
数据库设计:
5.2 数据权限控制
在企业应用中,除了功能权限外,数据权限也非常重要。通过 MyBatis 拦截器自动处理数据权限过滤,避免 SQL 注入风险:
@Component
@Intercepts({@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)})
public class DataPermissionInterceptor implements Interceptor {
@Autowired
private DataPermissionHelper permissionHelper;
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
// 获取SQL
BoundSql boundSql = (BoundSql) metaObject.getValue("delegate.boundSql");
String sql = boundSql.getSql();
// 判断是否需要数据权限过滤
if (needDataPermission(sql)) {
// 获取当前用户角色
List<String> roles = StpUtil.getRoleList();
// 根据角色获取数据权限类型
DataPermissionType permType = permissionHelper.getPermissionType(roles);
// 根据数据权限类型构建SQL条件
String permissionSql = buildDataPermissionSql(permType);
// 将条件拼接到原SQL中
if (permissionSql != null && !permissionSql.isEmpty()) {
sql = addConditionToSql(sql, permissionSql);
metaObject.setValue("delegate.boundSql.sql", sql);
}
}
return invocation.proceed();
}
// 判断SQL是否需要数据权限控制
private boolean needDataPermission(String sql) {
// 例如判断是否是查询语句,以及是否包含特定表名
String upperSql = sql.toUpperCase();
return upperSql.contains("SELECT") &&
(upperSql.contains("SYS_ORDER") || upperSql.contains("SYS_USER"));
}
// 根据权限类型构建SQL条件,防止SQL注入
private String buildDataPermissionSql(DataPermissionType permType) {
// 获取安全参数值
Long userId = null;
Long deptId = null;
try {
// 安全获取用户ID和部门ID
if (StpUtil.isLogin()) {
userId = StpUtil.getLoginIdAsLong();
deptId = StpUtil.getSession().getLong("deptId");
}
} catch (Exception e) {
log.error("获取用户信息失败", e);
return null;
}
// 根据权限类型返回安全的SQL条件
switch (permType) {
case ALL:
// 管理员可以查看所有数据
return null;
case DEPT:
// 部门经理只能查看本部门数据
// 注意:这里不直接拼接参数值,而是确保数值安全
if (deptId != null && deptId > 0) {
return "dept_id = " + deptId;
}
return "1=0"; // 无效部门ID时不返回数据
case SELF:
// 普通用户只能查看自己的数据
if (userId != null && userId > 0) {
return "create_user = " + userId;
}
return "1=0"; // 无效用户ID时不返回数据
default:
return "1=0"; // 未知权限类型不返回数据
}
}
// 将条件添加到SQL中
private String addConditionToSql(String sql, String condition) {
// 简单实现:在WHERE后添加条件,或添加WHERE子句
if (sql.toUpperCase().contains("WHERE")) {
return sql.replaceFirst("(?i)WHERE", "WHERE (" + condition + ") AND ");
} else if (sql.toUpperCase().contains("ORDER BY")) {
return sql.replaceFirst("(?i)ORDER BY", "WHERE " + condition + " ORDER BY");
} else {
return sql + " WHERE " + condition;
}
}
// 定义数据权限类型枚举
public enum DataPermissionType {
ALL, // 全部数据
DEPT, // 部门数据
SELF // 个人数据
}
}
业务层使用数据权限:
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Override
public List<Order> getOrderList(OrderQueryDTO queryDTO) {
// 构建查询条件
OrderExample example = new OrderExample();
OrderExample.Criteria criteria = example.createCriteria();
// 构建排序
example.setOrderByClause("create_time desc");
// 应用查询参数
if (queryDTO.getOrderStatus() != null) {
criteria.andOrderStatusEqualTo(queryDTO.getOrderStatus());
}
// 数据权限由MyBatis拦截器自动处理,业务代码无需关注
return orderMapper.selectByExample(example);
}
}
这种方式比手动编写数据权限过滤条件更加灵活,减少了代码重复并提高了可维护性。
6. 安全和性能优化
6.1 安全增强措施
@Configuration
public class SecurityConfig {
@Autowired
private LogService logService;
@Bean
public void configSaToken() {
// 注册侦听器,记录关键事件
SaTokenListener saTokenListener = new SaTokenListener() {
@Override
public void doLogin(String loginType, Object loginId, String tokenValue) {
// 获取当前请求中的IP和设备信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String ip = IpUtil.getIpAddr(request);
String userAgent = request.getHeader("User-Agent");
// 获取并标准化请求URI
String requestUri = request.getRequestURI();
String normalizedUri = normalizeUri(requestUri);
String method = request.getMethod();
// 记录登录日志,包含完整请求信息用于审计
logService.saveLoginLog(loginId.toString(), ip, userAgent,
"登录成功", normalizedUri, method);
// 异地登录检测
if (isLoginFromNewLocation(loginId.toString(), ip)) {
// 发送安全提醒邮件或短信
notifySecurityAlert(loginId.toString(), ip);
}
}
}
@Override
public void doLogout(String loginType, Object loginId, String tokenValue) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String requestUri = request.getRequestURI();
String normalizedUri = normalizeUri(requestUri);
String method = request.getMethod();
logService.saveLoginLog(loginId.toString(), IpUtil.getIpAddr(request),
request.getHeader("User-Agent"),
"用户注销", normalizedUri, method);
}
}
@Override
public void doKickout(String loginType, Object loginId, String tokenValue) {
logService.saveLoginLog(loginId.toString(), null, null,
"用户被踢下线", null, null);
}
@Override
public void doReplaced(String loginType, Object loginId, String tokenValue) {
logService.saveLoginLog(loginId.toString(), null, null,
"账号被顶下线", null, null);
}
@Override
public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {
logService.saveLoginLog(loginId.toString(), null, null,
"账号被封禁, 封禁时间: " + disableTime + "秒", null, null);
}
};
// 注册侦听器
SaTokenEventCenter.registerListener(saTokenListener);
}
/**
* 标准化URI,将路径变量替换为模板,便于后续分析
*/
private String normalizeUri(String uri) {
if (uri == null) return null;
// 正则表达式匹配常见的ID模式
String normalized = uri.replaceAll("/\\d+", "/{id}");
// 处理UUID格式
normalized = normalized.replaceAll("/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", "/{uuid}");
return normalized;
}
// 判断是否异地登录
private boolean isLoginFromNewLocation(String userId, String ip) {
// 实现IP地址检测逻辑
return false;
}
// 发送安全提醒
private void notifySecurityAlert(String userId, String ip) {
// 实现邮件或短信提醒逻辑
}
}
6.2 动态调整配置
Sa-Token 支持在运行时调整配置,适用于需要根据系统负载动态变更安全策略的场景:
@RestController
@RequestMapping("/admin/config")
public class ConfigController {
/**
* 动态调整Token有效期(小时)
*/
@SaCheckRole("super-admin")
@PostMapping("/token-timeout")
public Result updateTokenTimeout(@RequestParam int hours) {
if (hours <= 0 || hours > 720) { // 最多30天
return Result.error("有效期范围:1-720小时");
}
// 动态设置全局Token有效期
SaManager.getConfig().setTimeout(hours * 3600);
// 记录操作日志
logService.saveLog("系统配置", "更新Token有效期", "设置为" + hours + "小时");
return Result.ok().message("设置成功");
}
/**
* 动态调整临时有效期(分钟)
*/
@SaCheckRole("super-admin")
@PostMapping("/activity-timeout")
public Result updateActivityTimeout(@RequestParam int minutes) {
if (minutes <= 0 || minutes > 1440) { // 最多24小时
return Result.error("临时有效期范围:1-1440分钟");
}
// 动态设置临时有效期
SaManager.getConfig().setActivityTimeout(minutes * 60);
return Result.ok().message("设置成功");
}
/**
* 获取当前配置信息
*/
@SaCheckRole("super-admin")
@GetMapping("/current")
public Result getCurrentConfig() {
Map<String, Object> config = new HashMap<>();
SaTokenConfig saConfig = SaManager.getConfig();
config.put("tokenName", saConfig.getTokenName());
config.put("timeout", saConfig.getTimeout() / 3600 + "小时");
config.put("activityTimeout", saConfig.getActivityTimeout() / 60 + "分钟");
config.put("isConcurrent", saConfig.getIsConcurrent());
return Result.ok().data("config", config);
}
}
6.3 性能优化配置
sa-token:
# 使用动态token风格,适应分布式环境
token-style: uuid
# 开启token前缀模式
token-prefix: "Bearer"
# 从header中读取token
is-read-header: true
# 适当调整token有效期,避免频繁刷新
timeout: 2592000
# 开启共享token模式,适应集群环境
is-share: true
# 热点数据冷却时间(秒), 提高并发性能
data-refresh-period: 30
# 临时有效期(秒), 不频繁更新活跃状态
activity-timeout: 1800
Redis 配置优化:
spring:
redis:
database: 1
host: 127.0.0.1
port: 6379
password: ${REDIS_PASSWORD:}
timeout: 10s
lettuce:
pool:
max-active: 200
max-wait: -1ms
max-idle: 10
min-idle: 0
7. 总结
通过本文,我们了解了 Sa-Token 的基本用法和高级应用场景。总结 Sa-Token 的主要特点:
| 功能 | 实现方式 | 应用场景 | 性能影响 | 复杂度 |
|---|---|---|---|---|
| 登录认证 | StpUtil.login(id) | 用户登录 | 低 | 低 |
| 权限校验 | StpUtil.checkPermission() | 功能权限控制 | 中 | 中 |
| 角色校验 | StpUtil.checkRole() | 角色访问控制 | 中 | 中 |
| 注解鉴权 | @SaCheckLogin, @SaCheckPermission | 接口权限控制 | 低 | 低 |
| 会话管理 | StpUtil.getSession() | 用户数据存储 | 中 | 低 |
| 踢人下线 | StpUtil.kickout(id) | 强制用户离线 | 低 | 低 |
| 账号封禁 | StpUtil.disable(id, time) | 违规用户处理 | 低 | 低 |
| 单点登录 | Sa-Token-SSO | 多系统统一认证 | 中(跨域请求) | 高 |
| JWT 集成 | Sa-Token-JWT | 无状态认证 | 低 | 中 |
| Redis 集成 | Sa-Token-Redis | 分布式存储会话 | 中 | 中 |
| 多账号体系 | 自定义 StpLogic | 多种用户类型管理 | 低 | 中 |
| 数据权限 | MyBatis 拦截器+角色枚举 | 数据行级权限控制 | 高(需结合业务逻辑) | 高 |
| 微服务集成 | Gateway 过滤器+请求头传递 | 微服务架构认证 | 中 | 高(需处理跨服务信任) |