ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

better-authで実装する2要素認証(メールOTP)

はじめに

LDIで開発担当しているOKこと岡崎です。

普段はバックエンドの開発を主にやってますが、最近はWebのフロントエンドも学習しており、 今回はその一環で作ったところをテーマにブログに起こそうと思います。

本記事では、better-authを使ってメールOTPによる2要素認証を実装する際の課題と解決策を、実装コードとともに詳しく解説します。

本日のテーマ

Next.js + TypeScriptで認証機能を実装する際、better-authは非常に便利なライブラリです。しかし、2要素認証(2FA)を実装しようとすると、公式ドキュメントだけでは実現できない課題に直面しました。

このため、better-authで2FAを実装する際にハマった事象とその解決方法について、ご紹介します。

目次

  1. 実装する認証フロー
  2. 技術スタック
  3. 核心的な課題
  4. 実装手順
  5. ハマりポイントと解決策
  6. まとめ

実装する認証フロー

サインアップフロー

1. ユーザーがメールアドレス・パスワードを入力
   ↓
2. ユーザー作成(セッションなし)+ OTP送信
   ↓
3. ユーザーがOTPを入力
   ↓
4. OTP検証 + セッション確立
   ↓
5. ログイン完了

ログインフロー

1. ユーザーがメールアドレス・パスワードを入力
   ↓
2. パスワード検証 + OTP送信
   ↓
3. ユーザーがOTPを入力
   ↓
4. OTP検証 + セッション確立
   ↓
5. ログイン完了

技術スタック

  • フレームワーク: Next.js 15 (App Router)
  • 言語: TypeScript
  • 認証ライブラリ: better-auth
  • データベース: SQLite (better-sqlite3)
  • UI: Chakra UI

核心的な課題

better-authで2FAを実装する際、以下の3つの大きな課題がありました。

課題1: サインアップ時のセッション自動作成

better-authのsignUpEmailは、デフォルトでユーザー作成と同時にセッションを確立します。これでは2FAの意味がありません。

// ❌ これだとセッションが作成されてしまう
await auth.api.signUpEmail({ email, password, name });
// この時点でログイン済みになり、2FAが機能しない

課題2: パスワード検証とセッション作成の分離

better-authに「パスワード検証だけ行ってセッションは作らない」APIが見当たらない。

// ❌ これだとセッションも作成される
await auth.api.signIn.email({ email, password });
// パスワード検証はできるが、同時にセッションも作成される

課題3: OTP検証のタイミング

サインアップ時、ユーザーが存在しない状態でOTP検証を行うことができません。

実装手順

1. better-authの基本設定

まず、autoSignIn: falseとカスタムパスワードハッシュ関数を設定します。

// src/lib/auth.ts
import { betterAuth } from 'better-auth';
import { emailOTP } from 'better-auth/plugins';
import Database from 'better-sqlite3';
import { scrypt, randomBytes } from 'crypto';

const db = new Database('./sqlite.db');

// カスタムハッシュ関数
const hashPassword = async (password: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    const salt = randomBytes(16).toString('hex');
    scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, derivedKey) => {
      if (err) reject(err);
      else resolve(`${salt}:${derivedKey.toString('hex')}`);
    });
  });
};

// カスタム検証関数
const verifyPassword = async (data: { password: string; hash: string }): Promise<boolean> => {
  return new Promise((resolve, reject) => {
    const [salt, storedHash] = data.hash.split(':');
    scrypt(data.password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, derivedKey) => {
      if (err) reject(err);
      else resolve(derivedKey.toString('hex') === storedHash);
    });
  });
};

export const auth = betterAuth({
  database: db,
  emailAndPassword: {
    enabled: true,
    autoSignIn: false, // ★重要:サインアップ時に自動ログインしない
    requireEmailVerification: false,
    minPasswordLength: 8,
    password: {
      hash: hashPassword,     // ★重要:カスタムハッシュ化
      verify: verifyPassword, // ★重要:カスタム検証
    },
  },
  plugins: [
    emailOTP({
      sendVerificationOTP: async ({ email, otp, type }) => {
        // 本番環境ではメール送信サービスを使用
        console.log(`OTP送信: ${email} -> ${otp}`);
        return Promise.resolve();
      },
      expiresIn: 60 * 10, // 10分
      otpLength: 6,
    }),
  ],
});

// ★重要:検証関数をエクスポート
export { verifyPassword };

