Spring Boot 使用 JWT 的最佳实践
本文系统介绍在 Spring Boot 中使用 JWT 的概念、优势、生成与验证、刷新与撤销、工程化配置与安全要点,并配套示例代码与建议参数。遵循偏安全的默认策略与可维护的架构模式。
概念
- JWT 由三部分组成:Header、Payload、Signature,经 Base64URL 编码后以
header.payload.signature形式传输。 - Header 包含签名算法与类型;Payload 包含声明(如
sub、iss、aud、exp、iat、nbf、jti);Signature 用密钥或私钥对前两部分签名。 - JWT 通常作为无状态访问令牌,放在
Authorization: Bearer <token>头中传输。
优势与适用
- 无状态与水平扩展,减少服务端会话存储压力。
- 与前后端分离、移动端、微服务网关等场景天然适配。
- 通过标准声明与自定义声明承载最小必要身份信息。
限制与注意:
- 无内置撤销机制,需要配合短有效期、刷新令牌、黑名单、令牌轮换等手段。
- 令牌较长,不适合频繁通过 URL 传递。
令牌模型
- 访问令牌(Access Token):有效期短,如 15 分钟。
- 刷新令牌(Refresh Token):有效期长,如 7 天,仅用于换取新的访问令牌。
- 建议使用 HS256 或 RS256。生产环境优先 RS256 并实施密钥轮换。
工程化配置
# application.yml
security:
jwt:
issuer: example-issuer
access-exp-minutes: 15
refresh-exp-days: 7
hs256-secret: ${JWT_SECRET}
// JwtProperties.java
package com.example.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "security.jwt")
public class JwtProperties {
private String issuer;
private Integer accessExpMinutes;
private Integer refreshExpDays;
private String hs256Secret;
}
生成与验证
服务类
// JwtService.java
package com.example.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.Claims;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtProperties props;
private Key key() {
byte[] bytes = props.getHs256Secret().getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(bytes);
}
public String generateAccessToken(String subject, Map<String, Object> claims) {
Instant now = Instant.now();
Instant exp = now.plus(props.getAccessExpMinutes(), ChronoUnit.MINUTES);
return Jwts.builder()
.setIssuer(props.getIssuer())
.setSubject(subject)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(exp))
.addClaims(claims)
.signWith(key(), SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken(String subject) {
Instant now = Instant.now();
Instant exp = now.plus(props.getRefreshExpDays(), ChronoUnit.DAYS);
return Jwts.builder()
.setIssuer(props.getIssuer())
.setSubject(subject)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(exp))
.claim("typ", "refresh")
.signWith(key(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parse(String token) {
return Jwts.parserBuilder()
.requireIssuer(props.getIssuer())
.setSigningKey(key())
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean isAccessToken(String token) {
Claims c = parse(token);
Object typ = c.get("typ");
return typ == null || "access".equals(typ);
}
}
过滤器
// JwtAuthenticationFilter.java
package com.example.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.Claims;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtService.parse(token);
String username = claims.getSubject();
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception ignored) {}
}
chain.doFilter(request, response);
}
}
安全配置
// SecurityConfig.java
package com.example.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import lombok.RequiredArgsConstructor;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtService jwtService;
private final org.springframework.security.core.userdetails.UserDetailsService uds;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(jwtService, uds);
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
登录与刷新
// AuthController.java
package com.example.auth;
import com.example.security.JwtService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager am;
private final JwtService jwt;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginReq req) {
Authentication auth = am.authenticate(new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()));
String access = jwt.generateAccessToken(req.getUsername(), Map.of("roles", "user"));
String refresh = jwt.generateRefreshToken(req.getUsername());
return ResponseEntity.ok(Map.of("accessToken", access, "refreshToken", refresh));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestBody TokenReq req) {
String subject = jwt.parse(req.getRefreshToken()).getSubject();
String access = jwt.generateAccessToken(subject, Map.of("roles", "user"));
return ResponseEntity.ok(Map.of("accessToken", access));
}
@Data
public static class LoginReq { private String username; private String password; }
@Data
public static class TokenReq { private String refreshToken; }
}
验证要点与安全策略
- 验证签名、发行者、受众、过期时间、未生效时间、
jti等关键声明。 - 使用短期访问令牌与长效刷新令牌,搭配令牌轮换。
- 刷新令牌建议存放在 HttpOnly、Secure、SameSite 的 Cookie;访问令牌放在请求头。
- 服务器端维护刷新令牌的
jti或哈希值,支持撤销与黑名单。 - 控制声明最小化,只放必要信息;避免敏感数据出现在负载中。
- 限制跨域与 CSRF 风险,接口启用 CORS 严格白名单与方法限定。
- 生产环境优先使用 RS256,并规划密钥轮换与 JWKS 发布。
- 记录失败尝试与异常,配合限流与风控。
常见问题与建议参数
exp与服务器时钟差建议引入微小容忍窗口。- 秘钥管理通过环境变量或外部配置中心,避免硬编码。
- Access Token 15 分钟,Refresh Token 7–30 天,根据安全等级调整。
- 在网关或 API 层统一验证与鉴权,减少重复逻辑。
总结
- 以无状态访问令牌 + 刷新令牌的双令牌模型为基础,配合短期有效、声明最小化与统一验证策略。
- 在 Spring Security 中通过自定义过滤器与配置链实现无状态鉴权,与登录与刷新接口协同。
- 强化密钥与令牌管理、RS256 与轮换、黑名单与 Cookie 策略以提升整体安全性与可维护性。
💬 评论