コンテンツにスキップ

Claude CodeでWebアプリに管理画面を追加してBasic認証・トークン認証でアクセス制限する

このハンズオンはGit連携版またはGitHub Actions版(アーカイブ)のD1ハンズオンの続編。前回作った匿名一行掲示板に、管理者だけが使える管理画面を追加する。アクセス制限は「合言葉をひとつ決めて、知っている人だけが入れる」という最も手軽な方式で実現する。

DBを使うWebアプリには、DB管理という問題が付きまとう。掲示板なら「不適切な投稿を削除したい」「スパムを非表示にしたい」といった操作が必要になる。そのたびに SQL を直接叩くのは手間だし、ミスのリスクもある。

こうした管理操作はWebアプリの中に管理画面として用意しておくのが定石。必要な操作をボタンひとつでできるようにしておけば、後の運用がぐっと楽になる。

ただし、管理画面は誰でもアクセスできてはまずい。一般ユーザーが投稿を操作できてしまう。特定の人(管理者)だけが使えるように制限する必要がある。

ということで、このハンズオンでは、前回作った掲示板に以下を追加する。

  • 管理画面/admin):投稿の表示/非表示を切り替えられる
  • アクセス制限:Basic認証とトークン認証の2方式を順に試す

掲示板

前回作った掲示板

管理画面

今回作る管理画面

1-2. 合言葉ひとつで守る——単一共有シークレット認証

Section titled “1-2. 合言葉ひとつで守る——単一共有シークレット認証”

今回使う2つの方式は、見た目こそ違うが本質は同じ。サーバーに合言葉をひとつ置いておき、リクエストに付いてきた値と一致するかを照合するだけ。ユーザー登録もメール認証も不要で、個人を識別することもない。合言葉を知っていれば誰でも入れるし、漏れたら合言葉を変えるしかない。

  • Basic認証:ブラウザに昔から組み込まれている標準の仕組み。実装は最小で済むが、ログイン画面はブラウザ固定のダイアログで、ログアウトも事実上できない
  • トークン認証:合言葉(トークン)の照合を自前のコードで行う方式。ログイン画面を自分でデザインでき、ログアウトボタンも作れて、パスワードマネージャにもきれいに対応できる

防御の強さはどちらも同じで、「合言葉が推測不能であること」がすべて。違いは使い勝手と、守れる範囲(後述)にある。まず方法1(Basic認証)で動かし、その足りないところを方法2(トークン認証)で解消する、という流れで進める。方法1で十分ならそこで止めてもよい。

なお「管理者をメールアドレス単位で制限したい」「Google アカウントの2段階認証で守りたい」という場合は、合言葉方式の守備範囲を超える。その場合は次のステップとして Cloudflare Access を使うハンズオンがあり、最後の章で改めて案内する。

このハンズオンでやることの全体像。

  1. D1 スキーマ変更(Claude Code)
    • posts テーブルに hidden カラムを追加
  2. バックエンド追加(Claude Code)
    • 管理用 API を追加、公開側 API で hidden 投稿を除外
  3. フロントエンド追加(Claude Code)
    • /admin ページを作成
  4. 方法1:Basic認証で守る
    • 実装 → デプロイ → ADMIN_PASSWORD 設定 → 動作確認
  5. 方法2:トークン認証で守る
    • 実装 → デプロイ → ADMIN_TOKEN 設定 → 動作確認
  6. どの方式を選ぶか

ブラウザでの手作業は環境変数の設定だけ。実装はすべて Claude Code にプロンプトで依頼できる。

前提:Claude Codeの作業フォルダは前回のハンズオン(Git連携版/GitHub Actions版)で作った ~/Desktop/claude/my-bbs

2. 【データベース】スキーマ変更

Section titled “2. 【データベース】スキーマ変更”

「投稿を非表示にする」機能を実装するには、投稿ごとの「表示/非表示」状態をDBに保存する必要がある。Claude Code に「投稿を非表示にできる管理画面を作りたい」と相談すると、hidden カラムの追加が提案される。ここではその手順を先に進める。

マイグレーションファイルの作成を Claude Code に依頼する。

Claude
postsテーブルにhiddenカラム(INTEGER型、デフォルト0)を追加するマイグレーションファイルを作成して。ファイル名はmigrations/0002_add_hidden.sqlで

作成されたファイルを確認する。内容はこのようになっているはず。

ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;