ポイント

  • autoSignIn: falseにより、ユーザー作成時にセッションが作成されない
  • カスタムハッシュ関数により、後でデータベースから直接パスワードを検証できる

1.5. カスタムパスワードハッシュ関数の詳細

なぜカスタムハッシュ関数が必要なのか

better-authのデフォルトでは、パスワード検証とセッション作成が一体化しています。2FAを実装するには、以下の2つの処理を分離する必要があります:

  1. パスワード検証:ユーザーの入力したパスワードが正しいか確認
  2. セッション作成:認証成功後にログイン状態を確立

カスタムハッシュ関数を実装することで、データベースから直接パスワードハッシュを取得し、セッションを作成せずにパスワードのみを検証できるようになります。

ハッシュ化の仕様

本実装では、Node.jsのcryptoモジュールのscrypt関数を使用しています。

選定理由

  • PBKDF2よりも高速かつセキュア
  • メモリハード関数のため、ブルートフォース攻撃に強い
  • Node.jsの標準ライブラリで利用可能

パラメータの説明

パラメータ 説明
N 16384 (214) CPUコスト。値が大きいほど計算時間が増加し、ブルートフォース攻撃に強くなる
r 8 ブロックサイズ。メモリ使用量に影響
p 1 並列化パラメータ。並列処理の度合い
出力長 64バイト 生成されるハッシュの長さ
ソルト長 16バイト ランダムソルトの長さ

検証の流れ

  1. データベースから取得したハッシュ文字列を:で分割しソルトとハッシュを取得
  2. 分割したソルトを使って、入力パスワードを同じ方法でハッシュ化
  3. 生成されたハッシュと保存されたハッシュを比較
  4. 一致すればパスワードが正しい

2. サインアップエンドポイントの実装

// src/app/api/auth/prepare-signup/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';

export async function POST(request: NextRequest) {
  try {
    const { email, password, name } = await request.json();

    // バリデーション
    if (!email || !password || !name) {
      return NextResponse.json(
        { error: 'すべての項目が必要です' },
        { status: 400 }
      );
    }

    // 1. ユーザーを作成(autoSignIn: falseなのでセッションは作成されない)
    const signupResult = await auth.api.signUpEmail({
      body: { email, password, name },
      asResponse: true,
    });

    if (!signupResult.ok) {
      const errorData = await signupResult.json();
      return NextResponse.json(
        { error: errorData.error || 'ユーザー作成に失敗しました' },
        { status: 400 }
      );
    }

    // 2. OTPを送信
    await auth.api.sendVerificationOTP({
      body: { email, type: 'sign-in' },
    });

    return NextResponse.json({
      success: true,
      message: '認証コードを送信しました',
    });
  } catch (error) {
    console.error('サインアップ準備エラー:', error);
    return NextResponse.json(
      { error: 'サーバーエラーが発生しました' },
      { status: 500 }
    );
  }
}

ポイント

  • ユーザーは作成されるが、emailVerified: falseの状態
  • 同じメールアドレスで再度サインアップしても、emailVerified: falseの間は上書きされる

3. ログイン用パスワード検証エンドポイントの実装

// src/app/api/auth/verify-credentials/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth, verifyPassword } from '@/lib/auth';
import Database from 'better-sqlite3';

const db = new Database('./sqlite.db');

export async function POST(request: NextRequest) {
  try {
    const { email, password } = await request.json();

    // 1. ユーザーを取得
    const user = db
      .prepare('SELECT * FROM user WHERE email = ?')
      .get(email) as any;

    if (!user) {
      return NextResponse.json(
        { error: 'メールアドレスまたはパスワードが正しくありません' },
        { status: 401 }
      );
    }

    // 2. パスワードハッシュを取得
    const account = db
      .prepare('SELECT * FROM account WHERE userId = ? AND providerId = ?')
      .get(user.id, 'credential') as any;

    if (!account || !account.password) {
      return NextResponse.json(
        { error: 'メールアドレスまたはパスワードが正しくありません' },
        { status: 401 }
      );
    }

    // 3. パスワードを検証(セッション作成なし)
    const isValid = await verifyPassword({
      password,
      hash: account.password,
    });

    if (!isValid) {
      return NextResponse.json(
        { error: 'メールアドレスまたはパスワードが正しくありません' },
        { status: 401 }
      );
    }

    // 4. 認証成功 - OTPを送信
    await auth.api.sendVerificationOTP({
      body: { email, type: 'sign-in' },
    });

    return NextResponse.json({
      success: true,
      message: '認証コードを送信しました',
    });
  } catch (error) {
    console.error('認証エラー:', error);
    return NextResponse.json(
      { error: 'サーバーエラーが発生しました' },
      { status: 500 }
    );
  }
}

