史上最牛的 权限系统,如何设计? 来了一个 Sa-Token学习圣经

文章很长,且持续更新,建议收藏起来,慢慢读!疯狂创客圈总目录 博客园版 为您奉上珍贵的学习资源 :

免费赠送 :《尼恩Java面试宝典》 持续更新+ 史上最全 + 面试必备 2000页+ 面试必备 + 大厂必备 +涨薪必备
免费赠送 :《尼恩技术圣经+高并发系列PDF》 ,帮你 实现技术自由,完成职业升级, 薪酬猛涨!加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷1)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷2)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷3)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领

免费赠送 资源宝库: Java 必备 百度网盘资源大合集 价值>10000元 加尼恩领取


史上最牛的 权限系统,如何设计? 来了一个 Sa-Token学习圣经

尼恩特别说明: 尼恩的文章,都会在 《技术自由圈》 公号 发布, 并且维护最新版本。 如果发现图片 不可见, 请去 《技术自由圈》 公号 查找

尼恩说在前面

在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,并且拿了很多大厂offer。

其中 SpringCloud 工业级底座 ,是大家的面试核心,面试重点:

说说:用户权限认证,如何设计?

说说:用户SSO 单点登录,如何设计?

最近有小伙伴在面试高级开发岗位,问到了相关的面试题。

小伙伴没有系统的去梳理和总结,所以支支吾吾的说了几句,面试官不满意,面试挂了。

所以,尼恩给大家做一下系统化、体系化的梳理,联合社群小伙伴,来一个 Sa-Token学习圣经: 从入门到精通 Sa-Token学习圣经 。

特别说明的是, 本文属于 尼恩团队 从0到1 大实战:穿透 SpringCloud 工业级 底座工程(一共包括 15大圣经的 ) 其中之一。

15大圣经 ,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。

尼恩团队 从0到1 大实战 SpringCloud 工业级底座 的 知识体系的轮廓如下,详情请点击:15大圣经的介绍

在这里插入图片描述

工业级脚手架实现的包括的 15大学习圣经,目录如下:

在这里插入图片描述

详情请点击:15大圣经的介绍

其中,专题1 权限设计以及 安全认证相关的两个圣经,具体如下:

  • SpringSecurity& Auth2.0 学习圣经: 从入门到精通 SpringSecurity& Auth2.0
  • 史上最牛的 权限系统,如何设计? 来了一个 Sa-Token学习圣经

本文,就是 SpringSecurity& Auth2.0 学习圣经的 v1.0版本。 这个版本,稍后会录制视频, 录完之后,正式版本会有更新, 最新版本找尼恩获取。

1 基本概念

安全认证两个基本概念

  • 认证(Authentication)
  • 授权(Authorization)

1.1 认证

认证就是根据用户名密码登录的过程,就是所谓的登录认证
对于一些登录之后才能访问的接口(例如:查询我的账号资料),我们通常的做法是增加一层接口校验:

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

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

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

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

1.2 授权(鉴权)

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

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

深入到底层数据中,就是每个账号都会拥有一组权限码集合,框架来校验这个集合中是否包含指定的权限码。
例如:当前账号拥有权限码集合 ["user-add", "user-delete", "user-get"],这时候我来校验权限 "user-update",则其结果就是:验证失败,禁止访问

image.png

2 Sa-Token简介

2.1 介绍

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 设计是多么的简单、优雅!

2.2 功能一览

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

  • 登录认证 —— 单端登录、多端登录、同端互斥登录、七天内免登录。
  • 权限认证 —— 权限认证、角色认证、会话二级认证。
  • 踢人下线 —— 根据账号id踢人下线、根据Token值踢人下线。
  • 注解式鉴权 —— 优雅的将鉴权与业务代码分离。
  • 路由拦截式鉴权 —— 根据路由拦截鉴权,可适配 restful 模式。
  • Session会话 —— 全端共享Session,单端独享Session,自定义Session,方便的存取值。
  • 持久层扩展 —— 可集成 Redis,重启数据不丢失。
  • 前后台分离 —— APP、小程序等不支持 Cookie 的终端也可以轻松鉴权。
  • Token风格定制 —— 内置六种 Token 风格,还可:自定义 Token 生成策略。
  • 记住我模式 —— 适配 [记住我] 模式,重启浏览器免验证。
  • 二级认证 —— 在已登录的基础上再次认证,保证安全性。
  • 模拟他人账号 —— 实时操作任意用户状态数据。
  • 临时身份切换 —— 将会话身份临时切换为其它账号。
  • 同端互斥登录 —— 像QQ一样手机电脑同时在线,但是两个手机上互斥登录。
  • 账号封禁 —— 登录封禁、按照业务分类封禁、按照处罚阶梯封禁。
  • 密码加密 —— 提供基础加密算法,可快速 MD5、SHA1、SHA256、AES 加密。
  • 会话查询 —— 提供方便灵活的会话查询接口。
  • Http Basic认证 —— 一行代码接入 Http Basic、Digest 认证。
  • 全局侦听器 —— 在用户登陆、注销、被踢下线等关键性操作时进行一些AOP操作。
  • 全局过滤器 —— 方便的处理跨域,全局设置安全响应头等操作。
  • 多账号体系认证 —— 一个系统多套账号分开鉴权(比如商城的 User 表和 Admin 表)
  • 单点登录 —— 内置三种单点登录模式:同域、跨域、同Redis、跨Redis、前后端分离等架构都可以搞定。
  • 单点注销 —— 任意子系统内发起注销,即可全端下线。
  • OAuth2.0认证 —— 轻松搭建 OAuth2.0 服务,支持openid模式 。
  • 分布式会话 —— 提供共享数据中心分布式会话方案。
  • 微服务网关鉴权 —— 适配Gateway、ShenYu、Zuul等常见网关的路由拦截认证。
  • RPC调用鉴权 —— 网关转发鉴权,RPC调用鉴权,让服务调用不再裸奔
  • 临时Token认证 —— 解决短时间的 Token 授权问题。
  • 独立Redis —— 将权限缓存与业务缓存分离。
  • Quick快速登录认证 —— 为项目零代码注入一个登录页面。
  • 标签方言 —— 提供 Thymeleaf 标签方言集成包,提供 beetl 集成示例。
  • jwt集成 —— 提供三种模式的 jwt 集成方案,提供 token 扩展参数能力。
  • RPC调用状态传递 —— 提供 dubbo、grpc 等集成包,在RPC调用时登录状态不丢失。
  • 参数签名 —— 提供跨系统API调用签名校验模块,防参数篡改,防请求重放。
  • 自动续签 —— 提供两种Token过期策略,灵活搭配使用,还可自动续签。
  • 开箱即用 —— 提供SpringMVC、WebFlux、Solon 等常见框架集成包,开箱即用。
  • 最新技术栈 —— 适配最新技术栈:支持 SpringBoot 3.x,jdk 17。

功能结构图:
在这里插入图片描述

3 Sa-Token认证

在这里插入图片描述

spring boot整合sa-token

  1. 添加依赖
		<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc/ -->
		<dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot-starter</artifactId>
            <version>${sa-token.version}</version>
        </dependency>
  1. 配置文件
# 端口
server:
    port: 8081

# sa-token 配置
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

3.1 登录认证

在这里插入图片描述

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

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

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

你暂时不需要完整了解整个登录过程,你只需要记住关键一点:Sa-Token 为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端
所以一般情况下,我们的登录接口代码,会大致类似如下:

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

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

Cookie最基本的两点:

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

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

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

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

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

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

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

  1. 登录账号查询
// 获取当前会话账号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);
  1. token 查询
// 获取当前会话的 token 值
StpUtil.getTokenValue();

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

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

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

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

SaTokenInfo参数详解:

{
    "code": 200,
    "msg": "ok",
    "data": {
        "tokenName": "satoken",           // token名称
        "tokenValue": "e67b99f1-3d7a-4a8d-bb2f-e888a0805633",      // token值
        "isLogin": true,                  // 此token是否已经登录
        "loginId": "10001",               // 此token对应的LoginId,未登录时为null
        "loginType": "login",              // 账号类型标识
        "tokenTimeout": 2591977,          // token剩余有效期 (单位: 秒)
        "sessionTimeout": 2591977,        // Account-Session剩余有效时间 (单位: 秒)
        "tokenSessionTimeout": -2,        // Token-Session剩余有效时间 (单位: 秒) (-2表示系统中不存在这个缓存)
        "tokenActiveTimeout": -1,         // token 距离被冻结还剩的时间 (单位: 秒)
        "loginDevice": "default-device"   // 登录设备类型 
    },
}
  1. 测试案例

来个小测试加深下理解
新建 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("zhang".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();
    }
    
}

案例代码:com.pj.controller.LoginAuthController

3.2 踢人下线

所谓踢人下线,核心操作就是找到指定 loginId 对应的 Token,并设置其失效。
在这里插入图片描述

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

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

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

image.png

3.3 全局异常处理

如何根据NotLoginException异常的场景值,来定制化处理未登录的逻辑
应用场景举例:未登录、被顶下线、被踢下线等场景需要不同方式来处理
在会话未登录的情况下尝试获取loginId会使框架抛出NotLoginException异常,而同为未登录异常却有五种抛出场景的区分

场景值 对应常量 含义说明
-1 NotLoginException.NOT_TOKEN 未能从请求中读取到有效 token
-2 NotLoginException.INVALID_TOKEN 已读取到 token,但是 token 无效
-3 NotLoginException.TOKEN_TIMEOUT 已读取到 token,但是 token 已经过期 (
)
-4 NotLoginException.BE_REPLACED 已读取到 token,但是 token 已被顶下线
-5 NotLoginException.KICK_OUT 已读取到 token,但是 token 已被踢下线
-6 NotLoginException.TOKEN_FREEZE 已读取到 token,但是 token 已被冻结
-7 NotLoginException.NO_PREFIX 未按照指定前缀提交 token

可以使用Spring MVC全局异常处理机制对于未登录场景值处理,那么,如何获取场景值呢?废话少说直接上代码:

// 全局异常拦截(拦截项目中的NotLoginException异常)
@ExceptionHandler(NotLoginException.class)
public SaResult handlerNotLoginException(NotLoginException nle)
        throws Exception {

    // 打印堆栈,以供调试
    nle.printStackTrace(); 
    
    // 判断场景值,定制化异常信息 
    String message = "";
    if(nle.getType().equals(NotLoginException.NOT_TOKEN)) {
        message = "未能读取到有效 token";
    }
    else if(nle.getType().equals(NotLoginException.INVALID_TOKEN)) {
        message = "token 无效";
    }
    else if(nle.getType().equals(NotLoginException.TOKEN_TIMEOUT)) {
        message = "token 已过期";
    }
    else if(nle.getType().equals(NotLoginException.BE_REPLACED)) {
        message = "token 已被顶下线";
    }
    else if(nle.getType().equals(NotLoginException.KICK_OUT)) {
        message = "token 已被踢下线";
    }
    else if(nle.getType().equals(NotLoginException.TOKEN_FREEZE)) {
        message = "token 已被冻结";
    }
    else if(nle.getType().equals(NotLoginException.NO_PREFIX)) {
        message = "未按照指定前缀提交 token";
    }
    else {
        message = "当前会话未登录";
    }
    
    // 返回给前端
    return SaResult.error(message);
}

注意:以上代码并非处理逻辑的最佳方式,只为以最简单的代码演示出场景值的获取与应用,大家可以根据自己的项目需求来定制化处理

3.4 二级认证

在某些敏感操作下,我们需要对已登录的会话进行二次验证。
比如代码托管平台的仓库删除操作,尽管我们已经登录了账号,当我们点击 [删除] 按钮时,还是需要再次输入一遍密码,这么做主要为了两点:

  1. 保证操作者是当前账号本人。
  2. 增加操作步骤,防止误删除重要数据。

这就是我们本篇要讲的 —— 二级认证,即:在已登录会话的基础上,进行再次验证,提高会话的安全性。


  1. 具体API

Sa-Token中进行二级认证非常简单,只需要使用以下API:

// 在当前会话 开启二级认证,时间为120秒
StpUtil.openSafe(120); 

// 获取:当前会话是否处于二级认证时间内
StpUtil.isSafe(); 

// 检查当前会话是否已通过二级认证,如未通过则抛出异常
StpUtil.checkSafe(); 

// 获取当前会话的二级认证剩余有效时间 (单位: 秒, 返回-2代表尚未通过二级认证)
StpUtil.getSafeTime(); 

// 在当前会话 结束二级认证
StpUtil.closeSafe(); 
  1. 一个小示例

一个完整的二级认证业务流程,应该大致如下:

// 删除仓库
@RequestMapping("deleteProject")
public SaResult deleteProject(String projectId) {
    // 第1步,先检查当前会话是否已完成二级认证 
    if(!StpUtil.isSafe()) {
        return SaResult.error("仓库删除失败,请完成二级认证后再次访问接口");
    }

    // 第2步,如果已完成二级认证,则开始执行业务逻辑
    // ... 

    // 第3步,返回结果 
    return SaResult.ok("仓库删除成功"); 
}

// 提供密码进行二级认证 
@RequestMapping("openSafe")
public SaResult openSafe(String password) {
    // 比对密码(此处只是举例,真实项目时可拿其它参数进行校验)
    if("123456".equals(password)) {
        
        // 比对成功,为当前会话打开二级认证,有效期为120秒 
        StpUtil.openSafe(120);
        return SaResult.ok("二级认证成功");
    }
    
    // 如果密码校验失败,则二级认证也会失败
    return SaResult.error("二级认证失败"); 
}

调用步骤:

  • 前端调用 deleteProject 接口,尝试删除仓库。
  • 后端校验会话尚未完成二级认证,返回: 仓库删除失败,请完成二级认证后再次访问接口
  • 前端将信息提示给用户,用户输入密码,调用 openSafe 接口。
  • 后端比对用户输入的密码,完成二级认证,有效期为:120秒。
  • 前端在 120 秒内再次调用 deleteProject 接口,尝试删除仓库。
  • 后端校验会话已完成二级认证,返回:仓库删除成功
  1. 指定业务标识进行二级认证

如果项目有多条业务线都需要敏感操作验证,则 StpUtil.openSafe() 无法提供细粒度的认证操作, 此时我们可以指定一个业务标识来分辨不同的业务线:

// 在当前会话 开启二级认证,业务标识为client,时间为600秒
StpUtil.openSafe("client", 600); 

