再见,Shiro !你好,Sa-Token!

开发 前端
看到这个页面目前非常简陋,这是因为我们以上的代码示例,主要目标是为了带大家从零搭建一个可用的SSO认证服务端,所以就对一些不太必要的步骤做了简化。​

Sa-Token 介绍

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

Sa-Token 旨在以简单、优雅的方式完成系统的权限认证部分,以登录认证为例,你只需要:

// 会话登录,参数填登录人的账号id 
StpUtil.login(10001);

无需实现任何接口,无需创建任何配置文件,只需要这一句静态代码的调用,便可以完成会话登录认证。

如果一个接口需要登录后才能访问,我们只需调用以下代码:

// 校验当前客户端是否已经登录,如果未登录则抛出 `NotLoginException` 异常
StpUtil.checkLogin();

在 Sa-Token 中,大多数功能都可以一行代码解决:

踢人下线:

// 将账号id为 10077 的会话踢下线 
StpUtil.kickout(10077);

权限认证:

// 注解鉴权:只有具备 `user:add` 权限的会话才可以进入方法
@SaCheckPermission("user:add")
    public String insert(SysUser user) {
    // ... 
    return "用户增加";
}

路由拦截鉴权:

// 根据路由划分模块,不同模块不同鉴权 
registry.addInterceptor(new SaInterceptor(handler -> {
    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"));
    // 更多模块... 
})).addPathPatterns("/**");

当你受够 Shiro、SpringSecurity 等框架的三拜九叩之后,你就会明白,相对于这些传统老牌框架,Sa-Token 的 API 设计是多么的简单、优雅!

Sa-Token 功能一览

Sa-Token 目前主要五大功能模块:登录认证、权限认证、单点登录、OAuth2.0、微服务鉴权。

  • 登录认证 —— 单端登录、多端登录、同端互斥登录、七天内免登录
  • 权限认证 —— 权限认证、角色认证、会话二级认证
  • Session会话 —— 全端共享Session、单端独享Session、自定义Session
  • 踢人下线 —— 根据账号id踢人下线、根据Token值踢人下线
  • 账号封禁 —— 登录封禁、按照业务分类封禁、按照处罚阶梯封禁
  • 持久层扩展 —— 可集成Redis、Memcached等专业缓存中间件,重启数据不丢失
  • 分布式会话 —— 提供jwt集成、共享数据中心两种分布式会话方案
  • 微服务网关鉴权 —— 适配Gateway、ShenYu、Zuul等常见网关的路由拦截认证
  • 单点登录 —— 内置三种单点登录模式:无论是否跨域、是否共享Redis,都可以搞定
  • OAuth2.0认证 —— 轻松搭建 OAuth2.0 服务,支持openid模式
  • 二级认证 —— 在已登录的基础上再次认证,保证安全性
  • Basic认证 —— 一行代码接入 Http Basic 认证
  • 独立Redis —— 将权限缓存与业务缓存分离
  • 临时Token认证 —— 解决短时间的Token授权问题
  • 模拟他人账号 —— 实时操作任意用户状态数据
  • 临时身份切换 —— 将会话身份临时切换为其它账号
  • 前后台分离 —— APP、小程序等不支持Cookie的终端
  • 同端互斥登录 —— 像QQ一样手机电脑同时在线,但是两个手机上互斥登录
  • 多账号认证体系 —— 比如一个商城项目的user表和admin表分开鉴权
  • Token风格定制 —— 内置六种Token风格,还可:自定义Token生成策略、自定义Token前缀
  • 注解式鉴权 —— 优雅的将鉴权与业务代码分离
  • 路由拦截式鉴权 —— 根据路由拦截鉴权,可适配restful模式
  • 自动续签 —— 提供两种Token过期策略,灵活搭配使用,还可自动续签
  • 会话治理 —— 提供方便灵活的会话查询接口
  • 记住我模式 —— 适配[记住我]模式,重启浏览器免验证
  • 密码加密 —— 提供密码加密模块,可快速MD5、SHA1、SHA256、AES、RSA加密
  • 全局侦听器 —— 在用户登陆、注销、被踢下线等关键性操作时进行一些AOP操作
  • 开箱即用 —— 提供SpringMVC、WebFlux等常见web框架starter集成包,真正的开箱即用

