Skip to content
清晨的一缕阳光
返回

Spring Boot JWT 认证实战

前言

JWT(JSON Web Token)是一种流行的无状态认证方式。本文将介绍 Spring Boot 集成 JWT 的完整实现,包括 Token 生成、验证、刷新和双 Token 机制。

JWT 基础

1. JWT 结构

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

2. 添加依赖

<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>

JWT 工具类

1. 创建 JwtService

package com.example.demo.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Service
@RequiredArgsConstructor
public class JwtService {
    
    @Value("${app.jwt.secret}")
    private String jwtSecret;
    
    @Value("${app.jwt.expiration}")
    private long jwtExpiration;
    
    @Value("${app.jwt.refresh-expiration}")
    private long refreshExpiration;
    
    /**
     * 从 Token 提取用户名
     */
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }
    
    /**
     * 提取所有声明
     */
    public Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSignInKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
    
    /**
     * 提取单个声明
     */
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }
    
    /**
     * 生成 Token
     */
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }
    
    /**
     * 生成带额外声明的 Token
     */
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        Instant now = Instant.now();
        return Jwts.builder()
            .claims(extraClaims)
            .subject(userDetails.getUsername())
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(jwtExpiration, ChronoUnit.MILLIS)))
            .signWith(getSignInKey())
            .compact();
    }
    
    /**
     * 生成刷新 Token
     */
    public String generateRefreshToken(UserDetails userDetails) {
        Instant now = Instant.now();
        return Jwts.builder()
            .subject(userDetails.getUsername())
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(refreshExpiration, ChronoUnit.MILLIS)))
            .signWith(getSignInKey())
            .compact();
    }
    
    /**
     * 验证 Token 是否有效
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }
    
    /**
     * 检查 Token 是否过期
     */
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
    
    /**
     * 提取过期时间
     */
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }
    
    /**
     * 获取签名密钥
     */
    private SecretKey getSignInKey() {
        byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

2. 配置文件

app:
  jwt:
    secret: your-256-bit-secret-key-here-change-in-production
    expiration: 3600000        # 1 小时
    refresh-expiration: 604800000  # 7 天

JWT 认证过滤器

1. 创建过滤器

package com.example.demo.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    
    @Override
    protected void doFilterInternal(
        @NonNull HttpServletRequest request,
        @NonNull HttpServletResponse response,
        @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;
        
        // 检查 Token
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        
        jwt = authHeader.substring(7);
        
        try {
            username = jwtService.extractUsername(jwt);
            
            // 验证 Token 并设置认证
            if (username != null && 
                SecurityContextHolder.getContext().getAuthentication() == null) {
                
                UserDetails userDetails = 
                    userDetailsService.loadUserByUsername(username);
                
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken = 
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );
                    
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                    );
                    
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (JwtException e) {
            // Token 无效,继续过滤
            logger.warn("JWT 无效:" + e.getMessage());
        }
        
        filterChain.doFilter(request, response);
    }
}

2. 配置安全过滤器链

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final JwtAuthenticationFilter jwtAuthFilter;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
                .accessDeniedHandler(new JwtAccessDeniedHandler())
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

认证接口

1. 认证请求/响应

@Data
public class AuthRequest {
    
    @NotBlank(message = "用户名不能为空")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    private String password;
}

@Data
@AllArgsConstructor
public class AuthResponse {
    
    private String accessToken;
    private String refreshToken;
    private String tokenType = "Bearer";
    private long expiresIn;
}

@Data
@AllArgsConstructor
public class RefreshTokenRequest {
    
    @NotBlank(message = "刷新 Token 不能为空")
    private String refreshToken;
}

2. 认证控制器

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
    
    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    
    /**
     * 登录
     */
    @PostMapping("/login")
    public R<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
        // 认证
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(),
                request.getPassword()
            )
        );
        
        // 加载用户
        UserDetails userDetails = 
            userDetailsService.loadUserByUsername(request.getUsername());
        
        // 生成 Token
        String accessToken = jwtService.generateToken(userDetails);
        String refreshToken = jwtService.generateRefreshToken(userDetails);
        
        return R.ok(new AuthResponse(
            accessToken,
            refreshToken,
            "Bearer",
            3600
        ));
    }
    
    /**
     * 刷新 Token
     */
    @PostMapping("/refresh")
    public R<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
        String refreshToken = request.getRefreshToken();
        
        try {
            String username = jwtService.extractUsername(refreshToken);
            
            if (username != null) {
                UserDetails userDetails = 
                    userDetailsService.loadUserByUsername(username);
                
                if (jwtService.isTokenValid(refreshToken, userDetails)) {
                    String accessToken = jwtService.generateToken(userDetails);
                    
                    return R.ok(new AuthResponse(
                        accessToken,
                        refreshToken,
                        "Bearer",
                        3600
                    ));
                }
            }
            
            return R.error(401, "刷新 Token 无效");
        } catch (JwtException e) {
            return R.error(401, "刷新 Token 已过期");
        }
    }
    
    /**
     * 登出
     */
    @PostMapping("/logout")
    public R<Void> logout() {
        // 无状态认证,客户端删除 Token 即可
        return R.ok();
    }
}