マイグレーションファイルは wrangler.jsoncmigrations_dir で指定したフォルダに置く。GitHub Actions 方式なら push 時に自動適用される。Git連携方式なら push 前に npx wrangler d1 migrations apply my-bbs-db --remote を手元から実行する。

3. 【バックエンド】管理画面用 API の追加

Section titled “3. 【バックエンド】管理画面用 API の追加”

管理操作のAPIは /api/admin/ 配下に置く。後の章で /api/admin/ 配下をまとめてアクセス制限するため、公開側API(/api/posts)と区別しておくのがポイント。

Claude Code に依頼する。

Claude
以下の3点をfunctions/api/配下に追加・修正して。
1. GET /api/admin/posts を追加する。hiddenの値にかかわらず全投稿を返す
2. PATCH /api/admin/posts/:id を追加する。リクエストボディのhiddenフィールド(0か1)でpostsテーブルのhiddenカラムを更新する
3. GET /api/posts でhidden=0の投稿だけを返すようにする

公開側の /api/posts からは非表示投稿が消え、管理用の /api/admin/posts では全投稿(非表示も含む)が見える、という分担になる。

4. 【フロントエンド】管理画面の作成

Section titled “4. 【フロントエンド】管理画面の作成”

Claude Code に依頼する。

Claude
管理画面(public/admin/index.html)を作って。
GET /api/admin/posts で全投稿を一覧表示して、各投稿の表示/非表示を切り替えられるようにしたい。
一覧はテーブルで、各行にボタンをつけて。非表示中の投稿はグレーアウトして。シンプルなデザインで。

これで管理画面はできたが、今はまだ誰でも操作できる状態。このまま公開してはいけない。次の章でアクセス制限をかけてからデプロイする。

ブラウザが表示するBasic認証のログインダイアログ

この章のゴール:ブラウザが出す Basic 認証のログインダイアログ(ユーザー名 admin + パスワード)

Basic認証はブラウザに標準で備わっている認証の仕組み。保護されたページを開くと、ブラウザがユーザー名とパスワードを尋ねるダイアログを出す。入力した値はリクエストのたびにヘッダに載ってサーバーへ送られ、サーバー側で照合される。

sequenceDiagram
    participant B as ブラウザ
    participant CF as Cloudflare Pages
    B->>CF: /admin にアクセス
    CF-->>B: 401(認証してね)
    Note over B: ユーザー名とパスワードの<br>ダイアログを表示・入力
    B->>CF: 同じURLに再リクエスト<br>(ユーザー名とパスワードをヘッダに載せる)
    Note over CF: ADMIN_PASSWORD と照合
    alt 一致
        CF-->>B: HTML/JS を返す(ここで初めて画面が見える)
    else 不一致
        CF-->>B: 401(ダイアログを再表示)
    end

ブラウザが全部やってくれるので、HTML側の実装はゼロ。サーバー側も「ヘッダの値を環境変数と比べる」だけで済む。最小の手間でページもAPIもまとめて守れる。

Claude Code に依頼する。

Claude
管理画面(/admin 配下)と管理API(/api/admin 配下)へのアクセスに Basic 認証をかけて。パスワードは環境変数 ADMIN_PASSWORD から読んで。ユーザー名は admin で固定でよい。

変更をコミット・push する。

Claude
今回の変更をコミットしてpushして

GitHub の Actions タブ、または Cloudflare ダッシュボードの Deployments で進行状況を確認する。

この時点で /admin を開くと、認証ダイアログが出るがまだ入れない。サーバー側にパスワード(環境変数)を設定していないため。次へ進む。

Cloudflare ダッシュボードWorkers & Pages → プロジェクト名 → SettingsVariables and Secrets を開く。

Add をクリックして以下を入力し、Save をクリック。Type は Secret を選んでおくと、後から値が画面に表示されなくなる。

Variable nameValue
ADMIN_PASSWORD任意のパスワード

設定後、デプロイし直す(push するか、ダッシュボードから Retry deployment)と環境変数が反映される。

本番 URL の /admin にアクセスすると、ブラウザの認証ダイアログが表示される。ユーザー名 admin と設定したパスワードを入力すると管理画面に入れる。表示/非表示の切り替えを試し、公開側のページから非表示投稿が消えることも確認する。

APIが守られていることも確認する。ブラウザのシークレットウィンドウで https://掲示板のURL/api/admin/posts を直接開き、認証を求められる(キャンセルすると401になる)ことを確認する。

