06|(前端转后全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路

本篇面向已经熟悉前端登录、Axios interceptor、路由守卫、权限按钮,但还没有系统学过后端认证授权的同学。我们会基于当前 fullstack-mall 后端工程的真实源码,讲清楚登录、密码哈希、JWT 签发、Bearer token、Spring Security Filter、SecurityContext、401、403、角色权限这些概念。文中的 Vue / Axios / TypeScript 代码都只是"前端侧示意代码",用于类比理解,当前仓库没有真实前端源码。

1. 这篇解决什么问题

在前 5 篇里,我们已经把后端工程地图、Java / Maven / Spring Boot 启动、Controller 请求响应、MySQL、MyBatis-Plus 都打了一遍基础。现在你已经能从一个公开商品接口一路追到数据库查询。但真实业务不是所有接口都能匿名访问:用户查看自己的购物车要登录,用户创建订单要登录,管理员新增商品要管理员身份,普通用户不能随便访问后台接口。

对前端工程师来说,"登录"通常意味着:调用登录接口,拿到 token,把 token 存到内存或本地存储里,以后请求时通过 Axios interceptor 自动加上 Authorization 请求头;页面上再用路由守卫判断是否跳转到登录页,用角色字段决定按钮显示与否。这个理解没错,但它只覆盖了前端视角的一半。真正的安全边界一定在后端:后端必须能判断这个请求是谁发的,token 有没有被篡改,用户是否仍然有效,角色是否有权限访问当前 URL。前端路由守卫只是提升体验,不能作为安全依据。

当前项目的认证授权链路主要覆盖下面几个问题:

  1. 用户注册时,后端如何保存密码,为什么不能保存明文密码;
  2. 用户登录时,后端如何校验用户名和密码,为什么"用户不存在"和"密码错误"要返回同一种错误;
  3. 登录成功后,后端如何签发 JWT,并把 accessTokentokenTypeexpiresInSecondscurrentUser 返回给前端;
  4. 前端后续请求为什么要带 Authorization: Bearer <token>
  5. Spring Security 的 JwtAuthenticationFilter 为什么发生在 Controller 之前;
  6. Filter 如何把 token 转换成 Spring Security 能识别的 Authentication
  7. 后端为什么不能相信前端传来的 userId,而是要从 SecurityContext 里取当前用户;
  8. 什么情况下返回 401,什么情况下返回 403;
  9. /api/auth/login 为什么允许匿名访问,而 /api/admin/** 为什么必须是 ADMIN
  10. 你在本地如何用 curl 验证注册、登录、查当前用户、普通用户访问后台失败、管理员访问后台成功。

学完本篇,你应该能把"前端 token"升级成"后端认证授权链路"的完整认知:token 不是登录状态本身,而是前端每次请求带给后端的一张有签名、有过期时间、可被后端验证的通行凭证;真正判断请求是否能进入业务逻辑的是 Spring Security 配置、JWT Filter、数据库中的用户状态和角色权限。

2. 用前端知识类比:token、路由守卫、Axios interceptor 与后端安全边界

先从你熟悉的前端视角开始。一个典型前端登录流程可能长这样:

ts 复制代码
// 前端侧示意代码:登录后保存 token,不代表仓库里存在这个文件
async function login(username: string, password: string) {
  const response = await http.post('/api/auth/login', { username, password })
  const data = response.data.data

  // 常见做法:保存 token 和当前用户信息
  localStorage.setItem('accessToken', data.accessToken)
  currentUserStore.setUser(data.currentUser)
}

然后你会在 Axios interceptor 里统一追加请求头:

ts 复制代码
// 前端侧示意代码:请求拦截器自动加 Authorization
http.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

你还可能写前端路由守卫:

ts 复制代码
// 前端侧示意代码:路由守卫只能改善体验,不能替代后端鉴权
router.beforeEach((to) => {
  const token = localStorage.getItem('accessToken')
  if (to.meta.requiresAuth && !token) {
    return '/login'
  }
  if (to.meta.requiresAdmin && currentUserStore.user?.role !== 'ADMIN') {
    return '/403'
  }
})

这些代码从用户体验角度很重要:没有登录就跳登录页,普通用户隐藏后台按钮,token 过期后跳转重新登录。但它们不是安全边界,因为浏览器在用户机器上,任何人都可以打开 DevTools,改 localStorage,伪造请求,直接调用接口,甚至绕过你的页面访问 API。如果后端没有校验,攻击者根本不需要经过你的前端页面。

所以后端必须做 3 件前端不能替代的事:

前端做法 作用 为什么还需要后端
登录页提交用户名密码 收集凭据 后端要查数据库、校验密码哈希、判断账号状态
保存 token 让后续请求能携带凭证 后端要验证 token 签名、过期时间、对应用户是否存在
路由守卫 改善跳转体验 后端要按 URL 和角色强制拦截非法请求
权限按钮隐藏 避免用户点到不能操作的按钮 后端要防止用户直接 curl / Postman 调接口
前端 store 里的 role 控制页面展示 后端要以数据库角色为准,不能相信前端传参

当前项目就是按照这个思路设计的:前端可以拿到 currentUser.role 用于展示,但真正的 ADMIN 判断在 SecurityConfig 里;前端可以带 token,但真正的 token 校验在 JwtAuthenticationFilter 里;业务代码需要当前用户时,不让前端传 userId,而是通过 CurrentUserServiceSecurityContext 里取。

可以把它想象成前端路由守卫和后端 Spring Security 的分工:

flowchart LR A[用户点击后台菜单] --> B{前端路由守卫} B -->|无 token| C[跳转登录页] B -->|有 token| D[发起 HTTP 请求] D --> E[Axios 自动加 Authorization] E --> F{后端 Spring Security} F -->|token 缺失或无效| G[返回 401] F -->|已登录但角色不足| H[返回 403] F -->|身份和权限通过| I[进入 Controller]

前端守卫是第一道体验层,后端 Security 是最终强制层。你可以没有前端守卫,后端仍然安全;但如果只有前端守卫,没有后端校验,那就不是安全系统。

3. 后端核心概念讲解

3.1 认证 Authentication:你是谁

认证解决的问题是"你是谁"。在当前项目里,认证入口是用户名密码登录:前端提交 usernamepassword,后端用用户名查询数据库中的用户记录,再用 BCrypt 校验密码。如果校验成功,后端签发 JWT,前端以后每次请求带这个 JWT。

注意:登录成功后的每次请求不再重复提交密码,而是提交 token。token 是一种短期凭证,不是密码本身。密码应该只在登录请求中短暂出现,不能写日志,不能返回给前端,不能明文保存到数据库。

3.2 授权 Authorization:你能做什么

授权解决的问题是"你能做什么"。当前项目支持两个角色:USERADMIN。普通用户可以访问自己的购物车、订单、支付等接口;管理员可以访问 /api/users/**/api/admin/** 这类后台接口。

在 Spring Security 里,URL 权限规则写在 SecurityConfig 中:

java 复制代码
.requestMatchers("/api/users/**", "/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()

这两行的含义是:用户管理和管理后台接口必须有 ADMIN 角色;其他 /api/** 接口只要登录即可。这里的顺序很重要,越具体的规则应该放在更前面,否则可能被更宽泛的规则提前匹配。

3.3 401 和 403 的区别

前端经常会把 401 和 403 都理解成"没权限",但后端需要分清楚:

状态码 含义 当前项目常见场景 前端常见处理
401 Unauthorized 未认证或认证失败 没带 token、token 乱写、token 过期、token 对应用户不存在 清理 token,跳登录页
403 Forbidden 已认证但无权限 普通 USER 访问 /api/admin/ping 跳 403 页面或提示无权限

一句话记忆:401 是"我不知道你是谁,或者你的身份证无效";403 是"我知道你是谁,但你不能进这个房间"。

3.4 JWT 是什么:有签名的短期凭证,不是加密保险箱

JWT 通常由 3 段组成:Header、Payload、Signature,中间用点号连接。很多初学者误以为 JWT 是"加密后的用户信息",这是非常危险的误解。一般情况下,JWT 的 Payload 是可以被解码查看的,所以不要把密码、手机号、身份证号等敏感信息塞进去。

当前项目的 JwtTokenService 只把稳定用户 ID 放进 subject,同时放入 usernamerole 这类辅助 claim,并设置签发时间和过期时间。它用服务端配置的 security.jwt.secret 做签名。签名的作用是让后端能判断 token 是否被篡改:如果攻击者把 payload 里的角色从 USER 改成 ADMIN,由于他不知道服务端 secret,就无法生成合法签名,后端解析时会失败。

更关键的是,当前项目即使 JWT 还没过期,也会在 Filter 里重新查数据库用户,并检查 status == 1。这意味着管理员停用某个账号后,这个账号即使手里还有未过期 token,也会被拒绝访问。这是比"完全相信 JWT 内容"更稳妥的设计。

3.5 Bearer token 是什么

当前项目约定请求头格式是:

http 复制代码
Authorization: Bearer <accessToken>

Bearer 可以理解为"持有者凭证":谁持有这个 token,谁就可以代表对应用户发起请求。因此 token 泄露后非常危险。前端开发时不要随手把 token 打到控制台,不要把 token 放进 URL 查询参数,不要让第三方脚本轻易读取;后端日志也不要记录完整 token。

JwtAuthenticationFilter 中固定检查 Bearer 前缀。如果请求头不是这个格式,Filter 不会解析 token,而是继续放行到后续授权规则;如果该接口要求登录,后续规则会返回 401。

3.6 Spring Security Filter:为什么它在 Controller 之前

Spring MVC 的 Controller 不是请求进入后端的第一站。请求先经过 Servlet Filter 链,Spring Security 就是在这条链路上工作。当前项目自定义的 JwtAuthenticationFilter 继承 OncePerRequestFilter,意思是每个请求只执行一次这个 Filter。

这点对前端转后端非常重要:有些请求失败时,根本不会进入你的 Controller 方法,所以你在 Controller 里打断点可能完全进不去。比如 token 乱写、访问管理员接口但角色不足,这些问题大概率在 Security Filter 或授权阶段就已经返回响应了。

3.7 SecurityContext:后端可信的"当前用户 store"

前端会有 Pinia / Vuex / Redux store 保存当前用户。后端也需要一个地方保存"本次请求已经认证通过的用户是谁"。在 Spring Security 里,这个地方叫 SecurityContext

当前项目的 Filter 成功解析 JWT、查到数据库用户后,会创建 CurrentUserPrincipal,再创建 UsernamePasswordAuthenticationToken,最后放进:

java 复制代码
SecurityContextHolder.getContext().setAuthentication(authentication);

后续 Controller、Facade、Service 需要当前用户时,就不应该让前端传 userId,而应该从 SecurityContextHolder 中拿。当前项目的 CurrentUserService 就是对这个动作的封装。这样可以避免用户伪造别人的 userId 操作别人的购物车或订单。

4. 在本项目中对应哪些文件

本篇主要阅读这些真实工程文件:

模块 文件 作用
HTTP 入口 backend/service/src/main/java/com/example/fullstackmall/service/auth/AuthController.java 暴露注册、登录、查询当前用户接口
业务逻辑 backend/service/src/main/java/com/example/fullstackmall/service/auth/AuthService.java 注册、密码哈希、登录校验、签发 token、读取当前用户
Facade backend/service/src/main/java/com/example/fullstackmall/service/auth/AuthFacade.java 对 Controller 提供认证能力门面
JWT backend/service/src/main/java/com/example/fullstackmall/service/auth/JwtTokenService.java 创建和解析 JWT
Security 配置 backend/service/src/main/java/com/example/fullstackmall/service/config/SecurityConfig.java 定义 URL 权限、无状态会话、CORS、Filter 顺序
JWT Filter backend/service/src/main/java/com/example/fullstackmall/service/security/JwtAuthenticationFilter.java 把 Bearer token 转为 Authentication
当前用户 backend/service/src/main/java/com/example/fullstackmall/service/security/CurrentUserPrincipal.java 保存本次请求的可信用户信息
当前用户服务 backend/service/src/main/java/com/example/fullstackmall/service/security/CurrentUserService.java SecurityContext 读取当前用户
401 响应 backend/service/src/main/java/com/example/fullstackmall/service/security/RestAuthenticationEntryPoint.java 认证失败时返回统一 JSON
403 响应 backend/service/src/main/java/com/example/fullstackmall/service/security/RestAccessDeniedHandler.java 权限不足时返回统一 JSON
请求 DTO backend/contract/src/main/java/com/example/fullstackmall/contract/auth/LoginRequest.java 登录请求参数和校验规则
请求 DTO backend/contract/src/main/java/com/example/fullstackmall/contract/auth/RegisterRequest.java 注册请求参数和校验规则
响应 DTO backend/contract/src/main/java/com/example/fullstackmall/contract/auth/AuthTokenResponse.java 登录成功返回 token 和当前用户
响应 DTO backend/contract/src/main/java/com/example/fullstackmall/contract/auth/CurrentUserResponse.java 可安全返回前端的用户信息
角色枚举 backend/contract/src/main/java/com/example/fullstackmall/contract/auth/UserRole.java 定义 USERADMIN
用户表 Entity backend/service/src/main/java/com/example/fullstackmall/service/user/entity/UserEntity.java 映射 mall_user
用户 DB 服务 backend/service/src/main/java/com/example/fullstackmall/service/user/service/UserDbService.java 按用户名、用户 ID 查询用户
配置 backend/service/src/main/resources/application.yml security.jwt.secret 和过期时间配置
数据库结构 sql/01_schema.sql mall_user 表、唯一索引、密码哈希字段
种子数据 sql/02_seed.sql 演示账号 xiaomingadmin

其中最核心的是 4 个文件:AuthService.java 负责"登录业务",JwtTokenService.java 负责"token 签发和解析",JwtAuthenticationFilter.java 负责"每次请求的 token 校验",SecurityConfig.java 负责"哪些 URL 允许匿名、哪些必须登录、哪些必须管理员"。

5. Mermaid 图:把认证授权链路画出来

5.1 登录签发 JWT 时序图

sequenceDiagram autonumber participant Browser as 前端浏览器 participant AuthController as AuthController participant AuthService as AuthService participant UserDbService as UserDbService participant PasswordEncoder as BCrypt PasswordEncoder participant JwtTokenService as JwtTokenService participant MySQL as mall_user Browser->>AuthController: POST /api/auth/login username/password AuthController->>AuthService: login(LoginRequest) AuthService->>UserDbService: findByUsername(username) UserDbService->>MySQL: select * from mall_user where username=? MySQL-->>UserDbService: UserEntity(password_hash,status,role_code) UserDbService-->>AuthService: Optional AuthService->>PasswordEncoder: matches(rawPassword, passwordHash) PasswordEncoder-->>AuthService: true / false alt 用户存在、密码正确、账号启用 AuthService->>JwtTokenService: createAccessToken(user) JwtTokenService-->>AuthService: accessToken AuthService-->>AuthController: AuthTokenResponse AuthController-->>Browser: ApiResponse else 失败 AuthService-->>AuthController: BusinessException AuthController-->>Browser: 401 / 403 统一错误响应 end

这张图要特别注意:登录接口的核心不是"生成 token"这么简单,而是先查用户、校验 BCrypt 哈希、检查账号状态,然后才签发 token。token 是登录成功的结果,不是登录成功的原因。

5.2 带 Bearer token 访问受保护接口

flowchart TD A[&#34;前端请求 GET /api/auth/me&#34;] --> B[&#34;请求头 Authorization: Bearer token&#34;] B --> C[JwtAuthenticationFilter] C --> D{是否有 Bearer token} D -->|没有| E[继续 Filter 链] E --> F{URL 是否要求登录} F -->|要求登录| G[RestAuthenticationEntryPoint 返回 401] F -->|允许匿名| H[进入 Controller] D -->|有| I[JwtTokenService 解析签名和过期时间] I --> J{token 是否合法} J -->|非法/过期| G J -->|合法| K[按 userId 重新查 mall_user] K --> L{用户存在且 status=1} L -->|否| G L -->|是| M[构造 CurrentUserPrincipal] M --> N[写入 SecurityContext] N --> O[继续授权规则] O --> P[进入 Controller 或返回 403]

这张图解释了一个常见疑问:为什么 Filter 在没有 token 时不立即返回 401?因为有些接口本来就允许匿名访问,例如 /api/auth/login、公开商品列表、Swagger 文档、健康检查等。Filter 只负责"如果你带了 token,我就尝试解析成身份";最终是否必须登录,由 SecurityConfig 的 URL 规则决定。

5.3 401 与 403 分支图

flowchart LR A[请求进入后端] --> B{认证是否成功} B -->|无 token / token 无效 / token 过期| C[401 Unauthorized] B -->|成功识别用户| D{角色是否满足 URL 规则} D -->|不满足,比如 USER 访问 /api/admin/**| E[403 Forbidden] D -->|满足| F[进入业务 Controller]

前端处理这两个状态码时要分开:401 通常代表登录态失效,需要清理 token 并引导重新登录;403 代表用户已登录但权限不够,不应该简单清 token,而是提示无权限或跳转 403 页面。

5.4 Spring Security 位于 Controller 之前

flowchart TB A[HTTP Request] --> B[Servlet Filter Chain] B --> C[JwtAuthenticationFilter] C --> D[Spring Security Authorization] D -->|通过| E[DispatcherServlet] E --> F[AuthController / ProductAdminController] F --> G[Facade] G --> H[Service / DbService] D -->|认证失败| I[RestAuthenticationEntryPoint 401] D -->|权限不足| J[RestAccessDeniedHandler 403]

这张图能帮你排查问题:如果一个请求返回 401 或 403,而且 Controller 断点没有命中,不要怀疑 Controller 没注册,应该先看请求头、token、URL 权限规则和 Filter 日志。

6. 逐段读源码

6.1 AuthController:认证接口的 HTTP 入口

AuthController 标注了 @RestController@RequestMapping("/api/auth"),所以它下面的方法路径都以 /api/auth 开头。当前有 3 个接口:

java 复制代码
@PostMapping("/register")
public ResponseEntity<ApiResponse<CurrentUserResponse>> register(...)

注册接口接收 RegisterRequest,使用 @Valid @RequestBody 触发参数校验。注册成功返回 201 Created,响应体是统一结构 ApiResponse<CurrentUserResponse>。这里返回的是安全的用户信息,不会返回密码,也不会返回 passwordHash

java 复制代码
@PostMapping("/login")
public ApiResponse<AuthTokenResponse> login(...)

登录接口接收 LoginRequest,成功后返回 AuthTokenResponse。这个响应体对前端非常关键:accessToken 用于后续请求,tokenType 当前固定为 BearerexpiresInSeconds 用于前端估算过期时间,currentUser 用于页面展示和体验层权限控制。

java 复制代码
@GetMapping("/me")
public ApiResponse<CurrentUserResponse> currentUser(...)

/api/auth/me 是典型的"用 token 换当前用户信息"接口。它不应该接受前端传来的 userId。如果前端能传 userId,攻击者就可能把 userId=1 改成 userId=2 查看别人信息。当前项目的实现会在 Service 层从 SecurityContext 读取已经认证过的当前用户。

Controller 自己不做密码校验、不生成 JWT,也不直接读数据库。它的职责是接 HTTP 参数、调用 IAuthFacade、包装统一响应。这和前端组件不应该直接写所有 API 细节类似:组件调用 service,Controller 调 Facade。

6.2 LoginRequestRegisterRequest:请求参数也是后端边界

LoginRequest 中的用户名不能为空,最长 30 个字符;密码不能为空,最长 64 个字符。注释里强调:明文密码只用于本次 BCrypt 校验,禁止记录日志或写响应。

RegisterRequest 的约束更严格:用户名必须满足 ^[A-Za-z0-9_]{4,30}$,昵称长度 2~30,密码长度 8~64,并且必须同时包含字母和数字。这些规则类似前端表单校验,但区别在于:后端校验不能省。前端校验可以被绕过,后端校验才是最终数据入口。

前端可以先做同样规则的校验来减少无效请求:

ts 复制代码
// 前端侧示意代码:前端校验只是体验优化,后端仍必须 @Valid 校验
function validateRegisterForm(form: RegisterForm) {
  if (!/^[A-Za-z0-9_]{4,30}$/.test(form.username)) {
    return '用户名只能包含字母、数字、下划线,长度为 4~30'
  }
  if (!/(?=.*[A-Za-z])(?=.*\d)/.test(form.password)) {
    return '密码必须同时包含字母和数字'
  }
  return null
}

但你要始终记住:前端校验是"用户体验",后端校验是"系统边界"。

6.3 AuthService.register:注册时保存 BCrypt 哈希

注册逻辑的关键步骤是:

  1. normalizeUsername(request.getUsername()):用户名转小写,避免 Alicealice 被当成两个账号;
  2. userDbService.usernameExists(username):先查一次是否已存在,尽早返回友好错误;
  3. 创建 UserEntity:设置用户名、昵称、密码哈希、角色、状态、创建时间;
  4. passwordEncoder.encode(request.getPassword()):把明文密码转成 BCrypt 哈希;
  5. 默认角色设置为 UserRole.USER
  6. status 设置为 1,表示账号可用;
  7. 捕获 DuplicateKeyException:并发注册同一个用户名时,最终依赖数据库唯一索引兜底。

这里最值得前端同学建立的新意识是:数据库里不应该保存用户原始密码。即使数据库被泄露,攻击者也不应该直接拿到明文密码。BCrypt 是一种专门用于密码哈希的算法,它会加入随机盐,并且计算成本比普通哈希更高,目的是增加暴力破解难度。

为什么同一个密码每次 encode 结果可能不同?因为 BCrypt 会使用随机盐。登录时也不是把用户输入密码重新哈希后直接字符串比较,而是调用:

java 复制代码
passwordEncoder.matches(request.getPassword(), user.getPasswordHash())

matches 会根据保存的哈希内容提取盐和算法参数,再判断明文密码是否匹配。

6.4 AuthService.login:不要泄露用户名是否存在

登录逻辑大致是:

  1. 用户名 normalize;
  2. 按用户名查数据库;
  3. 如果用户不存在,抛出 INVALID_CREDENTIALS
  4. 如果密码不匹配,也抛出 INVALID_CREDENTIALS
  5. 如果账号停用,抛出 ACCOUNT_DISABLED,HTTP 状态为 403;
  6. jwtTokenService.createAccessToken(user) 签发 token;
  7. 返回 AuthTokenResponse

"用户不存在"和"密码错误"统一返回 INVALID_CREDENTIALS 是安全设计。假如接口明确返回"用户名不存在",攻击者就可以批量枚举哪些用户名已注册;如果返回"密码错误",说明用户名存在。统一错误能减少信息泄露。

账号停用返回 403,是因为这个用户身份可能是存在的,但当前账号不允许继续访问。你可以把它理解成"门禁卡是真的,但已被冻结"。

6.5 JwtTokenService:创建和解析 token

JwtTokenService 从配置中读取两个值:

yaml 复制代码
security:
  jwt:
    secret: ${JWT_SECRET:oGcp17L56HT4JnI/49W2euWtWbyT/D0tBlKhuD2lASQ=}
    expiration-seconds: ${JWT_EXPIRATION_SECONDS:1800}

本地学习环境提供了默认 secret,但注释已经说明:真实环境必须通过 JWT_SECRET 环境变量注入,并妥善保管。原因很简单:谁掌握 secret,谁就可能签发伪造 token。secret 不能提交到公开仓库,也不能写进前端代码。

创建 token 时,当前项目使用用户 ID 作为 subject:

java 复制代码
.subject(String.valueOf(user.getId()))
.claim("username", user.getUsername())
.claim("role", user.getRoleCode())
.issuedAt(now)
.expiration(expiration)
.signWith(signingKey)
.compact();

这里的设计很值得学习:subject 只保存稳定的用户 ID,而不是把所有用户资料都塞进 token。角色虽然也作为 claim 写入,但当前 Filter 后面仍然会重新查数据库,并用数据库中的 roleCode 构造权限。这样做的好处是:如果管理员把某个用户从 ADMIN 改成 USER,后端不必等旧 token 自然过期,下一次请求重新查数据库时就会使用新的角色。

解析 token 时,parseUserId 会校验签名和过期时间。如果 token 过期、签名不对、格式错误,都会抛出异常,最终由 Filter 转成 401 响应。

6.6 JwtAuthenticationFilter:把 token 转成后端身份

JwtAuthenticationFilter 是本篇最关键的文件。它做的事情可以拆成 7 步:

  1. 从请求头读取 Authorization
  2. 如果没有 Bearer 前缀,直接继续 Filter 链;
  3. 截取 token 字符串并 trim;
  4. token 为空则抛 BadCredentialsException
  5. jwtTokenService.parseUserId(token) 校验并拿到 userId;
  6. 用 userId 重新查数据库,要求用户存在且 status == 1
  7. 构造 CurrentUserPrincipalUsernamePasswordAuthenticationToken,写入 SecurityContext

其中第 6 步非常重要。很多 JWT 教程会直接相信 token payload 里的角色,但当前项目没有这么做。它把 JWT 当成"可验证的用户 ID 凭证",真正的用户状态和角色仍然从数据库读取。这会多一次数据库查询,但安全性和实时性更好,尤其适合学习项目和后台权限场景。

构造权限时,代码使用:

java 复制代码
new SimpleGrantedAuthority("ROLE_" + role.name())

Spring Security 的 hasRole("ADMIN") 会自动按 ROLE_ADMIN 这种 authority 判断,所以这里要加 ROLE_ 前缀。如果你以后遇到"明明用户是 ADMIN,却一直 403",一个常见排查点就是 authority 名称和 hasRole / hasAuthority 是否匹配。

异常处理也很关键:

java 复制代码
SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(...);

Filter 捕获 token 异常后会清空上下文,防止线程复用时误带旧身份。然后调用 RestAuthenticationEntryPoint 返回统一 401 JSON,而不是让异常一路变成默认 HTML 错误页。

6.7 SecurityConfig:URL 权限规则写在哪里

SecurityConfig 是整个安全规则的总配置。先看几个关闭项:

java 复制代码
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.logout(logout -> logout.disable())

这说明当前项目是前后端分离风格,登录状态不依赖服务端 Session,也不使用 Spring Security 默认表单登录页面。每次请求都靠前端带 JWT,后端无状态解析。STATELESS 对前端同学很好理解:后端不会像传统模板项目那样在服务端保存一份浏览器 session;token 在请求头里,每次请求自带凭证。

再看公开接口:

java 复制代码
.requestMatchers(
    "/api/health",
    "/api/auth/register",
    "/api/auth/login",
    "/api/categories/validate",
    "/api/products",
    "/api/products/**",
    "/api/mock-payments/callback",
    "/v3/api-docs/**",
    "/swagger-ui.html",
    "/swagger-ui/**",
    "/error"
).permitAll()

注册和登录必须允许匿名,否则用户还没登录就无法登录。公开商品接口也允许匿名,因为商城首页、商品详情通常不需要登录即可浏览。Swagger 和健康检查是开发调试需要。模拟支付回调不靠用户登录,而是另有共享密钥校验,所以也在匿名白名单里。

然后是管理员接口:

java 复制代码
.requestMatchers("/api/users/**", "/api/admin/**").hasRole("ADMIN")

这意味着普通 USER 即使登录成功,访问 /api/admin/ping 也应该返回 403。

最后是:

java 复制代码
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()

其他 /api/** 接口需要登录;非 API 请求默认放行。后续如果你新增接口,一定要想清楚它匹配哪条规则。比如新增 /api/cart/items,它会匹配 /api/**,因此必须登录;新增 /api/admin/coupons,它会匹配 /api/admin/**,因此必须管理员。

6.8 RestAuthenticationEntryPointRestAccessDeniedHandler:为什么错误也是统一 JSON

认证失败发生在 Controller 之前,不能完全依赖 Controller 层的全局异常处理。因此项目提供了两个 Security 专用错误处理器。

RestAuthenticationEntryPoint 负责 401:未登录、token 无效、token 过期等。它返回 ApiResponse.error(ApiCode.UNAUTHORIZED, ...),并设置 HTTP 状态码 401。

RestAccessDeniedHandler 负责 403:已经识别出当前用户,但权限不满足 URL 规则。它返回 ApiResponse.error(ApiCode.FORBIDDEN, ...),并设置 HTTP 状态码 403。

这对前端非常友好:无论错误发生在 Controller 里还是 Security Filter 里,前端都能按统一响应结构处理,而不是有时拿 JSON、有时拿 HTML。

6.9 CurrentUserService:业务代码不要相信前端传来的 userId

当前用户服务会从 SecurityContextHolder.getContext().getAuthentication() 中取身份,并要求 principal 必须是 CurrentUserPrincipal。如果没有认证信息,就抛 UNAUTHORIZED

这和前端 store 很像,但可信等级完全不同。前端 store 是用户浏览器里的一份状态,可以被改;后端 SecurityContext 是本次请求经过 JWT 签名校验、数据库用户状态校验后写入的身份。

后续你读购物车、订单、支付模块时会看到类似思想:用户维度的数据归属应该从当前登录用户来,而不是从请求体里相信一个 userId。例如创建订单时,前端可以提交 SKU 和数量,但订单归属的用户 ID 应该以后端当前用户为准。

7. 本地运行 / curl 验证

下面的命令假设服务运行在 http://localhost:8080,并且你已经按前面文章启动 MySQL、执行 schema 和 seed、启动 Spring Boot 服务。项目种子数据里提供了两个演示账号:普通用户 xiaoming / Mall123456,管理员 admin / Admin123456。SQL 中保存的是 BCrypt 哈希,不是明文密码。

7.1 登录普通用户并保存 token

bash 复制代码
curl -sS -X POST 'http://localhost:8080/api/auth/login' \
  -H 'Content-Type: application/json' \
  -d '{"username":"xiaoming","password":"Mall123456"}'

如果你想在 shell 里保存 token,可以用 python3 解析 JSON:

bash 复制代码
USER_TOKEN=$(curl -sS -X POST 'http://localhost:8080/api/auth/login' \
  -H 'Content-Type: application/json' \
  -d '{"username":"xiaoming","password":"Mall123456"}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["accessToken"])')

echo "$USER_TOKEN"

成功响应大致会包含:

json 复制代码
{
  "code": "SUCCESS",
  "message": "success",
  "data": {
    "accessToken": "...",
    "tokenType": "Bearer",
    "expiresInSeconds": 1800,
    "currentUser": {
      "username": "xiaoming",
      "role": "USER"
    }
  }
}

具体 token 每次都可能不同,因为签发时间不同。

7.2 不带 token 访问 /api/auth/me,应返回 401

bash 复制代码
curl -i 'http://localhost:8080/api/auth/me'

/api/auth/me 匹配 /api/**,并且不在匿名白名单中,所以不带 token 应该返回 401。这个请求通常不会进入 AuthController.currentUser,而是在 Spring Security 阶段被拦截。

7.3 带普通用户 token 访问 /api/auth/me,应返回当前用户

bash 复制代码
curl -sS 'http://localhost:8080/api/auth/me' \
  -H "Authorization: Bearer $USER_TOKEN"

这个请求会经过 JwtAuthenticationFilter:解析 token,拿到 userId,重新查数据库用户,写入 SecurityContext,然后进入 AuthController.currentUser,最终返回 CurrentUserResponse

7.4 token 乱写,应该返回 401

bash 复制代码
curl -i 'http://localhost:8080/api/auth/me' \
  -H 'Authorization: Bearer abc.def.ghi'

这会触发 JWT 解析异常,Filter 清空上下文,并调用 RestAuthenticationEntryPoint 返回 401。

7.5 普通用户访问管理员接口,应该返回 403

bash 复制代码
curl -i 'http://localhost:8080/api/admin/ping' \
  -H "Authorization: Bearer $USER_TOKEN"

这里 token 是合法的,所以不是 401;但用户角色是 USER,不满足 /api/admin/**hasRole("ADMIN"),因此应该返回 403。

7.6 登录管理员并访问管理员接口

bash 复制代码
ADMIN_TOKEN=$(curl -sS -X POST 'http://localhost:8080/api/auth/login' \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"Admin123456"}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["accessToken"])')

curl -sS 'http://localhost:8080/api/admin/ping' \
  -H "Authorization: Bearer $ADMIN_TOKEN"

成功时会返回类似 admin pong 的统一响应。这个接口是当前项目里的最小管理员权限演示接口,适合用来验证 401 / 403 / 200 的差异。

7.7 注册一个新用户

bash 复制代码
curl -i -X POST 'http://localhost:8080/api/auth/register' \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice_2026","nickname":"Alice","password":"Alice123456"}'

注册成功应返回 201。再次用相同用户名注册,应返回用户名已存在相关错误。这里同时体现了两层防护:Service 先查用户名是否存在,数据库唯一索引最终兜底并发重复注册。

8. 常见错误与排查方法

8.1 忘记加 Bearer 前缀

错误示例:

bash 复制代码
curl -i 'http://localhost:8080/api/auth/me' \
  -H "Authorization: $USER_TOKEN"

当前 Filter 只识别 AuthorizationBearer 开头的请求头。少了前缀,Filter 会当作"没有可解析 token",继续走后续授权规则,最终对受保护接口返回 401。

8.2 前端把 token 存了,但 Axios 没有真正带出去

你在浏览器里看到 localStorage 有 token,不代表请求头真的带了 token。要打开 Network 面板,看具体请求的 Request Headers 里是否有:

http 复制代码
Authorization: Bearer xxx

如果没有,优先查 Axios interceptor 是否注册在同一个 Axios 实例上,是否被后续代码覆盖 headers,跨域预检是否通过。

8.3 CORS 没放行 Authorization 请求头

当前 SecurityConfig 的 CORS 配置显式允许了 Authorization 请求头:

java 复制代码
configuration.setAllowedHeaders(List.of(
    "Authorization", "Content-Type", "X-Trace-Id", "Idempotency-Key", "X-Mock-Payment-Secret"
));

如果你以后改 CORS 配置时漏掉 Authorization,浏览器可能在预检阶段就拦截请求,后端业务接口甚至收不到真正请求。这个错误在 curl 里不一定复现,因为 curl 不受浏览器 CORS 限制。

8.4 把 403 当成 token 过期处理

前端响应拦截器常见写法是:遇到 401 清 token 跳登录页。不要把 403 也一律清 token。403 说明用户已登录但权限不足,清 token 只会让用户困惑。更合理的处理是跳转 403 页面、提示"当前账号无权限",或者隐藏对应功能入口。

ts 复制代码
// 前端侧示意代码:区分 401 和 403
http.interceptors.response.use(undefined, (error) => {
  const status = error.response?.status
  if (status === 401) {
    localStorage.removeItem('accessToken')
    router.push('/login')
  } else if (status === 403) {
    router.push('/403')
  }
  return Promise.reject(error)
})

8.5 以为 JWT 是加密的,把敏感信息塞进 payload

JWT 的 payload 通常只是 Base64URL 编码,不是加密。不要放密码、银行卡、身份证、手机号等敏感信息。当前项目把用户 ID 放在 subject,把 username、role 放在 claim,而且后端仍然重新查数据库用户,这是更适合入门项目的稳妥设计。

8.6 后端业务接口相信前端传来的 userId

这是前端转后端最容易犯的安全错误之一。前端可以传 userId=2,攻击者也可以改成 userId=3。后端涉及"我的购物车""我的订单""我的地址"时,应该从 CurrentUserService.requireCurrentUserId() 获取当前用户 ID,而不是从请求体相信前端传入。

8.7 Controller 断点没有命中就以为路由不存在

如果请求返回 401 / 403,Controller 断点不命中是正常现象,因为 Security Filter 在 Controller 之前。排查顺序应该是:请求 URL 是否正确、是否带 Authorization、token 是否过期、用户状态是否启用、角色是否满足 URL 规则、SecurityConfig 规则顺序是否正确。

8.8 本地 JWT secret 变了导致旧 token 全部失效

JWT 签名依赖 secret。如果你重启服务时换了 JWT_SECRET,之前签发的 token 会解析失败。这在本地学习时很正常,重新登录即可;在线上环境则要有密钥管理和灰度策略,不能随意更换。

8.9 数据库里角色码写错导致权限异常

UserRole.fromCode 遇到未知角色会立即失败,避免静默授予错误权限。如果数据库里 role_code 写成了 administrator 而不是 ADMIN,Filter 解析用户角色时会失败,最终导致认证失败。这个设计宁可失败,也不要把未知角色误当普通权限或管理员权限。

8.10 URL 权限规则顺序写反

Spring Security 的匹配规则通常按声明顺序判断。更具体、更严格的规则应该放在宽泛规则前面。当前项目先写 /api/admin/** 需要 ADMIN,再写 /api/** 需要登录,这是正确方向。如果你先把 /api/** 写成 authenticated,再写 /api/admin/**,可能导致管理员规则无法按预期生效。

9. 本章小练习

练习 1:画出登录成功后的数据结构

阅读 AuthTokenResponse.javaCurrentUserResponse.java,手动画出登录成功后前端能拿到哪些字段。然后回答:哪些字段可以用于页面展示?哪些字段不应该被前端当成安全依据?

参考思路:currentUser.nickname 可以用于展示,currentUser.role 可以用于隐藏按钮;但是否真的能访问后台接口,要以后端 SecurityConfig 和数据库角色为准。

练习 2:用 curl 验证 401、403、200

按本篇第 7 节执行 3 个请求:

  1. 不带 token 访问 /api/auth/me,观察 401;
  2. 普通用户 token 访问 /api/admin/ping,观察 403;
  3. 管理员 token 访问 /api/admin/ping,观察 200。

把 3 次响应的 HTTP 状态码和响应体记录下来。你要能用自己的话解释每个状态码发生在链路的哪一层。

练习 3:追源码证明 /api/products 为什么不需要登录

打开 SecurityConfig.java,找到 permitAll() 列表,确认 /api/products/api/products/** 在匿名白名单中。然后访问:

bash 复制代码
curl -i 'http://localhost:8080/api/products'

思考:为什么商城商品列表通常允许匿名访问?如果你以后新增"我的收藏商品"接口,它应该放在公开白名单里吗?为什么?

练习 4:解释为什么注册要依赖数据库唯一索引兜底

阅读 AuthService.registersql/01_schema.sql 里的 mall_user 表,找到用户名唯一索引。回答:既然 Service 已经先调用 usernameExists,为什么还要数据库唯一索引?

参考方向:两个请求可以同时通过"先查不存在",然后同时插入;只有数据库唯一约束能在最终写入时保证不会出现重复用户名。

练习 5:模拟前端响应拦截器的处理策略

写一段"前端侧示意代码",要求:401 清理 token 并跳登录页;403 跳无权限页;其他错误按普通 toast 提示。这个练习不是为了写真实前端,而是让你建立状态码语义。

练习 6:阅读 JwtAuthenticationFilter,标注 7 个关键步骤

打开 JwtAuthenticationFilter.java,在纸上或笔记里标出:读取请求头、判断 Bearer、解析 token、查数据库、构造 principal、写入 SecurityContext、异常转 401。标完以后再回头看本篇 5.2 的 Mermaid 图,看是否能一一对应。

10. 下一章预告:Redis 缓存基础

到这里,你已经知道后端如何判断"这个请求是谁"和"这个请求能不能访问某个 URL"。下一篇我们会进入另一个后端高频基础:Redis。

Redis 对前端同学来说可以先类比成"后端侧的高速缓存",但它比前端的 memory cache、localStorage、sessionStorage 更适合跨请求、跨实例共享数据。当前项目后续会在商品详情中用到缓存:第一次请求从 MySQL 查商品详情,并写入 Redis;后续请求优先查 Redis,提高响应速度;当商品被管理员修改、上下架时,需要删除或刷新缓存,避免用户看到旧数据。

下一篇会先补 Redis 基础,不会直接假设你已经懂缓存。我们会讲:Redis 是什么,为什么比 MySQL 更适合做热点数据缓存,什么是 key、value、TTL,缓存命中和缓存未命中是什么,Cache Aside 是什么,Redis 挂了接口是否还能降级,以及当前项目里 ProductDetailCacheServiceRedisOperatorClient 如何配合。

学完 Redis 基础后,我们就能在第 8 篇把"商品详情完整请求链路"串起来:HTTP 请求进入 Controller,Facade 判断业务规则,优先查 Redis 缓存,缓存没有再查 MySQL,最后返回统一 JSON。那会是你第一次完整理解一个"真实后端接口"从入口到数据库再到缓存的全链路。

11. 一个前端转后端的心智转换清单

学认证授权时,不要只记住"登录返回 token"这一句。你需要把注意力从页面状态切到服务端边界:请求是不是来自真实用户,凭证有没有被篡改,账号是否仍然启用,角色是否满足接口规则,业务数据归属是否以后端当前用户为准。前端可以决定按钮显不显示,但不能决定接口让不让过;前端可以保存登录态,但不能替代后端校验登录态;前端可以展示角色,但不能通过提交角色字段让自己升级为管理员。

以后你设计任何需要登录的接口,都可以按下面的问题自检:这个接口是否应该允许匿名访问?如果必须登录,它匹配的是 /api/** 还是更严格的 /api/admin/**?业务方法里有没有从请求体读取 userId 这种不可信字段?如果用户被停用,旧 token 是否还会被拒绝?如果普通用户直接用 curl 调后台接口,后端是否返回 403?这些问题比"代码能不能跑通"更接近后端工程师的安全思维。

12. 本篇总结

本篇你需要带走 8 个核心结论:

  1. 前端路由守卫和权限按钮只是体验层,真正安全边界在后端;
  2. 登录属于认证,判断能否访问某个接口属于授权;
  3. 401 表示未认证或认证失败,403 表示已认证但权限不足;
  4. JWT 是有签名和过期时间的凭证,不是加密保险箱,不能放敏感信息;
  5. 当前项目登录成功后返回 AuthTokenResponse,前端后续请求带 Authorization: Bearer <token>
  6. JwtAuthenticationFilter 在 Controller 之前执行,会把合法 token 转成 SecurityContext 里的当前用户;
  7. 当前项目即使 JWT 未过期,也重新查数据库用户状态和角色,避免停用账号继续访问;
  8. 业务代码需要当前用户时,应从 CurrentUserService / SecurityContext 读取,而不是相信前端传来的 userId

如果你能用自己的话把"登录接口签发 token"和"带 token 访问受保护接口"两条链路画出来,再能解释 401 和 403 的区别,就说明你已经迈过了后端认证授权的第一道门槛。

相关推荐
海上彼尚2 小时前
Nodejs也能写Agent - 22.LangGraph篇 - 上下文工程
前端·javascript·人工智能·langchain·node.js
阳光是sunny2 小时前
LangGraph实战教程:预定义状态MessagesState与AgentState
前端·人工智能·后端
我叫黑大帅2 小时前
add()和 __add__() 写法哪个更好呢?
后端·python·面试
GuWenyue2 小时前
等AI回复卡顿到劝退?Vue3+DeepSeek流式输出实战,70行代码实现打字机效果
前端·人工智能·客户端
Gopher_HBo3 小时前
moby-daemon-config配置
后端
拉斯特当思3 小时前
设备总掉线却复现不了?把弱网“造“出来:tcconfig 五种网络故障模拟实战
后端
Jackson__3 小时前
AI Agent 的能力从哪里来?一文讲清后训练、上下文学习和外部能力
前端·agent·ai编程
RainCity3 小时前
Java Swing 自定义组件库分享(十五)
java·笔记·后端