Live2D

Sa-Token

SpringBoot集成初体验

依赖引入

SpringBoot 3.x版本引入

<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot3-starter</artifactId>
    <version>1.35.0.RC</version>
</dependency>

3版本以下引入

<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.35.0.RC</version>
</dependency>
yml配置

application.yml中加入以下配置

server:
  # 端口
  port: 8081
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token: 
    # token 名称(同时也是 cookie 名称)
    token-name: satoken
    # token 有效期(单位:秒) 默认30天,-1 代表永久有效
    timeout: 2592000
    # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
    active-timeout: -1
    # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
    is-concurrent: true
    # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
    is-share: true
    # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
    token-style: uuid
    # 是否输出操作日志 
    is-log: true
controller测试
package com.wl.satoken.controller;

import cn.dev33.satoken.stp.StpUtil;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @Author NoDreamJava
 * @Date 2023-08-02 16:43
 * @Version 1.0
 */
@RestController
@RequestMapping("/auth")
public class UserController {

    @RequestMapping("/login")
    public String login(String username, String password) {
        if (username.equals("admin") && password.equals("123456")) {
            StpUtil.login(10001);
            return "登录成功";
        } else {
            return "登录失败";
        }
    }
    // 查询登陆状态
    @RequestMapping("/isLogin")
    public String isLogin() {
        return "当前会话是否登录:" + StpUtil.isLogin();
    }
}

访问地址:http://localhost:8081/auth/login?username=admin&password=123456

image

检查是否登陆:http://localhost:8081/auth/isLogin

image

全局异常处理

创建一个全局异常拦截器,统一返回给前端的格式

@RestControllerAdvice
public class GlobalExceptionHandler {
    // 全局异常拦截 
    @ExceptionHandler
    public SaResult handlerException(Exception e) {
        e.printStackTrace(); 
        return SaResult.error(e.getMessage());
    }
}

登录认证

在完成登陆后会发现,token被自动写入到浏览器中了

image

这是因为 sa-Token用了 Cookie 自动注入的特性,省略了手写返回 token 的代码

登录和注销
// 当前会话注销登录
StpUtil.logout();

// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();

// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();

会话查询
// 获取当前会话账号id, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.getLoginId();

// 类似查询API还有:
StpUtil.getLoginIdAsString();    // 获取当前会话账号id, 并转化为`String`类型
StpUtil.getLoginIdAsInt();       // 获取当前会话账号id, 并转化为`int`类型
StpUtil.getLoginIdAsLong();      // 获取当前会话账号id, 并转化为`long`类型

// ---------- 指定未登录情形下返回的默认值 ----------

// 获取当前会话账号id, 如果未登录,则返回 null 
StpUtil.getLoginIdDefaultNull();

// 获取当前会话账号id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
StpUtil.getLoginId(T defaultValue);

token查询
// 获取当前会话的 token 值
StpUtil.getTokenValue();

// 获取当前`StpLogic`的 token 名称
StpUtil.getTokenName();

// 获取指定 token 对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);

// 获取当前会话剩余有效期(单位:s,返回-1代表永久有效)
StpUtil.getTokenTimeout();

// 获取当前会话的 token 信息参数
StpUtil.getTokenInfo();

测试

加入两个接口测试

    // 查询token信息
    @RequestMapping("/getTokenInfo")
    public SaResult getTokenInfo() {
        return SaResult.data(StpUtil.getTokenInfo());
    }
    // 注销
    @RequestMapping("/logout")
    public SaResult logout() {
        StpUtil.logout();
        return SaResult.ok();
    }

访问:http://localhost:8081/auth/getTokenInfo查询token信息

image

访问:http://localhost:8081/auth/logout注销当前用户

image

权限认证

权限认证本质就是来控制一个账号是否拥有操作权限,深入到底层数据中,就是每个账号都会拥有一组权限码集合,框架来校验这个集合中是否包含指定的权限码。

例如:当前账号拥有权限码集合 ["user-add", "user-delete", "user-get"],这时候我来校验权限 "user-update",则其结果就是:验证失败,禁止访问

问题的核心就是两个:

  1. 如何获取一个账号所拥有的权限码集合?
  2. 本次操作需要验证的权限码是哪个?
获取当前账号权限码集合
/**
 * 自定义权限加载接口实现类
 */
@Component    // 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展 
public class StpInterfaceImpl implements StpInterface {

    /**
     * 返回一个账号所拥有的权限码集合 
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限
        List<String> list = new ArrayList<String>();    
        list.add("101");
        list.add("user.add");
        list.add("user.update");
        list.add("user.get");
        // list.add("user.delete");
        list.add("art.*");
        return list;
    }

    /**
     * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
        List<String> list = new ArrayList<String>();    
        list.add("admin");
        list.add("super-admin");
        return list;
    }

}
校验权限
// 获取:当前账号所拥有的权限集合
StpUtil.getPermissionList();

// 判断:当前账号是否含有指定权限, 返回 true 或 false
StpUtil.hasPermission("user.add");        

// 校验:当前账号是否含有指定权限, 如果验证未通过,则抛出异常: NotPermissionException 
StpUtil.checkPermission("user.add");        

// 校验:当前账号是否含有指定权限 [指定多个,必须全部验证通过]
StpUtil.checkPermissionAnd("user.add", "user.delete", "user.get");        

// 校验:当前账号是否含有指定权限 [指定多个,只要其一验证通过即可]
StpUtil.checkPermissionOr("user.add", "user.delete", "user.get");    

角色校验
// 获取:当前账号所拥有的角色集合
StpUtil.getRoleList();

// 判断:当前账号是否拥有指定角色, 返回 true 或 false
StpUtil.hasRole("super-admin");        

// 校验:当前账号是否含有指定角色标识, 如果验证未通过,则抛出异常: NotRoleException
StpUtil.checkRole("super-admin");        

// 校验:当前账号是否含有指定角色标识 [指定多个,必须全部验证通过]
StpUtil.checkRoleAnd("super-admin", "shop-admin");        

// 校验:当前账号是否含有指定角色标识 [指定多个,只要其一验证通过即可] 
StpUtil.checkRoleOr("super-admin", "shop-admin");        

权限通配符

sa-Token允许你根据通配符指定泛权限,例如当一个账号拥有art.*的权限时,art.addart.deleteart.update都将匹配通过

// 当拥有 art.* 权限时
StpUtil.hasPermission("art.add");        // true
StpUtil.hasPermission("art.update");     // true
StpUtil.hasPermission("goods.add");      // false

// 当拥有 *.delete 权限时
StpUtil.hasPermission("art.delete");      // true
StpUtil.hasPermission("user.delete");     // true
StpUtil.hasPermission("user.update");     // false

// 当拥有 *.js 权限时
StpUtil.hasPermission("index.js");        // true
StpUtil.hasPermission("index.css");       // false
StpUtil.hasPermission("index.html");      // false

测试

建一张用户表

CREATE TABLE `user` (
  `id` int NOT NULL,
  `user_name` varchar(255) DEFAULT NULL,
  `password` varchar(255) DEFAULT NULL,
  `permissions` varchar(255) DEFAULT NULL,
  `role` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

插入一条数据

INSERT INTO `sys`.`user`(`id`, `user_name`, `password`, `permissions`, `role`) VALUES (1001, 'wl', '123456', 'user.add,user.update', 'leader');

实现获取账号权限码集合接口

package com.wl.sa.auth;

/**
 * @Author NoDreamJava
 * @Date 2023-08-15 10:13
 * @Version 1.0
 */

import cn.dev33.satoken.stp.StpInterface;
import com.wl.sa.mapper.UserMapper;
import com.wl.sa.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 自定义权限加载接口实现类
 */
@Component    // 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展
public class StpInterfaceImpl implements StpInterface {

