目录
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))
})
}))
}
}