个人博客:无奈何杨(wnhyang)
个人语雀:wnhyang
共享语雀:在线知识共享
Github:wnhyang - Overview
继续上文Sa-Token登录pre,有了前面的基础,就可以完整的了解satoken
的登录流程了。
项目启动
可以看到satoken
的一些配置和组件都已经注入,这个日志是怎么做的?下次可以讲一下,使用的是观察者模式。
login
一开始我还想直接从源码角度来的,发现不太合适,还是结合项目debug
吧!
前面业务登录直接过,到satoken
登录。
StpUtil
的所有login
重载方法最后归结于StpLogic
的public void login(Object id, SaLoginModel loginModel)
,如下:
SaLoginModel
决定登录的一些细节,登录设备、是否持久化Cookie
、指定此次登录token
有效期、此次token
最低活跃频率等等。
1、创建会话
/**
* 创建指定账号 id 的登录会话数据
*
* @param id 账号id,建议的类型:(long | int | String)
* @param loginModel 此次登录的参数Model
* @return 返回会话令牌
*/
public String createLoginSession(Object id, SaLoginModel loginModel) {
// 1、先检查一下,传入的参数是否有效
checkLoginArgs(id, loginModel);
// 2、初始化 loginModel ,给一些参数补上默认值
SaTokenConfig config = getConfigOrGlobal();
loginModel.build(config);
// 3、给这个账号分配一个可用的 token
String tokenValue = distUsableToken(id, loginModel);
// 4、获取此账号的 Account-Session , 续期
SaSession session = getSessionByLoginId(id, true);
session.updateMinTimeout(loginModel.getTimeout());
// 5、在 Account-Session 上记录本次登录的 token 签名
TokenSign tokenSign = new TokenSign(tokenValue, loginModel.getDeviceOrDefault(), loginModel.getTokenSignTag());
session.addTokenSign(tokenSign);
// 6、保存 token -> id 的映射关系,方便日后根据 token 找账号 id
saveTokenToIdMapping(tokenValue, id, loginModel.getTimeout());
// 7、写入这个 token 的最后活跃时间 token-last-active
if(isOpenCheckActiveTimeout()) {
setLastActiveToNow(tokenValue, loginModel.getActiveTimeout(), loginModel.getTimeoutOrGlobalConfig());
}
// 8、$$ 发布全局事件:账号 xxx 登录成功
SaTokenEventCenter.doLogin(loginType, id, tokenValue, loginModel);
// 9、检查此账号会话数量是否超出最大值,如果超过,则按照登录时间顺序,把最开始登录的给注销掉
if(config.getMaxLoginCount() != -1) {
logoutByMaxLoginCount(id, session, null, config.getMaxLoginCount());
}
// 10、一切处理完毕,返回会话凭证 token
return tokenValue;
}
代码注释已经相当清晰了,接下来就依此来吧。
1.1、检查参数
protected void checkLoginArgs(Object id, SaLoginModel loginModel) {
// 1、账号 id 不能为空
if(SaFoxUtil.isEmpty(id)) {
throw new SaTokenException("loginId 不能为空").setCode(SaErrorCode.CODE_11002);
}
// 2、账号 id 不能是异常标记值
if(NotLoginException.ABNORMAL_LIST.contains(id.toString())) {
throw new SaTokenException("loginId 不能为以下值:" + NotLoginException.ABNORMAL_LIST);
}
// 3、账号 id 不能是复杂类型
if( ! SaFoxUtil.isBasicType(id.getClass())) {
SaManager.log.warn("loginId 应该为简单类型,例如:String | int | long,不推荐使用复杂类型:" + id.getClass());
}
// 4、判断当前 StpLogic 是否支持 extra 扩展参数
if( ! isSupportExtra()) {
// 如果不支持,开发者却传入了 extra 扩展参数,那么就打印警告信息
Map<String, Object> extraData = loginModel.getExtraData();
if(extraData != null && extraData.size() > 0) {
SaManager.log.warn("当前 StpLogic 不支持 extra 扩展参数模式,传入的 extra 参数将被忽略");
}
}
// 5、如果全局配置未启动动态 activeTimeout 功能,但是此次登录却传入了 activeTimeout 参数,那么就打印警告信息
if( ! getConfigOrGlobal().getDynamicActiveTimeout() && loginModel.getActiveTimeout() != null) {
SaManager.log.warn("当前全局配置未开启动态 activeTimeout 功能,传入的 activeTimeout 参数将被忽略");
}
}
1、检查空
2、对账号id
检查,异常情况有下
3、账号id
不能是除8
大基本基本数据类型、8
大包装类、String
之外的复杂类型。
4、是否支持扩展参数是在StpLogic
层面的,不是全局配置,默认不集成jwt
时是不支持扩展参数的。
5、项目启动日志打印的全局配置dynamicActiveTimeout=false
,所以登录时会有以下日志。
SA [WARN] -->: 当前全局配置未开启动态 activeTimeout 功能,传入的 activeTimeout 参数将被忽略
1.2、初始化loginModel
// 2、初始化 loginModel ,给一些参数补上默认值
SaTokenConfig config = getConfigOrGlobal();
loginModel.build(config);
根据全局配置补充loginModel
,如:token
有效期,登录后是否写入响应头。
根据就近原则,全局配置优先级不如自定义loginModel
,这个要知道。
当下配置,默认token
有效期1小时,不写入响应头。
1.3、账号分配可用token
// 3、给这个账号分配一个可用的 token
String tokenValue = distUsableToken(id, loginModel);
这个注释相当严谨!
protected String distUsableToken(Object id, SaLoginModel loginModel) {
// 1、获取全局配置的 isConcurrent 参数
// 如果配置为:不允许一个账号多地同时登录,则需要先将这个账号的历史登录会话标记为:被顶下线
Boolean isConcurrent = getConfigOrGlobal().getIsConcurrent();
if( ! isConcurrent) {
replaced(id, loginModel.getDevice());
}
// 2、如果调用者预定了要生成的 token,则直接返回这个预定的值,框架无需再操心了
if(SaFoxUtil.isNotEmpty(loginModel.getToken())) {
return loginModel.getToken();
}
// 3、只有在配置了 [ 允许一个账号多地同时登录 ] 时,才尝试复用旧 token,这样可以避免不必要地查询,节省开销
if(isConcurrent) {
// 3.1、看看全局配置的 IsShare 参数,配置为 true 才是允许复用旧 token
if(getConfigOfIsShare()) {
// 根据 账号id + 设备类型,尝试获取旧的 token
String tokenValue = getTokenValueByLoginId(id, loginModel.getDeviceOrDefault());
// 如果有值,那就直接复用
if(SaFoxUtil.isNotEmpty(tokenValue)) {
return tokenValue;
}
// 如果没值,那还是要继续往下走,尝试新建 token
// ↓↓↓
}
}
// 4、如果代码走到此处,说明未能成功复用旧 token,需要根据算法新建 token
return SaStrategy.instance.generateUniqueToken.execute(
"token",
getConfigOfMaxTryTimes(),
() -> {
return createTokenValue(id, loginModel.getDeviceOrDefault(), loginModel.getTimeout(), loginModel.getExtraData());
},
tokenValue -> {
return getLoginIdNotHandle(tokenValue) == null;
}
);
}
同端互斥登录
全局配置的是不允许同一用户并发登录(挤掉旧登录),注意!挤掉旧登录只能挤掉同设备登录,就是手机挤掉手机登录,不会挤掉PC登录。所以方法是这样的replaced(Object loginId, String device)
。这也就是官网的同端互斥登录。
因为我是配置了不允许同端登录is-concurrent: false
,那么进入replaced
方法。
/**
* 顶人下线,根据账号id 和 设备类型
* <p> 当对方再次访问系统时,会抛出 NotLoginException 异常,场景值=-4 </p>
*
* @param loginId 账号id
* @param device 设备类型 (填 null 代表顶替该账号的所有设备类型)
*/
public void replaced(Object loginId, String device) {
// 1、获取此账号的 Account-Session,上面记录了此账号的所有登录客户端数据
SaSession session = getSessionByLoginId(loginId, false);
if(session != null) {
// 2、遍历此账号所有从这个 device 设备上登录的客户端,清除相关数据
for (TokenSign tokenSign: session.getTokenSignListByDevice(device)) {
// 2.1、获取此客户端的 token 值
String tokenValue = tokenSign.getValue();
// 2.2、从 Account-Session 上清除 token 签名
session.removeTokenSign(tokenValue);
// 2.3、清除这个 token 的最后活跃时间记录
if(isOpenCheckActiveTimeout()) {
clearLastActive(tokenValue);
}
// 2.4、将此 token 标记为:已被顶下线
updateTokenToIdMapping(tokenValue, NotLoginException.BE_REPLACED);
// 2.5、此处不需要清除它的 Token-Session 对象
// deleteTokenSession(tokenValue);
// 2.6、$$ 发布事件:xx 账号的 xx 客户端注销了
SaTokenEventCenter.doReplaced(loginType, loginId, tokenValue);
}
// 3、因为调用顶替下线时,一般都是在新客户端正在登录,所以此处不需要清除该账号的 Account-Session
// session.logoutByTokenSignCountToZero();
}
}
getSessionByLoginId
是一个非常重要的方法,有很多引用。
/**
* 获取指定账号 id 的 Account-Session, 如果该 SaSession 尚未创建,isCreate=是否新建并返回
*
* @param loginId 账号id
* @param isCreate 是否新建
* @return SaSession 对象
*/
public SaSession getSessionByLoginId(Object loginId, boolean isCreate) {
return getSessionBySessionId(splicingKeySession(loginId), isCreate, session -> {
// 这里是该 Account-Session 首次创建时才会被执行的方法:
// 设定这个 SaSession 的各种基础信息:类型、账号体系、账号id
session.setType(SaTokenConsts.SESSION_TYPE__ACCOUNT);
session.setLoginType(getLoginType());
session.setLoginId(loginId);
});
}
实际上重要的是其重载方法,如下。
/**
* 获取指定 key 的 SaSession, 如果该 SaSession 尚未创建,isCreate = 是否立即新建并返回
*
* @param sessionId SessionId
* @param isCreate 是否新建
* @param appendOperation 如果这个 SaSession 是新建的,则要追加执行的动作
* @return Session对象
*/
public SaSession getSessionBySessionId(String sessionId, boolean isCreate, Consumer<SaSession> appendOperation) {
// 如果提供的 sessionId 为 null,则直接返回 null
if(SaFoxUtil.isEmpty(sessionId)) {
return null;
}
// 先检查这个 SaSession 是否已经存在,如果不存在且 isCreate=true,则新建并返回
SaSession session = getSaTokenDao().getSession(sessionId);
if(session == null && isCreate) {
// 创建这个 SaSession
session = SaStrategy.instance.createSession.apply(sessionId);
// 追加操作
if(appendOperation != null) {
appendOperation.accept(session);
}
// 将这个 SaSession 入库
getSaTokenDao().setSession(session, getConfigOrGlobal().getTimeout());
}
return session;
}
这个方法设计的就挺有意思,第二个参数可以决定是否创建新的session
,第三个参数是四大函数式接口之一的消费者。Java8
函数式接口、lambada
、stream
都应该了解的。
如下方法可知,在查不到sessionId
的session
时使用SaStrategy
类来创建session
,这个SaStrategy
类实际上是一个单例类,其中有很多策略方法,这个可以下次单独拎出来讲的。
因为这是我第一次登录,sessionKey
是这样的Authorization:login:session:1
并没有在redis
,而且这次仅仅是查看是否已有session
,所以不用创建。
那么replaced
就过了。
新建token
本次未预定token
值,而且是同端互斥登录,所以直接到新建token
。
createTokenValue
又是SaStrategy
的一个策略。
还记得satoken自定义 Token 风格吗?就体现在这里了。
getLoginIdNotHandle
这里通过拼接tokenKey
,查redis
检查token
是否已经存在,来保证唯一的。
generateUniqueToken
也是SaStrategy
的一个策略。
其函数式定义是这样的
根据传入的参数生成如下一个token
。
1.4、获取Account-Session,续期
// 4、获取此账号的 Account-Session , 续期
SaSession session = getSessionByLoginId(id, true);
session.updateMinTimeout(loginModel.getTimeout());
发现没?又是getSessionByLoginId
,不过这次传入isCreate=true
。
创建session
这次需要创建session
了
创建session
策略如下,其实其中还包含了一个发布事件的动作
SA [INFO] -->: SaSession [Authorization:login:session:1] 创建成功
续期
1.5、在Account-Session上记录本次登录token
// 5、在 Account-Session 上记录本次登录的 token 签名
TokenSign tokenSign = new TokenSign(tokenValue, loginModel.getDeviceOrDefault(), loginModel.getTokenSignTag());
session.addTokenSign(tokenSign);
前面讲过了Sa-Token 中的 Session会话 模型详解,Account-Session
归属账号的,一个账号可以存在多个token
,而本次token
当然也是属于这个账号,所以将此tokenSign
加入Account-Session
中,表示这个token
归属于同一账号。
1.6、保存token-id映射
// 6、保存 token -> id 的映射关系,方便日后根据 token 找账号 id
saveTokenToIdMapping(tokenValue, id, loginModel.getTimeout());
如下拼接redisKey
存入账号id
。
1.7、写入token最后活跃时间
// 7、写入这个 token 的最后活跃时间 token-last-active
if(isOpenCheckActiveTimeout()) {
setLastActiveToNow(tokenValue, loginModel.getActiveTimeout(), loginModel.getTimeoutOrGlobalConfig());
}
如下
1.8、发布全局事件
// 8、$$ 发布全局事件:账号 xxx 登录成功
SaTokenEventCenter.doLogin(loginType, id, tokenValue, loginModel);
这个可以再开一篇,观察者模式。
1.9、检查会话数量
// 9、检查此账号会话数量是否超出最大值,如果超过,则按照登录时间顺序,把最开始登录的给注销掉
if(config.getMaxLoginCount() != -1) {
logoutByMaxLoginCount(id, session, null, config.getMaxLoginCount());
}
这个方法注解很清晰了!
当我们要清除一个token
时,我们应该做些什么?
public void logoutByMaxLoginCount(Object loginId, SaSession session, String device, int maxLoginCount) {
// 1、如果调用者提供的 Account-Session 对象为空,则我们先手动获取一下
if(session == null) {
session = getSessionByLoginId(loginId, false);
if(session == null) {
return;
}
}
// 2、获取这个账号指定设备类型下的所有登录客户端
List<TokenSign> list = session.getTokenSignListByDevice(device);
// 3、按照登录时间倒叙,超过 maxLoginCount 数量的,全部注销掉
for (int i = 0; i < list.size() - maxLoginCount; i++) {
// 3.1、获取此客户端的 token 值
String tokenValue = list.get(i).getValue();
// 3.2、从 Account-Session 上清除 token 签名
session.removeTokenSign(tokenValue);
// 3.3、清除这个 token 的最后活跃时间记录
if(isOpenCheckActiveTimeout()) {
clearLastActive(tokenValue);
}
// 3.4、清除 token -> id 的映射关系
deleteTokenToIdMapping(tokenValue);
// 3.5、清除这个 token 的 Token-Session 对象
deleteTokenSession(tokenValue);
// 3.6、$$ 发布事件:xx 账号的 xx 客户端注销了
SaTokenEventCenter.doLogout(loginType, loginId, tokenValue);
}
// 4、如果代码走到这里的时候,此账号已经没有客户端在登录了,则直接注销掉这个 Account-Session
session.logoutByTokenSignCountToZero();
}
1.10、返回会话凭证token
// 10、一切处理完毕,返回会话凭证 token
return tokenValue;
至此终于创建会话结束。。。
2、在当前客户端注入 token
回到login
的第二步setTokenValue
。
public void login(Object id, SaLoginModel loginModel) {
// 1、创建会话
String token = createLoginSession(id, loginModel);
// 2、在当前客户端注入 token
setTokenValue(token, loginModel);
}
如下,根据配置,讲token
写入Storage
。
SpringMVC
环境下,就是SaStorageForServlet
,实际操作的是HttpServletRequest
,点开就看到了。
至于Cookie
,就是特殊的头信息,这些都类似的。
那么至此一次登录真的就完成了。
分析总结
登录日志
登录成功后控制台会打印如下日志,这就是提到但又没细说的事件发布机制做到的。
Redis数据
查看redis
数据有下
session
splicingKeySession
:${tokenName}:login:session:${loginId}
Authorization:login:session:1
存储值如下,这个就是账号id
对应的Account-Session
。
token映射id
splicingKeySession
:${tokenName}:login:token:${tokenValue}
对应前面1.6保存token-id
映射
last-active
splicingKeySession
:${tokenName}:login:last-active:${tokenValue}
对应前面1.7写入token
最后活跃时间
token-session
splicingKeySession
:${tokenName}:login:token-session:${tokenValue}
这里的dataMap
里的login_user
保留着当前登录用户信息,对应上篇文章的“缓存权限数据”章节。
获取缓存中的登录用户信息就可以使用以下方法了!
SaSession session = StpUtil.getTokenSession();
if (ObjectUtil.isNull(session)) {
return null;
}
LoginUser loginUser = (LoginUser) session.get(LOGIN_USER_KEY);
顶人下线
也就是说重复调用login
登录,会有什么效果,redis
会有什么变化呢?
因为这次是同端登录,可以看到session
的tokenSign
变为最新的,token
虽然还有两条,但其中一条指向的不再是账号id
,而是-4
,表示被顶下线,按理讲这里的token-session
应该是要被清除了的,源码里这里个步骤被注释了,不太理解。保留疑问。
过期登录
写不动了😂
场景太多了,自己实践吧!
写在最后
拙作艰辛,字句心血,望诸君垂青,多予支持,不胜感激。
个人博客:无奈何杨(wnhyang)
个人语雀:wnhyang
共享语雀:在线知识共享
Github:wnhyang - Overview