项目实战--Sa-Token详细方案笔记

Sa-Token权限系统设计

  • 一、前言
  • 二、认证授权的概念
  • 三、Sa-Token简介
    • 3.1 Sa-Token使用方式
    • 3.2 踢人下线
    • 3.3 全局异常处理
    • 3.4 二级认证
    • 3.5 同端互斥登录
    • 3.6 Http Basic/Digest 认证
      • 3.6.1 HttpBasic认证
      • 3.6.2 Http Digest 认证
  • 四、Sa-Token授权(鉴权)
    • 4.1 权限认证
      • 4.1.1 获取当前账号权限码集合
      • 4.1.2 权限校验
      • 4.1.3 角色校验
      • 4.1.4 拦截全局异常
      • 4.1.5 权限通配符
      • 4.1.6 把权限精确到按钮级
    • 4.2 注解鉴权
      • 4.2.1 注册拦截器
      • 4.2.2 使用注解鉴权
      • 4.2.3 设定校验模式
      • 4.2.4 角色权限双重 “or校验”
      • 4.2.5 忽略认证
      • 4.2.6 批量注解鉴权
    • 4.3路由拦截鉴权
      • 4.3.1 注册 Sa-Token 路由拦截器
      • 4.3.2 校验函数详解
      • 4.3.3 匹配特征详解
      • 4.3.4 提前退出匹配链
      • 4.3.5 使用free打开一个独立的作用域
      • 4.3.6 使用注解忽略掉路由拦截校验
      • 4.3.7 关闭注解校验
  • 5 Sa-Token 进阶
    • 5.1 Session会话
      • 5.1.1 Session模型结构图
      • 5.1.2 Account-Session
      • 5.1.3 Token-Session
      • 5.1.4 Custom-Session
      • 5.1.5 未登录场景下获取 Token-Session
    • 5.2 身份切换
    • 5.3 [记住我] 模式
    • 5.4 账号封禁
    • 5.5 密码加密
    • 5.6 全局侦听器
      • 5.6.1 工作原理
      • 5.6.2 自定义侦听器实现
      • 5.6.3 其它注意点
  • 六、微服务架构下安全认证
    • 6.1 集成Redis
    • 6.2 前后端分离(即无Cookie模式)
    • 6.3 内部服务外网隔离
      • 6.3.2 网关转发鉴权
      • 6.3.3 服务间内部调用鉴权
      • 6.3.4 Same-Token 模块详解
  • 七、单点登录(SSO)
    • 7.1 架构选型
    • 7.2 认证中心 SSO-Server
    • 7.3 模式一 :前端同域+后端同Redis
    • 7.4 模式二:前端不同域+后端同Redis
    • 7.5 模式三:前端不同域+后端不同Redis
      • 7.5.1 获取 UserInfo
      • 7.5.2 单点注销
      • 7.5.3 总结
    • 7.6 前后端分离架构下SSO
      • 7.6.1 SSO-Client后端
      • 7.6.2 SSO-Client前端
      • 7.6.3 SSO-Server后端
    • 7.7 Sa-Token-OAuth2.0 模块
      • 7.7.1 实战案例
      • 7.7.2 OAuth2开放接口详解

一、前言

由于JWT存在的一些问题,公司项目将认证授权的方案换成Sa-Token来实现。
简述一下JWT的问题:

1)注销登录等场景下 JWT 还有效:JWT一旦派发出去,若后端不增加其他逻辑的话,在失效之前都是有效的。由于JWT无状态,也导致最大的缺点是不可控。与之类似的具体相关场景有:退出登录;修改密码;服务端修改某个用户具有的权限或者角色;用户的帐户被封禁/删除;用户被服务端强制注销;用户被踢下线等。
(2)续签问题:JWT 通常有一个有效期(exp 字段),当令牌过期时,用户需要重新登录或获取一个新的令牌,即续签(refresh)问题。
(3JWT 体积太大:JWT 结构复杂(HeaderPayloadSignature),包含更多额外的信息,还需要进行Base64Url编码,这会使得JWT体积较大,增加网络传输的开销。

Sa-Token方案便是个优解。

二、认证授权的概念

安全认证两个基本概念认证(Authentication)授权(Authorization)。
认证就是根据用户名密码登录的过程。登录认证:指的就是服务器校验账号密码,为用户颁发 Token 会话凭证的过程,这个 Token 也是后续判断会话是否登录的关键。
在这里插入图片描述
权限认证:核心逻辑就是判断账号是否拥有指定权限:底层数据中,就是每个账号都会拥有一组权限码集合,框架来校验这个集合中是否包含指定的权限码。
例如:当前账号拥有权限码集合 [“user-add”, “user-delete”, “user-get”],这时候我来校验权限 “user-update”,则其结果就是:验证失败,禁止访问。
在这里插入图片描述

三、Sa-Token简介

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、分布式Session会话、微服务网关鉴权 等一系列权限相关问题。
Sa-Token 以简单、高效的方式完成系统的权限认证部分,以登录认证为例,只需要:

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

无需实现任何接口,无需创建任何配置文件,只需静态代码调用,便可完成会话登录认证。如果一个接口需要登录后才能访问,只需调用代码:

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

在 Sa-Token 中,大多数功能都可一行代码解决,如踢人下线:

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

权限认证:

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

路由拦截鉴权:

// 根据路由划分模块,不同模块不同鉴权 
registry.addInterceptor(new SaInterceptor(handler -> {SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));SaRouter.match("/notice/**", r -> StpUtil.checkPermission("notice"));// 更多模块... 
})).addPathPatterns("/**");

