SpringBoot硬核实战:实现一人一号与无感刷新JWT

一、引言

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

    privatefinal JwtUtil jwtUtil;
    privatefinal RedisUtil redisUtil;
    privatefinal SystemConfiguration systemConfiguration;
    privatefinal 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) {
        returnnull;
    }
    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 方法分别生成访问令牌和刷新令牌。

  1. 构建 LoginResult 对象

LoginResult 包含了生成的 accessToken 和 refreshToken 以及令牌的过期时间。这些信息将用于后续的用户请求认证。

  1. 权限信息写入

除了令牌信息,我们还将用户的权限信息存储在 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 为恶意刷新
        thrownew 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 过期
            thrownew ServiceException(ResultCode.AUTH_TOKEN_EXPIRED);
        } else {
            // 2.1.2 错误的 RefreshToken
            thrownew ServiceException(ResultCode.AUTH_MALICIOUS_TOKEN_REFRESH);
        }
    }
    // 3. 校验是否和缓存中 refreshToken 一致
    LoginResult loginResult = redisUtil.getCacheObject(RedisKeyConstants.USER_TOKEN_CACHE_PREFIX + userId);
    if (loginResult == null) {
        // 3.1 表示 refreshToken 过期
        thrownew ServiceException(ResultCode.AUTH_TOKEN_EXPIRED);
    }
    if (!refreshTokenForm.getAccessToken().equals(loginResult.getAccessToken()) ||
            !refreshTokenForm.getRefreshToken().equals(loginResult.getRefreshToken())) {
        // 3.2 如果 accessToken 和 refreshToken 有一个不一致 , 表示恶意刷新 Token
        thrownew ServiceException(ResultCode.AUTH_MALICIOUS_TOKEN_REFRESH);
    }
    // 4. 返回刷新后的token
    return jwtUtil.refreshToken(refreshTokenForm);
}
  1. 验证 AccessToken 是否过期

首先,系统会使用 jwtUtil.isJwtExpired 方法来检查用户提交的 AccessToken 是否已经过期。

如果 AccessToken 未过期,那么此次刷新请求将被视为恶意行为,因为通常只有在 AccessToken 过期后才需要刷新。

  1. 解析 RefreshToken 并验证用户 ID

接下来,系统会从用户提交的 RefreshToken 中解析出用户的ID。

如果解析结果为 null,这可能意味着 RefreshToken 无效或者已经过期。根据具体情况抛出 ServiceException 异常,提醒用户 RefreshToken 过期或存在恶意刷新行为。

  1. 验证 Redis 中的 Token 信息

系统会从 Redis 中读取该用户ID下的 LoginResult 对象,确保在刷新 Token 前,系统中已有的 AccessToken 和 RefreshToken 与提交的内容一致。

如果 Redis 中的 Token 信息不存在或者不匹配,这表明用户的 Token 已过期或存在恶意刷新行为。

  1. 返回刷新后的 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) 涉及文章

https://juejin.cn/post/7401027594231480360

四、在线演示 / 源码

详细了解还得知道具体代码和工具类如何编写,下面是源码地址,文章涉及到的核心类有

AuthServiceImpl.java
JwtUtil.java
JwtTokenFilter.java
...

在线预览

http://yf.wiki/yf-vue-admin/login

源码

https://gitee.com/fateyifei/yf

juejin.cn/post/7401144423562625076

posted @ 2025-05-11 21:27  CharyGao  阅读(149)  评论(0)    收藏  举报