Sa-Token 权限认证实战:构建 Java 项目高效身份与权限管理系统

659 阅读20分钟

每个后端开发者都曾为权限系统设计犯过难,从最初的 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 过滤器+请求头传递微服务架构认证高(需处理跨服务信任)