Old-School Backend / 2026 ・ No.11
古法后端入门
第11讲 认证与授权 ── 确认“是谁”,决定“能不能做”
发布
到上一讲为止,应用都相信写在头里的名字。谁都能自称古井,新井也能自己批准自己的申请。
这一讲用 Spring Security 加上登录和会话,用 bcrypt 保存密码,按角色区分操作。然后在自己的练习用应用里,确认只改 URL 里的受理编号就能看到别人申请的 IDOR,并用 MyBatis 的 where 堵上。
谁都能自称 furui
新井上一讲最后说的是真的吗?我能批准自己的申请?
古井在头里写上我的名字试试。
全 15 讲路线图(当前:第 11 讲)
# 新井在 X-User 写上 furui,向第 10 讲的窗口发送自己申请(EXP-2026-5010)的批准 POST /api/v10/claims/EXP-2026-5010/approve X-User: furui → 200 status=approved (applicant=新井) # 历史 submitted→approved furui
通过了。历史里记着“古井批准了”。窗口原样相信 X-User 里写的名字,所以只要写一行头,就能变成任何人。第 10 讲把状态守得再严,“是谁”是假的,就毫无意义。
- 登录与会话 ── 不发名字,只发编号,名字由服务器查(实测)
- 密码 ── 用 bcrypt,1 次 73 ms,故意放慢(实测)
- 角色 ── 3 人 × 5 种操作,不符的为 403(实测)
- IDOR ── 只改受理编号,就看到了别人的 23 件申请(实测)
应用里加入了 Spring Security(7.1.2)。只守护第 11 讲的窗口(/api/v11/**),以前的窗口为了对比而保留。
# 工具配置(与第 10 讲相比) 第 10 讲: jar 26,316,783 字节、库 52 第 11 讲: jar 30,270,061 字节、库 57、启动 4.134 s / 4.45 s / 4.805 s
认证与授权
两个词很像,却是不同的工作。认证(authentication)是确认“你是谁”;授权(authorization)是决定“这个人能不能做这件事”。登录是认证,能不能按批准按钮是授权。
谁来决定“是谁”
在 Spring Security 里,门卫的设置写在一个地方:守哪些窗口、在哪里接收登录、哪些操作需要哪个角色。
# 门卫的设置(SecurityConfig.java)
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/v11/**", "/api/login", "/api/logout", "/api/csrf") // 只守护第 11 讲的窗口
.authorizeHttpRequests(a -> a
.requestMatchers("/api/csrf", "/api/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v11/claims/*/approve", "/api/v11/claims/*/return",
"/api/v11/claims/*/reject").hasRole("APPROVER") // 批准・退回・驳回只限审批人
.requestMatchers(HttpMethod.POST, "/api/v11/claims/*/pay").hasRole("ACCOUNTING") // 支付只限会计
.anyRequest().authenticated()) // 其余只要登录即可
.formLogin(f -> f.loginProcessingUrl("/api/login")
.successHandler((req, res, auth) -> res.setStatus(200))
.failureHandler((req, res, e) -> res.sendError(401)))
.logout(l -> l.logoutUrl("/api/logout").logoutSuccessHandler((req, res, auth) -> res.setStatus(204)))
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // 默认是 bcrypt
}
密码用散列保存
用户表里放的不是密码本身,而是散列(hash)。散列是无法还原的变换,登录时把收到的密码同样变换后比较。
# 用户表(setup11.sql)及其内容
-- 第 11 讲:用户(password 是 bcrypt 散列,由应用的 --demo.seed=true 写入)
create table if not exists app_user (
username varchar(30) primary key,
password varchar(100) not null,
display_name varchar(50) not null,
role varchar(20) not null check (role in ('APPLICANT', 'APPROVER', 'ACCOUNTING'))
);
arai | 新井 | APPLICANT | {bcrypt}$2a$10$bgTjGTKBB.NSs5B…
furui | 古井 | APPROVER | {bcrypt}$2a$10$OnboLx4LUMQCyGC…
keiri | 経理 | ACCOUNTING | {bcrypt}$2a$10$MdHuG1f5v0oAEy1…
# 用 bcrypt 生成 1 次散列的时间(强度 10・12・14,各 5 次)
strength 10 : [73, 73, 73, 73, 73] ms
strength 12 : [307, 294, 295, 293, 299] ms
strength 14 : [1192, 1184, 1180, 1177, 1198] ms
# 1 次登录(POST /api/login)的时间,5 次
83.7 ms, 80.0 ms, 81.9 ms, 81.5 ms, 82.5 ms
bcrypt 的分量(实测)
开头的 {bcrypt} 是所用方法的标记,$10$ 是强度。bcrypt 每次都混入不同的“盐”(salt),所以同样的密码,第 1 次写入是 $2a$10$2MItAV…,重新写入的第 2 次是 $2a$10$bgTjG…,散列变了。
而且 bcrypt 故意很慢。强度 10 时 1 次 73 ms,14 则要 1.2 秒。这样即使表被盗,逐个尝试密码的工夫也很大。1 次登录约 82 ms,几乎都是这段时间。
# 附带:MyBatis 的 DEBUG 日志里原样出现了散列
==> Parameters: arai(String), {bcrypt}$2a$10$bgTjGTKBB.NSs5Ba8…
还发现了一件事。从第 9 讲开始加的 MyBatis SQL 日志(DEBUG)会原样输出参数,所以写入用户时的散列出现在了日志里。即使是散列,也不该出现在日志中。日志里该输出什么,第 13 讲再讲。
登录与会话
登录成功后,服务器创建“会话”,把它的编号用 cookie(JSESSIONID)返回。之后浏览器只发编号,服务器从编号查出“arai,申请人”。
# 登录前后(新井)
登录前访问 /me 401
取得口令 Set-Cookie: JSESSIONID=1ECAF68D334E76EA7DC2E47C9A5CAA69; Path=/; HttpOnly
密码错误 401
不存在的用户 401
不带口令登录 403
登录 200 Set-Cookie: JSESSIONID=289FD9B72BBF0250883F5AFFF5AE46CF; Path=/; HttpOnly
/me 200 {"displayName": "新井", "username": "arai", "roles": ["ROLE_APPLICANT"], "visibleClaims": 103}
# 登出后用旧 cookie/不带口令批准
/me 200
登出 204
用旧 cookie 访问 /me 401
不带口令批准(古井) 403
- 同样的 401 ── 密码错误和不存在的用户,回复相同。不告诉对方“这个人存在”
- 编号会变 ── 登录前拿到的编号,登录后换成了别的编号。防止事先塞给对方编号的手法(会话固定)
- HttpOnly ── 画面上的 JavaScript 读不到 cookie 的标记。生产环境还要加只通过 HTTPS 发送的 Secure(第 15 讲)
- 口令(CSRF 令牌) ── 写操作需要画面事先取得的口令。从其他网站擅自发来的 POST 为 403
- 登出 ── 丢弃会话,旧编号为 401
密码能无限次尝试可不行,生产环境还要加次数限制或临时锁定。这一讲不涉及。
按角色区分
给每个用户一个角色:新井是申请人(APPLICANT),古井是审批人(APPROVER),会计是 ACCOUNTING。门卫的设置里写着:批准・退回・驳回只限审批人,支付只限会计。
# 3 人 × 5 种操作(每次重设为可操作的状态) 用户 \ 操作 submit approve return reject pay arai 200 submitted 403 403 403 403 furui 404 200 approved 200 returned 200 rejected 403 keiri 404 403 403 403 200 paid
角色不符的操作,在到达窗口之前就被门卫以 403 拦下。古井和会计的 submit 是 404,因为负责人检查了“提交新井的申请只能由本人进行”(见下一章)。
IDOR:改编号就能看到
检查了登录,还有一个大洞。打开申请的 URL 是 /api/v11/claims/EXP-2026-0101。把编号依次改成 0102、0103、0104……会怎样?
# 把 126 个受理编号全部打开(naive=只检查登录,fixed=用 where 限定) arai 可看 103 / 126 naive: 200×126 fixed: 200×103 404×23 furui 可看 126 / 126 naive: 200×126 fixed: 200×126 404×0 arai GET /api/v11/naive/claims/EXP-2026-0104 → 200 applicant=古井 item=接待費(寿司) amount=48,000 arai GET /api/v11/claims/EXP-2026-0104 → 404 申請が見つかりません
在只检查登录的窗口(naive)里,新井能看到全部 126 件,包括古井 48,000 日元的招待费(寿司)。只改 URL 的编号就能碰到别人东西的漏洞,叫 IDOR(Insecure Direct Object Reference)。在 OWASP Top 10(2021 年版)中,包含它的“访问控制失效”排在第 1 位。
# 只取可以看的申请(ClaimAccessMapper.xml 与 SecureClaimService.java)
<select id="findVisible" resultType="example.expenses.v10.ClaimState">
select id, applicant, item, amount, status, version from expense
where id = #{id}
<if test="!seeAll">and applicant = #{name}</if>
</select>
public ClaimState find(String id, LoginUser u) { // 不能看就和“不存在”一样,404
ClaimState c = claims.findVisible(id, u.displayName(), seesAll(u));
if (c == null) throw new ExpenseNotFoundException(id);
return c;
}
解决办法是在仓库管理员的 where 里只取“可以看的”。申请人只能看自己的申请,审批人和会计能看全部。用 MyBatis 的 <if> 加上条件。不能看的申请不回 403,而是回与“不存在”相同的 404,因为 403 会告诉对方“这个编号的申请存在”。
新井画面上只显示自己的申请,我还以为没问题。
古井画面是显示上的安排,窗口才是约定。URL 谁都能手写。
不批准自己的申请
即使角色是审批人,也不能自己批准自己的申请。这不是由角色决定,而是由“这份申请与这个人的关系”决定,所以写不进门卫的 URL 规则,要在负责人那里检查。
# 确认可以碰之后,再交给第 10 讲的负责人
public ClaimState act(String id, Action action, LoginUser u) {
ClaimState c = find(id, u);
if (action == Action.SUBMIT && !c.applicant().equals(u.displayName())) throw new ExpenseNotFoundException(id);
if (action == Action.APPROVE && c.applicant().equals(u.displayName())) throw new SelfApprovalException(id); // 自己的申请
return flow.act(id, action, u.getUsername()); // 历史里记录的是登录者的名字
}
# 古井批准自己的申请和新井的申请
furui POST /api/v11/claims/EXP-2026-5101/approve (applicant=古井) → 403 自分の申請は承認できません
history: (无)
furui POST /api/v11/claims/EXP-2026-5011/approve (applicant=新井) → 200 approved
history: submitted→approved furui
# 已登录的新井在头里写 X-User: furui 发送批准
→ 403 Forbidden DB status=submitted history=0
古井不能批准自己的申请(5101),得到 403;新井的申请(5011)是 200,历史里记着 furui。这里记下的名字已经不是头里的,而是登录的人。已登录的新井即使在头里写 furui,门卫也把他当作新井,结果是 403。
401・403・404
请求要通过的门(实测)
门从上到下越来越细。门卫(Spring Security)在到达窗口之前,拦下“是否登录”“角色是否相符”。“这份申请与这个人的关系”不看数据库就不知道,所以由仓库管理员的 where 和负责人来检查。
“是谁”由服务器决定,不相信发来的名字。角色交给门卫,数据与人的关系用 where。别人的东西回 404,连存在都不让看到。
自己要负责到什么程度
这一讲里,用户表和密码都由应用自己保存。公司内部系统也常用其他方式。
| 方式 | 适合的场合 | 注意 |
|---|---|---|
| 自建登录+会话(这一讲) | 小型内部系统,用户少,不接外部机制 | 密码保管、次数限制、忘记密码的流程都要自己负责 |
| OpenID Connect(交给公司的身份平台) | 员工多,想与其他系统统一登录(SSO),想用多因素认证 | 认证可以交出去,授权(角色以及与数据的关系)仍由应用负责 |
| 每次发送令牌(JWT 等) | 手机应用,被其他服务器调用的 API | 中途难以撤销,有效期要短 |
无论选哪种,都防不住 IDOR。认证即使交给外部,授权也是应用的工作。Spring Security 对每种方式都以同样的门卫形式支持。
在自己的电脑上验证
| 想试的事 | 写法 |
|---|---|
| 查看浏览器 cookie 的标记 | 开发者工具 → Application → Cookies(HttpOnly・Secure 列) |
| 是否以明文保存密码 | select username, left(password, 12) from app_user; |
| 找出只按 id 取数据的 select | grep -n -B2 -A3 'where id = #{id}' src/main/resources/mapper/*.xml |
| 找出相信头里名字的地方 | grep -rn 'RequestHeader' src/main/java |
IDOR 的验证,要在自己的应用里,建两个用户来做。用一个人登录,把另一个人数据的编号写进 URL,看是否返回 404。请不要在别人的服务上尝试。
结 — 一页总结与下一讲
- 相信头里的名字,谁都能变成任何人。
- 认证是“是谁”,授权是“能不能做”。“是谁”由服务器决定。
- 密码用 bcrypt 散列保存,故意放慢(强度 10 为 73 ms)。
- 登录时换新编号,登出时丢弃。写操作要带口令。
- 角色交给门卫,数据与人的关系用 where。别人的东西回 404。
- 认证即使交给外部,授权也是应用的工作。
新井这下我终于真的是我了。
古井下一讲是文件。想给申请附上收据照片:名字、大小、内容、存放位置。文件比你想的更危险。
参考
- Spring Security — Reference
- Spring Security — Session Management
- Spring Security — Cross Site Request Forgery (CSRF)
- OWASP — Password Storage Cheat Sheet
- OWASP — Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP Top 10:2021 — A01 Broken Access Control
- MyBatis 3 — Dynamic SQL(if)
记录是笔者于 2026 年 10 月 31 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Spring Framework 7.0.10、Spring Security 7.1.2、MyBatis 3.5.19、mybatis-spring-boot 4.1.0)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。申请服务器与数据库服务器是在一台机器里建的不同网络空间(network namespace),往返约 0.04 ms。IDOR 的验证只在笔者的练习用应用和练习用数据库上进行。密码和散列都是练习用的,散列只截取了前半部分。会话编号是截图时的,现在已不能使用。文中的数字和输出,都是从当时命令输出里读到的实际值。登场人物均为虚构。
古法后端入门 第11讲 — 认证与授权 / 2026.10