// 获取:当前会话是否已完成指定业务的二级认证 
StpUtil.isSafe("client"); 

// 校验:当前会话是否已完成指定业务的二级认证 ,如未认证则抛出异常
StpUtil.checkSafe("client"); 

// 获取当前会话指定业务二级认证剩余有效时间 (单位: 秒, 返回-2代表尚未通过二级认证)
StpUtil.getSafeTime("client"); 

// 在当前会话 结束指定业务标识的二级认证
StpUtil.closeSafe("client"); 

业务标识可以填写任意字符串,不同业务标识之间的认证互不影响,比如:

// 打开了业务标识为 client 的二级认证 
StpUtil.openSafe("client"); 

// 判断是否处于 shop 的二级认证,会返回 false 
StpUtil.isSafe("shop");  // 返回 false 

// 也不会通过校验,会抛出异常 
StpUtil.checkSafe("shop");
  1. 使用注解进行二级认证

在一个方法上使用 @SaCheckSafe 注解,可以在代码进入此方法之前进行一次二级认证校验

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

// 指定业务类型,进行二级认证校验
@SaCheckSafe("art")
@RequestMapping("add2")
public String add2() {
    return "文章增加";
}

实例代码:com.pj.controller.SafeAuthController

3.5 同端互斥登录

如果你经常使用腾讯QQ,就会发现它的登录有如下特点:它可以手机电脑同时在线,但是不能在两个手机上同时登录一个账号。
同端互斥登录,指的就是:像腾讯QQ一样,在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线。
image.png

  1. 具体API

在 Sa-Token 中如何做到同端互斥登录?
首先在配置文件中,将 isConcurrent 配置为false,然后调用登录等相关接口时声明设备类型即可:

  1. 指定设备类型登录
// 指定`账号id`和`设备类型`进行登录
StpUtil.login(10001, "PC"); 

调用此方法登录后,同设备的会被顶下线(不同设备不受影响),再次访问系统时会抛出 NotLoginException 异常,场景值=-4

  1. 指定设备类型强制注销
// 指定`账号id`和`设备类型`进行强制注销 
StpUtil.logout(10001, "PC");    

如果第二个参数填写null或不填,代表将这个账号id所有在线端强制注销,被踢出者再次访问系统时会抛出 NotLoginException 异常,场景值=-2

  1. 查询当前登录的设备类型
// 返回当前token的登录设备类型
StpUtil.getLoginDevice();    
  1. Id 反查 Token
// 获取指定loginId指定设备类型端的tokenValue 
StpUtil.getTokenValueByLoginId(10001, "APP");    

案例代码:com.pj.controller.MutexLoginController

3.6 Http Basic/Digest 认证

3.11.1 HttpBasic认证

Http Basic 是 http 协议中最基础的认证方式,其有两个特点:

  • 简单、易集成。
  • 功能支持度低。

在 Sa-Token 中使用 Http Basic 认证非常简单,只需调用几个简单的方法


  1. 启用 Http Basic 认证

首先我们在一个接口中,调用 Http Basic 校验:

@RequestMapping("test3")
public SaResult test3() {
    SaHttpBasicUtil.check("sa:123456");
    // ... 其它代码
    return SaResult.ok();
}

然后我们访问这个接口时,浏览器会强制弹出一个表单:

当我们输入账号密码后 (sa / 123456),才可以继续访问数据:

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

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

// 以注解方式启用 Http Basic 校验
@SaCheckHttpBasic(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/**", () -> SaHttpBasicUtil.check("sa:123456"));
    });
}
  1. URL 认证

除了访问后再输入账号密码外,我们还可以在 URL 中直接拼接账号密码通过 Basic 认证,例如:

http://sa:123456@127.0.0.1:8081/test/test3

3.11.2 Http Digest 认证

Http Digest 认证是 Http Basic 认证的升级版,Http Digest 在提交请求时不会使用明文方式传输认证信息,而是使用一定的规则加密后提交。 不过对于开发者来讲,开启 Http Digest 认证校验的流程与 Http Basic 认证基本是一致的。

// 测试 Http Digest 认证   浏览器访问: http://localhost:8081/test/testDigest
@RequestMapping("testDigest")
public SaResult testDigest() {
    SaHttpDigestUtil.check("sa", "123456");
    return SaResult.ok();
}

// 使用注解方式开启 Http Digest 认证
@SaCheckHttpDigest("sa:123456")
@RequestMapping("testDigest2")
public SaResult testDigest() {
    return SaResult.ok();
}


// 对当前会话进行 Http Digest 校验,账号密码为 yml 配置的值(例如:sa-token.http-digest=sa:123456)
SaHttpDigestUtil.check();

与上面的 Http Basic 认证一致,在访问这个路由时,浏览器会强制弹出一个表单,客户端输入正确的账号密码后即可通过校验。
同样的,Http Digest 也支持在浏览器访问接口时直接使用 @ 符号拼接账号密码信息,使客户端直接通过校验。

http://sa:123456@127.0.0.1:8081/test/testDigest

4 Sa-Token授权(鉴权)

4.1 权限认证

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

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

深入到底层数据中,就是每个账号都会拥有一组权限码集合,框架来校验这个集合中是否包含指定的权限码。
例如:当前账号拥有权限码集合 ["user-add", "user-delete", "user-get"],这时候我来校验权限 "user-update",则其结果就是:验证失败,禁止访问

image.png

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

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

4.1.1 获取当前账号权限码集合

因为每个项目的需求不同,其权限设计也千变万化,因此 [ 获取当前账号权限码集合 ] 这一操作不可能内置到框架中, 所以 Sa-Token 将此操作以接口的方式暴露给你,以方便你根据自己的业务逻辑进行重写。
StpInterface类似Spring Security的UserDetailService
你需要做的就是新建一个类,实现 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:账号体系标识,此处可以暂时忽略,在 [ 多账户认证 ] 章节下会对这个概念做详细的解释。

可参考代码:com.pj.satoken.StpInterfaceImpl
有同学会产生疑问:我实现了此接口,但是程序启动时好像并没有执行,是不是我写错了?
答:不执行是正常现象,程序启动时不会执行这个接口的方法,在每次调用鉴权代码时,才会执行到此。

4.1.2 权限校验

然后就可以用以下 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 抛出的异常

4.1.3 角色校验

在 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 抛出的异常

4.1.4 拦截全局异常

有同学要问,鉴权失败,抛出异常,然后呢?要把异常显示给用户看吗?当然不可以!
你可以创建一个全局异常拦截器,统一返回给前端的格式,参考:

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

可参考:com.pj.current.GlobalException

4.1.5 权限通配符

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

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

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

权限精确到按钮级的意思就是指:权限范围可以控制到页面上的每一个按钮是否显示
思路:如此精确的范围控制只依赖后端已经难以完成,此时需要前端进行一定的逻辑判断。
如果是前后端一体项目,可以参考:Thymeleaf 标签方言,如果是前后端分离项目,则:

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

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

前端有了鉴权后端还需要鉴权吗?
需要!
前端的鉴权只是一个辅助功能,对于专业人员这些限制都是可以轻松绕过的,为保证服务器安全:无论前端是否进行了权限校验,后端接口都需要对会话请求再次进行权限校验!


代码示例:com.pj.controller.JurAuthController

4.2 注解鉴权

有同学表示:尽管使用代码鉴权非常方便,但是我仍希望把鉴权逻辑和业务逻辑分离开来,我可以使用注解鉴权吗?当然可以!

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

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

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

4.2.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启动类扫描到即可

4.2.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 校验:只有通过 Http Basic 认证后才能进入该方法 
@SaCheckHttpBasic(account = "sa:123456")
@RequestMapping("add")
public String add() {
    return "用户增加";
}

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

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

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

4.2.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.2.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"},代表必须同时具有三个角色。

4.2.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 拦截器中的路由鉴权,在下面的 [路由拦截鉴权] 章节中我们会讲到。

4.2.6 批量注解鉴权

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

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

每一项属性都可以写成数组形式,例如:

// 当前客户端只要有 [ login 账号登录] 或者 [user 账号登录] 其一,就可以通过验证进入方法。
//         注意:`type = "login"` 和 `type = "user"` 是多账号模式章节的扩展属性,此处你可以先略过这个知识点。
@SaCheckOr(
    login = { @SaCheckLogin(type = "login"), @SaCheckLogin(type = "user") }
)
@RequestMapping("test")
public SaResult test() {
    // ... 
    return SaResult.ok(); 
}

疑问:既然有了 @SaCheckOr,为什么没有与之对应的 @SaCheckAnd 呢?
因为当你写多个注解时,其天然就是 and 校验关系,例如:

// 当你在一个方法上写多个注解鉴权时,其默认就是要满足所有注解规则后,才可以进入方法,只要有一个不满足,就会抛出异常
@SaCheckLogin
@SaCheckRole("admin")
@SaCheckPermission("user.add")
@RequestMapping("test")
public SaResult test() {
    // ... 
    return SaResult.ok(); 
}

代码示例:com.pj.controller.AtCheckController

4.3 路由拦截鉴权

假设我们有如下需求:
需求场景
项目中所有接口均需要登录认证,只有 “登录接口” 本身对外开放
我们怎么实现呢?给每个接口加上鉴权注解?手写全局拦截器?似乎都不是非常方便。
在这个需求中我们真正需要的是一种基于路由拦截的鉴权模式,那么在Sa-Token怎么实现路由拦截鉴权呢?

4.3.1 注册 Sa-Token 路由拦截器

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

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
    // 注册拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。
        registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
        .addPathPatterns("/**")
        .excludePathPatterns("/user/doLogin"); 
    }
}

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

4.3.2 校验函数详解

自定义认证规则:new SaInterceptor(handle -> StpUtil.checkLogin()) 是最简单的写法,代表只进行登录校验功能。
我们可以往构造函数塞一个完整的 lambda 表达式,来定义详细的校验规则,例如:

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

SaRouter.match() 匹配函数有两个参数:

  • 参数一:要匹配的path路由。
  • 参数二:要执行的校验函数。

在校验函数内不只可以使用 StpUtil.checkPermission("xxx") 进行权限校验,你还可以写任意代码,例如:

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
    // 注册 Sa-Token 的拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册路由拦截器,自定义认证规则 
        registry.addInterceptor(new SaInterceptor(handler -> {
            
            // 登录校验 -- 拦截所有路由,并排除/user/doLogin 用于开放登录 
            SaRouter.match("/**", "/user/doLogin", r -> StpUtil.checkLogin());

            // 角色校验 -- 拦截以 admin 开头的路由,必须具备 admin 角色或者 super-admin 角色才可以通过认证 
            SaRouter.match("/admin/**", r -> StpUtil.checkRoleOr("admin", "super-admin"));

            // 权限校验 -- 不同模块校验不同权限 
            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"));
            
            // 甚至你可以随意的写一个打印语句
            SaRouter.match("/**", r -> System.out.println("----啦啦啦----"));

            // 连缀写法
            SaRouter.match("/**").check(r -> System.out.println("----啦啦啦----"));
            
        })).addPathPatterns("/**");
    }
}

4.3.3 匹配特征详解

除了上述示例的 path 路由匹配,还可以根据很多其它特征进行匹配,以下是所有可匹配的特征:

// 基础写法样例:匹配一个path,执行一个校验函数 
SaRouter.match("/user/**").check(r -> StpUtil.checkLogin());

// 根据 path 路由匹配   ——— 支持写多个path,支持写 restful 风格路由 
// 功能说明: 使用 /user , /goods 或者 /art/get 开头的任意路由都将进入 check 方法
SaRouter.match("/user/**", "/goods/**", "/art/get/{id}").check( /* 要执行的校验函数 */ );

// 根据 path 路由排除匹配 
// 功能说明: 使用 .html , .css 或者 .js 结尾的任意路由都将跳过, 不会进入 check 方法
SaRouter.match("/**").notMatch("*.html", "*.css", "*.js").check( /* 要执行的校验函数 */ );

// 根据请求类型匹配 
SaRouter.match(SaHttpMethod.GET).check( /* 要执行的校验函数 */ );

// 根据一个 boolean 条件进行匹配 
SaRouter.match( StpUtil.isLogin() ).check( /* 要执行的校验函数 */ );

// 根据一个返回 boolean 结果的lambda表达式匹配 
SaRouter.match( r -> StpUtil.isLogin() ).check( /* 要执行的校验函数 */ );

// 多个条件一起使用 
// 功能说明: 必须是 Get 请求 并且 请求路径以 `/user/` 开头 
SaRouter.match(SaHttpMethod.GET).match("/user/**").check( /* 要执行的校验函数 */ );

// 可以无限连缀下去 
// 功能说明: 同时满足 Get 方式请求, 且路由以 /admin 开头, 路由中间带有 /send/ 字符串, 路由结尾不能是 .js 和 .css
SaRouter
    .match(SaHttpMethod.GET)
    .match("/admin/**")
    .match("/**/send/**") 
    .notMatch("/**/*.js")
    .notMatch("/**/*.css")
    // ....
    .check( /* 只有上述所有条件都匹配成功,才会执行最后的check校验函数 */ );

4.3.4 提前退出匹配链

使用 SaRouter.stop() 可以提前退出匹配链,例:

registry.addInterceptor(new SaInterceptor(handler -> {
    SaRouter.match("/**").check(r -> System.out.println("进入1"));
    SaRouter.match("/**").check(r -> System.out.println("进入2")).stop();
    SaRouter.match("/**").check(r -> System.out.println("进入3"));
    SaRouter.match("/**").check(r -> System.out.println("进入4"));
    SaRouter.match("/**").check(r -> System.out.println("进入5"));
})).addPathPatterns("/**");

如上示例,代码运行至第2条匹配链时,会在stop函数处提前退出整个匹配函数,从而忽略掉剩余的所有match匹配
除了stop()函数,SaRouter还提供了 back() 函数,用于:停止匹配,结束执行,直接向前端返回结果

// 执行back函数后将停止匹配,也不会进入Controller,而是直接将 back参数 作为返回值输出到前端
SaRouter.match("/user/back").back("要返回到前端的内容");复制到剪贴板错误复制成功

1
2

stop() 与 back() 函数的区别在于:

  • SaRouter.stop() 会停止匹配,进入Controller。
  • SaRouter.back() 会停止匹配,直接返回结果到前端。

4.3.5 使用free打开一个独立的作用域

