springboot+vue3无感知刷新token实战教程
目录
- 一、Java后端
- 1、token构造实现类
- ①验证码方式实现类
- ②刷新token方式实现类
- 2、token相关操作:setCookie
- ①createToken
- ②refreshToken
- ③异常码
- 二、前端(vue3+axIOS)
- 总结
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); python 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.setCacheObjec编程t(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 ForbiddenEx编程客栈ception("认证失败!"); } // 验证 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' } ElMessagejs({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)) }) })) } }
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持编程客栈(www.devze.com)。
精彩评论