Spring Boot 使用 JWT + Redis 实现登录认证和 Token 自动续期

作者:old wang 发布时间: 2021-03-03 阅读量:4 评论数:0

在后台系统中,常见实现方式一般有两类:

Session 认证
JWT 认证

Session 认证会把用户登录状态保存在服务端。

JWT 认证则通常把 Token 返回给客户端,后续请求由客户端携带 Token 访问接口。

本文记录一种基于 JWT + Redis 的登录认证方案:

  1. 用户登录成功后生成 JWT;

  2. 服务端将 JWT 保存到 Redis;

  3. 每次请求通过拦截器校验 JWT;

  4. Redis 控制 Token 是否有效;

  5. Token 临近过期时自动续期;

  6. 用户登出或修改密码时更新 Redis 中的 Token 状态。

这种方式既保留了 JWT 适合分布式场景的特点,也通过 Redis 弥补了 JWT 无法主动失效的问题。

一、Session 和 JWT 的区别

Session 和 JWT 最大的区别是:

登录状态保存的位置不同。

1. Session 认证

Session 的认证流程大致是:

1. 用户提交用户名和密码
2. 服务端校验通过后创建 Session
3. 服务端保存 Session 信息
4. 浏览器保存 sessionId
5. 后续请求携带 sessionId
6. 服务端根据 sessionId 查询 Session 是否有效

Session 的状态保存在服务端。

因此在单机系统中使用比较简单。

但如果是分布式部署,就需要考虑 Session 共享问题。

常见方案有:

  • 粘性 Session;

  • Session 复制;

  • Session 共享;

  • Session 持久化;

  • Redis 统一保存 Session。

2. JWT 认证

JWT 的认证流程大致是:

1. 用户提交用户名和密码
2. 服务端校验通过后生成 Token
3. Token 返回给前端
4. 前端保存 Token
5. 后续请求在 Header 中携带 Token
6. 服务端解析 Token 并校验是否有效

JWT 通常保存在客户端。

例如:

localStorage
sessionStorage
Cookie

后续请求一般放到请求头中:

Authorization: Bearer xxxxxx

JWT 的优点是比较适合分布式场景,不需要天然依赖服务端 Session。

但 JWT 本身也有一些问题,需要额外处理。

二、JWT 的几个注意点

1. Payload 不能存敏感信息

JWT 的 Payload 通常只是 Base64URL 编码,不是加密。

所以不要在 JWT 中存放敏感信息。

例如不要放:

密码
身份证号
手机号完整明文
银行卡号
密钥

更适合放一些非敏感标识:

用户 ID
用户名
角色标识
Token 创建时间

即使服务端会对 JWT 做签名,也只能保证内容没有被篡改,不代表内容不能被看到。

2. JWT 一旦签发,默认无法主动废弃

JWT 的一个特点是无状态。

这意味着如果只依赖 JWT 本身,在 Token 过期之前,它通常一直有效。

问题是:

用户主动登出后,旧 Token 怎么失效?
用户修改密码后,旧 Token 怎么失效?
管理员禁用用户后,旧 Token 怎么失效?

如果没有服务端状态,就很难主动让旧 Token 失效。

因此实际项目中经常会结合 Redis 使用。

3. JWT 续期需要重新设计

传统 Session 可以做到:

用户 30 分钟内有访问,就刷新 Session 有效期

JWT 如果想修改有效期,通常需要重新签发一个新 Token。

但每次请求都重新生成 Token 又比较粗暴。

本文采用的做法是:

JWT 本身不设置过期时间,由 Redis 控制 Token 有效期。
每次请求校验 Redis 中的 Token 是否存在,并在临近过期时刷新 Redis TTL。

这样可以实现类似 Session 的滑动过期效果。

三、为什么使用 JWT + Redis

单独使用 JWT 的问题是:

不能主动失效
续期不方便
无法控制同一账号多端登录策略

结合 Redis 后,可以把 Token 状态交给服务端管理。

例如:

key: userId
value: token
ttl: 1 天

这样每次请求时:

  1. 解析 Token 获取用户 ID;

  2. 根据用户 ID 从 Redis 查询服务端保存的 Token;

  3. 判断 Redis 中的 Token 是否存在;

  4. 判断 Redis 中的 Token 是否和请求 Token 一致;

  5. 如果一致,说明请求有效;

  6. 如果不存在或不一致,说明 Token 无效。

这样可以支持:

  • 登出删除 Token;

  • 修改密码刷新 Token;

  • 单用户单端登录;

  • Redis TTL 控制过期;

  • 临近过期自动续期。

四、引入 JWT 依赖

示例使用 java-jwt

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.10.3</version>
</dependency>

五、定义 Token 中保存的用户信息

