JWT Token 认证

学习目标

  • 理解 JWT 的原理和结构
  • 掌握 JWT Token 的生成和验证
  • 实现基于 JWT 的认证系统
  • 实现 Token 刷新机制
  • 解决前后端分离的认证问题

一、为什么需要 JWT?

1.1 传统 Session 认证的问题

传统 Session 认证流程:
用户登录 → 服务器创建 Session → 返回 SessionID(Cookie)
    ↓
用户请求 → 携带 SessionID → 服务器查找 Session → 验证身份

问题:
├─ Session 存储在服务器内存,占用资源
├─ 分布式部署需要 Session 共享(Redis)
├─ 移动端不支持 Cookie
├─ 跨域问题复杂
└─ 扩展性差

1.2 JWT 的优势

JWT Token 认证流程:
用户登录 → 服务器生成 JWT Token → 返回 Token
    ↓
用户请求 → 携带 Token(Header) → 服务器验证 Token → 验证身份

优势:
✓ 无状态,服务器不存储 Token
✓ 适合分布式系统
✓ 支持移动端
✓ 跨域友好
✓ 扩展性好

1.3 适用场景

适合使用 JWT:
├─ 前后端分离项目
├─ 移动端 APP
├─ 微服务架构
├─ 单点登录(SSO)
└─ API 接口认证

不适合使用 JWT:
├─ 传统 Web 应用(Session 更简单)
├─ 需要立即撤销 Token 的场景
└─ Token 过期时间很长的场景

二、JWT 原理详解

2.1 JWT 结构

JWT Token 由三部分组成,用 . 分隔:

Header.Payload.Signature

示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    ↑                                       ↑                                                                           ↑
  Header                                 Payload                                                                   Signature

2.2 Header(头部)

{
  "alg": "HS256",    // 签名算法
  "typ": "JWT"       // Token 类型
}

Base64 编码后:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

2.3 Payload(负载)

{
  "sub": "1234567890",           // 主题(用户ID)
  "name": "John Doe",            // 用户名
  "iat": 1516239022,             // 签发时间
  "exp": 1516242622,             // 过期时间
  "roles": ["USER", "ADMIN"]     // 自定义字段:角色
}

标准字段(Claims):

iss (issuer):签发者
sub (subject):主题(通常是用户ID)
aud (audience):接收方
exp (expiration time):过期时间
nbf (not before):生效时间
iat (issued at):签发时间
jti (JWT ID):唯一标识

Base64 编码后:

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

2.4 Signature(签名)

签名算法:
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

作用:
├─ 防止 Token 被篡改
├─ 验证 Token 的真实性
└─ 只有拥有密钥的服务器才能验证

签名结果:

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

2.5 工作流程

┌─────────────────────────────────────────────────────────┐
│                    JWT 认证流程                          │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  1. 用户登录                                             │
│     ├─ POST /api/auth/login                             │
│     ├─ {username: "admin", password: "123456"}          │
│     └─ 服务器验证用户名密码                              │
│                                                          │
│  2. 生成 JWT Token                                      │
│     ├─ 创建 Payload(用户信息)                         │
│     ├─ 使用密钥签名                                      │
│     └─ 返回 Token 给客户端                              │
│                                                          │
│  3. 客户端存储 Token                                     │
│     ├─ LocalStorage / SessionStorage                    │
│     └─ 每次请求携带 Token                               │
│                                                          │
│  4. 服务器验证 Token                                     │
│     ├─ 提取 Token(从 Header)                          │
│     ├─ 验证签名                                          │
│     ├─ 检查过期时间                                      │
│     └─ 提取用户信息                                      │
│                                                          │
│  5. 返回响应                                             │
│     ├─ Token 有效 → 处理请求                            │
│     └─ Token 无效 → 返回 401                            │
│                                                          │
└─────────────────────────────────────────────────────────┘

三、JWT 实现

3.1 添加依赖

<!-- pom.xml -->
<dependencies>
    <!-- JWT 库 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.3</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

3.2 JWT 配置

# application.yml
jwt:
  secret: mySecretKeyForJWTTokenGenerationAndValidation123456789  # 密钥(至少32字符)
  expiration: 86400000  # Token 过期时间(毫秒,24小时)
  refresh-expiration: 604800000  # 刷新 Token 过期时间(7天)
  header: Authorization  # Token 在 Header 中的名称
  prefix: Bearer   # Token 前缀

