当前位置: 代码网 > it编程>编程语言>Java > springboot+vue3无感知刷新token实战教程

springboot+vue3无感知刷新token实战教程

2025年03月05日 Java 我要评论
web网站中,前后端交互时,通常使用token机制来做认证,token一般会设置有效期,当token过了有效期后,用户需要重新登录授权获取新的token,但是某些业务场景下,用户不希望频繁的进行登录授

web网站中,前后端交互时,通常使用token机制来做认证,token一般会设置有效期,当token过了有效期后,用户需要重新登录授权获取新的token,但是某些业务场景下,用户不希望频繁的进行登录授权,但是安全考虑,token的有效期不能设置太长时间,所以有了刷新token的设计,无感知刷新token的机制更进一步优化了用户体验,本文是博主实际业务项目中基于springboot和vue3无感知刷新token的代码实战。

首先介绍无感知刷新token的实现思路:

①首次授权颁发token时,我们通过后端给前端请求response中写入两种cookie

  • - access_token
  • - refresh_token(超时时间比access_token长一些)

需要注意:

-后端setcookie时httponly=true(限制cookie只能被http请求携带使用,不能被js操作)

-前端axios请求参数withcredentials=true(http请求时,自动携带token)

  • ②access_token失效时,抛出特殊异常,前后端约定http响应码(401),此时触发刷新token逻辑
  • ③前段http请求钩子中,如果出现http响应码为401时,立即触发刷新token逻辑,同时缓存后续请求,刷新token结束后,依次续发缓存中的请求

一、java后端

后端java框架使用springboot,spring-security

登录接口:

/**
 * @author lichenhao
 * @date 2023/2/8 17:41
 */
@restcontroller
public class authcontroller {

    /**
     * 登录方法
     *
     * @param loginbody 登录信息
     * @return 结果
     */
    @postmapping("/oauth")
    public ajaxresult login(@requestbody loginbody loginbody) {
        itokengranter granter = tokengranterbuilder.getgranter(loginbody.getgranttype());
        return granter.grant(loginbody);
    }
}


import lombok.data;

/**
 * 用户登录对象
 *
 * @author lichenhao
 */
@data
public class loginbody {

    /**
     * 用户名
     */
    private string username;

    /**
     * 用户密码
     */
    private string password;

    /**
     * 验证码
     */
    private string code;

    /**
     * 唯一标识
     */
    private string uuid;

    /*
     * granttype 授权类型
     * */
    private string granttype;

    /*
    * 是否直接强退该账号登陆的其他客户端
    * */
    private boolean forcelogoutflag;
}

token构造接口类和token实现类构造器如下:

/**
 * @author lichenhao
 * @date 2023/2/8 17:29
 * <p>
 * 获取token
 */
public interface itokengranter {

    ajaxresult grant(loginbody loginbody);
}


/**
 * @author lichenhao
 * @date 2023/2/8 17:29
 */
@allargsconstructor
public class tokengranterbuilder {

    /**
     * tokengranter缓存池
     */
    private static final map<string, itokengranter> granter_pool = new concurrenthashmap<>();

    static {
        granter_pool.put(captchatokengranter.grant_type, springutils.getbean(captchatokengranter.class));
        granter_pool.put(refreshtokengranter.grant_type, springutils.getbean(refreshtokengranter.class));
    }

    /**
     * 获取tokengranter
     *
     * @param granttype 授权类型
     * @return itokengranter
     */
    public static itokengranter getgranter(string granttype) {
        itokengranter tokengranter = granter_pool.get(stringutils.tostr(granttype, passwordtokengranter.grant_type));
        if (tokengranter == null) {
            throw new serviceexception("no granttype was found");
        } else {
            return tokengranter;
        }
    }

}

这里通过loginbody的granttype属性,指定实际的token构造实现类;同时,需要有token

本文我们用到了验证码方式和刷新token方式,如下:

1、token构造实现类

①验证码方式实现类

/**
 * @author lichenhao
 * @date 2023/2/8 17:32
 */
@component
public class captchatokengranter implements itokengranter {

    public static final string grant_type = "captcha";

    @autowired
    private sysloginservice loginservice;

