Sa-Token学习圣经:史上最全的权限设计方案,一文帮你成专家

2024-08-24 02:44

本文主要是介绍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异常,而同为未登录异常却有五种抛出场景的区分

场景值对应常量含义说明
-1NotLoginException.NOT_TOKEN未能从请求中读取到有效 token
-2NotLoginException.INVALID_TOKEN已读取到 token,但是 token 无效
-3NotLoginException.TOKEN_TIMEOUT已读取到 token,但是 token 已经过期 (详
)
-4NotLoginException.BE_REPLACED已读取到 token,但是 token 已被顶下线
-5NotLoginException.KICK_OUT已读取到 token,但是 token 已被踢下线
-6NotLoginException.TOKEN_FREEZE已读取到 token,但是 token 已被冻结
-7NotLoginException.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 {/*** 返回一个账号所拥有的权限码集合 */@Overridepublic 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;}/*** 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)*/@Overridepublic 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 {// 全局异常拦截 @ExceptionHandlerpublic 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 拦截器,打开注解式鉴权功能 @Overridepublic 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 {// 注册拦截器@Overridepublic 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 {@Overridepublic 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 的拦截器@Overridepublic 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());  // 输出 trueSystem.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 {/** 每次登录时触发 */@Overridepublic void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {System.out.println("---------- 自定义侦听器实现 doLogin");}/** 每次注销时触发 */@Overridepublic void doLogout(String loginType, Object loginId, String tokenValue) {System.out.println("---------- 自定义侦听器实现 doLogout");}/** 每次被踢下线时触发 */@Overridepublic void doKickout(String loginType, Object loginId, String tokenValue) {System.out.println("---------- 自定义侦听器实现 doKickout");}/** 每次被顶下线时触发 */@Overridepublic void doReplaced(String loginType, Object loginId, String tokenValue) {System.out.println("---------- 自定义侦听器实现 doReplaced");}/** 每次被封禁时触发 */@Overridepublic void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {System.out.println("---------- 自定义侦听器实现 doDisable");}/** 每次被解封时触发 */@Overridepublic void doUntieDisable(String loginType, Object loginId, String service) {System.out.println("---------- 自定义侦听器实现 doUntieDisable");}/** 每次二级认证时触发 */@Overridepublic void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) {System.out.println("---------- 自定义侦听器实现 doOpenSafe");}/** 每次退出二级认证时触发 */@Overridepublic void doCloseSafe(String loginType, String tokenValue, String service) {System.out.println("---------- 自定义侦听器实现 doCloseSafe");}/** 每次创建Session时触发 */@Overridepublic void doCreateSession(String id) {System.out.println("---------- 自定义侦听器实现 doCreateSession");}/** 每次注销Session时触发 */@Overridepublic void doLogoutSession(String id) {System.out.println("---------- 自定义侦听器实现 doLogoutSession");}/** 每次Token续期时触发 */@Overridepublic 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 对所有事件提供了空实现,通过继承此类,你只需重写一部分方法即可实现一个可用的侦听器。*//** 每次登录时触发 */@Overridepublic 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() {@Overridepublic 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() {@Overridepublic 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: 10slettuce: pool:# 连接池最大连接数max-active: 200# 连接池最大阻塞等待时间(使用负值表示没有限制)max-wait: -1ms# 连接池中的最大空闲连接max-idle: 10# 连接池中的最小空闲连接min-idle: 0spring: # 配置业务使用的Redis连接 redis: # Redis数据库索引(默认为0)database: 0# Redis服务器地址host: 127.0.0.1# Redis服务器连接端口port: 6379# Redis服务器连接密码(默认为空)password: # 连接超时时间(毫秒)timeout: 10slettuce: 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 简单模式)@Beanpublic 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.5.1 需求场景

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

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

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

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

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

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

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

6.5.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 {@Overridepublic 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 全局过滤器 @Beanpublic 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.5.3 服务间内部调用鉴权

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

  1. 首先在调用方添加 FeignInterceptor
/*** feign拦截器, 在feign请求发出之前,加入一些操作 */
@Component
public class FeignInterceptor implements RequestInterceptor {// 为 Feign 的 RCP调用 添加请求头Same-Token @Overridepublic 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.5.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相关参数 */@Autowiredprivate 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 {// 全局异常拦截 @ExceptionHandlerpublic 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: truesign:# 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 是否部署成功,暂时还不需要点击登录):

  • http://localhost:9000/sso/auth


可以看到这个页面目前非常简陋,这是因为我们以上的代码示例,主要目标是为了带大家从零搭建一个可用的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;}// 全局异常拦截 @ExceptionHandlerpublic 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 访问测试

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

  • http://s1.stp.com:9001/
  • http://s2.stp.com:9001/
  • http://s3.stp.com:9001/

均返回:

然后点击登录,被重定向至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) 我们再次访问Client2:http://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

重启项目,访问测试:

  • http://sa-sso-client1.com:9001/
  • http://sa-sso-client2.com:9001/
  • http://sa-sso-client3.com:9001/

注:如果已测试运行模式二,可先将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: kQwIOrYvnXmSDkwEiFngrKidMcdrgKorforest: # 关闭 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:

  • http://sa-sso-client1.com:9001/
  • http://sa-sso-client2.com:9001/
  • http://sa-sso-client3.com:9001/


在任意一个 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); }// 全局异常拦截 @ExceptionHandlerpublic 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);}}// 全局异常拦截 @ExceptionHandlerpublic 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 信息 @Overridepublic 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 @Overridepublic 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 定制化配置 @Autowiredpublic 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;});}// 全局异常拦截  @ExceptionHandlerpublic 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自由” 。