3.3 JWT 工具类

@Component
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.expiration}")
    private Long expiration;
    
    @Value("${jwt.refresh-expiration}")
    private Long refreshExpiration;
    
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
    
    /**
     * 生成访问 Token
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", userDetails.getUsername());
        claims.put("authorities", userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toList()));
        
        return createToken(claims, userDetails.getUsername(), expiration);
    }
    
    /**
     * 生成刷新 Token
     */
    public String generateRefreshToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", userDetails.getUsername());
        
        return createToken(claims, userDetails.getUsername(), refreshExpiration);
    }
    
    /**
     * 创建 Token
     */
    private String createToken(Map<String, Object> claims, String subject, Long expiration) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);
        
        return Jwts.builder()
            .setClaims(claims)
            .setSubject(subject)
            .setIssuedAt(now)
            .setExpiration(expiryDate)
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
    }
    
    /**
     * 从 Token 中提取用户名
     */
    public String getUsernameFromToken(String token) {
        return getClaimsFromToken(token).getSubject();
    }
    
    /**
     * 从 Token 中提取权限
     */
    @SuppressWarnings("unchecked")
    public List<String> getAuthoritiesFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        return (List<String>) claims.get("authorities");
    }
    
    /**
     * 从 Token 中提取过期时间
     */
    public Date getExpirationDateFromToken(String token) {
        return getClaimsFromToken(token).getExpiration();
    }
    
    /**
     * 解析 Token
     */
    private Claims getClaimsFromToken(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token)
            .getBody();
    }
    
    /**
     * 验证 Token 是否有效
     */
    public boolean validateToken(String token, UserDetails userDetails) {
        try {
            String username = getUsernameFromToken(token);
            return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
        } catch (Exception e) {
            return false;
        }
    }
    
    /**
     * 检查 Token 是否过期
     */
    private boolean isTokenExpired(String token) {
        Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }
}

3.4 JWT 认证过滤器

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Value("${jwt.header}")
    private String header;
    
    @Value("${jwt.prefix}")
    private String prefix;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                   HttpServletResponse response,
                                   FilterChain filterChain) 
            throws ServletException, IOException {
        
        try {
            // 1. 从请求头中获取 Token
            String token = getTokenFromRequest(request);
            
            if (token != null) {
                // 2. 从 Token 中提取用户名
                String username = tokenProvider.getUsernameFromToken(token);
                
                // 3. 加载用户信息
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                
                // 4. 验证 Token
                if (tokenProvider.validateToken(token, userDetails)) {
                    // 5. 创建认证对象
                    UsernamePasswordAuthenticationToken authentication = 
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );
                    
                    authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                    );
                    
                    // 6. 设置到 SecurityContext
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        } catch (Exception e) {
            logger.error("无法设置用户认证:{}", e.getMessage());
        }
        
        // 7. 继续过滤器链
        filterChain.doFilter(request, response);
    }
    
    /**
     * 从请求头中提取 Token
     */
    private String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader(header);
        
        if (bearerToken != null && bearerToken.startsWith(prefix + " ")) {
            return bearerToken.substring(7);  // 去掉 "Bearer " 前缀
        }
        
        return null;
    }
}

3.5 Security 配置

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 禁用 CSRF(JWT 不需要)
            .csrf(csrf -> csrf.disable())
            
            // 禁用 Session(使用 JWT,不需要 Session)
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            
            // 配置授权规则
            .authorizeHttpRequests(auth -> auth
                // 公开接口
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                
                // 需要认证的接口
                .requestMatchers("/api/**").authenticated()
                
                .anyRequest().permitAll()
            )
            
            // 添加 JWT 过滤器
            .addFilterBefore(jwtAuthenticationFilter, 
                           UsernamePasswordAuthenticationFilter.class)
            
            // 异常处理
            .exceptionHandling(exception -> exception
                .authenticationEntryPoint((request, response, authException) -> {
                    response.setContentType("application/json;charset=UTF-8");
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.getWriter().write("{\"error\":\"未授权,请先登录\"}");
                })
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    response.setContentType("application/json;charset=UTF-8");
                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    response.getWriter().write("{\"error\":\"权限不足\"}");
                })
            );
        
        return http.build();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