SpringBoot 集成 Sa-Token

一、添加maven依赖

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

注:如果你使用的 SpringBoot 3.x,只需要将 sa-token-spring-boot-starter 修改为 sa-token-spring-boot3-starter 即可。

二、设置配置文件

你可以零配置启动项目 ,但同时你也可以在 application.yml 中增加如下配置,定制性使用框架:

server:
    # 端口
    port: 8081


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

三、创建案例

@RestController
    @RequestMapping("/user/")
    public class UserController {


        // 测试登录,浏览器访问:http://localhost:8081/user/doLogin?username=zhang&password=123456
        @RequestMapping("doLogin")
        public String doLogin(String username, String password) {
            // 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对 
            if("iron".equals(username) && "123456".equals(password)) {
                StpUtil.login(10001);
                return "登录成功";
            }
            return "登录失败";
        }


        // 查询登录状态,浏览器访问:http://localhost:8081/user/isLogin
        @RequestMapping("isLogin")
        public String isLogin() {
            return "当前会话是否登录:" + StpUtil.isLogin();
        }


    }

登录认证

设计思路

对于一些登录之后才能访问的接口(例如:查询我的账号资料),我们通常的做法是增加一层接口校验:

  • 如果校验通过,则:正常返回数据。
  • 如果校验未通过,则:抛出异常,告知其需要先进行登录。

那么,判断会话是否登录的依据是什么?我们先来简单分析一下登录访问流程:

  1. 用户提交 name + password 参数,调用登录接口。
  2. 登录成功,返回这个用户的 Token 会话凭证。
  3. 用户后续的每次请求,都携带上这个 Token。
  4. 服务器根据 Token 判断此会话是否登录成功。

所谓登录认证,指的就是服务器校验账号密码,为用户颁发 Token 会话凭证的过程,这个 Token 也是我们后续判断会话是否登录的关键所在。

登录与注销

根据以上思路,我们需要一个会话登录的函数:

// 会话登录:参数填写要登录的账号id,建议的数据类型:long | int | String, 不可以传入复杂类型,如:User、Admin 等等
StpUtil.login(Object id);

只此一句代码,便可以使会话登录成功,实际上,Sa-Token 在背后做了大量的工作,包括但不限于:

  1. 检查此账号是否之前已有登录
  2. 为账号生成 Token 凭证与 Session 会话
  3. 通知全局侦听器,xx 账号登录成功
  4. 将 Token 注入到请求上下文
  5. 等等其它工作……

你暂时不需要完整的了解整个登录过程,你只需要记住关键一点:Sa-Token 为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端。

所以一般情况下,我们的登录接口代码,会大致类似如下:

// 会话登录接口 
@RequestMapping("doLogin")
    public SaResult doLogin(String name, String pwd) {
    // 第一步:比对前端提交的账号名称、密码
    if("iron".equals(name) && "123456".equals(pwd)) {
        // 第二步:根据账号id,进行登录 
        StpUtil.login(10001);
        return SaResult.ok("登录成功");
    }
    return SaResult.error("登录失败");
}

如果你对以上代码阅读没有压力,你可能会注意到略显奇怪的一点:此处仅仅做了会话登录,但并没有主动向前端返回 Token 信息。是因为不需要吗?严格来讲是需要的,只不过 StpUtil.login(id) 方法利用了 Cookie 自动注入的特性,省略了你手写返回 Token 的代码。

如果你对 Cookie 功能还不太了解,也不用担心,我们会在之后的 [ 前后端分离 ] 章节中详细的阐述 Cookie 功能,现在你只需要了解最基本的两点:

  • Cookie 可以从后端控制往浏览器中写入 Token 值。
  • Cookie 会在前端每次发起请求时自动提交 Token 值。