// 进入 free 独立作用域 
SaRouter.match("/**").free(r -> {
    SaRouter.match("/a/**").check(/* --- */);
    SaRouter.match("/b/**").check(/* --- */).stop();
    SaRouter.match("/c/**").check(/* --- */);
});
// 执行 stop() 函数跳出 free 后继续执行下面的 match 匹配 
SaRouter.match("/**").check(/* --- */);

free() 的作用是:打开一个独立的作用域,使内部的 stop() 不再一次性跳出整个 Auth 函数,而是仅仅跳出当前 free 作用域。

4.3.6 使用注解忽略掉路由拦截校验

我们可以使用 @SaIgnore 注解,忽略掉路由拦截认证:
1、先配置好了拦截规则:

@Override
public void addInterceptors(InterceptorRegistry registry) {
    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"));
        // ... 
    })).addPathPatterns("/**");
}

2、然后在 Controller 里又添加了忽略校验的注解

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

请求将会跳过拦截器的校验,直接进入 Controller 的方法中。

注解 **@SaIgnore** 的忽略效果只针对 SaInterceptor拦截器 和 AOP注解鉴权 生效,对自定义拦截器与过滤器不生效。

4.3.7 关闭注解校验

SaInterceptor 只要注册到项目中,默认就会打开注解校验,如果要关闭此能力,需要:

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(
        new SaInterceptor(handle -> {
            SaRouter.match("/**").check(r -> StpUtil.checkLogin());
        }).isAnnotation(false)  // 指定关闭掉注解鉴权能力,这样框架就只会做路由拦截校验了 
    ).addPathPatterns("/**");
}

实例代码:com.pj.satoken.SaTokenConfigure

拦截器和过滤器鉴权:
首先我们先梳理清楚一个问题,既然拦截器已经可以实现路由鉴权,为什么还要用过滤器再实现一遍呢?简而言之:

  1. 相比于拦截器,过滤器更加底层,执行时机更靠前,有利于防渗透扫描。
  2. 过滤器可以拦截静态资源,方便我们做一些权限控制。
  3. 部分Web框架根本就没有提供拦截器功能,但几乎所有的Web框架都会提供过滤器机制。

但是过滤器也有一些缺点,比如:

  1. 由于太过底层,导致无法率先拿到HandlerMethod对象,无法据此添加一些额外功能。
  2. 由于拦截的太全面了,导致我们需要对很多特殊路由(如/favicon.ico)做一些额外处理。
  3. 在Spring中,过滤器中抛出的异常无法进入全局@ExceptionHandler,我们必须额外编写代码进行异常处理。

Sa-Token同时提供过滤器和拦截器机制,不是为了让谁替代谁,而是为了让大家根据自己的实际业务合理选择,拥有更多的发挥空间。

5 Sa-Token 进阶

5.1 Session会话

Session 是数据缓存组件,通过 Session 我们可以很方便的缓存一些高频读写数据,提高程序性能。

提起Session,你脑海中最先浮现的可能就是 JSP 中的 HttpSession,它的工作原理可以大致总结为:
客户端每次与服务器第一次握手时,会被强制分配一个 [唯一id] 作为身份标识,注入到 Cookie 之中, 之后每次发起请求时,客户端都要将它提交到后台,服务器根据 [唯一id] 找到每个请求专属的Session对象,维持会话
这种机制简单粗暴,却有N多明显的缺点:

  1. 同一账号分别在PC、APP登录,会被识别为两个不相干的会话
  2. 一个设备难以同时登录两个账号
  3. 每次一个新的客户端访问服务器时,都会产生一个新的Session对象,即使这个客户端只访问了一次页面
  4. 在不支持Cookie的客户端下,这种机制会失效

Sa-Token Session可以理解为 HttpSession 的升级版:

  1. Sa-Token只在调用StpUtil.login(id)登录会话时才会产生Session,不会为每个陌生会话都产生Session,节省性能
  2. 在登录时产生的Session,是分配给账号id的,而不是分配给指定客户端的,也就是说在PC、APP上登录的同一账号所得到的Session也是同一个,所以两端可以非常轻松的同步数据
  3. Sa-Token支持Cookie、Header、body三个途径提交Token,而不是仅限于Cookie
  4. 由于不强依赖Cookie,所以只要将Token存储到不同的地方,便可以做到一个客户端同时登录多个账号

5.1.1 Session模型结构图

三种Session创建时机:

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

假设三个客户端登录同一账号,且配置了不共享token,那么此时的Session模型是:

简而言之:

  • Account-Session 以账号 id 为主,只要 token 指向的账号 id 一致,那么对应的Session对象就一致
  • Token-Session 以token为主,只要token不同,那么对应的Session对象就不同
  • Custom-Session 以特定的key为主,不同key对应不同的Session对象,同样的key指向同一个Session对

5.1.2 Account-Session

这种为账号id分配的Session,我们给它起一个合适的名字:Account-Session,你可以通过如下方式操作它:

// 获取当前会话的 Account-Session 
SaSession session = StpUtil.getSession();

// 从 Account-Session 中读取、写入数据 
session.get("name");
session.set("name", "张三");

使用Account-Session在不同端同步数据是非常方便的,因为只要 PC 和 APP 登录的账号id一致,它们对应的都是同一个Session, 举个应用场景:在PC端点赞的帖子列表,在APP端的点赞记录里也要同步显示出来

5.1.3 Token-Session

随着业务推进,我们还可能会遇到一些需要数据隔离的场景:
业务场景
指定客户端超过两小时无操作就自动下线,如果两小时内有操作,就再续期两小时,直到新的两小时无操作
那么这种请求访问记录应该存储在哪里呢?放在 Account-Session 里吗?
可别忘了,PC端和APP端可是共享的同一个 Account-Session ,如果把数据放在这里, 那就意味着,即使用户在PC端一直无操作,只要手机上用户还在不间断的操作,那PC端也不会过期!
解决这个问题的关键在于,虽然两个设备登录的是同一账号,但是两个它们得到的token是不一样的, Sa-Token针对会话登录,不仅为账号id分配了Account-Session,同时还为每个token分配了不同的Token-Session
不同的设备端,哪怕登录了同一账号,只要它们得到的token不一致,它们对应的 Token-Session 就不一致,这就为我们不同端的独立数据读写提供了支持:

// 获取当前会话的 Token-Session 
SaSession session = StpUtil.getTokenSession();

// 从 Token-Session 中读取、写入数据 
session.get("name");
session.set("name", "张三");

5.1.4 Custom-Session

除了以上两种Session,Sa-Token还提供了第三种Session,那就是:Custom-Session,你可以将其理解为:自定义Session
Custom-Session不依赖特定的 账号id 或者 token,而是依赖于你提供的SessionId:

// 获取指定key的 Custom-Session 
SaSession session = SaSessionCustomUtil.getSessionById("goods-10001");

// 从 Custom-Session 中读取、写入数据 
session.get("name");
session.set("name", "张三");

只要两个自定义Session的Id一致,它们就是同一个Session
Custom-Session的会话有效期默认使用SaManager.getConfig().getTimeout(), 如果需要修改会话有效期, 可以在创建之后, 使用对象方法修改

session.updateTimeout(1000); // 参数说明和全局有效期保持一致复制到剪贴板错误复制成功

1

5.1.5 未登录场景下获取 Token-Session

默认场景下,只有登录后才能通过 StpUtil.getTokenSession() 获取 Token-Session
如果想要在未登录场景下获取 Token-Session ,有两种方法:

  • 方法一:将全局配置项 tokenSessionCheckLogin 改为 false,详见:框架配置
  • 方法二:使用匿名 Token-Session
// 获取当前 Token 的匿名 Token-Session (可在未登录情况下使用的 Token-Session)
StpUtil.getAnonTokenSession();

注意点:如果前端没有提交 Token ,或者提交的 Token 是一个无效 Token 的话,框架将不会根据此 Token 创建 Token-Session 对象, 而是随机一个新的 Token 值来创建 Token-Session 对象,此 Token 值可以通过 StpUtil.getTokenValue() 获取到。

5.2 身份切换

以上介绍的 API 都是操作当前账号,对当前账号进行各种鉴权操作,你可能会问,我能不能对别的账号进行一些操作?
比如:查看账号 10001 有无某个权限码、获取 账号 id=10002 的 Account-Session,等等...
Sa-Token 在 API 设计时充分考虑了这一点,暴露出多个api进行此类操作:

  1. 有关操作其它账号的api
// 获取指定账号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");
  1. 临时身份切换

有时候,我们需要直接将当前会话的身份切换为其它账号,比如:

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

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

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

你还可以:直接在一个代码段里方法内,临时切换身份为指定loginId(此方式无需手动调用StpUtil.endSwitch()关闭身份切换)

System.out.println("------- [身份临时切换]调用开始...");
StpUtil.switchTo(10044, () -> {
    System.out.println("是否正在身份临时切换中: " + StpUtil.isSwitch());  // 输出 true
    System.out.println("获取当前登录账号id: " + StpUtil.getLoginId());   // 输出 10044
});
System.out.println("------- [身份临时切换]调用结束...");

实例代码:com.pj.controller.SwitchToController

5.3 [记住我] 模式

如图所示,一般网站的登录界面都会有一个 [记住我] 按钮,当你勾选它登录后,即使你关闭浏览器再次打开网站,也依然会处于登录状态,无须重复验证密码:

那么在Sa-Token中,如何做到 [ 记住我 ] 功能呢?

  1. 在 Sa-Token 中实现记住我功能

Sa-Token的登录授权,默认就是[记住我]模式,为了实现[非记住我]模式,你需要在登录时如下设置:

// 设置登录账号id为10001,第二个参数指定是否为[记住我],当此值为false后,关闭浏览器后再次打开需要重新登录
StpUtil.login(10001, false);

那么,Sa-Token实现[记住我]的具体原理是?

  1. 实现原理

Cookie作为浏览器提供的默认会话跟踪机制,其生命周期有两种形式,分别是:

  • 临时Cookie:有效期为本次会话,只要关闭浏览器窗口,Cookie就会消失。
  • 持久Cookie:有效期为一个具体的时间,在时间未到期之前,即使用户关闭了浏览器Cookie也不会消失。

利用Cookie的此特性,我们便可以轻松实现 [记住我] 模式:

  • 勾选 [记住我] 按钮时:调用StpUtil.login(10001, true),在浏览器写入一个持久Cookie储存 Token,此时用户即使重启浏览器 Token 依然有效。
  • 不勾选 [记住我] 按钮时:调用StpUtil.login(10001, false),在浏览器写入一个临时Cookie储存 Token,此时用户在重启浏览器后 Token 便会消失,导致会话失效。

image.png

  1. 前后端分离模式下如何实现[记住我]?

此时机智的你😏很快发现一个问题,Cookie虽好,却无法在前后端分离环境下使用,那是不是代表上述方案在APP、小程序等环境中无效?
准确的讲,答案是肯定的,任何基于Cookie的认证方案在前后端分离环境下都会失效(原因在于这些客户端默认没有实现Cookie功能),不过好在,这些客户端一般都提供了替代方案, 唯一遗憾的是,此场景中token的生命周期需要我们在前端手动控制:
以经典跨端框架 uni-app 为例,我们可以使用如下方式达到同样的效果:

// 使用本地存储保存token,达到 [持久Cookie] 的效果
uni.setStorageSync("satoken", "xxxx-xxxx-xxxx-xxxx-xxx");

// 使用globalData保存token,达到 [临时Cookie] 的效果
getApp().globalData.satoken = "xxxx-xxxx-xxxx-xxxx-xxx";

如果你决定在PC浏览器环境下进行前后端分离模式开发,那么更加简单:

// 使用 localStorage 保存token,达到 [持久Cookie] 的效果
localStorage.setItem("satoken", "xxxx-xxxx-xxxx-xxxx-xxx");

// 使用 sessionStorage 保存token,达到 [临时Cookie] 的效果
sessionStorage.setItem("satoken", "xxxx-xxxx-xxxx-xxxx-xxx");

Remember me, it's too easy!

  1. 登录时指定 Token 有效期

登录时不仅可以指定是否为[记住我]模式,还可以指定一个特定的时间作为 Token 有效时长,如下示例:

// 示例1:
// 指定token有效期(单位: 秒),如下所示token七天有效
StpUtil.login(10001, new SaLoginModel().setTimeout(60 * 60 * 24 * 7));

// ----------------------- 示例2:所有参数
// `SaLoginModel`为登录参数Model,其有诸多参数决定登录时的各种逻辑,例如:
StpUtil.login(10001, new SaLoginModel()
            .setDevice("PC")                // 此次登录的客户端设备类型, 用于[同端互斥登录]时指定此次登录的设备类型
            .setIsLastingCookie(true)        // 是否为持久Cookie(临时Cookie在浏览器关闭时会自动删除,持久Cookie在重新打开后依然存在)
            .setTimeout(60 * 60 * 24 * 7)    // 指定此次登录token的有效期, 单位:秒 (如未指定,自动取全局配置的 timeout 值)
            .setToken("xxxx-xxxx-xxxx-xxxx") // 预定此次登录的生成的Token 
            .setIsWriteHeader(false)         // 是否在登录后将 Token 写入到响应头
            );

案例代码:com.pj.controller.RememberMeController

5.4 账号封禁

踢人下线 和 强制注销 功能,用于清退违规账号。
在部分场景下,我们还需要将其 账号封禁,以防止其再次登录。

  1. 账号封禁

对指定账号进行封禁:

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

参数含义:

  • 参数1:要封禁的账号id。
  • 参数2:封禁时间,单位:秒,此为 86400秒 = 1天(此值为 -1 时,代表永久封禁)。

注意点:对于正在登录的账号,将其封禁并不会使它立即掉线,如果我们需要它即刻下线,可采用先踢再封禁的策略,例如:

// 先踢下线
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); 
  1. 分类封禁

有的时候,我们并不需要将整个账号禁掉,而是只禁止其访问部分服务。
假设我们在开发一个电商系统,对于违规账号的处罚,我们设定三种分类封禁:

  • 1、封禁评价能力:账号A 因为多次虚假好评,被限制订单评价功能。
  • 2、封禁下单能力:账号B 因为多次薅羊毛,被限制下单功能。
  • 3、封禁开店能力:账号C 因为店铺销售假货,被限制开店功能。

相比于封禁账号的一刀切处罚,这里的关键点在于:每一项能力封禁的同时,都不会对其它能力造成影响。
也就是说我们需要一种只对部分服务进行限制的能力,对应到代码层面,就是只禁止部分接口的调用。

// 封禁指定用户评论能力,期限为 1天
StpUtil.disable(10001, "comment", 86400);

