MENU

問い合わせ


    【Next.jsで作るオンライン予約サービス 第5回】会員ログインを付ける(メールのリンクで入る方式と予約履歴)

    予約サービスに「会員ログイン」を付けると、利用者は自分の予約履歴を見られるようになり、運営側は「誰の予約か」を確実に結びつけられます。一方で、ログインは作り方を間違えると、他人の予約が見えてしまうなど、情報漏えいに直結する機能です。前回のダブルブッキング防止(第4回)で予約の土台が固まったので、今回は会員ログインを付けます。

    「予約サービスの会員ログインは、パスワードを使わずにメールのリンクだけで入る方式にできますか?安全に作るには何に気をつければいいですか?」

    先に結論をお伝えします。パスワードを持たない「メールのリンクでログイン」方式は、小さな予約サービスと相性がよく、作る側もパスワード漏えいの心配を減らせます。ただし、リンクの合言葉は「推測できない」「1回しか使えない」「期限がある」「DBにそのまま保存しない」の4点を守る必要があります。今回は、この4点を満たす最小の仕組みを実装し、実際にブラウザでログインから予約履歴の表示までを確かめます。

    目次

    メールのリンクでログインする方式の仕組み

    流れは次のとおりです。パスワードの代わりに、「そのメールアドレスの持ち主だけがリンクを開ける」ことを本人確認に使います。

    1. 利用者がメールアドレスを入力する
    2. サーバーが、推測できない合言葉(トークン)を作り、リンクにして本人へメールする
    3. 利用者がリンクを開き、確認画面のボタンを押す
    4. サーバーが合言葉を確かめ、問題なければ「ログイン中」の印(セッション)を作る
    5. 以降の画面では、ブラウザが送る Cookie(=ブラウザに預けておく小さな情報)でログイン中の本人を判断する

    📰 出典:Next.js 公式ドキュメント「Authentication」

    Next.js の公式ドキュメントも、認証を「本人確認」「セッションの管理」「認可(=何を見てよいかの判断)」に分けて説明しています。今回の実装も、この3つに沿って作ります。

    認証ライブラリを使わず、小さく自分で作る理由

    連載の計画では「Auth.js などを使う」としていましたが、今回は仕組みを見えるようにするため、約100行の自前実装にしました。理由は2つです。

    • 何が安全の要なのか(合言葉の作り方・保存の仕方・使い切りの扱い)を、コードで一つずつ確認できる
    • Auth.js は2025年9月に Better Auth のチームが引き継ぐと発表されており、新規のプロジェクトでは Better Auth が推奨されている(2026年10月時点で、筆者が公式の告知で確認)

    📰 出典:Better Auth「Auth.js joins Better Auth」

    実際の案件では、メールの送信・不正アクセスの検知・多要素認証などまで含めて、実績のあるライブラリを使うのが一般的です。自前実装は「中身を理解するための教材」と考えてください。

    実装1:会員・ログイン用リンク・セッションの3つの表

    まず、データベースに3つの表を足します(lib/schema.ts)。パスワードの列はありません。

    // 会員の表。パスワードは持たない(メールのリンクで本人確認するため)
    export const users = pgTable("users", {
      id: serial("id").primaryKey(),
      email: text("email").notNull().unique(), // 小文字にそろえて保存する
      createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    });
    
    // ログイン用リンクの表。リンクに入れる合言葉そのものは保存せず、ハッシュ(元に戻せない変換値)だけ持つ
    export const loginTokens = pgTable("login_tokens", {
      id: serial("id").primaryKey(),
      tokenHash: text("token_hash").notNull().unique(),
      email: text("email").notNull(),
      expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
      usedAt: timestamp("used_at", { withTimezone: true }), // 使用済みなら日時が入る(1回しか使えない)
      createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    });

    ログイン中の状態を持つ sessions 表も同じ形で作り、予約の表には会員への結びつき(user_id)を足します。ログインしていない人でも予約はできるので、user_id は空(null)を許します。変更は npm run db:generate で作られる drizzle/0004_member_login.sql に入ります。

    合言葉は「推測できない」「DBに丸ごと置かない」

    合言葉は、暗号用の乱数で作ります。Math.random() のような予測されうる乱数は使いません(lib/auth-token.ts)。

    // 推測できない合言葉。randomBytes は暗号用の乱数(Math.random は使わない)。32バイト = 256ビット
    export function generateToken(): string {
      return randomBytes(32).toString("base64url");
    }
    
    // DB には合言葉そのものではなくハッシュを保存する。DB の中身が漏れても、そのままではログインに使えない。
    export function hashToken(token: string): string {
      return createHash("sha256").update(token).digest("hex");
    }

    📰 出典:Node.js 公式ドキュメント「crypto」

    DBにはハッシュ(=元の値に戻せない変換結果)だけを保存します。万一DBの中身が外に出ても、そこにある文字列ではログインできません。合言葉が十分に長いランダム値なので、パスワード用の遅いハッシュではなく SHA-256 で足りる、というのが一般的な考え方です。

    実装2:1回しか使えず、期限がある合言葉

    ログイン用リンクの発行と使用は lib/auth.ts にまとめました。使用の処理(consumeLoginToken)が最も大事です。

    // 「未使用」かつ「期限内」の行を1回の UPDATE で使用済みにする。
    // 確認してから更新する2段階にすると、同時に2回開かれたとき両方が通ってしまうため、1文で済ませる
    export async function consumeLoginToken(token: string, now: Date = new Date(), db: Db = getDb()) {
      const rows = await db
        .update(loginTokens)
        .set({ usedAt: now })
        .where(and(eq(loginTokens.tokenHash, hashToken(token)), isNull(loginTokens.usedAt), gt(loginTokens.expiresAt, now)))
        .returning({ email: loginTokens.email });
      return rows[0]?.email;
    }

    第4回の二重予約と同じ考え方です。「使われていないか確認してから、使用済みにする」と2段階に分けると、ほぼ同時に2回開かれたときに両方が通ってしまいます。条件つきの更新を1文で行い、更新できた行があったときだけ成功とします。

    テストでは、同じリンクを同時に10回開いても成功は1回だけになること、15分を過ぎたリンク・でたらめな合言葉・使用済みのリンクが使えないことを確かめています。

    メールの連続送信と、会員の探りへの備え

    ログイン画面は誰でも使えるので、悪用への備えも要ります。今回は次の2点を入れました。

    • 同じメールアドレスへの依頼は、15分間に5回まで(超えたら断る)。他人のメールボックスにリンクを大量に送りつけられるのを防ぐ
    • 画面の案内は、会員登録済みかどうかに関係なく同じ文面にする。入力されたメールアドレスが会員かどうかを、第三者に探らせない
    // 登録済みかどうかにかかわらず同じ表示にする(他人のメールアドレスが会員かどうかを探られないため)
    return { message: "ログイン用のリンクをメールで送りました。メールのリンクを開いてください(15分間有効)", sentTo: parsed.email };

    📰 出典:OWASP「Authentication Cheat Sheet」

    なお、メールの送信そのものは第8回で扱います。この回では、開発中だけサーバーのログにリンクを表示する仕組み(lib/mailer.ts)にしてあり、本番環境ではわざとエラーにして、リンクがログに残らないようにしています。

    実装3:リンクを開いただけではログインさせない

    メールのリンクは、メールソフトやセキュリティ製品が内容確認のために自動で先に開くことがあります。リンクを開いた時点でログインを確定させると、利用者が開く前に1回きりの合言葉が使われてしまい、「リンクが無効です」と表示される原因になります。

    そこで、リンクの行き先は確認画面にして、ボタンを押したとき(POST)に初めてログインを確定させました(app/login/actions.ts)。

    export async function verifyLogin(formData: FormData) {
      const token = formData.get("token");
      const email = typeof token === "string" && token ? await consumeLoginToken(token) : undefined;
      if (!email) redirect("/login?error=expired");
    
      const user = await findOrCreateUser(email);
      const session = await createSession(user.id);
      await setSessionCookie(session.token, session.expiresAt);
      redirect("/reservations");
    }

    ログインの確定後は、セッション用の合言葉を作り、DBにはハッシュだけ、ブラウザには本物を Cookie で渡します。Cookie には次の設定を付けます(lib/auth.ts)。

    (await cookies()).set(SESSION_COOKIE, token, {
      httpOnly: true, // ブラウザ上の JavaScript から読めない(XSS で盗まれにくくする)
      secure: process.env.NODE_ENV === "production", // 本番は HTTPS のときだけ送る
      sameSite: "lax", // 他のサイトからの POST には付けない
      path: "/",
      expires: expiresAt,
    });

    📰 出典:Next.js 公式ドキュメント「cookies」

    設定守るもの
    httpOnly画面に仕込まれた不正なスクリプトからの盗み取り
    secure暗号化されていない通信での漏えい(本番のみ)
    sameSite: "lax"他のサイトを経由した、なりすましの操作

    実装4:予約履歴は「本人の分だけ」を出す

    予約履歴の画面(app/reservations/page.tsx)では、未ログインなら必ずログイン画面へ移し、検索条件にはログイン中の本人のIDだけを使います。

    const user = await getCurrentUser();
    if (!user) redirect("/login"); // 未ログインは入れない。画面ごとに必ずここで確認する
    
    const rows = await listReservationsForUser(user.id); // 条件は「ログイン中の本人の ID」。URL や入力からは受け取らない

    「/reservations?user=3 のようにURLで会員IDを渡す」作りにすると、数字を書き換えるだけで他人の履歴が見えてしまいます。何を見てよいかの判断(認可)は、画面ごと・処理ごとにサーバー側で行うのが基本です。

    予約を保存する処理(Server Action)も、ログイン中ならその会員のIDを結びつけます。ログインは必須にせず、未ログインでも予約できる形を保ちました。

    動作確認:ログインから予約履歴まで

    ローカルの PostgreSQL 16 と開発サーバーで、ブラウザを自動操作して次の流れを確かめました(開発環境での確認です)。

    • 未ログインで /reservations を開くと /login へ移る
    • メールアドレス(大文字混じり)を入力すると、同じ案内文が出てリンクが作られる
    • リンクを開くと確認画面が出て、この時点では Cookie は作られない
    • ボタンを押すとログインし、Cookie は HttpOnly・SameSite=Lax
    • 同じリンクを別のブラウザで開き直すと「期限切れか使用済み」の表示になる
    • ログイン後に取った予約が、予約履歴に1件表示される
    • ログアウト後、予約履歴を開くとログイン画面に戻る
    ログイン用リンクを送った後のログイン画面(開発環境での確認画面)
    ログイン後の予約履歴(開発環境での確認画面)

    検証コマンドは、lint・型チェック・本番ビルド・テスト(認証の単体テストと、DBにつなぐテスト)のすべてが成功しました。Docker のデーモンが使えない環境だったため、docker compose での起動は今回も未確認です。

    つまずきやすい点と、本番に向けて足すこと

    • 本番でメールが送れず、ログインできない:今回は開発用のログ表示のみです。実際のメール送信(送信元の認証設定を含む)は第8回で扱います
    • リンクの宛先のホスト名:サンプルではリクエストの情報から作っています。本番では環境変数で固定するほうが安全です(第11回)
    • 連続アクセスへの対策が簡易:回数の上限はメールアドレス単位だけです。接続元ごとの制限・不正検知・多要素認証は、本番では別途検討が必要です
    • セッションの掃除:期限切れのセッションや使用済みのリンクはDBに残るため、定期的に消す仕組みが要ります

    「これで絶対に安全」という状態はありません。ログインまわりは、実績のあるライブラリやサービスを使い、公開前に第三者の確認を受けるのが一般的です。

    発注者向けメモ:会員ログインを頼むときの確認点

    会員ログインは、見た目の割に確認すべき点が多い機能です。見積もりの前に、次の点をそろえておくとやり取りが早くなります。

    • 工数が増えやすい条件:SNSアカウントでのログイン(Google・LINE等)、パスワードでのログイン、多要素認証、会員の退会・メールアドレス変更、会員種別(個人・法人・管理者)の使い分け、既存の会員データの移行、など。
    • リスクが増えやすい条件:個人情報(氏名・住所・電話番号・決済情報)を会員情報として持つ、本人確認が必要な予約を扱う、など。個人情報の取り扱いは、個人情報保護委員会の公式の案内を確認し、個別の判断は専門家に相談してください。
    • ログイン方式(メールのリンク・パスワード・SNS)によって、利用者の使いやすさと、問い合わせの内容(「メールが届かない」「パスワードを忘れた」)が変わります。

    発注者がやることチェックリストです。

    • ☐ 会員登録を必須にするか、未ログインでも予約できるようにするかを決める
    • ☐ ログイン方式(メールのリンク・パスワード・SNS)の希望と、その理由を整理する
    • ☐ 会員に持たせる情報(メールアドレスだけか、氏名・電話番号も持つか)を決める
    • ☐ 退会したときに、予約履歴などのデータをどうするかを決める
    • ☐ 「メールが届かない」ときの問い合わせ窓口と対応方法を決める

    開発会社への質問例:

    • 「ログイン用の合言葉やパスワードは、データベースにそのまま保存せず、ハッシュなどで保護されていますか?」
    • 「ログイン用のリンクには有効期限と1回限りの制限がありますか? 連続して依頼されたときの制限はありますか?」
    • 「他の会員の予約や個人情報が見えないことを、どのように確認していますか?(テストの内容も教えてください)」
    • 「メールが届かない場合の原因の切り分けと、対応の範囲は保守に含まれますか?」
    • 「ログインの仕組みに認証ライブラリやサービスを使う場合、その更新・サポート終了への対応は誰が担当しますか?」

    まとめと次回予告

    今回は次のことをしました。

    • パスワードを持たない、メールのリンクでのログインを実装した(合言葉は暗号用の乱数・ハッシュで保存・1回限り・15分の期限)
    • リンクを開いただけでは確定せず、ボタン(POST)でログインを確定する形にした
    • Cookie に HttpOnly・SameSite=Lax(本番は Secure)を付け、予約履歴は本人の分だけを出すようにした
    • 同時に10回開いても成功が1回だけになることなどを、テストで確かめた

    次回の第6回では、Stripe Checkout で支払いを受ける仕組みを作ります。金額をブラウザから受け取らず、サーバー側で決める点を中心に解説する予定です。Stripe の実環境のキーがないため、テストモードの範囲で確認できた内容と、未確認の部分を分けて書きます。

    この連載の記事一覧

    この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (2件)

      目次