因此,在 Cookie 功能的加持下,我们可以仅靠 StpUtil.login(id) 一句代码就完成登录认证。

除了登录方法,我们还需要:

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


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


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

异常 NotLoginException 代表当前会话暂未登录,可能的原因有很多:前端没有提交 Token、前端提交的 Token 是无效的、前端提交的 Token 已经过期 …… 等等,可参照此篇:未登录场景值,了解如何获取未登录的场景值。

会话查询

// 获取当前会话账号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();

有关TokenInfo参数详解,请参考:TokenInfo参数详解

来个小测试,加深一下理解

新建 LoginController,复制以下代码

/**
 * 登录测试 
 */
@RestController
@RequestMapping("/acc/")
public class LoginController {


    // 测试登录  ---- http://localhost:8081/acc/doLogin?name=zhang&pwd=123456
    @RequestMapping("doLogin")
    public SaResult doLogin(String name, String pwd) {
        // 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对 
        if("iron".equals(name) && "123456".equals(pwd)) {
            StpUtil.login(10001);
            return SaResult.ok("登录成功");
        }
        return SaResult.error("登录失败");
    }


    // 查询登录状态  ---- http://localhost:8081/acc/isLogin
    @RequestMapping("isLogin")
    public SaResult isLogin() {
        return SaResult.ok("是否登录:" + StpUtil.isLogin());
    }


    // 查询 Token 信息  ---- http://localhost:8081/acc/tokenInfo
    @RequestMapping("tokenInfo")
    public SaResult tokenInfo() {
        return SaResult.data(StpUtil.getTokenInfo());
    }


    // 测试注销  ---- http://localhost:8081/acc/logout
    @RequestMapping("logout")
    public SaResult logout() {
        StpUtil.logout();
        return SaResult.ok();
    }


}

权限认证

设计思路

所谓权限认证,核心逻辑就是判断一个账号是否拥有指定权限:

  • 有,就让你通过。
  • 没有?那么禁止访问!

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

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

所以现在问题的核心就是:

  1. 如何获取一个账号所拥有的的权限码集合?
  2. 本次操作需要验证的权限码是哪个?

获取当前账号权限码集合

因为每个项目的需求不同,其权限设计也千变万化,因此 [ 获取当前账号权限码集合 ] 这一操作不可能内置到框架中, 所以 Sa-Token 将此操作以接口的方式暴露给你,以方便你根据自己的业务逻辑进行重写。

你需要做的就是新建一个类,实现 StpInterface接口,例如以下代码:

/**
 * 自定义权限验证接口扩展
 */
@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;
    }


}

参数解释:

  • loginId:账号id,即你在调用 StpUtil.login(id) 时写入的标识值。
  • loginType:账号体系标识,此处可以暂时忽略,在 [ 多账户认证 ] 章节下会对这个概念做详细的解释。

可参考代码:码云:StpInterfaceImpl.java

注意: StpInterface 接口在需要鉴权时由框架自动调用,开发者只需要配置好就可以使用下面的鉴权方法或后面的注解鉴权

权限校验

然后就可以用以下api来鉴权了

// 获取:当前账号所拥有的权限集合
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");

扩展:NotPermissionException 对象可通过 getLoginType() 方法获取具体是哪个 StpLogic 抛出的异常

角色校验

在Sa-Token中,角色和权限可以独立验证

// 获取:当前账号所拥有的角色集合
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");

扩展:NotRoleException 对象可通过 getLoginType() 方法获取具体是哪个 StpLogic 抛出的异常

权限通配符

Sa-Token允许你根据通配符指定泛权限,例如当一个账号拥有art.*的权限时,art.add、art.delete、art.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

上帝权限:当一个账号拥有 "*" 权限时,他可以验证通过任何权限码 (角色认证同理)

如何把权限精确到按钮级?

权限精确到按钮级的意思就是指:权限范围可以控制到页面上的每一个按钮是否显示。

思路:如此精确的范围控制只依赖后端已经难以完成,此时需要前端进行一定的逻辑判断。

