Use Cases

Claude Codeでパスワードリセット機能を実装する

Claude Codeを使って、セキュアなパスワードリセットフローを実装する方法を解説。トークン管理、メール送信、セキュリティ対策を紹介します。

パスワードリセット機能をClaude Codeで実装する

パスワードリセットはほぼすべてのWebアプリに必要な機能ですが、セキュリティ上の落とし穴も多い領域です。Claude Codeを使えば、トークンの安全な生成・検証、レート制限、メール送信を含む堅牢な実装を構築できます。

リセットフローの全体像

  1. ユーザーがメールアドレスを入力
  2. サーバーがリセットトークンを生成しメール送信
  3. ユーザーがメール内のリンクをクリック
  4. 新しいパスワードを入力して更新
> セキュアなパスワードリセット機能を実装して。
> トークンの有効期限は1時間、使用は1回のみ。
> レート制限とメール送信も含めて。

トークン生成とメール送信

// src/services/password-reset.ts
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { sendEmail } from '@/lib/email';

export class PasswordResetService {
  private static TOKEN_EXPIRY_HOURS = 1;

  async requestReset(email: string) {
    const user = await prisma.user.findUnique({ where: { email } });

    // ユーザーが存在しなくても同じレスポンスを返す(情報漏洩防止)
    if (!user) return { success: true };

    // レート制限: 同一メールへの送信は5分に1回まで
    const recentRequest = await prisma.passwordReset.findFirst({
      where: {
        userId: user.id,
        createdAt: { gt: new Date(Date.now() - 5 * 60 * 1000) },
      },
    });
    if (recentRequest) return { success: true };

    // セキュアなトークン生成
    const token = crypto.randomBytes(32).toString('hex');
    const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

    // 古いトークンを削除
    await prisma.passwordReset.deleteMany({ where: { userId: user.id } });

    // 新しいトークンを保存
    await prisma.passwordReset.create({
      data: {
        userId: user.id,
        token: hashedToken,
        expiresAt: new Date(
          Date.now() + this.constructor.TOKEN_EXPIRY_HOURS * 60 * 60 * 1000
        ),
      },
    });

    // リセットメール送信
    const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
    await sendEmail({
      to: email,
      subject: 'パスワードリセットのご案内',
      html: `
        <h2>パスワードリセット</h2>
        <p>以下のリンクをクリックしてパスワードをリセットしてください。</p>
        <a href="${resetUrl}" style="display:inline-block;background:#3B82F6;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;">
          パスワードをリセット
        </a>
        <p style="color:#666;font-size:14px;margin-top:16px;">
          このリンクは1時間で有効期限が切れます。<br />
          心当たりがない場合は、このメールを無視してください。
        </p>
      `,
    });

    return { success: true };
  }

  async resetPassword(token: string, newPassword: string) {
    const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

    const resetRecord = await prisma.passwordReset.findFirst({
      where: {
        token: hashedToken,
        expiresAt: { gt: new Date() },
        used: false,
      },
    });

    if (!resetRecord) {
      throw new Error('無効または期限切れのトークンです');
    }

    // パスワードのバリデーション
    if (newPassword.length < 8) {
      throw new Error('パスワードは8文字以上である必要があります');
    }

    const hashedPassword = await bcrypt.hash(newPassword, 12);

    // トランザクションでパスワード更新とトークン無効化
    await prisma.$transaction([
      prisma.user.update({
        where: { id: resetRecord.userId },
        data: { password: hashedPassword },
      }),
      prisma.passwordReset.update({
        where: { id: resetRecord.id },
        data: { used: true },
      }),
    ]);

    return { success: true };
  }
}

リセットフォームのUI

// src/app/(auth)/reset-password/page.tsx
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';

export default function ResetPasswordPage() {
  const searchParams = useSearchParams();
  const token = searchParams.get('token');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (password !== confirmPassword) {
      setError('パスワードが一致しません');
      return;
    }

    try {
      const res = await fetch('/api/auth/reset-password', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, password }),
      });
      if (!res.ok) throw new Error((await res.json()).error);
      setStatus('success');
    } catch (err: any) {
      setError(err.message);
      setStatus('error');
    }
  };

  if (status === 'success') {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <h2 className="text-2xl font-bold mb-4">パスワードを更新しました</h2>
          <a href="/login" className="text-blue-600 hover:underline">ログインページへ</a>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <form onSubmit={handleSubmit} className="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
        <h1 className="text-2xl font-bold mb-6">新しいパスワード</h1>
        {error && <p className="text-red-500 text-sm mb-4">{error}</p>}
        <div className="space-y-4">
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="新しいパスワード(8文字以上)"
            className="w-full border rounded-lg px-4 py-3"
            minLength={8}
            required
          />
          <input
            type="password"
            value={confirmPassword}
            onChange={(e) => setConfirmPassword(e.target.value)}
            placeholder="パスワードの確認"
            className="w-full border rounded-lg px-4 py-3"
            required
          />
          <button type="submit" className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium">
            パスワードを更新
          </button>
        </div>
      </form>
    </div>
  );
}

セキュリティチェックリスト

  • トークンは暗号学的に安全な乱数で生成する
  • DBにはハッシュ化したトークンを保存する
  • トークンの有効期限を設ける(1時間程度)
  • トークンは1回使用後に無効化する
  • ユーザーの存在有無を応答で漏らさない

関連記事

認証全般の設計は認証機能の実装、二要素認証との組み合わせは二要素認証の実装をご覧ください。

メール送信にはResend(resend.com)がシンプルでおすすめです。

#Claude Code #パスワードリセット #認証 #セキュリティ #メール