ここまでで管理画面は守られた。ただ、しばらく使うと不便も見えてくる。ログアウトのボタンがどこにもない(ブラウザを完全に終了するまで認証されたまま)し、ログイン画面の見た目も変えられない。この不便を解消するのが次の方法2。方法1のままで困らなければ、7章まで飛ばしてよい。

6. 【方法2】トークン認証で守る

Section titled “6. 【方法2】トークン認証で守る”

管理画面の自前ログイン画面(ユーザー名は固定、管理トークンを入力)

この章のゴール:自前で用意したトークン認証のログイン画面(管理トークンを入力)

6-1. 仕組み:ページは公開、APIを守る

Section titled “6-1. 仕組み:ページは公開、APIを守る”

Basic認証は「ページもAPIも入り口ごと守る」方式だった。トークン認証では発想を変えて、ページ(HTML)は誰でも見られるままにし、データを読み書きするAPIだけを守る

sequenceDiagram
    participant B as ブラウザ
    participant CF as Cloudflare Pages
    B->>CF: /admin にアクセス
    CF-->>B: HTML/JS を返す<br>(誰でも取得できる。ガワだけでデータは入っていない)
    Note over B: ログインフォームを表示<br>トークンを入力
    B->>CF: fetch /api/admin/…<br>(x-admin-token ヘッダ)
    Note over CF: _middleware.js が<br>ヘッダのトークンを照合
    alt トークンが一致
        CF-->>B: 処理を実行して結果を返す
    else 不一致
        CF-->>B: 401
    end

「管理画面のHTMLが見えてしまっていいのか?」と不安になるかもしれない。認証されていない人がソースを読んで分かるのは、ここまで。

  • 管理画面にどんな操作があるか(投稿の表示/非表示を切り替えられる、など)
  • APIの構成/api/admin/posts といったエンドポイント)
  • 認証の仕組みx-admin-token ヘッダで照合していること)

逆に、投稿データの中身は保護されたAPIからしか出ないので一切見えないし、操作もできない。トークンの値もHTMLのどこにも埋まっていない(サーバーの環境変数にしかない)。いわば「金庫の設計図は見られるが、中身も鍵も手に入らない」状態。仕組みが全部バレてもトークンが推測不能であれば破れない、つまり守りを「仕組みを隠すこと」に依存させない、というのは認証設計の健全な姿勢でもある。

ただし、ガワ自体に秘密がある場合はこの方式は不向き。未公開機能の名前がボタンに書いてある、内部用語や運用ルールがUIの文言に出ている、など「画面の構造すら見せたくない」要件があるなら、ページごと守れるBasic認証(方法1)や Cloudflare Access を選ぶ。7章の比較表にある「守れる範囲」の違いは、これを指している。

Basic認証を外し、ミドルウェア(/api/admin/ 配下の全リクエストを検問する仕組み)とログイン画面に置き換える。Claude Code に依頼する。

Claude
さっき実装したBasic認証を削除して。
管理画面をトークン認証で実装する。
1. functions/api/admin/_middleware.js を作成して。リクエストヘッダ x-admin-token が環境変数 ADMIN_TOKEN と一致しなければ 401 を返す。トークンをURLクエリで受け取る仕組みは作らないで
2. public/admin/index.html にログイン画面を追加して。要件は以下。
- 未ログイン時はログインフォームだけを表示する。form要素で作る
- ユーザー名欄は値 my-bbs-site-admin の readonly テキスト欄(autocomplete="username")
- トークン入力欄は type="password"(autocomplete="current-password")
- 送信ボタンは type="submit"
- 送信時に GET /api/admin/posts を x-admin-token ヘッダ付きで呼び、成功したらトークンを sessionStorage に保存して location.reload() する。失敗したら「トークンが違います」と表示する
- ページ読み込み時に sessionStorage にトークンがあれば同様に検証し、有効なら管理画面を表示する
- 管理画面にはログアウトボタンを置く。sessionStorage からトークンを削除して再読み込みする
- 管理APIへのfetchにはすべて x-admin-token ヘッダを付ける

方法1を飛ばしてトークン認証から始める場合は、プロンプトの1行目(Basic認証の削除)を消して実行すればよい。