    @Autowired
    private UserMapper userMapper;

    /**
     * 返回一个账号所拥有的权限码集合
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        User user = userMapper.selectById(Long.parseLong(loginId.toString()));
        return Arrays.stream(user.getPermissions().split(",")).collect(Collectors.toList());
    }

    /**
     * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        User user = userMapper.selectById(Long.parseLong(loginId.toString()));
        return Arrays.stream(user.getRole().split(",")).collect(Collectors.toList());
    }

}


添加测试接口

    @RequestMapping("/getPermission")
    public SaResult getPermission() {
        // 查询权限信息 ,如果当前会话未登录,会返回一个空集合
        List<String> permissionList = StpUtil.getPermissionList();
        System.out.println("当前登录账号拥有的所有权限:" + permissionList);
        return SaResult.ok()
                .set("permissionList", permissionList);
    }

访问地址:http://localhost:8081/auth/getPermission验证

image

踢人下线

踢人下线,核心操作就是找到指定 loginId 对应的 Token,并设置其失效。

强制注销
StpUtil.logout(10001);                    // 强制指定账号注销下线 
StpUtil.logout(10001, "PC");              // 强制指定账号指定端注销下线 
StpUtil.logoutByTokenValue("token");      // 强制指定 Token 注销下线 
踢人下线
StpUtil.kickout(10001);                    // 将指定账号踢下线 
StpUtil.kickout(10001, "PC");              // 将指定账号指定端踢下线
StpUtil.kickoutByTokenValue("token");      // 将指定 Token 踢下线

强制注销 和 踢人下线 的区别在于:

  • 强制注销等价于对方主动调用了注销方法,再次访问会提示:Token无效。
  • 踢人下线不会清除Token信息,而是将其打上特定标记,再次访问会提示:Token已被踢下线。
小案例测试

添加查询角色接口,只有登录才可以访问

	// 登录校验:只有登录之后才能进入该方法 
    @SaCheckLogin
    @RequestMapping("/getUserInfo")
    public SaResult getUserInfo() {
        List<User> userList = userMapper.selectList(null);
        return SaResult.ok()
                .set("userList", userList);
    }
    // 根据id踢人下线
    @RequestMapping("/kickById")
    public SaResult kickById(String loginId) {
        StpUtil.kickout(loginId);
        return SaResult.ok();
    }

先访问接口 /kickById踢人下线,然后访问 /getUserInfo,发现提示已经被提下线

image

注解鉴权

注解鉴权 —— 优雅的将鉴权与业务代码分离!

  • @SaCheckLogin: 登录校验 —— 只有登录之后才能进入该方法。
  • @SaCheckRole("admin"): 角色校验 —— 必须具有指定角色标识才能进入该方法。
  • @SaCheckPermission("user:add"): 权限校验 —— 必须具有指定权限才能进入该方法。
  • @SaCheckSafe: 二级认证校验 —— 必须二级认证之后才能进入该方法。
  • @SaCheckBasic: HttpBasic校验 —— 只有通过 Basic 认证后才能进入该方法。
  • @SaIgnore:忽略校验 —— 表示被修饰的方法或类无需进行注解鉴权和路由拦截器鉴权。
  • @SaCheckDisable("comment"):账号服务封禁校验 —— 校验当前账号指定服务是否被封禁。
注册拦截器
package com.wl.sa.interceptor;

import cn.dev33.satoken.interceptor.SaInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @Author NoDreamJava
 * @Date 2023-08-15 15:17
 * @Version 1.0
 */
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
    }
}
使用注解鉴权
    // 权限校验:必须具有指定权限才能进入该方法
    @SaCheckPermission("user.add")
    @RequestMapping("/addByPermission")
    public String add() {
        return "用户增加";
    }

    // 角色校验:必须具有指定角色才能进入该方法
    @SaCheckRole("admin")
    @RequestMapping("/addByRole")
    public String addByRole() {
        return "用户增加";
    }

目前数据库表中的 权限列表为 user.add,user.update 角色列表为leader

访问权限接口:http://localhost:8081/auth/addByPermission

image

访问角色接口:http://localhost:8081/auth/addByRole

image

设定校验模式

@SaCheckRole@SaCheckPermission注解可设置校验模式

// 注解式鉴权:只要具有其中一个权限即可通过校验
    @RequestMapping("/atJurOr")
    @SaCheckPermission(value = {"user.add", "user.all", "user.delete"}, mode = SaMode.OR)
    public SaResult atJurOr() {
        return SaResult.data("用户信息");
    }

访问接口:http://localhost:8081/auth/atJurOr

image

mode有两种取值:

  • SaMode.AND,标注一组权限,会话必须全部具有才可通过校验。
  • SaMode.OR,标注一组权限,会话只要具有其一即可通过校验。
角色权限双重“or校验”

一个接口在具有权限 user.add 或角色 admin 时可以调通。怎么写?

// 角色权限双重 “or校验”:具备指定权限或者指定角色即可通过校验
@RequestMapping("userAdd")
@SaCheckPermission(value = "user.add", orRole = "admin")        
public SaResult userAdd() {
    return SaResult.data("用户信息");
}

访问:http://localhost:8081/auth/userAdd

image

orRole 字段代表权限校验未通过时的次要选择,两者只要其一校验成功即可进入请求方法,其有三种写法:

  • 写法一:orRole = "admin",代表需要拥有角色 admin 。
  • 写法二:orRole = {"admin", "manager", "staff"},代表具有三个角色其一即可。
  • 写法三:orRole = {"admin, manager, staff"},代表必须同时具有三个角色。
忽略认证

使用 @SaIgnore 可表示一个接口忽略认证

  • @SaIgnore 修饰方法时代表这个方法可以被游客访问,修饰类时代表这个类中的所有接口都可以游客访问。
  • @SaIgnore 具有最高优先级,当 @SaIgnore 和其它鉴权注解一起出现时,其它鉴权注解都将被忽略。
  • @SaIgnore 同样可以忽略掉 Sa-Token 拦截器中的路由鉴权
批量注解鉴权

使用 @SaCheckOr 表示批量注解鉴权

// 在 `@SaCheckOr` 中可以指定多个注解,只要当前会话满足其中一个注解即可通过验证,进入方法。
@SaCheckOr(
        login = @SaCheckLogin,
        role = @SaCheckRole("admin"),
        permission = @SaCheckPermission("user.add"),
        safe = @SaCheckSafe("update-password"),
        basic = @SaCheckBasic(account = "sa:123456"),
        disable = @SaCheckDisable("submit-orders")
)
@RequestMapping("test")
public SaResult test() {
    // ... 
    return SaResult.ok(); 
}

