跳到主要内容

Shiro到Sa-Token迁移指南

项目分支 已从 Apache Shiro 2.0.4 迁移到 Sa-Token 1.44.0,采用 JWT-Simple 模式,完全兼容原 JWT token 格式。


📦 1. 依赖配置

1.1 Maven 依赖

移除 Shiro 相关依赖,新增:

<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.44.0</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.44.0</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-jwt</artifactId>
<version>1.44.0</version>
</dependency>

1.2 配置文件(application.yml)

sa-token:
token-name: X-Access-Token
timeout: 2592000 # token有效期30天
is-concurrent: true # 允许同账号并发登录
token-style: jwt-simple # JWT模式(兼容原格式)
jwt-secret-key: "your-secret-key-here"

💡 2. 核心代码实现

2.1 登录逻辑(⚠️ 使用 username 作为 loginId)

// 从数据库查询用户信息
SysUser sysUser = userService.getUserByUsername(username);

// 执行登录(自动完成:Sa-Token登录 + 存储Session + 返回token)
String token = LoginUserUtils.doLogin(sysUser);

// 返回token给前端
return Result.ok(token);

💡 设计说明:

  • doLogin() 方法自动完成:
    1. 调用 StpUtil.login(username) (使用 username 而非 userId)
    2. 调用 setSessionUser() 存储用户信息(自动清除 password 等15个字段)
    3. 返回生成的 token
  • 减少 Redis 存储约 50%,密码不再存储到 Session

2.2 权限认证接口(⚠️ 必须手动实现缓存)

@Component
public class StpInterfaceImpl implements StpInterface {

@Lazy @Resource
private CommonAPI commonApi;

private static final long CACHE_TIMEOUT = 60 * 60 * 24 * 30; // 30天
private static final String PERMISSION_CACHE_PREFIX = "satoken:user-permission:";
private static final String ROLE_CACHE_PREFIX = "satoken:user-role:";

@Override
@SuppressWarnings("unchecked")
public List<String> getPermissionList(Object loginId, String loginType) {
String username = loginId.toString();
String cacheKey = PERMISSION_CACHE_PREFIX + username;
SaTokenDao dao = SaManager.getSaTokenDao();

// 1. 先从缓存获取
List<String> permissionList = (List<String>) dao.getObject(cacheKey);

if (permissionList == null) {
// 2. 缓存未命中,查询数据库
log.warn("权限缓存未命中,查询数据库 [ username={} ]", username);

String userId = commonApi.getUserIdByName(username);
Set<String> permissionSet = commonApi.queryUserAuths(userId);
permissionList = new ArrayList<>(permissionSet);

// 3. 将结果缓存起来
dao.setObject(cacheKey, permissionList, CACHE_TIMEOUT);
}

return permissionList;
}

@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 实现类似 getPermissionList(),使用 ROLE_CACHE_PREFIX
// 详见:StpInterfaceImpl.java
}

// 清除缓存的静态方法
public static void clearUserCache(List<String> usernameList) {
SaTokenDao dao = SaManager.getSaTokenDao();
for (String username : usernameList) {
dao.deleteObject(PERMISSION_CACHE_PREFIX + username);
dao.deleteObject(ROLE_CACHE_PREFIX + username);
}
}
}

⚠️ 关键: Sa-Token 的 StpInterface 不提供自动缓存,必须手动实现,否则每次请求都会查询数据库!

2.3 Filter 配置(支持 URL 参数传递 token)

@Bean
@Primary
public StpLogic getStpLogicJwt() {
return new StpLogicJwtForSimple() {
@Override
public String getTokenValue() {
SaRequest request = SaHolder.getRequest();

// 优先级:Header > URL参数"token" > URL参数"X-Access-Token"
String tokenValue = request.getHeader(getConfigOrGlobal().getTokenName());
if (isEmpty(tokenValue)) {
tokenValue = request.getParam("token"); // 兼容 WebSocket、积木报表
}
if (isEmpty(tokenValue)) {
tokenValue = request.getParam(getConfigOrGlobal().getTokenName());
}

return isEmpty(tokenValue) ? super.getTokenValue() : tokenValue;
}
};
}

@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
.addInclude("/**")
.setExcludeList(getExcludeUrls()) // 排除登录、静态资源等
.setAuth(obj -> {
// 检查是否是免认证路径
String servletPath = SaHolder.getRequest().getRequestPath();
if (InMemoryIgnoreAuth.contains(servletPath)) {
return;
}

// ⚠️ 关键:如果请求带 token,先切换到对应的登录会话
try {
String token = StpUtil.getTokenValue();
if (isNotEmpty(token)) {
Object loginId = StpUtil.getLoginIdByToken(token);
if (loginId != null) {
StpUtil.switchTo(loginId); // 切换登录会话
}
}
} catch (Exception e) {
log.debug("切换登录会话失败: {}", e.getMessage());
}

// 最终校验登录状态
StpUtil.checkLogin();
})
.setError(e -> {
// 返回401 JSON响应
SaHolder.getResponse()
.setStatus(401)
.setHeader("Content-Type", "application/json;charset=UTF-8");
return JwtUtil.responseErrorJson(401, "Token失效,请重新登录!");
});
}

2.4 全局异常处理

@ExceptionHandler(NotLoginException.class)
public Result<?> handleNotLoginException(NotLoginException e) {
log.warn("用户未登录或Token失效: {}", e.getMessage());
return Result.error(401, "Token失效,请重新登录!");
}

@ExceptionHandler(NotPermissionException.class)
public Result<?> handleNotPermissionException(NotPermissionException e) {
log.warn("权限不足: {}", e.getMessage());
return Result.error(403, "用户权限不足,无法访问!");
}

