Files
JeecgBoot/jeecg-boot/Shiro到Sa-Token迁移指南.md

13 KiB
Raw Blame History

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 注解替换

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 权限缓存动态更新

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

// 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 格式

📚 参考资料