路由拦截鉴权

简单鉴权配置

注册了一个基于 StpUtil.checkLogin() 的登录校验拦截器,并且排除了/auth/login接口用来开放登录(除了/auth/login以外的所有接口都需要登录才能访问)。

/**
 * @Author NoDreamJava
 * @Date 2023-08-15 15:17
 * @Version 1.0
 */
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SaInterceptor(handler-> StpUtil.checkLogin()))
                .addPathPatterns("/**")
                .excludePathPatterns("/auth/login");
    }
}
自定义校验规则

自定义认证规则:new SaInterceptor(handle -> StpUtil.checkLogin()) 是最简单的写法,代表只进行登录校验功能。

可以使用自定义校验函数来实现复杂校验

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册 Sa-Token 拦截器,定义详细认证规则 
        registry.addInterceptor(new SaInterceptor(handler -> {
            // 指定一条 match 规则
            SaRouter
                .match("/**")    // 拦截的 path 列表,可以写多个 */
                .notMatch("/user/doLogin")        // 排除掉的 path 列表,可以写多个 
                .check(r -> StpUtil.checkLogin());        // 要执行的校验动作,可以写完整的 lambda 表达式
                
            // 根据路由划分模块,不同模块不同鉴权 
            SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
            SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
            SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
            SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
            SaRouter.match("/notice/**", r -> StpUtil.checkPermission("notice"));
            SaRouter.match("/comment/**", r -> StpUtil.checkPermission("comment"));
        })).addPathPatterns("/**");
    }
}

忽略路由拦截认证

我们可以使用 @SaIgnore 注解,忽略掉路由拦截认证

@SaIgnore
@RequestMapping("/user/getList")
public SaResult getList() {
    System.out.println("------------ 访问进来方法"); 
    return SaResult.ok(); 
}

Session会话

在 Sa-Token 中,Session 分为三种,分别是:

  • Account-Session: 指的是框架为每个 账号id 分配的 Session
  • Token-Session: 指的是框架为每个 token 分配的 Session
  • Custom-Session: 指的是以一个 特定的值 作为SessionId,来分配的 Session

有关 Account-Session 与 Token-Session 的详细区别,可参考:Session模型详解

Account-Session
// 获取当前账号 id 的 Account-Session (必须是登录后才能调用)
StpUtil.getSession();

// 获取当前账号 id 的 Account-Session, 并决定在 Session 尚未创建时,是否新建并返回
StpUtil.getSession(true);

// 获取账号 id 为 10001 的 Account-Session
StpUtil.getSessionByLoginId(10001);

// 获取账号 id 为 10001 的 Account-Session, 并决定在 Session 尚未创建时,是否新建并返回
StpUtil.getSessionByLoginId(10001, true);

// 获取 SessionId 为 xxxx-xxxx 的 Account-Session, 在 Session 尚未创建时, 返回 null 
StpUtil.getSessionBySessionId("xxxx-xxxx");

Token-Session
// 获取当前 Token 的 Token-Session 对象
StpUtil.getTokenSession();

// 获取指定 Token 的 Token-Session 对象
StpUtil.getTokenSessionByToken(token);

Custom-Session
// 查询指定key的Session是否存在
SaSessionCustomUtil.isExists("goods-10001");

// 获取指定key的Session,如果没有,则新建并返回
SaSessionCustomUtil.getSessionById("goods-10001");

// 获取指定key的Session,如果没有,第二个参数决定是否新建并返回  
SaSessionCustomUtil.getSessionById("goods-10001", false);   

// 删除指定key的Session
SaSessionCustomUtil.deleteSessionById("goods-10001");

集成Redis

sa-Token 默认将数据保存在内存中,此模式读写速度最快,但是也有以下缺点:

  • 重启后数据会丢失
  • 无法在分布式环境中共享数据

以下是框架提供的 Redis 集成包:

jdk默认序列化方式(二选一)

