🔒

Next.jsとBasic認証でハマった話

に公開

Next.js(App Router)で静的サイトを制作していて、かつ一部のページを㊙️にしたくパスワード認証をかけようと思いました。
そこで手っ取り早く行うためにBasic認証を選択しましたが、Basic認証の知識不足やNext.jsのクセによるハマりどころがいくらかあったので共有します。

概要

/, /about, ... などのいくらかのページがある中に/private/private/fooという特定のルート配下にBasic認証を適用します。

page.tsxの中身はできている前提で読んでください)

middleware

まずはmiddlewareです。

テンプレートはこちらのリンクにあります。
最低限改変する場所はパスのマッチングとID/PWのベタ書きを辞めることですが、ここはまだ分かりやすいでしょう。

https://github.com/vercel/examples/blob/main/edge-middleware/basic-auth-password/middleware.ts

// src/middleware.ts

import { type NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: ["/private/:path*"],
};

export default function middleware(req: NextRequest) {
  const url = req.nextUrl;

  const basicAuth = req.headers.get("authorization");

  if (basicAuth) {
    const authValue = basicAuth.split(" ")[1];
    const [user, pwd] = atob(authValue).split(":");

    if (
      user === process.env.BASIC_AUTH_USER &&
      pwd === process.env.BASIC_AUTH_PASSWORD
    ) {
      return NextResponse.next();
    }
  }
  url.pathname = "/api/auth";
  return NextResponse.rewrite(url);
}

Route Handler

先ほどのテンプレートに続きます。
こちらの該当箇所はPages RouterのAPI Routeで書かれていますが...

https://github.com/vercel/examples/blob/main/edge-middleware/basic-auth-password/pages/api/auth.ts

App RouterではRoute Handlerに置き換える必要があります。

// src/app/api/auth/route.ts

export async function GET() {
  return new Response("ログインに失敗しました。", {
    status: 401,
    headers: {
      "WWW-Authenticate": 'Basic realm="Secure Area"',
    },
  });
}

プライベートページへのリンクを設置する

グローバルナビゲーションに上記ページへのリンクを追加するのですが、
このときいくらかの選択肢を検証する羽目になりました。

結論から言うと通常のaタグを使ってください。

候補1: Linkコンポーネント(通常)

最もオーソドックスな選択肢です。

// src/nav.tsx

 <Link
  href="/private"
 >
   秘密のページ
 </Link>

これはローカル環境でも問題なく動いていたのですが、本番環境にデプロイ(私はVercelです)したときに問題があり...

なんとトップページを表示しただけでBasic認証のダイアログが出てくるのです!
また、ダイアログを閉じても問題なくトップページは閲覧できます。

  • これはprivate以外のページ全般でも同様でした。
  • 通常のリロードをしたときに現象が発生します。

何が何だか分からなかったのでRevertしたら先ほどの問題は消えていました。
しかしこのRevertでミスをしており、middlewareを消そうと思ったにもかかわらず残ったままでした。そのため、プライベートページは正常な認証動作付きで表示できていました。

...あれ?こうなっているということは、ナビゲーションの表示が問題なのか!?

なんということに手順をミスったことから問題解決への道が開けました。まるでりんごを誤って焦がしてしまったのをきっかけにタルトタタンが生まれたような奇跡です。

話を続けると、過去のうっすらした記憶で「Linkを表示するときにprefetchが思いがけない悪さをする」という嫌な思い出が多少あったことが蘇りました...。

候補2: Linkコンポーネント(prefetch OFF)

prefetchの挙動を調べた結果、図星のようです。
(なぜ本番で環境のみで失敗したかについても...)

詳細は次のリンクなどをご覧ください。

https://zenn.dev/frontendflat/articles/nextjs-prefetch#developmentとproductionによる挙動の違い

結果的にprefetch={false}とすることで解消されました。

// src/nav.tsx

 <Link
  href="/private"
+ prefetch={false}
 >
   秘密のページ
 </Link>

しかしよく観察すると、まだ微妙な動きをしている箇所があります。
ダイアログの「キャンセル」をクリックすると、もう一度ダイアログが出現します。

候補3: 通常のaタグ(prefetch OFF)

結局のところ、これで一件落着しました。

// src/nav.tsx

<a href="/private">秘密のページ</a>

根本的な原因が完全にクリアになった訳ではないですが、令和のFE開発ではNext.jsが覆い隠しているレイヤーを疑うことも時には重要です。

ログアウト処理

Basic認証はセキュリティの担保に長けている方ではないにせよ、常時ログインを回避させたい場合はログアウトボタンを設置しておきたいです。

いくらか調べると、Basic認証におけるログアウト処理はhttps://www.example.com/privateというURLに対してhttps://{任意の文字列}@www.example.com/privateにアクセスさせて認証状態を消去すれば良いとのことでした。

それ自体は基本的には問題ないのですが、ユーザーから見ると

    1. 再びダイアログが表示される
    1. キャンセルをクリックして明示的に認証を中止する
    1. 認証失敗画面が表示されるので、行きたければ手動でどこかのページに飛ぶ
    • 注: preタグにより自動でAPIレスポンスの結果が表示されるだけなので、独自HTMLを用意することは困難だと思われる

というなんとも直感的でない体験をする羽目になります。
日頃からBasic認証に慣れている社内メンバーとかにサイトを見せるならこれでもOKだと思いますが、今回は幅広いユーザーに公開しているサイトでした。なのでこの問題もどうにかしたいです。

候補1: バックグラウンドでタブがポップアップし、自動で閉じる

URLにアクセスさえ行えればOKなので、それをユーザーから隠せば自然になるだろうと思いました。
しかしこのような実装は昔は可能だったにも関わらず現在はブラウザ側でセキュリティのためにブロックされているようで、無理でした。(そういえばネットサーフィンしていても昔は見たけど近年見ないですね)

  • Ctrl/Command + クリック をトリガーして、別タブで開く操作を再現する
  • 別タブが開いたら即座にfocusメソッドで元のタブに移る
// logout-button.tsx

function generateLogoutUrl() {
  const url = new URL(window.location.href);
  url.username = "logout";
  return url.toString().replace(/\/$/, "");
}

// onClickの中を抜粋...
const confirmed = window.confirm("ログアウトしますか?");
if (!confirmed) return;

const logoutUrl = generateLogoutUrl();
const newWindow = window.open(logoutUrl, "_blank");
// 以後、処理を書く...

候補2: ポップアップしたタブが自動で閉じるのみ

悩んだ結果これに行き着きました。
開かれたタブはアクティブになってしまいますが、それに待ったをかけるように追記すればまぁ良いかなという願いです。

// logout-button.tsx
function generateLogoutUrl() {
  const url = new URL(window.location.href);
  url.username = "logout";
  return url.toString().replace(/\/$/, "");
}

// onClickの中を抜粋...
const confirm = window.confirm(
  [
    "ログアウトしますか?\n\n",
    "・ログアウト後は自動でホーム画面に戻ります。\n",
    "・新規ウィンドウが開きますが、自動で閉じられます。",
  ].join(""),
);
if (!confirm) return;

const logoutUrl = generateLogoutUrl();
const newWindow = window.open(logoutUrl, "_blank");

setTimeout(() => {
  newWindow?.close();
  // ホーム画面にリダイレクト
  window.location.href = window.location.origin;
  // 暫く待たないとログアウトされずに遷移される
}, 1000);

もっと良い方法があるかもしれませんが、時間の兼ね合いでこれで対処しました。

おわりに

静的サイト制作にてサクッと何か非公開にしたい場合は心に留めておくと良いかもしれません。

Discussion