参数释义:

  • 参数1:要封禁的账号id。
  • 参数2:针对这个账号,要封禁的服务标识(可以是任意的自定义字符串)。
  • 参数3:要封禁的时间,单位:秒,此为 86400秒 = 1天(此值为 -1 时,代表永久封禁)。

分类封禁模块所有可用API:

/*
 * 以下示例中:"comment"=评论服务标识、"place-order"=下单服务标识、"open-shop"=开店服务标识
 */

// 封禁指定用户评论能力,期限为 1天
StpUtil.disable(10001, "comment", 86400);

// 在评论接口,校验一下,会抛出异常:`DisableServiceException`,使用 e.getService() 可获取业务标识 `comment` 
StpUtil.checkDisable(10001, "comment");

// 在下单时,我们校验一下 下单能力,并不会抛出异常,因为我们没有限制其下单功能
StpUtil.checkDisable(10001, "place-order");

// 现在我们再将其下单能力封禁一下,期限为 7天 
StpUtil.disable(10001, "place-order", 86400 * 7);

// 然后在下单接口,我们添加上校验代码,此时用户便会因为下单能力被封禁而无法下单(代码抛出异常)
StpUtil.checkDisable(10001, "place-order");

// 但是此时,用户如果调用开店功能的话,还是可以通过,因为我们没有限制其开店能力 (除非我们再调用了封禁开店的代码)
StpUtil.checkDisable(10001, "open-shop");

通过以上示例,你应该大致可以理解 业务封禁 -> 业务校验 的处理步骤。
有关分类封禁的所有方法:

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

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

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

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

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

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

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

基于处罚时间的阶梯,我们只需在封禁时 StpUtil.disable(10001, 86400) 传入不同的封禁时间即可,下面我们着重探讨一下基于处罚力度的阶梯形式。
假设我们在开发一个论坛系统,对于违规账号的处罚,我们设定三种力度:

  • 1、轻度违规:封禁其发帖、评论能力,但允许其点赞、关注等操作。
  • 2、中度违规:封禁其发帖、评论、点赞、关注等一切与别人互动的能力,但允许其浏览帖子、浏览评论。
  • 3、重度违规:封禁其登录功能,限制一切能力。

解决这种需求的关键在于,我们需要把不同处罚力度,量化成不同的处罚等级,比如上述的 轻度中度重度 3 个力度, 我们将其量化为一级封禁二级封禁三级封禁 3个等级,数字越大代表封禁力度越高。
然后我们就可以使用阶梯封禁的API,进行鉴权了:

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

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

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

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

注意点:DisableServiceException 异常代表当前账号未通过封禁校验,可以:

  • 通过 e.getLevel() 获取这个账号实际被封禁的等级。
  • 通过 e.getLimitLevel() 获取这个账号在校验时要求低于的等级。当 Level >= LimitLevel 时,框架就会抛出异常。

如果业务足够复杂,我们还可能将 分类封禁 和 阶梯封禁 组合使用:

// 分类阶梯封禁,参数:封禁账号、封禁服务、封禁级别、封禁时间 
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);
  1. 使用注解完成封禁校验

首先我们需要注册 Sa-Token 全局拦截器(可参考 注解鉴权 章节),然后我们就可以使用以下注解校验账号是否封禁

// 校验当前账号是否被封禁,如果已被封禁会抛出异常,无法进入方法 
@SaCheckDisable
@PostMapping("send")
public SaResult send() {
    // ... 
    return SaResult.ok(); 
}

// 校验当前账号是否被封禁 comment 服务,如果已被封禁会抛出异常,无法进入方法 
@SaCheckDisable("comment")
@PostMapping("send")
public SaResult send() {
    // ... 
    return SaResult.ok(); 
}

// 校验当前账号是否被封禁 comment、place-order、open-shop 等服务,指定多个值,只要有一个已被封禁,就无法进入方法 
@SaCheckDisable({"comment", "place-order", "open-shop"})
@PostMapping("send")
public SaResult send() {
    // ... 
    return SaResult.ok(); 
}

// 阶梯封禁,校验当前账号封禁等级是否达到5级,如果达到则抛出异常 
@SaCheckDisable(level = 5)
@PostMapping("send")
public SaResult send() {
    // ... 
    return SaResult.ok(); 
}

// 分类封禁 + 阶梯封禁 校验:校验当前账号的 comment 服务,封禁等级是否达到5级,如果达到则抛出异常 
@SaCheckDisable(value = "comment", level = 5)
@PostMapping("send")
public SaResult send() {
    // ... 
    return SaResult.ok(); 
}

测试代码:com.pj.controller.DisableController

5.5 密码加密

严格来讲,密码加密不属于 [权限认证] 的范畴,但是对于大多数系统来讲,密码加密又是安全认证不可或缺的部分, 所以,应大家要求,Sa-Token在 v1.14 版本添加密码加密模块,该模块非常简单,仅仅封装了一些常见的加密算法。

  1. 摘要加密

md5、sha1、sha256

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

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

// sha256加密 
SaSecureUtil.sha256("123456");
  1. 对称加密

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);
  1. 非对称加密

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());
  1. Base64编码与解码
// 文本
String text = "Sa-Token 一个轻量级java权限认证框架";

// 使用Base64编码
String base64Text = SaBase64Util.encode(text);
System.out.println("Base64编码后:" + base64Text);

// 使用Base64解码
String text2 = SaBase64Util.decode(base64Text);
System.out.println("Base64解码后:" + text2); 
  1. BCrypt加密

由它加密的文件可在所有支持的操作系统和处理器上进行转移
它的口令必须是8至56个字符,并将在内部被转化为448位的密钥
此类来自于https://github.com/jeremyh/jBCrypt/

// 使用方法
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); 

如需更多加密算法,可参考 Hutool-crypto: 加密

案例代码:com.pj.controller.SecureController

5.6 全局侦听器

5.6.1 工作原理

Sa-Token 提供一种侦听器机制,通过注册侦听器,你可以订阅框架的一些关键性事件,例如:用户登录、退出、被踢下线等。
事件触发流程大致如下:
在这里插入图片描述

框架默认内置了侦听器 SaTokenListenerForLog 实现:代码参考 ,功能是控制台 log 打印输出,你可以通过配置sa-token.is-log=true开启。
要注册自定义的侦听器也非常简单:

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

5.6.2 自定义侦听器实现

  1. 新建实现类:

新建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");
    }
}
  1. 将侦听器注册到事件中心:

以上代码由于添加了 @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); 
  1. 启动测试:

TestController 中添加登录测试代码:

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

启动项目,访问登录接口,观察控制台输出:

5.6.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");
    }
}复制到剪贴板错误复制成功

1
2
3
4
5
6
7
8
9
10
11
  1. 使用匿名内部类的方式注册:
// 登录时触发 
SaTokenEventCenter.registerListener(new SaTokenListenerForSimple() {
    @Override
    public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
        System.out.println("---------------- doLogin");
    }
});
  1. 使用 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();
        }
    }
});
  1. 疑问:一个项目可以注册多个侦听器吗?

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

测试案例:com.pj.controller.advance.MySaTokenListener

6 微服务架构下安全认证

6.1 微服务分布式Session认证架构方案

  1. 微服务架构下安全认证面临的挑战:

微服务架构下的第一个难题便是数据同步,单机版的Session在分布式环境下一般不能正常工作,为此我们需要对框架做一些特定的处理。
首先我们要明白,分布式环境下为什么Session会失效?因为用户在一个节点对会话做出的更改无法实时同步到其它的节点, 这就导致一个很严重的问题:如果用户在节点一上已经登录成功,那么当下一次的请求落在节点二上时,对节点二来讲,此用户仍然是未登录状态。

  1. 解决方案

要怎么解决这个问题呢?目前的主流方案有四种:

  • Session同步:只要一个节点的数据发生了改变,就强制同步到其它所有节点
  • Session粘滞:通过一定的算法,保证一个用户的所有请求都稳定的落在一个节点之上,对这个用户来讲,就好像还是在访问一个单机版的服务
  • 建立会话中心:将Session存储在专业的缓存中间件上,使每个节点都变成了无状态服务,例如:Redis
  • 颁发无状态token:放弃Session机制,将用户数据直接写入到令牌本身上,使会话数据做到令牌自解释,例如:jwt
  1. 方案选择

该如何选择一个合适的方案?

  • 方案一:性能消耗太大,不太考虑
  • 方案二:需要从网关处动手,与框架无关
  • 方案三:Sa-Token 整合Redis非常简单,详见章节:集成 Redis
  • 方案四:详见官方仓库中 Sa-Token 整合jwt的示例

由于jwt模式不在服务端存储数据,对于比较复杂的业务可能会功能受限,因此更加推荐使用方案三
在这里插入图片描述

6.2 集成Redis

Sa-Token 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:

  1. 重启后数据会丢失。
  2. 无法在分布式环境中共享数据。

为此,Sa-Token 提供了扩展接口,你可以轻松将会话数据存储在一些专业的缓存中间件上(比如 Redis), 做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性。
以下是框架提供的 Redis 集成包:

  1. 引入依赖
		<!-- Sa-Token 整合Redis (使用jackson序列化方式) -->
		<dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-redis-jackson</artifactId>
            <version>${sa-token.version}</version>
        </dependency>
        
		<!-- 提供Redis连接池 -->
		<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>${sa-token.version}</version>
        </dependency>

Sa-Token的Redis依赖解释如下:

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

优点:兼容性好,缺点:Session 序列化后基本不可读,对开发者来讲等同于乱码。

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

优点:Session 序列化后可读性强,可灵活手动修改,缺点:兼容性稍差。

  1. 集成 Redis 请注意:

无论使用哪种序列化方式,你都必须为项目提供一个 Redis 实例化方案,例如:

<!-- 提供Redis连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
  1. Sa-Token-Alone-Redis 独立Redis插件

Sa-Token默认的Redis集成方式会把权限数据和业务缓存放在一起,但在部分场景下我们需要将他们彻底分离开来,比如:
image.png

<!-- Sa-Token插件:权限缓存与业务缓存分离 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-alone-redis</artifactId>
    <version>1.38.0</version>
</dependency>
  1. 配置文件
# 端口
server:
    port: 8081

# Sa-Token配置
sa-token: 
    # Token名称 (同时也是cookie名称)
    token-name: satoken
    # Token有效期,单位s 默认30天, -1代表永不过期 
    timeout: 2592000
    # Token风格
    token-style: uuid
    # 配置Sa-Token单独使用的Redis连接 
    alone-redis:
        # Redis模式(默认单体)
        # pattern: single
        # Redis数据库索引(默认为0)
        database: 2
        # 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
        
spring: 
    # 配置业务使用的Redis连接 
    redis: 
        # Redis数据库索引(默认为0)
        database: 0
        # 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

演示案例:sa-token-demo-alone-redis

6.3 集成jwt

  1. 引入依赖

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

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

版本兼容性

  1. 注意: sa-token-jwt 显式依赖 hutool-jwt 5.7.14 版本,保险起见:你的项目中要么不引入 hutool,要么引入版本 >= 5.7.14 的 hutool 版本。
  2. hutool 5.8.13 和 5.8.14 版本下会出现类型转换问题。
  1. 配置秘钥

application.yml 配置文件中配置 jwt 生成秘钥:
yaml 风格

sa-token:
    # jwt秘钥 
    jwt-secret-key: asdasdasifhueuiwyurfewbfjsdafjk
  1. 注入jwt实现

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

  • Simple 简单模式
  • Mixin 混入模式
  • Stateless 无状态模式
@Configuration
public class SaTokenConfigure {
    // Sa-Token 整合 jwt (Simple 简单模式)
    @Bean
    public StpLogic getStpLogicJwt() {
        return new StpLogicJwtForSimple();
        //return new StpLogicJwtForMixin();
        //return new StpLogicJwtForStateless();
    }
}
  1. 开始使用

然后我们就可以像之前一样使用 Sa-Token 了

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

    // 测试登录
    @RequestMapping("login")
    public SaResult login() {
        StpUtil.login(10001);
        return SaResult.ok("登录成功");
    }

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

    // 测试注销
    @RequestMapping("logout")
    public SaResult logout() {
        StpUtil.logout();
        return SaResult.ok();
    }

}

访问上述接口,观察Token生成的样式

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpbklkIjoiMTAwMDEiLCJybiI6IjZYYzgySzBHVWV3Uk5NTTl1dFdjbnpFZFZHTVNYd3JOIn0.F_7fbHsFsDZmckHlGDaBuwDotZwAjZ0HB14DRujQfOQ
  1. 不同模式策略对比

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

功能点 Simple 简单模式 Mixin 混入模式 Stateless 无状态模式
Token风格 jwt风格 jwt风格 jwt风格
登录数据存储 Redis中存储 Token中存储 Token中存储
Session存储 Redis中存储 Redis中存储 无Session
注销下线 前后端双清数据 前后端双清数据 前端清除数据
踢人下线API 支持 不支持 不支持
顶人下线API 支持 不支持 不支持
登录认证 支持 支持 支持
角色认证 支持 支持 支持
权限认证 支持 支持 支持
timeout 有效期 支持 支持 支持
active-timeout 有效期 支持 支持 不支持
id反查Token 支持 支持 不支持
会话管理 支持 部分支持 不支持
注解鉴权 支持 支持 支持
路由拦截鉴权 支持 支持 支持
账号封禁 支持 支持 不支持
身份切换 支持 支持 支持
二级认证 支持 支持 支持
模式总结 Token风格替换 jwt 与 Redis 逻辑混合 完全舍弃Redis,只用jwt
  1. 扩展参数

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

// 登录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");
  1. 几个注意点

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

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

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

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

  1. 使用 jwt-mixin 模式后,max-try-times 恒等于 -1。

为防止框架错误判断 token 唯一性,当使用 jwt-mixin 模式后,max-try-times 恒等于 -1。

6.4 前后端分离(无Cookie模式)

  1. 何为无 Cookie 模式?

无 Cookie 模式:特指不支持 Cookie 功能的终端,通俗来讲就是我们常说的 —— 前后端分离模式
常规 Web 端鉴权方法,一般由 Cookie模式 完成,而 Cookie 有两个特性:

  1. 可由后端控制写入。
  2. 每次请求自动提交。

