Spring Boot 中基于 Redis 与 JWT 的分布式登录态管理实战
在单体应用时代,登录态这件事常常“看起来不复杂”:Session 放在 Tomcat 里,浏览器带着 Cookie,服务端一查就知道是谁。
但一旦系统开始横向扩容,问题马上就来了:
- 用户请求会打到不同实例
- Session 不能只存在单机内存
- 登录后想“踢人下线”“主动失效”,纯 JWT 又不太好做
- 微服务之间还要统一身份校验
- 安全要求一上来,还要考虑 token 泄露、刷新、黑名单、并发登录控制
这篇文章我换一个更偏架构落地的角度,带你把 Spring Boot + Redis + JWT 组合起来,做一个既适合分布式部署、又保留一定可控性的登录态管理方案。重点不是“JWT 是什么”,而是:
在真实业务里,如何让 JWT 的无状态优势,与 Redis 的可控状态管理结合起来。
背景与问题
先说结论:纯 Session 和 纯 JWT 都有明显边界。
方案 1:传统 Session 的问题
如果用传统 Session:
- 登录信息保存在应用服务器内存
- 多实例下需要 Session 共享
- 要么引入 Spring Session + Redis
- 要么做粘性会话,但扩展性和容灾都一般
这套方案当然能做,但在前后端分离、网关统一鉴权、移动端/小程序多端接入时,灵活性差一些。
方案 2:纯 JWT 的问题
JWT 很适合做无状态认证:
- 服务端不需要存 Session
- token 自包含用户身份信息
- 非常适合网关、微服务透传
但它也有典型痛点:
- token 一旦签发,在过期前默认有效
- 用户登出后,旧 token 仍可能继续使用
- 无法轻松实现“单端登录”或“踢下线”
- 权限变更后,旧 token 里的 claim 可能还保留旧权限信息
为什么要 Redis + JWT
所以很多中大型系统最终会落在一种折中方案上:
- JWT 负责身份声明与跨服务传递
- Redis 负责 token 生命周期控制、续期、黑名单、并发登录策略
也就是说:
JWT 不是完全无状态,而是“弱状态”; Redis 不是每次都查全量用户信息,而是只存登录会话控制信息。
这套方案兼顾了:
- 分布式部署
- 服务横向扩容
- 登录态统一管理
- 可主动失效
- 多端策略控制
方案全景与取舍分析
先看整体架构。
flowchart LR
U[用户/前端] --> G[Spring Boot API]
G --> F[JWT 解析与校验过滤器]
F --> R[(Redis)]
F --> S[业务服务]
S --> DB[(MySQL)]
G --> L[登录接口]
L --> DB
L --> R
L --> U
核心思路
登录成功后:
- 服务端校验用户名密码
- 生成 JWT,里面放
uid、loginId、exp等关键信息 - 把
loginId -> 会话信息存入 Redis,并设置 TTL - 前端后续请求携带 JWT
- 过滤器先验签,再根据
loginId去 Redis 查会话是否存在 - 存在则放行,不存在则说明已失效/被踢出/已登出
这样做的关键点在于:
- JWT 用来证明“这个 token 是我签发的”
- Redis 用来证明“这个 token 现在还被我认可”
与其他方案的对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯 Session | 简单直观,服务端完全可控 | 分布式共享麻烦,跨服务不灵活 | 单体、后台系统 |
| 纯 JWT | 无状态、扩展性强 | 难主动失效,难控并发 | 简单 API、低风控要求 |
| Redis + JWT | 兼顾扩展性与可控性 | 多一次 Redis 查询,设计复杂一些 | 中大型前后端分离、微服务 |
典型取舍
这个方案本质上是用一点 Redis 成本,换来登录态的强管控能力。
如果你的系统有下面这些诉求,我会优先建议上 Redis + JWT:
- 要支持退出登录立即失效
- 要支持单点登录或限制多端同时在线
- 要支持风控封禁
- 要支持刷新 token 与滑动过期
- 要支持网关统一认证
核心原理
这一节把最核心的对象拆开讲。
1. JWT 里放什么,不放什么
建议放:
uid:用户 IDloginId:本次登录会话唯一标识iat:签发时间exp:过期时间
不建议放:
- 敏感信息,如手机号、密码摘要、身份证号
- 经常变更的大对象,如完整权限列表
- 太长的用户画像信息
原因很简单:JWT 一旦发给客户端,就是可见的,虽然签名能防篡改,但不能防查看。
2. Redis 里存什么
推荐最少存这几类信息:
token:login:{loginId}-> 用户会话信息user:login:{userId}-> 当前活跃 loginId(用于单端登录)token:blacklist:{jti}-> 黑名单(可选)
比如:
token:login:9f3c... -> {"userId":1001,"status":"ONLINE"}
user:login:1001 -> 9f3c...
3. 校验链路
请求到来时,流程通常是:
- 取
Authorization: Bearer xxx - 验证 JWT 签名是否合法
- 检查
exp是否过期 - 取出
loginId - 去 Redis 查
token:login:{loginId}是否存在 - 若存在,构建登录用户上下文
- 若开启滑动过期,可顺带刷新 Redis TTL
这个过程建议放在过滤器或拦截器里,但在 Spring Security 下,用过滤器更自然。
sequenceDiagram
participant C as Client
participant A as API
participant J as JWT Filter
participant R as Redis
participant B as Biz Service
C->>A: 请求 + Bearer Token
A->>J: 进入认证过滤器
J->>J: 验签/校验 exp
J->>R: 查询 loginId 会话
alt 会话存在
R-->>J: 返回会话
J->>B: 放行并注入用户上下文
B-->>C: 业务响应
else 会话不存在
R-->>J: null
J-->>C: 401 未登录或已失效
end
4. 单端登录与多端登录
这块是很多项目一开始没想清楚,后面越改越乱的地方。
单端登录
如果一个账号只允许一个终端在线:
- 用户登录时生成新的
loginId - 查
user:login:{userId}是否存在旧值 - 若有旧
loginId,删除对应 Redis 会话 - 再写入新的映射
多端登录
如果允许 PC、APP、H5 各自在线:
- key 设计里加设备类型
- 如
user:login:{userId}:{device} - 每种设备维护独立 loginId
全局踢下线
只要删掉 Redis 中对应 loginId 的会话即可。
JWT 即便还没过期,也无法再通过 Redis 校验。
数据模型与状态设计
这里给一个更工程化的状态设计。
stateDiagram-v2
[*] --> UNLOGIN
UNLOGIN --> ONLINE: 登录成功
ONLINE --> REFRESHED: 刷新token
REFRESHED --> ONLINE
ONLINE --> LOGOUT: 主动退出
ONLINE --> KICKOUT: 被踢下线
ONLINE --> EXPIRED: Redis/JWT过期
LOGOUT --> [*]
KICKOUT --> [*]
EXPIRED --> [*]
这个状态图的意义在于:你在做日志审计、风控、客服排查时,不只是“token 无效”,而是能回答:
- 是主动退出?
- 是被新设备顶掉?
- 是 Redis 过期?
- 还是 JWT 自然过期?
实战代码(可运行)
下面给一个可运行的最小实现,基于:
- Spring Boot 3.x
- Spring Web
- Spring Security
- Spring Data Redis
- JJWT
为了让重点集中,我省略数据库真实查询,用户账号先写死模拟。
1. Maven 依赖
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>redis-jwt-auth</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
2. application.yml
server:
port: 8080
spring:
data:
redis:
host: localhost
port: 6379
auth:
jwt-secret: "ThisIsASecretKeyForJwtAuthDemo123456"
jwt-expire-seconds: 1800
redis-expire-seconds: 1800
3. 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RedisJwtAuthApplication {
public static void main(String[] args) {
SpringApplication.run(RedisJwtAuthApplication.class, args);
}
}
4. 配置类
package com.example.demo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "auth")
public class AuthProperties {
private String jwtSecret;
private long jwtExpireSeconds;
private long redisExpireSeconds;
public String getJwtSecret() {
return jwtSecret;
}
public void setJwtSecret(String jwtSecret) {
this.jwtSecret = jwtSecret;
}
public long getJwtExpireSeconds() {
return jwtExpireSeconds;
}
public void setJwtExpireSeconds(long jwtExpireSeconds) {
this.jwtExpireSeconds = jwtExpireSeconds;
}
public long getRedisExpireSeconds() {
return redisExpireSeconds;
}
public void setRedisExpireSeconds(long redisExpireSeconds) {
this.redisExpireSeconds = redisExpireSeconds;
}
}
5. JWT 工具类
package com.example.demo.util;
import com.example.demo.config.AuthProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
public class JwtUtil {
private final AuthProperties authProperties;
private final SecretKey secretKey;
public JwtUtil(AuthProperties authProperties) {
this.authProperties = authProperties;
this.secretKey = Keys.hmacShaKeyFor(authProperties.getJwtSecret().getBytes(StandardCharsets.UTF_8));
}
public String generateToken(Map<String, Object> claims) {
long now = System.currentTimeMillis();
long expire = now + authProperties.getJwtExpireSeconds() * 1000;
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(now))
.setExpiration(new Date(expire))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
6. 登录会话对象
package com.example.demo.model;
import java.io.Serializable;
public class LoginSession implements Serializable {
private Long userId;
private String username;
private String loginId;
public LoginSession() {
}
public LoginSession(Long userId, String username, String loginId) {
this.userId = userId;
this.username = username;
this.loginId = loginId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getLoginId() {
return loginId;
}
public void setLoginId(String loginId) {
this.loginId = loginId;
}
}
7. Redis 会话服务
这里演示“单端登录”策略。
package com.example.demo.service;
import com.example.demo.config.AuthProperties;
import com.example.demo.model.LoginSession;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class LoginSessionService {
private final RedisTemplate<String, Object> redisTemplate;
private final AuthProperties authProperties;
public LoginSessionService(RedisTemplate<String, Object> redisTemplate,
AuthProperties authProperties) {
this.redisTemplate = redisTemplate;
this.authProperties = authProperties;
}
private String tokenKey(String loginId) {
return "token:login:" + loginId;
}
private String userLoginKey(Long userId) {
return "user:login:" + userId;
}
public void saveSession(LoginSession session) {
String oldLoginId = (String) redisTemplate.opsForValue().get(userLoginKey(session.getUserId()));
if (oldLoginId != null && !oldLoginId.equals(session.getLoginId())) {
redisTemplate.delete(tokenKey(oldLoginId));
}
redisTemplate.opsForValue().set(
tokenKey(session.getLoginId()),
session,
authProperties.getRedisExpireSeconds(),
TimeUnit.SECONDS
);
redisTemplate.opsForValue().set(
userLoginKey(session.getUserId()),
session.getLoginId(),
authProperties.getRedisExpireSeconds(),
TimeUnit.SECONDS
);
}
public LoginSession getSession(String loginId) {
Object obj = redisTemplate.opsForValue().get(tokenKey(loginId));
return obj instanceof LoginSession ? (LoginSession) obj : null;
}
public void refreshSession(LoginSession session) {
redisTemplate.opsForValue().set(
tokenKey(session.getLoginId()),
session,
authProperties.getRedisExpireSeconds(),
TimeUnit.SECONDS
);
redisTemplate.expire(userLoginKey(session.getUserId()),
authProperties.getRedisExpireSeconds(),
TimeUnit.SECONDS);
}
public void removeSession(Long userId, String loginId) {
redisTemplate.delete(tokenKey(loginId));
redisTemplate.delete(userLoginKey(userId));
}
}
8. 登录服务
package com.example.demo.service;
import com.example.demo.model.LoginSession;
import com.example.demo.util.JwtUtil;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Service
public class AuthService {
private final JwtUtil jwtUtil;
private final LoginSessionService loginSessionService;
public AuthService(JwtUtil jwtUtil, LoginSessionService loginSessionService) {
this.jwtUtil = jwtUtil;
this.loginSessionService = loginSessionService;
}
public String login(String username, String password) {
if (!"admin".equals(username) || !"123456".equals(password)) {
throw new RuntimeException("用户名或密码错误");
}
Long userId = 1001L;
String loginId = UUID.randomUUID().toString().replace("-", "");
LoginSession session = new LoginSession(userId, username, loginId);
loginSessionService.saveSession(session);
Map<String, Object> claims = new HashMap<>();
claims.put("uid", userId);
claims.put("username", username);
claims.put("loginId", loginId);
return jwtUtil.generateToken(claims);
}
public void logout(Long userId, String loginId) {
loginSessionService.removeSession(userId, loginId);
}
}
9. 认证过滤器
我个人更建议把逻辑拆得清楚点:JWT 只做解析,Redis 只做会话确认,出了问题就尽量早返回 401。
package com.example.demo.security;
import com.example.demo.model.LoginSession;
import com.example.demo.service.LoginSessionService;
import com.example.demo.util.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final LoginSessionService loginSessionService;
public JwtAuthenticationFilter(JwtUtil jwtUtil,
LoginSessionService loginSessionService) {
this.jwtUtil = jwtUtil;
this.loginSessionService = loginSessionService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
Claims claims = jwtUtil.parseToken(token);
String loginId = claims.get("loginId", String.class);
LoginSession session = loginSessionService.getSession(loginId);
if (session == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Login session expired or invalid");
return;
}
loginSessionService.refreshSession(session);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
session,
null,
AuthorityUtils.createAuthorityList("ROLE_USER")
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid token");
return;
}
filterChain.doFilter(request, response);
}
}
10. Security 配置
package com.example.demo.config;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
11. Controller
package com.example.demo.controller;
import com.example.demo.model.LoginSession;
import com.example.demo.service.AuthService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public Map<String, Object> login(@RequestBody Map<String, String> body) {
String token = authService.login(body.get("username"), body.get("password"));
Map<String, Object> result = new HashMap<>();
result.put("token", token);
return result;
}
@PostMapping("/logout")
public String logout(Authentication authentication) {
LoginSession session = (LoginSession) authentication.getPrincipal();
authService.logout(session.getUserId(), session.getLoginId());
return "ok";
}
@GetMapping("/me")
public Object me(Authentication authentication) {
return authentication.getPrincipal();
}
}
12. RedisTemplate 序列化配置
如果不配,很多人第一次跑就会遇到 key/value 可读性差、反序列化异常等问题。
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
13. 测试方式
登录
curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"123456"}'
返回:
{
"token": "eyJhbGciOiJIUzI1NiJ9..."
}
访问受保护接口
curl 'http://localhost:8080/auth/me' \
-H 'Authorization: Bearer 你的token'
登出
curl -X POST 'http://localhost:8080/auth/logout' \
-H 'Authorization: Bearer 你的token'
登出后再次访问 /auth/me,会返回 401。
方案进阶:刷新 token 与双 token 设计
如果你把 access token 设置得比较短,比如 30 分钟,用户体验可能会变差;设置得太长,风险又上升。
这时通常会引入双 token:
- Access Token:短期有效,比如 30 分钟
- Refresh Token:长期有效,比如 7 天
刷新流程如下:
flowchart TD
A[用户登录] --> B[签发 Access Token + Refresh Token]
B --> C[Access Token 访问接口]
C --> D{是否过期}
D -- 否 --> E[继续访问]
D -- 是 --> F[携带 Refresh Token 请求刷新]
F --> G[服务端校验 Refresh Token + Redis 会话]
G -- 成功 --> H[签发新的 Access Token]
G -- 失败 --> I[要求重新登录]
什么时候需要双 token
建议在以下场景使用:
- 移动端、Web 长会话
- 用户停留时间长
- 系统希望 access token 尽量短命
什么时候不一定要上
如果你的系统是后台管理系统,用户量不大,且登录后停留周期可接受,那么单 token + Redis 续期其实已经够用。
架构不要一上来就做满配,否则排查问题时复杂度也会同步上升。
常见坑与排查
这一部分我尽量写得“像在现场排故”,因为这类问题真不是看概念能避免的。
坑 1:JWT 没过期,但接口返回未登录
现象:
- token 解析没问题
- 但接口返回 401
高概率原因:
- Redis 里的
loginId会话过期了 - 用户被新登录顶掉了
- key 前缀写错或环境串了
排查方式:
redis-cli
keys token:login:*
get user:login:1001
如果用了 JSON 序列化,建议直接看 TTL:
ttl token:login:xxxx
坑 2:服务重启后 Redis 里的对象反序列化失败
原因:
- 使用了 JDK 默认序列化
- 类结构改动后旧数据无法兼容
建议:
- 尽量使用 JSON 序列化
- Redis 中的会话对象字段保持扁平
- 不要把复杂嵌套对象一股脑塞进去
坑 3:滑动续期导致“永不过期”
我以前就见过有人每次请求都刷新 Redis TTL,而 JWT 自己又设得很长,结果一个 token 只要有人一直调用,就能长期有效。
建议:
- Redis 续期可以有
- 但 JWT 最长生命周期要设上限
- 更严谨一点,可以增加绝对过期时间
maxExpireAt
比如:
- Redis 空闲超时 30 分钟
- JWT 有效期 30 分钟
- 最大登录存活时间 7 天
这样既能续期,也不会无限延长。
坑 4:多实例部署时偶发认证不一致
原因可能是:
- 不同实例 JWT secret 不一致
- 时间不同步导致
exp判断异常 - Redis 指向了不同库或不同环境
排查建议:
- 所有实例统一配置中心
- 机器做 NTP 时间同步
- 登录链路打印
loginId、uid、实例名
坑 5:Spring Security 拦截顺序不对
如果过滤器没放到正确位置,可能会出现:
- 明明带 token 了,但拿不到认证上下文
- Controller 里
Authentication为 null
建议:
- JWT 过滤器放在
UsernamePasswordAuthenticationFilter之前 - 确保无 token 请求可以正常穿过登录接口
- 不要在过滤器里吞掉所有异常却不打日志
安全最佳实践
登录态方案的核心不是“能不能跑”,而是“出事时损失有多大”。
1. Access Token 尽量短命
推荐:
- 后台管理:30 分钟到 2 小时
- 高风险系统:15 到 30 分钟
- 配合 Refresh Token 使用更稳妥
2. Redis 只存必要会话信息
不要把下面这些直接放 Redis 会话里:
- 大量权限菜单树
- 用户详细资料
- 高敏感字段
建议只保留:
- userId
- loginId
- device
- status
- 登录时间 / 最后访问时间
3. JWT secret 必须足够强
不要用这种:
123456
test
jwt-secret
应当:
- 长度足够
- 来自安全随机源
- 放配置中心或环境变量
- 定期轮换
4. 前端存储位置要谨慎
这是老问题,但仍然常见:
- 放
localStorage:实现方便,但更怕 XSS - 放 HttpOnly Cookie:可降低脚本窃取风险,但要处理 CSRF
没有绝对标准,要看你的前后端架构。
如果是同域 Web,我更偏向 HttpOnly Cookie + CSRF 防护;
如果是前后端分离跨域 API,很多团队还是会用 Authorization 头,但一定要强化 XSS 防护。
5. 重要操作做二次校验
即使登录态有效,以下场景仍建议加二次校验:
- 修改密码
- 换绑手机号
- 提现
- 删除关键数据
可以要求:
- 输入密码
- 短信验证码
- MFA / TOTP
6. 记录登录审计日志
建议至少记录:
- userId
- loginId
- IP
- User-Agent
- 登录时间
- 登出时间
- 失败原因
当线上出现“我明明没操作”“账号被顶下线”时,这些日志非常关键。
性能最佳实践
很多人看到“每个请求都查 Redis”会担心性能,其实大部分情况下这不是瓶颈,但需要设计得合理。
1. Redis QPS 预估
假设:
- 日活 10 万
- 峰值同时在线 1 万
- 峰值请求 5000 QPS
- 每次请求 1 次 Redis 读
那 Redis 至少要扛住 5000 QPS 的读请求。
这对单个 Redis 来说通常不是问题,但要注意:
- 网络延迟
- 序列化成本
- 热 key
- 连接池配置
2. 会话对象尽量轻量
Redis 存的对象越小:
- 序列化越快
- 网络传输越省
- 内存占用越低
建议把会话控制信息控制在很小的 JSON 结构内。
3. 合理设置 TTL
TTL 太短:
- 用户频繁掉线
- 刷新过于频繁
TTL 太长:
- 风险窗口变大
- 垃圾会话回收变慢
常见实践:
- Access Token:30 分钟
- Redis 会话:30 分钟空闲过期
- Refresh Token:7 天
4. 网关统一认证
如果是微服务架构,认证最好收敛到网关层,避免每个服务都重复做:
- token 解析
- Redis 查询
- 用户上下文构建
后端服务之间传递已经认证好的用户信息,能减少重复开销。
5. 限流与熔断
登录接口、刷新接口要重点保护:
- 防暴力破解
- 防 token 刷新风暴
- 防 Redis 抖动引发认证雪崩
可以做:
- IP 限流
- 用户维度限流
- 登录失败次数限制
- Redis 异常时的降级策略
边界条件与落地建议
这里给几个非常实用的选型建议。
适合 Redis + JWT 的系统
- 前后端分离
- 多实例部署
- 有统一认证需求
- 需要踢人下线、单端登录、滑动续期
不一定非要上这套的系统
- 只有一个单体服务
- 用户量小
- 登录控制不复杂
- 没有跨服务认证需求
这种场景下,Spring Session + Redis 可能更省心。
我建议的默认落地版本
如果你让我给一个“别太复杂、但够实用”的版本,我会建议:
- Access Token 中只放
uid + loginId - Redis 存轻量会话对象
- 所有请求都做 Redis 会话校验
- 支持主动登出与单端登录
- 先不上黑名单,除非有刷新 token 或高安全场景
- 需要长期登录时再加 Refresh Token
这样复杂度是可控的,而且后续能平滑扩展。
总结
基于 Redis + JWT 的分布式登录态管理,本质上是在两种思路之间做平衡:
- JWT 提供无状态、易扩展、适合跨服务传递的能力
- Redis 提供会话可控、可失效、可踢下线、可续期的能力
如果只记住一句话,我希望是:
不要把 JWT 当成“签发了就完全不管”的令牌,真正可落地的分布式登录态,往往需要 Redis 做会话控制兜底。
落地时建议优先把这几件事做好:
- JWT claim 保持精简
- Redis key 设计清晰
- 单端/多端策略提前定好
- 过滤器中明确区分“验签失败”和“会话失效”
- 给登录链路补齐日志与 TTL 观测
最后提醒一个边界条件:
如果你的系统对“立即失效”“风控封禁”“并发会话控制”完全没有要求,那么纯 JWT 也不是不能用;但只要你开始需要“可控登录态”,Redis 几乎就会重新回到方案里。
这套方案没有银弹,但在大多数中型 Java Web 系统里,它是一个非常稳、也非常常见的工程化选择。