CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
JWT 与 API 鉴权 无状态 token、登录接口、受保护路由。
JWT 与 API 鉴权
这一课你将能做到
说清 JWT 的三段结构、签名校验原理,以及它和 Session 认证的本质区别
实现完整登录链路:登录接口签发 token → 自定义过滤器校验 token → 写入安全上下文
用 curl 走通「登录拿 token → 带 token 访问受保护接口 → 不带 token 得 401」全流程
为什么要学
上一课用 HTTP Basic/Session 做认证,有两个硬伤:服务端要存会话状态 (集群部署要 session 共享),跨端(App/小程序)不方便 。JWT(JSON Web Token)把登录态编码成一个自包含的字符串交给客户端,服务端只需验证签名——无状态、天然跨端,是前后端分离 API 的主流方案。本站前端的每次带鉴权请求,走的就是这套机制。
人话:Session 认证像「超市储物柜」——东西存在店里,你拿个号码牌;JWT 像「签名盖章的介绍信」——内容都在纸上,门卫只验章不查库 。验章的成本低,但介绍信发出去就没法作废,这是它的代价。
先建立图像
一个 JWT 长这样(两个点分隔三段):
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJhbGljZSIsImV4cCI6MTc4fQ . SflKxwRJSM==
头部 header 载荷 payload 签名 signature
算法:HS256 内容:用户名、过期时间 对前两段用密钥 HMAC 签名
三个关键认知:
Payload 只是 Base64 编码,不是加密 ——任何人都能解开看,所以绝不能放密码、手机号等敏感信息 ;
签名保证「没被改过」 :改一个字符,重算签名对不上(密钥只在服务端),伪造即失败;
服务端不存任何东西 :过期时间在 token 里,验证时比对当前时间即可。
对比 Session JWT 状态存储 服务端(内存/Redis) 客户端 token 自包含 集群部署 需 session 共享 任意节点可验签,天然水平扩展 登出/吊销 删 session 即刻生效 困难,需黑名单/短有效期补救 典型场景 传统单体 Web 前后端分离 API、微服务、多端
跟着做
步骤 1:引入 jjwt 与密钥配置
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
密钥与过期时间走配置(呼应第 3 课的敏感信息纪律):
app:
jwt:
# HS256 要求至少 32 字节;生产从环境变量注入,严禁提交仓库
secret: ${JWT_SECRET:dev-only-secret-key-change-me-32bytes!}
expire-minutes: 120
package com.example.taskflow.auth;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.jwt")
public record JwtProperties(String secret, long expireMinutes) { }
步骤 2:JwtService——签发与解析 package com.example.taskflow.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
@Service
public class JwtService {
private final SecretKey key;
private final long expireMinutes;
public JwtService(JwtProperties properties) {
this.key = Keys.hmacShaKeyFor(properties.secret().getBytes(StandardCharsets.UTF_8));
this.expireMinutes = properties.expireMinutes();
}
/** 登录成功后签发 token */
public String generate(String username, List<String> roles) {
Date now = new Date();
return Jwts.builder()
.subject(username) // 主体:用户名
.claim("roles", roles) // 自定义声明:角色
.issuedAt(now)
.expiration(new Date(now.getTime() + expireMinutes * 60_000))
.signWith(key) // HS256 签名
.compact();
}
/** 校验签名+过期,合法返回载荷,非法直接抛异常 */
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key) // 验签密钥
.build()
.parseSignedClaims(token) // 验签+验过期,失败抛 JwtException
.getPayload();
}
}
parse 是整个方案的信任基石:签名不对抛 SignatureException、过期抛 ExpiredJwtException——任何异常都意味着「不是我们签发的或已失效」,一律拒绝 。
步骤 3:JwtAuthFilter——每个请求的验票员 package com.example.taskflow.auth;
import io.jsonwebtoken.Claims;
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.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@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();
List<String> roles = claims.get("roles", List.class);
var authorities = roles == null ? List.<SimpleGrantedAuthority>of()
: roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
// 已认证的 token:密码位置放 null(此时不需要凭证)
var authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
// 无效/过期 token:不设置认证信息,后续授权裁决自然返回 401
}
}
chain.doFilter(request, response); // 无论成败都放行到下一个过滤器
}
}
设计要点:过滤器只负责「有合法 token 就恢复身份」,不在这里拦人或写错误响应 ——拦截决策统一交给 Security 的授权规则,职责分离。OncePerRequestFilter 保证一次请求只执行一次(转发/包含场景不会重复验)。
步骤 4:注册登录接口(含 BCrypt 校验) package com.example.taskflow.controller;
import com.example.taskflow.auth.JwtService;
import com.example.taskflow.common.ApiResponse;
import com.example.taskflow.exception.BusinessException;
import com.example.taskflow.exception.ErrorCode;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
public AuthController(JwtService jwtService, PasswordEncoder passwordEncoder) {
this.jwtService = jwtService;
this.passwordEncoder = passwordEncoder;
}
public record LoginRequest(String username, String password) { }
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
// 演示用内存账号;真实项目查用户表 + 状态检查 + 失败次数限制
boolean ok = "alice".equals(req.username())
&& passwordEncoder.matches(req.password(),
"$2a$10$" + passwordEncoder.encode("alice123").substring(7));
if (!ok) {
throw new BusinessException(ErrorCode.TASK_NOT_FOUND); // 实际项目定义 LOGIN_FAILED 错误码
}
String token = jwtService.generate("alice", List.of("USER"));
return ApiResponse.ok(Map.of("token", token, "tokenType", "Bearer"));
}
}
(演示环境密码校验从简;正式实现用上一课的 UserDetailsService 从库里取 BCrypt 串做 matches,勿在代码里现算哈希。)
步骤 5:SecurityConfig 整合——把过滤器插进链条 @Bean
public SecurityFilterChain jwtFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/hello").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // 关键:插在认证过滤器之前
return http.build();
}
执行顺序从此固定:JwtAuthFilter 先验票恢复身份 → 授权规则裁决 → 业务 Controller 。这就是「过滤器链」具象化的一刻。
步骤 6:curl 全流程验收 # ① 登录拿 token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"alice123"}' \
| sed 's/.*"token":"\([^"]*\)".*/\1/')
# ② 不带 token → 401
curl -i http://localhost:8080/api/tasks
# ③ 带上 token → 200,且 /api/me 能取到用户名
curl http://localhost:8080/api/tasks -H "Authorization: Bearer $TOKEN"
curl http://localhost:8080/api/me -H "Authorization: Bearer $TOKEN"
# ④ 篡改 token → 401(签名对不上)
curl -i http://localhost:8080/api/tasks -H "Authorization: Bearer ${TOKEN%?}X"
四步全通,你就拥有了一个生产形态的无状态认证链路——本站前端登录后的每个请求都是这个模式。
讲清楚要点
JWT = 编码的载荷 + 不可伪造的签名 :验签防篡改,但不防「看」。Payload 放 userId/username/roles 足矣,敏感信息要么不放、要么整体加密(JWE,少用)。
STATELESS 与过滤器位置是这套机制的两个锚点 :STATELESS 让服务端彻底不存会话;addFilterBefore(UsernamePasswordAuthenticationFilter.class) 决定验票发生在裁决之前。两处少一个,机制就残缺。
过期策略是安全与体验的平衡 :access token 短(30 分钟~2 小时),配合 refresh token(长有效期、单独端点、仅换新 access 用)。本课为演示给 2 小时;生产必须做 refresh 机制。
无状态的最大代价是「无法即刻作废」 :token 发出去,服务端删不掉。主流补救:①短有效期自然过期;②Redis 黑名单(登出的 token 记入,过滤器先查黑名单);③改密后按用户失效(token 带版本号)。「JWT 不能吊销」的面试题答案就是这三条。
密钥管理是生命线 :HS256 密钥泄露 = 任何人可伪造任意用户 token。生产走环境变量/KMS,定期轮换;HS256 换 RS256(非对称)则验签公钥可公开分发。
你可能会懵的地方
JWT 的 payload 是明文的吗?
是(Base64URL 编码可逆)。所以 JWT 防的是「篡改」不是「偷看」。放进去的字段当作「公开信息」设计;需要保密的内容引用 id、服务端再查库。
token 放哪?localStorage 还是 cookie?
Header + 内存/localStorage 是常见做法(注意 XSS 风险,配合 CSP 和输入过滤);HttpOnly + Secure + SameSite 的 cookie 能防 XSS 读取但要重新处理 CSRF。没有完美答案,取决于威胁模型——能说出权衡即可。
为什么登出不能直接删 token?
token 在客户端手里,服务端没有「它的账户」。要做「即刻登出」只能服务端记账:黑名单表/Redis 记 token 的 jti(唯一编号)直到其自然过期。这本质上重新引入了状态——工程上的常见妥协是「短有效期 + 客户端删除」。
HS256 和 RS256 怎么选?
HS256 对称密钥:签发验签同一密钥,内部单体服务够用。RS256 非对称:签发用私钥、验签用公钥,适合「认证中心签发、多个资源服务验签」的微服务场景——验签方拿不到签发权。
前端怎么处理 401?
统一拦截器(axios interceptor)捕获 401 → 尝试用 refresh token 换新 → 失败则清本地态跳登录页。注意并发请求同时 401 时只触发一次刷新(请求队列等待新 token)。
错法对照 错法 问题 正解 JWT secret 写死在代码/配置提交进 git 泄露即可伪造任意用户 环境变量/KMS 注入,可轮换 Payload 里放手机号、身份证 Base64 可逆,等于明文泄露 只放 id/角色等非敏感声明 过滤器里对无效 token 直接写 401 响应 剥夺了 Security 统一处理与白名单 不设置 Authentication,交授权裁决 token 有效期设 7 天 泄露后攻击窗口过长 短 access + refresh 机制 忘记 STATELESS,session 又被创建 集群 session 不一致 + CSRF 风险回归 sessionCreationPolicy(STATELESS)
卡点急救
带 token 仍然 401 :按序查——Authorization 头格式是不是 Bearer (有空格)+ token;token 是否过期;过滤器有没有注册(addFilterBefore);permitAll 白名单是否覆盖了登录接口本身。
WeakKeyException: The signing key's size is 128 bits :secret 不足 32 字节。HS256 要求 ≥ 256 位密钥,换更长的 secret。
ExpiredJwtException 频繁出现 :核对 expireMinutes 单位(分钟 × 60_000 毫秒);检查客户端与服务器时钟偏差(跨机器部署时 NTP 同步)。
SecurityContext 里拿到的用户是 null :请求根本没带 token(公开接口无认证正常);或过滤器把异常吞了没设置 Authentication——在 catch 里加日志确认 parse 失败原因。
面试常问
JWT 的结构与防篡改原理?
header.payload.signature;header 声明算法,payload 放声明(sub/exp/自定义),signature = HMAC(header+payload, 服务端密钥)。服务端收到后重算签名比对,不一致即被篡改。payload 仅编码非加密,不能放敏感信息。
JWT 和 Session 的取舍?
Session:状态在服务端,登出即时生效,集群需共享存储;JWT:无状态、水平扩展友好、跨端简单,但无法即刻吊销。组合方案:短 JWT + refresh token + Redis 黑名单/白名单,兼顾两者。
token 泄露了怎么办?如何设计续期与登出?
access token 短有效期限制伤害窗口;refresh token 单独存放(HttpOnly cookie)、旋转使用(每次刷新换新,旧的失效);关键操作要求重新认证;高危场景配合黑名单实现即刻失效。
为什么自定义过滤器要插在 UsernamePasswordAuthenticationFilter 之前?
鉴权裁决发生在链条后段的授权过滤器,验票(恢复 Authentication)必须先于裁决执行。插在 UsernamePasswordAuthenticationFilter 之前保证「先恢复身份、后判断权限」的顺序,同时不影响 permitAll 白名单路径。
自测三问
JWT 三段各放什么?为什么说它「防篡改不防偷看」?
JwtAuthFilter 里验签失败为什么不直接返回 401?谁来返回?
「JWT 无法即刻吊销」的工程补救方案有哪三条?
小结一句话 JWT 把登录态签名后交给客户端,服务端只验不存——过滤器验票、授权裁决、STATELESS 三件套拼出无状态认证链路,而「短有效期 + 刷新 + 黑名单」是它安全性的补丁。
延伸