这就使得我们在前端代码中,无需任何特殊操作,就能完成鉴权的全部流程(因为整个流程都是后端控制完成的)
而在app、小程序等前后端分离场景中,一般是没有 Cookie 这一功能的,此时大多数人都会一脸懵逼,咋进行鉴权啊?
见招拆招,其实答案很简单:

  • 不能后端控制写入了,就前端自己写入。(难点在后端如何将 Token 传递到前端
  • 每次请求不能自动提交了,那就手动提交。(难点在前端如何将 Token 传递到后端,同时后端将其读取出来
  1. 后端将 token 返回到前端
  • 首先调用 StpUtil.login(id) 进行登录。
  • 调用 StpUtil.getTokenInfo() 返回当前会话的 token 详细参数。
    • 此方法返回一个对象,其有两个关键属性:tokenNametokenValue(token 的名称和 token 的值)。
    • 将此对象传递到前台,让前端人员将这两个值保存到本地。

代码示例:

// 登录接口
@RequestMapping("doLogin")
public SaResult doLogin() {
    // 第1步,先登录上 
    StpUtil.login(10001);
    // 第2步,获取 Token  相关参数 
    SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
    // 第3步,返回给前端 
    return SaResult.data(tokenInfo);
}
  1. 前端将 token 提交到后端
  • 无论是app还是小程序,其传递方式都大同小异。
  • 那就是,将 token 塞到请求header里 ,格式为:{tokenName: tokenValue}
  • 以经典跨端框架 uni-app 为例:

方式1,简单粗暴

// 1、首先在登录时,将 tokenValue 存储在本地,例如:
uni.setStorageSync('tokenValue', tokenValue);

// 2、在发起ajax请求的地方,获取这个值,并塞到header里 
uni.request({
  url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
  header: {
    "content-type": "application/x-www-form-urlencoded",
    "satoken": uni.getStorageSync('tokenValue')        // 关键代码, 注意参数名字是 satoken 
  },
  success: (res) => {
    console.log(res.data);    
  }
});

方式2,更加灵活

// 1、首先在登录时,将tokenName和tokenValue一起存储在本地,例如:
uni.setStorageSync('tokenName', tokenName); 
uni.setStorageSync('tokenValue', tokenValue); 

// 2、在发起ajax的地方,获取这两个值, 并组织到head里 
var tokenName = uni.getStorageSync('tokenName');    // 从本地缓存读取tokenName值
var tokenValue = uni.getStorageSync('tokenValue');    // 从本地缓存读取tokenValue值
var header = {
  "content-type": "application/x-www-form-urlencoded"
};
if (tokenName != undefined && tokenName != '') {
  header[tokenName] = tokenValue;
}

// 3、后续在发起请求时将 header 对象塞到请求头部 
uni.request({
  url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
  header: header,
  success: (res) => {
    console.log(res.data);    
  }
});
  • 只要按照如此方法将token值传递到后端,Sa-Token 就能像传统PC端一样自动读取到 token 值,进行鉴权。
  • 你可能会有疑问,难道我每个ajax都要写这么一坨?岂不是麻烦死了?
    • 你当然不能每个 ajax 都写这么一坨,因为这种重复性代码都是要封装在一个函数里统一调用的。
  1. 后端尝试从header中读取token
#    # 是否尝试从header里读取token
#    is-read-header: true
#    # 是否尝试从cookie里读取token
#    is-read-cookie: true

测试代码:com.pj.controller.advance.NotCookieController

6.5 内部服务外网隔离

6.4.1 需求场景

我们的子服务一般不能通过外网直接访问,必须通过网关转发才是一个合法的请求,这种子服务与外网的隔离一般分为两种:

  • 物理隔离:子服务部署在指定的内网环境中,只有网关对外网开放
  • 逻辑隔离:子服务与网关同时暴露在外网,但是子服务会有一个权限拦截层保证只接受网关发送来的请求,绕过网关直接访问子服务会被提示:无效请求

这种鉴权需求牵扯到两个环节:

  • 网关转发鉴权
  • 服务间内部调用鉴权

Sa-Token提供两种解决方案:

  1. 使用 OAuth2.0 模式的凭证式,将 Client-Token 用作各个服务的身份凭证进行权限校验
  2. 使用 Same-Token 模块提供的身份校验能力,完成服务间的权限认证

这里讲解方案二 Same-Token 模块的整合步骤,其鉴权流程与 OAuth2.0 类似,不过使用方式上更加简洁(使用方案一的同学可参考Sa-OAuth2模块,此处不再赘述)

6.4.2 网关转发鉴权

  1. 引入依赖
<!-- Sa-Token 权限认证(Reactor响应式集成), 在线文档:https://sa-token.cc -->
<dependency>
  <groupId>cn.dev33</groupId>
  <artifactId>sa-token-reactor-spring-boot-starter</artifactId>
  <version>1.38.0</version>
</dependency>

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

在上游子服务引入的依赖为:

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

<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
  <groupId>cn.dev33</groupId>
  <artifactId>sa-token-redis-jackson</artifactId>
  <version>1.38.0</version>
</dependency>
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-pool2</artifactId>
</dependency>
  1. 网关处添加Same-Token

为网关添加全局过滤器:

/**
 * 全局过滤器,为请求添加 Same-Token 
 */
@Component
public class ForwardAuthFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest newRequest = exchange
        .getRequest()
        .mutate()
        // 为请求追加 Same-Token 参数 
        .header(SaSameUtil.SAME_TOKEN, SaSameUtil.getToken())
        .build();
        ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
        return chain.filter(newExchange);
    }
}

此过滤器会为 Request 请求头追加 Same-Token 参数,这个参数会被转发到子服务

  1. 在子服务里校验参数

在子服务添加过滤器校验参数

/**
 * Sa-Token 权限认证 配置类 
 */
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
    // 注册 Sa-Token 全局过滤器 
    @Bean
    public SaServletFilter getSaServletFilter() {
        return new SaServletFilter()
        .addInclude("/**")
        .addExclude("/favicon.ico")
        .setAuth(obj -> {
            // 校验 Same-Token 身份凭证     —— 以下两句代码可简化为:SaSameUtil.checkCurrentRequestToken(); 
            String token = SaHolder.getRequest().getHeader(SaSameUtil.SAME_TOKEN);
            SaSameUtil.checkToken(token);
        })
        .setError(e -> {
            return SaResult.error(e.getMessage());
        })
        ;
    }
}

启动网关与子服务,访问测试:
如果通过网关转发,可以正常访问。如果直接访问子服务会提示:无效Same-Token:xxx

6.4.3 服务间内部调用鉴权

有时候我们需要在一个服务调用另一个服务的接口,这也是需要添加Same-Token作为身份凭证的
在服务里添加 Same-Token 流程与网关类似,我们以RPC框架 Feign 为例:

  1. 首先在调用方添加 FeignInterceptor
/**
 * feign拦截器, 在feign请求发出之前,加入一些操作 
 */
@Component
public class FeignInterceptor implements RequestInterceptor {
    // 为 Feign 的 RCP调用 添加请求头Same-Token 
    @Override
    public void apply(RequestTemplate requestTemplate) {
        requestTemplate.header(SaSameUtil.SAME_TOKEN, SaSameUtil.getToken());

        // 如果希望被调用方有会话状态,此处就还需要将 satoken 添加到请求头中
        // requestTemplate.header(StpUtil.getTokenName(), StpUtil.getTokenValue());
    }
}
  1. 在调用接口里使用此 Interceptor
/**
 * 服务调用 
 */
@FeignClient(
        name = "sp-home",                 // 服务名称 
        configuration = FeignInterceptor.class,        // 请求拦截器 (关键代码)
        fallbackFactory = SpCfgInterfaceFallback.class    // 服务降级处理 
        )    
public interface SpCfgInterface {

    // 获取server端指定配置信息 
    @RequestMapping("/SpConfig/getConfig")
    public String getConfig(@RequestParam("key")String key);
    
}

被调用方的代码无需更改(按照网关转发鉴权处的代码注册全局过滤器),保持启动测试即可

6.4.4 Same-Token 模块详解

Same-Token —— 专门解决同源系统互相调用时的身份认证校验,它的作用不仅局限于微服务调用场景
基本使用流程为:

  • 服务调用方获取Token,
  • 提交到请求中,被调用方取出Token进行校验,
  • Token一致则校验通过,否则拒绝服务

首先我们预览一下此模块的相关API:

// 获取当前Same-Token
SaSameUtil.getToken();

// 判断一个Same-Token是否有效
SaSameUtil.isValid(token);

// 校验一个Same-Token是否有效 (如果无效则抛出异常)
SaSameUtil.checkToken(token);

// 校验当前Request提供的Same-Token是否有效 (如果无效则抛出异常)
SaSameUtil.checkCurrentRequestToken();

// 刷新一次Same-Token (注意集群环境中不要多个服务重复调用) 
SaSameUtil.refreshToken();

// 在 Request 上储存 Same-Token 时建议使用的key
SaSameUtil.SAME_TOKEN;

几个问题

  1. 疑问:这个Token保存在什么地方?有没有泄露的风险?Token为永久有效还是临时有效?

Same-Token 默认随 Sa-Token 数据一起保存在Redis中,理论上不会存在泄露的风险,每个Token默认有效期只有一天

  1. 如何主动刷新Same-Token,例如:五分钟、两小时刷新一次?

Same-Token 刷新间隔越短,其安全性越高,每个Token的默认有效期为一天,在一天后再次获取会自动产生一个新的Token

需要注意的一点是:Same-Token默认的自刷新机制,并不能做到高并发可用,多个服务一起触发Token刷新可能会造成毫秒级的短暂服务失效,其只能适用于 项目开发阶段 或 低并发业务场景

因此在微服务架构下,我们需要有专门的机制主动刷新Same-Token,保证其高可用
例如,我们可以专门起一个服务,使用定时任务来刷新Same-Token

/**
 * Same-Token,定时刷新
 */
@Configuration
public class SaSameTokenRefreshTask {
    // 从 0 分钟开始 每隔 5 分钟执行一次 Same-Token  
    @Scheduled(cron = "0 0/5 * * * ? ")
    public void refreshToken(){
        SaSameUtil.refreshToken();
    }
}

以上的cron表达式刷新间隔可以配置为五分钟十分钟两小时,只要低于Same-Token的有效期(默认为一天)即可。

  1. 如果网关携带token转发的请求在落到子服务的节点上时,恰好刷新了token,导致鉴权未通过怎么办?

Same-Token 模块在每次刷新 Token 时,旧 Token 会被作为次级 Token 存储起来, 只要网关携带的 Token 符合新旧 Token 其一即可通过认证,直至下一次刷新,新 Token 再次作为次级 Token 将此替换掉。

7 单点登录(SSO)

7.1 单点登录架构选型

凡是稍微上点规模的系统,统一认证中心都是绕不过去的槛。

而单点登录——便是我们搭建统一认证中心的关键。

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

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

  1. 架构选型

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

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

  2. API 简单易用,文档介绍详细,且提供直接可用的集成示例。

  3. 支持三种模式,不论是否跨域、是否共享Redis、是否前后端分离,都可以完美解决。

  4. 安全性高:内置域名校验、Ticket校验、秘钥校验等,杜绝Ticket劫持Token窃取等常见攻击手段(文档讲述攻击原理和防御手段)。

  5. 不丢参数:笔者曾试验多个单点登录框架,均有参数丢失的情况,比如重定向之前是:http://a.com?id=1&name=2,登录成功之后就变成了:http://a.com?id=1,Sa-Token-SSO内有专门的算法保证了参数不丢失,登录成功之后原路返回页面。

  6. 无缝集成:由于Sa-Token本身就是一个权限认证框架,因此你可以只用一个框架同时解决权限认证 + 单点登录问题,让你不再到处搜索:xxx单点登录与xxx权限认证如何整合……

  7. 高可定制:Sa-Token-SSO模块对代码架构侵入性极低,结合Sa-Token本身的路由拦截特性,你可以非常轻松的定制化开发。

7.2 认证中心 SSO-Server

在开始SSO三种模式的对接之前,我们必须先搭建一个 SSO-Server 认证中心

  1. 添加依赖

创建 SpringBoot 项目 sa-token-demo-sso-server,引入依赖:
Maven 方式

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

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

<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
  <groupId>cn.dev33</groupId>
  <artifactId>sa-token-redis-jackson</artifactId>
  <version>1.38.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-startersa-token-sso 以外,其它包都是可选的:

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

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

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

    /**
     * SSO-Server端:处理所有SSO相关请求 (下面的章节我们会详细列出开放的接口) 
     */
    @RequestMapping("/sso/*")
    public Object ssoRequest() {
        return SaSsoServerProcessor.instance.dister();
    }

    /**
     * 配置SSO相关参数 
     */
    @Autowired
    private void configSso(SaSsoServerConfig ssoServer) {
        // 配置:未登录时返回的View 
        ssoServer.notLoginView = () -> {
            String msg = "当前会话在SSO-Server端尚未登录,请先访问"
            + "<a href='/sso/doLogin?name=sa&pwd=123456' target='_blank'> doLogin登录 </a>"
            + "进行登录之后,刷新页面开始授权";
            return msg;
        };

        // 配置:登录处理函数 
        ssoServer.doLoginHandle = (name, pwd) -> {
            // 此处仅做模拟登录,真实环境应该查询数据进行登录 
            if("sa".equals(name) && "123456".equals(pwd)) {
                StpUtil.login(10001);
                return SaResult.ok("登录成功!").setData(StpUtil.getTokenValue());
            }
            return SaResult.error("登录失败!");
        };

        // 配置 Http 请求处理器 (在模式三的单点注销功能下用到,如不需要可以注释掉) 
        ssoServer.sendHttp = url -> {
            try {
                System.out.println("------ 发起请求:" + url);
                String resStr = Forest.get(url).executeAsString();
                System.out.println("------ 请求结果:" + resStr);
                return resStr;
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        };
    }

}

注意:

  • doLoginHandle函数里如果要获取name, pwd以外的参数,可通过SaHolder.getRequest().getParam("xxx")来获取
  • sendHttp 函数中,使用 try-catch 是为了提高整个注销流程的容错性,避免在一些极端情况下注销失败(例如:某个 Client 端上线之后又下线,导致 http 请求无法调用成功,从而阻断了整个注销流程)

全局异常处理:

@RestControllerAdvice
public class GlobalExceptionHandler {
    // 全局异常拦截 
    @ExceptionHandler
    public SaResult handlerException(Exception e) {
        e.printStackTrace(); 
        return SaResult.error(e.getMessage());
    }
}
  1. application.yml配置
# 端口
server:
    port: 9000