如果是前后端一体项目,可以参考:Thymeleaf 标签方言,如果是前后端分离项目,则:

  1. 在登录时,把当前账号拥有的所有权限码一次性返回给前端。
  2. 前端将权限码集合保存在localStorage或其它全局状态管理对象中。
  3. 在需要权限控制的按钮上,使用 js 进行逻辑判断,例如在Vue框架中我们可以使用如下写法:
<button v-if="arr.indexOf('user.delete') > -1">删除按钮</button>

其中:arr是当前用户拥有的权限码数组,user.delete是显示按钮需要拥有的权限码,删除按钮是用户拥有权限码才可以看到的内容。

注意:以上写法只为提供一个参考示例,不同框架有不同写法,大家可根据项目技术栈灵活封装进行调用。

踢人下线

所谓踢人下线,核心操作就是找到指定 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: 登录校验 —— 只有登录之后才能进入该方法。
  • @SaCheckRole("admin"): 角色校验 —— 必须具有指定角色标识才能进入该方法。
  • @SaCheckPermission("user:add"): 权限校验 —— 必须具有指定权限才能进入该方法。
  • @SaCheckSafe: 二级认证校验 —— 必须二级认证之后才能进入该方法。
  • @SaCheckBasic: HttpBasic校验 —— 只有通过 Basic 认证后才能进入该方法。
  • @SaIgnore:忽略校验 —— 表示被修饰的方法或类无需进行注解鉴权和路由拦截器鉴权。
  • @SaCheckDisable("comment"):账号服务封禁校验 —— 校验当前账号指定服务是否被封禁。

Sa-Token 使用全局拦截器完成注解鉴权功能,为了不为项目带来不必要的性能负担,拦截器默认处于关闭状态因此,为了使用注解鉴权,你必须手动将 Sa-Token 的全局拦截器注册到你项目中

1、注册拦截器

以SpringBoot2.0为例,新建配置类SaTokenConfigure.java

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
    // 注册 Sa-Token 拦截器,打开注解式鉴权功能 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册 Sa-Token 拦截器,打开注解式鉴权功能 
        registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");    
    }
}

保证此类被springboot启动类扫描到即可

2、使用注解鉴权

然后我们就可以愉快的使用注解鉴权了:

// 登录校验:只有登录之后才能进入该方法 
@SaCheckLogin                        
    @RequestMapping("info")
    public String info() {
    return "查询用户信息";
}


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


// 权限校验:必须具有指定权限才能进入该方法 
@SaCheckPermission("user-add")        
    @RequestMapping("add")
    public String add() {
    return "用户增加";
}


// 二级认证校验:必须二级认证之后才能进入该方法 
@SaCheckSafe()        
    @RequestMapping("add")
    public String add() {
    return "用户增加";
}


// Http Basic 校验:只有通过 Basic 认证后才能进入该方法 
@SaCheckBasic(account = "sa:123456")
    @RequestMapping("add")
    public String add() {
    return "用户增加";
}


// 校验当前账号是否被封禁 comment 服务,如果已被封禁会抛出异常,无法进入方法 
@SaCheckDisable("comment")                
    @RequestMapping("send")
    public String send() {
    return "查询用户信息";
}

注:以上注解都可以加在类上,代表为这个类所有方法进行鉴权

3、设定校验模式

@SaCheckRole与@SaCheckPermission注解可设置校验模式,例如:

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

mode有两种取值:

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

4、角色权限双重 “or校验”

假设有以下业务场景:一个接口在具有权限 user.add 或角色 admin 时可以调通。怎么写?

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

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

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

5、忽略认证

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

@SaCheckLogin
@RestController
public class TestController {


    // ... 其它方法 


    // 此接口加上了 @SaIgnore 可以游客访问 
    @SaIgnore
    @RequestMapping("getList")
    public SaResult getList() {
        // ... 
        return SaResult.ok(); 
    }
}

如上代码表示:TestController 中的所有方法都需要登录后才可以访问,但是 getList 接口可以匿名游客访问。

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

6、在业务逻辑层使用注解鉴权

