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 对比表格

维度 Session JWT
存储位置 服务器内存/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编程工具,助力开发者即刻编程。

更多推荐