AI 摘要

文章基于 Spring Boot + MyBatisPlus + ElementUI 后台管理系统,详解 Sa-Token 在前后端分离场景下的登录、注销与异常处理实现。内容涵盖:依赖引入、JWT 与 Redis 集成、token 风格定制、全局过滤器路由拦截、统一 404/500 异常封装,以及完整的登录/注销接口代码,确保重启会话不丢、跨域安全、异常友好。

Sa-Token

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、分布式 Session 会话、微服务网关鉴权等一系列权限相关问题。

Sa-Token 官网:https://sa-token.cc/

一体式项目的登录认证

传统的登录流程,登录成功后把用户信息写到 session 中了。

前后端分离项目的登录认证

在传统的一体式项目当中,登录鉴权可以使用 session 会话。但是在前后端分离项目中,没办法使用 session,cookie 又存在跨域问题,此时鉴权就成了最大的问题。

前后端分离的项目实现登录认证,后端接口登录成功后,可以形成一个 token,叫做口令给前端,前端每一次请求后端接口的时候,都会携带这个口令,如果这个口令的状态正常,即已经登录,如果这个口令的状态不正常,即未登录。

Sa-Token 的登录认证

修改 pom.xml,导入 Sa-Token 的依赖。

<dependency>
    <groupId>cn.dev33</groupId>
      <artifactId>sa-token-spring-boot-starter</artifactId>
      <version>1.34.0</version>
</dependency>

修改 application.properties 配置文件,配置 Sa-Token。

# Sa-Token配置
# token名称 (同时也是cookie名称)
sa-token.token-name=satoken
# token有效期,单位s 默认30天, -1代表永不过期
sa-token.timeout=2592000
# token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
sa-token.activity-timeout=-1
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
sa-token.is-concurrent=false
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
sa-token.is-share=false
# token风格
sa-token.token-style=uuid
# 是否输出操作日志
sa-token.is-log=false

新建测试控制器 cn.duozai.ct.controller.TestController,实现 Sa-Token 的测试方法。

@GetMapping("/login")
public ResponseResult login() {
    // 调用StpUtil进行登录
    StpUtil.login(10001);

    // 获取当前会话的token值
    System.out.println(StpUtil.getTokenValue());

    return ResponseResult.success("登录成功");
}

可以参考 Sa-Token 的官方文档,详细了解登录认证的相关方法。

Sa-Token 自定义 token 风格

Sa-Token 默认的 token 生成策略是 uuid 风格,我们还可以将 token 生成设置为其他风格。

修改 application.properties 配置文件,配置 Sa-Token 的 token 风格。

sa-token.token-style=uuid

Sa-Token 整合 JWT

在实战过程中,token 的风格一般是 JWT。JWT 是一个开放的行业标准,它定义了一种简介的、自包含的协议格式,用于在通信双方传递 JSON 对象,传递的信息经过数字签名可以被验证和信任。JWT 可以使用 HMAC 算法或使用 RSA 的公钥/私钥对来签名,防止被篡改。

Sa-Token 整合 JWT,在官方文档中也提供了对应的介绍和说明。

修改 pom.xml,导入 Sa-Token-JWT 的依赖。

<dependency>
    <groupId>cn.dev33</groupId>
      <artifactId>sa-token-spring-boot-starter</artifactId>
      <version>1.34.0</version>
</dependency>

修改 application.properties 配置文件,配置 Sa-Token-JWT。

# token风格
# sa-token.token-style=uuid
# JWT密钥
sa-token.jwt-secret-key=86765768hjnkdjifgbjtrfef

新建 Sa-Token 配置类 cn.duozai.ct.configure.SaTokenConfigure,配置 JWT。

@Configuration
public class SaTokenConfigure {

    /**
     * 整合JWT
     *
     * @author 多仔ヾ
     */
    @Bean
    public StpLogic getStpLogicJwt() {
        return new StpLogicJwtForSimple();
    }

}

重启项目进行测试,查看输出的 token 的风格。

Sa-Token 整合 Redis

Sa-Toke 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:重启后数据会丢失。无法在分布式环境中共享数据。为此,Sa-Token 提供了扩展接口,我们可以轻松将会话数据存储在 Redis、Memcached 等专业的缓存中间件中,做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性。

Sa-Token 整合 Redis,在官方文档中也提供了对应的介绍和说明。

修改 pom.xml,导入 Sa-Token-Redis-Jackson 的依赖。

<dependency>
    <groupId>cn.dev33</groupId>
      <artifactId>sa-token-dao-redis-jackson</artifactId>
      <version>1.34.0</version>
</dependency>

重启项目进行测试,查看 Redis 数据库的数据变化。

可以在整合 Redis 之前测试重启项目后的登录情况:项目重启后,登录状态失效。

整合 Redis 之后:项目重启后,登录状态还在。

新建测试控制器 TestController,实现 Sa-Token 的测试方法。

@GetMapping("/login")
public ResponseResult login() {
    // 验证是否登录
    System.out.println("是否登录:" + StpUtil.isLogin())

    // 调用StpUtil进行登录
    StpUtil.login(10001);

    // 获取当前会话的token值
    System.out.println(StpUtil.getTokenValue());

    return ResponseResult.success("登录成功");
}

@GetMapping("/getUser")
public ResponseResult getUser() {
    if(StpUtil.isLogin()) {
        return ResponseResult.success("用户信息");
    } else {
        return ResponseResult.failure("请先登录");
    }
}

实现后端路由拦截

项目中所有接口均需要登录认证,只有登录接口本身对外开放,此时可以借助 Sa-Token 路由拦截鉴权的功能。也就是说,在没有登录之前,无法访问其他功能接口。