3. 异常处理

public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    @Override
    public void commence(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException authException
    ) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":401,\"message\":\"未授权访问\"}");
    }
}

public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    
    @Override
    public void handle(
        HttpServletRequest request,
        HttpServletResponse response,
        AccessDeniedException accessDeniedException
    ) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":403,\"message\":\"无权访问\"}");
    }
}

双 Token 机制

1. Token 刷新策略

@Service
@RequiredArgsConstructor
public class TokenRefreshService {
    
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    
    /**
     * 刷新 Access Token
     */
    public String refreshAccessToken(String refreshToken) {
        try {
            String username = jwtService.extractUsername(refreshToken);
            
            if (username == null) {
                throw new BusinessException("TOKEN_INVALID", "刷新 Token 无效");
            }
            
            UserDetails userDetails = 
                userDetailsService.loadUserByUsername(username);
            
            if (!jwtService.isTokenValid(refreshToken, userDetails)) {
                throw new BusinessException("TOKEN_EXPIRED", "刷新 Token 已过期");
            }
            
            return jwtService.generateToken(userDetails);
        } catch (JwtException e) {
            throw new BusinessException("TOKEN_INVALID", "刷新 Token 无效");
        }
    }
}

2. Token 黑名单

@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 将 Token 加入黑名单
     */
    public void blacklist(String token, long expireTime) {
        redisTemplate.opsForValue().set(
            "token:blacklist:" + token,
            "blacklisted",
            expireTime,
            TimeUnit.MILLISECONDS
        );
    }
    
    /**
     * 检查 Token 是否在黑名单中
     */
    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(
            redisTemplate.hasKey("token:blacklist:" + token)
        );
    }
}

3. 登出时加入黑名单

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
    
    private final TokenBlacklistService blacklistService;
    private final JwtService jwtService;
    
    @PostMapping("/logout")
    public R<Void> logout(
        @RequestHeader("Authorization") String authorization
    ) {
        if (authorization != null && authorization.startsWith("Bearer ")) {
            String token = authorization.substring(7);
            Date expiration = jwtService.extractClaim(token, Claims::getExpiration);
            long expireTime = expiration.getTime() - System.currentTimeMillis();
            
            if (expireTime > 0) {
                blacklistService.blacklist(token, expireTime);
            }
        }
        
        return R.ok();
    }
}

最佳实践

1. 安全配置

app:
  jwt:
    # 使用环境变量
    secret: ${JWT_SECRET:your-256-bit-secret-key}
    expiration: 3600000        # 1 小时
    refresh-expiration: 604800000  # 7 天
    issuer: demo-api
    audience: demo-client

2. Token 存储

// 前端存储
localStorage.setItem('refreshToken', refreshToken); // 长期存储
sessionStorage.setItem('accessToken', accessToken); // 会话存储

3. 自动刷新

// 前端自动刷新
let refreshTimer: NodeJS.Timeout;

function startRefreshTimer() {
    refreshTimer = setTimeout(async () => {
        const refreshToken = localStorage.getItem('refreshToken');
        const response = await fetch('/api/auth/refresh', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ refreshToken })
        });
        
        const data = await response.json();
        sessionStorage.setItem('accessToken', data.accessToken);
        startRefreshTimer();
    }, 50 * 60 * 1000); // 50 分钟刷新
}

4. 拦截器

// Axios 拦截器
axios.interceptors.request.use(config => {
    const token = sessionStorage.getItem('accessToken');
    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
});

axios.interceptors.response.use(
    response => response,
    async error => {
        if (error.response?.status === 401) {
            // Token 过期,尝试刷新
            const refreshToken = localStorage.getItem('refreshToken');
            if (refreshToken) {
                const response = await axios.post('/api/auth/refresh', {
                    refreshToken
                });
                
                sessionStorage.setItem('accessToken', response.data.accessToken);
                
                // 重试原请求
                const originalConfig = error.config;
                originalConfig.headers.Authorization = 
                    `Bearer ${response.data.accessToken}`;
                return axios(originalConfig);
            }
        }
        return Promise.reject(error);
    }
);

总结

JWT 认证要点:

JWT 是无状态认证的理想选择。


分享这篇文章到:

上一篇文章
微服务日志规范
下一篇文章
MySQL 子查询与临时表优化