ログイン画面を作り、メールアドレスとパスワードで入れるようになった。自分の投稿一覧も表示されています。ところが、URLの投稿IDを別の値へ変えたら、ほかの利用者の投稿まで開けてしまいました。

ログイン機能は動いています。それでも、アプリは安全ではありません。

本人が誰かを確かめることと、その人が何をしてよいかを決めることは別の処理です。この記事では、エージェントが作ったログイン機能を見たときに、認証、セッション、権限確認がどこで行われているかを追えるようにします。

認証と認可を分ける

認証はauthentication、認可はauthorizationです。英語ではAuthN、AuthZと略されることもあります。

  • 認証:アクセスしている人が誰かを確かめる
  • 認可:その人が要求した操作をしてよいか判断する

たとえば、学校のシステムへログインした学生が、自分の成績を見られるのは自然です。同じ学生が、ほかの学生の成績や教員用の管理画面まで見られてはいけません。

シリーズ内の「AI開発で困らないためのデータベース入門」でも、ログインできることと、すべてのデータを読めることは別だと説明しました。ここでは、リクエストが届くたびにどのような確認が必要かを見ていきます。

ログイン後もHTTPは一回ずつ届く

HTTPのリクエストは、基本的に一回ごとに独立しています。そのままでは、サーバーは前のリクエストでログインした人と、次のリクエストを結び付けられません。

そこで、ログイン後の状態をセッションとして管理します。

  1. メールアドレスとパスワードを送る
  2. サーバーが本人確認をする
  3. セッションを作る
  4. ブラウザへセッションを識別するCookieなどを渡す
  5. 次のリクエストで、その情報をサーバーへ送る
  6. サーバーが現在の利用者を特定する

「ログイン状態」とは、画面に名前を表示していることではありません。サーバーがリクエストごとに認証情報を確かめ、現在の利用者を特定できる状態です。

Cookie、Session、Token

認証の説明では、Cookie、Session、Tokenという言葉が出てきます。

Cookieは、ブラウザがWebサイトごとに保存し、条件に合うリクエストへ添えて送る小さなデータです。セッションIDをCookieに保存することがあります。

認証用Cookieでは、次の設定を確認します。

  • HttpOnly:ブラウザのJavaScriptから読み取りにくくする
  • Secure:HTTPS通信でだけ送る
  • SameSite:別サイトから送られるリクエストを制限する
  • ExpiresまたはMax-Age:有効期限

これらは一律に同じ値へすればよいのではなく、ログイン方式や外部サービスとの連携に合わせて決めます。

Server-side Session

サーバー側にセッション情報を保存し、ブラウザにはランダムなセッションIDだけを持たせる方式があります。

  1. Cookieに session_id=ランダムな値 を保存する
  2. サーバー側の保存先で利用者IDや期限を確認する

ログアウト時には、ブラウザのCookieを消すだけでなく、サーバー側のセッションも無効にします。

Token

Tokenは、認証やAPI利用のために渡す文字列です。JWTもTokenの一種です。Token自体に利用者IDや期限などを含め、署名を検証する方式もあります。

Tokenを使えばセッション管理が自動的に安全になるわけではありません。保存場所、漏れた場合の無効化、期限、更新方法を決める必要があります。長期間有効なTokenをブラウザのlocalStorageへ置く構成では、XSSによって読み取られる危険も考えます。

OAuthと「Googleでログイン」

Google、GitHubなどのアカウントを使ってログインする仕組みでは、OAuthやOpenID Connectが使われます。

大まかな流れは次のとおりです。

  1. 自分のアプリからGoogleのログイン画面へ移動する
  2. Googleで本人確認と同意を行う
  3. callback URLへ戻り、自分のアプリが返された情報を検証する
  4. 自分のアプリ内の利用者と結び付ける

外部サービスが本人確認を担当しても、自分のアプリ内の権限は自分で決めます。「Googleでログインできた人は全員管理者」といった設計にしてはいけません。

OAuth設定では、callback URLやredirect URIを登録します。ローカル、Preview、本番でURLが違う場合は、環境ごとの設定を確認します。許可するredirect URIを広げすぎると、認証情報を意図しない場所へ送る原因になります。

権限はroleだけでは決まらない

管理者、編集者、一般利用者といったroleで権限を分ける方法があります。

type Role = "admin" | "editor" | "user"

しかし、一般利用者同士でも、自分のデータと他人のデータを分ける必要があります。ここではownership、つまり所有者の確認が必要です。

  1. 現在の利用者IDは user-123
  2. 編集する記事の authorIduser-123
  3. 同じなので編集を許可する

別の組織のデータを分けるSaaSでは、organizationIdtenantIdも確認します。利用者IDだけが一致していても、操作対象の組織が違えば許可できない場合があります。

権限確認では、少なくとも次の三つを区別します。

  • 誰か:現在の利用者
  • 何を:対象の記事、注文、ファイルなど
  • 何をするか:読む、作る、変更する、削除する

ボタンを隠すだけでは権限制御にならない

一般利用者には削除ボタンを表示せず、管理者にだけ表示する画面を作ったとします。

