Newer
Older
client_dwatch / proxy.ts
/**
 * 全ルートの横断処理(Next.js 16 の `proxy.ts` 規約)。
 *
 * ★このファイルは `middleware.ts` ではありません★
 *   Next.js 16 で `middleware` は非推奨になり `proxy` に改名されました
 *   (`node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`)。
 *   `middleware.ts` を置くと dev サーバが deprecation 警告を出します。
 *
 * ★ここには順序制御だけを書き、ロジックを置かないこと★
 *   1ファイルしか置けない規約なので、中身を膨らませると次に横断処理を足すときに壊れます。
 *   公式ガイドの推奨どおり機能ごとに `lib/proxy/` へ分けて import しています。
 *
 * ★実行順(逆にしないこと)★
 *   1. 相関IDの発行  … 弾いたリクエストのログにも ID が付くように★必ず先★
 *      (`docs/logging-design.md` §9 の申し送り)
 *   2. IP制限        … 拒否なら 403
 *   3. 認証          … 未認証なら 画面は /login へリダイレクト、APIは 401
 *
 * ★`runtime` を指定しないこと★ — proxy は Node.js ランタイム固定で、
 *   `runtime` を設定するとエラーになります(Edge は proxy では非対応)。
 */
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { clientIp } from "@/lib/auth/client-ip";
import { TRUSTED_PROXY_HOPS } from "@/lib/config/allowed-ips";
import { logEvent, runWithRequestId } from "@/lib/logger";
import {
  authenticate,
  isApiPath,
  SESSION_COOKIE_NAME,
} from "@/lib/proxy/authenticate";
import {
  issueRequestHeaders,
  REQUEST_ID_HEADER,
} from "@/lib/proxy/request-id";

export function proxy(request: NextRequest) {
  // 1. 相関IDの発行(★認証より先★)。クライアント指定の値は信頼せず常に上書き
  const { requestId, headers } = issueRequestHeaders(request.headers);

  // ★発行した ID をこの中の logEvent() にも効かせる★
  //   こうしないと、弾いたリクエストのログだけ request_id が "-" になり、
  //   「認証失敗もログで追跡できるようにする」という順序の意図(logging-design.md §9)が
  //   半分しか達成できない
  return runWithRequestId(requestId, () => {
    const { pathname } = request.nextUrl;
    const forwardedFor = request.headers.get("x-forwarded-for");
    const decision = authenticate(
      pathname,
      forwardedFor,
      request.cookies.get(SESSION_COOKIE_NAME)?.value ?? null
    );

    // 2. IP制限
    if (decision.kind === "deny_ip") {
      // ★client_ip は拒否時のみ出す(D-101)★ — 表記ゆれを正規化しない設計上、
      //   拒否された接続元の生の表記が分からないと ALLOWED_IPS に登録できない。
      //   ★X-Forwarded-For の生値は出さない★(先頭側はクライアントが自由に書ける)
      logEvent("warn", {
        event: "auth.ip_denied",
        route: pathname,
        method: request.method,
        client_ip: clientIp(forwardedFor, TRUSTED_PROXY_HOPS),
        xff_element_count: forwardedFor ? forwardedFor.split(",").length : 0,
      });
      return NextResponse.json(
        {
          error: {
            code: "forbidden",
            message: "このIPアドレスからは利用できません",
          },
        },
        { status: 403, headers: { [REQUEST_ID_HEADER]: requestId } }
      );
    }

    // 3. 認証
    //    ★ここではログを出さない★ — 未認証は「まだログインしていない」だけで、
    //      画面を開くたびに出るとノイズになる。追跡したいログイン試行は
    //      /api/auth/login 側で auth.login_success / auth.login_failed として記録する
    if (decision.kind === "unauthenticated") {
      if (isApiPath(pathname)) {
        return NextResponse.json(
          { error: { code: "unauthorized", message: "認証が必要です" } },
          { status: 401, headers: { [REQUEST_ID_HEADER]: requestId } }
        );
      }
      // ★絶対URLを自前で組まないこと★ — LB が HTTPS を終端するため、
      //   アプリ側で組み立てると http:// になる。request.nextUrl 起点で組む
      const loginUrl = new URL("/login", request.nextUrl);
      loginUrl.searchParams.set("next", pathname);
      return NextResponse.redirect(loginUrl);
    }

    // ★NextResponse.next({ request: { headers } }) で「後段へ渡す」リクエストヘッダになる★
    //   NextResponse.next({ headers }) はクライアントへ返すレスポンスヘッダなので別物
    return NextResponse.next({ request: { headers } });
  });
}

export const config = {
  /**
   * ★matcher が無いと静的ファイルにも全部かかる★(CSS / JS / 画像が読めなくなる)。
   * 除外するもの: `_next/static` / `_next/image` / `favicon.ico` / `public/` 配下の svg。
   *
   * ★`/login` と `/api/auth/login` はここで除外しないこと★ —
   *   関数の中(`isPublicPath()`)で分岐して通す。matcher で削ると保護漏れの発見が難しくなる。
   * ★matcher に変数は使えない★(ビルド時に静的解析されるため)。
   *   許可IPリストを matcher に書こうとしないこと。
   * ★`_next/data` は除外しても必ず proxy が走る★(意図的な挙動。除外できないものとして扱う)。
   */
  matcher: [
    "/((?!_next/static|_next/image|favicon\\.ico|.*\\.svg$).*)",
  ],
};