Old-School Backend / 2026 ・ No.11
いにしえのバックエンド入門
第11回 認証と認可 ── 「だれか」を確かめ、「してよいか」を決める
公開
前回までのアプリは、ヘッダーに書かれた名前を信じていました。だれでも古井さんを名乗れ、新井さんは自分の申請を自分で承認できます。
今回は Spring Security でログインとセッションを入れ、パスワードを bcrypt で持ち、役割で操作を分けます。そして、URL の受付番号を変えるだけでほかの人の申請が見える IDOR を、自分の練習用アプリで確かめ、MyBatis の where で塞ぎます。
だれでも furui と名乗れる
新井前回の最後の話、本当ですか。ぼくが自分の申請を承認できるって。
古井ヘッダーに私の名前を書いてごらん。
全15回の路線図(いまここ:第11回)
# 新井さんが、第10回の窓口に X-User: furui と書いて、自分の申請(EXP-2026-5010)の承認を送る POST /api/v10/claims/EXP-2026-5010/approve X-User: furui → 200 status=approved (applicant=新井) # 履歴 submitted→approved furui
通りました。履歴には「古井さんが承認した」と残っています。窓口は X-User に書かれた名前をそのまま信じていたので、ヘッダーを1行書けば、だれにでもなれます。第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 では、門番の設定を1か所に書きます。どの窓口を守るか、ログインはどこで受けるか、どの操作にどの役割が要るか、です。
# 門番の設定(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
}
パスワードはハッシュで
利用者の表には、パスワードそのものではなく、ハッシュを入れます。ハッシュは元に戻せない変換で、ログインのときは届いたパスワードを同じように変換して比べます。
# 利用者の表(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つずつ試す手間を大きくするためです。ログイン1回の 82 ms ほどは、ほとんどこの時間でした。
# おまけ:MyBatis の DEBUG ログに、ハッシュがそのまま出ていた
==> Parameters: arai(String), {bcrypt}$2a$10$bgTjGTKBB.NSs5Ba8…
ひとつ気づいたことがあります。第9回から入れている MyBatis の SQL ログ(DEBUG)は、パラメーターをそのまま出すので、利用者を入れたときのハッシュがログに出ていました。ハッシュでも、ログに出してよいものではありません。ログに何を出すかは第13回で扱います。
ログインとセッション
ログインに成功すると、サーバーは「セッション」を作り、その番号をクッキー(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}
# ログアウトのあと、古いクッキーで/合言葉なしで承認
/me 200
ログアウト 204
古いクッキーで /me 401
合言葉なしで承認(古井) 403
- 同じ 401 ── パスワードちがいと、いない利用者は、同じ返事。「その人はいる」と教えない
- 番号が変わる ── ログインの前にもらった番号は、ログインで別の番号になった。前もって番号を渡しておく手口(セッション固定)を防ぐ
- HttpOnly ── 画面の JavaScript からクッキーを読めない印。本番では HTTPS だけで送る Secure も付ける(第15回)
- 合言葉(CSRF トークン) ── 書きこみには、画面が先にもらった合言葉が要る。ほかのサイトから勝手に送られた POST は 403
- ログアウト ── セッションを捨てるので、古い番号は 401
パスワードを何度でも試せるのは困るので、本番では回数の制限や一時的なロックも入れます。この回では扱いません。
役割で分ける
利用者には役割を1つ付けました。新井さんは申請者(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)は「ログインしているか」「役割は合うか」までを、窓口に届く前に止めます。「その申請とその人の関係」は、DB を見ないと分からないので、倉庫番の where と担当者で確かめます。
「だれか」はサーバーが決める。送られてきた名前は信じない。役割は門番で、そのデータとその人の関係は where で。ほかの人のものは 404 で、あることさえ見せない。
どこまで自前で持つか
この回は、利用者の表もパスワードも、アプリが自分で持ちました。社内のシステムでは、ほかのやり方もよく使われます。
| やり方 | 向いている場面 | 気をつけること |
|---|---|---|
| 自前のログイン+セッション(この回) | 小さな社内システム、利用者が少ない、外の仕組みにつなげない | パスワードの保管、回数制限、パスワードを忘れたときの手順まで自分で持つ |
| OpenID Connect(会社の ID 基盤に任せる) | 社員が多い、ほかのシステムと同じログインにしたい(SSO)、多要素認証を使いたい | 認証は任せても、認可(役割と、データとの関係)はアプリが持つ |
| トークン(JWT など)を毎回送る | スマホのアプリ、ほかのサーバーから呼ばれる API | 途中で取り消しにくい。有効期限を短くする |
どれを選んでも、IDOR は防げません。認証を外に任せても、認可はアプリの仕事です。Spring Security は、どのやり方にも同じ門番の形で対応しています。
自分の PC で確かめる
| 試したいこと | 書き方 |
|---|---|
| ブラウザのクッキーの印を見る | 開発者ツール → 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 の確かめは、自分のアプリで、2人の利用者を作って行います。1人でログインし、もう1人のデータの番号を 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)
記録は、筆者がクラウド上の 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 を使って 2026年10月31日に取ったものです。申請サーバーと DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)で、往復は 0.04 ms ほどです。IDOR の確かめは、筆者の練習用アプリと練習用 DB だけで行いました。パスワードとハッシュは練習用のもので、ハッシュは途中で切って載せています。セッションの番号は撮影のときのもので、いまは使えません。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第11回 — 認証と認可 / 2026.10