UIでボタンを隠すだけではなく、APIで認可し、データベースでも制約する三層の権限制御を示した図

{user.role === "admin" && <DeleteButton />}

これは画面を分かりやすくするためには役立ちますが、APIの権限制御にはなりません。利用者は開発者ツールや別のプログラムから、削除APIへ直接リクエストできます。

DELETE /api/articles/123

そのため、データを読む直前、変更する直前に、サーバー側で権限を確認します。

const user = await requireCurrentUser()
const article = await getArticle(articleId)

if (article.authorId !== user.id && user.role !== "admin") {
  return new Response("Forbidden", { status: 403 })
}

画面側の条件とサーバー側の条件が違うと、「ボタンは見えないのにAPIは実行できる」「ボタンは見えるのに403になる」といった不具合が起きます。権限ルールを一か所にまとめ、再利用する設計も検討します。

データベース側の権限

Supabaseのように、ブラウザからデータベースAPIを利用する構成では、Row Level Security、略してRLSを使うことがあります。

たとえば、記事テーブルでは、articles.author_idが現在ログインしている利用者IDと一致する行だけ読めるRLSポリシーを設定します。

Cloudflare D1などをサーバー側のAPIから使う構成では、APIが現在の利用者と操作対象を確認してからSQLを実行します。

どちらの場合も、フロントエンドから送られてきたuserIdをそのまま信用してはいけません。

{
  "userId": "admin-user-id",
  "title": "投稿"
}

利用者は送信内容を書き換えられます。現在の利用者IDは、検証済みのセッションからサーバー側で取得します。

管理者用の処理を確認する

管理画面や管理APIは、通常の利用者機能より大きな権限を持ちます。

  • 利用者を停止する
  • 投稿を削除する
  • 料金や個人情報を見る
  • システム設定を変える

URLを/adminにしただけでは守れません。管理画面を開くときと、管理APIを実行するときの両方で権限を確認します。

また、管理者用の秘密鍵やservice role keyを、ブラウザへ渡してはいけません。サーバー側だけで使い、必要な処理に限定します。

期限切れ、ログアウト、権限変更

正常にログインできることだけでなく、認証状態が変わる場面も確認します。

セッションの期限が切れた

APIは401などを返し、画面は再ログインを案内します。読み込み中の表示が永遠に続かないようにします。

ログアウトした

ブラウザ側の表示を切り替えるだけでなく、セッションやTokenを無効にします。戻るボタンや別タブから、保護された情報が再表示されないかも確認します。

roleを変更した

管理者から一般利用者へ変更したあと、古いセッションへ管理者権限が残り続けないか確認します。Tokenにroleを長期間埋め込む方式では、更新が反映される時期を決める必要があります。

パスワードを変更した

ほかの端末のセッションを無効にするか、重要操作で再認証を求めるかを決めます。

権限漏れをどう試すか

自分のアカウント一つだけで確認すると、権限漏れを見つけにくくなります。最低限、次の利用者を用意します。

  • 利用者A:データAを所有
  • 利用者B:データBを所有
  • 管理者:管理機能を利用可能
  • 未ログイン:セッションなし

そして、読む、変更する、削除する操作を組み合わせます。

操作 A本人 B 管理者 未ログイン
データAを読む 許可 拒否 方針による 拒否
データAを変更 許可 拒否 方針による 拒否
データAを削除 方針による 拒否 方針による 拒否

「方針による」を残したまま実装を始めると、エージェントが都合のよい判断を補ってしまいます。誰が何をできるかを先に決めます。

URLのID、request bodyのuserId、organizationIdを別の値へ変えた場合も試します。画面上のボタンだけでなく、APIへ直接リクエストした結果を確認します。

エージェントの認証実装を確認する

  • 認証サービスは何を使っているか
  • セッションはCookie、サーバー側、Tokenのどこで管理するか
  • CookieのHttpOnlySecureSameSite、期限はどうなっているか
  • OAuthのcallback URLは環境ごとに正しいか
  • 現在の利用者IDをどこから取得するか
  • role、所有者、組織のどれで権限を判断するか
  • 権限確認は画面だけでなくAPI側にもあるか
  • RLSまたはAPI側の認可が全操作に適用されているか
  • 管理者用の鍵がブラウザへ渡っていないか
  • ログアウトと期限切れでセッションが無効になるか
  • A、B、管理者、未ログインで拒否される操作を試したか

エージェントには、次のように頼めます。

この機能の認証と認可を分けて説明してください。 現在の利用者を特定する場所と、対象データを操作してよいか判断する場所を示してください。

利用者A、利用者B、管理者、未ログインの権限表を作ってください。 画面の表示だけでなく、各APIを直接呼んだ場合も拒否されることをテストしてください。

request bodyやURLで送られたuserIdを信用している箇所がないか確認してください。 利用者IDは検証済みのセッションから取得してください。

ログインできたことは、認証の入口が動いたことを示すだけです。現在の利用者、操作対象、実行する操作をサーバー側で照らし合わせて、初めて権限が守られます。