Spring Boot + JWT + Spring Security 认证授权实战:双角色、双 Token、方法级权限,一次讲透

本文是紧接着上篇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()

记忆技巧

  • parserBuilderparser:Builder 没了,直接 parser

  • setSigningKeyverifyWith:语义更强------"我要用这个密钥来校验"

  • getBodygetPayload: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 接口,我们的 AdminFamily 实体都不实现这个接口。所以需要一个「转换插头」把我们的实体包装成 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')")

这行注解背后发生了什么:

  1. Spring Security 用 AOP 代理拦截被标记的方法

  2. 取出注解里的 SpEL 表达式字符串:"hasRole('ADMIN')"

  3. SpEL 解析器解析这个表达式(SpEL = Spring Expression Language = Spring 表达式语言 )

  4. hasRole 是 SpEL 上下文里注册的函数

  5. 函数内部:从 SecurityContextHolder 取当前用户权限

  6. 自动加 ROLE_ 前缀 → 检查是否包含 ROLE_ADMIN

  7. 有 → 放行 / 没有 → 抛 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.saslorg.apache.tomcat.websocketorg.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 是加密还是编码?能存密码吗?
  • UserDetailsServiceUserDetails 分别负责什么?
  • 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 是"知道你是谁但没权限")。

相关推荐
JAVA面经实录9171 小时前
Spring Cloud Alibaba 微服务企业实战完整文档(架构+规范+调优+故障+源码)
java·运维·spring cloud·微服务
csdndeyeye1 小时前
从Ctrl+C/V到一键填充:AI投简历工具实测
c语言·开发语言·自动化·秋招·ai助手·网申·ai投简历
大G的笔记本1 小时前
生产级 Spring Boot 网关完整实现方案
java·笔记·gateway
LucianaiB1 小时前
Swarm管理面板的多项目配置策略与模型别名机制的效率分析
java·服务器·前端
诸葛大钢铁1 小时前
如何降低Word文件的体积?压缩Word文件的三种方法
开发语言·c#
小白学大数据1 小时前
如何自动追踪 eBay 售价?Python 爬虫实战解析
开发语言·人工智能·爬虫·python
qq_2518364571 小时前
基于Spring Boot的数据标注与质检系统设计与实现
java·spring boot·后端
莫逸风1 小时前
【AgentScope】6.文件系统(Filesystem)详解
开发语言·windows·springai·agentscope·agnet
utf8mb4安全女神1 小时前
怎么写shell/bash脚本【if嵌套】【case】【while死循环】【while嵌套if】【for】【随机数】
开发语言·bash