基于SpringBoot的影像注册系统04 sa-token使用(源码解析 + 万字)

步骤 1 什么是sa-token

我是偶然间在知乎发现了这个框架,是国人写的,还不错,就用了。

官网:https://sa-token.dev33.cn

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证权限认证Session会话单点登录OAuth2.0微服务网关鉴权 等一系列权限相关问题。

步骤 2 pom.xml

<!-- Sa-Token 权限认证, 在线文档:http://sa-token.dev33.cn/ -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.24.0</version>
</dependency>

导入依赖即可

步骤 3 登录的时候怎么用sa-token的,什么原理?

看下登录方法:

/**
 * 用户 登录
 * @param user
 * @return
 */
@PostMapping("login.do")
public Result login(User user){

    //先判断用户名是否存在
    User userReal = null;
    if((userReal = service.getUserByUsername(user.getUserName())) == null){
        throw new BizException(ExceptionCodeEnum.ERROR_PARAM.setDesc("用户名不存在!"));
    }

    //再判断密码是否正确
    if(!userReal.getPassword().equals(user.getPassword())){
        throw new BizException(ExceptionCodeEnum.ERROR_PARAM.setDesc("密码错误!"));
    }

      StpUtil.login(userReal.getId());  return Result.success();

}

逻辑:查询username存不存在,再查询密码是否正确,只要没有抛异常就调用sa-token的login方法。

StpUtil.login(userReal.getId());

login方法传入我们user的id,比如:

image

把int类型的数值传进去了。

步骤 4 sa-token登录认证

核心思想

所谓登录认证,说白了就是限制某些API接口必须登录后才能访问(例:查询我的账号资料)
那么如何判断一个会话是否登录?框架会在登录成功后给你做个标记,每次登录认证时校验这个标记,有标记者视为已登录,无标记者视为未登录!

所以,只要登录成功,我们就用

StpUtil.login(userReal.getId());

记录了当前用户的登录id

步骤 5 StpUtil.login(userReal.getId())的秘密

我们如果不使用sa-token,怎么做登录功能呢?

是不是需要在传参的时候加一个HttpServletRequest,然后再用getSession方法获取session,把登录用户的信息放到session中?

而现在,只需要一句StpUtil.login(userReal.getId())就维持了登录状态,想也知道sa-token框架肯定也是把登录id放到session中了。因为没有用redis,所以要维持登录肯定是用了session或cookie。

如果是第一次登录,就生成tokenValue

image

然后把生成的token写入storage([存储器] 包装类SaStorage),

// 在当前会话写入当前tokenValue 
setTokenValue(tokenValue, loginModel.getCookieTimeout());

setTokenValue方法:

/**
 * 在当前会话写入当前TokenValue 
 * @param tokenValue token值 
 * @param cookieTimeout Cookie存活时间(秒)
 */
public void setTokenValue(String tokenValue, int cookieTimeout){
    SaTokenConfig config = getConfig();

    // 将token保存到[存储器]里  
    SaStorage storage = SaHolder.getStorage();

    // 判断是否配置了token前缀 
    String tokenPrefix = config.getTokenPrefix();
    if(SaFoxUtil.isEmpty(tokenPrefix)) {
        storage.set(splicingKeyJustCreatedSave(), tokenValue);  
    } else {
        // 如果配置了token前缀,则拼接上前缀一起写入 
        storage.set(splicingKeyJustCreatedSave(), tokenPrefix + SaTokenConsts.TOKEN_CONNECTOR_CHAT + tokenValue);   
    }

    // 注入Cookie 
    if(config.getIsReadCookie()){
        SaResponse response = SaHolder.getResponse();
        response.addCookie(getTokenName(), tokenValue, "/", config.getCookieDomain(), cookieTimeout);
    }
}

SaStorage是一个接口,set方法是把token存入request对象中。

关键是下面一段:

esponse.addCookie(getTokenName(), tokenValue, "/", config.getCookieDomain(), cookieTimeout);

这句话利用cookie保存了当前登录用户的token。

谷歌浏览器查看cookie方式:右上角有三个点的按钮 - 设置

image

image

搜索localhost,找到satoken,这就是上面代码中getTokenName()方法的返回值

image

步骤 6 sa-token为什么能获取到response对象?SaHolder的秘密。。。(深挖,看不懂就跳过,没事)

之前我也一直想不通一个问题,sa-token用起来也太方便了吧,就这么一句话,什么都搞定了。我也不需要去关心session,也不要管HttpServletResponse啥的。

秘密就在这:

SaResponse response = SaHolder.getResponse();

SaHolder调用getResponse方法得到SaResponse, 这个SaResponse是一个接口

package cn.dev33.satoken.context.model;

/**
 * Response 包装类 
 * @author kong
 *
 */
public interface SaResponse {

    /**
     * 获取底层源对象 
     * @return see note 
     */
    public Object getSource();

    /**
     * 删除指定Cookie 
     * @param name Cookie名称
     */
    public void deleteCookie(String name);

    /**
     * 写入指定Cookie 
     * @param name     Cookie名称
     * @param value    Cookie值
     * @param path     Cookie路径
     * @param domain   Cookie的作用域
     * @param timeout  过期时间 (秒)
     */
    public void addCookie(String name, String value, String path, String domain, int timeout);

    /**
     * 在响应头里写入一个值 
     * @param name 名字
     * @param value 值 
     * @return 对象自身 
     */
    public SaResponse setHeader(String name, String value);

    /**
     * 在响应头写入 [Server] 服务器名称 
     * @param value 服务器名称  
     * @return 对象自身 
     */
    public default SaResponse setServer(String value) {
        return this.setHeader("Server", value);
    }

    /**
     * 重定向 
     * @param url 重定向地址 
     * @return 任意值 
     */
    public Object redirect(String url);

}

我们目前只用了addCookie方法,然后再看SaResponse的实现类

image

只有一个实现类,addCookie方法如下:

/**
 * 写入指定Cookie 
 */
@Override
public void addCookie(String name, String value, String path, String domain, int timeout) {
    Cookie cookie = new Cookie(name, value);
    if(SaFoxUtil.isEmpty(path) == true) {
        path = "/";
    }
    if(SaFoxUtil.isEmpty(domain) == false) {
        cookie.setDomain(domain);
    }
    cookie.setPath(path);
    cookie.setMaxAge(timeout);
    response.addCookie(cookie);
}

和我们预期的是一致的。

现在的问题是,SaHolder究竟是怎么getResponse的?

代码如下:

public static SaResponse getResponse() {
    return SaManager.getSaTokenContext().getResponse();
}

原来是saTokenContext的绝活,再看SaManager的getSaTokenContext方法:

public static SaTokenContext getSaTokenContext() {
    if (saTokenContext == null) {
        synchronized (SaManager.class) {
            if (saTokenContext == null) {
                setSaTokenContext(new SaTokenContextDefaultImpl());
            }
        }
    }
    return saTokenContext;
}

我以为秘密在 new SaTokenContextDefaultImpl() 里面。

SaTokenContextDefaultImpl是Sa-Token 上下文处理器 [默认实现类]。

结果一看代码,懵逼了:

image

敢情这是在嘲讽我吗,这估计是不正常的情况下才会走到这吧,肯定不是。那如果不是用的SaTokenContextDefaultImpl,难道saTokenContext本来就有值的?

image

saTokenContext是一个接口,有三个实现类:

image

因为这是SpringBoot项目怎么看也像是SaTokenContextForSpring

image

重新登录看看,发现

image

这个方法返回了SaResponseForServlet对象,属于SaResponse的实现类,所以在上面SaHolder的getResponse方法获取的实际是SaResponseForServlet对象。又是多态,多态好是好,封装了底层的实现。只给出接口类的简单操作,就是如果翻源码会有点麻烦,需要一层层地找。

SaResponseForServlet的实例化代码如下

image

那么关键就在于SpringMVCUtil是怎么getResponse的?

image

关键就在于RequestContextHolder了,它调用了getRequestAttributes方法,得到 servletRequestAttributes对象,查看数据发现

image

哦,到这一步就有点豁然开朗了,response的值是:com.alibaba.druid.support.http.WebStatFilter$StatHttpServletResponseWrapper@3cca73f3

至于这个玩意又是什么东东,老实说,我目前还没有完全搞明白,目前只知道这个类的位置是在druid的jar包里面:

image

image

点开WebStatFilter,发现里面有个内部类StatHttpServletResponseWrapper,原来那个$是内部类的意思

public final static class StatHttpServletResponseWrapper extends HttpServletResponseWrapper implements HttpServletResponse {
        //初始值应该设置为:HttpServletResponse.SC_OK,而不是 0。
        private int status = HttpServletResponse.SC_OK;

        public StatHttpServletResponseWrapper(HttpServletResponse response){
            super(response);
        }

        public void setStatus(int statusCode) {
            super.setStatus(statusCode);
            this.status = statusCode;
        }

        @SuppressWarnings("deprecation")
        public void setStatus(int statusCode, String statusMessage) {
            super.setStatus(statusCode, statusMessage);
            this.status = statusCode;
        }

        public void sendError(int statusCode, String statusMessage) throws IOException {
            super.sendError(statusCode, statusMessage);
            this.status = statusCode;
        }

        public void sendError(int statusCode) throws IOException {
            super.sendError(statusCode);
            this.status = statusCode;
        }

        public int getStatus() {
            return status;
        }
    }

这个StatHttpServletResponseWrapper类继承了HttpServletResponseWrapper,而HttpServletResponseWrapper又继承了ServletResponseWrapperServletResponseWrapper实现了HttpServletResponse(嗯??见到 HttpServletResponse了,终于看到了老朋友,不容易)

因为这个项目使用了Druid数据源,所以肯定是某个时间点把这个类new出来了,因为多态的关系,不会影响其他功能,这个咱们先讲到这。

好了,回到SpringMVCUtil的getResponse方法:

/**
 * 获取当前会话的 response
 * @return response
 */
public static HttpServletResponse getResponse() {
    ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    if(servletRequestAttributes == null) {
        throw new SaTokenException("非Web上下文无法获取Response");
    }
    return servletRequestAttributes.getResponse();
}

我知道你一定有很多的疑惑,比如RequestContextHolder是啥,怎么就getRequestAttributes了,servletRequestAttributes又是啥,凭什么就可以getResponse?

别着急,咱一个一个来。

首先是RequestContextHolder,它的getRequestAttributes实现如下:

image

注释说返回绑定当前线程的RequestAttributes(请求参数),用到了requestAttributesHolder

private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
            new NamedThreadLocal<RequestAttributes>("Request attributes");

这个requestAttributesHolder是一个ThreadLocal,是线程本地变量,并且设置了final和static。设置final是因为不希望被修改,设置static是为了方便其他地方也能调用它。

ThreadLocal是java.lang包下面的,已经和框架无关了。其get方法源码如下:

image

简单说一下,ThreadLocal是和当前线程相关的,具体原理我们就先不说了,等以后重新开一个章节单独。现在,你只需要知道,Spring框架的org.springframework.web.context.request帮我们完成了这个事情,他就是拿到response了。而sa-token框架是直接取用了Spring框架的Response。

这个Response是和当前线程相关的,每个用户访问Tomcat,走到Controller,service,dao再返回数据,这整个过程都是在一个线程里面,和其他用户的访问无关,这个叫做线程隔离。

咳咳,最后我们捋一捋:

讲了这么多,其实我们的问题就是SaHolder为什么能获取response对象,现在直接说结论,因为SaHolder调用了SaManager.getSaTokenContext(),得到了SaTokenContext才可以通过getResponse方法获取SaResponse,而SaTokenContext的真实身份其实是SaTokenContextForSpring,SaTokenContextForSpring重写了getResponse,就是在这个方法去调用Spring的Response。

image

我们理解到这一步已经足够了。

步骤 7 sa-token默认配置

如果你不单独做配置,就采用默认配置,默认配置是写在SaTokenConfig中的。

/** token名称 (同时也是cookie名称) */
private String tokenName = "satoken";

/** token的长久有效期(单位:秒) 默认30天, -1代表永久 */
private long timeout = 60 * 60 * 24 * 30;

/**
 * token临时有效期 [指定时间内无操作就视为token过期] (单位: 秒), 默认-1 代表不限制
 * (例如可以设置为1800代表30分钟内无操作就过期)
 */
private long activityTimeout = -1;

/** 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录) */
private Boolean isConcurrent = true;

/** 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) */
private Boolean isShare = true;

/** 是否尝试从请求体里读取token */
private Boolean isReadBody = true;

/** 是否尝试从header里读取token */
private Boolean isReadHead = true;

/** 是否尝试从cookie里读取token */
private Boolean isReadCookie = true;

/** token风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik) */
private String tokenStyle = "uuid";

/** 默认dao层实现类中,每次清理过期数据间隔的时间 (单位: 秒) ,默认值30秒,设置为-1代表不启动定时清理 */
private int dataRefreshPeriod = 30;

/** 获取[token专属session]时是否必须登录 (如果配置为true,会在每次获取[token-session]时校验是否登录) */
private Boolean tokenSessionCheckLogin = true;

/** 是否打开自动续签 (如果此值为true, 框架会在每次直接或间接调用getLoginId()时进行一次过期检查与续签操作)  */
private Boolean autoRenew = true;

/** 写入Cookie时显式指定的作用域, 常用于单点登录二级域名共享Cookie的场景 */
private String cookieDomain;

/** token前缀, 格式样例(satoken: Bearer xxxx-xxxx-xxxx-xxxx) */
private String tokenPrefix;

/** 是否在初始化配置时打印版本字符画 */
private Boolean isPrint = true;

/** 是否打印操作日志 */
private Boolean isLog = false;

/**
 * jwt秘钥 (只有集成 sa-token-temp-jwt 模块时此参数才会生效) 
 */
private String jwtSecretKey;

/**
 * Id-Token的有效期 (单位: 秒)
 */
private long idTokenTimeout = 60 * 60 * 24;

步骤 8 考考你,现在有记住我的功能吗?