    @override
    public ajaxresult grant(loginbody loginbody) {
        string username = loginbody.getusername();
        string code = loginbody.getcode();
        string password = loginbody.getpassword();
        string uuid = loginbody.getuuid();
        boolean forcelogoutflag = loginbody.getforcelogoutflag();

        ajaxresult ajaxresult = validateloginbody(username, password, code, uuid);
        // 验证码
        loginservice.validatecaptcha(username, code, uuid);
        // 登录
        loginservice.login(username, password, uuid, forcelogoutflag);
        // 删除验证码
        loginservice.deletecaptcha(uuid);
        return ajaxresult;
    }

    private ajaxresult validateloginbody(string username, string password, string code, string uuid) {
        if (stringutils.isblank(username)) {
            return ajaxresult.error("用户名必填");
        }
        if (stringutils.isblank(password)) {
            return ajaxresult.error("密码必填");
        }
        if (stringutils.isblank(code)) {
            return ajaxresult.error("验证码必填");
        }
        if (stringutils.isblank(uuid)) {
            return ajaxresult.error("uuid必填");
        }
        return ajaxresult.success();
    }
}


    /**
     * 登录验证
     *
     * @param username 用户名
     * @param password 密码
     * @return 结果
     */
    public void login(string username, string password, string uuid, boolean forcelogoutflag) {
        // 校验basic auth
        iclientdetails iclientdetails = tokenservice.validbasicauth();
        // 用户验证
        authentication authentication = null;
        try {
            usernamepasswordauthenticationtoken authenticationtoken = new usernamepasswordauthenticationtoken(username, password);
            authenticationcontextholder.setcontext(authenticationtoken);
            // 该方法会去调用userdetailsserviceimpl.loaduserbyusername
            authentication = authenticationmanager.authenticate(authenticationtoken);
        } catch (exception e) {
            if (e instanceof badcredentialsexception) {
                asyncmanager.me().execute(asyncfactory.recordlogininfor(username, constants.login_fail, messageutils.message("user.password.not.match")));
                throw new userpasswordnotmatchexception();
            } else {
                asyncmanager.me().execute(asyncfactory.recordlogininfor(username, constants.login_fail, e.getmessage()));
                throw new serviceexception(e.getmessage());
            }
        } finally {
            authenticationcontextholder.clearcontext();
        }
        loginuser loginuser = (loginuser) authentication.getprincipal();
        tokenservice.setuseragent(loginuser);
        long customerid = loginuser.getuser().getcustomerid();
        boolean singleclientflag = systemconfig.issingleclientflag();
        if(customerid != null){
            customer customer = customerservice.selectcustomerbyid(customerid);
            singleclientflag = customer.getsingleclientflag();
            log.info(string.format("客户【%s】单账号登录限制开关:%s", customer.getcode(), singleclientflag));
        }
        if(singleclientflag){
            list<sysuseronline> useronlinelist = useronlineservice.getuseronlinelist(null, username);
            if(collectionutils.isnotempty(useronlinelist)){
                if(forcelogoutflag != null && forcelogoutflag){
                    // 踢掉其他使用该账号登陆的客户端
                    useronlineservice.forcelogoutbysysuseronlinelist(useronlinelist);
                }else{
                    throw new serviceexception("【" + username + "】已登录,是否仍然登陆", 400);
                }
            }
        }
        // 生成token
        tokenservice.createtoken(iclientdetails, loginuser, uuid);
        asyncmanager.me().execute(asyncfactory.recordlogininfor(username, constants.login_success, messageutils.message("user.login.success")));
        recordlogininfo(loginuser.getuserid());
    }

②刷新token方式实现类

/**
 * @author lichenhao
 * @date 2023/2/8 17:35
 */
@component
public class refreshtokengranter implements itokengranter {

    public static final string grant_type = "refresh_token";

    @autowired
    private tokenservice tokenservice;

    @override
    public ajaxresult grant(loginbody loginbody) {
        tokenservice.refreshtoken();
        return ajaxresult.success();
    }
}

2、token相关操作:setcookie