3.6 登录接口

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    /**
     * 用户登录
     */
    @PostMapping("/login")
    public Result<LoginResponse> login(@RequestBody LoginRequest request) {
        try {
            // 1. 认证用户
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    request.getUsername(),
                    request.getPassword()
                )
            );
            
            // 2. 设置到 SecurityContext
            SecurityContextHolder.getContext().setAuthentication(authentication);
            
            // 3. 生成 Token
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            String accessToken = tokenProvider.generateToken(userDetails);
            String refreshToken = tokenProvider.generateRefreshToken(userDetails);
            
            // 4. 返回 Token
            LoginResponse response = new LoginResponse();
            response.setAccessToken(accessToken);
            response.setRefreshToken(refreshToken);
            response.setTokenType("Bearer");
            response.setExpiresIn(86400L);  // 24小时
            
            return Result.success(response);
            
        } catch (BadCredentialsException e) {
            return Result.error("用户名或密码错误");
        } catch (Exception e) {
            return Result.error("登录失败:" + e.getMessage());
        }
    }
    
    /**
     * 刷新 Token
     */
    @PostMapping("/refresh")
    public Result<LoginResponse> refresh(@RequestBody RefreshTokenRequest request) {
        try {
            String refreshToken = request.getRefreshToken();
            
            // 1. 从刷新 Token 中提取用户名
            String username = tokenProvider.getUsernameFromToken(refreshToken);
            
            // 2. 加载用户信息
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            
            // 3. 验证刷新 Token
            if (tokenProvider.validateToken(refreshToken, userDetails)) {
                // 4. 生成新的访问 Token
                String newAccessToken = tokenProvider.generateToken(userDetails);
                
                LoginResponse response = new LoginResponse();
                response.setAccessToken(newAccessToken);
                response.setRefreshToken(refreshToken);  // 刷新 Token 不变
                response.setTokenType("Bearer");
                response.setExpiresIn(86400L);
                
                return Result.success(response);
            } else {
                return Result.error("刷新 Token 无效");
            }
            
        } catch (Exception e) {
            return Result.error("刷新 Token 失败:" + e.getMessage());
        }
    }
    
    /**
     * 退出登录
     */
    @PostMapping("/logout")
    public Result<Void> logout() {
        // JWT 是无状态的,客户端删除 Token 即可
        // 如果需要立即失效,可以将 Token 加入黑名单
        SecurityContextHolder.clearContext();
        return Result.success("退出成功");
    }
}

// 请求对象
@Data
class LoginRequest {
    private String username;
    private String password;
}

@Data
class RefreshTokenRequest {
    private String refreshToken;
}

// 响应对象
@Data
class LoginResponse {
    private String accessToken;
    private String refreshToken;
    private String tokenType;
    private Long expiresIn;
}

四、Token 刷新机制

4.1 为什么需要刷新 Token?

问题:
├─ 访问 Token 过期时间短(如 1 小时)
├─ 用户频繁登录体验差
└─ 安全性和用户体验的平衡

解决方案:
├─ 访问 Token(Access Token):过期时间短(1小时)
└─ 刷新 Token(Refresh Token):过期时间长(7天)

流程:
访问 Token 过期 → 使用刷新 Token 获取新的访问 Token → 继续使用

4.2 双 Token 机制

┌─────────────────────────────────────────────────────────┐
│                  双 Token 机制                           │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  1. 用户登录                                             │
│     └─ 返回:Access Token(1小时) + Refresh Token(7天)│
│                                                          │
│  2. 正常请求                                             │
│     ├─ 携带 Access Token                                │
│     └─ Token 有效 → 正常响应                            │
│                                                          │
│  3. Access Token 过期                                   │
│     ├─ 请求返回 401                                      │
│     ├─ 前端自动调用刷新接口                              │
│     ├─ 携带 Refresh Token                               │
│     └─ 获取新的 Access Token                            │
│                                                          │
│  4. Refresh Token 也过期                                │
│     └─ 需要重新登录                                      │
│                                                          │
└─────────────────────────────────────────────────────────┘

4.3 前端自动刷新

// Axios 拦截器实现自动刷新
import axios from 'axios';

// 创建 axios 实例
const api = axios.create({
  baseURL: 'http://localhost:8080/api'
});

