本文是紧接着上篇demo继续往下的第二阶段技术复盘,涵盖 JWT 令牌机制、Spring Security 6认证授权、双角色用户体系、方法级权限控制、异常分层处理等实战要点。全文约 8000 字,每个知识点都从【为什么 → 怎么做 → 原理是什么 → 踩了什么坑】四个维度展开,适合正在学习 Spring Security 的同学系统阅读。
一、前言:从阶段 0 到阶段 1
上一篇文章 我们完成了后端地基------CRUD、分页搜索、Knife4j 文档、CORS 跨域。但当时的系统有一个严重问题:所有接口裸奔,任何人都能调。
这就好比一栋写字楼没有门禁------谁来都能进,想看什么数据看什么数据。这在实际项目中绝对不行。
阶段 1 的目标就是给系统装上【门禁系统】:
-
双角色认证:管理员(ADMIN)+ 家属(FAMILY),两套用户表,同一登录入口
-
JWT 令牌:AccessToken(2h)+ RefreshToken(7d)双卡策略
-
方法级权限 :
@PreAuthorize按角色控制接口访问 -
异常分层处理:Filter 层 401/403 + Controller 层统一捕获
技术栈:Spring Boot 3.5 + Spring Security 6 + jjwt 0.12.6 + MyBatis-Plus + Knife4j
二、整体架构:一个请求的完整旅行
在深入每个组件之前,先用一张图建立全局认知:
浏览器发请求
│
▼
┌──────────────────────────────────────┐
│ ① JwtAuthenticationFilter │
│ 从 Header 取 Token → 验签 → 注入身份│
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ ② SecurityConfig │
│ 白名单放行 / 其余拦截 │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ ③ @PreAuthorize │
│ 方法级角色校验 │
└──────────────┬───────────────────────┘
▼
Controller → Service → DB
│
▼
┌──────────────────────────────────────┐
│ ④ GlobalExceptionHandler │
│ 统一返回 Result JSON │
└──────────────────────────────────────┘
登录流程:AuthController → AuthenticationManager → UserDetailsServiceImpl(查库)→ JwtTokenProvider(制卡)
后续请求:JwtAuthenticationFilter(验卡)→ SecurityContext(存身份)→ SecurityConfig + @PreAuthorize(权限判断)
整个认证体系由 5 个角色配合工作:
| 角色 | 对应代码 | 职责 |
|---|---|---|
| 🏠 门禁卡工厂 | JwtTokenProvider |
制卡、验卡、读卡信息 |
| 📋 员工档案库 | UserDetailsServiceImpl + CustomUserDetails |
从数据库查用户 |
| 🛂 门禁保安 | JwtAuthenticationFilter |
每次请求检查门禁卡 |
| 🏢 大楼安保总监 | SecurityConfig |
制定安保规则 |
| 👩 前台接待 | AuthController |
登录接待,发卡 |
下面逐个展开。
三、JWT 令牌:门禁卡是怎么造出来的
3.1 JWT 三段式结构
JWT 分三段,用 . 连接:xxxxx.yyyyy.zzzzz
eyJhbGciOiJIUzM4NCJ9 ← Header:签名算法
.
eyJzdWIiOiJhZG1pbiJ9 ← Payload:用户数据
.
signed-hash-here ← Signature:防伪签名
身份证类比(非常形象):
-
Header:身份证的版式说明("这是第二代身份证")
-
Payload:身份证上的姓名、性别、住址
-
Signature:公安局的钢印------防止伪造
⚠️ 关键认知 :Payload 只是 Base64 编码 ,不是加密 !任何人拿到 Token 都能解码看到内容。所以永远不要把密码、身份证号等敏感信息放进 JWT。
3.2 签名如何防篡改
java
Signature = HMAC-SHA384(
Base64(Header) + "." + Base64(Payload),
secretKey
)
你把 Payload 里role从 FAMILY 改成 ADMIN → 签名对不上了 → 服务器验签拒绝。
就像你拿笔改了身份证上的年龄,钢印对不上,一眼假。
3.3 JwtTokenProvider 核心代码
这个类封装了 6 个方法:
| 方法 | 作用 |
|---|---|
generateAccessToken(username, role) |
生成 2h 访问令牌 |
generateRefreshToken(username, role) |
生成 7d 刷新令牌 |
validateToken(token) |
判断 Token 是否有效 |
getUsernameFromToken(token) |
读用户名 |
getRoleFromToken(token) |
读角色 |
parseToken(token) |
解析并验签(私有) |
密钥初始化:
java
@Value("${jwt.secret}")
private String secret; // yml 里那串 Base64 编码的密钥
// Bean初始化时执行
@PostConstruct
private void init() {
byte[] keyBytes = Base64.getDecoder().decode(secret); //解码
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
}
@PostConstruct的意思是:Spring 容器启动后、Bean 可用之前,先把密钥准备好------就像工厂开门前先把防伪印章刻好。
3.4 jjwt 0.12 新旧 API 对比(重点!)
jjwt 从 0.11.x 升级到 0.12.x 是一次大改。Spring Boot 3.x + JDK 21 必须用 0.12.x,API 变化很大:
| 操作 | 0.11.x | 0.12.x |
|---|---|---|
| 构建解析器 | Jwts.parserBuilder() |
Jwts.parser() |
| 设置验签密钥 | .setSigningKey(key) |
.verifyWith(key) |
| 解析 Token | .parseClaimsJws(token) |
.parseSignedClaims(token) |
| 取载荷 | .getBody() |
.getPayload() |
记忆技巧:
-
parserBuilder→parser:Builder 没了,直接 parser -
setSigningKey→verifyWith:语义更强------"我要用这个密钥来校验" -
getBody→getPayload:JWT 术语叫 Payload,不叫 Body
这是我们项目中实际使用的解析方法:
java
private Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(this.secretKey) // 用密钥校验签名
.build()
.parseSignedClaims(token) // 解析已签名的 Token
.getPayload(); // 取出载荷
}
四、多表用户加载:为什么 Admin 和 Family 能共用一套登录
4.1 问题
Admin 存在 admin 表,Family 存在 family 表------两张不同的表,但登录入口只有一个 /api/auth/login。Spring Security 怎么知道去哪个表查?
4.2 解决方案:一个 Service,查两张表
java
UserDetailsServiceImpl.loadUserByUsername("admin")
│
├── 1. 先查 admin 表
│ └── 找到了 → 返回 CustomUserDetails(username, bcrypt密码, "ADMIN")
│
├── 2. 没找到?再查 family 表
│ └── 找到了 → 返回 CustomUserDetails(username, bcrypt密码, "FAMILY")
│
└── 3. 都找不到 → 抛 UsernameNotFoundException
为什么要先查 admin? admin 表数据量极小(通常就几条),先查它能快速命中,少跑一次 family 表的查询。
4.3 CustomUserDetails:适配器模式
这里用了一个经典设计模式------适配器模式(Adapter Pattern)。
Spring Security 只认 UserDetails 接口,我们的 Admin 和 Family 实体都不实现这个接口。所以需要一个「转换插头」把我们的实体包装成 Spring Security 认识的格式:
java
public class CustomUserDetails implements UserDetails {
private String username;
private String password;
private String role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
}
}
⚠️ 必须加 ROLE_ 前缀! 这是 Spring Security 的硬性规定。hasRole('ADMIN') 内部自动拼成 ROLE_ADMIN 去比对,如果你的 authority 是裸的 "ADMIN",永远匹配不上。
就像国内手机号必须加 +86 才能被国际系统识别------Spring Security 只认带 ROLE_ 前缀的角色名。
五、JwtAuthenticationFilter:最难但最重要的一环
5.1 继承关系
java
public class JwtAuthenticationFilter extends OncePerRequestFilter
OncePerRequestFilter 保证每个请求这个 Filter 只执行一次。因为一个请求可能在服务器内部被转发多次(比如 forward 到错误页面),不加这个保证,Filter 可能被执行多次。
5.2 核心逻辑流程图
java
请求进来了
│
▼
┌──────────────────────┐
│ 1.从 Header 拿 Token │ "Authorization: Bearer xxxxx"
└──────┬───────────────┘
│
▼
┌──────────────────────┐
│ 2.没有 Token? │──是──→ 放行(让 SecurityConfig 拦截)
└──────┬───────────────┘
│ 否
▼
┌──────────────────────┐
│ 3.Token 有效? │──否──→ 放行(让 SecurityConfig 拦截)
└──────┬───────────────┘
│ 是
▼
┌──────────────────────────────┐
│ 4. 提取 username + role │
│ 构造 Authentication 对象 │
│ 写入 SecurityContext │ ← 灵魂一行!
└──────┬───────────────────────┘
│
▼
放行(此时用户已"认证通过")
5.3 SecurityContext 到底是啥
一句话:SecurityContext 是容器,Authentication 是内容。
java
每次请求 = 一个人走进写字楼
SecurityContext = 这个人胸前的访客牌
JwtAuthenticationFilter 的工作:
1. 检查他有没有带门禁卡(Authorization Header)
2. 刷卡验证(validateToken)
3. 验证通过 → 给他挂上访客牌(写入 SecurityContext)
后续的保安(@PreAuthorize)看到他胸前的访客牌,就知道他是谁、什么权限。
代码上最关键的就这一行:
java
SecurityContextHolder.getContext().setAuthentication(authentication);
这一行执行后,Spring Security 就认为【当前请求的用户已登录】。
5.4 一个经典困惑:为什么验证失败要「放行」?
java
if (!jwtTokenProvider.validateToken(token)) {
filterChain.doFilter(request, response); // ← 为什么不直接返回 401?
return;
}
原因:职责分离。
-
Filter 的职责是尝试识别用户 ,不是拒绝未登录用户
-
拒绝的活交给
SecurityConfig的.anyRequest().authenticated()
具体流程:
-
Filter 验不过 → SecurityContext 里没身份 → SecurityConfig 检查 → 返回 401
-
Filter 验过了 → SecurityContext 里有身份 → SecurityConfig 检查 → 放行
Filter 只管「能不能认出你是谁」,SecurityConfig 管「你能不能进这个门」。
六、SecurityConfig:安保总监的规则手册
6.1 完整配置
java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 勿忘这个!否则 @PreAuthorize 不生效
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 关闭 CSRF(前后端分离 + JWT,不存在 CSRF 攻击面)
.csrf(csrf -> csrf.disable())
// 2. 关闭 Session(JWT 无状态,不需要服务器记 Session)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 3. URL 权限规则
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/doc.html", "/webjars/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
// 4. 把 JWT Filter 插到 UsernamePasswordAuthenticationFilter 前面
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}
6.2 逐条解释
① 关闭 Session(STATELESS)
| Session 模式 | JWT 模式(STATELESS) | |
|---|---|---|
| 状态存哪 | 服务器内存 | 客户端(浏览器/App) |
| 工作方式 | 发 sessionId,服务器拿它查数据 | 用户信息直接在 Token 里 |
| 分布式 | 需要 Redis 共享 Session | 天然支持,任何服务器拿密钥就能验 |
| 类比 | 存包处(服务员帮你保管) | 身份证(信息在身上) |
设置 STATELESS 告诉 Spring:"别创建 HttpSession,我没用"。
② URL 白名单
/api/auth/login 必须放行------否则用户连登录都登不了,死循环。Knife4j 的文档页面也要放行,但这里有个坑:放行 /doc.html 不代表它引用的 CSS/JS 也放行 ,必须同时放行 /webjars/** 和 /v3/api-docs/**。
③ Filter 插入顺序
addFilterBefore(A, B) 把 A 插在 B 前面。我们的 JWT Filter 插在 UsernamePasswordAuthenticationFilter 前面,效果是:请求先过 JWT 校验,Token 有效则直接标记「已登录」,不再走表单登录流程。
6.3 Lambda DSL 是什么
Spring Security 5.7+ 推荐使用 Lambda DSL 配置风格。对比旧版:
java
// 旧版(Spring Security 5.7 之前)
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated();
// 新版 Lambda DSL
http.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated());
Lambda 写法把每个配置模块隔离在独立作用域里,不需要容易忘记的 .and(),更清晰、更安全。
七、登录接口:前台接待发卡流程
7.1 AuthController 核心代码
java
@PostMapping("/login")
public Result<?> login(@RequestBody @Valid LoginDto loginDto) {
// 第1步:包装成"未认证"的令牌
UsernamePasswordAuthenticationToken unauthenticated =
new UsernamePasswordAuthenticationToken(
loginDto.getUsername(),
loginDto.getPassword()
);
// 第2步:交给 AuthenticationManager 认证
Authentication authentication = authenticationManager.authenticate(unauthenticated);
// 第3步:认证成功,生成 JWT
String accessToken = jwtTokenProvider.generateAccessToken(username, role);
String refreshToken = jwtTokenProvider.generateRefreshToken(username, role);
// 第4步:返回双 Token
return Result.success(Map.of(
"access_token", accessToken,
"refresh_token", refreshToken
));
}
7.2 authenticate() 背后发生了什么
java
authenticationManager.authenticate(未认证Token)
│
▼
Spring Security 内部自动:
① 调 UserDetailsServiceImpl.loadUserByUsername("admin")
② AdminMapper 查 admin 表 → 找到记录
③ 包装成 CustomUserDetails("admin", BCrypt密文, "ADMIN")
④ BCryptPasswordEncoder.matches("明文密码", "密文密码") → 匹配!
⑤ 返回已认证的 Authentication 对象
如果密码不对 → 抛 BadCredentialsException → 全局异常处理 → 返回 401。
八、@PreAuthorize:方法级权限控制
8.1 权限矩阵
我们根据业务场景设计了如下权限矩阵:
| 接口 | 方法 | 权限 | 设计理由 |
|---|---|---|---|
/api/auth/login |
POST | 匿名 | 登录入口 |
/api/elderly |
GET(列表) | ADMIN | 老人名单属于管理数据 |
/api/elderly/{id} |
GET(详情) | ADMIN + FAMILY | 家属需要看自家老人 |
/api/elderly |
POST | ADMIN | 只有管理员能添加老人档案 |
/api/elderly/{id} |
PUT/DELETE | ADMIN | 只有管理员能修改删除 |
/api/health-record/** |
GET/POST | ADMIN + FAMILY | 家属可以查看和录入健康数据 |
/api/threshold |
GET | ADMIN + FAMILY | 家属需要参考阈值判断异常 |
/api/threshold |
PUT | ADMIN | 只有管理员能修改阈值配置 |
8.2 @PreAuthorize 原理
java
@PreAuthorize("hasRole('ADMIN')")
这行注解背后发生了什么:
-
Spring Security 用 AOP 代理拦截被标记的方法
-
取出注解里的 SpEL 表达式字符串:
"hasRole('ADMIN')" -
SpEL 解析器解析这个表达式(SpEL = Spring Expression Language = Spring 表达式语言 )
-
hasRole是 SpEL 上下文里注册的函数 -
函数内部:从
SecurityContextHolder取当前用户权限 -
自动加
ROLE_前缀 → 检查是否包含ROLE_ADMIN -
有 → 放行 / 没有 → 抛
AccessDeniedException
8.3 常用权限表达式
java
@PreAuthorize("hasRole('ADMIN')") // 单个角色
@PreAuthorize("hasAnyRole('ADMIN', 'FAMILY')") // 多个角色任意一个
@PreAuthorize("isAuthenticated()") // 只要登录就行
@PreAuthorize("hasRole('ADMIN') or #id == authentication.name") // 管理员或本人
8.4 hasRole vs hasAuthority
java
@PreAuthorize("hasRole('ADMIN')") // 自动加 ROLE_ → 匹配 ROLE_ADMIN
@PreAuthorize("hasAuthority('ROLE_ADMIN')") // 精确匹配,不自动加前缀
效果一样,demo里统一用 hasRole。
九、Access Token + Refresh Token 双卡策略
本项目同时生成两种 Token:
| Access Token | Refresh Token | |
|---|---|---|
| 类比 | 门禁卡 | 续期凭证 |
| 有效期 | 2 小时 | 7 天 |
| 用途 | 日常请求认证 | 换取新的 Access Token |
| 发送频率 | 每次请求都带 | 只在 Access Token 过期时才用 |
| 安全考量 | 频繁传输,短有效期为佳 | 很少传输,长有效期不影响安全 |
为什么要两个 Token?
java
没有 Refresh Token:
2 小时后 → Token 过期 → 用户被踢出 → 重新输入密码登录
体验很差!
有 Refresh Token:
2 小时后 → Token 过期 → 前端用 Refresh Token 换一个新的 Access Token
→ 用户无感知,继续使用
→ 7 天后 Refresh Token 也过期 → 才需要重新登录
十、异常处理分层:最容易搞混的概念
10.1 两层异常处理体系
java
┌──────────────────────────────────────────────┐
│ Filter 层(Spring MVC 外面) │
│ │
│ 未带 Token → SecurityConfig 检测 │
│ → authenticationEntryPoint → 401 │
│ │
│ 角色不对(Filter 层拦截) │
│ → accessDeniedHandler → 403 │
├──────────────────────────────────────────────┤
│ Controller 层(Spring MVC 里面) │
│ │
│ 登录密码错误 → AuthenticationException │
│ → @ExceptionHandler → 401 │
│ │
│ @PreAuthorize 拒绝 → AccessDeniedException │
│ → @ExceptionHandler → 403 │
│ │
│ @Valid 校验失败 → MethodArgumentNotValid... │
│ → @ExceptionHandler → 400 │
└──────────────────────────────────────────────┘
10.2 为什么 Filter 层要手写 JSON
java
// Filter 里只能手写,不能 return Result 对象
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}");
因为 Filter 在 DispatcherServlet 前面执行,Jackson 还没启动,没人帮你把 Result 对象转成 JSON。@RestControllerAdvice 管不到 Filter 层。
10.3 401 vs 403
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| 含义 | 我不知道你是谁 | 我知道你是谁,但你没权限 |
| 触发场景 | 没带 Token、Token 无效、密码错误 | 带了 Token 但角色不够 |
| 怎么解决 | 去登录 | 找管理员开通权限 |
| 类比 | 酒店门卫:你没房卡,不让进 | 你有房卡,但总统套房不让进 |
十一、踩坑记录
坑 1:Knife4j doc.html 样式加载失败 → 401
现象 :打开 /doc.html 页面空白,控制台报 CSS/JS 加载 401。
根因 :Knife4j 的静态资源路径 /webjars/** 没加入 Security 白名单。/doc.html 放行不代表它引用的资源也放行。
修复 :白名单追加 /webjars/** 和 /v3/api-docs/**。
坑 2:@PreAuthorize 写了但不生效
根因 :忘了加 @EnableMethodSecurity 注解。这个注解是 Spring Security 方法级权限的总开关,不加的话所有 @PreAuthorize 都会被忽略。
教训 :配置类的注解一个都不能少------@Configuration + @EnableWebSecurity + @EnableMethodSecurity,缺一不可。
坑 3:GlobalExceptionHandler 导包错误
现象:认证失败返回 500 而不是 401。
根因 :AuthenticationException 有多个同名类------javax.security.sasl、org.apache.tomcat.websocket、org.springframework.security.core。IDE 自动补全时导错了包,编译不报错,但运行时永远匹配不上。
教训 :遇到 @ExceptionHandler 不生效,第一反应检查导包!
坑 4:端口 8080 被占用
根因 :上次 mvn spring-boot:run 的进程没关。
修复:
java
netstat -ano | findstr :8080 # 查 PID
taskkill /PID xxx /F # 杀掉
坑 5:BCrypt 密码匹配失败
根因 :数据库存的是明文密码,BCryptPasswordEncoder.matches() 期望数据库存的是 $2a$10$... 格式的 BCrypt 密文。明文和 BCrypt 格式永远匹配失败。
修复 :先用 BCryptPasswordEncoder.encode() 生成密文,再插入数据库。
坑 6:SecurityConfig 里 .authenticated() 和 .permitAll() 的顺序
规则 :Spring Security 的 URL 匹配是从上到下 的,第一个匹配的规则生效。所以具体的放行规则要写在前面,兜底的 .anyRequest().authenticated() 要写在最后。
十二、自检清单
学完这部分,你应该能回答以下问题。建议对着口述一遍------能说出来 = 真懂了:
- JWT 三部分分别是什么?签名如何防篡改?
- Payload 是加密还是编码?能存密码吗?
UserDetailsService和UserDetails分别负责什么?OncePerRequestFilter为什么叫这个名字?SecurityContext的作用是什么?Filter 为什么要往里面写东西?- 为什么 Filter 验证失败是【放行】而不是【拒绝】?
- Access Token 和 Refresh Token 分别用来干什么?
ROLE_前缀是必须的吗?不加会发生什么?@PreAuthorize底层用了什么技术?- 401 和 403 的本质区别?
@EnableMethodSecurity不写会怎样?
十三、总结
核心是 5 个组件配合:JwtTokenProvider 造卡、UserDetailsServiceImpl 查人、JwtAuthentication验卡、SecurityConfig定规则、AuthController 发卡。
三个关键认知:
- JWT 是无状态的(服务器不记东西,信息全在 Token 里);
- SecurityContext 是桥梁(Filter往里塞身份,后续组件从里面取);
- 异常分两层处理(Filter 层手写 JSON,Controller 层用 @ExceptionHandler,401是"不知道你是谁",403 是"知道你是谁但没权限")。