可见使用方式比Shiro、SpringSecurity 等框架的复杂配置,Sa-Token 的 API 设计是很简单高效的。
Sa-Token功能一览:
在这里插入图片描述
Sa-Token认证流程:
在这里插入图片描述

3.1 Sa-Token使用方式

spring boot整合sa-token,添加依赖:

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

配置文件:

# 端口
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

登录接口:

// 会话登录接口 
@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("登录失败");
}

登录过程Sa-Token背后实现的逻辑:

检查此账号是否之前已有登录;
为账号生成 Token 凭证与 Session 会话;
记录 Token 活跃时间;
通知全局侦听器,xx 账号登录成功;
将 Token 注入到请求上下文;
................

StpUtil.login(id) 方法利用 Cookie自动注入的特性,省略手写返回 token 的代码,自动向前端返回 token 信息。其他方法:

// 当前会话注销登录
StpUtil.logout();// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();/********************************登录账号查询******************************/
// 获取当前会话账号id, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.getLoginId();// 类似查询API还有:
StpUtil.getLoginIdAsString();    // 获取当前会话账号id, 并转化为`String`类型
StpUtil.getLoginIdAsInt();       // 获取当前会话账号id, 并转化为`int`类型
StpUtil.getLoginIdAsLong();      // 获取当前会话账号id, 并转化为`long`类型// ---------- 指定未登录情形下返回的默认值 ----------// 获取当前会话账号id, 如果未登录,则返回 null 
StpUtil.getLoginIdDefaultNull();// 获取当前会话账号id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
StpUtil.getLoginId(T defaultValue);
/********************************token 查询******************************/
// 获取当前会话的 token 值
StpUtil.getTokenValue();// 获取当前`StpLogic`的 token 名称
StpUtil.getTokenName();// 获取指定 token 对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);// 获取当前会话剩余有效期(单位:s,返回-1代表永久有效)
StpUtil.getTokenTimeout();// 获取当前会话的 token 信息参数
StpUtil.getTokenInfo();

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"   // 登录设备类型 },
}

测试案例:

/*** 登录测试 */
@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();}
}

3.2 踢人下线

核心操作是找到指定 loginId 对应的 Token,并设置其失效。
在这里插入图片描述
强制注销:

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

踢人下线:

StpUtil.kickout(10001);                    // 将指定账号踢下线 
StpUtil.kickout(10001, "PC");              // 将指定账号指定端踢下线
StpUtil.kickoutByTokenValue("token");      // 将指定 Token 踢下线

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

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

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

3.4 二级认证

在某些敏感操作下,需要对已登录的会话进行二次验证。
比如代码托管平台的仓库删除操作,尽管已经登录账号,当点击 [删除] 按钮时,还是需要再次输入一遍密码,这么做主要因为:保证操作者是当前账号本人以及增加操作步骤,防止误删除重要数据。
二级认证,即:在已登录会话的基础上,进行再次验证,提高会话的安全性。
使用以下API进行二级认证:

// 在当前会话 开启二级认证,时间为120秒
StpUtil.openSafe(120); // 获取:当前会话是否处于二级认证时间内
StpUtil.isSafe(); // 检查当前会话是否已通过二级认证,如未通过则抛出异常
StpUtil.checkSafe(); // 获取当前会话的二级认证剩余有效时间 (单位: 秒, 返回-2代表尚未通过二级认证)
StpUtil.getSafeTime(); // 在当前会话 结束二级认证
StpUtil.closeSafe();

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

// 删除仓库
@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("二级认证失败"); 
}

指定业务标识进行二级认证
如果项目有多条业务线都需要敏感操作验证,则 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");

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

// 二级认证:必须二级认证之后才能进入该方法 
@SaCheckSafe      
@RequestMapping("add")
public String add() {return "用户增加";
}// 指定业务类型,进行二级认证校验
@SaCheckSafe("art")
@RequestMapping("add2")
public String add2() {return "文章增加";
}

3.5 同端互斥登录