プロンプトの要件が細かいのには理由がある。後半の要件はほぼすべてパスワードマネージャ対応のため。

  • form要素+type=submit+成功後の location.reload():パスワードマネージャ(Chrome内蔵・iCloudキーチェーン・1Password等)が「ログインフォームが送信されて成功した」と認識して保存を提案するための条件。ボタンのクリックイベントで処理するだけの作りだと、保存提案が出ないことがある
  • autocomplete 属性:どの欄がユーザー名でどの欄がパスワード(トークン)かをパスワードマネージャに伝えるラベル
  • ユーザー名欄が my-bbs-site-admin の readonly 欄:パスワードマネージャは「サイト+ユーザー名」の組で記憶する。ユーザー名なしでも保存できるものはある(Chrome なら「ユーザー名が指定されていません」というエントリになる)が、どのアカウントの合言葉か分からなくなるし、後からフォームを直しても保存済みエントリは自動では直らない。最初から名前付きで保存させるのが確実。値を admin のようなありふれた語にしないのは、同じサイトの別アカウントと衝突して上書きし合うのを避けるため
  • sessionStorage:保存したトークンはタブを閉じると消える。「ブラウザに長く残さない」と「再入力はパスワードマネージャのオートフィルに任せる」の組み合わせで、安全と手軽さのバランスを取っている

トークンには推測不能なランダム文字列を使う。動作確認の段階なら test123 のような簡単な値でも動くが、こういう仮の合言葉は本番移行のときに変え忘れがち。「後でちゃんとしたものに変えよう」は高確率で忘れるので、最初からランダム文字列にしておくのが安全。ターミナルで生成できる。

Terminal window
node -e "console.log(crypto.randomUUID())"

表示された値(例:f47ac10b-58cc-4372-a567-0e02b2c3d479)をコピーしておく。

変更をコミット・push する。

Claude
今回の変更をコミットしてpushして

デプロイが終わったら、5-4 と同じ手順で Variables and Secrets に環境変数を追加する。

Variable nameValue
ADMIN_TOKEN生成したランダム文字列

方法1で設定した ADMIN_PASSWORD はもう使わないので削除してよい。設定後、Retry deployment(または push)で反映する。

本番 URL の /admin にアクセスすると、今度はブラウザのダイアログではなく自前のログイン画面が表示される。トークンを入力してログインすると管理画面に入れる。このときパスワードマネージャが「保存しますか?」と提案してくるので保存しておく。次回からはオートフィルで一発ログインできる。

ほかも一通り確認する。

  • 間違ったトークンでは「トークンが違います」と表示されて入れない
  • シークレットウィンドウで https://掲示板のURL/api/admin/posts を直接開くと 401 が返る
  • ログアウトボタンでログイン画面に戻る(Basic認証ではできなかったこと)
  • タブを閉じて開き直すとログイン画面に戻る(sessionStorage が消えるため)

今回の2方式に、次のステップである Cloudflare Access を加えた比較。

Basic認証トークン認証Cloudflare Access
実装・設定の手間最小少ない多い(初回のみ)
ログイン画面ブラウザ固定のダイアログ自前(自由にデザイン可)Google のログイン画面
ログアウト事実上できないできるできる
パスワードマネージャブラウザ依存対応させやすいGoogle に委ねる
アクセス制限の単位合言葉を知っているか合言葉を知っているかメールアドレス単位
守れる範囲ページもAPIもAPI(ページは公開)ページもAPIも
外部サービス不要不要Google Cloud Console が必要

合言葉方式(Basic認証・トークン認証)の限界は「誰がアクセスしたか」を区別できないこと。管理者が自分ひとりなら何の問題もないが、複数人で運用して個人を識別したい、Google アカウントの2段階認証で守りたい、となったら Cloudflare Access を使うハンズオンに進むとよい。コードを書かずに「このメールアドレスだけ許可」という制限がかけられる。

  • 合言葉(パスワード・トークン)が漏れたら、Variables and Secrets の値を変えて再デプロイするだけで全とっかえできる — 単一共有シークレット方式の数少ない利点のひとつ
  • トークンを ?token=xxx のようにURLで渡す設計にはしない — ブラウザ履歴・サーバーログ・Referer に残るため。ヘッダで渡すのが原則
  • 環境変数の変更はデプロイし直すまで反映されない — push するか Retry deployment
  • 管理者が複数いる場合は同じ合言葉を共有することになる — 個人を分けたくなったら Cloudflare Access へ
  • ログインフォームまわりの実装が崩れるとパスワードマネージャが反応しなくなる — 改修するときは 6-2 の要件(form・autocomplete・reload)を維持する