主要介绍Sa-Token的鉴权使用以及实现原理。
文章目录
- 简介
- 使用
- 源码解释
- 创建会话
- 1.前置检查
- 2.获取配置
- 3.分配token
- 4.获取 User-Session
- 5.设置token-id映射关系
- 6.登录成功事件发布
- 7.检查会话数量
- 客户端注入Token
简介
官网介绍的非常详细,主要突出这是一个轻量级鉴权框架的特点,详情可自行访问:https://sa-token.dev33.cn/doc.html#/
使用
旨在简单使用,大部分功能均可以在一行代码内实现,这里举几个官网示例:
首先添加依赖:
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.33.0</version>
</dependency>
yaml配置文件:
server:
# 端口
port: 8081
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token名称 (同时也是cookie名称)
token-name: satoken
# token有效期,单位s 默认30天, -1代表永不过期
timeout: 2592000
# token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
activity-timeout: -1
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: true
# token风格
token-style: uuid
# 是否输出操作日志
is-log: false
// 会话登录,参数填登录人的账号id
StpUtil.login(10001);
// 校验当前客户端是否已经登录,如果未登录则抛出 `NotLoginException` 异常
StpUtil.checkLogin();
// 将账号id为 10077 的会话踢下线
StpUtil.kickout(10077);
功能图如下所示:
源码解释
解释登录原理
StpUtil.login(usId);
这短短的一句话代码蕴藏了多少玄机,我们一探究竟。
首先我们观察现象,模拟一个登录入口/login,里面做一个最简单的动作,就是将前端传入的usId作为用户id并交给StpUtil执行登录逻辑,并最终将usId返回。
@GetMapping("/login")
private String login(String usId){
StpUtil.login(usId);
return usId;
}
访问 http://localhost:8081/login?usId=123,成功返回结果123。
随后我们F12查看浏览器的控制台,Application->打开Cookies:
能够观察到Cookies自动新增了一个键为satoken的cookie键值对,值类似于uuid随机字符串,此处示例为9ea38efb-228f-4131-b844-903467caf205,过期时间设置了30天,可对应前面配置文件的配置信息,satoken的cookie名称以及过期时间均支持配置调整。
接下来我们分析源码:
首先通过StpUtil作为入口,实际上通过实例化StpLogic来进行调用:
// StpUtil.java
public static StpLogic stpLogic = new StpLogic("login");
public static void login(Object id) {
stpLogic.login(id);
}
随后在StpLogic中,通过传入的Object id以及SaLoginModel进行构建会话:
// StpLogic.java
public void login(Object id) {
this.login(id, new SaLoginModel());
}
// 通过这两个方法进行会话建立 createLoginSession()/setTokenValue()
public void login(Object id, SaLoginModel loginModel) {
// 1、创建会话
String token = this.createLoginSession(id, loginModel);
// 2、在当前客户端注入Token
setTokenValue(token, loginModel);
}
创建会话
分析StpLogic.createLoginSession()方法
public String createLoginSession(Object id, SaLoginModel loginModel) {
// 1.这个设计可以借鉴一下,直接可以判断id值为null就抛异常,将异常封装起来
SaTokenException.throwByNull(id, "账号id不能为空", 11002);
// 2.获取配置信息
SaTokenConfig config = this.getConfig();
loginModel.build(config);
// 3.生成token(若已经有存在生效的token则使用原先的token)
String tokenValue = this.distUsableToken(id, loginModel);
// 4.获取 User-Session/首次登录则创建会话,使用自定义封装的SaSession对象接收
SaSession session = this.getSessionByLoginId(id, true);
session.updateMinTimeout(loginModel.getTimeout());
session.addTokenSign(tokenValue, loginModel.getDeviceOrDefault());
// 5.设置token -> id 映射关系
this.saveTokenToIdMapping(tokenValue, id, loginModel.getTimeout());
// 调用getSaTokenDao().set设置tokenValue与失效时间的关系
this.setLastActivityToNow(tokenValue);
// 6.事件发布
SaTokenEventCenter.doLogin(this.loginType, id, tokenValue, loginModel);
// 7.检查此账号会话数量是否超出最大值,-1表示不限会话数量
if (config.getMaxLoginCount() != -1) {
logoutByMaxLoginCount(id, session, (String)null, config.getMaxLoginCount());
}
return tokenValue;
}
1.前置检查
判断Object id是否为null
SaTokenException.throwByNull(id, "账号id不能为空", 11002);
public static void throwByNull(Object value, String message, int code) {
if(SaFoxUtil.isEmpty(value)) {
throw new SaTokenException(message).setCode(code);
}
}
2.获取配置
StpLogic.getConfig():
// 2.获取配置信息
SaTokenConfig config = this.getConfig();
3.分配token
StpLogic.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 {
if (isConcurrent && this.getConfigOfIsShare() && !loginModel.isSetExtraData()) {
// 获取token
String tokenValue = this.getTokenValueByLoginId(id, loginModel.getDeviceOrDefault());
// 已经存在会话,将之前生成的token返回
if (SaFoxUtil.isNotEmpty(tokenValue)) {
return tokenValue;
}
}
// *新会话,生成一个新token,可借鉴,见下方解析
return this.createTokenValue(id, loginModel.getDeviceOrDefault(), loginModel.getTimeout(), loginModel.getExtraData());
}
}
解析StpLogic.createTokenValue():
public class StpLogic {
// ...
public String createTokenValue(Object loginId, String device, long timeout, Map<String, Object> extraData) {
// SaStrategy.me:使用SaStrategy的单例引用
// SaStrategy.me.createToken:调用createToken()方法
// SaStrategy.me.createToken.apply:使得结果生效
return SaStrategy.me.createToken.apply(loginId, loginType);
}
}
public final class SaStrategy {
public static final SaStrategy me = new SaStrategy();
public BiFunction<Object, String, String> createToken = (loginId, loginType) -> {
// 根据配置的tokenStyle生成不同风格的token
String tokenStyle = SaManager.getConfig().getTokenStyle();
// uuid
if(SaTokenConsts.TOKEN_STYLE_UUID.equals(tokenStyle)) {
return UUID.randomUUID().toString();
}
// 简单uuid (不带下划线)
if(SaTokenConsts.TOKEN_STYLE_SIMPLE_UUID.equals(tokenStyle)) {
return UUID.randomUUID().toString().replaceAll("-", "");
}
// 32位随机字符串
if(SaTokenConsts.TOKEN_STYLE_RANDOM_32.equals(tokenStyle)) {
return SaFoxUtil.getRandomString(32);
}
// 64位随机字符串
if(SaTokenConsts.TOKEN_STYLE_RANDOM_64.equals(tokenStyle)) {
return SaFoxUtil.getRandomString(64);
}
// 128位随机字符串
if(SaTokenConsts.TOKEN_STYLE_RANDOM_128.equals(tokenStyle)) {
return SaFoxUtil.getRandomString(128);
}
// tik风格 (2_14_16)
if(SaTokenConsts.TOKEN_STYLE_TIK.equals(tokenStyle)) {
return SaFoxUtil.getRandomString(2) + "_" + SaFoxUtil.getRandomString(14) + "_" + SaFoxUtil.getRandomString(16) + "__";
}
// 默认,还是uuid
return UUID.randomUUID().toString();
};
}
4.获取 User-Session
StpLogic.getSessionByLoginId()
public SaSession getSessionByLoginId(Object loginId, boolean isCreate) {
return getSessionBySessionId(splicingKeySession(loginId), isCreate);
}
/**
* 获取指定key的Session, 如果Session尚未创建,isCreate=是否新建并返回
* @param sessionId SessionId
* @param isCreate 是否新建
* @return Session对象
*/
public SaSession getSessionBySessionId(String sessionId, boolean isCreate) {
// getSaTokenDao使用了懒加载初始化SaTokenDao对象,最终由new SaTokenDaoDefaultImpl()进行实现具体方法
// 并根据sessionId获取session
SaSession session = getSaTokenDao().getSession(sessionId);
// session暂未建立,进行session新建
if(session == null && isCreate) {
// 与上方的createToken使用了同样的设计
session = SaStrategy.me.createSession.apply(sessionId);
// 设置session与session过期时效
getSaTokenDao().setSession(session, getConfig().getTimeout());
}
return session;
}
5.设置token-id映射关系
StpLogic.saveTokenToIdMapping()
public void saveTokenToIdMapping(String tokenValue, Object loginId, long timeout) {
// 如果继续往下深挖其实set方法的实现底层就是一个new ConcurrentHashMap<String, Object>()
// 并且封装了一个new ConcurrentHashMap<String, Long>()来记录key与过期时间的关系
// 并且设置的过期过期时间
getSaTokenDao().set(splicingKeyTokenValue(tokenValue), String.valueOf(loginId), timeout);
}
这里浅浅看一下设置的过期时间如何实现:
/**
* 数据集合
*/
public Map<String, Object> dataMap = new ConcurrentHashMap<String, Object>();
/**
* 过期时间集合 (单位: 毫秒) , 记录所有key的到期时间 [注意不是剩余存活时间]
*/
public Map<String, Long> expireMap = new ConcurrentHashMap<String, Long>();
@Override
public void set(String key, String value, long timeout) {
if(timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) {
return;
}
// 设置key-value
dataMap.put(key, value);
// 设置key-到期时间
expireMap.put(key, (timeout == SaTokenDao.NEVER_EXPIRE) ? (SaTokenDao.NEVER_EXPIRE) : (System.currentTimeMillis() + timeout * 1000));
}
/**
* 如果指定key已经过期,则立即清除它。在每个get方法前都首先调用一下这个方法
* @param key 指定key
*/
void clearKeyByTimeout(String key) {
Long expirationTime = expireMap.get(key);
// 清除条件:如果不为空 && 不是[永不过期] && 已经超过过期时间
if(expirationTime != null && expirationTime != SaTokenDao.NEVER_EXPIRE && expirationTime < System.currentTimeMillis()) {
dataMap.remove(key);
expireMap.remove(key);
}
}
// ------------------------ String 读写操作
@Override
public String get(String key) {
// 首先判断一下key是否已经过期
clearKeyByTimeout(key);
return (String)dataMap.get(key);
}
6.登录成功事件发布
SaTokenEventCenter.doLogin
// --------- 注册侦听器
private static List<SaTokenListener> listenerList = new ArrayList<>();
/**
* 每次登录时触发
* @param loginType 账号类别
* @param loginId 账号id
* @param tokenValue 本次登录产生的 token 值
* @param loginModel 登录参数
*/
public static void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
for (SaTokenListener listener : listenerList) {
listener.doLogin(loginType, loginId, tokenValue, loginModel);
}
}
主要目的是登录成功后的一些后置处理方法回调等,通过观察我们可以发现只要注册到SaTokenEventCenter.listenerList中即可在遍历中执行监听器的doLogin()方法。目前默认添加控制台日志侦听器,new SaTokenListenerForLog(),主要实现一些日志打印:
还有一个空实现的SaTokenListenerForSimple监听器,后续我们如果想要做一些自定义扩展,就可以继承SaTokenListenerForSimple做一些属于我们自己的业务监听器处理:
7.检查会话数量
StpLogic.logoutByMaxLoginCount()
/**
* 会话注销,根据账号id 和 设备类型 和 最大同时在线数量
*
* @param loginId 账号id
* @param session 此账号的 Session 对象,可填写null,框架将自动获取
* @param device 设备类型 (填null代表注销所有设备类型)
* @param maxLoginCount 保留最近的几次登录
*/
public void logoutByMaxLoginCount(Object loginId, SaSession session, String device, int maxLoginCount) {
if(session == null) {
session = getSessionByLoginId(loginId, false);
if(session == null) {
return;
}
}
List<TokenSign> list = session.tokenSignListCopyByDevice(device);
// 遍历操作
for (int i = 0; i < list.size(); i++) {
// 只操作前n条
if(i >= list.size() - maxLoginCount) {
continue;
}
// 清理: token签名、token最后活跃时间
String tokenValue = list.get(i).getValue();
session.removeTokenSign(tokenValue);
clearLastActivity(tokenValue);
// 删除Token-Id映射 & 清除Token-Session
deleteTokenToIdMapping(tokenValue);
deleteTokenSession(tokenValue);
// $$ 发布事件:指定账号注销
SaTokenEventCenter.doLogout(loginType, loginId, tokenValue);
}
// 注销 Session
session.logoutByTokenSignCountToZero();
}
客户端注入Token
分析StpLogic.setTokenValue()方法
/**
* 在当前会话写入当前TokenValue
* @param tokenValue token值
* @param loginModel 登录参数
*/
public void setTokenValue(String tokenValue, SaLoginModel loginModel){
if(SaFoxUtil.isEmpty(tokenValue)) {
return;
}
// 1. 将 Token 保存到 [存储器] 里
setTokenValueToStorage(tokenValue);
// 2. 将 Token 保存到 [Cookie] 里 此处对应
if (getConfig().getIsReadCookie()) {
setTokenValueToCookie(tokenValue, loginModel.getCookieTimeout());
}
// 3. 将 Token 写入到响应头里
if(loginModel.getIsWriteHeaderOrGlobalConfig()) {
setTokenValueToResponseHeader(tokenValue);
}
}
参考资料:
- Sa-Token官网介绍
- sa-token使用(源码解析 + 万字)