// 请求拦截器:添加 Token
api.interceptors.request.use(
  config => {
    const token = localStorage.getItem('accessToken');
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器:处理 Token 过期
api.interceptors.response.use(
  response => {
    return response;
  },
  async error => {
    const originalRequest = error.config;
    
    // 如果是 401 错误且没有重试过
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      
      try {
        // 调用刷新接口
        const refreshToken = localStorage.getItem('refreshToken');
        const response = await axios.post('/api/auth/refresh', {
          refreshToken: refreshToken
        });
        
        // 保存新的 Token
        const { accessToken } = response.data.data;
        localStorage.setItem('accessToken', accessToken);
        
        // 重新发送原请求
        originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
        return api(originalRequest);
        
      } catch (refreshError) {
        // 刷新失败,跳转到登录页
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    
    return Promise.reject(error);
  }
);

export default api;

五、Token 黑名单

5.1 为什么需要黑名单?

JWT 的问题:
├─ Token 一旦签发,无法撤销
├─ 即使用户退出登录,Token 在过期前仍然有效
└─ 存在安全风险

解决方案:
├─ 将已退出的 Token 加入黑名单
├─ 验证 Token 时检查黑名单
└─ 使用 Redis 存储黑名单(支持过期)

5.2 Redis 黑名单实现

@Service
public class TokenBlacklistService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    private static final String BLACKLIST_PREFIX = "token:blacklist:";
    
    /**
     * 将 Token 加入黑名单
     */
    public void addToBlacklist(String token) {
        // 获取 Token 的过期时间
        Date expiration = tokenProvider.getExpirationDateFromToken(token);
        long ttl = expiration.getTime() - System.currentTimeMillis();
        
        if (ttl > 0) {
            // 存入 Redis,过期时间与 Token 一致
            String key = BLACKLIST_PREFIX + token;
            redisTemplate.opsForValue().set(key, "1", ttl, TimeUnit.MILLISECONDS);
        }
    }
    
    /**
     * 检查 Token 是否在黑名单中
     */
    public boolean isBlacklisted(String token) {
        String key = BLACKLIST_PREFIX + token;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }
    
    /**
     * 从黑名单中移除 Token(一般不需要,会自动过期)
     */
    public void removeFromBlacklist(String token) {
        String key = BLACKLIST_PREFIX + token;
        redisTemplate.delete(key);
    }
}

// 修改 JWT 过滤器,检查黑名单
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private TokenBlacklistService blacklistService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                   HttpServletResponse response,
                                   FilterChain filterChain) 
            throws ServletException, IOException {
        
        try {
            String token = getTokenFromRequest(request);
            
            if (token != null) {
                // 检查黑名单
                if (blacklistService.isBlacklisted(token)) {
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.getWriter().write("{\"error\":\"Token 已失效\"}");
                    return;
                }
                
                // ... 其他验证逻辑
            }
        } catch (Exception e) {
            logger.error("认证失败:{}", e.getMessage());
        }
        
        filterChain.doFilter(request, response);
    }
}

// 修改退出登录接口
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
    // 获取 Token
    String token = getTokenFromRequest(request);
    
    if (token != null) {
        // 加入黑名单
        blacklistService.addToBlacklist(token);
    }
    
    SecurityContextHolder.clearContext();
    return Result.success("退出成功");
}

六、完整示例

6.1 前端登录

// 登录
async function login(username, password) {
  try {
    const response = await axios.post('/api/auth/login', {
      username,
      password
    });
    
    const { accessToken, refreshToken } = response.data.data;
    
    // 保存 Token
    localStorage.setItem('accessToken', accessToken);
    localStorage.setItem('refreshToken', refreshToken);
    
    // 跳转到首页
    window.location.href = '/home';
    
  } catch (error) {
    alert('登录失败:' + error.response.data.message);
  }
}

// 发送请求
async function getUserInfo() {
  try {
    const response = await api.get('/api/user/info');
    console.log('用户信息:', response.data);
  } catch (error) {
    console.error('获取用户信息失败:', error);
  }
}

// 退出登录
async function logout() {
  try {
    await api.post('/api/auth/logout');
    
    // 清除 Token
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
    
    // 跳转到登录页
    window.location.href = '/login';
    
  } catch (error) {
    console.error('退出失败:', error);
  }
}

6.2 测试接口

# 1. 登录获取 Token
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "admin",
    "password": "admin123"
  }'

