一篇文章带你搞定 SpringBoot 实现一人一号与无感刷新Jwt

1,370 阅读6分钟

一、引言

在现代应用的安全体系中,用户认证和授权是至关重要的一环。特别是在多设备登录和频繁请求的场景下,如何确保一人一号的安全性并有效地管理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
...