Token 中不要保存敏感信息。

可以定义一个简单的 DTO。

import lombok.Data;

@Data
public class UserTokenDTO {

    private String id;

    private String username;

    /**
     * Token 创建时间
     */
    private Long gmtCreate;
}

这里只保存:

用户 ID
用户名
Token 创建时间

不要保存密码。

六、JWT 工具类

JWT 工具类负责生成和解析 Token。

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;

public class JWTUtil {

    private static final Logger log = LoggerFactory.getLogger(JWTUtil.class);

    /**
     * 示例密钥。
     * 生产环境不要硬编码,建议放到配置中心、环境变量或密钥管理系统中。
     */
    private static final String TOKEN_SECRET = "123456";

    /**
     * 生成 Token。
     */
    public static String generateToken(UserTokenDTO userTokenDTO) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);

            Map<String, Object> header = new HashMap<>(2);
            header.put("typ", "JWT");
            header.put("alg", "HS256");

            return JWT.create()
                    .withHeader(header)
                    .withClaim("token", JSONObject.toJSONString(userTokenDTO))
                    .sign(algorithm);
        } catch (Exception e) {
            log.error("generate token error", e);
            return null;
        }
    }

    /**
     * 解析 Token。
     */
    public static UserTokenDTO parseToken(String token) {
        Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);

        JWTVerifier verifier = JWT.require(algorithm).build();

        DecodedJWT jwt = verifier.verify(token);

        String tokenInfo = jwt.getClaim("token").asString();

        return JSON.parseObject(tokenInfo, UserTokenDTO.class);
    }
}

这里有两个点需要注意。

第一,示例中没有在 JWT 中设置过期时间。

Token 有效期交给 Redis 管理。

第二,TOKEN_SECRET 不要硬编码在生产代码中。

更推荐使用配置:

jwt:
  secret: xxx

再通过配置类读取。

七、Redis 工具类封装

Redis 用来保存用户当前有效 Token。

示例:

import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.util.concurrent.TimeUnit;

@Slf4j
public class RedisServiceImpl implements RedisService {

    /**
     * Token 有效期:1 天
     */
    private final Long DURATION = 1 * 24 * 60 * 60 * 1000L;

    @Resource
    private RedisTemplate redisTemplate;

    private ValueOperations<String, String> valueOperations;

    @PostConstruct
    public void init() {
        RedisSerializer redisSerializer = new StringRedisSerializer();

        redisTemplate.setKeySerializer(redisSerializer);
        redisTemplate.setValueSerializer(redisSerializer);
        redisTemplate.setHashKeySerializer(redisSerializer);
        redisTemplate.setHashValueSerializer(redisSerializer);

        valueOperations = redisTemplate.opsForValue();
    }

    @Override
    public void set(String key, String value) {
        valueOperations.set(key, value, DURATION, TimeUnit.MILLISECONDS);
        log.info("set token into redis, key={}", key);
    }

    @Override
    public String get(String key) {
        return valueOperations.get(key);
    }

    @Override
    public boolean delete(String key) {
        Boolean result = redisTemplate.delete(key);
        log.info("delete token from redis, key={}", key);
        return Boolean.TRUE.equals(result);
    }

    @Override
    public Long getExpireTime(String key) {
        return valueOperations.getOperations().getExpire(key);
    }
}

对应接口:

public interface RedisService {

    void set(String key, String value);

    String get(String key);

    boolean delete(String key);

    Long getExpireTime(String key);
}

这里 Redis 中保存的是:

key   = 用户 ID
value = 当前有效 Token
ttl   = 1 天

这意味着同一时间一个用户只保留一个有效 Token。

如果用户重新登录,新 Token 会覆盖旧 Token。

旧 Token 即使 JWT 本身还能解析,也会因为和 Redis 中保存的不一致而失效。

八、登录功能

登录流程如下:

1. 查询用户是否存在
2. 校验用户名和密码
3. 构造 UserTokenDTO
4. 生成 JWT
5. 将 Token 保存到 Redis
6. 返回 Token 给前端

示例代码:

public String login(LoginUserVO loginUserVO) {
    UserPO userPO = userMapper.getByUsername(loginUserVO.getUsername());

    if (userPO == null) {
        throw new UserException(ErrorCodeEnum.TNP1001001);
    }

    if (!loginUserVO.getPassword().equals(userPO.getPassword())) {
        throw new UserException(ErrorCodeEnum.TNP1001002);
    }

    UserTokenDTO userTokenDTO = new UserTokenDTO();
    userTokenDTO.setId(userPO.getId());
    userTokenDTO.setUsername(userPO.getUsername());
    userTokenDTO.setGmtCreate(System.currentTimeMillis());

    String token = JWTUtil.generateToken(userTokenDTO);

    redisService.set(userPO.getId(), token);

    return token;
}