# Sa-Token 配置
sa-token: 
    # ------- SSO-模式一相关配置  (非模式一不需要配置) 
    # cookie: 
        # 配置 Cookie 作用域 
        # domain: stp.com 
        
    # ------- SSO-模式二相关配置 
    sso-server: 
        # 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

注意点:sa-token.sso-server.allow-url为了方便测试配置为*,线上生产环境一定要配置为详细URL地址,否则会有被 Ticket 劫持的风险,比如
http://sa-sso-server.com:9000/sso/auth?redirect=https://www.baidu.com/
借此漏洞,攻击者完全可以构建一个URL将小红的 Ticket 码自动提交到攻击者自己的服务器,伪造小红身份登录网站
推荐配置:allow-url: [http://sa-sso-client1.com:9001/sso/login](http://sa-sso-client1.com:9001/sso/login)

  1. 创建启动类
@SpringBootApplication
public class SaSsoServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaSsoServerApplication.class, args);

        System.out.println();
        System.out.println("---------------------- Sa-Token SSO 统一认证中心启动成功 ----------------------");
        System.out.println("配置信息:" + SaSsoManager.getServerConfig());
        System.out.println();
    }
}

启动项目,不出意外的情况下我们将看到如下输出:
在这里插入图片描述

访问统一授权地址(仅测试 SSO-Server 是否部署成功,暂时还不需要点击登录):


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

大家可以下载运行一下官方仓库里的示例/sa-token-demo/sa-token-demo-sso/sa-token-demo-sso-server/,里面有制作好的登录页面:

默认账号密码为:sa / 123456,先别着急点击登录,因为我们还没有搭建对应的 Client 端项目, 真实项目中我们是不会直接从浏览器访问 /sso/auth 授权地址的,我们需要在 Client 端点击登录按钮重定向而来。

现在我们先来看看除了 /sso/auth 统一授权地址,这个 SSO-Server 认证中心还开放了哪些API:SSO-Server 认证中心开放接口

7.3 SSO接口详解

如果你的 SSO-Server 端和 SSO-Client 端都使用 Sa-Token-SSO 搭建,那么client可以调用默认的server
如果你仅在 SSO-Server 端使用 Sa-Token-SSO 搭建,而 SSO-Client 端使用其它框架的话,那么你就需要手动调用 http 请求来对接 SSO-Server 认证中心, 下面的 API 列表将给你的对接步骤做一份参考。

7.3.1 SSO-Server 认证中心接口

  1. 单点登录授权地址
http://{host}:{port}/sso/auth

接收参数:

参数 是否必填 说明
redirect 登录成功后的重定向地址,一般填写 location.href(从哪来回哪去)
mode 授权模式,取值 [simple, ticket],simple=登录后直接重定向,ticket=带着ticket参数重定向,默认值为ticket
client 客户端标识,可不填,代表是一个匿名应用,若填写了,则校验 ticket 时也必须是这个 client 才可以校验成功

访问接口后有两种情况:

  • 情况一:当前会话在 SSO 认证中心未登录,会进入登录页开始登录。
  • 情况二:当前会话在 SSO 认证中心已登录,会被重定向至 redirect 地址,并携带 ticket 参数。
  1. RestAPI 登录接口
http://{host}:{port}/sso/doLogin

接收参数:

参数 是否必填 说明
name 用户名
pwd 密码
  • 此接口属于 RestAPI (使用ajax访问),会进入后端配置的 ssoServer.doLoginHandle 函数中,此函数的返回值即是此接口的响应值。
  • 另外需要注意:此接口并非只能携带 name、pwd 参数,因为你可以在方法里通过 SaHolder.getRequest().getParam("xxx") 来获取前端提交的其它参数。
  1. Ticket 校验接口

此接口仅配置模式三 (isHttp=true) 时打开

http://{host}:{port}/sso/checkTicket

接收参数:

参数 是否必填 说明
ticket 在步骤 1 中授权重定向时的 ticket 参数
ssoLogoutCall 单点注销时的回调通知地址,只在SSO模式三单点注销时需要携带此参数
client 客户端标识,可不填,代表是一个匿名应用,若填写了,则必须填写的和 /sso/auth
登录时填写的一致才可以校验成功
timestamp 当前时间戳,13位
nonce 随机字符串
sign 签名,生成算法:md5( [client={client值}&]nonce={随机字符串}&[ssoLogoutCall={单点注销回调地址}&]ticket={ticket值}&timestamp={13位时间戳}&key={secretkey秘钥} )
注:[]内容代表可选

返回值场景:

  • 校验成功时:
{
    "code": 200,
    "msg": "ok",
    "data": "10001",    // 此 ticket 指向的 loginId
    "remainSessionTimeout": 7200, // 此账号在 sso-server 端的会话剩余有效期(单位:s)
}
  • 校验失败时:
{
    "code": 500,
    "msg": "无效ticket:vESj0MtqrtSoucz4DDHJnsqU3u7AKFzbj0KH57EfJvuhkX1uAH23DuNrMYSjTnEq",
    "data": null
}
  1. 单点注销接口
http://{host}:{port}/sso/signout

此接口有两种调用方式

4.1 方式一:在 Client 的前端页面引导用户直接跳转,并带有 back 参数

例如:

http://{host}:{port}/sso/signout?back=xxx

用户注销成功后将返回 back 地址

4.2 方式二:在 Client 的后端通过 http 工具来调用

接受参数:

参数 是否必填 说明
loginId 要注销的账号 id
timestamp 当前时间戳,13位
nonce 随机字符串
sign 签名,生成算法:md5( loginId={账号id}&nonce={随机字符串}&timestamp={13位时间戳}&key={secretkey秘钥} )
client 客户端标识,可不填,一般在帮助 “sso-server 端不同client不同秘钥” 的场景下找到对应秘钥时,才填写

例如:

http://{host}:{port}/sso/signout?loginId={value}&timestamp={value}&nonce={value}&sign={value}

将返回 json 数据结果,形如:

{
    "code": 200,    // 200表示请求成功,非200标识请求失败
    "msg": "单点注销成功",
    "data": null
}

如果单点注销失败,将返回:

{
    "code": 500,    // 200表示请求成功,非200标识请求失败
    "msg": "签名无效:xxx",    // 失败原因 
    "data": null
}

SSO 认证中心只有这四个接口

7.3.2 SSO-Client 接口详解

  1. 登录地址
http://{host}:{port}/sso/login

接收参数:

参数 是否必填 说明
back 登录成功后的重定向地址,一般填写 location.href(从哪来回哪去)
ticket 授权 ticket 码

此接口有两种访问方式:

  • 方式一:我们需要登录操作,所以带着 back 参数主动访问此接口,框架会拼接好参数后再次将用户重定向至认证中心。
  • 方式二:用户在认证中心登录成功后,带着 ticket 参数重定向而来,此为框架自动处理的逻辑,开发者无需关心。
  1. 注销地址
http://{host}:{port}/sso/logout

接收参数:

参数 是否必填 说明
back 注销成功后的重定向地址,一般填写 location.href(从哪来回哪去),也可以填写 self 字符串,含义同上

此接口有两种访问方式:

  • 方式一:直接 location.href 网页跳转,此时可携带 back 参数。
  • 方式二:使用 Ajax 异步调用(此方式不可携带 back 参数,但是需要提交会话 Token ),注销成功将返回以下内容:
{
    "code": 200,    // 200表示请求成功,非200标识请求失败
    "msg": "单点注销成功",
    "data": null
}
  1. 单点注销回调接口

此接口仅配置模式三 (isHttp=true) 时打开,且为框架回调,开发者无需关心

http://{host}:{port}/sso/logoutCall

接受参数:

参数 是否必填 说明
loginId 要注销的账号 id
timestamp 当前时间戳,13位
nonce 随机字符串
sign 签名,生成算法:md5( loginId={账号id}&nonce={随机字符串}&timestamp={13位时间戳}&key={secretkey秘钥} )
client 客户端标识,如果你在登录时向 sso-server 端传递了 client 值,那么在此处 sso-server 也会给你回传过来,否则此参数无值。如果此参数有值,则此参数也要参与签名,放在 loginId 参数前面(字典顺序)
autoLogout 是否为“登录client超过最大数量”引起的自动注销(true=超限系统自动注销,false=用户主动发起注销)。如果此参数有值,则此参数也要参与签名,放在 client 参数前面(字典顺序)

返回数据:

{
    "code": 200,    // 200表示请求成功,非200标识请求失败
    "msg": "单点注销回调成功",
    "data": null
}

7.4 模式一 共享Cookie同步会话

案例项目:

  • sa-token-demo-sso-server
  • sa-token-demo-sso1-client

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

7.4.1 设计思路

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

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

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

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

所谓共享Cookie,就是主域名Cookie在二级域名下的共享,举个例子:写在父域名stp.com下的Cookie,在s1.stp.coms2.stp.com等子域名都是可以共享访问的。
而共享Redis,并不需要我们把所有项目的数据都放在同一个Redis中
在这里插入图片描述

OK,所有理论就绪,下面开始实战:

7.4.2 SSO-Server

  1. 准备工作

首先修改hosts文件(C:\windows\system32\drivers\etc\hosts),添加以下IP映射,方便我们进行测试:

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

其中:sso.stp.com为统一认证中心地址,当用户在其它 Client 端发起登录请求时,均将其重定向至认证中心,待到登录成功之后再原路返回到 Client 端。

  1. 指定Cookie的作用域

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

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

这个配置原本是被注释掉的,现在将其打开。另外我们格外需要注意: 在SSO模式一测试完毕之后,一定要将这个配置再次注释掉,因为模式一与模式二三使用不同的授权流程,这行配置会影响到我们模式二和模式三的正常运行。

7.4.2 SSO-Client

  1. 引入依赖
<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc -->
<dependency>
  <groupId>cn.dev33</groupId>
  <artifactId>sa-token-spring-boot-starter</artifactId>
  <version>1.38.0</version>
</dependency>
<!-- Sa-Token 插件:整合SSO -->
<dependency>
  <groupId>cn.dev33</groupId>
  <artifactId>sa-token-sso</artifactId>
  <version>1.38.0</version>
</dependency>

<!-- Sa-Token 整合redis (使用jackson序列化方式) -->
<dependency>
  <groupId>cn.dev33</groupId>
  <artifactId>sa-token-redis-jackson</artifactId>
  <version>1.38.0</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.38.0</version>
</dependency>
  1. 新建 Controller 控制器
/**
 * Sa-Token-SSO Client端 Controller 
 * @author click33
 */
@RestController
public class SsoClientController {

    // SSO-Client端:首页 
    @RequestMapping("/")
    public String index() {
        String authUrl = SaSsoManager.getClientConfig().splicingAuthUrl();
        String solUrl = SaSsoManager.getClientConfig().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());
    }
    
}
  1. application.yml 配置
# 端口
server:
    port: 9001

# Sa-Token 配置 
sa-token: 
    # SSO-相关配置
    sso-client:
        # SSO-Server端主机地址
        server-url: http://sso.stp.com:9000
    
    # 配置 Sa-Token 单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis)
    alone-redis: 
        # Redis数据库索引
        database: 1
        # Redis服务器地址
        host: 127.0.0.1
        # Redis服务器连接端口
        port: 6379
        # Redis服务器连接密码(默认为空)
        password: 
        # 连接超时时间
        timeout: 10s
  1. 启动类
/**
 * SSO模式一,Client端 Demo 
 */
@SpringBootApplication
public class SaSso1ClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaSso1ClientApplication.class, args);
        
        System.out.println();
        System.out.println("---------------------- Sa-Token SSO 模式一 Client 端启动成功 ----------------------");
        System.out.println("配置信息:" + SaSsoManager.getClientConfig());
        System.out.println("测试访问应用端一: http://s1.stp.com:9001");
        System.out.println("测试访问应用端二: http://s2.stp.com:9001");
        System.out.println("测试访问应用端三: http://s3.stp.com:9001");
        System.out.println("测试前需要根据官网文档修改hosts文件,测试账号密码:sa / 123456");
        System.out.println();
    }
}

7.4.3 访问测试

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

均返回:

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

我们点击登录,然后刷新页面:

刷新另外两个Client端,均显示已登录

测试完成

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

案例项目:

  • sa-token-demo-sso-server
  • sa-token-demo-sso1-client

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

7.5.1 设计思路

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

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

关于第二点,使用sa-token集成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认证中心已有会话存在, 所以第四步也将自动化,也就是单点登录的最终目的 —— 一次登录,处处通行。
在这里插入图片描述

为什么不直接回传 Token,而是先回传 Ticket,再用 Ticket 去查询对应的账号id?
Token 作为长时间有效的会话凭证,在任何时候都不应该直接暴露在 URL 之中(虽然 Token 直接的暴露本身不会造成安全漏洞,但会为很多漏洞提供可乘之机)
为了不让系统安全处于亚健康状态,Sa-Token-SSO 选择先回传 Ticket,再由 Ticket 获取账号id,且 Ticket 一次性用完即废,提高安全性。

下面我们按照步骤依次完成上述过程:

7.5.2 SSO-Server

  1. 准备工作

首先修改hosts文件(C:\windows\system32\drivers\etc\hosts),添加以下IP映射,方便我们进行测试:

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
  1. 去除 SSO-Server 的 Cookie 作用域配置

在SSO模式一章节中我们打开了配置:
yaml 风格

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

