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ù)場(chǎng)景下,用戶不希望頻繁的進(jìn)行登錄授權(quán),但是安全考慮,token的有效期不能設(shè)置太長(zhǎng)時(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í),我們通過后端給前端請(qǐng)求response中寫入兩種cookie
- - access_token
- - refresh_token(超時(shí)時(shí)間比access_token長(zhǎng)一些)
需要注意:
-后端setCookie時(shí)httpOnly=true(限制cookie只能被http請(qǐng)求攜帶使用,不能被js操作)
-前端axios請(qǐng)求參數(shù)withCredentials=true(http請(qǐng)求時(shí),自動(dòng)攜帶token)
- ②access_token失效時(shí),拋出特殊異常,前后端約定http響應(yīng)碼(401),此時(shí)觸發(fā)刷新token邏輯
- ③前段http請(qǐng)求鉤子中,如果出現(xiàn)http響應(yīng)碼為401時(shí),立即觸發(fā)刷新token邏輯,同時(shí)緩存后續(xù)請(qǐng)求,刷新token結(jié)束后,依次續(xù)發(fā)緩存中的請(qǐng)求
一、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("用戶信息無效,請(qǐng)重新登陸!"); } 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í)間為最長(zhǎng)過期時(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("用戶信息無效,請(qǐng)重新登陸!"); } 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)登錄頁(yè)面的場(chǎng)景
二、前端(vue3+axios)
// 創(chuàng)建axios實(shí)例 const service = axios.create({ // axios中請(qǐng)求配置有baseURL選項(xiàng),表示請(qǐ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)接口請(qǐ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; // 請(qǐng)求等待隊(duì)列 let waitQueue = []; function refreshFun(config) { if (refreshing == false) { refreshing = true; return useUserStore().refreshToken().then(() => { waitQueue.forEach(callback => callback()); // 已成功刷新token,隊(duì)列中的所有請(qǐng)求重試 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ù)留在該頁(yè)面,或者重新登錄', '系統(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-05Java 實(shí)戰(zhàn)項(xiàng)目之誠(ché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-11Spring Boot 自動(dòng)配置的實(shí)現(xiàn)
這篇文章主要介紹了Spring Boot 自動(dòng)配置的實(shí)現(xiàn),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-08-08SpringBoot?整合?ShardingSphere4.1.1實(shí)現(xiàn)分庫(kù)分表功能
ShardingSphere是一套開源的分布式數(shù)據(jù)庫(kù)中間件解決方案組成的生態(tài)圈,它由Sharding-JDBC、Sharding-Proxy和Sharding-Sidecar(計(jì)劃中)這3款相互獨(dú)立的產(chǎn)品組成,本文給大家介紹SpringBoot?整合?ShardingSphere4.1.1實(shí)現(xiàn)分庫(kù)分表,感興趣的朋友一起看看吧2023-12-12springboot整合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-10java中類和對(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