例如腾讯QQ的登录有如下特点:可以手机电脑同时在线,但是不能在两个手机上同时登录一个账号。同端互斥登录,指的就是:像腾讯QQ一样,在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线。
在这里插入图片描述
首先在配置文件中,将 isConcurrent 配置为false,然后调用登录等相关接口时声明设备类型即可。
指定设备类型登录:

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

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

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

若第二个参数填写null或不填,代表将这个账号id所有在线端强制注销,被踢出者再次访问系统时会抛出 NotLoginException 异常,场景值=-2
查询当前登录的设备类型:

// 返回当前token的登录设备类型
StpUtil.getLoginDevice();

Id 反查 Token:

// 获取指定loginId指定设备类型端的tokenValue 
StpUtil.getTokenValueByLoginId(10001, "APP");

3.6 Http Basic/Digest 认证

3.6.1 HttpBasic认证

Http Basic 是 http 协议中最基础的认证方式,其有两个特点:简单、易集成。功能支持度低。
启用 Http Basic 认证:

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

访问这个接口时,浏览器会强制弹出一个表单:
在这里插入图片描述
当输入账号密码后 (sa / 123456),才可以继续访问数据:
在这里插入图片描述
其它启用方式:

// 对当前会话进行 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"));});
}

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

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

3.6.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

四、Sa-Token授权(鉴权)

4.1 权限认证

核心逻辑就是判断一个账号是否拥有指定权限:有,就允许通过。没有?则禁止访问。
问题的核心就是两个:

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

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:账号体系标识,此处可以暂时忽略,在 [ 多账户认证 ] 章节下会对这个概念做详细的解释。

程序启动时不会执行这个接口的方法,在每次调用鉴权代码时,才会执行到此。

4.1.2 权限校验

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

PS: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");

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

4.1.4 拦截全局异常

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

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

4.1.5 权限通配符

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

// 当拥有 art.* 权限时
StpUtil.hasPermission("art.add");        // true
StpUtil.hasPermission("art.update");     // true
StpUtil.hasPermission("goods.add");      // false// 当拥有 *.delete 权限时
StpUtil.hasPermission("art.delete");      // true
StpUtil.hasPermission("user.delete");     // true
StpUtil.hasPermission("user.update");     // false// 当拥有 *.js 权限时
StpUtil.hasPermission("index.js");        // true
StpUtil.hasPermission("index.css");       // false
StpUtil.hasPermission("index.html");      // false

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

4.1.6 把权限精确到按钮级

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

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

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

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 "查询用户信息";
}

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

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(); 
}

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

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

4.3路由拦截鉴权

假设需求: 项目中所有接口均需要登录认证,只有 “登录接口” 本身对外开放 。
肯定不能给每个接口加上鉴权注解或者手写全局拦截器,需要的是一种基于路由拦截的鉴权模式。

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("要返回到前端的内容");
stop()back() 函数的区别在于:
SaRouter.stop() 会停止匹配,进入ControllerSaRouter.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 的方法中。
PS:注解 @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("/**");
}

PS:梳理清楚一个问题,既然拦截器已经可以实现路由鉴权,为什么还要用过滤器再实现一遍。

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

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

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

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

5 Sa-Token 进阶

5.1 Session会话

Session 是数据缓存组件,通过 Session 可以很方便的缓存一些高频读写数据,提高程序性能。
Sa-Token Session可理解为 HttpSession 的升级:

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

5.1.1 Session模型结构图

三种Session创建时机:

  • Account-Session: 指的是框架为每个 账号id 分配的 SessionT
  • oken-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

数据隔离的场景:指定客户端超过两小时无操作就自动下线,如果两小时内有操作,就再续期两小时,直到新的两小时无操作。
登录同一账号不仅为账号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 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); 

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

默认场景下,只有登录后才能通过 StpUtil.getTokenSession() 获取 Token-Session。如果想要在未登录场景下获取 Token-Session ,有两种方法:
方法一:将全局配置项 tokenSessionCheckLogin 改为 false,详见:框架配置
方法二:使用匿名 Token-Session

// 获取当前 Token 的匿名 Token-Session (可在未登录情况下使用的 Token-Session)
StpUtil.getAnonTokenSession();

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

5.2 身份切换

以上介绍的 API 都是操作当前账号,对当前账号进行各种鉴权操作,若对别的账号进行一些操作,比如:查看账号 10001 有无某个权限码、获取 账号 id=10002 的 Account-Session等。
有关操作其它账号的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");

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

// 将当前会话[身份临时切换]为其它账号(本次请求内有效)
StpUtil.switchTo(10044);// 此时再调用此方法会返回 10044 (我们临时切换到的账号id)
StpUtil.getLoginId();// 结束 [身份临时切换]
StpUtil.endSwitch();

或者:直接在一个代码段里方法内,临时切换身份为指定loginId

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