<!-- Sa-Token 整合 Redis (使用 jdk 默认序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis</artifactId>
    <version>1.35.0.RC</version>
</dependency>

jackjson序列化方式(二选一)

<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.35.0.RC</version>
</dependency>

连接池

<!-- 提供Redis连接池 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

yml配置redis

spring:
  # redis配置
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    # password:
    # 连接超时时间
    timeout: 10s
    lettuce:
      pool:
        # 连接池最大连接数
        max-active: 200
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1ms
        # 连接池中的最大空闲连接
        max-idle: 10
        # 连接池中的最小空闲连接
        min-idle: 0

集成redis后,token信息无需自己写入redis,框架自动保存

前后端分离

在前后端分离模式中,不能后端控制写入了,需要前端写入,后端只需返回token信息即可。

修改一下接口

    // 登陆
    @RequestMapping("/login")
    public SaResult login(String username, String password) {
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name", username)
                .eq("password", password);

        User user = userMapper.selectOne(wrapper);
        StpUtil.login(user.getId());
        // 获取token信息
        SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
        return SaResult.data(tokenInfo);
    }

自定义token风格

内置风格

Sa-Token 默认的 token 生成策略是 uuid 风格

// 1. token-style=uuid    —— uuid风格 (默认风格)
"623368f0-ae5e-4475-a53f-93e4225f16ae"

// 2. token-style=simple-uuid    —— 同上,uuid风格, 只不过去掉了中划线
"6fd4221395024b5f87edd34bc3258ee8"

// 3. token-style=random-32    —— 随机32位字符串
"qEjyPsEA1Bkc9dr8YP6okFr5umCZNR6W"

// 4. token-style=random-64    —— 随机64位字符串
"v4ueNLEpPwMtmOPMBtOOeIQsvP8z9gkMgIVibTUVjkrNrlfra5CGwQkViDjO8jcc"

// 5. token-style=random-128    —— 随机128位字符串
"nojYPmcEtrFEaN0Otpssa8I8jpk8FO53UcMZkCP9qyoHaDbKS6dxoRPky9c6QlftQ0pdzxRGXsKZmUSrPeZBOD6kJFfmfgiRyUmYWcj4WU4SSP2ilakWN1HYnIuX0Olj"

// 6. token-style=tik    —— tik风格
"gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__"

自定义Token生成策略

只需要重写 SaStrategy 策略类的 createToken 算法即可

import cn.dev33.satoken.strategy.SaStrategy;
import cn.dev33.satoken.util.SaFoxUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SaTokenConfigure {
    /**
     * 重写 Sa-Token 框架内部算法策略 
     */
    @Autowired
    public void rewriteSaStrategy() {
        // 重写 Token 生成策略 
        SaStrategy.instance.createToken = (loginId, loginType) -> {
            return SaFoxUtil.getRandomString(150);    // 随机150位长度字符串
        };
    }
}
自定义Token前缀

在某些系统中,前端提交token时会在前面加个固定的前缀

{
    "satoken": "Bearer xxxx-xxxx-xxxx-xxxx"
}

此时后端如果不做任何特殊处理,框架将会把Bearer 视为token的一部分,无法正常读取token信息,导致鉴权失败。

这时只需要在yml配置前缀即可

sa-token: 
    # token前缀
    token-prefix: Bearer

记住我模式

sa-Token默认是记住我模式,在浏览器写入一个持久Cookie储存 Token,此时用户即使重启浏览器 Token 依然有效

如果想取消记住我则在登陆成功后,StpUtil.login()方法第二个参数置为false即可

StpUtil.login(10001, false);

前后端分离场景下,需要前端进行记住我操作

模拟他人&身份切换

操作其他账号
// 获取指定账号10001的`tokenValue`值 
StpUtil.getTokenValueByLoginId(10001);

// 将账号10001的会话注销登录
StpUtil.logout(10001);

// 获取账号10001的Session对象, 如果session尚未创建, 则新建并返回
StpUtil.getSessionByLoginId(10001);

// 获取账号10001的Session对象, 如果session尚未创建, 则返回null 
StpUtil.getSessionByLoginId(10001, false);

// 获取账号10001是否含有指定角色标识 
StpUtil.hasRole(10001, "super-admin");

// 获取账号10001是否含有指定权限码
StpUtil.hasPermission(10001, "user:add");

临时身份切换
// 将当前会话[身份临时切换]为其它账号(本次请求内有效)
StpUtil.switchTo(10044);

// 此时再调用此方法会返回 10044 (我们临时切换到的账号id)
StpUtil.getLoginId();

// 结束 [身份临时切换]
StpUtil.endSwitch();
案例测试

往数据库插入一条数据

INSERT INTO `sys`.`user`(`id`, `user_name`, `password`, `permissions`, `role`) VALUES (1002, 'admin', '123456', 'user.add,user.update,user.delete', 'admin');

接口代码

 // 角色权限双重 “or校验”:具备指定权限或者指定角色即可通过校验
    @RequestMapping("userAdd")
    @SaCheckPermission(value = "user.add", orRole = "admin")
    public SaResult userAdd() {
        return SaResult.data("用户信息");
    }

    // 查看其他账号的token值 保证只有admin角色才有权限查看
    @RequestMapping("/getTokenByLoginId")
    @SaCheckRole("admin")
    public SaResult getTokenByLoginId(String loginId) {
        return SaResult.data(StpUtil.getTokenValueByLoginId(loginId));
    }

    // 切换角色
    @RequestMapping("/switchRole")
    public SaResult switchRole(String loginId) {
        StpUtil.switchTo(loginId);
        return SaResult.ok();
    }
    // 获取当前登陆id
    @RequestMapping("/getLoginId")
    public SaResult getLoginId() {
        return SaResult.data(StpUtil.getLoginId());
    }

先调用登录接口:http://localhost:8081/auth/login?username=admin&password=123456

再获取其他账号信息的接口:http://localhost:8081/auth/getTokenByLoginId?loginId=1001

image

切换账号:http://localhost:8081/auth/switchRole?loginId=1001

再去访问获取token信息的接口:http://localhost:8081/auth/getTokenByLoginId?loginId=1001

获取当前账号id:http://localhost:8081/auth/switchRole?loginId=1001

image

同端互斥

同端互斥就是在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线。

在sa-Token中如何做到同端互斥?

指定设备类型登陆

首先在配置文件中,将 isConcurrent 配置为false,然后调用登录等相关接口时声明设备类型即可:

// 指定`账号id`和`设备类型`进行登录
StpUtil.login(10001, "PC");    
指定设备类型强制注销
// 指定`账号id`和`设备类型`进行强制注销 
StpUtil.logout(10001, "PC");    
查询当前设备类型
// 返回当前token的登录设备类型
StpUtil.getLoginDevice();    
id反查token
// 获取指定loginId指定设备类型端的tokenValue 
StpUtil.getTokenValueByLoginId(10001, "APP");    
案例测试

接口代码

  // 登陆
    @RequestMapping("/loginDevice")
    public SaResult loginDevice(String username, String password) {
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name", username)
                .eq("password", password);
        User user = userMapper.selectOne(wrapper);
        StpUtil.login(user.getId(),"PC");
        SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
        return SaResult.data(tokenInfo);
    }

访问:http://localhost:8081/auth/loginDevice?username=admin&password=123456

image

再访问:http://localhost:8081/auth/loginDevice?username=admin&password=123456

image

发现token值被更新了,再看控制台打印

image

账号封禁

账号封禁

在部分场景下,我们还需要将其 账号封禁,以防止其再次登录。

// 封禁指定账号 
StpUtil.disable(10001, 86400); 

对于正在登陆的账号,封禁并不会立即使他掉线,需要先踢下线再封禁的策略

// 先踢下线
StpUtil.kickout(10001); 
// 再封禁账号
StpUtil.disable(10001, 86400); 

待到下次登录时,我们先校验一下这个账号是否已被封禁:

// 校验指定账号是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
StpUtil.checkDisable(10001); 

// 通过校验后,再进行登录:
StpUtil.login(10001); 


// 封禁指定账号 
StpUtil.disable(10001, 86400); 

// 获取指定账号是否已被封禁 (true=已被封禁, false=未被封禁) 
StpUtil.isDisable(10001); 

// 校验指定账号是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
StpUtil.checkDisable(10001); 

// 获取指定账号剩余封禁时间,单位:秒,如果该账号未被封禁,则返回-2 
StpUtil.getDisableTime(10001); 

// 解除封禁
StpUtil.untieDisable(10001); 

分类封禁

有的时候,我们并不需要将整个账号禁掉,而是只禁止其访问部分服务

有关分类封禁的所有方法

// 封禁:指定账号的指定服务 
StpUtil.disable(10001, "<业务标识>", 86400); 

// 判断:指定账号的指定服务 是否已被封禁 (true=已被封禁, false=未被封禁) 
StpUtil.isDisable(10001, "<业务标识>"); 

// 校验:指定账号的指定服务 是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
StpUtil.checkDisable(10001, "<业务标识>"); 

// 获取:指定账号的指定服务 剩余封禁时间,单位:秒(-1=永久封禁,-2=未被封禁)
StpUtil.getDisableTime(10001, "<业务标识>"); 

// 解封:指定账号的指定服务
StpUtil.untieDisable(10001, "<业务标识>"); 

阶梯封禁

对于多次违规的用户,我们常常采取阶梯处罚的策略,这种 “阶梯” 一般有两种形式:

  • 处罚时间阶梯:首次违规封禁 1 天,第二次封禁 7 天,第三次封禁 30 天,依次顺延……
  • 处罚力度阶梯:首次违规消息提醒、第二次禁言禁评论、第三次禁止账号登录,等等……

相关接口

// 阶梯封禁,参数:封禁账号、封禁级别、封禁时间 
StpUtil.disableLevel(10001, 3, 10000);

// 获取:指定账号封禁的级别 (如果此账号未被封禁则返回 -2)
StpUtil.getDisableLevel(10001);

// 判断:指定账号是否已被封禁到指定级别,返回 true 或 false
StpUtil.isDisableLevel(10001, 3);

// 校验:指定账号是否已被封禁到指定级别,如果已达到此级别(例如已被3级封禁,这里校验是否达到2级),则抛出异常 `DisableServiceException`
StpUtil.checkDisableLevel(10001, 2);

// 分类阶梯封禁,参数:封禁账号、封禁服务、封禁级别、封禁时间 
StpUtil.disableLevel(10001, "comment", 3, 10000);

// 获取:指定账号的指定服务 封禁的级别 (如果此账号未被封禁则返回 -2)
StpUtil.getDisableLevel(10001, "comment");

// 判断:指定账号的指定服务 是否已被封禁到指定级别,返回 true 或 false
StpUtil.isDisableLevel(10001, "comment", 3);

// 校验:指定账号的指定服务 是否已被封禁到指定级别(例如 comment服务 已被3级封禁,这里校验是否达到2级),如果已达到此级别,则抛出异常 
StpUtil.checkDisableLevel(10001, "comment", 2);
使用注解完成封禁校验
@SaCheckDisable // 校验当前账号是否被封禁
@SaCheckDisable("comment") // 校验当前账号是否被封禁 comment 服务
@SaCheckDisable({"comment", "place-order", "open-shop"}) //  校验当前账号是否被封禁 comment、place-order、open-shop 等服务,指定多个值
@SaCheckDisable(level = 5) // 阶梯封禁,校验当前账号封禁等级是否达到5级,如果达到则抛出异常
@SaCheckDisable(value = "comment", level = 5) // 分类封禁 + 阶梯封禁 校验:校验当前账号的 comment 服务,封禁等级是否达到5级
案例测试
   // 校验当前账号是否被封禁,如果已被封禁会抛出异常,无法进入方法
    @SaCheckDisable
    @RequestMapping("/send")
    public SaResult send() {
        return SaResult.ok();
    }
    // 封禁账号接口
    @RequestMapping("/disable/{loginId}")
    public SaResult disableById(@PathVariable String loginId) {
        StpUtil.disable(loginId, 120);
        return SaResult.ok();
    }

先登陆账号1002,封禁账号1001,访问:http://localhost:8081/auth/disable/1001

再登陆账号1001,访问接口:http://localhost:8081/auth/send

image

密码加密

摘要加密

md5、sha1、sha256

// md5加密 
SaSecureUtil.md5("123456");

// sha1加密 
SaSecureUtil.sha1("123456");

// sha256加密 
SaSecureUtil.sha256("123456");

对称加密

AES加密

// 定义秘钥和明文
String key = "123456";
String text = "Sa-Token 一个轻量级java权限认证框架";

// 加密 
String ciphertext = SaSecureUtil.aesEncrypt(key, text);
System.out.println("AES加密后:" + ciphertext);

// 解密 
String text2 = SaSecureUtil.aesDecrypt(key, ciphertext);
System.out.println("AES解密后:" + text2);
非对称加密

RSA加密

// 定义私钥和公钥 
String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAO+wmt01pwm9lHMdq7A8gkEigk0XKMfjv+4IjAFhWCSiTeP7dtlnceFJbkWxvbc7Qo3fCOpwmfcskwUc3VSgyiJkNJDs9ivPbvlt8IU2bZ+PBDxYxSCJFrgouVOpAr8ar/b6gNuYTi1vt3FkGtSjACFb002/68RKUTye8/tdcVilAgMBAAECgYA1COmrSqTUJeuD8Su9ChZ0HROhxR8T45PjMmbwIz7ilDsR1+E7R4VOKPZKW4Kz2VvnklMhtJqMs4MwXWunvxAaUFzQTTg2Fu/WU8Y9ha14OaWZABfChMZlpkmpJW9arKmI22ZuxCEsFGxghTiJQ3tK8npj5IZq5vk+6mFHQ6aJAQJBAPghz91Dpuj+0bOUfOUmzi22obWCBncAD/0CqCLnJlpfOoa9bOcXSusGuSPuKy5KiGyblHMgKI6bq7gcM2DWrGUCQQD3SkOcmia2s/6i7DUEzMKaB0bkkX4Ela/xrfV+A3GzTPv9bIBamu0VIHznuiZbeNeyw7sVo4/GTItq/zn2QJdBAkEA8xHsVoyXTVeShaDIWJKTFyT5dJ1TR++/udKIcuiNIap34tZdgGPI+EM1yoTduBM7YWlnGwA9urW0mj7F9e9WIQJAFjxqSfmeg40512KP/ed/lCQVXtYqU7U2BfBTg8pBfhLtEcOg4wTNTroGITwe2NjL5HovJ2n2sqkNXEio6Ji0QQJAFLW1Kt80qypMqot+mHhS+0KfdOpaKeMWMSR4Ij5VfE63WzETEeWAMQESxzhavN1WOTb3/p6icgcVbgPQBaWhGg==";
String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDvsJrdNacJvZRzHauwPIJBIoJNFyjH47/uCIwBYVgkok3j+3bZZ3HhSW5Fsb23O0KN3wjqcJn3LJMFHN1UoMoiZDSQ7PYrz275bfCFNm2fjwQ8WMUgiRa4KLlTqQK/Gq/2+oDbmE4tb7dxZBrUowAhW9NNv+vESlE8nvP7XXFYpQIDAQAB";

// 文本
String text = "Sa-Token 一个轻量级java权限认证框架";

// 使用公钥加密
String ciphertext = SaSecureUtil.rsaEncryptByPublic(publicKey, text);
System.out.println("公钥加密后:" + ciphertext);

// 使用私钥解密
String text2 = SaSecureUtil.rsaDecryptByPrivate(privateKey, ciphertext);
System.out.println("私钥解密后:" + text2); 

调用以下方法生成公钥和私钥

// 生成一对公钥和私钥,其中Map对象 (private=私钥, public=公钥)
System.out.println(SaSecureUtil.rsaGenerateKeyPair());
Bcrypt加密

由它加密的文件可在所有支持的操作系统和处理器上进行转移

它的口令必须是8至56个字符,并将在内部被转化为448位的密钥

// 使用方法
String pw_hash = BCrypt.hashpw(plain_password, BCrypt.gensalt()); 

// 使用checkpw方法检查被加密的字符串是否与原始字符串匹配:
BCrypt.checkpw(candidate_password, stored_hash); 

// gensalt方法提供了可选参数 (log_rounds) 来定义加盐多少,也决定了加密的复杂度:
String strong_salt = BCrypt.gensalt(10);
String stronger_salt = BCrypt.gensalt(12); 
案例测试

新增注册接口

    // 注册接口
    @RequestMapping("/register")
    public SaResult register(String username, String password) {
        // 使用 BCrypt加密
        String salt = BCrypt.gensalt();
        User user = new User();
        user.setUserName(username);
        user.setPassword(BCrypt.hashpw(password, salt));
        userMapper.insert(user);
        return SaResult.ok();
    } 

// 登陆
    @RequestMapping("/loginEncodePassword")
    public SaResult loginEncodePassword(String username, String password) {
        // 根据用户名查询用户信息
        User user = userMapper.selectOne(new QueryWrapper<User>().eq("user_name", username));

        if (user == null || !BCrypt.checkpw(password, user.getPassword())) {
            throw new NullPointerException("用户名或密码错误");
        }

        StpUtil.login(user.getId(), "PC");
        SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
        return SaResult.data(tokenInfo);
    }

访问注册接口:http://localhost:8081/auth/register?username=super-admin&password=123456

访问登录接口:http://localhost:8081/auth/loginEncodePassword?username=super-admin&password=123456

image

会话查询

具体api
// 查询所有已登录的 Token
StpUtil.searchTokenValue(String keyword, int start, int size, boolean sortType);

// 查询所有账号 Session 会话
StpUtil.searchSessionId(String keyword, int start, int size, boolean sortType);

// 查询所有令牌 Session 会话
StpUtil.searchTokenSessionId(String keyword, int start, int size, boolean sortType);

  • keyword: 查询关键字,只有包括这个字符串的 token 值才会被查询出来。
  • start: 数据开始处索引。
  • size: 要获取的数据条数 (值为-1代表一直获取到末尾)。
  • sortType: 排序方式(true=正序:先登录的在前,false=反序:后登录的在前)。

简单例子

// 查询 value 包括 1000 的所有 token,结果集从第 0 条开始,返回 10 条
List<String> tokenList = StpUtil.searchTokenValue("1000", 0, 10, true);    
for (String token : tokenList) {
    System.out.println(token);
}

Http Basic 认证

Http Basic 是 http 协议中最基础的认证方式。

启用认证
    // http Basic 认证
    @RequestMapping("/basicAuth")
    public SaResult basicAuth() {
        SaBasicUtil.check("admin:123456");
        return SaResult.ok();
    }

在访问接口:http://localhost:8081/auth/basicAuth后会弹出一个对话框

image

其他启用方式
// 对当前会话进行 Basic 校验,账号密码为 yml 配置的值(例如:sa-token.basic=sa:123456)
SaBasicUtil.check();

// 对当前会话进行 Basic 校验,账号密码为:`sa / 123456`
SaBasicUtil.check("sa:123456");

// 以注解方式启用 Basic 校验
@SaCheckBasic(account = "sa:123456")
@RequestMapping("test3")
public SaResult test3() {
    return SaResult.ok();
}

// 在全局拦截器 或 过滤器中启用 Basic 认证 
@Bean
public SaServletFilter getSaServletFilter() {
    return new SaServletFilter()
            .addInclude("/**").addExclude("/favicon.ico")
            .setAuth(obj -> {
                SaRouter.match("/test/**", () -> SaBasicUtil.check("sa:123456"));
            });
}

全局侦听器

Sa-Token 提供一种侦听器机制,通过注册侦听器,你可以订阅框架的一些关键性事件,例如:用户登录、退出、被踢下线等。

开启日志打印

在yml文件中配置开启日志打印

sa-token:
  # 是否输出操作日志
  is-log: true
自定义侦听器实现
新建实现类
package com.wl.sa.listener;

import cn.dev33.satoken.listener.SaTokenListener;
import cn.dev33.satoken.stp.SaLoginModel;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * 自定义侦听器的实现 
 */
@Component
public class MySaTokenListener implements SaTokenListener {

    /** 每次登录时触发 */
    @Override
    public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {

        String nowTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

        System.out.println(loginId + " - " +nowTime+"登陆成功,token为:" + tokenValue);
    }

    /** 每次注销时触发 */
    @Override
    public void doLogout(String loginType, Object loginId, String tokenValue) {
        System.out.println("---------- 自定义侦听器实现 doLogout");
    }

    /** 每次被踢下线时触发 */
    @Override
    public void doKickout(String loginType, Object loginId, String tokenValue) {
        System.out.println("---------- 自定义侦听器实现 doKickout");
    }

    /** 每次被顶下线时触发 */
    @Override
    public void doReplaced(String loginType, Object loginId, String tokenValue) {
        System.out.println("---------- 自定义侦听器实现 doReplaced");
    }

    /** 每次被封禁时触发 */
    @Override
    public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {
        System.out.println("---------- 自定义侦听器实现 doDisable");
    }

    /** 每次被解封时触发 */
    @Override
    public void doUntieDisable(String loginType, Object loginId, String service) {
        System.out.println("---------- 自定义侦听器实现 doUntieDisable");
    }

    /** 每次二级认证时触发 */
    @Override
    public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) {
        System.out.println("---------- 自定义侦听器实现 doOpenSafe");
    }

    /** 每次退出二级认证时触发 */
    @Override
    public void doCloseSafe(String loginType, String tokenValue, String service) {
        System.out.println("---------- 自定义侦听器实现 doCloseSafe");
    }

    /** 每次创建Session时触发 */
    @Override
    public void doCreateSession(String id) {
        System.out.println("---------- 自定义侦听器实现 doCreateSession");
    }

    /** 每次注销Session时触发 */
    @Override
    public void doLogoutSession(String id) {
        System.out.println("---------- 自定义侦听器实现 doLogoutSession");
    }
    
    /** 每次Token续期时触发 */
    @Override
    public void doRenewTimeout(String tokenValue, Object loginId, long timeout) {
        System.out.println("---------- 自定义侦听器实现 doRenewTimeout");
    }
}