# 响应:
# {
#   "code": 200,
#   "data": {
#     "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
#     "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
#     "tokenType": "Bearer",
#     "expiresIn": 86400
#   }
# }

# 2. 使用 Token 访问受保护接口
curl http://localhost:8080/api/user/info \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

# 3. 刷新 Token
curl -X POST http://localhost:8080/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{
    "refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
  }'

# 4. 退出登录
curl -X POST http://localhost:8080/api/auth/logout \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

七、常见问题

7.1 Token 被盗用怎么办?

风险:

Token 被盗取 → 攻击者可以冒充用户 → 访问用户资源

防护措施:

// 1. 使用 HTTPS
// 防止 Token 在传输过程中被窃取

// 2. 设置较短的过期时间
jwt.expiration=3600000  // 1小时

// 3. 绑定 IP 地址
public String generateToken(UserDetails userDetails, String ipAddress) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("ip", ipAddress);
    // ...
}

// 验证时检查 IP
public boolean validateToken(String token, String currentIp) {
    String tokenIp = getClaimFromToken(token, "ip");
    return tokenIp.equals(currentIp) && !isTokenExpired(token);
}

// 4. 使用刷新 Token 机制
// 访问 Token 过期时间短,刷新 Token 过期时间长

// 5. 实现 Token 黑名单
// 用户退出或修改密码时,将旧 Token 加入黑名单

7.2 Token 过期后如何处理?

方案1:前端自动刷新(推荐)

// 使用 Axios 拦截器自动刷新
api.interceptors.response.use(
  response => response,
  async error => {
    if (error.response.status === 401 && !error.config._retry) {
      error.config._retry = true;
      const newToken = await refreshToken();
      error.config.headers['Authorization'] = `Bearer ${newToken}`;
      return api(error.config);
    }
    return Promise.reject(error);
  }
);

方案2:后端自动续期

// 在过滤器中检查 Token 是否即将过期
if (isTokenExpiringSoon(token)) {
    // 生成新 Token
    String newToken = tokenProvider.generateToken(userDetails);
    // 在响应头中返回新 Token
    response.setHeader("New-Token", newToken);
}

7.3 如何立即撤销 Token?

问题: JWT 无法撤销,即使用户退出登录,Token 在过期前仍然有效

解决方案:

// 方案1:Token 黑名单(推荐)
// 将已退出的 Token 加入 Redis 黑名单
blacklistService.addToBlacklist(token);

// 方案2:版本号机制
// 在 Token 中加入版本号,用户修改密码时版本号+1
public String generateToken(UserDetails userDetails, Integer version) {
    claims.put("version", version);
    // ...
}

// 验证时检查版本号
public boolean validateToken(String token, User user) {
    Integer tokenVersion = getVersionFromToken(token);
    return tokenVersion.equals(user.getTokenVersion());
}

// 方案3:短过期时间 + 刷新 Token
// 访问 Token 过期时间设置为 15 分钟
// 用户退出时删除刷新 Token

7.4 Token 太长怎么办?

问题: JWT Token 包含用户信息和权限,可能很长

解决方案:

// 1. 减少 Payload 内容
// 只存储必要信息(用户ID),其他信息从数据库查询
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());  // 只存储 ID
// 不要存储:用户名、邮箱、角色、权限等

// 2. 使用压缩
public String generateToken(UserDetails userDetails) {
    return Jwts.builder()
        .setClaims(claims)
        .compressWith(CompressionCodecs.GZIP)  // 启用压缩
        .signWith(key)
        .compact();
}

// 3. 使用 Redis 存储 Token
// Token 只存储一个随机 ID,真实信息存在 Redis
String tokenId = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("token:" + tokenId, userInfo, 1, TimeUnit.HOURS);
return tokenId;

7.5 跨域问题

问题: 前后端分离,跨域请求无法携带 Token

解决方案:

@Configuration
public class CorsConfig {
    
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        
        // 允许的源
        config.addAllowedOrigin("http://localhost:3000");
        
        // 允许的请求头
        config.addAllowedHeader("*");
        
        // 允许的方法
        config.addAllowedMethod("*");
        
        // 允许携带凭证
        config.setAllowCredentials(true);
        
