用户登录态与Token管理:让登录安全又高效!🔐

118 阅读9分钟

标题: 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
愿你的系统安全可靠! 🔐✨