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

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview


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

项目启动

Sa-Token登录详解_redis

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

login

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

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

Sa-Token登录详解_session_02

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

Sa-Token登录详解_springboot_03

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检查,异常情况有下

Sa-Token登录详解_satoken_04

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)。这也就是官网的同端互斥登录

Sa-Token登录详解_redis_05

因为我是配置了不允许同端登录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函数式接口、lambadastream都应该了解的。

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

Sa-Token登录详解_token_06

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

那么replaced就过了。

Sa-Token登录详解_session_07

新建token

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

Sa-Token登录详解_satoken_08

createTokenValue又是SaStrategy的一个策略。

Sa-Token登录详解_redis_09

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

Sa-Token登录详解_redis_10

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

Sa-Token登录详解_satoken_11

generateUniqueToken也是SaStrategy的一个策略。

Sa-Token登录详解_session_12

其函数式定义是这样的

Sa-Token登录详解_redis_13

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

Sa-Token登录详解_satoken_14

1.4、获取Account-Session,续期

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

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

创建session

这次需要创建session

Sa-Token登录详解_session_15

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

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

Sa-Token登录详解_token_16

续期

Sa-Token登录详解_token_17

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

Sa-Token登录详解_redis_18

1.6、保存token-id映射

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

如下拼接redisKey存入账号id

Sa-Token登录详解_session_19

1.7、写入token最后活跃时间

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

如下

Sa-Token登录详解_session_20

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

Sa-Token登录详解_redis_21

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

Sa-Token登录详解_satoken_22

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

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

分析总结

登录日志

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

Sa-Token登录详解_session_23

Redis数据

查看redis数据有下

Sa-Token登录详解_token_24

session

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

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

Sa-Token登录详解_springboot_25

token映射id

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

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

Sa-Token登录详解_token_26

last-active

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

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

Sa-Token登录详解_redis_27

token-session

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

Sa-Token登录详解_token_28

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

Sa-Token登录详解_token_29

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

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

顶人下线

Sa-Token登录详解_redis_30

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

Sa-Token登录详解_redis_31

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

过期登录

写不动了😂

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

写在最后

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


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

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview

Sa-Token登录详解_redis_32