全局过滤器

Sa-Token全局过滤器默认处于关闭状态,开启需要手动开启

/**
 * [Sa-Token 权限认证] 配置类 
 */
@Configuration
public class SaTokenConfigure {
    
    /**
     * 注册 [Sa-Token全局过滤器] 
     */
    @Bean
    public SaServletFilter getSaServletFilter() {
        return new SaServletFilter()
        
                // 指定 拦截路由 与 放行路由
                .addInclude("/**").addExclude("/favicon.ico")    /* 排除掉 /favicon.ico */
                
                // 认证函数: 每次请求执行 
                .setAuth(obj -> {
                    System.out.println("---------- 进入Sa-Token全局认证 -----------");
                    
                    // 登录认证 -- 拦截所有路由,并排除/user/doLogin 用于开放登录 
                    SaRouter.match("/**", "/user/doLogin", () -> StpUtil.checkLogin());
                    
                    // 更多拦截处理方式,请参考“路由拦截式鉴权”章节 */
                })
                
                // 异常处理函数:每次认证函数发生异常时执行此函数 
                .setError(e -> {
                    System.out.println("---------- 进入Sa-Token异常处理 -----------");
                    return SaResult.error(e.getMessage());
                })
                
                // 前置函数:在每次认证函数之前执行(BeforeAuth 不受 includeList 与 excludeList 的限制,所有请求都会进入)
                .setBeforeAuth(r -> {
                    // ---------- 设置一些安全响应头 ----------
                    SaHolder.getResponse()
                    // 服务器名称 
                    .setServer("sa-server")
                    // 是否可以在iframe显示视图: DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以 
                    .setHeader("X-Frame-Options", "SAMEORIGIN")
                    // 是否启用浏览器默认XSS防护: 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时,停止渲染页面 
                    .setHeader("X-XSS-Protection", "1; mode=block")
                    // 禁用浏览器内容嗅探 
                    .setHeader("X-Content-Type-Options", "nosniff")
                    ;
                })
                ;
    }
    
}

多账号认证

有的时候,我们会在一个项目中设计两套账号体系,比如一个电商系统的 user表admin表, 在这种场景下,如果两套账号我们都使用 StpUtil 类的API进行登录鉴权,那么势必会发生逻辑冲突。

比如说,对于原生StpUtil类,我们只做admin账号权限认证,而对于user账号,我们则:

  1. 新建一个新的权限认证类,比如: StpUserUtil.java
  2. StpUtil.java类的全部代码复制粘贴到 StpUserUtil.java里。
  3. 更改一下其 LoginType, 比如:
public class StpUserUtil {
    
    /**
     * 账号体系标识 
     */
    public static final String TYPE = "user";    // 将 LoginType 从`login`改为`user` 

    // 其它代码 ... 

}复制到剪贴板错误复制成功12345678910
  1. 接下来就可以像调用StpUtil.java一样调用 StpUserUtil.java了,这两套账号认证的逻辑是完全隔离的。

单点登陆

单点登陆简介

参考:单点登陆简介

搭建统一认证中心
添加依赖
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot-starter</artifactId>
            <version>1.35.0.RC</version>
        </dependency>

        <!-- Sa-Token 插件:整合SSO -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-sso</artifactId>
            <version>1.35.0.RC</version>
        </dependency>

        <!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-redis-jackson</artifactId>
            <version>1.35.0.RC</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <!-- 视图引擎(在前后端不分离模式下提供视图支持) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <!-- Http请求工具(在模式三的单点注销功能下用到,如不需要可以注释掉) -->
        <dependency>
            <groupId>com.dtflys.forest</groupId>
            <artifactId>forest-spring-boot-starter</artifactId>
            <version>1.5.26</version>
        </dependency>
