Old-School Backend / 2026 ・ No.11

古法后端入门

第11讲 认证与授权 ── 确认“是谁”,决定“能不能做”

发布

终端画面。ClaimAccessMapper.xml 的 findVisible 在 where id = #{id} 之后,用 if test=!seeAll 加上 and applicant = #{name}。show11.py idor 的结果:arai 可看 103 件,但 naive 里 126 件全部 200;fixed 里 200 为 103 件、404 为 23 件。furui 为 126 件。arai 用 naive 打开 EXP-2026-0104,得到 200,applicant=古井 item=接待費(寿司) amount=48,000;fixed 则是 404 申請が見つかりません
以新井身份登录,把 126 个受理编号全部打开。在只检查登录的窗口里,古井的 23 件申请也能看到。用 where 限定后,只剩自己的 103 件(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

到上一讲为止,应用都相信写在头里的名字。谁都能自称古井,新井也能自己批准自己的申请。
这一讲用 Spring Security 加上登录和会话,用 bcrypt 保存密码,按角色区分操作。然后在自己的练习用应用里,确认只改 URL 里的受理编号就能看到别人申请的 IDOR,并用 MyBatis 的 where 堵上。

一

谁都能自称 furui

新井上一讲最后说的是真的吗?我能批准自己的申请?

古井在头里写上我的名字试试。

全 15 讲路线图(当前:第 11 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
# 新井在 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)是决定“这个人能不能做这件事”。登录是认证,能不能按批准按钮是授权。

谁来决定“是谁”

第 10 讲:把名字写在头里 新井的电脑 X-User: furui 窗口 第 11 讲:只发编号,名字由服务器查 新井的电脑 JSESSIONID=289F… 窗口 以 furui 身份批准 → 200 历史里也记成了 furui 289F… → arai ROLE_APPLICANT 由 Cookie 的编号当作 arai。批准是 403(头里的 furui 被忽略) “是谁”不是发送方写的,而是服务器决定的 每次登录都重新生成编号。登出后,旧编号得到 401

在 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 生成 1 次散列的时间(5 次的中间值,实测) 强度 10 73 ms 强度 12 295 ms 强度 14 1184 ms 1 次登录 81.9 ms 强度每提高 2,约慢 4 倍(10→12 为 4.0 倍,12→14 为 4.0 倍) 故意慢。为了加大从被盗的表中找出原密码的工夫 默认强度 10。登录的 82 ms 大部分是这个校验的时间

开头的 {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,申请人”。

终端画面。show11.py login 的结果:登录前 me 为 401;csrf 时发放 JSESSIONID;密码错误和不存在的用户都是 401;不带口令的登录是 403;登录为 200,JSESSIONID 换成了另一个编号;me 为 200,visibleClaims 103。5 次登录的时间为 99.5、79.9、86.4、83.4、82.0 ms。登出为 204,旧 cookie 访问 me 为 401,不带口令的批准为 403
登录前后。密码错误和不存在的用户是同样的 401。登录后会话编号换新,登出后旧编号不能再用(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 登录前后(新井)
  登录前访问 /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。门卫的设置里写着:批准・退回・驳回只限审批人,支付只限会计。

终端画面。SecurityConfig.java 中 hasRole 的行:approve・return・reject 需要 APPROVER,pay 需要 ACCOUNTING,其余 authenticated。show11.py roles 的表:arai 只有 submit 为 200,其余 403。furui 的 approve・return・reject 为 200,submit 为 404,pay 为 403。keiri 只有 pay 为 200,submit 为 404,其余 403
3 人 × 5 种操作。角色不符的操作由门卫以 403 拦下,提交别人的申请(submit)由负责人返回 404(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 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) 401 登录前访问 /me 有口令(CSRF)吗? 门卫 403 不带口令的批准 角色对吗? 门卫(URL 规则) 403 新井的 approve 能看这份申请吗? 仓库管理员(SQL 的 where) 404 新井打开古井的申请 批准自己的申请? 负责人 403 古井批准自己的申请 状态对吗? 负责人(第 10 讲的表) 409 退回已支付的申请 401 是“你是谁?”,403 是“认识你,但不行”,404 是“不给看” 别人的申请不回 403 而回 404,连“存在”都不告诉

门从上到下越来越细。门卫(Spring Security)在到达窗口之前,拦下“是否登录”“角色是否相符”。“这份申请与这个人的关系”不看数据库就不知道,所以由仓库管理员的 where 和负责人来检查。

POINT

“是谁”由服务器决定,不相信发来的名字。角色交给门卫,数据与人的关系用 where。别人的东西回 404,连存在都不让看到。

九

自己要负责到什么程度

这一讲里,用户表和密码都由应用自己保存。公司内部系统也常用其他方式。

方式适合的场合注意
自建登录+会话(这一讲)小型内部系统,用户少,不接外部机制密码保管、次数限制、忘记密码的流程都要自己负责
OpenID Connect(交给公司的身份平台)员工多,想与其他系统统一登录(SSO),想用多因素认证认证可以交出去,授权(角色以及与数据的关系)仍由应用负责
每次发送令牌(JWT 等)手机应用,被其他服务器调用的 API中途难以撤销,有效期要短

无论选哪种,都防不住 IDOR。认证即使交给外部,授权也是应用的工作。Spring Security 对每种方式都以同样的门卫形式支持。

十

在自己的电脑上验证

想试的事写法
查看浏览器 cookie 的标记开发者工具 → Application → Cookies(HttpOnly・Secure 列)
是否以明文保存密码select username, left(password, 12) from app_user;
找出只按 id 取数据的 selectgrep -n -B2 -A3 'where id = #{id}' src/main/resources/mapper/*.xml
找出相信头里名字的地方grep -rn 'RequestHeader' src/main/java

IDOR 的验证,要在自己的应用里,建两个用户来做。用一个人登录,把另一个人数据的编号写进 URL,看是否返回 404。请不要在别人的服务上尝试。

结 — 一页总结与下一讲

  1. 相信头里的名字,谁都能变成任何人。
  2. 认证是“是谁”,授权是“能不能做”。“是谁”由服务器决定。
  3. 密码用 bcrypt 散列保存,故意放慢(强度 10 为 73 ms)。
  4. 登录时换新编号,登出时丢弃。写操作要带口令。
  5. 角色交给门卫,数据与人的关系用 where。别人的东西回 404。
  6. 认证即使交给外部,授权也是应用的工作。

新井这下我终于真的是我了。

古井下一讲是文件。想给申请附上收据照片:名字、大小、内容、存放位置。文件比你想的更危险。

参考

记录是笔者于 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

← 返回文章列表