5.3 [记住我] 模式

登录界面有一个 [记住我] 按钮,当勾选它登录后,即使关闭浏览器再次打开网站,也依然会处于登录状态,无须重复验证密码:
在这里插入图片描述
在 Sa-Token 中实现记住我功能:
Sa-Token的登录授权,默认就是[记住我]模式,为实现[非记住我]模式,需要在登录时如下设置:

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

实现原理:

Cookie作为浏览器提供的默认会话跟踪机制,其生命周期有两种形式,分别是:
临时Cookie:有效期为本次会话,只要关闭浏览器窗口,Cookie就会消失。
持久Cookie:有效期为一个具体的时间,在时间未到期之前,即使用户关闭了浏览器Cookie也不会消失。
利用Cookie的此特性,可以轻松实现 [记住我] 模式:
勾选 [记住我] 按钮时:调用StpUtil.login(10001, true),在浏览器写入一个持久Cookie储存 Token,此时用户即使重启浏览器 Token 依然有效。
不勾选 [记住我] 按钮时:调用StpUtil.login(10001, false),在浏览器写入一个临时Cookie储存 Token,此时用户在重启浏览器后 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");

登录时指定 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 写入到响应头);

5.4 账号封禁

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

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

参数含义:

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

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

// 先踢下线
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天
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 天,第二次封禁 7 天,第三次封禁 30 天,依次顺延……
处罚力度阶梯:首次违规消息提醒、第二次禁言禁评论、第三次禁止账号登录,等等……

将其量化为一级封禁、二级封禁、三级封禁 3个等级,数字越大代表封禁力度越高。

// 阶梯封禁,参数:封禁账号、封禁级别、封禁时间 
StpUtil.disableLevel(10001, 3, 10000);// 获取:指定账号封禁的级别 (如果此账号未被封禁则返回 -2)
StpUtil.getDisableLevel(10001);// 判断:指定账号是否已被封禁到指定级别,返回 true 或 false
StpUtil.isDisableLevel(10001, 3);// 校验:指定账号是否已被封禁到指定级别,如果已达到此级别(例如已被3级封禁,这里校验是否达到2级),则抛出异常 `DisableServiceException`
StpUtil.checkDisableLevel(10001, 2);

PS: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);

使用注解完成封禁校验:

// 校验当前账号是否被封禁,如果已被封禁会抛出异常,无法进入方法 
@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(); 
}

5.5 密码加密

摘要加密:md5、sha1、sha256// md5加密

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

对称加密:AES加密

// 定义秘钥和明文
String key = "123456";
String text = "Sa-Token 一个轻量级java权限认证框架";// 加密 
String ciphertext = SaSecureUtil.aesEncrypt(key, text);
System.out.println("AES加密后:" + ciphertext);// 解密 
String text2 = SaSecureUtil.aesDecrypt(key, ciphertext);
System.out.println("AES解密后:" + text2);

非对称加密:RSA加密

// 定义私钥和公钥 
String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAO+wmt01pwm9lHMdq7A8gkEigk0XKMfjv+4IjAFhWCSiTeP7dtlnceFJbkWxvbc7Qo3fCOpwmfcskwUc3VSgyiJkNJDs9ivPbvlt8IU2bZ+PBDxYxSCJFrgouVOpAr8ar/b6gNuYTi1vt3FkGtSjACFb002/68RKUTye8/tdcVilAgMBAAECgYA1COmrSqTUJeuD8Su9ChZ0HROhxR8T45PjMmbwIz7ilDsR1+E7R4VOKPZKW4Kz2VvnklMhtJqMs4MwXWunvxAaUFzQTTg2Fu/WU8Y9ha14OaWZABfChMZlpkmpJW9arKmI22ZuxCEsFGxghTiJQ3tK8npj5IZq5vk+6mFHQ6aJAQJBAPghz91Dpuj+0bOUfOUmzi22obWCBncAD/0CqCLnJlpfOoa9bOcXSusGuSPuKy5KiGyblHMgKI6bq7gcM2DWrGUCQQD3SkOcmia2s/6i7DUEzMKaB0bkkX4Ela/xrfV+A3GzTPv9bIBamu0VIHznuiZbeNeyw7sVo4/GTItq/zn2QJdBAkEA8xHsVoyXTVeShaDIWJKTFyT5dJ1TR++/udKIcuiNIap34tZdgGPI+EM1yoTduBM7YWlnGwA9urW0mj7F9e9WIQJAFjxqSfmeg40512KP/ed/lCQVXtYqU7U2BfBTg8pBfhLtEcOg4wTNTroGITwe2NjL5HovJ2n2sqkNXEio6Ji0QQJAFLW1Kt80qypMqot+mHhS+0KfdOpaKeMWMSR4Ij5VfE63WzETEeWAMQESxzhavN1WOTb3/p6icgcVbgPQBaWhGg==";
String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDvsJrdNacJvZRzHauwPIJBIoJNFyjH47/uCIwBYVgkok3j+3bZZ3HhSW5Fsb23O0KN3wjqcJn3LJMFHN1UoMoiZDSQ7PYrz275bfCFNm2fjwQ8WMUgiRa4KLlTqQK/Gq/2+oDbmE4tb7dxZBrUowAhW9NNv+vESlE8nvP7XXFYpQIDAQAB";// 文本
String text = "Sa-Token 一个轻量级java权限认证框架";// 使用公钥加密
String ciphertext = SaSecureUtil.rsaEncryptByPublic(publicKey, text);
System.out.println("公钥加密后:" + ciphertext);// 使用私钥解密
String text2 = SaSecureUtil.rsaDecryptByPrivate(privateKey, ciphertext);
System.out.println("私钥解密后:" + text2);