开发认证接口

新建 SsoServerController,用于对外开放接口:

package com.example.sasso.controller;

import cn.dev33.satoken.config.SaSsoConfig;
import cn.dev33.satoken.sso.SaSsoProcessor;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import com.dtflys.forest.Forest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * Sa-Token-SSO Server端 Controller 
 */
@RestController
public class SsoServerController {

    /*
     * SSO-Server端:处理所有SSO相关请求 (下面的章节我们会详细列出开放的接口) 
     */
    @RequestMapping("/sso/*")
    public Object ssoRequest() {
        return SaSsoProcessor.instance.serverDister();
    }
    
    /**
     * 配置SSO相关参数 
     */
    @Autowired
    private void configSso(SaSsoConfig sso) {
        // 配置:未登录时返回的View 
        sso.setNotLoginView(() -> {
            String msg = "当前会话在SSO-Server端尚未登录,请先访问"
                    + "<a href='/sso/doLogin?name=sa&pwd=123456' target='_blank'> doLogin登录 </a>"
                    + "进行登录之后,刷新页面开始授权";
            return msg;
        });
        
        // 配置:登录处理函数 
        sso.setDoLoginHandle((name, pwd) -> {
            // 此处仅做模拟登录,真实环境应该查询数据进行登录 
            if("sa".equals(name) && "123456".equals(pwd)) {
                StpUtil.login(10001);
                return SaResult.ok("登录成功!").setData(StpUtil.getTokenValue());
            }
            return SaResult.error("登录失败!");
        });
        
        // 配置 Http 请求处理器 (在模式三的单点注销功能下用到,如不需要可以注释掉) 
        sso.setSendHttp(url -> {
            try {
                // 发起 http 请求 
                System.out.println("------ 发起请求:" + url);
                return Forest.get(url).executeAsString();
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        });
    }
    
}
yml配置
# 端口
server:
  port: 9000

# Sa-Token 配置
sa-token:
  # ------- SSO-模式一相关配置  (非模式一不需要配置) 
  # cookie: 
  # 配置 Cookie 作用域 
  # domain: stp.com 
  
  # ------- SSO-模式二相关配置 
  sso:
    # Ticket有效期 (单位: 秒),默认五分钟 
    ticket-timeout: 300
    # 所有允许的授权回调地址
    allow-url: "*"
    
    # ------- SSO-模式三相关配置 (下面的配置在使用SSO模式三时打开)
    # 是否打开模式三 
    is-http: true
  sign:
    # API 接口调用秘钥
    secret-key: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
    # ---- 除了以上配置项,你还需要为 Sa-Token 配置http请求处理器(文档有步骤说明) 

spring:
  # Redis配置 (SSO模式一和模式二使用Redis来同步会话)
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    password:

forest:
  # 关闭 forest 请求日志打印
  log-enabled: false

测试

访问:http://localhost:9000/sso/auth

image

默认账号密码为:sa / 123456

SSO模式一共享Cookie同步会话

设计思路

如果我们的多个系统可以做到:前端同域、后端同Redis,那么便可以使用 [共享Cookie同步会话] 的方式做到单点登录。

首先我们分析一下多个系统之间,为什么无法同步登录状态?

  1. 前端的 Token 无法在多个系统下共享。
  2. 后端的 Session 无法在多个系统间共享。

所以单点登录第一招,就是对症下药:

  1. 使用 共享Cookie 来解决 Token 共享问题。
  2. 使用 Redis 来解决 Session 共享问题。

所谓共享Cookie,就是主域名Cookie在二级域名下的共享,举个例子:写在父域名stp.com下的Cookie,在s1.stp.coms2.stp.com等子域名都是可以共享访问的。

而共享Redis,并不需要我们把所有项目的数据都放在同一个Redis中,Sa-Token提供了 [权限缓存与业务缓存分离] 的解决方案,详情戳:Alone独立Redis插件

准备工作

在hosts文件里添加以下内容

127.0.0.1 sso.stp.com
127.0.0.1 s1.stp.com
127.0.0.1 s2.stp.com
127.0.0.1 s3.stp.com
指定Cookie作用域

sso.stp.com访问服务器,其Cookie也只能写入到sso.stp.com下,为了将Cookie写入到其父级域名stp.com下,我们需要更改 SSO-Server 端的 yml 配置

sa-token: 
    cookie: 
        # 配置 Cookie 作用域 
        domain: stp.com 

这个时候 server认证中心yml配置应该是

# 端口
server:
  port: 9000

# Sa-Token 配置
sa-token:
  # ------- SSO-模式一相关配置  (非模式一不需要配置)
  cookie:
  # 配置 Cookie 作用域
   domain: stp.com
spring:
  # Redis配置 (SSO模式一和模式二使用Redis来同步会话)
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    password:

forest:
  # 关闭 forest 请求日志打印
  log-enabled: false
搭建Client端项目
添加依赖
        <!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot-starter</artifactId>
            <version>1.35.0.RC</version>
        </dependency>
        <!-- Sa-Token 插件:整合SSO -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-sso</artifactId>
            <version>1.35.0.RC</version>
        </dependency>

        <!-- Sa-Token 整合redis (使用jackson序列化方式) -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-redis-jackson</artifactId>
            <version>1.35.0.RC</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <!-- Sa-Token插件:权限缓存与业务缓存分离 -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-alone-redis</artifactId>
            <version>1.35.0.RC</version>
        </dependency>
新建controller
package com.wl.sassoclient.controller;

import cn.dev33.satoken.sso.SaSsoManager;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
public class SsoClientController {

