Sa-Token登录详解

个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview


继续上文Sa-Token登录pre,有了前面的基础,就可以完整的了解satoken的登录流程了。

项目启动

1706270541534

可以看到satoken的一些配置和组件都已经注入,这个日志是怎么做的?下次可以讲一下,使用的是观察者模式。

login

一开始我还想直接从源码角度来的,发现不太合适,还是结合项目debug吧!

前面业务登录直接过,到satoken登录。

1706430133588

StpUtil的所有login重载方法最后归结于StpLogicpublic void login(Object id, SaLoginModel loginModel),如下:

1706430214464

SaLoginModel决定登录的一些细节,登录设备、是否持久化Cookie、指定此次登录token有效期、此次token最低活跃频率等等。

1、创建会话

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 创建指定账号 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、检查参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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检查,异常情况有下

1706365480421

3、账号id不能是除8大基本基本数据类型、8大包装类、String之外的复杂类型。

4、是否支持扩展参数是在StpLogic层面的,不是全局配置,默认不集成jwt时是不支持扩展参数的。

5、项目启动日志打印的全局配置dynamicActiveTimeout=false,所以登录时会有以下日志。

SA [WARN] -->: 当前全局配置未开启动态 activeTimeout 功能,传入的 activeTimeout 参数将被忽略

1.2、初始化loginModel

1
2
3
// 2、初始化 loginModel ,给一些参数补上默认值
SaTokenConfig config = getConfigOrGlobal();
loginModel.build(config);

根据全局配置补充loginModel,如:token有效期,登录后是否写入响应头。

根据就近原则,全局配置优先级不如自定义loginModel,这个要知道。

当下配置,默认token有效期1小时,不写入响应头。

1.3、账号分配可用token

1
2
// 3、给这个账号分配一个可用的 token
String tokenValue = distUsableToken(id, loginModel);

这个注释相当严谨!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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)。这也就是官网的同端互斥登录

1706430463435

因为我是配置了不允许同端登录is-concurrent: false,那么进入replaced方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 顶人下线,根据账号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是一个非常重要的方法,有很多引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** 
* 获取指定账号 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);
});
}

实际上重要的是其重载方法,如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/** 
* 获取指定 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函数式接口、lambadastream都应该了解的。

如下方法可知,在查不到sessionIdsession时使用SaStrategy类来创建session,这个SaStrategy类实际上是一个单例类,其中有很多策略方法,这个可以下次单独拎出来讲的。

1706430845654

因为这是我第一次登录,sessionKey是这样的Authorization:login:session:1并没有在redis,而且这次仅仅是查看是否已有session,所以不用创建。

那么replaced就过了。

1706431274485

新建token

本次未预定token值,而且是同端互斥登录,所以直接到新建token

1706431701776

createTokenValue又是SaStrategy的一个策略。

1706432438324

还记得satoken自定义 Token 风格吗?就体现在这里了。

1706756081342

getLoginIdNotHandle这里通过拼接tokenKey,查redis检查token是否已经存在,来保证唯一的。

1706756265354

generateUniqueToken也是SaStrategy的一个策略。

1706432183757

其函数式定义是这样的

1706431807597

根据传入的参数生成如下一个token

1706432183757

1.4、获取Account-Session,续期

1
2
3
// 4、获取此账号的 Account-Session , 续期
SaSession session = getSessionByLoginId(id, true);
session.updateMinTimeout(loginModel.getTimeout());

发现没?又是getSessionByLoginId,不过这次传入isCreate=true

创建session

这次需要创建session

1706433791024

创建session策略如下,其实其中还包含了一个发布事件的动作

SA [INFO] -->: SaSession [Authorization:login:session:1] 创建成功

1706433888100

续期

1706434158075

1.5、在Account-Session上记录本次登录token

1
2
3
// 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归属于同一账号。

1706434300989

1.6、保存token-id映射

1
2
// 6、保存 token -> id 的映射关系,方便日后根据 token 找账号 id
saveTokenToIdMapping(tokenValue, id, loginModel.getTimeout());

如下拼接redisKey存入账号id

1706435142961

1.7、写入token最后活跃时间

1
2
3
4
// 7、写入这个 token 的最后活跃时间 token-last-active
if(isOpenCheckActiveTimeout()) {
setLastActiveToNow(tokenValue, loginModel.getActiveTimeout(), loginModel.getTimeoutOrGlobalConfig());
}

如下

1706435233957

1.8、发布全局事件

1
2
// 8、$$ 发布全局事件:账号 xxx 登录成功
SaTokenEventCenter.doLogin(loginType, id, tokenValue, loginModel);

这个可以再开一篇,观察者模式。

1.9、检查会话数量

1
2
3
4
// 9、检查此账号会话数量是否超出最大值,如果超过,则按照登录时间顺序,把最开始登录的给注销掉
if(config.getMaxLoginCount() != -1) {
logoutByMaxLoginCount(id, session, null, config.getMaxLoginCount());
}

这个方法注解很清晰了!

当我们要清除一个token时,我们应该做些什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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

1
2
// 10、一切处理完毕,返回会话凭证 token
return tokenValue;

至此终于创建会话结束。。。

2、在当前客户端注入 token

回到login的第二步setTokenValue

1
2
3
4
5
6
7
public void login(Object id, SaLoginModel loginModel) {
// 1、创建会话
String token = createLoginSession(id, loginModel);

// 2、在当前客户端注入 token
setTokenValue(token, loginModel);
}

如下,根据配置,讲token写入Storage

1706436634411

SpringMVC环境下,就是SaStorageForServlet,实际操作的是HttpServletRequest,点开就看到了。

1706436847528

至于Cookie,就是特殊的头信息,这些都类似的。

那么至此一次登录真的就完成了。

分析总结

登录日志

登录成功后控制台会打印如下日志,这就是提到但又没细说的事件发布机制做到的。

1706438403332

Redis数据

查看redis数据有下

1706437218015

session

splicingKeySession${tokenName}:login:session:${loginId}

Authorization:login:session:1存储值如下,这个就是账号id对应的Account-Session

1706437940996

token映射id

splicingKeySession${tokenName}:login:token:${tokenValue}

对应前面1.6保存token-id映射

1706438624034

last-active

splicingKeySession${tokenName}:login:last-active:${tokenValue}

对应前面1.7写入token最后活跃时间

1706438716381

token-session

splicingKeySession${tokenName}:login:token-session:${tokenValue}

1706439023184

这里的dataMap里的login_user保留着当前登录用户信息,对应上篇文章的“缓存权限数据”章节。

1706439108099

获取缓存中的登录用户信息就可以使用以下方法了!

1
2
3
4
5
SaSession session = StpUtil.getTokenSession();
if (ObjectUtil.isNull(session)) {
return null;
}
LoginUser loginUser = (LoginUser) session.get(LOGIN_USER_KEY);

顶人下线

1706439683479

也就是说重复调用login登录,会有什么效果,redis会有什么变化呢?

1706439905503

因为这次是同端登录,可以看到sessiontokenSign变为最新的,token虽然还有两条,但其中一条指向的不再是账号id,而是-4,表示被顶下线,按理讲这里的token-session应该是要被清除了的,源码里这里个步骤被注释了,不太理解。保留疑问。

过期登录

写不动了😂

场景太多了,自己实践吧!

写在最后

拙作艰辛,字句心血,望诸君垂青,多予支持,不胜感激。


个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview