mirror of
https://github.com/jeecgboot/JeecgBoot.git
synced 2026-02-07 02:55:36 +08:00
重命名迁移说明文件并调整标题格式
This commit is contained in:
368
jeecg-boot/Shiro到Sa-Token迁移指南.md
Normal file
368
jeecg-boot/Shiro到Sa-Token迁移指南.md
Normal file
@ -0,0 +1,368 @@
|
||||
# `Shiro 到 Sa-Token 迁移指南`
|
||||
|
||||
本项目已从 **Apache Shiro 2.0.4** 迁移到 **Sa-Token 1.44.0**,采用 JWT-Simple 模式,完全兼容原 JWT token 格式。
|
||||
|
||||
---
|
||||
|
||||
## 📦 1. 依赖配置
|
||||
|
||||
### 1.1 Maven 依赖
|
||||
|
||||
移除 Shiro 相关依赖,新增:
|
||||
|
||||
```xml
|
||||
<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)
|
||||
|
||||
```yaml
|
||||
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)
|
||||
|
||||
```java
|
||||
// 从数据库查询用户信息
|
||||
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 权限认证接口(⚠️ 必须手动实现缓存)
|
||||
|
||||
```java
|
||||
@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)
|
||||
|
||||
```java
|
||||
@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 全局异常处理
|
||||
|
||||
```java
|
||||
@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 注解替换
|
||||
|
||||
| Shiro | Sa-Token | 说明 |
|
||||
|-------|----------|------|
|
||||
| `@RequiresPermissions("user:add")` | `@SaCheckPermission("user:add")` | 权限校验 |
|
||||
| `@RequiresRoles("admin")` | `@SaCheckRole("admin")` | 角色校验 |
|
||||
|
||||
### 3.2 API 替换
|
||||
|
||||
| Shiro | Sa-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 权限缓存动态更新
|
||||
|
||||
修改角色权限后,系统会自动清除受影响用户的权限缓存:
|
||||
|
||||
```java
|
||||
// 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 格式 |
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
- [Sa-Token 官方文档](https://sa-token.cc/)
|
||||
- [Sa-Token JWT-Simple 模式](https://sa-token.cc/doc.html#/plugin/jwt-extend)
|
||||
- [Sa-Token 权限缓存最佳实践](https://sa-token.cc/doc.html#/fun/jur-cache)
|
||||
Reference in New Issue
Block a user