1.简介
Sa-Token是一个轻量级Java权限认证框架,是国产开源框架,引入非常简单,丰富的自定义扩展,主要解决:登陆认证、权限认证、单点登陆、Oauth2.0、分布式Session会话、微服务网关鉴权等一系列权限相关问题。更多的功能点可以结合官网:https://sa-token.cc/doc.html#/进行学习,这里主要对部分功能点进行分析说明。
集成到spring boot非常简单,集成步骤为:
(1)pom.xml引入依赖
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.34.0</version>
</dependency>
(2)application.yml配置文件
server:
port: 8090
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token名称 (同时也是cookie名称)
token-name: satoken
# token有效期,单位s 默认30天, -1代表永不过期
timeout: 2592000
# token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
activity-timeout: -1
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: true
# token风格
token-style: uuid
# 是否输出操作日志
is-log: false
2.登陆流程分析
登陆信息验证通过后,会话登陆特别简单,一行代码解决:StpUtil.login(userId),具体来看下此登陆方法做的处理。StpUtil是一个工具类,提供了一个登陆的方法login,看源码①:
//StpUtil类里创建了一个StpLogin类
public static StpLogic stpLogic = new StpLogic("login");
//登陆方法
public static void login(Object id) {
//调用StpLogin类的login方法
stpLogic.login(id);
}
从源码可以看出StpUtil类里创建了一个StpLogin类,login方法最终调用的是StpLogic类的login方法,看StpLogic类的login源码②:
public void login(Object id) {
//调用类里的login方法,并且创建一个用于记录登陆相关配置信息的类SaLoginModel
this.login(id, new SaLoginModel());
}
从源码可以看出调用类里的login方法,并且创建一个用于记录登陆相关配置信息的类SaLoginModel,看下this.login(id, new SaLoginModel())源码③:
public void login(Object id, SaLoginModel loginModel) {
//创建登陆的Session并且获取token值
String token = this.createLoginSession(id, loginModel);
//把token信息写入HttpServletRequest、HttpServletResponse中
this.setTokenValue(token, loginModel);
}
从源码中可以看出处理功能为:创建登陆的Session并且获取token值、把token信息写入HttpServletRequest、HttpServletResponse中。看下this.createLoginSession(id, loginModel)的源码④:
public String createLoginSession(Object id, SaLoginModel loginModel) {
SaTokenException.throwByNull(id, "账号id不能为空", 11002);
//获取yml或者properties中配置的信息
SaTokenConfig config = this.getConfig();
//使用SaLoginModel接收config的timeout、isWriteHeader字段值
loginModel.build(config);
//获取token值
String tokenValue = this.distUsableToken(id, loginModel);
//创建session
SaSession session = this.getSessionByLoginId(id, true);
//设置session属性
session.updateMinTimeout(loginModel.getTimeout());
session.addTokenSign(tokenValue, loginModel.getDeviceOrDefault());
//把token信息保存到dataMap中
this.saveTokenToIdMapping(tokenValue, id, loginModel.getTimeout());
this.setLastActivityToNow(tokenValue);
//向事件中心添加事件监听
SaTokenEventCenter.doLogin(this.loginType, id, tokenValue, loginModel);
if (config.getMaxLoginCount() != -1) {
this.logoutByMaxLoginCount(id, session, (String)null, config.getMaxLoginCount());
}
return tokenValue;
}
从源码可以看出处理的功能为:根据yml或者properties配置信息创建token值,创建Session,把token信息保存到SaTokenDao的一个Map集合里面。看一下获取token方法this.distUsableToken的源码⑤:
protected String distUsableToken(Object id, SaLoginModel loginModel) {
//获取配置变量中是否并发登陆
Boolean isConcurrent = this.getConfig().getIsConcurrent();
if (!isConcurrent) {
this.replaced(id, loginModel.getDevice());
}
if (SaFoxUtil.isNotEmpty(loginModel.getToken())) {
return loginModel.getToken();
} else {
//多人登陆相同账号时,是否共享token
if (isConcurrent && this.getConfigOfIsShare()) {
String tokenValue = this.getTokenValueByLoginId(id, loginModel.getDeviceOrDefault());
if (SaFoxUtil.isNotEmpty(tokenValue)) {
return tokenValue;
}
}
//创建token值
return this.createTokenValue(id, loginModel.getDeviceOrDefault(), loginModel.getTimeout(), loginModel.getExtraData());
}
}
从源码可以看出处理的功能为:获取是否可以并发登陆、多人登陆相同账号时,是否共享token,然后再创建token。看一下生成token的方法createTokenValue源码⑥:
public String createTokenValue(Object loginId, String device, long timeout, Map<String, Object> extraData) {
//传递参数生成token值,createToken是一个函数
return (String)SaStrategy.me.createToken.apply(loginId, this.loginType);
}
从源码可以看出处理的功能为:调用createToken函数,传递参数,生成token。看一下生成token的函数createToken源码⑦:
public BiFunction<Object, String, String> createToken = (loginId, loginType) -> {
//获取配置变量中指定的token生成方式sa-token.token-style
String tokenStyle = SaManager.getConfig().getTokenStyle();
if ("uuid".equals(tokenStyle)) {
//使用uuid的方式
return UUID.randomUUID().toString();
} else if ("simple-uuid".equals(tokenStyle)) {
//uuid去掉中杠的字符串
return UUID.randomUUID().toString().replaceAll("-", "");
} else if ("random-32".equals(tokenStyle)) {
//生成32位长度的随机字符串
return SaFoxUtil.getRandomString(32);
} else if ("random-64".equals(tokenStyle)) {
return SaFoxUtil.getRandomString(64);
} else if ("random-128".equals(tokenStyle)) {
return SaFoxUtil.getRandomString(128);
} else {
return "tik".equals(tokenStyle) ? SaFoxUtil.getRandomString(2) + "_" + SaFoxUtil.getRandomString(14) + "_" + SaFoxUtil.getRandomString(16) + "__" : UUID.randomUUID().toString();
}
};
//根据指定长度从数组字母组成的字符串中生成一个随机组合的新字符串
public static String getRandomString(int length) {
String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
StringBuilder sb = new StringBuilder();
for(int i = 0; i < length; ++i) {
int number = ThreadLocalRandom.current().nextInt(62);
sb.append(str.charAt(number));
}
return sb.toString();
}
从源码可以看出处理的功能为:createToken是一个函数,根据配置的token生成方式sa-token.token-style,进行对应token的生成,到此token的生成结束。
生成的token存放到Map集合中,看下源码④处的this.saveTokenToIdMapping方法源码⑧:
public void saveTokenToIdMapping(String tokenValue, Object loginId, long timeout) {
//保存token到map集合中,this.getSaTokenDao()是一个SaTokenDao的接口,只有一个默认实现类SaTokenDaoDefaultImpl,调用它的保存方法
this.getSaTokenDao().set(this.splicingKeyTokenValue(tokenValue), String.valueOf(loginId), timeout);
}
//this.getSaTokenDao()方法,获取SatokenDao
public SaTokenDao getSaTokenDao() {
return SaManager.getSaTokenDao();
}
//SaManager.getSaTokenDao()的方法,获取SatokenDao
public static SaTokenDao getSaTokenDao() {
if (saTokenDao == null) {
Class var0 = SaManager.class;
synchronized(SaManager.class) {
if (saTokenDao == null) {
//SaTokenDaoDefaultImpl是SaTokenDao接口的唯一实现类
setSaTokenDaoMethod(new SaTokenDaoDefaultImpl());
}
}
}
return saTokenDao;
}
//this.splicingKeyTokenValue方法,生成把token值存放到map中的key值
public String splicingKeyTokenValue(String tokenValue) {
//this.getConfig().getTokenName():sa-token.token-name配置的值
return this.getConfig().getTokenName() + ":" + this.loginType + ":token:" + tokenValue;
}
从源码可以看出处理的功能为:从管理器中获取SatokenDao接口类,SaTokenDaoDefaultImpl是SaTokenDao接口的唯一实现类,获取到的最终类型为SaTokenDaoDefaultImpl类,token需要存放到Map集合中,调用方法splicingKeyTokenValue生成map的key。看下SaTokenDaoDefaultImpl存储token信息的源码⑨:
public class SaTokenDaoDefaultImpl implements SaTokenDao {
//记录值的map
public Map<String, Object> dataMap = new ConcurrentHashMap();
//记录过期时间的map
public Map<String, Long> expireMap = new ConcurrentHashMap();
//根据key获取值的方法
public String get(String key) {
//校验此key是否已经过期,过期则删除
this.clearKeyByTimeout(key);
//返回值
return (String)this.dataMap.get(key);
}
//把值设置到map中
public void set(String key, String value, long timeout) {
//判断过期时间
if (timeout != 0L && timeout > -2L) {
//值设置到Map中
this.dataMap.put(key, value);
//记录此key的过期时间:当前时间加上设置的过期时间
this.expireMap.put(key, timeout == -1L ? -1L : System.currentTimeMillis() + timeout * 1000L);
}
}
//根据key判断是否过期,过期则从map中把值删除
void clearKeyByTimeout(String key) {
//获取到key对应的过期时间
Long expirationTime = (Long)this.expireMap.get(key);
//过期时间不为空,且不等于-1,并且过期时间要小于当前系统时间,则说明此key过期,删除对应的map值
if (expirationTime != null && expirationTime != -1L && expirationTime < System.currentTimeMillis()) {
this.dataMap.remove(key);
this.expireMap.remove(key);
}
}
}
从源码可以看出处理的功能为:数据都是用map进行存储的,一个dataMap存储具体值,一个expireMap存储此key的过期时间,set进来记录的这两个map的key都是同一个,过期时间=当前时间加上设置的过期时间;当根据key取值时,先判断是否过期,过期则从dataMap中把值删除,从expireMap把过期时间删除。
3.获取用户id分析
上面通过StpUtil.login(userId)设置进去的id值,在接口访问中需要查询当前用户的id,也可以通过一行代码StpUtil.getLoginId()进行获取。看getLoginId源码⑩:
//获取登录用户的id
public static Object getLoginId() {
return stpLogic.getLoginId();
}
//stpLogic.getLoginId()方法
public Object getLoginId() {
//是否是切换用户
if (this.isSwitch()) {
//返回切换用户的id
return this.getSwitchLoginId();
} else {
//获取token值
String tokenValue = this.getTokenValue();
if (tokenValue == null) {
throw NotLoginException.newInstance(this.loginType, "-1").setCode(11011);
} else {
//根据token值获取id值
String loginId = this.getLoginIdNotHandle(tokenValue);
if (loginId == null) {
throw NotLoginException.newInstance(this.loginType, "-2", tokenValue).setCode(11012);
} else if (loginId.equals("-3")) {
throw NotLoginException.newInstance(this.loginType, "-3", tokenValue).setCode(11013);
} else if (loginId.equals("-4")) {
throw NotLoginException.newInstance(this.loginType, "-4", tokenValue).setCode(11014);
} else if (loginId.equals("-5")) {
throw NotLoginException.newInstance(this.loginType, "-5", tokenValue).setCode(11015);
} else {
this.checkActivityTimeout(tokenValue);
if (this.getConfig().getAutoRenew()) {
this.updateLastActivityToNow(tokenValue);
}
return loginId;
}
}
}
}
从源码可以看出处理的功能为:先判断当前是否处于切换用户阶段(sa-token提供临时切换用户,具体可以看官网),是则返回切换用户的id;先获取token值,在使用token值获取用户id。看下获取token值的源码⑪:
public String getTokenValue() {
//获取token值
String tokenValue = this.getTokenValueNotCut();
//看token是否配置了前缀
String tokenPrefix = this.getConfig().getTokenPrefix();
if (!SaFoxUtil.isEmpty(tokenPrefix)) {
//配置了前缀,则进行token值的截取
if (!SaFoxUtil.isEmpty(tokenValue) && tokenValue.startsWith(tokenPrefix + " ")) {
tokenValue = tokenValue.substring(tokenPrefix.length() + " ".length());
} else {
tokenValue = null;
}
}
return tokenValue;
}
从源码可以看出处理的功能为:获取token值,看token是否配置了前缀,配置了前缀,则进行token值的截取。看下获取token的方法getTokenValueNotCut()源码⑫:
public String getTokenValueNotCut() {
//获取SaStorage
SaStorage storage = SaHolder.getStorage();
//获取HttpServletRequest
SaRequest request = SaHolder.getRequest();
//获取配置信息类
SaTokenConfig config = this.getConfig();
//获取token的名称
String keyTokenName = this.getTokenName();
String tokenValue = null;
//先从HttpServletRequest的getAttribute里面先获取
if (storage.get(this.splicingKeyJustCreatedSave()) != null) {
tokenValue = String.valueOf(storage.get(this.splicingKeyJustCreatedSave()));
}
//从HttpServletRequest中根据参数名获取token值
if (tokenValue == null && config.getIsReadBody()) {
tokenValue = request.getParam(keyTokenName);
}
//从HttpServletRequest中head参数名获取token值
if (tokenValue == null && config.getIsReadHeader()) {
tokenValue = request.getHeader(keyTokenName);
}
//从HttpServletRequest中Cookie参数名获取token值
if (tokenValue == null && config.getIsReadCookie()) {
tokenValue = request.getCookieValue(keyTokenName);
}
return tokenValue;
}
从源码可以看出处理的功能为:从HttpServletRequest的getAttribute里面根据token名称获取token值,获取不到再根据配置的token存放方式从HttpServletRequest中获取(存放方式分为参数传递、head传递、cookie传递),token值是从接口访问请求中获取到的。获取到token之后,再根据此token值找到它对应的身份id,来看获取用户id的方法,源码⑩中的方法getLoginIdNotHandle方法源码⑬:
//根据token获取用户id的方法
public String getLoginIdNotHandle(String tokenValue) {
//保存token到map集合中,this.getSaTokenDao()是一个SaTokenDao的接口,只有一个默认实现类SaTokenDaoDefaultImpl,调用它的获取数据
return this.getSaTokenDao().get(this.splicingKeyTokenValue(tokenValue));
}
//this.splicingKeyTokenValue方法,生成token值存放到map的key值
public String splicingKeyTokenValue(String tokenValue) {
return this.getConfig().getTokenName() + ":" + this.loginType + ":token:" + tokenValue;
}
从源码可以看出处理的功能为:根据token值,调用splicingKeyTokenValue生成key值,生成方式与存入的时候调用的是同一个方法,然后调用SaTokenDaoDefaultImpl类的get方法获取到用户id。看下SaTokenDaoDefaultImpl类的get方法源码⑭:
public class SaTokenDaoDefaultImpl implements SaTokenDao {
//记录值的map
public Map<String, Object> dataMap = new ConcurrentHashMap();
//记录t过期时间的map
public Map<String, Long> expireMap = new ConcurrentHashMap();
//根据key获取值的方法
public String get(String key) {
//校验此key是否已经过期,过期则删除
this.clearKeyByTimeout(key);
//返回值
return (String)this.dataMap.get(key);
}
//根据key判断是否过期,过期则从map中把值删除
void clearKeyByTimeout(String key) {
//获取到key对应的过期时间
Long expirationTime = (Long)this.expireMap.get(key);
//过期时间不为空,且不等于-1,并且过期时间要小于当前系统时间,则说明此key过期,删除对应的map值
if (expirationTime != null && expirationTime != -1L && expirationTime < System.currentTimeMillis()) {
this.dataMap.remove(key);
this.expireMap.remove(key);
}
}
}
从源码可以看出处理的功能为:先判断是否过期,过期则从dataMap中把值删除,从expireMap把过期时间删除,然后再根据key从dataMap中返回value值。
4.向浏览器写入cookie分析
当登陆成功调用StpUtil.login(userId)方法后,会向浏览器或者调用客户端写入带有token信息的cookie。
现在来分析下具体的实现原理。来看源码③处的this.setTokenValue方法源码⑮:
public void setTokenValue(String tokenValue, SaLoginModel loginModel) {
if (!SaFoxUtil.isEmpty(tokenValue)) {
//把token值记录到Storage中
this.setTokenValueToStorage(tokenValue);
//参数isReadCookie默认是true
if (this.getConfig().getIsReadCookie()) {
//向服务响应中写入记录token信息的cookie
this.setTokenValueToCookie(tokenValue, loginModel.getCookieTimeout());
}
if (loginModel.getIsWriteHeaderOrGlobalConfig()) {
//把token信息写入响应头中
this.setTokenValueToResponseHeader(tokenValue);
}
}
}
从源码可以看出处理的功能为:参数isReadCookie默认是true,会向HttpServletResponse服务响应中写入记录token信息的cookie。看下this.setTokenValueToCookie的源码⑯:
public void setTokenValueToCookie(String tokenValue, int cookieTimeout) {
//获取cookie相关的配置
SaCookieConfig cfg = this.getConfig().getCookie();
//创建一个cookie,设置cookie的名称、过期时间、写入的域名等信息
SaCookie cookie = (new SaCookie()).setName(this.getTokenName()).setValue(tokenValue).setMaxAge(cookieTimeout).setDomain(cfg.getDomain()).setPath(cfg.getPath()).setSecure(cfg.getSecure()).setHttpOnly(cfg.getHttpOnly()).setSameSite(cfg.getSameSite());
//把cookie通过Response向客户端或者浏览器写回
SaHolder.getResponse().addCookie(cookie);
}
//SaHolder.getResponse()调用到的方法,获取Response
public static SaResponse getResponse() {
return SaManager.getSaTokenContextOrSecond().getResponse();
}
//SaManager.getSaTokenContextOrSecond().getResponse()调用到的方法
public SaResponse getResponse() {
return new SaResponseForServlet(SpringMVCUtil.getResponse());
}
//SpringMVCUtil.getResponse()调用到的方法
public static HttpServletResponse getResponse() {
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
if (servletRequestAttributes == null) {
throw (new NotWebContextException("非Web上下文无法获取Response")).setCode(20101);
} else {
return servletRequestAttributes.getResponse();
}
}
//RequestContextHolder.getRequestAttributes()调用到的方法
public static RequestAttributes getRequestAttributes() {
RequestAttributes attributes = requestAttributesHolder.get();
if (attributes == null) {
attributes = inheritableRequestAttributesHolder.get();
}
return attributes;
}
//RequestContextHolder的内部变量类,使用ThreadLocal修饰,是线程内部变量,从此RequestAttributes中获取到的Response为此处请求处理线程的响应
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
new NamedThreadLocal<>("Request attributes");
private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
new NamedInheritableThreadLocal<>("Request context");
//SaHolder.getResponse().addCookie(cookie)方法,向Response设置cookie
default void addCookie(SaCookie cookie) {
this.addHeader("Set-Cookie", cookie.toHeaderValue());
}
从源码可以看出处理的功能为:获取cookie相关的配置,创建一个cookie,设置cookie的名称、过期时间、写入的域名等信息;获取此次请求对应的HttpServletResponse,获取HttpServletResponse的流程为:先获取到此次请求的RequestAttributes,使用ThreadLocal来修饰它,被ThreadLocal修饰的为线程内部变量,在线程的生命周期内共享此变量;通过RequestAttributes调用getResponse()方法取到此次请求的HttpServletResponse;通过HttpServletResponse向客户端或者浏览器写入cookie。
5.权限验证流程分析
接口的调用往往需要权限的校验,一般的系统会给用户绑定某种角色,再给此角色分配权限,设置权限码,具有此角色或者权限码才放行请求。sa-token实现权限需要进行自定义扩展,以下为官网给的案例:
/**
* 自定义权限验证接口扩展
*/
@Component // 保证此类被SpringBoot扫描,完成Sa-Token的自定义权限验证扩展
public class StpInterfaceImpl implements StpInterface {
/**
* 返回一个账号所拥有的权限码集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 本list仅做模拟,实际项目中要根据具体业务逻辑来查询权限
//1.先从redis中取,有则直接返回
//2.若是redis中没有,则从数据库中查询,再把结果添加到redis中
List<String> list = new ArrayList<String>();
list.add("101");
list.add("user.add");
list.add("user.update");
list.add("user.get");
// list.add("user.delete");
list.add("art.*");
return list;
}
/**
* 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 本list仅做模拟,实际项目中要根据具体业务逻辑来查询角色
List<String> list = new ArrayList<String>();
list.add("admin");
list.add("super-admin");
return list;
}
}
从此案例中可以看到,扩展类实现了StpInterface接口,重写它里面根据用户id来获取权限码、获取用户角色码的方法,方法里面的实现可以改为:根据用户id先从redis中取,有则直接返回;redis中没有,则从数据库查询,再把结果添加到redis中。
判断用户是否有某个权限码,有此权限码才让访问接下来的流程,一行代码StpUtil.hasPermission(“user.add”)就能实现,看下StpUtil.hasPermission校验权限码的源码⑰:
public static boolean hasPermission(String permission) {
//校验是否有此权限码
return stpLogic.hasPermission(permission);
}
//stpLogic.hasPermission调用的方法
public boolean hasPermission(String permission) {
//获取用户拥有的权限码集合,判断是否有此权限码
return this.hasElement(this.getPermissionList(), permission);
}
//获取用户权限码集合
public List<String> getPermissionList() {
try {
//传递当前用户id,查询他具备的权限码集合
return this.getPermissionList(this.getLoginId());
} catch (NotLoginException var2) {
return SaFoxUtil.emptyList();
}
}
//根据用户id获取此用户具有的权限码集合
public List<String> getPermissionList(Object loginId) {
//调用接口SaManager.getStpInterface()的类型为StpInterface,会调用到用户扩展的获取权限码的方法
return SaManager.getStpInterface().getPermissionList(loginId, this.loginType);
}
//判断一个集合中是否包含另一个元素的方法
public boolean hasElement(List<String> list, String element) {
//hasElement是一个函数,判断是否包含某个元素
return (Boolean)SaStrategy.me.hasElement.apply(list, element);
}
//hasElement函数判断集合中是否包含某个元素
public BiFunction<List<String>, String, Boolean> hasElement = (list, element) -> {
if (list != null && list.size() != 0) {
//list中包含,则返回true
if (list.contains(element)) {
return true;
} else {
//集合中不包含某个元素,迭代判断,若是有*号的权限码,则使用正则表达式判断
Iterator var2 = list.iterator();
String patt;
do {
if (!var2.hasNext()) {
return false;
}
patt = (String)var2.next();
} while(!SaFoxUtil.vagueMatch(patt, element)); //使用正则表达式判断
return true;
}
} else {
return false;
}
};
//SaFoxUtil.vagueMatch的方法,使用正则表达式判断
public static boolean vagueMatch(String patt, String str) {
if (patt == null && str == null) {
return true;
} else if (patt != null && str != null) {
//当不包括*号,则使用相等判断;包含*号,则使用正则表达式进行判断
return patt.indexOf("*") == -1 ? patt.equals(str) : Pattern.matches(patt.replaceAll("\\*", ".*"), str);
} else {
return false;
}
}
从源码可以看出处理的功能为:先根据用户id获取此用户拥有的权限码集合,会调用到用户扩展实现StpInterface接口类的对应方法,拿到此用户的权限码集合后,再调用hasElement函数,权限码和待校验的字符串作为参数进行判断;当权限码集合中包含此字符串时,则返回true;权限码集合中不包含此字符串时,遍历权限码集合,若是权限码包含*号,则使用正则表达式进行判断,其它使用相等判断。
6.路由拦截分析
在调用后台服务时,我们可以在路由时做一些拦截,例如添加登陆权限拦截、放开一些接口白名单等。看下官网给出的案例:
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
// 注册 Sa-Token 的拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册路由拦截器,自定义认证规则
registry.addInterceptor(new SaInterceptor(handler -> {
// 登录校验 -- 拦截所有路由,并排除/user/doLogin 用于开放登录
SaRouter.match("/**", "/user/doLogin", r -> StpUtil.checkLogin());
// 角色校验 -- 拦截以 admin 开头的路由,必须具备 admin 角色或者 super-admin 角色才可以通过认证
SaRouter.match("/admin/**", r -> StpUtil.checkRoleOr("admin", "super-admin"));
// 权限校验 -- 不同模块校验不同权限
SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
SaRouter.match("/notice/**", r -> StpUtil.checkPermission("notice"));
SaRouter.match("/comment/**", r -> StpUtil.checkPermission("comment"));
// 甚至你可以随意的写一个打印语句
SaRouter.match("/**", r -> System.out.println("----啦啦啦----"));
// 连缀写法
SaRouter.match("/**").check(r -> System.out.println("----啦啦啦----"));
})).addPathPatterns("/**")
.excludePathPatterns("/login");
}
}
从此案例中可以看到:自定义路由拦截器需要实现WebMvcConfigurer接口,重写addInterceptors方法,定义拦截规则。SaInterceptor类实现了HandlerInterceptor接口,是sa-token用来配置拦截器的类。配置的每一个路由规则SaRouter支持拦截配置、白名单配置、拦截要求配置,例如:拦截所有接口,对login登陆接口开放,都需要校验用户已经登陆了才进行放行。SaRouter配置的拦截器可以细化到某个接口需要有某种权限才进行放行,也可以通过HandlerInterceptor的addPathPatterns加入拦截规则、excludePathPatterns加白某些接口。看下SaRouter.match具体拦截验证的源码⑱:
//传递拦截的配置、加白的配置,需要满足的要求
public static SaRouterStaff match(String pattern, String excludePattern, SaParamFunction<SaRouterStaff> fun) {
//创建一个SaRouterStaff类,调用match匹配方法
return (new SaRouterStaff()).match(pattern, excludePattern, fun);
}
//匹配方法
public SaRouterStaff match(String pattern, String excludePattern, SaParamFunction<SaRouterStaff> fun) {
//校验是否匹配上,是否放行、检查函数运行结果
return this.match(pattern).notMatch(excludePattern).check(fun);
}
//this.match(pattern)进行匹配的方法
public SaRouterStaff match(String... patterns) {
//isHit是否命中,初始为true
if (this.isHit) {
//校验当前访问的请求地址是否与配置的地址匹配
this.isHit = SaRouter.isMatchCurrURI(patterns);
}
return this;
}
//notMatch(excludePattern)调用的方法
public SaRouterStaff notMatch(String... patterns) {
if (this.isHit) {
//校验当前访问的请求地址是否与配置的地址匹配,再取反
this.isHit = !SaRouter.isMatchCurrURI(patterns);
}
return this;
}
//检查,运行函数
public SaRouterStaff check(SaFunction fun) {
//当match和notMatch都匹配检查后,isHit还是为true,则执行函数
if (this.isHit) {
fun.run();
}
return this;
}
//SaRouter.isMatchCurrURI(patterns)方法,
public static boolean isMatchCurrURI(String[] patterns) {
//获取当前访问的请求地址是否与配置的地址匹配
return isMatch(patterns, SaHolder.getRequest().getRequestPath());
}
//当前请求地址能否与配置的地址集合匹配
public static boolean isMatch(String[] patterns, String path) {
if (patterns == null) {
return false;
} else {
String[] var2 = patterns;
int var3 = patterns.length;
//遍历地址集合
for(int var4 = 0; var4 < var3; ++var4) {
String pattern = var2[var4];
if (isMatch(pattern, path)) {
return true;
}
}
return false;
}
}
//当前请求地址能否与配置的地址匹配
public static boolean isMatch(String pattern, String path) {
return SaManager.getSaTokenContextOrSecond().matchPath(pattern, path);
}
//匹配路径
public boolean matchPath(String pattern, String path) {
//使用PathMatcher路径匹配器匹配:当前请求地址能否与配置的地址匹配
return SaPathMatcherHolder.getPathMatcher().match(pattern, path);
}
从源码可以看出处理的功能为:match方法接收三个参数,第一个是匹配配置、第二个是不匹配配置、第三个是条件要求;从HttpServletRequest获取当前请求的地址path,然后使用PathMatcher路径匹配器匹配是否在拦截的配置里,以及不拦截的配置里,使用isHit字段来记录是否命中,当前面两个校验之后,isHit还是为true,则运行函数判断是否满足,满足了才算校验通过。
7.侦听器分析
当系统用户登录、登出、被踢下线等操作时,系统希望记录下日志信息,此时就可以使用侦听器来实现。看下官网给的案例:
/**
* 自定义侦听器的实现
*/
@Component
public class MySaTokenListener implements SaTokenListener {
/** 每次登录时触发 */
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
System.out.println("---------- 自定义侦听器实现 doLogin");
}
/** 每次注销时触发 */
@Override
public void doLogout(String loginType, Object loginId, String tokenValue) {
System.out.println("---------- 自定义侦听器实现 doLogout");
}
/** 每次被踢下线时触发 */
@Override
public void doKickout(String loginType, Object loginId, String tokenValue) {
System.out.println("---------- 自定义侦听器实现 doKickout");
}
/** 每次被顶下线时触发 */
@Override
public void doReplaced(String loginType, Object loginId, String tokenValue) {
System.out.println("---------- 自定义侦听器实现 doReplaced");
}
/** 每次被封禁时触发 */
@Override
public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {
System.out.println("---------- 自定义侦听器实现 doDisable");
}
/** 每次被解封时触发 */
@Override
public void doUntieDisable(String loginType, Object loginId, String service) {
System.out.println("---------- 自定义侦听器实现 doUntieDisable");
}
/** 每次二级认证时触发 */
@Override
public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) {
System.out.println("---------- 自定义侦听器实现 doOpenSafe");
}
/** 每次退出二级认证时触发 */
@Override
public void doCloseSafe(String loginType, String tokenValue, String service) {
System.out.println("---------- 自定义侦听器实现 doCloseSafe");
}
/** 每次创建Session时触发 */
@Override
public void doCreateSession(String id) {
System.out.println("---------- 自定义侦听器实现 doCreateSession");
}
/** 每次注销Session时触发 */
@Override
public void doLogoutSession(String id) {
System.out.println("---------- 自定义侦听器实现 doLogoutSession");
}
/** 每次Token续期时触发 */
@Override
public void doRenewTimeout(String tokenValue, Object loginId, long timeout) {
System.out.println("---------- 自定义侦听器实现 doRenewTimeout");
}
}
从此案例中可以看到:自定义侦听器需要实现SaTokenListener接口,然后重写里面的方法,当对应的方法有调用时会触发监听方法,使用观察者设计模式实现。实现此侦听器是基于SaTokenEventCenter事件处理中心类实现的,看下SaTokenEventCenter类的相关源码⑲:
//事件处理中心类
public class SaTokenEventCenter {
//记录所有的侦听器类;实现了SaTokenListener接口,并使用@Component修饰的自定义侦听器,在程序启动的时候,会被spring boot扫描加载进来,此时listenerList里面会存放这些自定义侦听器
private static List<SaTokenListener> listenerList = new ArrayList();
//登陆方法的侦听方法
public static void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
//使用迭代器遍历所有的侦听器类
Iterator var4 = listenerList.iterator();
while(var4.hasNext()) {
SaTokenListener listener = (SaTokenListener)var4.next();
//调用侦听器类的登陆方法,执行自定义的逻辑处理代码,此时会调用到SaTokenListener真正的实现类方法
listener.doLogin(loginType, loginId, tokenValue, loginModel);
}
}
//登出方法的侦听方法
public static void doLogout(String loginType, Object loginId, String tokenValue) {
Iterator var3 = listenerList.iterator();
while(var3.hasNext()) {
SaTokenListener listener = (SaTokenListener)var3.next();
listener.doLogout(loginType, loginId, tokenValue);
}
}
}
从源码可以看出处理的功能为:SaTokenEventCenter类是事件处理中心,listenerList是一个类型为SaTokenListener接口类的集合。自定义的侦听器类,实现了SaTokenListener接口,并使用@Component修饰,在程序启动的时候,会被spring boot扫描加载进来,此时listenerList里面会存放这些自定义侦听器。SaTokenEventCenter类定义了很多需要监听的方法,当调用某个监听方法时,会向所有的侦听器传递此消息,调用侦听器对应的方法,使用了java的观察者模式。来分析下doLogin方法的调用来源,在用户登录源码④的SaTokenEventCenter.doLogin中进行的调用,当登陆完成后,会调用事件处理中心的方法,事件处理中心再遍历所有注册的侦听器,去执行侦听器对应的方法。