疑问:我能否将注解写在其它架构层呢,比如业务逻辑层?

使用拦截器模式,只能在Controller层进行注解鉴权,如需在任意层级使用注解鉴权,请参考:AOP注解鉴权

自定义 Token 风格

自定义token前缀

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

内置风格

Sa-Token默认的token生成策略是uuid风格,其模样类似于:623368f0-ae5e-4475-a53f-93e4225f16ae。如果你对这种风格不太感冒,还可以将token生成设置为其他风格。

怎么设置呢?只需要在yml配置文件里设置 sa-token.token-style=风格类型 即可,其有多种取值:

// 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 生成策略

如果你觉着以上风格都不是你喜欢的类型,那么你还可以自定义token生成策略,来定制化token生成风格。

怎么做呢?只需要重写 SaStrategy 策略类的 createToken 算法即可:

参考步骤如下:

1、在SaTokenConfigure配置类中添加代码:

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

2、再次调用 StpUtil.login(10001)方法进行登录,观察其生成的token样式:

fuPSwZsnUhwgz08GTCH4wOgasWtc3odP4HLwXJ7NDGOximTvT4OlW19zeLH

集成JWT的Token风格

引入依赖

首先在项目已经引入 Sa-Token 的基础上,继续添加:

<!-- Sa-Token 整合 jwt -->
<dependency>
  <groupId>cn.dev33</groupId>
  <artifactId>sa-token-jwt</artifactId>
  <version>1.34.0</version>
</dependency>

注意: sa-token-jwt 显式依赖 hutool-jwt 5.7.14 版本,意味着:你的项目中要么不引入 Hutool,要么引入版本 >= 5.7.14 的 Hutool 版本

配置密钥

在 application.yml 配置文件中配置 jwt 生成秘钥:

sa-token:
    # jwt秘钥 
    jwt-secret-key: asdasdasifhueuiwyurfewbfjsdafjk

注入jwt实现

根据不同的整合规则,插件提供了三种不同的模式,你需要 选择其中一种 注入到你的项目中

@Configuration
public class SaTokenConfigure {
    // Sa-Token 整合 jwt (Simple 简单模式)
    @Bean
    public StpLogic getStpLogicJwt() {
        return new StpLogicJwtForSimple();
    }


     // Sa-Token 整合 jwt (Mixin 混入模式)
    @Bean
    public StpLogic getStpLogicJwt() {
        return new StpLogicJwtForMixin();
    }


     // Sa-Token 整合 jwt (Stateless 无状态模式)
    @Bean
    public StpLogic getStpLogicJwt() {
        return new StpLogicJwtForStateless();
    }
}

不同模式策略对比

注入不同模式会让框架具有不同的行为策略,以下是三种模式的差异点(为方便叙述,以下比较以同时引入 jwt 与 Redis 作为前提):

功能点

Simple 简单模式

Mixin 混入模式

Stateless 无状态模式

Token风格

jwt风格

jwt风格

jwt风格

登录数据存储

Redis中

Token中

Token中

Session存储

Redis中

Redis中

无Session

注销下线

前后端双清数据

前后端双清数据

前端清除数据

踢人下线API

支持

不支持

不支持

顶人下线API

支持

不支持

不支持

登录认证

支持

支持

支持

角色认证

支持

支持

支持

权限认证

支持

支持

支持

timeout 有效期

支持

支持

支持

activity-timeout 有效期

支持

支持

不支持

id反查Token

支持

支持

不支持

会话管理

支持

部分支持

不支持

注解鉴权

支持

支持

支持

路由拦截鉴权

支持

支持

支持

账号封禁

支持

支持

不支持

身份切换

支持

支持

支持

二级认证

支持

支持

支持

模式总结

Token风格替换

jwt 与 Redis 逻辑混合

完全舍弃Redis,只用jwt

扩展参数

你可以通过以下方式在登录时注入扩展参数:

// 登录10001账号,并为生成的 Token 追加扩展参数name
StpUtil.login(10001, SaLoginConfig.setExtra("name", "zhangsan"));