生成私钥和公钥:

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

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);

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);

5.6 全局侦听器

5.6.1 工作原理

Sa-Token 提供一种侦听器机制,通过注册侦听器,可以订阅框架的一些关键性事件,例如:用户登录、退出、被踢下线等。事件触发流程大致如下:
在这里插入图片描述
框架默认内置侦听器 SaTokenListenerForLog 实现 ,功能是控制台 log 打印输出,可以通过配置sa-token.is-log=true开启。
也可注册自定义的侦听器:(1)新建类实现 SaTokenListener 接口。(2)将实现类注册到 SaTokenEventCenter 事件发布中心。

5.6.2 自定义侦听器实现

/*** 自定义侦听器的实现 */
@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");}
}

若没有添加 @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);

启动测试:

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

访问登录接口,观察控制台输出:
在这里插入图片描述

5.6.3 其它注意点

继承SaTokenListenerForSimple快速实现一个侦听器:

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

使用匿名内部类的方式注册:

// 登录时触发 
SaTokenEventCenter.registerListener(new SaTokenListenerForSimple() {@Overridepublic void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {System.out.println("---------------- doLogin");}
});

使用 try-catch 包裹不安全的代码:

// 登录时触发 
SaTokenEventCenter.registerListener(new SaTokenListenerForSimple() {@Overridepublic void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {try {// 不安全代码需要写在 try-catch 里 // ......  } catch (Exception e) {e.printStackTrace();}}
});

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

六、微服务架构下安全认证

6.1 集成Redis

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

		<!-- 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>

配置文件:

# 端口
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

6.2 前后端分离(即无Cookie模式)

后端将 token 返回到前端:

// 登录接口
@RequestMapping("doLogin")
public SaResult doLogin() {// 第1步,先登录上 StpUtil.login(10001);// 第2步,获取 Token  相关参数 SaTokenInfo tokenInfo = StpUtil.getTokenInfo();// 第3步,返回给前端 return SaResult.data(tokenInfo);
}

前端将 token 提交到后端:

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

后端尝试从header中读取token:

# 是否尝试从header里读取token
#    is-read-header: true
#    # 是否尝试从cookie里读取token
#    is-read-cookie: true

6.3 内部服务外网隔离

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

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

6.3.2 网关转发鉴权

引入依赖:

<!-- 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>

网关处添加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 参数,这个参数会被转发到子服务。
在子服务里校验参数:

/*** 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.3.3 服务间内部调用鉴权

需要在一个服务调用另一个服务的接口,这也是需要添加Same-Token作为身份凭证的 在服务里添加 Same-Token 流程与网关类似,以RPC框架 Feign 为例:
首先在调用方添加 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());}
}

在调用接口里使用此 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.3.4 Same-Token 模块详解

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

  • 1.服务调用方获取Token,
  • 2.提交到请求中,被调用方取出Token进行校验,
  • 3.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;

Same-Token 默认随 Sa-Token 数据一起保存在Redis中,理论上不会存在泄露的风险,每个Token默认有效期只有一天。
Same-Token 刷新间隔越短,其安全性越高,每个Token的默认有效期为一天,在一天后再次获取会自动产生一个新的Token。

PS: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();}
}

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

七、单点登录(SSO)

7.1 架构选型

Sa-Token-SSO 由简入难划分为三种模式,解决不同架构下的 SSO 接入问题:
在这里插入图片描述

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

7.2 认证中心 SSO-Server

在开始SSO三种模式的对接之前,必须先搭建一个 SSO-Server 认证中心
创建 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-starter 和 sa-token-sso 以外,其它包都是可选的:
在 SSO 模式三时 Redis 相关包是可选的
在前后端分离模式下可以删除 thymeleaf 相关包
在不需要 SSO 模式三单点注销的情况下可以删除 http 工具包

开放认证接口:
新建 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());}
}

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

ps: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
创建启动类:

@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 是否部署成功,暂时还不需要点击登录):
在这里插入图片描述
页面目前非常简陋,可下载运行一下官方仓库里的示例/sa-token-demo/sa-token-demo-sso/sa-token-demo-sso-server/,里面有制作好的登录页面:
在这里插入图片描述
默认账号密码为:sa / 123456,先别点击登录,因为还没有搭建对应的 Client 端项目, 真实项目中是不会直接从浏览器访问 /sso/auth 授权地址的,需要在 Client 端点击登录按钮重定向而来。

7.3 模式一 :前端同域+后端同Redis

思路:
使用 共享Cookie 来解决 Token 共享问题;使用 Redis 来解决 Session 共享问题。
如果 SSO-Server 端和 SSO-Client 端都使用 Sa-Token-SSO 搭建,那么client可以调用默认的server ,如果仅在 SSO-Server 端使用 Sa-Token-SSO 搭建,而 SSO-Client 端使用其它框架的话,那就需要手动调用 http 请求来对接 SSO-Server 认证中心。
在这里插入图片描述
修改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 端。
指定Cookie的作用域:
在sso.stp.com访问服务器,其Cookie也只能写入到sso.stp.com下,为将Cookie写入到其父级域名stp.com下,需要更改 SSO-Server 端的 yml 配置:

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

引入依赖:

<!-- 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>

新建 Controller 控制器:

/*** Sa-Token-SSO Client端 Controller */
@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());}
}

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