①createtoken

    /**
     * 创建令牌
     * 注意:access_token和refresh_token 使用同一个tokenid
     */
    public void createtoken(iclientdetails clientdetails, loginuser loginuser, string tokenid) {

        if(loginuser == null){
            throw new forbiddenexception("用户信息无效,请重新登陆!");
        }

        loginuser.settokenid(tokenid);

        string username = loginuser.getusername();
        string clientid = clientdetails.getclientid();

        // 设置jwt要携带的用户信息
        map<string, object> claimsmap = new hashmap<>();
        initclaimsmap(claimsmap, loginuser);

        long nowmillis = system.currenttimemillis();
        date now = new date(nowmillis);

        int accesstokenvalidity = clientdetails.getaccesstokenvalidity();
        long accesstokenexpmillis = nowmillis + accesstokenvalidity * millis_second;
        date accesstokenexpdate = new date(accesstokenexpmillis);
        string accesstoken = createjwttoken(secureconstant.access_token, accesstokenexpdate, now, jwt_token_secret, claimsmap, clientid, tokenid, username);

        int refreshtokenvalidity = clientdetails.getrefreshtokenvalidity();
        long refreshtokenexpmillis = nowmillis + refreshtokenvalidity * millis_second;
        date refreshtokenexpdate = new date(refreshtokenexpmillis);
        string refreshtoken = createjwttoken(secureconstant.refresh_token, refreshtokenexpdate, now, jwt_refresh_token_secret, claimsmap, clientid, tokenid, username);

        // 写入cookie中
        httpservletresponse response = servletutils.getresponse();
        webutil.setcookie(response, secureconstant.access_token, accesstoken, accesstokenvalidity);
        webutil.setcookie(response, secureconstant.refresh_token, refreshtoken, refreshtokenvalidity);

        //插入缓存(过期时间为最长过期时间=refresh_token的过期时间 理论上,保持操作的情况下,一直会被刷新)
        loginuser.setlogintime(nowmillis);
        loginuser.setexpiretime(refreshtokenexpmillis);
        updateusercache(loginuser);
    }

    private void initclaimsmap(map<string, object> claims, loginuser loginuser) {
        // 添加jwt自定义参数
    }

    /**
     * 生成jwt token
     *
     * @param jwttokentype token类型:access_token、refresh_token
     * @param expdate      token过期日期
     * @param now          当前日期
     * @param signkey      签名key
     * @param claimsmap    jwt自定义信息(可携带额外的用户信息)
     * @param clientid     应用id
     * @param tokenid      token的唯一标识(建议同一组 access_token、refresh_token 使用一个)
     * @param subject      jwt下发的用户标识
     * @return token字符串
     */
    private string createjwttoken(string jwttokentype, date expdate, date now, string signkey, map<string, object> claimsmap, string clientid, string tokenid, string subject) {

        jwtbuilder jwtbuilder = jwts.builder().setheaderparam("typ", "jwt")
                .setid(tokenid)
                .setsubject(subject)
                .signwith(signaturealgorithm.hs512, signkey);

        //设置jwt参数(user维度)
        claimsmap.foreach(jwtbuilder::claim);

        //设置应用id
        jwtbuilder.claim(secureconstant.claims_client_id, clientid);

        //设置token type
        jwtbuilder.claim(secureconstant.claims_token_type, jwttokentype);

        //添加token过期时间
        jwtbuilder.setexpiration(expdate).setnotbefore(now);
        return jwtbuilder.compact();
    }

    /*
     * 更新缓存中的用户信息
     * */
    public void updateusercache(loginuser loginuser) {
        // 根据tokenid将loginuser缓存
        string userkey = gettokenkey(loginuser.gettokenid());
        redisservice.setcacheobject(userkey, loginuser, parseintbylong(loginuser.getexpiretime() - loginuser.getlogintime()), timeunit.milliseconds);
    }

    private string gettokenkey(string uuid) {
        return "login_tokens:" + uuid;
    }

