标题: Session还是JWT?一文搞懂登录态管理的所有套路!
副标题: 从Session到JWT,从单机到分布式,登录认证全攻略
🎬 开篇:一次糟糕的登录体验
用户小明的一天:
上午9点 - 登录系统,开始工作
上午10点 - 刷新页面,提示"请重新登录" 😰
上午10点05分 - 重新登录
上午11点 - 又掉线了... 😤
下午2点 - 换了台电脑,之前的登录失效
下午3点 - 手机APP和网页登录冲突,被踢下线
小明:这什么破系统!💀
技术原因:
1. Session存在单台服务器,负载均衡后找不到
2. Session超时时间设置太短(30分钟)
3. 没有实现多端登录
4. 没有记住登录功能
用户流失率:30% ⬆️
投诉量:爆炸 💥
教训:登录态管理直接影响用户体验!
🤔 什么是登录态?
想象你去健身房:
- ❌ 每次都办卡: 每做一个动作都要重新办卡(烦死)
- ✅ 办一张会员卡: 进门刷卡即可,有效期内随便用(方便!)
登录态 = 会员卡,证明"你已经登录过了"
📚 知识地图
登录态管理技术体系
├── 🍪 Session + Cookie(传统方案)
├── 🎫 JWT(无状态Token)
├── 🔄 OAuth 2.0(第三方登录)
├── 🔐 单点登录 SSO
└── 📱 多端登录管理
🍪 方案1:Session + Cookie(传统方案)
🌰 生活中的例子
寄存柜:
- 存东西拿到号码牌(SessionId)
- 凭号码牌取东西(Cookie携带SessionId)
- 号码牌有效期(Session过期时间)
💻 技术实现
/**
* 传统Session方案
*/
@RestController
@RequestMapping("/auth")
public class SessionAuthController {
@Autowired
private UserService userService;
/**
* 登录
*/
@PostMapping("/login")
public Result login(@RequestBody LoginDTO dto, HttpServletRequest request) {
// 1. 验证用户名密码
User user = userService.findByUsername(dto.getUsername());
if (user == null) {
return Result.error("用户不存在");
}
// 2. 验证密码(使用BCrypt)
if (!BCrypt.checkpw(dto.getPassword(), user.getPassword())) {
return Result.error("密码错误");
}
// 3. 检查账号状态
if (user.getStatus() == UserStatus.DISABLED) {
return Result.error("账号已被禁用");
}
// 4. ⚡ 创建Session
HttpSession session = request.getSession();
session.setAttribute("userId", user.getId());
session.setAttribute("username", user.getUsername());
session.setAttribute("loginTime", System.currentTimeMillis());
// 5. 设置Session超时时间(30分钟)
session.setMaxInactiveInterval(30 * 60);
log.info("用户登录成功:userId={}, sessionId={}",
user.getId(), session.getId());
return Result.success(Map.of(
"userId", user.getId(),
"username", user.getUsername(),
"sessionId", session.getId()
));
}
/**
* 登出
*/
@PostMapping("/logout")
public Result logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
Long userId = (Long) session.getAttribute("userId");
session.invalidate(); // 销毁Session
log.info("用户登出:userId={}", userId);
}
return Result.success("登出成功");
}
/**
* 获取当前用户信息
*/
@GetMapping("/current-user")
public Result getCurrentUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return Result.error("未登录");
}
Long userId = (Long) session.getAttribute("userId");
String username = (String) session.getAttribute("username");
return Result.success(Map.of(
"userId", userId,
"username", username
));
}
}
/**
* 登录拦截器
*/
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 1. 获取Session
HttpSession session = request.getSession(false);
// 2. 检查是否登录
if (session == null || session.getAttribute("userId") == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{"code":401,"message":"未登录"}");
return false;
}
// 3. 将用户信息放入ThreadLocal
Long userId = (Long) session.getAttribute("userId");
UserContext.setCurrentUserId(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// 清理ThreadLocal
UserContext.clear();
}
}
/**
* 拦截器配置
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login",
"/auth/register",
"/public/**"
);
}
}
/**
* 优点:
* ✅ 实现简单
* ✅ 服务端可控(可随时销毁)
* ✅ 安全性高(数据在服务端)
*
* 缺点:
* ❌ 占用服务器内存
* ❌ 不支持分布式(需要Session共享)
* ❌ 跨域问题(Cookie限制)
* ❌ 移动端不友好
*
* 适用场景:
* ✅ 单体应用
* ✅ 传统Web应用
* ✅ 对安全性要求高的场景
*/
Session分布式共享方案
/**
* 方案1:Spring Session + Redis
*/
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) // 30分钟
public class RedisSessionConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 使用Jackson序列化
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(Object.class);
template.setDefaultSerializer(serializer);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
return template;
}
}
/**
* 配置文件
*/
// application.yml
spring:
session:
store-type: redis
redis:
namespace: spring:session # Redis key前缀
redis:
host: localhost
port: 6379
database: 0
password:
timeout: 3000ms
/**
* 使用后效果:
*
* 1. Session自动存储到Redis
* 2. 多台服务器共享Session
* 3. Session持久化,重启不丢失
*
* Redis中的数据结构:
* spring:session:sessions:xxx-xxx-xxx -> Session数据(Hash)
* spring:session:expirations:timestamp -> 过期时间索引
*/
🎫 方案2:JWT(推荐!)
🌰 生活中的例子
身份证:
- 上面写着你的信息(姓名、年龄等)
- 有防伪标识(签名)
- 别人改了会被发现(签名验证失败)
- 不需要联网查询(自包含)
💻 JWT原理
JWT结构:Header.Payload.Signature
Header(头部):
{
"alg": "HS256", # 签名算法
"typ": "JWT" # Token类型
}
Payload(负载):
{
"sub": "1001", # 用户ID
"username": "张三", # 用户名
"exp": 1735027200, # 过期时间
"iat": 1735023600 # 签发时间
}
Signature(签名):
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret # 密钥
)
最终Token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiLlvKDkuIkiLCJleHAiOjE3MzUwMjcyMDAsImlhdCI6MTczNTAyMzYwMH0.
xxx-signature-xxx
JWT实现
/**
* JWT工具类
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret; // 密钥
@Value("${jwt.expiration}")
private Long expiration; // 过期时间(秒)
/**
* 生成Token
*/
public String generateToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());
claims.put("roles", user.getRoles());
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration * 1000);
return Jwts.builder()
.setClaims(claims) // 自定义声明
.setSubject(user.getId().toString()) // 主题(用户ID)
.setIssuedAt(now) // 签发时间
.setExpiration(expiryDate) // 过期时间
.signWith(SignatureAlgorithm.HS512, secret) // 签名算法和密钥
.compact();
}
/**
* 从Token中获取用户ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.get("userId", Long.class);
}
/**
* 从Token中获取用户名
*/
public String getUsernameFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.get("username", String.class);
}
/**
* 验证Token
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token);
return true;
} catch (SignatureException e) {
log.error("Invalid JWT signature");
} catch (MalformedJwtException e) {
log.error("Invalid JWT token");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token");
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty");
}
return false;
}
/**
* 判断Token是否过期
*/
public boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return true;
}
}
/**
* 刷新Token
*/
public String refreshToken(String token) {
try {
Claims claims = getClaimsFromToken(token);
claims.setIssuedAt(new Date());
claims.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000));
return Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
} catch (Exception e) {
return null;
}
}
/**
* 解析Token
*/
private Claims getClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
}
/**
* JWT认证过滤器
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. 从Header中获取Token
String token = getTokenFromRequest(request);
if (token != null && jwtUtil.validateToken(token)) {
// 2. 从Token中获取用户信息
Long userId = jwtUtil.getUserIdFromToken(token);
String username = jwtUtil.getUsernameFromToken(token);
// 3. 将用户信息放入ThreadLocal
UserContext.setCurrentUserId(userId);
UserContext.setCurrentUsername(username);
log.debug("JWT认证成功:userId={}", userId);
}
// 4. 继续过滤链
filterChain.doFilter(request, response);
// 5. 请求结束后清理ThreadLocal
UserContext.clear();
}
/**
* 从请求中获取Token
*/
private String getTokenFromRequest(HttpServletRequest request) {
// 从Header中获取:Authorization: Bearer <token>
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
// 也可以从Cookie中获取
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("token".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
/**
* 登录接口
*/
@RestController
@RequestMapping("/auth")
public class JwtAuthController {
@Autowired
private UserService userService;
@Autowired
private JwtUtil jwtUtil;
/**
* 登录
*/
@PostMapping("/login")
public Result login(@RequestBody LoginDTO dto) {
// 1. 验证用户名密码
User user = userService.authenticate(dto.getUsername(), dto.getPassword());
if (user == null) {
return Result.error("用户名或密码错误");
}
// 2. ⚡ 生成JWT Token
String token = jwtUtil.generateToken(user);
// 3. 生成刷新Token(有效期更长)
String refreshToken = jwtUtil.generateRefreshToken(user);
log.info("用户登录成功:userId={}", user.getId());
return Result.success(Map.of(
"token", token,
"refreshToken", refreshToken,
"tokenType", "Bearer",
"expiresIn", jwtUtil.getExpiration()
));
}
/**
* 刷新Token
*/
@PostMapping("/refresh")
public Result refreshToken(@RequestBody Map<String, String> request) {
String refreshToken = request.get("refreshToken");
if (!jwtUtil.validateToken(refreshToken)) {
return Result.error("刷新Token无效");
}
// 生成新的访问Token
String newToken = jwtUtil.refreshToken(refreshToken);
return Result.success(Map.of(
"token", newToken,
"tokenType", "Bearer"
));
}
/**
* 登出(客户端删除Token即可,服务端可选实现黑名单)
*/
@PostMapping("/logout")
public Result logout(@RequestHeader("Authorization") String authorization) {
String token = authorization.substring(7); // 去掉"Bearer "
// 可选:将Token加入黑名单(Redis)
redisTemplate.opsForValue().set(
"token:blacklist:" + token,
"1",
Duration.ofHours(24) // 黑名单有效期
);
return Result.success("登出成功");
}
}
/**
* 优点:
* ✅ 无状态(服务端不存储)
* ✅ 支持分布式(天然支持)
* ✅ 跨域友好(放在Header中)
* ✅ 移动端友好
* ✅ 性能好(无需查询Session)
*
* 缺点:
* ❌ Token无法撤销(需要黑名单)
* ❌ Token较大(包含用户信息)
* ❌ 安全性依赖密钥管理
*
* 适用场景:
* ✅ 分布式系统(推荐)
* ✅ 微服务架构
* ✅ 移动端APP
* ✅ 前后端分离
*/
JWT最佳实践
/**
* 双Token方案(访问Token + 刷新Token)
*/
@Component
public class DoubleTokenService {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 生成访问Token和刷新Token
*/
public TokenPair generateTokenPair(User user) {
// 1. 访问Token(短期有效:2小时)
String accessToken = jwtUtil.generateToken(user, 2 * 3600);
// 2. 刷新Token(长期有效:7天)
String refreshToken = UUID.randomUUID().toString();
// 3. 将刷新Token存入Redis
String key = "refresh:token:" + user.getId();
redisTemplate.opsForValue().set(
key,
refreshToken,
Duration.ofDays(7)
);
return TokenPair.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(2 * 3600)
.build();
}
/**
* 使用刷新Token获取新的访问Token
*/
public String refreshAccessToken(Long userId, String refreshToken) {
// 1. 从Redis获取刷新Token
String key = "refresh:token:" + userId;
String storedToken = (String) redisTemplate.opsForValue().get(key);
// 2. 验证刷新Token
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("刷新Token无效");
}
// 3. 生成新的访问Token
User user = userService.getById(userId);
return jwtUtil.generateToken(user, 2 * 3600);
}
/**
* 登出(删除刷新Token)
*/
public void logout(Long userId) {
String key = "refresh:token:" + userId;
redisTemplate.delete(key);
}
}
/**
* 前端使用示例:
*/
// 1. 登录后保存Token
localStorage.setItem('accessToken', response.data.accessToken)
localStorage.setItem('refreshToken', response.data.refreshToken)
// 2. 请求时携带Token
axios.interceptors.request.use(config => {
const token = localStorage.getItem('accessToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 3. Token过期自动刷新
axios.interceptors.response.use(
response => response,
async error => {
if (error.response.status === 401) {
// Token过期,使用refreshToken刷新
const refreshToken = localStorage.getItem('refreshToken')
const response = await axios.post('/auth/refresh', { refreshToken })
const newToken = response.data.token
// 保存新Token
localStorage.setItem('accessToken', newToken)
// 重试原请求
error.config.headers.Authorization = `Bearer ${newToken}`
return axios.request(error.config)
}
return Promise.reject(error)
}
)
📱 多端登录管理
/**
* 多端登录控制
*/
@Service
public class MultiDeviceLoginService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 登录策略
*/
public enum LoginStrategy {
SINGLE, // 单设备登录(踢掉其他设备)
MULTIPLE, // 多设备登录(不限制)
SAME_TYPE // 同类型设备单登录(PC单登录,手机单登录)
}
/**
* 设备类型
*/
public enum DeviceType {
PC,
MOBILE,
TABLET,
WEB
}
/**
* 登录
*/
public String login(User user, DeviceType deviceType, LoginStrategy strategy) {
String token = jwtUtil.generateToken(user);
String deviceKey = "user:devices:" + user.getId();
switch (strategy) {
case SINGLE:
// 单设备登录:清除所有旧Token
redisTemplate.delete(deviceKey);
break;
case SAME_TYPE:
// 同类型设备单登录:只清除同类型设备的Token
Map<String, String> devices =
(Map<String, String>) redisTemplate.opsForHash().entries(deviceKey);
devices.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(deviceType.name()))
.forEach(entry -> redisTemplate.opsForHash().delete(deviceKey, entry.getKey()));
break;
case MULTIPLE:
// 多设备登录:不做处理
break;
}
// 保存新Token
String deviceId = deviceType.name() + "_" + UUID.randomUUID().toString();
redisTemplate.opsForHash().put(deviceKey, deviceId, token);
redisTemplate.expire(deviceKey, Duration.ofDays(7));
return token;
}
/**
* 查询用户的所有在线设备
*/
public List<DeviceInfo> getOnlineDevices(Long userId) {
String deviceKey = "user:devices:" + userId;
Map<Object, Object> devices = redisTemplate.opsForHash().entries(deviceKey);
return devices.entrySet().stream()
.map(entry -> {
String deviceId = (String) entry.getKey();
String token = (String) entry.getValue();
return DeviceInfo.builder()
.deviceId(deviceId)
.deviceType(deviceId.split("_")[0])
.loginTime(jwtUtil.getIssuedAt(token))
.lastActiveTime(new Date())
.build();
})
.collect(Collectors.toList());
}
/**
* 踢出指定设备
*/
public void kickOutDevice(Long userId, String deviceId) {
String deviceKey = "user:devices:" + userId;
redisTemplate.opsForHash().delete(deviceKey, deviceId);
log.info("踢出设备:userId={}, deviceId={}", userId, deviceId);
}
}
✅ 最佳实践
Token设计:
□ 使用双Token方案(访问Token + 刷新Token)
□ 访问Token短期有效(2小时)
□ 刷新Token长期有效(7天)
□ Token包含必要信息(不要太大)
□ 使用强密钥(至少256位)
安全加固:
□ HTTPS传输(防止Token被窃取)
□ Token存储安全(不要存localStorage,用httpOnly Cookie)
□ 实现Token黑名单(登出、密码修改)
□ 限制Token刷新次数(防止滥用)
□ 记录登录日志(IP、设备、时间)
用户体验:
□ 记住登录(7天免登录)
□ 多端登录支持
□ Token自动刷新(用户无感知)
□ 登录设备管理
□ 异地登录提醒
性能优化:
□ JWT无状态(无需查询Session)
□ Redis缓存用户信息
□ Token不要过大(影响传输)
□ 合理设置过期时间
🎉 总结
核心要点
登录态管理方案选择:
1️⃣ 传统Web应用 -> Session + Cookie
- 简单可靠
- 服务端可控
- 适合单体应用
2️⃣ 分布式系统 -> JWT(推荐)
- 无状态
- 天然分布式
- 移动端友好
3️⃣ 企业级应用 -> OAuth 2.0 + SSO
- 统一认证
- 第三方登录
- 微服务架构
4️⃣ 最佳实践:
- 双Token方案
- Token黑名单
- 多端登录管理
- 安全传输(HTTPS)
📚 延伸阅读
记住:登录态管理要"安全、便捷、可控"三者兼顾! 🔐
文档编写时间:2025年10月24日
作者:热爱安全的认证工程师
版本:v1.0
愿你的系统安全可靠! 🔐✨