ポイント

  • better-authのAPIを使わず、データベースに直接アクセス
  • カスタムverifyPassword関数でパスワードのみ検証
  • セッションは作成せず、OTPのみ送信

4. OTP検証エンドポイントの実装(サインアップ・ログイン共通)

// src/app/api/auth/verify-otp/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';

export async function POST(request: NextRequest) {
  try {
    const { email, otp } = await request.json();

    if (!email || !otp) {
      return NextResponse.json(
        { error: 'メールアドレスとOTPが必要です' },
        { status: 400 }
      );
    }

    // OTPを検証してセッションを確立
    const result = await auth.api.signInEmailOTP({
      body: { email, otp },
      headers: request.headers,
      asResponse: true,
    });

    if (!result.ok) {
      const errorData = await result.json();
      return NextResponse.json(
        { error: errorData.error || 'OTPが正しくありません' },
        { status: 401 }
      );
    }

    // セッションクッキーを転送
    const cookies = result.headers.get('set-cookie');
    const data = await result.json();

    const response = NextResponse.json({
      success: true,
      data,
    });

    if (cookies) {
      response.headers.set('set-cookie', cookies);
    }

    return response;
  } catch (error) {
    console.error('OTP検証エラー:', error);
    return NextResponse.json(
      { error: 'サーバーエラーが発生しました' },
      { status: 500 }
    );
  }
}

ポイント

  • signInEmailOTPは、ユーザーが存在すればOTP検証してセッション確立
  • サインアップ時は既にユーザーが作成済みなので、同じAPIが使える
  • OTP検証成功時にemailVerified: trueに更新される

ハマりポイントと解決策

ハマりポイント1: verifyEmailOTPは使えない

// ❌ これは動作しない
await auth.api.verifyEmailOTP({ body: { email, otp } });

理由verifyEmailOTPはメールアドレスの検証用で、ログイン用ではありません。

解決策signInEmailOTPを使用する

// ✅ 正しい
await auth.api.signInEmailOTP({ body: { email, otp } });

ハマりポイント2: サインアップ時のOTP検証タイミング

最初は「OTP検証してからユーザー作成」を試みましたが、ユーザーが存在しないとsignInEmailOTPが失敗します。

解決策:先にユーザーを作成(autoSignIn: false)してから、OTP検証する

ハマりポイント3: セッションクッキーの転送

// ❌ これだとクッキーが設定されない
const data = await result.json();
return NextResponse.json({ success: true, data });

解決策set-cookieヘッダーを明示的に転送する

// ✅ 正しい
const cookies = result.headers.get('set-cookie');
const response = NextResponse.json({ success: true, data });
if (cookies) {
  response.headers.set('set-cookie', cookies);
}
return response;

まとめ

better-authで2要素認証を実装するための重要なポイント:

  1. autoSignIn: falseの設定が最重要
  2. カスタムパスワードハッシュ関数でパスワード検証とセッション作成を分離
  3. signInEmailOTPでOTP検証とセッション確立を同時に行う
  4. サインアップとログインで共通のOTP検証エンドポイントを使用
  5. セッションクッキーの転送を忘れずに

この実装により、セキュアな2要素認証が実現できます。

参考リンク

おわりに

ここまでフロントエンドの実装についての話ですが、最後にAIについてちょっと触れておきます。

今回の実装ですが、実はAIと壁打ちしながら仕上げています。

ですが、AIがほとんどサクッと仕上げてくれたかと言うと全然そんなことはなく、 課題とかハマりポイントとして記事に挙げている部分は、最初AIが間違った提案をしてきたので、人間が仕様書などと睨めっこをして、こうしたら出来るのではないかと軌道修正を何度もした結果、最終的に思い描いていた形で実現できました。

AIは確かに便利ですが、まだまだエンジニアのできることはあるなと実感しました。 とはいえ、コーディングが出来るだけでは、もうAIに取って代わられるようになるので、ものづくりにおいては、何を実現したいのかを明確にし、それを伝えて確認するスキルが求められるのかなと感じました。