此为模式一专属配置,现在我们将其注释掉(一定要注释掉!

7.5.3 SSO-Client

  1. 创建 SSO-Client 端项目

创建一个 SpringBoot 项目 sa-token-demo-sso2-client,引入依赖:
Maven 方式

<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.38.0</version>
</dependency>
<!-- Sa-Token 插件:整合SSO -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-sso</artifactId>
    <version>1.38.0</version>
</dependency>

<!-- Sa-Token 整合redis (使用jackson序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.38.0</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.38.0</version>
</dependency>
  1. 创建 SSO-Client 端认证接口

同 SSO-Server 一样,Sa-Token 为 SSO-Client 端所需代码也提供了完整的封装,你只需提供一个访问入口,接入 Sa-Token 的方法即可。

/**
 * 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 SaSsoClientProcessor.instance.dister();
    }

}
  1. 配置SSO认证中心地址

你需要在 application.yml 配置如下信息:

# 端口
server:
    port: 9001

# sa-token配置 
sa-token: 
    # SSO-相关配置
    sso-client: 
        # SSO-Server 端主机地址
        server-url: http://sa-sso-server.com:9000

    # 配置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

注意点:sa-token.alone-redis 的配置需要和SSO-Server端连接同一个Redis(database 值也要一样!database 值也要一样!database 值也要一样!重说三!)

  1. 写启动类
@SpringBootApplication
public class SaSso2ClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaSso2ClientApplication.class, args);

        System.out.println();
        System.out.println("---------------------- Sa-Token SSO 模式二 Client 端启动成功 ----------------------");
        System.out.println("配置信息:" + SaSsoManager.getClientConfig());
        System.out.println("测试访问应用端一: http://sa-sso-client1.com:9001");
        System.out.println("测试访问应用端二: http://sa-sso-client2.com:9001");
        System.out.println("测试访问应用端三: http://sa-sso-client3.com:9001");
        System.out.println("测试前需要根据官网文档修改hosts文件,测试账号密码:sa / 123456");
        System.out.println();
    }
}

启动项目

7.5.4 测试访问

(1) 依次启动 SSO-ServerSSO-Client,然后从浏览器访问:http://sa-sso-client1.com:9001/

在这里插入图片描述

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

(3) SSO-Server提示我们在认证中心尚未登录,我们点击 **doLogin登录** 按钮进行模拟登录

(4) SSO-Server认证中心登录成功,我们回到刚才的页面刷新页面

(5) 页面被重定向至Client端首页,并提示登录成功,至此,Client1应用已单点登录成功!
(6) 我们再次访问Client2http://sa-sso-client2.com:9001/

(7) 提示未登录,我们点击 **登录** 按钮,会直接提示登录成功

(8) 同样的方式,我们打开Client3,也可以直接登录成功:http://sa-sso-client3.com:9001/

至此,测试完毕!
可以看出,除了在Client1端我们需要手动登录一次之外,在Client2端Client3端都是可以无需再次认证,直接登录成功的。
我们可以通过 F12控制台 Network 跟踪整个过程

7.6 模式三 Http请求获取会话

如果既无法做到前端同域,也无法做到后端同Redis,那么可以使用模式三完成单点登录

先实战SSO模式二!因为模式三仅仅属于模式二的一个特殊场景

7.6.1 问题分析

我们先来分析一下,当后端不使用共享 Redis 时,会对架构产生哪些影响:

  1. Client 端无法直连 Redis 校验 ticket,取出账号id。
  2. Client 端无法与 Server 端共用一套会话,需要自行维护子会话。

所以模式三的主要目标:也就是在 模式二的基础上 解决上述 三个难题

案例项目:

  • sa-token-demo-sso-server
  • sa-token-demo-sso3-client

7.6.2 在Client 端更改 Ticket 校验方式

在 application.yml 新增配置:
yaml 风格

sa-token: 
    sso-client: 
        # 打开模式三(使用Http请求校验ticket)
        is-http: true

重启项目,访问测试:

注:如果已测试运行模式二,可先将Redis中的数据清空,以防旧数据对测试造成干扰

7.6.3 获取 UserInfo

除了账号id,我们可能还需要将用户的昵称、头像等信息从 Server端 带到 Client端,即:用户资料的拉取。
在模式二中我们只需要将需要同步的资料放到 SaSession 即可,但是在模式三中两端不再连接同一个 Redis,这时候我们需要通过 http 接口来同步信息。

  1. 首先在 Server 端开放一个查询数据的接口
// 示例:获取数据接口(用于在模式三下,为 client 端开放拉取数据的接口)
@RequestMapping("/sso/getData")
public SaResult getData(String apiType, String loginId) {
    System.out.println("---------------- 获取数据 ----------------");
    System.out.println("apiType=" + apiType);
    System.out.println("loginId=" + loginId);

    // 校验签名:只有拥有正确秘钥发起的请求才能通过校验
    SaSignUtil.checkRequest(SaHolder.getRequest());

    // 自定义返回结果(模拟)
    return SaResult.ok()
    .set("id", loginId)
    .set("name", "LinXiaoYu")
    .set("sex", "女")
    .set("age", 18);
}

如果配置了 “不同 client 不同秘钥” 模式,则需要将上述的:
SaSignUtil.checkRequest(SaHolder.getRequest());

改为以下方式:
String client = SaHolder.getRequest().getHeader("client");
SaSsoServerProcessor.instance.ssoServerTemplate.getSignTemplate(client).checkRequest(SaHolder.getRequest());

  1. 在 Client 端调用此接口查询数据

SsoClientController 中新增接口

// 查询我的账号信息 
@RequestMapping("/sso/myInfo")
public Object myInfo() {
    // 组织请求参数
    Map<String, Object> map = new HashMap<>();
    map.put("apiType", "userinfo");
    map.put("loginId", StpUtil.getLoginId());

    // 发起请求
    Object resData = SaSsoUtil.getData(map);
    System.out.println("sso-server 返回的信息:" + resData);
    return resData;
}

3.3 访问测试

访问测试:http://sa-sso-client1.com:9001/sso/myInfo

7.6.4 单点注销

image.png
有了单点登录,就必然伴随着单点注销(一处注销,全端下线)
如果你的所有 client 都是基于 SSO 模式二来对接的,那么单点注销其实很简单:

// 在 `sa-token.is-share=true` 的情况下,调用此代码即可单点注销:
StpUtil.logout();

// 在 `sa-token.is-share=false` 的情况下,调用此代码即可单点注销:
StpUtil.logout(StpUtil.getLoginId());

你可能会比较疑惑,这不就是个普通的会话注销API吗,为什么会有单点注销的效果?
因为模式二需要各个 sso-client 和 sso-server 连接同一个 redis,即使登录再多的 client,本质上对应的仍是同一个会话,因此可以做到任意一处调用注销,全端一起下线的效果。
而如果你的各个 client 架构各不相同,有的是模式二对接,有的是模式三对接,则需要麻烦一点才能做到单点注销。
这里的“麻烦”指两处:1、框架内部逻辑麻烦;2、开发者集成麻烦。
框架内部的麻烦 sa-token-sso 已经封装完毕,无需过多关注,而开发者的麻烦步骤也不是很多:

  1. 增加 pom.xml 配置

Maven 方式

<!-- Http请求工具 -->
<dependency>
  <groupId>com.dtflys.forest</groupId>
  <artifactId>forest-spring-boot-starter</artifactId>
  <version>1.5.26</version>
</dependency>

Forest 是一个轻量级 http 请求工具,详情参考:Forest
因为我们已经在控制台手动打印 url 请求日志了,所以此处 forest.log-enabled=false 关闭 Forest 框架自身的日志打印,这不是必须的,你可以将其打开。

  1. SSO-Client 端新增配置:API调用秘钥

application.yml 增加:
yaml 风格

sa-token: 
    sign:
        # API 接口调用秘钥
        secret-key: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
        
forest: 
    # 关闭 forest 请求日志打印
    log-enabled: false

注意 secretkey 秘钥需要与SSO认证中心的一致

  1. SSO-Client 配置 http 请求处理器
// 配置SSO相关参数
@Autowired
private void configSso(SaSsoClientConfig ssoClient) {
// 配置Http请求处理器
ssoClient.sendHttp = url -> {
    System.out.println("------ 发起请求:" + url);
    String resStr = Forest.get(url).executeAsString();
    System.out.println("------ 请求结果:" + resStr);
    return resStr;
};
}
  1. 启动测试

重启项目,依次登录三个 client:


在任意一个 client 里,点击 **[注销]** 按钮,即可单点注销成功(打开另外两个client,刷新一下页面,登录态丢失)。

PS:这里我们为了方便演示,使用的是超链接跳页面的形式,正式项目中使用 Ajax 调用接口即可做到无刷单点登录退出。
例如,我们使用 Apifox 接口测试工具 可以做到同样的效果:

测试完毕!

7.6.5 总结

当我们熟读三种模式的单点登录之后,其实不难发现:所谓单点登录,其本质就是多个系统之间的会话共享。
当我们理解这一点之后,三种模式的工作原理也浮出水面:

  • 模式一:采用共享 Cookie 来做到前端 Token 的共享,从而达到后端的 Session 会话共享。
  • 模式二:采用 URL 重定向,以 ticket 码为授权中介,做到多个系统间的会话传播。
  • 模式三:采用 Http 请求主动查询会话,做到 Client 端与 Server 端的会话同步。

7.7 前后端分离架构下SSO

如果系统是前后端分离模式,需要处理SSO-Server和SSO-Client前后端分离,也就是有4个部署应用:

  • 后端server:端口9000
  • 前端server:端口8848
  • 后端client:端口9001
  • 前端client:端口8849

7.7.1 SSO-Client后端

  1. 新建H5Controller开放接口
@RestController
public class H5Controller {

	// 当前是否登录 
	@RequestMapping("/sso/isLogin")
	public Object isLogin() {
		return SaResult.data(StpUtil.isLogin());
	}
	
	// 返回SSO认证中心登录地址 
	@RequestMapping("/sso/getSsoAuthUrl")
	public SaResult getSsoAuthUrl(String clientLoginUrl) {
		String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(clientLoginUrl, "");
		return SaResult.data(serverAuthUrl);
	}
	
	// 根据ticket进行登录 
	@RequestMapping("/sso/doLoginByTicket")
	public SaResult doLoginByTicket(String ticket) {
		Object loginId = SaSsoProcessor.instance.checkTicket(ticket, "/sso/doLoginByTicket");
		if(loginId != null) {
			StpUtil.login(loginId);
			return SaResult.data(StpUtil.getTokenValue());
		}
		return SaResult.error("无效ticket:" + ticket); 
	}

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

  1. 增加跨域过滤器CorsFilter.java

源码详见:CorsFilter.java

  1. 配置统一认证地址是server的前端页面
sa-token: 
    # SSO-相关配置
    sso:
        # SSO-Server端 统一认证地址 
        # auth-url: http://sa-sso-server.com:9000/sso/auth #前后端一体配置
        auth-url: http://127.0.0.1:8848/sso-auth.html #前后端分离sso-server配置,
        # 是否打开单点注销接口
        is-slo: true

7.7.2 SSO-Client前端

  1. 新建前端项目

任意文件夹新建前端项目:sa-token-demo-sso-client-h5,在根目录添加测试文件:index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Sa-Token-SSO-Client端-测试页(前后端分离版)</title>
</head>
<body>
<h2>Sa-Token SSO-Client 应用端(前后端分离版)</h2>
<p>当前是否登录:<b class="is-login"></b></p>
<p>
<a href="javascript:location.href='sso-login.html?back=' + encodeURIComponent(location.href);">登录</a>
<a href="javascript:location.href=baseUrl + '/sso/logout?satoken=' + localStorage.satoken + '&back=' + encodeURIComponent(location.href);">注销</a>
</p>
<script src="https://unpkg.zhimg.com/jquery@3.4.1/dist/jquery.min.js"></script>
<script type="text/javascript">

// 后端接口地址 
var baseUrl = "http://sa-sso-client1.com:9001";

// 查询当前会话是否登录 
$.ajax({
    url: baseUrl + '/sso/isLogin',
    type: "post", 
    dataType: 'json',
    headers: {
        "X-Requested-With": "XMLHttpRequest",
        "satoken": localStorage.getItem("satoken")
    },
    success: function(res){
        $('.is-login').html(res.data + '');
    },
    error: function(xhr, type, errorThrown){
        return alert("异常:" + JSON.stringify(xhr));
    }
});

</script>
</body>
</html>
  1. 添加登录处理文件sso-login.html

源码详见:sso-login.html, 将其复制到项目中即可,与index.html一样放在根目录下

  1. 测试运行

可以在nginx或者tomcat中部署SSO-Client前端代码,端口8848

7.7.3 SSO-Server后端

H5Controller

@RestController
public class H5Controller {
	
	/**
	 * 获取 redirectUrl 
	 */
	@RequestMapping("/sso/getRedirectUrl")
	private Object getRedirectUrl(String redirect, String mode, String client) {
		// 未登录情况下,返回 code=401 
		if(StpUtil.isLogin() == false) {
			return SaResult.code(401);
		}
		// 已登录情况下,构建 redirectUrl 
		if(SaSsoConsts.MODE_SIMPLE.equals(mode)) {
			// 模式一 
			SaSsoUtil.checkRedirectUrl(SaFoxUtil.decoderUrl(redirect));
			return SaResult.data(redirect);
		} else {
			// 模式二或模式三 
			String redirectUrl = SaSsoUtil.buildRedirectUrl(StpUtil.getLoginId(), client, redirect);
			return SaResult.data(redirectUrl);
		}
	}

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

7.7.4 SSO-Server前端

参考sa-token-demo-sso-server-h5源码

可以在nginx或者tomcat中部署SSO-Server前端,端口8849

SSO测试与模式2相同

7.8 Sa-Token-OAuth2.0 模块

7.8.1 简介

什么是OAuth2.0?解决什么问题?
简单来讲,OAuth2.0的应用场景可以理解为单点登录的升级版,单点登录解决了多个系统间会话的共享,OAuth2.0在此基础上增加了应用之间的权限控制 (SO:有些系统采用OAuth2.0模式实现了单点登录,但这总给人一种“杀鸡焉用宰牛刀”的感觉)
关于Sa-token和OAuth2.0技术选型

功能点 SSO单点登录 OAuth2.0
统一认证 支持度高 支持度高
统一注销 支持度高 支持度低
多个系统会话一致性 强一致 弱一致
第三方应用授权管理 不支持 支持度高
自有系统授权管理 支持度高 支持度低
Client级的权限校验 不支持 支持度高
集成简易度 比较简单 难度中等

OAuth2.0 四种模式
基于不同的使用场景,OAuth2.0设计了四种模式:

  1. 授权码(Authorization Code):OAuth2.0标准授权步骤,Server端向Client端下放Code码,Client端再用Code码换取授权Token
  2. 隐藏式(Implicit):无法使用授权码模式时的备用选择,Server端使用URL重定向方式直接将Token下放到Client端页面
  3. 密码式(Password):Client直接拿着用户的账号密码换取授权Token
  4. 客户端凭证(Client Credentials):Server端针对Client级别的Token,代表应用自身的资源授权

7.8.2 实战案例

  1. 准备工作

首先修改hosts文件(C:\windows\system32\drivers\etc\hosts),添加以下IP映射,方便我们进行测试:

127.0.0.1 sa-oauth-server.com
127.0.0.1 sa-oauth-client.com
  1. 引入依赖

创建SpringBoot项目 sa-token-demo-oauth2-server(不会的同学自行百度或参考仓库示例),添加pom依赖:
Maven 方式

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

<!-- Sa-Token-OAuth2.0 模块 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-oauth2</artifactId>
    <version>1.38.0</version>
</dependency>
  1. 开放服务

1、新建 SaOAuth2TemplateImpl

/**
 * Sa-Token OAuth2.0 整合实现 
 */
@Component
public class SaOAuth2TemplateImpl extends SaOAuth2Template {

    // 根据 id 获取 Client 信息 
    @Override
    public SaClientModel getClientModel(String clientId) {
        // 此为模拟数据,真实环境需要从数据库查询 
        if("1001".equals(clientId)) {
            return new SaClientModel()
            .setClientId("1001")
            .setClientSecret("aaaa-bbbb-cccc-dddd-eeee")
            .setAllowUrl("*")
            .setContractScope("userinfo")
            .setIsAutoMode(true);
        }
        return null;
    }

    // 根据ClientId 和 LoginId 获取openid 
    @Override
    public String getOpenid(String clientId, Object loginId) {
        // 此为模拟数据,真实环境需要从数据库查询 
        return "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__";
    }

}

2、新建SaOAuth2ServerController

/**
 * Sa-OAuth2 Server端 控制器 
 */
@RestController
public class SaOAuth2ServerController {

    // 处理所有OAuth相关请求 
    @RequestMapping("/oauth2/*")
    public Object request() {
        System.out.println("------- 进入请求: " + SaHolder.getRequest().getUrl());
        return SaOAuth2Handle.serverRequest();
    }

    // Sa-OAuth2 定制化配置 
    @Autowired
    public void setSaOAuth2Config(SaOAuth2Config cfg) {
        cfg.
        // 配置:未登录时返回的View 
        setNotLoginView(() -> {
            String msg = "当前会话在OAuth-Server端尚未登录,请先访问"
            + "<a href='/oauth2/doLogin?name=sa&pwd=123456' target='_blank'> doLogin登录 </a>"
            + "进行登录之后,刷新页面开始授权";
            return msg;
        }).
        // 配置:登录处理函数 
        setDoLoginHandle((name, pwd) -> {
            if("sa".equals(name) && "123456".equals(pwd)) {
                StpUtil.login(10001);
                return SaResult.ok();
            }
            return SaResult.error("账号名或密码错误");
        }).
        // 配置:确认授权时返回的View 
        setConfirmView((clientId, scope) -> {
            String msg = "<p>应用 " + clientId + " 请求授权:" + scope + "</p>"
            + "<p>请确认:<a href='/oauth2/doConfirm?client_id=" + clientId + "&scope=" + scope + "' target='_blank'> 确认授权 </a></p>"
            + "<p>确认之后刷新页面</p>";
            return msg;
        })
        ;
    }

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

}

注意:在setDoLoginHandle函数里如果要获取name, pwd以外的参数,可通过SaHolder.getRequest().getParam("xxx")来获取
3、创建启动类:

/**
 * 启动:Sa-OAuth2 Server端 
 */
@SpringBootApplication 
public class SaOAuth2ServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaOAuth2ServerApplication.class, args);
        System.out.println("\nSa-Token-OAuth Server 端启动成功");
    }
}