// 连缀写法追加多个
StpUtil.login(10001, SaLoginConfig
              .setExtra("name", "zhangsan")
              .setExtra("age", 18)
              .setExtra("role", "超级管理员"));


// 获取扩展参数 
String name = StpUtil.getExtra("name");


// 获取任意 Token 的扩展参数 
String name = StpUtil.getExtra("tokenValue", "name");

在多账户模式中集成 jwt

sa-token-jwt 插件默认只为 StpUtil 注入 StpLogicJwtFoxXxx 实现,自定义的 StpUserUtil 是不会自动注入的,我们需要帮其手动注入:

/**
 * 为 StpUserUtil 注入 StpLogicJwt 实现 
 */
@Autowired
public void setUserStpLogic() {
    StpUserUtil.setStpLogic(new StpLogicJwtForSimple(StpUserUtil.TYPE));
}

自定义 SaJwtUtil 生成 token 的算法

如果需要自定义生成 token 的算法(例如更换sign方式),直接重写 SaJwtTemplate 对象即可:

/**
 * 自定义 SaJwtUtil 生成 token 的算法 
 */
@Autowired
public void setSaJwtTemplate() {
    SaJwtUtil.setSaJwtTemplate(new SaJwtTemplate() {
        @Override
        public String generateToken(JWT jwt, String keyt) {
            System.out.println("------ 自定义了 token 生成算法");
            return super.generateToken(jwt, keyt);
        }
    });
}

注意点

1、使用 jwt-simple 模式后,is-share=false 恒等于 false。

is-share=true 的意思是每次登录都产生一样的 token,这种策略和 [ 为每个 token 单独设定 setExtra 数据 ] 不兼容的, 为保证正确设定 Extra 数据,当使用 jwt-simple 模式后,is-share 配置项 恒等于 false。

2、使用 jwt-mixin 模式后,is-concurrent 必须为 true。

is-concurrent=false 代表每次登录都把旧登录顶下线,但是 jwt-mixin 模式登录的 token 并不会记录在持久库数据中, 技术上来讲无法将其踢下线,所以此时顶人下线和踢人下线等 API 都属于不可用状态,所以此时 is-concurrent 配置项必须配置为 true。

全局侦听器

工作原理

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

事件触发流程大致如下:

框架默认内置了侦听器 SaTokenListenerForLog 实现:代码参考 ,功能是控制台 log 打印输出,你可以通过配置sa-token.is-log=true开启。

要注册自定义的侦听器也非常简单:

  1. 新建类实现 SaTokenListener 接口。
  2. 将实现类注册到 SaTokenEventCenter 事件发布中心。

自定义侦听器实现

新建实现类:

新建MySaTokenListener.java,实现SaTokenListener接口,并添加上注解@Component,保证此类被SpringBoot扫描到:

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


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


    /** 每次注销时触发 */
    @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");
    }
}
2.2、将侦听器注册到事件中心:

以上代码由于添加了 @Component 注解,会被 SpringBoot 扫描并自动注册到事件中心,此时我们无需手动注册。

如果我们没有添加 @Component 注解或者项目属于非 IOC 自动注入环境,则需要我们手动将这个侦听器注册到事件中心:

// 将侦听器注册到事件发布中心
SaTokenEventCenter.registerListener(new MySaTokenListener());

事件中心的其它一些常用方法:

// 获取已注册的所有侦听器 
SaTokenEventCenter.getListenerList(); 


// 重置侦听器集合 
SaTokenEventCenter.setListenerList(listenerList); 


// 注册一个侦听器 
SaTokenEventCenter.registerListener(listener); 


// 注册一组侦听器 
SaTokenEventCenter.registerListenerList(listenerList); 


// 移除一个侦听器 
SaTokenEventCenter.removeListener(listener); 


// 移除指定类型的所有侦听器 
SaTokenEventCenter.removeListener(cls); 


// 清空所有已注册的侦听器 
SaTokenEventCenter.clearListener(); 


// 判断是否已经注册了指定侦听器  
SaTokenEventCenter.hasListener(listener); 