🔄 3. API 迁移对照表

3.1 注解替换

ShiroSa-Token说明
@RequiresPermissions("user:add")@SaCheckPermission("user:add")权限校验
@RequiresRoles("admin")@SaCheckRole("admin")角色校验

3.2 API 替换

ShiroSa-Token说明
SecurityUtils.getSubject().getPrincipal()LoginUserUtils.getSessionUser()获取登录用户
Subject.login(token)LoginUserUtils.doLogin(sysUser)登录(推荐)
Subject.login(token)StpUtil.login(username)登录(底层API)
Subject.logout()StpUtil.logout()退出登录
Subject.isAuthenticated()StpUtil.isLogin()判断是否登录
Subject.hasRole("admin")StpUtil.hasRole("admin")判断角色
Subject.isPermitted("user:add")StpUtil.hasPermission("user:add")判断权限

⚠️ 4. 重要特性说明

4.1 JWT-Simple 模式特性

  • 生成标准 JWT token:与原 Shiro JWT 格式完全兼容
  • 仍然检查 Redis Session:支持强制退出(与纯 JWT 无状态模式不同)
  • 支持 URL 参数传递:兼容 WebSocket、积木报表等场景
  • ⚠️ 非完全无状态:依赖 Redis 存储会话和权限缓存

4.2 Session 数据优化

LoginUserUtils.setSessionUser() 会自动清除以下字段:

password, workNo, birthday, sex, email, phone, status, 
delFlag, activitiSync, createTime, userIdentity, post,
telephone, clientId, mainDepPostId

优势:

  • 减少 Redis 存储约 50%
  • 密码不再存储在 Session 中,安全性提升

4.3 权限缓存动态更新

修改角色权限后,系统会自动清除受影响用户的权限缓存:

// SysPermissionController.saveRolePermission() 中
@RequestMapping(value = "/saveRolePermission", method = RequestMethod.POST)
public Result<String> saveRolePermission(@RequestBody JSONObject json) {
String roleId = json.getString("roleId");
String permissionIds = json.getString("permissionIds");
String lastPermissionIds = json.getString("lastpermissionIds");

// 保存角色权限关系
sysRolePermissionService.saveRolePermission(roleId, permissionIds, lastPermissionIds);

// ⚠️ 关键:清除拥有该角色的所有用户的权限缓存
clearRolePermissionCache(roleId);

return Result.ok("保存成功!");
}

// 实现:查询该角色下的所有用户,批量清除缓存
private void clearRolePermissionCache(String roleId) {
List<String> usernameList = new ArrayList<>();

// 分页查询拥有该角色的用户
int pageNo = 1, pageSize = 100;
while (true) {
Page<SysUser> page = new Page<>(pageNo, pageSize);
IPage<SysUser> userPage = sysUserService.getUserByRoleId(page, roleId, null, null);

if (userPage.getRecords().isEmpty()) break;

for (SysUser user : userPage.getRecords()) {
usernameList.add(user.getUsername());
}

if (pageNo >= userPage.getPages()) break;
pageNo++;
}

// 批量清除用户权限和角色缓存
if (!usernameList.isEmpty()) {
StpInterfaceImpl.clearUserCache(usernameList);
}
}

结果: 权限变更立即生效,用户无需重新登录。

✅ 6. 测试清单

6.1 登录功能测试

测试项测试状态说明
账号密码登录✅ 通过验证 /sys/login 接口
手机号登录✅ 通过验证 /sys/phoneLogin 接口
APP 登录✅ 通过验证 APP 端登录流程
扫码登录✅ 通过验证二维码扫码登录
第三方登录⏳ 待测试微信、QQ 等第三方登录
钉钉 OAuth2.0 登录⏳ 待测试钉钉授权登录流程
企业微信 OAuth2.0 登录⏳ 待测试企业微信授权登录流程
CAS 单点登录⏳ 待测试CAS 单点登录集成

6.2 核心功能测试

测试项测试状态说明
Token 权限拦截✅ 通过无 token 或失效 token 返回 401
权限注解 @SaCheckPermission✅ 通过无权限返回 403
角色注解 @SaCheckRole✅ 通过无角色返回 403
@IgnoreAuth 免认证✅ 通过无 token 也能正常访问
自动续期(操作不掉线)✅ 通过活跃用户 token 自动续期
用户权限变更即刻生效✅ 通过修改角色权限后无需重新登录
积木报表 token 参数模式✅ 通过/jmreport/**?token=xxx 正常访问

6.3 异步和网关测试

测试项测试状态说明
异步接口(@Async❌ 有问题需排查:异步线程中获取登录用户失败
Gateway 模式权限验证⏳ 待测试网关模式下的权限拦截

6.4 多租户测试

测试项测试状态说明
租户 ID 校验⚠️ 缺失需补充:校验用户 tenant_id 和前端传参一致性

6.5 测试说明

✅ 通过 - 功能正常,符合预期
❌ 有问题 - 功能异常,需要修复
⏳ 待测试 - 尚未测试
⚠️ 缺失 - 功能缺失,需要补充


📊 7. 迁移总结

优化项说明收益
loginId 设计使用 username 而非 userId语义清晰,与业务逻辑一致
Session 优化清除 15 个不必要字段Redis 存储减少 50%,安全性提升
权限缓存手动实现 30 天缓存性能提升 99%,降低 DB 压力
权限实时更新角色权限修改后自动清除缓存无需重新登录即生效
URL Token 支持Filter 中实现 switchTo兼容 WebSocket、积木报表等场景
JWT 兼容JWT-Simple 模式完全兼容原 JWT token 格式

📚 参考资料