启动项目

  1. 授权码模式访问测试

1、由于暂未搭建Client端,我们可以使用Sa-Token官网作为重定向URL进行测试:

http://sa-oauth-server.com:8001/oauth2/authorize?response_type=code&client_id=1001&redirect_uri=https://sa-token.cc&scope=userinfo

2、由于首次访问,我们在OAuth-Server端暂未登录,会被转发到登录视图

3、点击doLogin进行登录之后刷新页面,会提示我们确认授权
4、点击确认授权之后刷新页面,我们会被重定向至 redirect_uri 页面,并携带了code参数

4、我们拿着code参数,访问以下地址:

http://sa-oauth-server.com:8001/oauth2/token?grant_type=authorization_code&client_id=1001&client_secret=aaaa-bbbb-cccc-dddd-eeee&code={code}

将得到 Access-TokenRefresh-Tokenopenid等授权信息

测试完毕

  1. 客户端测试

依次启动OAuth2-ServerOAuth2-Client,然后从浏览器访问:http://sa-oauth-client.com:8002

如图,可以针对OAuth2.0四种模式进行详细测试

7.8.3 OAuth2开放接口详解

7.8.3.1 模式一:授权码(Authorization Code)

  1. 获取授权码

根据以下格式构建URL,引导用户访问 (复制时请注意删减掉相应空格和换行符)

http://sa-oauth-server.com:8001/oauth2/authorize
    ?response_type=code
    &client_id={value}
    &redirect_uri={value}
    &scope={value}
    &state={value}

参数详解:

参数 是否必填 说明
response_type 返回类型,这里请填写:code
client_id 应用id
redirect_uri 用户确认授权后,重定向的url地址
scope 具体请求的权限,多个用逗号隔开
state 随机值,此参数会在重定向时追加到url末尾,不填不追加

注意点:

  1. 如果用户在Server端尚未登录:会被转发到登录视图,你可以参照文档或官方示例自定义登录页面
  2. 如果scope参数为空,或者请求的权限用户近期已确认过,则无需用户再次确认,达到静默授权的效果,否则需要用户手动确认,服务器才可以下放code授权码

用户确认授权之后,会被重定向至redirect_uri,并追加code参数与state参数,形如:

redirect_uri?code={code}&state={state}

Code授权码具有以下特点:

  • 每次授权产生的Code码都不一样
  • Code码用完即废,不能二次使用
  • 一个Code的有效期默认为五分钟,超时自动作废
  • 每次授权产生新Code码,会导致旧Code码立即作废,即使旧Code码尚未使用
  1. 根据授权码获取Access-Token

获得Code码后,我们可以通过以下接口,获取到用户的Access-TokenRefresh-Tokenopenid等关键信息

http://sa-oauth-server.com:8001/oauth2/token
    ?grant_type=authorization_code
    &client_id={value}
    &client_secret={value}
    &code={value}

参数详解:

参数 是否必填 说明
grant_type 授权类型,这里请填写:authorization_code
client_id 应用id
client_secret 应用秘钥
code 步骤1.1中获取到的授权码

接口返回示例:

{
    "code": 200,    // 200表示请求成功,非200标识请求失败, 以下不再赘述 
    "msg": "ok",
    "data": {
        "access_token": "7Ngo1Igg6rieWwAmWMe4cxT7j8o46mjyuabuwLETuAoN6JpPzPO2i3PVpEVJ",     // Access-Token值
        "refresh_token": "ZMG7QbuCVtCIn1FAJuDbgEjsoXt5Kqzii9zsPeyahAmoir893ARA4rbmeR66",    // Refresh-Token值
        "expires_in": 7199,                 // Access-Token剩余有效期,单位秒  
        "refresh_expires_in": 2591999,      // Refresh-Token剩余有效期,单位秒  
        "client_id": "1001",                // 应用id
        "scope": "userinfo",                // 此令牌包含的权限
        "openid": "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__"     // openid 
    }
}
  1. 根据 Refresh-Token 刷新 Access-Token (如果需要的话)

Access-Token的有效期较短,如果每次过期都需要重新授权的话,会比较影响用户体验,因此我们可以在后台通过Refresh-Token 刷新 Access-Token

http://sa-oauth-server.com:8001/oauth2/refresh
    ?grant_type=refresh_token
    &client_id={value}
    &client_secret={value}
    &refresh_token={value}

参数详解:

参数 是否必填 说明
grant_type 授权类型,这里请填写:refresh_token
client_id 应用id
client_secret 应用秘钥
refresh_token 步骤1.2中获取到的Refresh-Token
  1. 回收 Access-Token (如果需要的话)

在Access-Token过期前主动将其回收

http://sa-oauth-server.com:8001/oauth2/revoke
    ?client_id={value}
    &client_secret={value}
    &access_token={value}

参数详解:

参数 是否必填 说明
client_id 应用id
client_secret 应用秘钥
access_token 步骤1.2中获取到的Access-Token

返回值样例:

{
    "code": 200,
    "msg": "ok",
    "data": null
}
  1. 根据 Access-Token 获取相应用户的账号信息

注:此接口为官方仓库模拟接口,正式项目中大家可以根据此样例,自定义需要的接口及参数

http://sa-oauth-server.com:8001/oauth2/userinfo?access_token={value}

返回值样例:

{
    "code": 200,
    "msg": "ok",
    "data": {
        "nickname": "shengzhang_",         // 账号昵称
        "avatar": "http://xxx.com/1.jpg",  // 头像地址
        "age": "18",                       // 年龄
        "sex": "男",                       // 性别
        "address": "山东省 青岛市 城阳区"   // 所在城市 
    }
}

7.8.3.2 模式二:隐藏式(Implicit)

根据以下格式构建URL,引导用户访问:

http://sa-oauth-server.com:8001/oauth2/authorize
    ?response_type=token
    &client_id={value}
    &redirect_uri={value}
    &scope={value}
    $state={value}

参数详解:

参数 是否必填 说明
response_type 返回类型,这里请填写:token
client_id 应用id
redirect_uri 用户确认授权后,重定向的url地址
scope 具体请求的权限,多个用逗号隔开
state 随机值,此参数会在重定向时追加到url末尾,不填不追加

此模式会越过授权码的步骤,直接返回Access-Token到前端页面,形如:

redirect_uri#token=xxxx-xxxx-xxxx-xxxx

7.8.3.3 模式三:密码式(Password)

首先在Client端构建表单,让用户输入Server端的账号和密码,然后在Client端访问接口

http://sa-oauth-server.com:8001/oauth2/token
    ?grant_type=password
    &client_id={value}
    &client_secret={value}
    &username={value}
    &password={value}

参数详解:

参数 是否必填 说明
grant_type 返回类型,这里请填写:password
client_id 应用id
client_secret 应用秘钥
username 用户的Server端账号
password 用户的Server端密码
scope 具体请求的权限,多个用逗号隔开

接口返回示例:

{
    "code": 200,    // 200表示请求成功,非200标识请求失败, 以下不再赘述 
    "msg": "ok",
    "data": {
        "access_token": "7Ngo1Igg6rieWwAmWMe4cxT7j8o46mjyuabuwLETuAoN6JpPzPO2i3PVpEVJ",     // Access-Token值
        "refresh_token": "ZMG7QbuCVtCIn1FAJuDbgEjsoXt5Kqzii9zsPeyahAmoir893ARA4rbmeR66",    // Refresh-Token值
        "expires_in": 7199,                 // Access-Token剩余有效期,单位秒  
        "refresh_expires_in": 2591999,      // Refresh-Token剩余有效期,单位秒  
        "client_id": "1001",                // 应用id
        "scope": "",                        // 此令牌包含的权限
        "openid": "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__"     // openid 
    }
}

7.8.3.4 模式四:凭证式(Client Credentials)

以上三种模式获取的都是用户的 Access-Token,代表用户对第三方应用的授权, 在OAuth2.0中还有一种针对 Client级别的授权, 即:Client-Token,代表应用自身的资源授权
在Client端的后台访问以下接口:

http://sa-oauth-server.com:8001/oauth2/client_token
    ?grant_type=client_credentials
    &client_id={value}
    &client_secret={value}

参数详解:

参数 是否必填 说明
grant_type 返回类型,这里请填写:client_credentials
client_id 应用id
client_secret 应用秘钥
scope 申请权限

接口返回值样例:

{
    "code": 200,
    "msg": "ok",
    "data": {
        "client_token": "HmzPtaNuIqGrOdudWLzKJRSfPadN497qEJtanYwE7ZvHQWDy0jeoZJuDIiqO",    // Client-Token 值
        "expires_in": 7199,     // Token剩余有效时间,单位秒 
        "client_id": "1001",    // 应用id
        "scope": null           // 包含权限 
    }
}

注:Client-Token具有延迟作废特性,即:在每次获取最新Client-Token的时候,旧Client-Token不会立即过期,而是作为Past-Token再次储存起来, 资源请求方只要携带其中之一便可通过Token校验,这种特性保证了在大量并发请求时不会出现“新旧Token交替造成的授权失效”, 保证了服务的高可用

说在最后:有问题找老架构取经

在这里插入图片描述

尼恩团队15大技术圣经 ,使得大家内力猛增,

可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。

在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典PDF》,里边有大量的大厂真题、面试难题、架构难题。

很多小伙伴刷完后, 吊打面试官, 大厂横着走。

在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。

另外,如果没有面试机会,可以找尼恩来改简历、做帮扶。

遇到职业难题,找老架构取经, 可以省去太多的折腾,省去太多的弯路。

尼恩指导了大量的小伙伴上岸,前段时间,刚指导一个40岁+被裁小伙伴,拿到了一个年薪100W的offer。

狠狠卷,实现 “offer自由” 很容易的, 前段时间一个武汉的跟着尼恩卷了2年的小伙伴, 在极度严寒/痛苦被裁的环境下, offer拿到手软, 实现真正的 “offer自由” 。

技术自由的实现路径:

实现你的 架构自由:

吃透8图1模板,人人可以做架构

10Wqps评论中台,如何架构?B站是这么做的!!!

阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了

峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?

100亿级订单怎么调度,来一个大厂的极品方案

2个大厂 100亿级 超大流量 红包 架构方案

… 更多架构文章,正在添加中

实现你的 响应式 自由:

响应式圣经:10W字,实现Spring响应式编程自由

这是老版本 《Flux、Mono、Reactor 实战(史上最全)

实现你的 spring cloud 自由:

Spring cloud Alibaba 学习圣经》 PDF

分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)

一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)

实现你的 linux 自由:

Linux命令大全:2W多字,一次实现Linux自由

实现你的 网络 自由:

TCP协议详解 (史上最全)

网络三张表:ARP表, MAC表, 路由表,实现你的网络自由!!

实现你的 分布式锁 自由:

Redis分布式锁(图解 - 秒懂 - 史上最全)

Zookeeper 分布式锁 - 图解 - 秒懂

实现你的 王者组件 自由:

队列之王: Disruptor 原理、架构、源码 一文穿透

缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)

缓存之王:Caffeine 的使用(史上最全)

Java Agent 探针、字节码增强 ByteBuddy(史上最全)

实现你的 面试题 自由:

4800页《尼恩Java面试宝典 》 40个专题

免费获取11个技术圣经PDF:

posted @ 2024-08-23 19:57  疯狂创客圈  阅读(184)  评论(0编辑  收藏  举报