springboot+vue3無感知刷新token實(shí)戰(zhàn)教程
web網(wǎng)站中,前后端交互時(shí),通常使用token機(jī)制來做認(rèn)證,token一般會(huì)設(shè)置有效期,當(dāng)token過了有效期后,用戶需要重新登錄授權(quán)獲取新的token,但是某些業(yè)務(wù)場景下,用戶不希望頻繁的進(jìn)行登錄授權(quán),但是安全考慮,token的有效期不能設(shè)置太長時(shí)間,所以有了刷新token的設(shè)計(jì),無感知刷新token的機(jī)制更進(jìn)一步優(yōu)化了用戶體驗(yàn),本文是博主實(shí)際業(yè)務(wù)項(xiàng)目中基于springboot和vue3無感知刷新token的代碼實(shí)戰(zhàn)。
首先介紹無感知刷新token的實(shí)現(xiàn)思路:
①首次授權(quán)頒發(fā)token時(shí),我們通過后端給前端請求response中寫入兩種cookie
- - access_token
- - refresh_token(超時(shí)時(shí)間比access_token長一些)
需要注意:
-后端setCookie時(shí)httpOnly=true(限制cookie只能被http請求攜帶使用,不能被js操作)
-前端axios請求參數(shù)withCredentials=true(http請求時(shí),自動(dòng)攜帶token)
- ②access_token失效時(shí),拋出特殊異常,前后端約定http響應(yīng)碼(401),此時(shí)觸發(fā)刷新token邏輯
- ③前段http請求鉤子中,如果出現(xiàn)http響應(yīng)碼為401時(shí),立即觸發(fā)刷新token邏輯,同時(shí)緩存后續(xù)請求,刷新token結(jié)束后,依次續(xù)發(fā)緩存中的請求
一、java后端
后端java框架使用springboot,spring-security
登錄接口:
/**
* @author lichenhao
* @date 2023/2/8 17:41
*/
@RestController
public class AuthController {
/**
* 登錄方法
*
* @param loginBody 登錄信息
* @return 結(jié)果
*/
@PostMapping("/oauth")
public AjaxResult login(@RequestBody LoginBody loginBody) {
ITokenGranter granter = TokenGranterBuilder.getGranter(loginBody.getGrantType());
return granter.grant(loginBody);
}
}
import lombok.Data;
/**
* 用戶登錄對(duì)象
*
* @author lichenhao
*/
@Data
public class LoginBody {
/**
* 用戶名
*/
private String username;
/**
* 用戶密碼
*/
private String password;
/**
* 驗(yàn)證碼
*/
private String code;
/**
* 唯一標(biāo)識(shí)
*/
private String uuid;
/*
* grantType 授權(quán)類型
* */
private String grantType;
/*
* 是否直接強(qiáng)退該賬號(hào)登陸的其他客戶端
* */
private Boolean forceLogoutFlag;
}token構(gòu)造接口類和token實(shí)現(xiàn)類構(gòu)造器如下:
/**
* @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 授權(quán)類型
* @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屬性,指定實(shí)際的token構(gòu)造實(shí)現(xiàn)類;同時(shí),需要有token
本文我們用到了驗(yàn)證碼方式和刷新token方式,如下:
1、token構(gòu)造實(shí)現(xiàn)類
①驗(yàn)證碼方式實(shí)現(xiàn)類
/**
* @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);
// 驗(yàn)證碼
loginService.validateCaptcha(username, code, uuid);
// 登錄
loginService.login(username, password, uuid, forceLogoutFlag);
// 刪除驗(yàn)證碼
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("驗(yàn)證碼必填");
}
if (StringUtils.isBlank(uuid)) {
return AjaxResult.error("uuid必填");
}
return AjaxResult.success();
}
}
/**
* 登錄驗(yàn)證
*
* @param username 用戶名
* @param password 密碼
* @return 結(jié)果
*/
public void login(String username, String password, String uuid, Boolean forceLogoutFlag) {
// 校驗(yàn)basic auth
IClientDetails iClientDetails = tokenService.validBasicAuth();
// 用戶驗(yàn)證
Authentication authentication = null;
try {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 該方法會(huì)去調(diào)用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】單賬號(hào)登錄限制開關(guān):%s", customer.getCode(), singleClientFlag));
}
if(singleClientFlag){
List<SysUserOnline> userOnlineList = userOnlineService.getUserOnlineList(null, username);
if(CollectionUtils.isNotEmpty(userOnlineList)){
if(forceLogoutFlag != null && forceLogoutFlag){
// 踢掉其他使用該賬號(hào)登陸的客戶端
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方式實(shí)現(xiàn)類
/**
* @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相關(guān)操作:setCookie
①createToken
/**
* 創(chuàng)建令牌
* 注意:access_token和refresh_token 使用同一個(gè)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();
// 設(shè)置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);
//插入緩存(過期時(shí)間為最長過期時(shí)間=refresh_token的過期時(shí)間 理論上,保持操作的情況下,一直會(huì)被刷新)
loginUser.setLoginTime(nowMillis);
loginUser.setExpireTime(refreshTokenExpMillis);
updateUserCache(loginUser);
}
private void initClaimsMap(Map<String, Object> claims, LoginUser loginUser) {
// 添加jwt自定義參數(shù)
}
/**
* 生成jwt token
*
* @param jwtTokenType token類型:access_token、refresh_token
* @param expDate token過期日期
* @param now 當(dāng)前日期
* @param signKey 簽名key
* @param claimsMap jwt自定義信息(可攜帶額外的用戶信息)
* @param clientId 應(yīng)用id
* @param tokenId token的唯一標(biāo)識(shí)(建議同一組 access_token、refresh_token 使用一個(gè))
* @param subject jwt下發(fā)的用戶標(biāo)識(shí)
* @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);
//設(shè)置JWT參數(shù)(user維度)
claimsMap.forEach(jwtBuilder::claim);
//設(shè)置應(yīng)用id
jwtBuilder.claim(SecureConstant.CLAIMS_CLIENT_ID, clientId);
//設(shè)置token type
jwtBuilder.claim(SecureConstant.CLAIMS_TOKEN_TYPE, jwtTokenType);
//添加Token過期時(shí)間
jwtBuilder.setExpiration(expDate).setNotBefore(now);
return jwtBuilder.compact();
}
/*
* 更新緩存中的用戶信息
* */
public void updateUserCache(LoginUser loginUser) {
// 根據(jù)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("認(rèn)證失敗!");
}
// 驗(yàn)證 refreshToken 是否有效
Claims claims = parseToken(refreshToken, JWT_REFRESH_TOKEN_SECRET);
if (claims == null) {
throw new ForbiddenException("認(rèn)證失??!");
}
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());
}
/**
* 根據(jù)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無效,或者其他需要跳轉(zhuǎn)登錄頁面的場景
二、前端(vue3+axios)
// 創(chuàng)建axios實(shí)例
const service = axios.create({
// axios中請求配置有baseURL選項(xiàng),表示請求URL公共部分
baseURL: import.meta.env.VITE_APP_BASE_API,
// 超時(shí)
timeout: 120000,
withCredentials: true
})
// request攔截器
service.interceptors.request.use(config => {
// do something
return config
}, error => {
})
// 響應(yīng)攔截器
service.interceptors.response.use(res => {
loadingInstance?.close()
loadingInstance = null
// 未設(shè)置狀態(tài)碼則默認(rèn)成功狀態(tài)
const code = res.data.code || 200;
// 獲取錯(cuò)誤信息
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是否強(qiáng)制登陸
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 = "系統(tǒng)接口請求超時(shí)";
} else {
message = error.response.data ? error.response.data.msg : 'message'
}
ElMessage({message: message, type: 'error', duration: 5 * 1000})
return Promise.reject(error)
}
)
// 正在刷新標(biāo)識(shí),避免重復(fù)刷新
let refreshing = false;
// 請求等待隊(duì)列
let waitQueue = [];
function refreshFun(config) {
if (refreshing == false) {
refreshing = true;
return useUserStore().refreshToken().then(() => {
waitQueue.forEach(callback => callback()); // 已成功刷新token,隊(duì)列中的所有請求重試
waitQueue = [];
refreshing = false;
return service(config)
}).catch((err) => {
waitQueue = [];
refreshing = false;
if (err.response) {
if (err.response.status === 403) {
ElMessageBox.confirm('登錄狀態(tài)已過期(認(rèn)證失?。?,您可以繼續(xù)留在該頁面,或者重新登錄', '系統(tǒng)提示', {
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,返回未執(zhí)行resolve的Promise,刷新token執(zhí)行回調(diào)
return new Promise((resolve => {
waitQueue.push(() => {
resolve(service(config))
})
}))
}
}總結(jié)
以上為個(gè)人經(jīng)驗(yàn),希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
Java中與數(shù)字相關(guān)的常用類的用法詳解
在我們的代碼中,經(jīng)常會(huì)遇到一些數(shù)字&數(shù)學(xué)問題、隨機(jī)數(shù)問題、日期問題和系統(tǒng)設(shè)置問題等,為了解決這些問題,Java給我們提供了多個(gè)處理相關(guān)問題的類,比如Number類、Math類、Random類等等,本篇文章我們先從Number數(shù)字類和Math數(shù)學(xué)類學(xué)起2023-05-05
Java 實(shí)戰(zhàn)項(xiàng)目之誠途旅游系統(tǒng)的實(shí)現(xiàn)流程
讀萬卷書不如行萬里路,只學(xué)書上的理論是遠(yuǎn)遠(yuǎn)不夠的,只有在實(shí)戰(zhàn)中才能獲得能力的提升,本篇文章手把手帶你用java+SpringBoot+Vue+maven+Mysql實(shí)現(xiàn)一個(gè)精美的物流管理系統(tǒng),大家可以在過程中查缺補(bǔ)漏,提升水平2021-11-11
Spring Boot 自動(dòng)配置的實(shí)現(xiàn)
這篇文章主要介紹了Spring Boot 自動(dòng)配置的實(shí)現(xiàn),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-08-08
SpringBoot?整合?ShardingSphere4.1.1實(shí)現(xiàn)分庫分表功能
ShardingSphere是一套開源的分布式數(shù)據(jù)庫中間件解決方案組成的生態(tài)圈,它由Sharding-JDBC、Sharding-Proxy和Sharding-Sidecar(計(jì)劃中)這3款相互獨(dú)立的產(chǎn)品組成,本文給大家介紹SpringBoot?整合?ShardingSphere4.1.1實(shí)現(xiàn)分庫分表,感興趣的朋友一起看看吧2023-12-12
springboot整合Quartz實(shí)現(xiàn)動(dòng)態(tài)配置定時(shí)任務(wù)的方法
本篇文章主要介紹了springboot整合Quartz實(shí)現(xiàn)動(dòng)態(tài)配置定時(shí)任務(wù)的方法,非常具有實(shí)用價(jià)值,需要的朋友可以參考下2017-10-10
java中類和對(duì)象的知識(shí)點(diǎn)總結(jié)
在本篇文章里小編給大家整理了一篇關(guān)于java中類和對(duì)象的知識(shí)點(diǎn)總結(jié),有需要的朋友們可以學(xué)習(xí)下。2020-12-12
詳解SpringBoot+Thymeleaf 基于HTML5的現(xiàn)代模板引擎
本篇文章主要介紹了SpringBoot+Thymeleaf 基于HTML5的現(xiàn)代模板引擎,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-10-10

