本篇面向已经熟悉前端登录、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。前端路由守卫只是提升体验,不能作为安全依据。
当前项目的认证授权链路主要覆盖下面几个问题:
- 用户注册时,后端如何保存密码,为什么不能保存明文密码;
- 用户登录时,后端如何校验用户名和密码,为什么"用户不存在"和"密码错误"要返回同一种错误;
- 登录成功后,后端如何签发 JWT,并把
accessToken、tokenType、expiresInSeconds和currentUser返回给前端; - 前端后续请求为什么要带
Authorization: Bearer <token>; - Spring Security 的
JwtAuthenticationFilter为什么发生在 Controller 之前; - Filter 如何把 token 转换成 Spring Security 能识别的
Authentication; - 后端为什么不能相信前端传来的
userId,而是要从SecurityContext里取当前用户; - 什么情况下返回 401,什么情况下返回 403;
/api/auth/login为什么允许匿名访问,而/api/admin/**为什么必须是ADMIN;- 你在本地如何用 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,而是通过 CurrentUserService 从 SecurityContext 里取。
可以把它想象成前端路由守卫和后端 Spring Security 的分工:
前端守卫是第一道体验层,后端 Security 是最终强制层。你可以没有前端守卫,后端仍然安全;但如果只有前端守卫,没有后端校验,那就不是安全系统。
3. 后端核心概念讲解
3.1 认证 Authentication:你是谁
认证解决的问题是"你是谁"。在当前项目里,认证入口是用户名密码登录:前端提交 username 和 password,后端用用户名查询数据库中的用户记录,再用 BCrypt 校验密码。如果校验成功,后端签发 JWT,前端以后每次请求带这个 JWT。
注意:登录成功后的每次请求不再重复提交密码,而是提交 token。token 是一种短期凭证,不是密码本身。密码应该只在登录请求中短暂出现,不能写日志,不能返回给前端,不能明文保存到数据库。
3.2 授权 Authorization:你能做什么
授权解决的问题是"你能做什么"。当前项目支持两个角色:USER 和 ADMIN。普通用户可以访问自己的购物车、订单、支付等接口;管理员可以访问 /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,同时放入 username 和 role 这类辅助 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 |
定义 USER、ADMIN |
| 用户表 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 |
演示账号 xiaoming 和 admin |
其中最核心的是 4 个文件:AuthService.java 负责"登录业务",JwtTokenService.java 负责"token 签发和解析",JwtAuthenticationFilter.java 负责"每次请求的 token 校验",SecurityConfig.java 负责"哪些 URL 允许匿名、哪些必须登录、哪些必须管理员"。
5. Mermaid 图:把认证授权链路画出来
5.1 登录签发 JWT 时序图
这张图要特别注意:登录接口的核心不是"生成 token"这么简单,而是先查用户、校验 BCrypt 哈希、检查账号状态,然后才签发 token。token 是登录成功的结果,不是登录成功的原因。
5.2 带 Bearer token 访问受保护接口
这张图解释了一个常见疑问:为什么 Filter 在没有 token 时不立即返回 401?因为有些接口本来就允许匿名访问,例如 /api/auth/login、公开商品列表、Swagger 文档、健康检查等。Filter 只负责"如果你带了 token,我就尝试解析成身份";最终是否必须登录,由 SecurityConfig 的 URL 规则决定。
5.3 401 与 403 分支图
前端处理这两个状态码时要分开:401 通常代表登录态失效,需要清理 token 并引导重新登录;403 代表用户已登录但权限不够,不应该简单清 token,而是提示无权限或跳转 403 页面。
5.4 Spring Security 位于 Controller 之前
这张图能帮你排查问题:如果一个请求返回 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 当前固定为 Bearer,expiresInSeconds 用于前端估算过期时间,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 LoginRequest 与 RegisterRequest:请求参数也是后端边界
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 哈希
注册逻辑的关键步骤是:
normalizeUsername(request.getUsername()):用户名转小写,避免Alice和alice被当成两个账号;userDbService.usernameExists(username):先查一次是否已存在,尽早返回友好错误;- 创建
UserEntity:设置用户名、昵称、密码哈希、角色、状态、创建时间; passwordEncoder.encode(request.getPassword()):把明文密码转成 BCrypt 哈希;- 默认角色设置为
UserRole.USER; status设置为 1,表示账号可用;- 捕获
DuplicateKeyException:并发注册同一个用户名时,最终依赖数据库唯一索引兜底。
这里最值得前端同学建立的新意识是:数据库里不应该保存用户原始密码。即使数据库被泄露,攻击者也不应该直接拿到明文密码。BCrypt 是一种专门用于密码哈希的算法,它会加入随机盐,并且计算成本比普通哈希更高,目的是增加暴力破解难度。
为什么同一个密码每次 encode 结果可能不同?因为 BCrypt 会使用随机盐。登录时也不是把用户输入密码重新哈希后直接字符串比较,而是调用:
java
passwordEncoder.matches(request.getPassword(), user.getPasswordHash())
matches 会根据保存的哈希内容提取盐和算法参数,再判断明文密码是否匹配。
6.4 AuthService.login:不要泄露用户名是否存在
登录逻辑大致是:
- 用户名 normalize;
- 按用户名查数据库;
- 如果用户不存在,抛出
INVALID_CREDENTIALS; - 如果密码不匹配,也抛出
INVALID_CREDENTIALS; - 如果账号停用,抛出
ACCOUNT_DISABLED,HTTP 状态为 403; - 调
jwtTokenService.createAccessToken(user)签发 token; - 返回
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 步:
- 从请求头读取
Authorization; - 如果没有
Bearer前缀,直接继续 Filter 链; - 截取 token 字符串并 trim;
- token 为空则抛
BadCredentialsException; - 调
jwtTokenService.parseUserId(token)校验并拿到 userId; - 用 userId 重新查数据库,要求用户存在且
status == 1; - 构造
CurrentUserPrincipal和UsernamePasswordAuthenticationToken,写入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 RestAuthenticationEntryPoint 与 RestAccessDeniedHandler:为什么错误也是统一 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 只识别 Authorization 以 Bearer 开头的请求头。少了前缀,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.java 和 CurrentUserResponse.java,手动画出登录成功后前端能拿到哪些字段。然后回答:哪些字段可以用于页面展示?哪些字段不应该被前端当成安全依据?
参考思路:currentUser.nickname 可以用于展示,currentUser.role 可以用于隐藏按钮;但是否真的能访问后台接口,要以后端 SecurityConfig 和数据库角色为准。
练习 2:用 curl 验证 401、403、200
按本篇第 7 节执行 3 个请求:
- 不带 token 访问
/api/auth/me,观察 401; - 普通用户 token 访问
/api/admin/ping,观察 403; - 管理员 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.register 和 sql/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 挂了接口是否还能降级,以及当前项目里 ProductDetailCacheService 和 RedisOperatorClient 如何配合。
学完 Redis 基础后,我们就能在第 8 篇把"商品详情完整请求链路"串起来:HTTP 请求进入 Controller,Facade 判断业务规则,优先查 Redis 缓存,缓存没有再查 MySQL,最后返回统一 JSON。那会是你第一次完整理解一个"真实后端接口"从入口到数据库再到缓存的全链路。
11. 一个前端转后端的心智转换清单
学认证授权时,不要只记住"登录返回 token"这一句。你需要把注意力从页面状态切到服务端边界:请求是不是来自真实用户,凭证有没有被篡改,账号是否仍然启用,角色是否满足接口规则,业务数据归属是否以后端当前用户为准。前端可以决定按钮显不显示,但不能决定接口让不让过;前端可以保存登录态,但不能替代后端校验登录态;前端可以展示角色,但不能通过提交角色字段让自己升级为管理员。
以后你设计任何需要登录的接口,都可以按下面的问题自检:这个接口是否应该允许匿名访问?如果必须登录,它匹配的是 /api/** 还是更严格的 /api/admin/**?业务方法里有没有从请求体读取 userId 这种不可信字段?如果用户被停用,旧 token 是否还会被拒绝?如果普通用户直接用 curl 调后台接口,后端是否返回 403?这些问题比"代码能不能跑通"更接近后端工程师的安全思维。
12. 本篇总结
本篇你需要带走 8 个核心结论:
- 前端路由守卫和权限按钮只是体验层,真正安全边界在后端;
- 登录属于认证,判断能否访问某个接口属于授权;
- 401 表示未认证或认证失败,403 表示已认证但权限不足;
- JWT 是有签名和过期时间的凭证,不是加密保险箱,不能放敏感信息;
- 当前项目登录成功后返回
AuthTokenResponse,前端后续请求带Authorization: Bearer <token>; JwtAuthenticationFilter在 Controller 之前执行,会把合法 token 转成SecurityContext里的当前用户;- 当前项目即使 JWT 未过期,也重新查数据库用户状态和角色,避免停用账号继续访问;
- 业务代码需要当前用户时,应从
CurrentUserService/SecurityContext读取,而不是相信前端传来的userId。
如果你能用自己的话把"登录接口签发 token"和"带 token 访问受保护接口"两条链路画出来,再能解释 401 和 403 的区别,就说明你已经迈过了后端认证授权的第一道门槛。