另外,尼恩也给一线企业提供 《DDD 的架构落地》企业内部培训,目前给不少企业做过内部的咨询和培训,效果非常好。

在这里插入图片描述

尼恩技术圣经系列PDF

  • 《NIO圣经:一次穿透NIO、Selector、Epoll底层原理》
  • 《Docker圣经:大白话说Docker底层原理,6W字实现Docker自由》
  • 《K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由》
  • 《SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由》
  • 《大数据HBase学习圣经:一本书实现HBase学习自由》
  • 《大数据Flink学习圣经:一本书实现大数据Flink自由》
  • 《响应式圣经:10W字,实现Spring响应式编程自由》
  • 《Go学习圣经:Go语言实现高并发CRUD业务开发》

……完整版尼恩技术圣经PDF集群,请找尼恩领取

《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓

这篇关于Sa-Token学习圣经:史上最全的权限设计方案,一文帮你成专家的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1101226

相关文章

HarmonyOS学习(七)——UI(五)常用布局总结

自适应布局 1.1、线性布局(LinearLayout) 通过线性容器Row和Column实现线性布局。Column容器内的子组件按照垂直方向排列,Row组件中的子组件按照水平方向排列。 属性说明space通过space参数设置主轴上子组件的间距,达到各子组件在排列上的等间距效果alignItems设置子组件在交叉轴上的对齐方式,且在各类尺寸屏幕上表现一致,其中交叉轴为垂直时,取值为Vert

Ilya-AI分享的他在OpenAI学习到的15个提示工程技巧

Ilya(不是本人,claude AI)在社交媒体上分享了他在OpenAI学习到的15个Prompt撰写技巧。 以下是详细的内容: 提示精确化:在编写提示时,力求表达清晰准确。清楚地阐述任务需求和概念定义至关重要。例:不用"分析文本",而用"判断这段话的情感倾向:积极、消极还是中性"。 快速迭代:善于快速连续调整提示。熟练的提示工程师能够灵活地进行多轮优化。例:从"总结文章"到"用

Spring Security 基于表达式的权限控制

前言 spring security 3.0已经可以使用spring el表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。 常见的表达式 Spring Security可用表达式对象的基类是SecurityExpressionRoot。 表达式描述hasRole([role])用户拥有制定的角色时返回true (Spring security默认会带有ROLE_前缀),去

【前端学习】AntV G6-08 深入图形与图形分组、自定义节点、节点动画(下)

【课程链接】 AntV G6:深入图形与图形分组、自定义节点、节点动画(下)_哔哩哔哩_bilibili 本章十吾老师讲解了一个复杂的自定义节点中,应该怎样去计算和绘制图形,如何给一个图形制作不间断的动画,以及在鼠标事件之后产生动画。(有点难,需要好好理解) <!DOCTYPE html><html><head><meta charset="UTF-8"><title>06

学习hash总结

2014/1/29/   最近刚开始学hash,名字很陌生,但是hash的思想却很熟悉,以前早就做过此类的题,但是不知道这就是hash思想而已,说白了hash就是一个映射,往往灵活利用数组的下标来实现算法,hash的作用:1、判重;2、统计次数;

零基础学习Redis(10) -- zset类型命令使用

zset是有序集合,内部除了存储元素外,还会存储一个score,存储在zset中的元素会按照score的大小升序排列,不同元素的score可以重复,score相同的元素会按照元素的字典序排列。 1. zset常用命令 1.1 zadd  zadd key [NX | XX] [GT | LT]   [CH] [INCR] score member [score member ...]

【机器学习】高斯过程的基本概念和应用领域以及在python中的实例

引言 高斯过程(Gaussian Process,简称GP)是一种概率模型,用于描述一组随机变量的联合概率分布,其中任何一个有限维度的子集都具有高斯分布 文章目录 引言一、高斯过程1.1 基本定义1.1.1 随机过程1.1.2 高斯分布 1.2 高斯过程的特性1.2.1 联合高斯性1.2.2 均值函数1.2.3 协方差函数(或核函数) 1.3 核函数1.4 高斯过程回归(Gauss

【学习笔记】 陈强-机器学习-Python-Ch15 人工神经网络(1)sklearn

系列文章目录 监督学习:参数方法 【学习笔记】 陈强-机器学习-Python-Ch4 线性回归 【学习笔记】 陈强-机器学习-Python-Ch5 逻辑回归 【课后题练习】 陈强-机器学习-Python-Ch5 逻辑回归(SAheart.csv) 【学习笔记】 陈强-机器学习-Python-Ch6 多项逻辑回归 【学习笔记 及 课后题练习】 陈强-机器学习-Python-Ch7 判别分析 【学

系统架构师考试学习笔记第三篇——架构设计高级知识(20)通信系统架构设计理论与实践

本章知识考点:         第20课时主要学习通信系统架构设计的理论和工作中的实践。根据新版考试大纲,本课时知识点会涉及案例分析题(25分),而在历年考试中,案例题对该部分内容的考查并不多,虽在综合知识选择题目中经常考查,但分值也不高。本课时内容侧重于对知识点的记忆和理解,按照以往的出题规律,通信系统架构设计基础知识点多来源于教材内的基础网络设备、网络架构和教材外最新时事热点技术。本课时知识

线性代数|机器学习-P36在图中找聚类

文章目录 1. 常见图结构2. 谱聚类 感觉后面几节课的内容跨越太大,需要补充太多的知识点,教授讲得内容跨越较大,一般一节课的内容是书本上的一章节内容,所以看视频比较吃力,需要先预习课本内容后才能够很好的理解教授讲解的知识点。 1. 常见图结构 假设我们有如下图结构: Adjacency Matrix:行和列表示的是节点的位置,A[i,j]表示的第 i 个节点和第 j 个