        // 暴露的响应头(前端可以访问)
        config.addExposedHeader("Authorization");
        config.addExposedHeader("New-Token");
        
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

八、JWT vs Session

8.1 对比表格

维度SessionJWT
存储位置服务器内存/Redis客户端
状态有状态无状态
扩展性需要 Session 共享天然支持分布式
性能需要查询存储无需查询,直接验证
安全性较高(服务器控制)较低(无法撤销)
跨域复杂(Cookie 限制)简单(Header 传递)
移动端不友好友好
撤销容易(删除 Session)困难(需要黑名单)
大小SessionID 很小Token 较大
适用场景传统 Web 应用前后端分离、微服务

8.2 选择建议

使用 Session:
├─ 传统 Web 应用
├─ 需要立即撤销会话
├─ 安全性要求高
└─ 单体应用

使用 JWT:
├─ 前后端分离
├─ 移动端 APP
├─ 微服务架构
├─ 单点登录(SSO)
└─ 第三方 API 认证

九、最佳实践

9.1 Token 设计

// ✓ 正确:只存储必要信息
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());

// ✗ 错误:存储敏感信息
claims.put("password", user.getPassword());  // 永远不要存储密码
claims.put("creditCard", user.getCreditCard());  // 不要存储敏感信息

// ✓ 正确:设置合理的过期时间
.setExpiration(new Date(now.getTime() + 3600000))  // 1小时

// ✗ 错误:过期时间太长
.setExpiration(new Date(now.getTime() + 31536000000L))  // 1年

9.2 密钥管理

# ✓ 正确:使用强密钥
jwt:
  secret: ${JWT_SECRET:myVeryLongAndSecureSecretKeyForJWTTokenGeneration123456789}
  # 从环境变量读取,至少 32 字符

# ✗ 错误:使用弱密钥
jwt:
  secret: 123456  # 太短,容易被破解

9.3 安全建议

1. 使用 HTTPS
   └─ 防止 Token 在传输过程中被窃取

2. 设置合理的过期时间
   ├─ 访问 Token:15分钟 - 1小时
   └─ 刷新 Token:7天 - 30天

3. 实现 Token 刷新机制
   └─ 避免用户频繁登录

4. 使用黑名单
   └─ 支持立即撤销 Token

5. 不要在 Token 中存储敏感信息
   └─ Token 可以被解码(Base64)

6. 验证 Token 的完整性
   └─ 检查签名、过期时间、发行者等

7. 限制 Token 的使用范围
   └─ 绑定 IP、设备等

十、实战案例

10.1 完整的认证流程

/**
 * 完整的 JWT 认证系统
 */
@Service
public class AuthService {
    
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private TokenBlacklistService blacklistService;
    
    /**
     * 用户登录
     */
    public LoginResponse login(LoginRequest request, String ipAddress) {
        // 1. 认证用户
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(),
                request.getPassword()
            )
        );
        
        // 2. 生成 Token
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String accessToken = tokenProvider.generateToken(userDetails, ipAddress);
        String refreshToken = tokenProvider.generateRefreshToken(userDetails);
        
        // 3. 记录登录日志
        logLoginActivity(request.getUsername(), ipAddress, true);
        
        // 4. 返回响应
        return new LoginResponse(accessToken, refreshToken, "Bearer", 3600L);
    }
    
    /**
     * 刷新 Token
     */
    public LoginResponse refreshToken(String refreshToken, String ipAddress) {
        // 1. 验证刷新 Token
        String username = tokenProvider.getUsernameFromToken(refreshToken);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        
        if (!tokenProvider.validateToken(refreshToken, userDetails)) {
            throw new RuntimeException("刷新 Token 无效");
        }
        
        // 2. 生成新的访问 Token
        String newAccessToken = tokenProvider.generateToken(userDetails, ipAddress);
        
        return new LoginResponse(newAccessToken, refreshToken, "Bearer", 3600L);
    }
    
    /**
     * 退出登录
     */
    public void logout(String token) {
        // 1. 加入黑名单
        blacklistService.addToBlacklist(token);
        
        // 2. 清除 SecurityContext
        SecurityContextHolder.clearContext();
        
        // 3. 记录退出日志
        String username = tokenProvider.getUsernameFromToken(token);
        logLoginActivity(username, null, false);
    }
    
    private void logLoginActivity(String username, String ipAddress, boolean isLogin) {
        // 记录登录/退出日志
        // ...
    }
}
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