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)) }) })) } }
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论