路由拦截鉴权一共有两种方式,一种是拦截器,一种是过滤器。相比于拦截器,过滤器更加底层,执行时机更靠前,有利于防渗透扫描。过滤器可以拦截静态资源,方便我们做一些权限控制。推荐使用过滤器实现路由拦截鉴权。

修改 Sa-Token 配置类 cn.duozai.ct.configure.SaTokenConfigure,注册 Sa-Token 全局过滤器。

/**
 * 注册Sa-Token全局过滤器
 *
 * @author 多仔ヾ
 * @return cn.dev33.satoken.filter.SaServletFilter
 */
@Bean
public SaServletFilter getSaServletFilter() {
    return new SaServletFilter()
            // 指定拦截路由与放行路由
            .addInclude("/**").addExclude("/favicon.ico")
            // 认证函数:每次请求执行
            .setAuth(obj -> {
                // 登录认证:拦截所有路由,并排除/api/auth/login用于开放登录
                SaRouter.match("/**", "/api/auth/login", () -> StpUtil.checkLogin());
            })
            // 异常处理函数:每次认证函数发生异常时执行此函数
            .setError(e -> {
                // 设置响应头
                SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
                // 使用封装的 JSON 工具类转换数据格式
                return JSONUtil.toJsonStr(ResponseResult.resp(ResponseStatus.OF_LOGINED.code, ResponseStatus.OF_LOGINED.message));
            })
            // 前置函数:在每次认证函数之前执行
            .setBeforeAuth(obj -> {
                // 设置跨域响应头
                SaHolder.getResponse()
                        // 允许指定域访问跨域资源
                        .setHeader("Access-Control-Allow-Origin", "*")
                        // 允许的header参数
                        .setHeader("Access-Control-Allow-Headers", "*")
                        // 允许所有请求方式
                        .setHeader("Access-Control-Allow-Methods", "*");
            });
}

运行测试,在未登录时访问其他接口,会返回 401。登录成功后,即可正常访问其他接口。

实现全局异常处理

用户在访问一个不存在的接口时,会直接出现“Whitelabel Error Page”,很不美观。包括我们的程序当中出现异常时,也是直接抛出 500,出现 500 页面。在实际开发当中,我们不建议直接抛出异常,而是要对这些异常进行处理。如:当出现 404 时,我们返回友好的 404 提示。

新建统一异常处理类 cn.duozai.ct.utils.exception.GlobalExceptionHandler,捕获并处理异常。

/**
 * 统一异常处理类
 *
 * @author 多仔ヾ
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * NoHandlerFoundException异常处理(404)
     *
     * @author 多仔ヾ
     * @param ex NoHandlerFoundException
     * @return cn.duozai.ct.utils.response.ResponseResult
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseResult exceptionHandler(NoHandlerFoundException ex) {
        return ResponseResult.resp(ResponseStatus.NOT_FOUND.code, ResponseStatus.NOT_FOUND.message);
    }

    /**
     * Exception异常处理(500)
     *
     * @author 多仔ヾ
     * @param ex Exception
     * @return cn.duozai.ct.utils.response.ResponseResult
     */
    @ExceptionHandler(value = Exception.class)
    public ResponseResult handler(Exception ex) {
        return ResponseResult.error();
    }

}

@RestControllerAdvice 用于标记统一异常处理类,@ExceptionHandler 用于指定异常处理。

要统一处理 404,还需要修改配置文件 application.properties。

# 统一异常处理
spring.mvc.throw-exception-if-no-handler-found=true
# 禁用资源映射
spring.web.resources.add-mappings=false

实现登录接口

新建权限认证控制器 cn.duozai.ct.controller.AuthController,实现登录接口。

/**
* 权限认证控制器
*
* @author 多仔ヾ
*/
@RequestMapping("/api/auth")
@RestController
public class AuthController {

    @Resource
    SysUsersService sysUsersService;

    /**
     * 系统用户登录
     *
     * @author 多仔ヾ
     * @param account 账户账号
     * @param password 账户密码
     * @return cn.duozai.ct.utils.response.ResponseResult
     */
    @PostMapping("/login")
    public ResponseResult login(@RequestParam(name = "account") String account,
                                @RequestParam(name = "password") String password) {
        // 根据账户账号查询系统用户信息
        SysUsers sysUser = sysUsersService.getOne(new QueryWrapper<SysUsers>().eq("account", account));

        // 判断账户账号是否存在
        if(sysUser == null) {
            return ResponseResult.failure("该账户账号不存在!");
        }

        // 判断密码是否正确
        String md5SaltsPassword = MD5Util.encode(password, sysUser.getSalts());
        if(!md5SaltsPassword.equals(sysUser.getPassword())) {
            return ResponseResult.failure("账户密码错误!");
        }

        // 判断账户状态是否启用
        if(sysUser.getStatus() != 1) {
            return ResponseResult.failure("该账户已被禁用!");
        }

        // 登录成功,调用StpUtil进行登录
        StpUtil.login(sysUser.getId());

        // 返回成功结果 + token令牌
        return ResponseResult.success("登录成功", MapUtil.builder().put("token", StpUtil.getTokenValue()).build());
    }

}

使用 Apifox 完善文档,并进行接口测试。

实现注销接口

在权限认证控制器 cn.duozai.ct.controller.AuthController 中实现注销接口。

/**
 * 系统用户注销
 *
 * @author 多仔ヾ
 * @return cn.duozai.ct.utils.response.ResponseResult
 */
@GetMapping("/logout")
public ResponseResult logout() {
    StpUtil.logout();
    return ResponseResult.success("注销成功");
}

使用 Apifox 完善文档,并进行接口测试。