什么是双 Token?
简单来说双 Token 解决了用户登录问题,我们来想一下大家有没有登录过这样的一种网站,登录一次,几天时间内不需要登录,直接打开网站就是登录状态。双 Token 就可以实现这样的登录功能。双Token 顾明思议就是两个Token ,我们一般称为 访问令牌(accessToken)和 刷新令牌 Token (refreshToken) 这两个令牌下文就用英文代替了,具体每个 Token 的功能我们会在下面解释,双 Token 对于别的登录的实现方式,比如 session 或者其他方式更安全,因为 accessToken 有效时间比较短一般是两个小时,即使泄漏了危害相比于其他方式实现的登录更小。当然双 Token 也有缺点,实现逻辑比较复杂,accessToken 刷新期间,Redis 和数据库都不存在 accessToken 封装实体,此时有前端获取用户登录信息,会显示未登录,发生概率比较小,大家有疑问不要怕,先向后看。
双Token的具体功能
accessToken : 用户成功后会生成 accessToken 设置有效期 , 并缓存到 Redis 和 保存到数据库,传送到前端保存起来,前端的每次请求,请求头中携带者 accessToken ,进行校验。详细细节后面会讲清楚,这里只是大概思想。
refreshToken: 为了保证安全, accessToken 有效时间比较短,每次 accessToken 过期不能每两个小时登录一次吧,这显然是不合理的, refreshToken 就是来解决这个问题的,每次 accessToken 过期了,在 refreshToken 未过期的条件下,就可以使用 refreshToken 来重新生成 accessToken 返回到前端更新过期的 accessToken 实现前端的无感刷新。
双Token 功能实现分为五部分(login,refresh,logout,filter,getLoginUser)
login
登录方法要做的事: 校验密码信息成功后生成 accessToken 和 refreshToken ,注意这里先生成 refreshToken 然后通过 refreshToken 生成 accessToken 将 accessToken 、refreshToken 、用户信息、过期时间 四部分进行封装,进行 Redis 缓存, 将 refreshToken 和 过期时间进行封装,这两个封装结果都保存到数据库。 将 accessToken 封装结果作为返回值返回前端, 前端进行保存,在前端发送请求的时候,请求头添加 accessToken
refresh
刷新 accessToken 方法要做的事 : 通过 refreshToken 得到相关的 accessToken 封装实体列表,进行数据库删除操作, Redis 缓存删除,判断 refreshToken 是否过期,过期删除数据库存储的 refreshToken封装实体然后抛出异常,否则通过 refreshToken 重新生成 accessToken 封装实体, 返回前端,前端进行更新操作。
logout
退出登录操作 :通过请求头中 accessToken 删除 redis 中的 关于 accessToken 封装实体 ,并进行数据库删除accessToken 封装实体 和 refreshToken 实体。
filter
过滤器做的操作: 拦截前端所有请求,查看请求头中是否携带 accessToken ,如果没有携带 accessToken 直接放行,因为有些操作是不需要登录的。如果携带 accessToken ,根据 accessToken 获取到 redis 中缓存的 accessToken 实体 ,进而判断 accessToken ,是否存在,是否过期,如果 校验 accessToken 不成功 抛出异常,外层 使用 try-catch 拦截异常,然后放行,因为即使 accessToken 校验失败也应该放行,因为有些操作是不需要登录的,跟上面逻辑相似,如果 accessToken 校验成功就根据 redis 中获得的 accessToken 实体获得 用户信息,存储在应用上下文中,供后续逻辑使用。
getLoginUser
获得登录用户信息操作: 从 redis 中获得 accessToken 封装实体,判断是否存在,是否过期,校验失败抛出异常,全局异常处理器捕捉到异常,检验成功 ,从应用上下文中获取用户信息返回前端。
大家可能出现疑问的点
应用上下文能保证用户信息隔离吗?
在一个多用户同时登录的环境中,应用上下文对象通常通过基于线程的上下文来保证不同用户信息的隔离。在 Java 中,每个请求通常会被封装成一个线程,而应用上下文对象会在每个线程中存储和管理用户信息,从而确保不同用户能够获取到其自己的正确信息。
在Spring框架中,SecurityContextHolder 是一个用于存储 SecurityContext 的静态对象。每个线程的SecurityContext 中包含了当前用户的认证信息和授权信息。这意味着在多个线程中,每个线程都有自己独立的SecurityContext ,可以存储与该线程相关的用户信息,以确保不同用户可以获取到正确的用户信息。通过这种方式,不同用户信息的隔离是通过线程的上下文隔离来保证的,每个线程都有自己独立的 SecurityContext 存储着当前用户的信息,这样就可以确保不同用户能够获取到不同的用户信息,从而保证了信息的隔离和正确性。
什么时候进行判断 accessToken 过期,如何进行 accessToken 的刷新?
需要登录才能做的操作或者才显示的页面,是前端进行限制的,当需要登录时,前端向后端发送获取登录信息的请求,后端就会进行 accessToken 的校验, 如果过期,抛出异常,封装错误信息返回前端,前端根据获得的信息(状态码))进行判断,如果是 accessToken 过期,前端把 refreshToken 作为参数 向后端发送请求使用 refreshToken 刷新 accessToken ,后端 进行 redis 和数据库 accessToken 封装实体删除,如果 refreshToken 过期 ,删数据库 refreshToken 封装实体,然后抛出异常,封装错误信息返回前端, 前端收到信息,进行用户重新登录操作,如果未过期,利用 refreshToken 生成 accessToken 实体,返回前端,前端进行 accessToken 更新操作。
我来画个流程图,帮助大家理解。
双Token流程图
代码
后端代码
用户登录
@PostMapping("/login")
public BaseResponse<UserLoginRespVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
if (userLoginRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String userAccount = userLoginRequest.getUserAccount();
String userPassword = userLoginRequest.getUserPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
UserLoginRespVO userLoginRespVO = userService.userLogin(userAccount, userPassword);
return ResultUtils.success(userLoginRespVO);
}
@Override
public UserLoginRespVO userLogin(String userAccount, String userPassword) {
// 1. 校验
if (StringUtils.isAnyBlank(userAccount, userPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号错误");
}
if (userPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
}
// 2. 加密
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
// 查询用户是否存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
queryWrapper.eq("userPassword", encryptPassword);
User user = this.baseMapper.selectOne(queryWrapper);
// 用户不存在
if (user == null) {
log.info("user login failed, userAccount cannot match userPassword");
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");
}
//创建 accessToken 和 refreshToke
return createToken(user.getId());
}
private UserLoginRespVO createToken(Long userId) {
RefreshToken userRefreshToken = userTokenService.getUserRefreshToken(userId);
AccessToken userAccessToken = userTokenService.getUserAccessToken(userRefreshToken);
return ConvertUtils.convert(userAccessToken);
}
@Override
public RefreshToken getUserRefreshToken(Long userId) {
RefreshToken refreshToken = new RefreshToken().setRefreshToken(generateRefreshToken()).setUserId(userId).setExpiresTime(LocalDateTime.now().plusSeconds(TokenConstant.REFRESH_TOKEN_EXPIRES_TIME));
refreshTokenMapper.insert(refreshToken);
return refreshToken;
}
public static String generateRefreshToken() {
return IdUtil.fastSimpleUUID();
}
@Override
public AccessToken getUserAccessToken(RefreshToken refreshToken) {
AccessToken accessToken = new AccessToken().setUserId(refreshToken.getUserId()).setUserInfo(BuildUserInfo(refreshToken.getUserId())).setAccessToken(generateAccessToken()).setExpiresTime(LocalDateTime.now().plusSeconds(TokenConstant.ACCESS_TOKEN_EXPIRES_TIME)).setRefreshToken(refreshToken.getRefreshToken());
accessTokenMapper.insert(accessToken);
//缓存到 redis 中
redisTokenUtils.set(accessToken);
return accessToken;
}
private Map<String, String> BuildUserInfo(Long userId) {
User user = userService.getById(userId);
return MapUtil.builder(LoginUserVO.INFO_KEY_USERNAME, user.getUserName()).put(LoginUserVO.INFO_KEY_USERAVATAR, user.getUserAvatar()).put(LoginUserVO.INFO_KEY_USERPROFILE, user.getUserProfile()).put(LoginUserVO.INFO_KEY_USERROLE, user.getUserRole()).put(LoginUserVO.INFO_KEY_USEMAILBOX,user.getUserMailbox()).build();
}
刷新 accessToken
@PostMapping("/refreshToken")
public BaseResponse<UserLoginRespVO> RefreshToken(String refreshToken) {
return ResultUtils.success(userService.refreshToken(refreshToken));
}
@Override
public UserLoginRespVO refreshToken(String refreshToken) {
return ConvertUtils.convert(userTokenService.refreshToken(refreshToken));
}
public static UserLoginRespVO convert(AccessToken accessToken) {
if(accessToken == null) return null;
UserLoginRespVO userLoginRespVO = new UserLoginRespVO();
BeanUtils.copyProperties(accessToken, userLoginRespVO);
return userLoginRespVO;
}
@Override
public AccessToken refreshToken(String refreshToken) {
if (StrUtil.isBlank(refreshToken)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数refreshToken为null");
}
RefreshToken refreshToken1 = refreshTokenMapper.selectOne(RefreshToken::getRefreshToken, refreshToken);
if (refreshToken1 == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "无效的刷新令牌");
}
//移除相关访问令牌
List<AccessToken> accessTokens = accessTokenMapper.selectListByRefreshToken(refreshToken);
accessTokenMapper.deleteBatchIds(CollectionUtils.convertSet(accessTokens, AccessToken::getId));
redisTokenUtils.deleteList(CollectionUtils.convertSet(accessTokens, AccessToken::getAccessToken));
//判断refreshToken是否过期
if (DateUtils.isExpired(refreshToken1.getExpiresTime())) {
refreshTokenMapper.deleteByToken(refreshToken1.getRefreshToken());
throw new BusinessException(ErrorCode.REFRESH_TOKEN_EXPIRED);
}
return getUserAccessToken(refreshToken1);
}
public static <T, U> Set<U> convertSet(Collection<T> from, Function<T, U> func) {
if (CollUtil.isEmpty(from)) {
return new HashSet<>();
}
return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toSet());
}
获得用户登录信息
@GetMapping("/get/login")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {
LoginUserVO loginUserVO= userService.getLoginUser(request);
return ResultUtils.success(loginUserVO);
}
@Override
public LoginUserVO getLoginUser(HttpServletRequest request) {
String accessToken = request.getHeader(TokenConstant.HEADER_ACCESS_TOKEN);
if (accessToken == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
AccessToken accessToken1 = redisTokenUtils.get(accessToken);
if (accessToken1 == null) {
accessToken1 = accessTokenMapper.selectByAccessToke(accessToken);
}
if (accessToken1 != null && DateUtils.isExpired(accessToken1.getExpiresTime())) {
throw new BusinessException(ErrorCode.ACCESS_TOKEN_EXPIRED);
}
LoginUserVO loginUserVO = SecurityFrameworkUtils.getLoginUserVO();
if (loginUserVO == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
return loginUserVO;
}
@Nullable
public static LoginUserVO getLoginUserVO() {
Authentication authentication = getAuthentication();
if (authentication == null) {
return null;
}
return authentication.getPrincipal() instanceof LoginUserVO ? (LoginUserVO) authentication.getPrincipal() : null;
}
public static Authentication getAuthentication() {
SecurityContext context = SecurityContextHolder.getContext();
if (context == null) {
return null;
}
return context.getAuthentication();
}
过滤器
@RequiredArgsConstructor
@Component
public class UserTokenFilter extends OncePerRequestFilter {
@Resource
UserTokenService userTokenService;
@Resource
UserService userService;
private final GlobalExceptionHandler globalExceptionHandler;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String accessToken = SecurityFrameworkUtils.getAccessTokenFromRequest(request, TokenConstant.HEADER_ACCESS_TOKEN);
if (StrUtil.isNotBlank(accessToken)) {
LoginUserVO loginUserVO = buildLoginUserVO(accessToken);
if(loginUserVO != null) {
SecurityFrameworkUtils.setLoginUserVO(loginUserVO,request);
}
}
} catch (BusinessException businessException) {
BaseResponse<?> baseResponse = globalExceptionHandler.businessExceptionHandler(businessException);
ServletUtils.writeJSON(response,baseResponse);
return ;
}
filterChain.doFilter(request,response);
}
private LoginUserVO buildLoginUserVO(String accessToken) {
try {
AccessToken accessToken1 = userTokenService.checkAccessToken(accessToken);
return LoginUserVO.builder()
.userName(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERNAME))
.userProfile(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERPROFILE))
.userRole(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERROLE))
.userAvatar(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERAVATAR))
.userMailbox(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USEMAILBOX))
.id(accessToken1.getUserId())
.build();
} catch (BusinessException businessException) {
// 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
return null;
}
}
}
public static String getAccessTokenFromRequest(HttpServletRequest request,String headerName) {
String accessToken = request.getHeader(headerName);
return accessToken;
}
private LoginUserVO buildLoginUserVO(String accessToken) {
try {
AccessToken accessToken1 = userTokenService.checkAccessToken(accessToken);
return LoginUserVO.builder()
.userName(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERNAME))
.userProfile(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERPROFILE))
.userRole(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERROLE))
.userAvatar(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USERAVATAR))
.userMailbox(accessToken1.getUserInfo().get(LoginUserVO.INFO_KEY_USEMAILBOX))
.id(accessToken1.getUserId())
.build();
} catch (BusinessException businessException) {
// 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
return null;
}
}
@Override
public AccessToken checkAccessToken(String accessToken) {
AccessToken accessToken1 = redisTokenUtils.get(accessToken);
if(accessToken1 == null) {
accessToken1 = accessTokenMapper.selectByAccessToke(accessToken);
}
if(accessToken1 == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "访问令牌不存在");
}
if(DateUtils.isExpired(accessToken1.getExpiresTime())) {
throw new BusinessException(ErrorCode.ACCESS_TOKEN_EXPIRED);
}
return accessToken1;
}
}
public static void setLoginUserVO(LoginUserVO loginUserVO, HttpServletRequest request) {
Authentication authentication = buildAuthentication(loginUserVO, request);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
public static Authentication buildAuthentication(LoginUserVO loginUserVO, HttpServletRequest request) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginUserVO, null, Collections.emptyList());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
return authenticationToken;
}
退出登录
@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String accessToken = SecurityFrameworkUtils
.getAccessTokenFromRequest(request, TokenConstant.HEADER_ACCESS_TOKEN);
if (StrUtil.isNotBlank(accessToken)) {
userService.userLogout(accessToken);
}
return ResultUtils.success(true);
}
public static String getAccessTokenFromRequest(HttpServletRequest request,String headerName) {
String accessToken = request.getHeader(headerName);
return accessToken;
}
@Override
public void userLogout(String accessToken) {
userTokenService.removeToken(accessToken);
}
@Override
public AccessToken removeToken(String accessToken) {
AccessToken accessToken1 = accessTokenMapper.selectByAccessToke(accessToken);
if (accessToken1 == null) return null;
accessTokenMapper.deleteById(accessToken1.getId());
redisTokenUtils.del(accessToken1.getAccessToken());
refreshTokenMapper.deleteByToken(accessToken1.getRefreshToken());
return accessToken1;
}
前端代码
获取用户登录信息
import { StoreOptions, useStore } from "vuex";
import { UserControllerService } from "../../generated";
import { useRouter } from "vue-router";
//存储用户信息
export default {
namespaced: true,
state: () => ({
loginUser: {
userName: "未登录",
userProfile: "",
userMailbox: "",
},
}),
actions: {
async getLoginUser({ commit, state }) {
const router = useRouter(); // 获取路由实例
const store = useStore();
try {
const res = await UserControllerService.getLoginUserUsingGet1();
// 获得登录用户信息成功,更新用户信息
if (res.code === 0) {
commit("updateUser", res.data);
sessionStorage.setItem("loginUser", JSON.stringify(state.loginUser));
} else if (res.code === 40100) { // 用户未登录,跳转到登录页面,这是思想,具体按照每个人的代码修改即可
store.commit("tologin/setShowLoginDialog", true);
} else { // accessToken 过期,发送请求更新 accessToken
const refreshToken = localStorage.getItem("refreshToken");
const res1 = await UserControllerService.refreshTokenUsingPost1(
refreshToken as any
);
if (res1.code === 0) { // refreshToken 未过期,更新成功,更新用户信息,更新 accessToken
commit("updateUser", res1.data.userInfo);
localStorage.setItem("accessToken", res1.data.accessToken);
localStorage.setItem("refreshToken", res1.data.refreshToken);
} else if (res1.code === 50020) { // refreshToken 过期 跳转到登录页面
store.dispatch("tologin/showLogin");
}
}
} catch (error) {
console.error("An error occurred:", error);
}
},
},
mutations: { //更新用户信息函数
updateUser(state, payload) {
state.loginUser = payload;
},
},
} as StoreOptions<any>;
拦截器添加请求头
const store1 = useStore();
axios.interceptors.request.use(
function (config) {
const accessToken = localStorage.getItem("accessToken");
if (accessToken != null && accessToken != "") {
config.headers["token_access"] = accessToken;
} else {
console.log("拦截器,accessToken为空");
}
return config;
},
function (error) {
// Do something with request error
return Promise.reject(error);
}
);
// Add a response interceptor
axios.interceptors.response.use(
function (response) {
if (response.data === null) {
console.log("response拦截器 data为null");
}
return response;
},
function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
}
);
用户登录
const doUserLogin = async () => {
const res = await UserControllerService.userLoginUsingPost1(form);
if (res.code === 0) {
message.success("登录成功");
//保存 accessToken 和 refreshToken
localStorage.setItem("accessToken", res.data.accessToken);
localStorage.setItem("refreshToken", res.data.refreshToken);
store.commit("user/updateUser", res.data.userInfo);
} else {
message.error("登录失败" + res.message);
}
};
文章就到这里了,如果对小伙伴有帮助的话,就点个赞吧,你们的支持是我最大的动力,哈哈哈。