// 判断是否已经注册了指定类型的侦听器   
SaTokenEventCenter.hasListener(cls);
2.3、启动测试:

在 TestController 中添加登录测试代码:

// 测试登录接口 
@RequestMapping("login")
public SaResult login() {
    System.out.println("登录前");
    StpUtil.login(10001);        
    System.out.println("登录后");
    return SaResult.ok();
}

3、其它注意点

3.1、你可以通过继承SaTokenListenerForSimple快速实现一个侦听器:
@Component
public class MySaTokenListener extends SaTokenListenerForSimple {
    /*
     * SaTokenListenerForSimple 对所有事件提供了空实现,通过继承此类,你只需重写一部分方法即可实现一个可用的侦听器。
     */
    /** 每次登录时触发 */
    @Override
    public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
        System.out.println("---------- 自定义侦听器实现 doLogin");
    }
}


3.2、使用匿名内部类的方式注册:
// 登录时触发 
SaTokenEventCenter.registerListener(new SaTokenListenerForSimple() {
    @Override
    public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
        System.out.println("---------------- doLogin");
    }
});
3.3、使用 try-catch 包裹不安全的代码:

如果你认为你的事件处理代码是不安全的(代码可能在运行时抛出异常),则需要使用 try-catch 包裹代码,以防因为抛出异常导致 Sa-Token 的整个登录流程被强制中断。

