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