CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/JWT 与 API 鉴权
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

JWT 与 API 鉴权

无状态 token、登录接口、受保护路由。

35 分钟

JWT 与 API 鉴权

这一课你将能做到

  1. 说清 JWT 的三段结构、签名校验原理,以及它和 Session 认证的本质区别
  2. 实现完整登录链路:登录接口签发 token → 自定义过滤器校验 token → 写入安全上下文
  3. 用 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 签名

三个关键认知:

  1. Payload 只是 Base64 编码,不是加密——任何人都能解开看,所以绝不能放密码、手机号等敏感信息;
  2. 签名保证「没被改过」:改一个字符,重算签名对不上(密钥只在服务端),伪造即失败;
  3. 服务端不存任何东西:过期时间在 token 里,验证时比对当前时间即可。
对比SessionJWT
状态存储服务端(内存/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"

四步全通,你就拥有了一个生产形态的无状态认证链路——本站前端登录后的每个请求都是这个模式。

讲清楚要点

  1. JWT = 编码的载荷 + 不可伪造的签名:验签防篡改,但不防「看」。Payload 放 userId/username/roles 足矣,敏感信息要么不放、要么整体加密(JWE,少用)。
  2. STATELESS 与过滤器位置是这套机制的两个锚点:STATELESS 让服务端彻底不存会话;addFilterBefore(UsernamePasswordAuthenticationFilter.class) 决定验票发生在裁决之前。两处少一个,机制就残缺。
  3. 过期策略是安全与体验的平衡:access token 短(30 分钟~2 小时),配合 refresh token(长有效期、单独端点、仅换新 access 用)。本课为演示给 2 小时;生产必须做 refresh 机制。
  4. 无状态的最大代价是「无法即刻作废」:token 发出去,服务端删不掉。主流补救:①短有效期自然过期;②Redis 黑名单(登出的 token 记入,过滤器先查黑名单);③改密后按用户失效(token 带版本号)。「JWT 不能吊销」的面试题答案就是这三条。
  5. 密钥管理是生命线: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)

卡点急救

  1. 带 token 仍然 401:按序查——Authorization 头格式是不是 Bearer (有空格)+ token;token 是否过期;过滤器有没有注册(addFilterBefore);permitAll 白名单是否覆盖了登录接口本身。
  2. WeakKeyException: The signing key's size is 128 bits:secret 不足 32 字节。HS256 要求 ≥ 256 位密钥,换更长的 secret。
  3. ExpiredJwtException 频繁出现:核对 expireMinutes 单位(分钟 × 60_000 毫秒);检查客户端与服务器时钟偏差(跨机器部署时 NTP 同步)。
  4. 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 白名单路径。

自测三问

  1. JWT 三段各放什么?为什么说它「防篡改不防偷看」?
  2. JwtAuthFilter 里验签失败为什么不直接返回 401?谁来返回?
  3. 「JWT 无法即刻吊销」的工程补救方案有哪三条?

小结一句话

JWT 把登录态签名后交给客户端,服务端只验不存——过滤器验票、授权裁决、STATELESS 三件套拼出无状态认证链路,而「短有效期 + 刷新 + 黑名单」是它安全性的补丁。

延伸

  • jjwt 官方 README
  • RFC 7519:JSON Web Token 规范
  • 下一课:单元测试与 MockMvc——给这套链路装上「自动验收」。
加载本节练习…
← 上一课Spring Security 基础下一课 →单元测试与 MockMvc