仮パスワードの強制変更が直URLで素通りしていた。ガードを画面ごとに書くと必ず付け忘れる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
顧客向け画面の強制リダイレクトを画面ごとに個別実装すると、新しい画面を1つ足すたびにどれかへ必ず付け忘れる。直URLで仮パスワードのまま通過できる穴は、実装漏れではなくガードの置き場所そのものの設計不備が原因だった。
結論
顧客向け画面の強制リダイレクトを画面ごとに個別実装すると、新しい画面を1つ足すたびにどれかへ必ず付け忘れる。 広告主向けportalでは、仮パスワード(must_change_password=true)のまま強制変更をスキップさせるリダイレクトが/と/dashboardの2画面にしか書かれておらず、それ以外の顧客向け画面は仮パスワードかどうかを一切見ていなかった。ログイン後に/creativesや/materialsへ直URLで入れば、強制変更を素通りできた。直し方は、リダイレクトの判定をrequireCustomer()という認可関数1箇所に集約し、各画面はそれを呼ぶだけにすることだった。
発端
広告主向けportalには、初回ログイン用の仮パスワードで入った顧客を、必ずパスワード変更画面へ誘導する仕組みがある。この誘導は/と/dashboardの2箇所に、それぞれ個別のリダイレクト処理として書かれていた。
// 修正前相当
export async function requireCustomer(): Promise<CurrentUser> {
const user = await requireUser();
if (user.role !== "customer") redirect("/admin");
return user;
}
requireCustomer()自体は「ログインしていて、かつ顧客ロールであること」しか見ておらず、仮パスワードの状態は/と/dashboard側の呼び出しコードが個別に判定していた。/report /creatives /materials /accountといった他の顧客向け画面は、このrequireCustomer()(またはログイン確認だけのrequireUser())を呼ぶだけで、仮パスワードの判定コードは持っていなかった。
原因
仮パスワードの広告主がログイン後に/dashboardを経由せず、ブックマークや共有されたURLから直接/creativesへアクセスすると、そこには仮パスワードを見るコードが無いため、強制変更をまったく経由せずに入稿画面が開いた。原因は実装漏れそのものというより、「どの画面が仮パスワードをチェックするか」を画面側に持たせる設計になっていたことにある。チェックを持つべき画面が増えるたびに、そのつど個別実装しなければ穴になる構造だった。
さらに、画面側のガードだけを直しても不十分な経路が残っていた。入稿の保存を行うサーバーアクションsubmitCreativeは、呼び出し元のページがリダイレクトされていたかとは無関係に直接実行できる。
// 修正前相当(creatives/actions.ts)
import { requireUser } from "@/lib/auth";
// ...
export async function submitCreative(
_prev: SubmitState,
formData: FormData
): Promise<SubmitState> {
const user = await requireUser();
// ...
}
ページの表示だけをガードしても、フォームの送信自体は別経路として直接呼べてしまうため、画面と書き込み処理の両方に同じ判定を通す必要があった。
直し方
requireCustomer()に仮パスワードの判定を集約し、判定ロジック自体はredirectの副作用を持たない純粋関数customerGuardRedirectに分離した。
export function customerGuardRedirect(
user: Pick<CurrentUser, "role" | "mustChangePassword">,
options: { allowTempPassword?: boolean } = {}
): "/admin" | "/account" | null {
if (user.role !== "customer") return "/admin";
if (!options.allowTempPassword && user.mustChangePassword) return "/account";
return null;
}
export async function requireCustomer(
options: { allowTempPassword?: boolean } = {}
): Promise<CurrentUser> {
const user = await requireUser();
const dest = customerGuardRedirect(user, options);
if (dest) redirect(dest);
return user;
}
顧客向けの各画面(account creatives materials)は、requireUser()ではなくこのrequireCustomer()を呼ぶように統一した。パスワードを変更する場であるaccount画面だけは、requireCustomer({ allowTempPassword: true })として仮パスワードのままでも通す例外にした。この統一は同時に、admin/staffロールが顧客向け画面をそのまま通過できてしまっていた別の不整合も解消している。
書き込み処理も揃えた。
// 修正後(creatives/actions.ts)
import { requireCustomer } from "@/lib/auth";
// ...
export async function submitCreative(
_prev: SubmitState,
formData: FormData
): Promise<SubmitState> {
// 入稿は顧客のみ。仮パスワードのままなら requireCustomer が /account へ飛ばす
const user = await requireCustomer();
// ...
}
これで画面の表示経路とサーバーアクションの直叩き経路の両方が、同じ1つの判定関数を通るようになった。account画面自体にも、contractsとad_metricsの取得にcompany_idでの絞り込みを追加し、他の画面と同じ多層防御を揃えている。
再発防止
customerGuardRedirectがredirectの副作用を持たない純粋関数として分離されたことで、全分岐と優先順位を単体テストで固定できるようになった。
it("社内判定はパスワード例外より優先(allowTempPassword でも社内は /admin)", () => {
expect(
customerGuardRedirect(
{ role: "staff", mustChangePassword: true },
{ allowTempPassword: true }
)
).toBe("/admin");
});
社内ユーザーの弾き出し・仮パスワードの強制・allowTempPasswordの例外・優先順位(社内判定が仮パスワード例外より常に優先される)の4パターンがテストとして固定され、以後どの画面を追加しても、この関数を呼ぶ限り同じ判定が保証される構造になった。
顧客向け画面がまだ4つしか無かった段階では、画面ごとにリダイレクトを書いても抜けは目立たなかった。画面数が増えるほど、個別実装は「新しい画面を作るたびに、既存の全画面と同じチェックを覚えて書き写す」作業になり、その作業は確率的に必ずどこかで抜ける。認可の判定は画面の数だけ書く対象ではなく、画面が何個になっても呼ぶだけで済む1箇所に置く対象だった。
よくある質問
Q1なぜ/と/dashboard以外は強制変更を素通りしていたのですか?
must_change_password=trueの広告主を仮パスワード変更画面へ飛ばす強制リダイレクトが、/と/dashboardの2画面だけに個別実装されていたためです。それ以外の顧客向け画面(/report /creatives /materials /account)はrequireUser()でログイン確認だけを行い、仮パスワードの状態は見ていませんでした。ログイン済みで/dashboardを経由せず直URLで/creativesなどへ入れば、強制変更のチェック自体を通らずに画面が表示されました。
Q2画面のリダイレクトだけ塞げば十分ではなかったのですか?
不十分でした。入稿の保存を行うサーバーアクションsubmitCreativeは、呼び出し元の画面がリダイレクトされていたかどうかに関係なく直接実行できます。ページのガードだけを直しても、フォームを直接送信する経路や別クライアントからのリクエストは塞げないため、書き込みを行う関数自体にも同じガードを効かせる必要がありました。
Q3なぜaccount画面だけ仮パスワードのままアクセスを許しているのですか?
仮パスワードを変更する操作そのものをaccount画面で行うためです。強制変更中の顧客をaccount以外の画面から締め出しつつ、account自体は例外的に通す必要があるため、requireCustomer()にallowTempPasswordという明示的なオプションを設け、account画面だけがこれを渡す形にしました。
確認した環境
- Next.js 16.2.7 / React 19.2.4
- 2026-06-18 に修正コミットで発見・解消
この記事の根拠
- TypeScriptファイル 60〜95行目コミット 225c323
- TypeScriptファイルコミット 225c323
- TypeScriptファイルコミット 225c323
- TypeScriptファイルコミット 225c323
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。