一、引言
在现代应用的安全体系中,用户认证和授权是至关重要的一环。特别是在多设备登录和频繁请求的场景下,如何确保一人一号的安全性并有效地管理Token的刷新,成为后端开发中的一大挑战。通过Spring Boot 3、Spring Security 6、JWT、Redis的结合,我们可以实现高效且安全的用户认证机制,既能保证每个用户只能登录一个账户,又能通过Token刷新机制提升系统的灵活性和用户体验。本篇文章将详细介绍如何利用这些技术栈,实现一人一号与Token自动刷新功能。
二、简介
本文将探讨如何使用Spring Boot 3、Spring Security 6、JWT、Redis构建一个安全且高效的用户认证系统,重点讲解一人一号的实现与Token刷新机制。我们将从设计思路开始,逐步深入到具体的代码实现,详细解释如何通过Redis存储用户状态、JWT进行安全认证,以及在Token过期时进行无缝刷新。无论你是后端开发新手,还是希望优化现有认证机制的开发者,这篇文章都将为你提供实用的指导和参考。
三、具体实现
3.1 一人一号
(1)实现一人一号认证
在我们的系统中,我们希望确保每个用户在同一时间只能在一个设备上登录。这意味着,如果用户在新设备上登录,旧设备上的登录状态将自动失效。为实现这一目标,我们使用了 JWT 和 Redis 结合的方式进行认证和存储。
(2)代码解析:一人一号认证
Security 拦截器 (代码讲解)
在这个拦截器 JwtTokenFilter
中,我们的目标是解析传入请求的 JWT token,并且通过对比存储在 Redis 中的 token,来判断用户的 token 是否有效和一致,从而实现当 token 改变后,原本能通过的 token 也将被拒绝访问。
@Component
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
private final SystemConfiguration systemConfiguration;
private final ServerProperties properties;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.removeTokenPrefix(request);
// 获取请求路径uri
String uri = request.getRequestURI();
String contextPath = properties.getServlet().getContextPath();
if (StringUtils.hasText(contextPath)) {
uri = uri.substring(contextPath.length());
}
// 1. 判断token是否存在, 判断是否在 security白名单中 ,不存在交给下一个责任链解决
if (!SecurityUtil.isWhitelisted(uri, systemConfiguration.getSecurityWhitelistPaths())
&& StringUtils.hasText(token)) {
// 2. 解析权限信息
Authentication auth = jwtUtil.getAuthentication(token);
if (auth == null) {
// 2.1 解析权限信息失败
if (jwtUtil.isJwtExpired(token)) {
// 2.1.1 token 过期处理
ResponseUtil.writeIResultCodeMsg(response, HttpStatus.UNAUTHORIZED, ResultCode.AUTH_TOKEN_EXPIRED);
} else {
// 2.1.2 token 无效处理
ResponseUtil.writeIResultCodeMsg(response, HttpStatus.UNAUTHORIZED, ResultCode.AUTH_TOKEN_INVALID);
}
return;
}
// 3. 解析 auth 中用户id
Long userId = SecurityUtil.getUserId(auth);
if (userId == null) {
// 3.1 解析用户id失败表示未授权
ResponseUtil.writeIResultCodeMsg(response, HttpStatus.UNAUTHORIZED, ResultCode.AUTH_TOKEN_INVALID);
return;
}
// 4. 获取 redis 中 token 是否与当前 token 匹配
LoginResult loginResult = redisUtil.getCacheObject(RedisKeyConstants.USER_TOKEN_CACHE_PREFIX + userId);
if (loginResult == null) {
// 4.1 缓存获取中未存储 token , 表示用户被踢出
ResponseUtil.writeIResultCodeMsg(response, HttpStatus.UNAUTHORIZED, ResultCode.AUTH_KICK_OUT);
return;
}
if (!token.equals(loginResult.getAccessToken())) {
// 4.2 token 不相等表示 用户在别处登录
ResponseUtil.writeIResultCodeMsg(response, HttpStatus.FORBIDDEN, ResultCode.AUTH_USER_ELSEWHERE_LOGIN);
return;
}
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
登录颁发Jwt流程(代码讲解)
覆盖原本的 token ,当拦截器与redis对比token不一致的时候,会拦截掉以前的token
public LoginResult getLoginResult(Authentication authenticate) {
if (authenticate == null || authenticate.getPrincipal() == null) {
return null;
}
SysUserDetails principal = (SysUserDetails) authenticate.getPrincipal();
// 1. 构建对应参数
// 1.1 特殊说明一下过期时间 , 会有短暂误差
Duration accessTokenExpirationTime = jwtConfiguration.getAccessTokenExpirationTime();
Duration refreshTokenExpirationTime = jwtConfiguration.getRefreshTokenExpirationTime();
String accessToken = generateAccessToken(authenticate);
String refreshToken = generateRefreshToken(authenticate);
// 2. 构建 LoginResult 对象
LoginResult result = LoginResult.builder().accessToken(accessToken).refreshToken(refreshToken).expires(Date.from(Instant.now().plus(accessTokenExpirationTime)).getTime()).build();
// 3. 数据存入 redis ( 做一人一号认证,以及退出登录 )
redisUtil.setCacheObject(RedisKeyConstants.USER_TOKEN_CACHE_PREFIX + principal.getUserId(), result, refreshTokenExpirationTime.toMillis(), TimeUnit.MILLISECONDS);
// 4. 写入 权限信息
redisUtil.setCacheObject(RedisKeyConstants.USER_PERMISSIONS_CACHE_PREFIX + principal.getUserId(), principal.getPermissions(), refreshTokenExpirationTime.toMillis(), TimeUnit.MILLISECONDS);
return result;
}
1. 参数构建与过期时间设定
- accessTokenExpirationTime 和 refreshTokenExpirationTime 是从配置中读取的令牌过期时间,这些时间会影响用户登录状态的持续时间。
- 使用
generateAccessToken
和generateRefreshToken
方法分别生成访问令牌和刷新令牌。
2. 构建 LoginResult 对象
LoginResult
包含了生成的accessToken
和refreshToken
以及令牌的过期时间。这些信息将用于后续的用户请求认证。
3. 权限信息写入
- 除了令牌信息,我们还将用户的权限信息存储在 Redis 中,方便在用户请求时快速获取权限进行访问控制。
3.2 Token 刷新
(1)实现 Token 刷新
登录信息中 LoginResult
生成了 accessToken
和 refreshToken
, 我们需要校验 accessToken 、refreshToken 的合法性,来更新对应的token即可(需前后端配合)。
(2)代码解析:Token 刷新
我们通过解析和验证用户的 AccessToken 和 RefreshToken,确保只有在合法情况下才能刷新 Token。让我们逐步分析这段代码的实现。
@Override
public LoginResult refreshToken(RefreshTokenForm refreshTokenForm) {
// 1. 解析 accessToken 是否真正过期
if (!jwtUtil.isJwtExpired(refreshTokenForm.getAccessToken())) {
// 1.1 未过期刷新 token 为恶意刷新
throw new ServiceException(ResultCode.AUTH_MALICIOUS_TOKEN_REFRESH);
}
// 2. 解析 refreshTokenForm 中的用户id
Long userId = jwtUtil.getRefreshTokenUserId(refreshTokenForm.getRefreshToken());
if (userId == null) {
// 2.1 userId 等于 null 表示 refreshToken 错误
if (jwtUtil.isJwtExpired(refreshTokenForm.getRefreshToken())) {
// 2.1.1 RefreshToken 过期
throw new ServiceException(ResultCode.AUTH_TOKEN_EXPIRED);
} else {
// 2.1.2 错误的 RefreshToken
throw new ServiceException(ResultCode.AUTH_MALICIOUS_TOKEN_REFRESH);
}
}
// 3. 校验是否和缓存中 refreshToken 一致
LoginResult loginResult = redisUtil.getCacheObject(RedisKeyConstants.USER_TOKEN_CACHE_PREFIX + userId);
if (loginResult == null) {
// 3.1 表示 refreshToken 过期
throw new ServiceException(ResultCode.AUTH_TOKEN_EXPIRED);
}
if (!refreshTokenForm.getAccessToken().equals(loginResult.getAccessToken()) ||
!refreshTokenForm.getRefreshToken().equals(loginResult.getRefreshToken())) {
// 3.2 如果 accessToken 和 refreshToken 有一个不一致 , 表示恶意刷新 Token
throw new ServiceException(ResultCode.AUTH_MALICIOUS_TOKEN_REFRESH);
}
// 4. 返回刷新后的token
return jwtUtil.refreshToken(refreshTokenForm);
}
1. 验证 AccessToken 是否过期
- 首先,系统会使用
jwtUtil.isJwtExpired
方法来检查用户提交的 AccessToken 是否已经过期。 - 如果 AccessToken 未过期,那么此次刷新请求将被视为恶意行为,因为通常只有在 AccessToken 过期后才需要刷新。
2. 解析 RefreshToken 并验证用户 ID
- 接下来,系统会从用户提交的 RefreshToken 中解析出用户的ID。
- 如果解析结果为
null
,这可能意味着 RefreshToken 无效或者已经过期。根据具体情况抛出ServiceException
异常,提醒用户 RefreshToken 过期或存在恶意刷新行为。
3. 验证 Redis 中的 Token 信息
- 系统会从 Redis 中读取该用户ID下的 LoginResult 对象,确保在刷新 Token 前,系统中已有的 AccessToken 和 RefreshToken 与提交的内容一致。
- 如果 Redis 中的 Token 信息不存在或者不匹配,这表明用户的 Token 已过期或存在恶意刷新行为。
4. 返回刷新后的 Token
- 在所有验证通过后,系统将调用
jwtUtil.refreshToken
方法生成新的 Token 并返回给用户。新 Token 的生成过程会基于当前用户的 RefreshToken。
3.3 对外提供服务
具体使用
@Override
public LoginResult login(LoginForm loginForm, LoginTypeEnum type) {
// 参数说明 : principal 主体 ,credentials 凭据
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginForm, type);
// 1. 获取到 UserDetails 对象
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
// 2. 生成 Jwt Token;
return jwtUtil.getLoginResult(authenticate);
}
authenticationManager.authenticate(authenticationToken) 涉及文章 优雅的使用Spring Security完成多种登录方式 (含Security源码讲解)
四、在线演示 / 源码
详细了解还得知道具体代码和工具类如何编写,下面是源码地址,文章涉及到的核心类有
AuthServiceImpl.java
JwtUtil.java
JwtTokenFilter.java
...