第 15 章 · JWT、限流与 OAuth2 概念
本章目标:在 shop-spring-demo 使用 jjwt 实现无状态 JWT 登录与鉴权;配置 Spring Security 的 JwtAuthenticationFilter;对比 Session 与 Token 方案;接入 Bucket4j 或 Resilience4j 实现 API 限流;理解 OAuth2 / OIDC 授权码流程与资源服务器角色(概念级,不连真实 IdP);与 ch14 Swagger Authorize 联调。
学时建议:5~6 小时(含 2 小时 JWT + 限流跟练)
前置:ch06 Spring Security;ch13 LoginRequest / TokenVO;ch14 Bearer 文档配置。
15.1 Session vs JWT
| 对比项 | Session(ch06) | JWT(本章) |
|---|---|---|
| 状态 | 服务端存 Session | 无状态,Claims 在 Token 内 |
| 扩展 | 需 Session 共享(Redis) | 多实例只需校验签名 |
| 注销 | 服务端销毁 Session | 需黑名单或短过期 + Refresh |
| 体积 | Cookie 小 | Header 较大 |
| 适用 | 传统 SSR、同源 | SPA、移动端、跨域 API |
登录 POST /api/auth/login
│
▼
校验用户名密码(BCrypt)
│
▼
签发 JWT(sub, roles, exp)
│
▼
客户端存 localStorage / 内存
│
▼
后续请求 Header: Authorization: Bearer <jwt>
│
▼
JwtAuthenticationFilter 解析 → SecurityContext
教学密钥使用application-dev.yml占位符;严禁提交真实JWT_SECRET或公司 IdP 客户端密钥。
15.2 引入 jjwt 依赖
pom.xml:
<properties>
<jjwt.version>0.12.5</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
</dependencies>
配置类 JwtProperties:
shop:
jwt:
secret: ${JWT_SECRET:dev-only-change-me-in-production-32chars!!}
issuer: shop-spring-demo
access-token-ttl: PT2H # ISO-8601 Duration
15.3 JwtService 签发与解析
package com.example.shop.security.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.List;
@Service
public class JwtService {
private final JwtProperties props;
private final SecretKey key;
public JwtService(JwtProperties props) {
this.props = props;
this.key = Keys.hmacShaKeyFor(props.getSecret().getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(String username, List<String> roles) {
Instant now = Instant.now();
Instant exp = now.plus(props.getAccessTokenTtl());
return Jwts.builder()
.issuer(props.getIssuer())
.subject(username)
.claim("roles", roles)
.issuedAt(Date.from(now))
.expiration(Date.from(exp))
.signWith(key)
.compact();
}
public Jws<Claims> parse(String token) {
return Jwts.parser()
.verifyWith(key)
.requireIssuer(props.getIssuer())
.build()
.parseSignedClaims(token);
}
public String extractUsername(String token) {
return parse(token).getPayload().getSubject();
}
@SuppressWarnings("unchecked")
public List<String> extractRoles(String token) {
return parse(token).getPayload().get("roles", List.class);
}
}
| Claim | 含义 |
|---|---|
sub | 用户名 |
roles | 角色列表 ROLE_USER, ROLE_ADMIN |
exp | 过期时间 |
iss | 签发者,防 Token 混用 |
15.4 AuthController 登录
@RestController
@RequestMapping("/api/auth")
@Tag(name = "认证")
public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
@PostMapping("/login")
@Operation(summary = "用户名密码登录,返回 JWT")
public Result<TokenVO> login(@Valid @RequestBody LoginRequest req) {
User user = authService.authenticate(req.getUsername(), req.getPassword());
String token = jwtService.generateAccessToken(
user.getUsername(),
user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
TokenVO vo = new TokenVO();
vo.setAccessToken(token);
vo.setExpiresIn(jwtService.getAccessTokenTtlSeconds());
return Result.ok(vo);
}
}
ch13 的 TokenVO 直接复用;注销可返回 204(客户端删 Token)或维护 Redis 黑名单(选修)。
15.5 JwtAuthenticationFilter
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String header = req.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(req, res);
return;
}
String token = header.substring(7);
try {
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (JwtException e) {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
res.getWriter().write("{\"code\":40101,\"message\":\"无效或过期的 Token\"}");
return;
}
chain.doFilter(req, res);
}
}
Security 配置:
@Bean
public SecurityFilterChain apiSecurity(HttpSecurity http,
JwtAuthenticationFilter jwtFilter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
15.6 Refresh Token(概念与选修)
| Token 类型 | 有效期 | 存储 | 用途 |
|---|---|---|---|
| Access Token | 短(15min~2h) | 内存 | 调 API |
| Refresh Token | 长(7~30d) | HttpOnly Cookie 或安全存储 | 换新的 Access |
选修实现:POST /api/auth/refresh 校验 Refresh Token 后签发新 Access,不在本章必做。