实际项目中,密码不要明文比较。

更常见的是:

前端传输加密
后端加盐哈希
数据库保存哈希值
登录时使用密码算法校验

例如使用 BCrypt。

九、登出功能

登出时只需要删除 Redis 中保存的 Token。

public boolean logout(String id) {
    boolean result = redisService.delete(id);

    if (!result) {
        throw new UserException(ErrorCodeEnum.TNP1001003);
    }

    return true;
}

删除后,即使前端还保留旧 Token,请求时也会因为 Redis 中查不到 Token 而认证失败。

这就实现了 JWT 的主动失效。

十、修改密码后刷新 Token

用户修改密码后,旧 Token 应该失效。

可以重新生成 Token,并覆盖 Redis 中旧 Token。

public String updatePassword(UpdatePasswordUserVO updatePasswordUserVO) {
    UserPO user = userMapper.getById(updatePasswordUserVO.getId());

    if (user == null) {
        throw new UserException(ErrorCodeEnum.TNP1001001);
    }

    UserPO updateUser = UserPO.builder()
            .id(updatePasswordUserVO.getId())
            .password(updatePasswordUserVO.getPassword())
            .build();

    if (userMapper.updatePassword(updateUser) != 1) {
        throw new UserException(ErrorCodeEnum.TNP1001005);
    }

    UserTokenDTO userTokenDTO = UserTokenDTO.builder()
            .id(updatePasswordUserVO.getId())
            .username(user.getUsername())
            .gmtCreate(System.currentTimeMillis())
            .build();

    String token = JWTUtil.generateToken(userTokenDTO);

    redisService.set(user.getId(), token);

    return token;
}

这样做的效果是:

  1. 密码修改成功;

  2. 生成新 Token;

  3. Redis 中保存新 Token;

  4. 旧 Token 自动失效;

  5. 前端用新 Token 替换本地旧 Token。

如果业务希望修改密码后让用户重新登录,也可以直接删除 Redis Token,而不是返回新 Token。

十一、请求拦截器校验 Token

拦截器主要做两件事:

  1. 校验 Token 是否有效;

  2. 判断是否需要续期。

示例:

public boolean preHandle(HttpServletRequest request,
                         HttpServletResponse response,
                         Object handler) throws Exception {

    String authToken = request.getHeader("Authorization");

    if (authToken == null || !authToken.startsWith("Bearer ")) {
        return false;
    }

    String token = authToken.substring("Bearer".length() + 1).trim();

    UserTokenDTO userTokenDTO = JWTUtil.parseToken(token);

    String redisToken = redisService.get(userTokenDTO.getId());

    if (redisToken == null || !redisToken.equals(token)) {
        return false;
    }

    Long expireTime = redisService.getExpireTime(userTokenDTO.getId());

    if (expireTime != null && expireTime > 0 && expireTime < 30 * 60) {
        redisService.set(userTokenDTO.getId(), token);
        log.info("refresh token expire time, userId={}", userTokenDTO.getId());
    }

    return true;
}

校验逻辑:

1. 从 Header 中获取 Authorization
2. 提取 Bearer Token
3. 解析 Token 获取用户 ID
4. 根据用户 ID 查询 Redis 中保存的 Token
5. Redis 中不存在,说明 Token 已过期或已登出
6. Redis 中存在但不一致,说明 Token 已被替换
7. Redis 中存在且一致,放行请求

续期逻辑:

如果 Redis TTL 小于 30 分钟
则重新 set 一次 Token
刷新过期时间

这样可以避免每次请求都刷新 Redis。

只有临近过期时才续期。

十二、注册拦截器

配置拦截器:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticateInterceptor())
                .excludePathPatterns("/login/**")
                .excludePathPatterns("/logout/**")
                .addPathPatterns("/**");
    }

    @Bean
    public AuthenticateInterceptor authenticateInterceptor() {
        return new AuthenticateInterceptor();
    }
}

这里排除了:

/login/**
/logout/**

登录接口不需要 Token。

登出接口是否排除,要看具体实现。

如果登出需要根据 Token 获取当前用户,通常不应该排除登出接口,而是让登出接口也经过认证。

十三、Token 自动续期方案

本文中的自动续期方式是:

JWT 本身不设置 exp
Redis 中保存 Token,并设置 TTL
请求时检查 Redis TTL
如果剩余时间小于 30 分钟,就刷新 Redis TTL

这种方式的好处是:

  1. 可以服务端主动控制 Token 是否有效;

  2. 可以通过 Redis 删除 Token 实现登出;

  3. 可以通过覆盖 Redis Token 实现单端登录;

  4. 可以在临近过期时自动续期;

  5. 避免每次请求都重新签发 JWT。

但这种方案也意味着:

每次请求都需要访问 Redis。

所以它已经不是完全无状态的 JWT 方案,而是 JWT + 服务端状态校验。

这在很多业务系统中是可以接受的,因为它换来了更好的可控性。

十四、这种方案的优点

1. 适合分布式部署

各服务实例都可以通过 Redis 校验 Token 状态。

不依赖某台机器上的 Session。

2. 支持主动失效

登出时删除 Redis Token。

修改密码时覆盖 Redis Token。

管理员禁用用户时也可以删除 Token。

3. 支持单用户单端登录

Redis 中以用户 ID 作为 Key,只保存一个 Token。

新登录会覆盖旧 Token。

旧 Token 会在下一次请求时失效。

4. 支持滑动过期

Token 临近过期时刷新 Redis TTL。

用户持续访问时不会突然掉线。

十五、需要注意的问题

1. JWT 密钥不要硬编码

示例中:

private static final String TOKEN_SECRET = "123456";

只能用于演示。

生产环境应该使用:

  • 配置中心;

  • 环境变量;

  • KMS;

  • 密钥文件。

并且密钥需要定期轮换。

2. 不要在 Token 中保存敏感信息

JWT Payload 可以被解析。

不要保存密码、身份证号、手机号明文等敏感信息。

3. Authorization 解析要做好异常处理

下面这种代码:

String token = authToken.substring("Bearer".length() + 1).trim();

如果 Header 为空,或者格式不正确,会直接报错。

生产代码中需要先判断:

if (authToken == null || !authToken.startsWith("Bearer ")) {
    return false;
}

并对 JWTUtil.parseToken(token) 的异常进行处理。

4. Redis getExpire 返回值要注意

Redis 的 getExpire 可能返回:

-1:Key 存在但没有设置过期时间
-2:Key 不存在
正数:剩余过期时间

所以判断续期时要考虑这些情况。

5. 每次请求查 Redis 会带来开销

这种方案为了支持主动失效和续期,每次请求都会查 Redis。

如果接口 QPS 很高,需要关注:

  • Redis 性能;

  • Redis 连接池;

  • Token 校验接口耗时;

  • Redis 不可用时的降级策略。

6. 单端登录不一定适合所有业务

以用户 ID 作为 Redis Key,只能保存一个 Token。

这意味着新设备登录会让旧设备 Token 失效。

如果业务需要支持多端登录,可以改成:

key: userId + deviceId
value: token

或者:

key: userId
value: token 列表或 token 版本号

具体要看业务需求。

7. 修改密码后的策略要统一

修改密码后有两种常见策略:

生成新 Token,前端无感续用
删除旧 Token,要求用户重新登录

两种都可以。

但要和安全要求统一。

如果系统安全等级较高,更推荐修改密码后要求重新登录。

十六、完整认证流程

登录流程

1. 用户提交用户名和密码
2. 服务端校验用户是否存在
3. 服务端校验密码是否正确
4. 生成 UserTokenDTO
5. 生成 JWT
6. Redis 保存 userId -> token
7. 返回 Token 给前端

请求认证流程

1. 前端请求 Header 携带 Authorization: Bearer token
2. 后端拦截器提取 Token
3. 解析 Token 获取 userId
4. 根据 userId 查询 Redis Token
5. Redis Token 不存在,拒绝请求
6. Redis Token 和请求 Token 不一致,拒绝请求
7. Redis Token 一致,判断是否需要续期
8. 放行请求

登出流程

1. 用户请求登出
2. 服务端删除 Redis 中的 Token
3. 前端删除本地 Token
4. 后续旧 Token 请求失效

修改密码流程

1. 用户提交新密码
2. 服务端修改密码
3. 重新生成 Token
4. Redis 覆盖旧 Token
5. 返回新 Token 给前端

结论

JWT 和 Session 的核心区别在于登录状态保存位置不同。

Session 状态保存在服务端。

JWT 通常保存在客户端,天然更适合分布式场景,但也带来几个问题:

无法主动废弃
续期需要额外设计
Payload 不能保存敏感信息
Token 泄露后在过期前可能一直有效

本文记录的方案是使用 JWT + Redis

  1. JWT 负责携带用户身份信息;

  2. Redis 负责保存当前有效 Token;

  3. 拦截器负责解析和校验 Token;

  4. Redis TTL 控制过期时间;

  5. 临近过期时自动续期;

  6. 登出或修改密码时更新 Redis 状态。

JWT 适合做身份凭证,Redis 适合做 Token 状态管理。两者结合,可以让 Token 既适合分布式系统,又具备主动失效和自动续期能力。

评论