Cookie作为浏览器提供的默认会话跟踪机制,其生命周期有两种形式,分别是:

  • 临时Cookie:有效期为本次会话,只要关闭浏览器窗口,Cookie就会消失
  • 永久Cookie:有效期为一个具体的时间,在时间未到期之前,即使用户关闭了浏览器Cookie也不会消失

因此,记住我的功能对应的就是永久Cookie

登录的时候,我们的代码是这样写的:

StpUtil.login(userReal.getId());

源码链:

image

image

SaLoginModel的isLastingCookie属性是Boolean的,注意是Boolean而不是boolean,所以默认值是null!

新建SaLoginModel后,会走到这个方法:

public void login(Object id, SaLoginModel loginModel)

image

上面代码说明了,loginModel会根据config调用自身的build方法。

image

真相大白,sa-token默认就是记住我的模式。

哈哈,刚刚给作者发了个issue:

image

image

步骤 9 如何获取登录用户ID?

页面:my.jsp

该页面可以查看个人信息,对应接口为 /user/getUserInfo.do

/**
 * 查询当前用户信息
 * @param user
 * @return
 */
@PostMapping("getUserInfo.do")
public User getUserInfo(){
    int loginIdAsInt = StpUtil.getLoginIdAsInt();
    User user = service.getUserById(loginIdAsInt);
    return user;
}

这个getLoginIdAsInt方法,源码链如下

image

image

找到stpLogin::getLoginId 方法

/** 
 * 获取当前会话账号id, 如果未登录,则抛出异常 
 * @return 账号id
 */
public Object getLoginId() {
    // 如果正在[临时身份切换], 则返回临时身份 
    if(isSwitch()) {
        return getSwitchLoginId();
    }
    // 如果获取不到token,则抛出: 无token
    String tokenValue = getTokenValue();
    if(tokenValue == null) {
        throw NotLoginException.newInstance(loginType, NotLoginException.NOT_TOKEN);
    }
    // 查找此token对应loginId, 如果找不到则抛出:无效token 
    String loginId = getLoginIdNotHandle(tokenValue);
    if(loginId == null) {
        throw NotLoginException.newInstance(loginType, NotLoginException.INVALID_TOKEN, tokenValue);
    }
    // 如果是已经过期,则抛出已经过期 
    if(loginId.equals(NotLoginException.TOKEN_TIMEOUT)) {
        throw NotLoginException.newInstance(loginType, NotLoginException.TOKEN_TIMEOUT, tokenValue);
    }
    // 如果是已经被顶替下去了, 则抛出:已被顶下线 
    if(loginId.equals(NotLoginException.BE_REPLACED)) {
        throw NotLoginException.newInstance(loginType, NotLoginException.BE_REPLACED, tokenValue);
    }
    // 如果是已经被踢下线了, 则抛出:已被踢下线 
    if(loginId.equals(NotLoginException.KICK_OUT)) {
        throw NotLoginException.newInstance(loginType, NotLoginException.KICK_OUT, tokenValue);
    }
    // 检查是否已经 [临时过期]
    checkActivityTimeout(tokenValue);
    // 如果配置了自动续签, 则: 更新[最后操作时间] 
    if(getConfig().getAutoRenew()) {
        updateLastActivityToNow(tokenValue);
    }
    // 至此,返回loginId 
    return loginId;
}

核心就是getLoginIdNotHandle方法,点开

/**
  * 获取指定Token对应的账号id (不做任何特殊处理) 
  * @param tokenValue token值 
  * @return 账号id
  */
public String getLoginIdNotHandle(String tokenValue) {
    return getSaTokenDao().get(splicingKeyTokenValue(tokenValue));
}

这边获取了saTokenDao对象,SaTokenDao是一个接口,默认实现是SaTokenDaoDefaultImpl(好像也只有这么一个实现类)

让我们来看看是怎么实现的,找到了SaManager关于SaTokenDao的部分。

image

什么是SaTokenDao?

这个应该是sa-token的内部持久化容器,我不明白为啥作者要用xxxDao来命名,我还以为是存到数据库呢。我个人觉得用xxxContext,xxxBeanFactory来命名比较合适。

SaTokenDao的默认实现是SaTokenDaoDefaultImpl,里面维护了一个Map,用的是ConcurrentHashMap,看来这个部分是很重要的,看得出作者处处都在想着线程安全。

我们获取loginId,用的是get方法

image

key就是tokenValue(已经经过splicingKeyTokenValue方法修饰过,加了前缀TokenName和loginType)

真相大白,在login的时候,sa-token就在saTokenDao中根据tokenValue注册了loginId,其他地方需要取的时候,只需要问saTokenDao拿就行了。

本次实验中,

key=satoken:login:token:50b2dcf9-922b-4aaa-b000-33526199dd52,

value=6

步骤 10 sa-token作者回复

今天发现,sa-token作者回复我的issue

image

确实也有道理。

猜你喜欢

转载自blog.csdn.net/weixin_39570751/article/details/121291274