    // SSO-Client端:首页 
    @RequestMapping("/")
    public String index() {
        String authUrl = SaSsoManager.getConfig().splicingAuthUrl();
        String solUrl = SaSsoManager.getConfig().splicingSloUrl();
        String str = "<h2>Sa-Token SSO-Client 应用端</h2>" + 
                    "<p>当前会话是否登录:" + StpUtil.isLogin() + "</p>" +
                    "<p><a href=\"javascript:location.href='" + authUrl + "?mode=simple&redirect=' + encodeURIComponent(location.href);\">登录</a> " + 
                    "<a href=\"javascript:location.href='" + solUrl + "?back=' + encodeURIComponent(location.href);\">注销</a> </p>";
        return str;
    }
    
    // 全局异常拦截 
    @ExceptionHandler
    public SaResult handlerException(Exception e) {
        e.printStackTrace(); 
        return SaResult.error(e.getMessage());
    }
    
}

yml配置

# 端口
server:
  port: 9001

# Sa-Token 配置
sa-token:
  # SSO-相关配置
  sso:
    # SSO-Server端-单点登录授权地址
    auth-url: http://sso.stp.com:9000/sso/auth
    # SSO-Server端-单点注销地址
    slo-url: http://sso.stp.com:9000/sso/signout

  # 配置 Sa-Token 单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis)
  alone-redis:
    # Redis数据库索引
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    password:
    # 连接超时时间
    timeout: 10s
访问测试

启动项目访问三个应用端:

都返回

image

点击登录,被重定向至SSO认证中心

image

点击登陆,刷线页面,另外两个Client端也登陆了

image

image

image

SSO模式二URL重定向传播会话

如果我们的多个系统:部署在不同的域名之下,但是后端可以连接同一个Redis,那么便可以使用 [URL重定向传播会话] 的方式做到单点登录。

首先我们再次复习一下,多个系统之间为什么无法同步登录状态?

  1. 前端的Token无法在多个系统下共享。
  2. 后端的Session无法在多个系统间共享。

关于第二点,我们已在 "SSO模式一" 章节中阐述,使用 Alone独立Redis插件 做到权限缓存直连 SSO-Redis 数据中心,在此不再赘述。

而第一点,才是我们解决问题的关键所在,在跨域模式下,意味着 "共享Cookie方案" 的失效,我们必须采用一种新的方案来传递Token。

  1. 用户在 子系统 点击 [登录] 按钮。

  2. 用户跳转到子系统登录接口/sso/login,并携带back参数

    记录初始页面URL。

    • 形如:http://{sso-client}/sso/login?back=xxx
  3. 子系统检测到此用户尚未登录,再次将其重定向至SSO认证中心,并携带redirect参数记录子系统的登录页URL。

    • 形如:http://{sso-server}/sso/auth?redirect=xxx?back=xxx
  4. 用户进入了 SSO认证中心 的登录页面,开始登录。

  5. 用户 输入账号密码 并 登录成功,SSO认证中心再次将用户重定向至子系统的登录接口/sso/login

    ,并携带ticket码参数。

    • 形如:http://{sso-client}/sso/login?back=xxx&ticket=xxxxxxxxx
  6. 子系统根据 ticket码SSO-Redis 中获取账号id,并在子系统登录此账号会话。

  7. 子系统将用户再次重定向至最初始的 back 页面。

整个过程,除了第四步用户在SSO认证中心登录时会被打断,其余过程均是自动化的,当用户在另一个子系统再次点击[登录]按钮,由于此用户在SSO认证中心已有会话存在, 所以第四步也将自动化,也就是单点登录的最终目的 —— 一次登录,处处通行。

1.修改hosts文件

添加以下内容

127.0.0.1 sa-sso-server.com
127.0.0.1 sa-sso-client1.com
127.0.0.1 sa-sso-client2.com
127.0.0.1 sa-sso-client3.com
2.注释配置认证中心 server 的yml以下配置
sa-token: 
    cookie: 
        # 配置 Cookie 作用域 
        domain: stp.com 

这个时候server的yml应该为

# 端口
server:
  port: 9000

# Sa-Token 配置
sa-token:
  # ------- SSO-模式二相关配置
  sso:
    # Ticket有效期 (单位: 秒),默认五分钟
    ticket-timeout: 300
    # 所有允许的授权回调地址
    allow-url: "*"

spring:
  # Redis配置 (SSO模式一和模式二使用Redis来同步会话)
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    password:

forest:
  # 关闭 forest 请求日志打印
  log-enabled: false
3.引入依赖
<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.35.0.RC</version>
</dependency>
<!-- Sa-Token 插件:整合SSO -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-sso</artifactId>
    <version>1.35.0.RC</version>
</dependency>

<!-- Sa-Token 整合redis (使用jackson序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.35.0.RC</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

<!-- Sa-Token插件:权限缓存与业务缓存分离 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-alone-redis</artifactId>
    <version>1.35.0.RC</version>
</dependency>
创建sso-client端认证接口
/**
 * Sa-Token-SSO Client端 Controller 
 */
@RestController
public class SsoClientController {

    // 首页 
    @RequestMapping("/")
    public String index() {
        String str = "<h2>Sa-Token SSO-Client 应用端</h2>" + 
                    "<p>当前会话是否登录:" + StpUtil.isLogin() + "</p>" + 
                    "<p><a href=\"javascript:location.href='/sso/login?back=' + encodeURIComponent(location.href);\">登录</a> " + 
                    "<a href='/sso/logout?back=self'>注销</a></p>";
        return str;
    }
    
    /*
     * SSO-Client端:处理所有SSO相关请求 
     *         http://{host}:{port}/sso/login          -- Client端登录地址,接受参数:back=登录后的跳转地址 
     *         http://{host}:{port}/sso/logout         -- Client端单点注销地址(isSlo=true时打开),接受参数:back=注销后的跳转地址 
     *         http://{host}:{port}/sso/logoutCall     -- Client端单点注销回调地址(isSlo=true时打开),此接口为框架回调,开发者无需关心
     */
    @RequestMapping("/sso/*")
    public Object ssoRequest() {
        return SaSsoProcessor.instance.clientDister();
    }

}
yml配置
# 端口
server:
    port: 9001

# sa-token配置 
sa-token: 
    # SSO-相关配置
    sso: 
        # SSO-Server端 统一认证地址 
        auth-url: http://sa-sso-server.com:9000/sso/auth

    # 配置Sa-Token单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis)
    alone-redis: 
        # Redis数据库索引 (默认为0)
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        password: 
        # 连接超时时间
        timeout: 10s
测试

启动项目,访问:http://sa-sso-client1.com:9001/

首次打开,提示当前未登录,我们点击登录 按钮,页面会被重定向到登录中心

image

点击登录,刷新页面

image

再访问:http://sa-sso-client1.com:9001/,发现还是未登陆状态,点击登录,会直接提示登录成功

image

这样就完成了不同域, 同redis的单点登陆。

posted @ 2023-08-15 15:57  没有梦想的java菜鸟  阅读(1681)  评论(1编辑  收藏  举报