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 件だけになります(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)

前回までのアプリは、ヘッダーに書かれた名前を信じていました。だれでも古井さんを名乗れ、新井さんは自分の申請を自分で承認できます。
今回は Spring Security でログインとセッションを入れ、パスワードを bcrypt で持ち、役割で操作を分けます。そして、URL の受付番号を変えるだけでほかの人の申請が見える IDOR を、自分の練習用アプリで確かめ、MyBatis の where で塞ぎます。

一

だれでも furui と名乗れる

新井前回の最後の話、本当ですか。ぼくが自分の申請を承認できるって。

古井ヘッダーに私の名前を書いてごらん。

全15回の路線図(いまここ:第11回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
# 新井さんが、第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)は「その人が、それをしてよいか」を決めること。ログインは認証、承認ボタンを押せるかどうかは認可です。

「だれか」を決めるのはだれか

第10回:名前をヘッダーに書く 新井の PC X-User: furui 窓口 第11回:番号だけを送り、名前はサーバーが引く 新井の PC JSESSIONID=289F… 窓口 furui として承認 → 200 履歴にも furui と残った 289F… → arai ROLE_APPLICANT Cookie の番号から arai として扱う。承認は 403(ヘッダーの furui は無視) 「だれか」は、送る側が書くものではなく、サーバーが決めるもの 番号はログインのたびに作りなおす。ログアウトすると、古い番号は 401

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 でハッシュを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つずつ試す手間を大きくするためです。ログイン1回の 82 ms ほどは、ほとんどこの時間でした。

# おまけ:MyBatis の DEBUG ログに、ハッシュがそのまま出ていた
==> Parameters: arai(String), {bcrypt}$2a$10$bgTjGTKBB.NSs5Ba8…

ひとつ気づいたことがあります。第9回から入れている MyBatis の SQL ログ(DEBUG)は、パラメーターをそのまま出すので、利用者を入れたときのハッシュがログに出ていました。ハッシュでも、ログに出してよいものではありません。ログに何を出すかは第13回で扱います。

四

ログインとセッション

ログインに成功すると、サーバーは「セッション」を作り、その番号をクッキー(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、古いクッキーの me は 401、合言葉なしの承認は 403
ログインの前後。パスワードちがいと、いない利用者は同じ 401。ログインするとセッションの番号が新しくなり、ログアウトすると古い番号は使えなくなりました(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# ログインの前後(新井さん)
  ログイン前に /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。承認・差戻し・却下は承認者だけ、支払いは経理だけ、と門番の設定に書いてあります。

端末の画面。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 にしました(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 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)は「ログインしているか」「役割は合うか」までを、窓口に届く前に止めます。「その申請とその人の関係」は、DB を見ないと分からないので、倉庫番の where と担当者で確かめます。

POINT

「だれか」はサーバーが決める。送られてきた名前は信じない。役割は門番で、そのデータとその人の関係は 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 が返るかを見ます。ほかの人のサービスで試すのは、やめてください。

結 — 一枚まとめと、次回

  1. ヘッダーの名前を信じると、だれでもだれにでもなれる。
  2. 認証は「だれか」、認可は「してよいか」。だれかはサーバーが決める。
  3. パスワードは bcrypt のハッシュで。わざと遅い(強さ 10 で 73 ms)。
  4. ログインで番号を新しく、ログアウトで捨てる。書きこみには合言葉。
  5. 役割は門番で。データとその人の関係は where で。ほかの人のものは 404。
  6. 認証を外に任せても、認可はアプリの仕事。

新井これで、ぼくは本当にぼくになりました。

古井次回はファイルだ。領収書の写真を申請に付けたい。名前、大きさ、中身、置き場所。ファイルは、思っているより危ない。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