启动类:

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

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

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

在这里插入图片描述
然后点击登录,被重定向至SSO认证中心:
在这里插入图片描述
我们点击登录,然后刷新页面:
在这里插入图片描述
刷新另外两个Client端,均显示已登录:
在这里插入图片描述
测试完成。

7.4 模式二:前端不同域+后端同Redis

在这里插入图片描述
首先修改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

在SSO模式一中打开的配置:

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

此为模式一专属配置,现在必须将其注释掉
创建 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>

创建 SSO-Client 端认证接口:

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

在 application.yml 配置SSO认证中心地址:

# 端口
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

PS:sa-token.alone-redis 的配置需要和SSO-Server端连接同一个Redis,即database 值也要一样
写启动类:

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

依次启动 SSO-Server 与 SSO-Client,然后从浏览器访问:http://sa-sso-client1.com:9001/
在这里插入图片描述
点击 登录 按钮,页面会被重定向到登录中心,SSO-Server提示我们在认证中心尚未登录,点击 doLogin登录 按钮进行模拟登录
在这里插入图片描述 SSO-Server认证中心登录成功,回到刚才的页面刷新页面
在这里插入图片描述
页面被重定向至Client端首页,并提示登录成功,至此,Client1应用已单点登录成功,再次访问Client2:http://sa-sso-client2.com:9001/和Client3:http://sa-sso-client3.com:9001/ 无需再次认证。

7.5 模式三:前端不同域+后端不同Redis

属于模式二的一个特殊场景。
在Client 端更改 Ticket 校验方式,application.yml 新增配置:

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/

7.5.1 获取 UserInfo

除账号id,可能还需将用户的昵称、头像等信息从 Server端 带到 Client端,即:用户资料的拉取。在模式二中只需要将需要同步的资料放到 SaSession 即可,但是在模式三中两端不再连接同一个 Redis,这就需要通过 http 接口来同步信息。
首先在 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());

在 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;
}

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

7.5.2 单点注销

在这里插入图片描述

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

模式二需要各个 sso-client 和 sso-server 连接同一个 redis,即使登录再多的 client,本质上对应的仍是同一个会话,因此可以做到任意一处调用注销,全端一起下线的效果。而如果各个 client 架构各不相同,有的是模式二对接,有的是模式三对接,则需要麻烦一点才能做到单点注销。
增加 pom.xml 配置:

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

SSO-Client 端新增配置:API调用秘钥,application.yml 增加

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

PS: secretkey 秘钥需要与SSO认证中心的一致
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;
};
}

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

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

在任意一个 client 里,点击 [注销] 按钮,即可单点注销成功。

7.5.3 总结

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

7.6 前后端分离架构下SSO

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

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

7.6.1 SSO-Client后端

新建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());}}

增加跨域过滤器CorsFilter.java
配置统一认证地址是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.6.2 SSO-Client前端

新建前端项目: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>

添加登录处理文件sso-login.html
可以在nginx或者tomcat中部署SSO-Client前端代码,端口8848

7.6.3 SSO-Server后端

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

可以在nginx或者tomcat中部署SSO-Server前端,端口8849
在这里插入图片描述

7.7 Sa-Token-OAuth2.0 模块

OAuth2.0的应用场景可以理解为单点登录的升级版,单点登录解决多个系统间会话的共享,OAuth2.0在此基础上增加应用之间的权限控制.
OAuth2.0设计了四种模式:

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