// 登录时触发 
SaTokenEventCenter.registerListener(new SaTokenListenerForSimple() {
    @Override
    public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
        try {
            // 不安全代码需要写在 try-catch 里 
            // ......  
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
});
3.4、疑问:一个项目可以注册多个侦听器吗?

可以,多个侦听器间彼此独立,互不影响,按照注册顺序依次接受到事件通知。

单点登录

技术选型:[ 单点登录 ] VS [ OAuth2.0 ]

功能点

SSO单点登录

OAuth2.0

统一认证

支持度高

支持度高

统一注销

支持度高

支持度低

多个系统会话一致性

强一致

弱一致

第三方应用授权管理

不支持

支持度高

自有系统授权管理

支持度高

支持度低

Client级的权限校验

不支持

支持度高

集成简易度

比较简单

难度中等

注:以上仅为在 Sa-Token 中两种技术的差异度比较,不同框架的实现可能略有差异,但整体思想是一致的。

SSO单点登录

什么是单点登录?解决什么问题?

举个场景,假设我们的系统被切割为N个部分:商城、论坛、直播、社交…… 如果用户每访问一个模块都要登录一次,那么用户将会疯掉, 为了优化用户体验,我们急需一套机制将这N个系统的认证授权互通共享,让用户在一个系统登录之后,便可以畅通无阻的访问其它所有系统。

单点登录——就是为了解决这个问题而生!

简而言之,单点登录可以做到:在多个互相信任的系统中,用户只需登录一次,就可以访问所有系统。

架构选型

Sa-Token-SSO 由简入难划分为三种模式,解决不同架构下的 SSO 接入问题:

系统架构

采用模式

简介

文档链接

前端同域 + 后端同 Redis

模式一

共享 Cookie 同步会话

文档

、示例

前端不同域 + 后端同 Redis

模式二

URL重定向传播会话

文档

、示例

前端不同域 + 后端不同 Redis

模式三

Http请求获取会话

文档

、示例

  1. 前端同域:就是指多个系统可以部署在同一个主域名之下,比如:c1.domain.com、c2.domain.com、c3.domain.com。
  2. 后端同Redis:就是指多个系统可以连接同一个Redis。PS:这里并不需要把所有项目的数据都放在同一个Redis中,Sa-Token提供了 [权限缓存与业务缓存分离] 的解决方案,详情戳:Alone独立Redis插件。
  3. 如果既无法做到前端同域,也无法做到后端同Redis,那么只能走模式三,Http请求获取会话(Sa-Token对SSO提供了完整的封装,你只需要按照示例从文档上复制几段代码便可以轻松集成)。

Sa-Token-SSO 特性

  1. API 简单易用,文档介绍详细,且提供直接可用的集成示例。
  2. 支持三种模式,不论是否跨域、是否共享Redis、是否前后端分离,都可以完美解决。
  3. 安全性高:内置域名校验、Ticket校验、秘钥校验等,杜绝Ticket劫持、Token窃取等常见攻击手段(文档讲述攻击原理和防御手段)。
  4. 不丢参数:笔者曾试验多个单点登录框架,均有参数丢失的情况,比如重定向之前是:http://a.com?id=1&name=2,登录成功之后就变成了:http://a.com?id=1,Sa-Token-SSO内有专门的算法保证了参数不丢失,登录成功之后原路返回页面。
  5. 无缝集成:由于Sa-Token本身就是一个权限认证框架,因此你可以只用一个框架同时解决权限认证 + 单点登录问题,让你不再到处搜索:xxx单点登录与xxx权限认证如何整合……
  6. 高可定制:Sa-Token-SSO模块对代码架构侵入性极低,结合Sa-Token本身的路由拦截特性,你可以非常轻松的定制化开发。

搭建统一认证中心 SSO-Server

添加maven依赖

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


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


<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-dao-redis-jackson</artifactId>
    <version>1.34.0</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>

除了 sa-token-spring-boot-starter 和 sa-token-sso 以外,其它包都是可选的:

  • 在 SSO 模式三时 Redis 相关包是可选的
  • 在前后端分离模式下可以删除 thymeleaf 相关包
  • 在不需要 SSO 模式三单点注销的情况下可以删除 http 工具包

建议先完整测试三种模式之后再对pom依赖进行酌情删减。

开放认证接口

/**
 * 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;
            }
        });
    }


}

application.yml配置

# 端口
server:
    port: 9000


# Sa-Token 配置
sa-token: 
    # ------- SSO-模式一相关配置  (非模式一不需要配置) 
    # cookie: 
        # 配置 Cookie 作用域 
        # domain: stp.com 


    # ------- SSO-模式二相关配置 
    sso: 
        # Ticket有效期 (单位: 秒),默认五分钟 
        ticket-timeout: 300
        # 所有允许的授权回调地址
        allow-url: "*"
        # 是否打开单点注销功能
        is-slo: true


        # ------- SSO-模式三相关配置 (下面的配置在SSO模式三并且 is-slo=true 时打开)
        # 是否打开模式三 
        isHttp: true
        # 接口调用秘钥(用于SSO模式三的单点注销功能)
        secretkey: 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

访问统一授权地址:

图片

可以看到这个页面目前非常简陋,这是因为我们以上的代码示例,主要目标是为了带大家从零搭建一个可用的SSO认证服务端,所以就对一些不太必要的步骤做了简化。

责任编辑:武晓燕 来源: HELLO程序员
相关推荐

2022-02-18 08:34:33

JavaSa-Token项目

2021-01-13 11:13:46

ExcelPandas代码

2012-08-01 09:50:09

HotmailOutlook微软

2015-03-16 11:09:28

MongoDBPostgreSQL数据迁移

2021-04-23 09:09:19

GraphQLREST查询

2021-04-13 14:47:53

认证授权Java

2019-02-01 10:35:33

PythonGo语言编程语言

2021-07-27 05:56:53

CrocFTPSFTP

2009-03-30 08:44:22

微软Windows 7操作系统

2020-09-27 11:15:37

可视化PandasPython

2018-01-02 08:40:19

云安全云迁移数据泄露

2011-01-07 18:05:37

QQ腾讯移动互联网

2021-06-02 22:25:26

2G5G运营商

2014-11-27 14:26:46

苹果iPhone停产

2011-09-23 14:24:58

惠普云计算李艾科

2023-05-26 01:05:10

2020-10-29 10:44:59

斗鱼腾讯虎牙

2021-01-21 07:16:03

RocketMQKafka中间件

2021-05-04 22:31:15

零信任网络安全网络攻击

2020-02-17 15:17:57

钉钉
点赞
收藏

51CTO技术栈公众号