②refreshtoken

    /**
     * 刷新令牌有效期
     */
    public void refreshtoken() {
        // 从cookie中拿到refreshtoken
        string refreshtoken = webutil.getcookieval(servletutils.getrequest(), secureconstant.refresh_token);
        if (stringutils.isblank(refreshtoken)) {
            throw new forbiddenexception("认证失败!");
        }
        // 验证 refreshtoken 是否有效
        claims claims = parsetoken(refreshtoken, jwt_refresh_token_secret);
        if (claims == null) {
            throw new forbiddenexception("认证失败!");
        }
        string clientid = stringutils.tostr(claims.get(secureconstant.claims_client_id));
        string tokenid = claims.getid();
        loginuser loginuser = getloginuserbytokenid(tokenid);
        if(loginuser == null){
            throw new forbiddenexception("用户信息无效,请重新登陆!");
        }
        iclientdetails clientdetails = getclientdetailsservice().loadclientbyclientid(clientid);
        // 删除原token缓存
        delloginusercache(tokenid);
        // 重新生成token
        createtoken(clientdetails, loginuser, idutils.simpleuuid());
    }

    /**
     * 根据tokenid获取用户信息
     *
     * @return 用户信息
     */
    public loginuser getloginuserbytokenid(string tokenid) {
        string userkey = gettokenkey(tokenid);
        loginuser user = redisservice.getcacheobject(userkey);
        return user;
    }

    /**
     * 删除用户缓存
     */
    public void delloginusercache(string tokenid) {
        if (stringutils.isnotempty(tokenid)) {
            string userkey = gettokenkey(tokenid);
            redisservice.deleteobject(userkey);
        }
    }

③异常码

  • 401:access_token无效,开始刷新token逻辑
  • 403:refresh_token无效,或者其他需要跳转登录页面的场景

二、前端(vue3+axios)

// 创建axios实例
const service = axios.create({
    // axios中请求配置有baseurl选项,表示请求url公共部分
    baseurl: import.meta.env.vite_app_base_api,
    // 超时
    timeout: 120000,
    withcredentials: true
})

// request拦截器
service.interceptors.request.use(config => {
    // do something
    return config
}, error => {

})


// 响应拦截器
service.interceptors.response.use(res => {
        loadinginstance?.close()
        loadinginstance = null
        // 未设置状态码则默认成功状态
        const code = res.data.code || 200;
        // 获取错误信息
        const msg = errorcode[code] || res.data.msg || errorcode['default']
        if (code === 500) {
            elmessage({message: msg, type: 'error'})
            return promise.reject(new error(msg))
        } else if (code === 401) {
            return refreshfun(res.config);
        } else if (code === 601) {
            elmessage({message: msg, type: 'warning'})
            return promise.reject(new error(msg))
        } else if (code == 400) {
            // 需要用户confirm是否强制登陆
            return promise.resolve(res.data)
        } else if (code !== 200) {
            elnotification.error({title: msg})
            return promise.reject('error')
        } else {
            return promise.resolve(res.request.responsetype === 'blob' ? res : res.data)
        }
    },
    error => {
        loadinginstance?.close()
        loadinginstance = null
        if (error.response.status == 401) {
            return refreshfun(error.config);
        }
        let {message} = error;
        if (message == "network error") {
            message = "后端接口连接异常";
        } else if (message.includes("timeout")) {
            message = "系统接口请求超时";
        } else {
            message = error.response.data ? error.response.data.msg : 'message'
        }
        elmessage({message: message, type: 'error', duration: 5 * 1000})
        return promise.reject(error)
    }
)

// 正在刷新标识,避免重复刷新
let refreshing = false;
// 请求等待队列
let waitqueue = [];

function refreshfun(config) {
    if (refreshing == false) {
        refreshing = true;
        return useuserstore().refreshtoken().then(() => {
            waitqueue.foreach(callback => callback()); // 已成功刷新token,队列中的所有请求重试
            waitqueue = [];
            refreshing = false;
            return service(config)
        }).catch((err) => {
            waitqueue = [];
            refreshing = false;
            if (err.response) {
                if (err.response.status === 403) {
                    elmessagebox.confirm('登录状态已过期(认证失败),您可以继续留在该页面,或者重新登录', '系统提示', {
                        confirmbuttontext: '重新登录',
                        cancelbuttontext: '取消',
                        type: 'warning'
                    }).then(() => {
                        useuserstore().logoutclear();
                        router.push(`/login`);
                    }).catch(() => {

                    });
                    return promise.reject()
                } else {
                    console.log('err:' + (err.response && err.response.data.msg) ? err.response.data.msg : err)
                }
            } else {
                elmessage({
                    message: err.message,
                    type: 'error',
                    duration: 5 * 1000
                })
            }
        })
    } else {
        // 正在刷新token,返回未执行resolve的promise,刷新token执行回调
        return new promise((resolve => {
            waitqueue.push(() => {
                resolve(service(config))
            })
        }))
    }
}

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com