在这里插入图片描述

7.7.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

创建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>

新建 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__";}}

新建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 端启动成功");}
}

授权码模式访问测试:
由于暂未搭建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

首次访问,在OAuth-Server端暂未登录,会被转发到登录视图,点击doLogin进行登录之后刷新页面,会提示确认授权
在这里插入图片描述
点击确认授权之后刷新页面,会被重定向至 redirect_uri 页面,并携带code参数
在这里插入图片描述
拿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-Token、Refresh-Token、openid等授权信息
在这里插入图片描述
依次启动OAuth2-Server 与 OAuth2-Client,然后从浏览器访问:http://sa-oauth-client.com:8002
在这里插入图片描述
如图,可以针对OAuth2.0四种模式进行详细测试

7.7.2 OAuth2开放接口详解

模式一:授权码(Authorization Code)
根据以下格式构建URL,引导用户访问

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

模式二:隐藏式(Implicit)根据以下格式构建URL,引导用户访问:

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

在这里插入图片描述
此模式会越过授权码的步骤,直接返回Access-Token到前端页面,形如:

redirect_uri#token=xxxx-xxxx-xxxx-xxxx

模式三:密码式(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}

在这里插入图片描述
接口返回示例:

{"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 }
}

模式四:凭证式(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}

在这里插入图片描述
接口返回值样例:

{"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交替造成的授权失效”, 保证服务的高可用。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/413207.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

详说 类和对象

类怎么定义 类是什么呢&#xff1f;类就是我们上篇文说的命名空间&#xff0c;单独创建一个域&#xff0c;自己有自己的生命空间&#xff0c;那么类怎么定义呢&#xff1f;C规定&#xff0c;假设 stack就是他的类名&#xff0c;那么前面要加个class&#xff0c;换行之后就是他…

汽车乘客热舒适度大挑战,如何利用仿真技术提高汽车环境舒适度

舒适性在人们选择汽车的决定性方面占比越来越重&#xff0c;而汽车乘员舱环境的舒适性是指为乘员提供舒适愉快便利的乘坐环境与条件&#xff0c;包括良好的平顺性、车内的低噪声、适宜的空气环境以及良好的驾驶操作性能。 舒适性 经济性 安全性、动力性 典型的乘员舱热舒适性模…

laravel的队列的使用

laravel队列 laravel的特性&#xff1a;laravel队列可以基于不同的后台存储服务提供统一的api&#xff0c;后台存储服务包括 Redis MySQL等。队列实现了业务解耦&#xff0c;异步处理&#xff0c;错误重试的功能。比如调用第三方api&#xff0c;无法保证api的可靠性&#xff0…

Transformer 与传统模型Informer

Transformer 与传统模型:Informer 如何改变时间序列预测的规则 Transformers 是那些聪明的注意力构建者,它们在机器学习的各个领域掀起了波澜。但在时间序列预测领域,它们才真正大显身手。你可能会问,为什么?想象一下,有一个水晶球,它不仅能看到未来,还能理解导致未来的…

TCP协议 配合 Wireshark 分析数据

在TCP连接中&#xff0c;无论是客户端还是服务端&#xff0c;都有可能成为发送端或接收端&#xff0c;这是因为TCP是一个全双工协议&#xff0c;允许数据在同一连接中双向流动 客户端&#xff08;Client&#xff09;&#xff1a;通常是指主动发起连接请求的一方。例如&#xf…

宠物空气净化器有用吗?为什么养宠家庭要买宠物空气净化器?

身为一个鼻炎患者&#xff0c;却喜欢猫咪&#xff0c;所以毅然决然的养了两只宠物&#xff0c;而且还是长毛猫&#xff0c;不要问为什么鼻炎还买两只猫咪&#xff0c;因为怕一只猫咪孤单&#xff0c;所以养了两只。对于很多人来说&#xff0c;猫咪就像焦虑不安时的精神搭子&…

如何让私域服务赢得用户的心?

私域流量的概念在当今的商业环境中已经变得极为重要&#xff0c;许多品牌和企业都投入大量资源尝试通过各种策略吸引并保留用户。然而&#xff0c;单纯的流量积累并不足以确保商业成功。当面对用户的沉默、缺乏活跃度以及无法变现的困境时&#xff0c;我们必须重新审视私域流量…

语音控制开关的语音识别ic芯片方案

语音控制开关是一种基于语音识别技术的设备&#xff0c;它通过内置的语音识别芯片&#xff0c;将用户的语音指令转化为电信号&#xff0c;从而实现对设备的控制。例如在智能家居设备上的应用&#xff0c;通常需要连接到家庭的Wi-Fi网络上&#xff0c;以便与智能手机或智能音箱等…

Java之初始泛型

1 包装类 在Java中&#xff0c;由于基本类型不是继承自Object&#xff0c;为了在泛型代码中可以支持基本类型&#xff0c;Java给每个基本类型都对应了一个包装类型。 1.1 基本数据类型和对应的包装类 基本数据类型包装类byteByteshortShortintIntegerlongLongfloatFloatdoub…

FaceFormer嘴形同步论文复现

一、项目地址 https://github.com/EvelynFan/FaceFormer 二、复现过程 1、项目环境 系统&#xff1a;Ubuntu 18.04.1 python版本&#xff1a;Python 3.7 使用conda创建一个虚拟环境&#xff0c;安装requirements.txt中所需要的库 2、安装ffmpeg 教程网址&#xff1a;http…

8个Python编程进阶常用技巧!

介绍 Python 炫酷功能&#xff08;例如&#xff0c;变量解包&#xff0c;偏函数&#xff0c;枚举可迭代对象等&#xff09;的文章层出不穷。但是还有很多 Python 的编程小技巧鲜被提及。因此&#xff0c;本文会试着介绍一些其它文章没有提到的小技巧&#xff0c;这些小技巧也是…

Selenium+Python自动化测试环境搭建

1. 什么是Selenium&#xff1f; Selenium主要用于web应用程序的自动化测试&#xff0c;但并不局限于此&#xff0c;它还支持所有基于web的管理任务自动化。 2、selenium 自动化流程如下&#xff1a; 自动化程序调用Selenium 客户端库函数&#xff08;比如点击按钮元素&#xff…

【计算机组成原理】六、总线:3.操作和定时

5.操作和定时 文章目录 5.操作和定时5.1总线传输的四个阶段5.2总线定时5.2.1同步通信5.2.2异步通信5.2.3半同步通信5.2.4分离式通信 2.3按时序控制方式 同步总线异步总线 5.1总线传输的四个阶段 总线周期&#xff1a; 申请分配阶段&#xff1a;由需要使用总线的主模块&#…

计算机组成原理:实验一运算器组成实验

一、实验目的 1.掌握算术逻辑运算加、减、乘、与的工作原理。 2.熟悉简单运算器的数据传送通路。 3.验证实验台运算器的8位加、减、与、直通功能。 4.验证实验台的4位乘4位功能。 5.按给定数据&#xff0c;完成几种指定的算术和逻辑运算。 二、实验电路 图1.1 运算器数据…

约瑟夫环和一元多项式

约瑟夫环 一、问题描述 假设有 n 个人围成一圈&#xff0c;从第一个人开始报数&#xff0c;报数到 m 的人将被淘汰出圈&#xff0c;然后从下一个人开始继续从 1 报数&#xff0c;如此重复&#xff0c;直到最后只剩下一个人。求最后剩下的这个人的编号。 二、问题分析 可…

DDR3详解

1.DDR3简介 DDR3 SDRAM&#xff0c;全称第三代双倍速率同步动态随机存取存储器&#xff0c;简称 DDR3&#xff0c;双倍速率&#xff08;double-data-rate&#xff09;&#xff0c;是指时钟的上升沿和下降沿都发生数据传输&#xff1b;同步&#xff0c;是指DDR3数据的读取写入是…

使用 nuxi build-module 命令构建 Nuxt 模块

title: 使用 nuxi build-module 命令构建 Nuxt 模块 date: 2024/8/31 updated: 2024/8/31 author: cmdragon excerpt: nuxi build-module 命令是构建 Nuxt 模块的核心工具,它将你的模块打包成适合生产环境的格式。通过使用 --stub 选项,你可以在开发过程中加快模块构建速度…

linux Vim的安装和基本使用

Vim 什么是 Vim Vim是一个高度可定制的文本编辑器&#xff0c;源自Unix系统的vi编辑器。它被广泛用于类Unix系统中&#xff0c;包括Linux、Mac OS和Windows平台。Vim特别受到程序员的青睐&#xff0c;因为它提供了丰富的编程功能&#xff0c;如代码补全、编译及错误跳转等。这…

Kubernetes 上安装 Jenkins

安装 Helm curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash添加 Jenkins Helm 仓库 首先添加 Jenkins Helm 仓库 helm repo add jenkins https://charts.jenkins.io helm repo update安装 Jenkins 使用 Helm 安装 Jenkins 的最新版本&…

产品经理角度分析:朋友圈点赞与评论仅共同好友可见

你有没有在刷朋友圈时&#xff0c;看到某位朋友发了条状态&#xff0c;下面一堆点赞和评论&#xff0c;然后他自己来个“统一回复下&#xff0c;感谢大家”&#xff1f; 这种现象就像是在朋友圈里开了个小型新闻发布会&#xff0c;大家在台下疯狂举手&#xff0c;结果发言人最后…