import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; import { prisma } from '@/lib/prisma'; import { rateLimit, clientIp, tooManyRequests, RateLimitResult } from '@/lib/rate-limit'; // POST: Verify via 6-digit code export async function POST(request: NextRequest) { try { const { email, code } = await request.json(); // A 6-digit code has only 10^6 combinations — without attempt limiting // it is brute-forceable in minutes. Limit per email AND per IP. const ip = clientIp(request); const perIp = rateLimit(`verify-email:ip:${ip}`, 10, 15 * 60 * 1000); const perEmail: RateLimitResult = email ? rateLimit(`verify-email:email:${String(email).toLowerCase()}`, 5, 15 * 60 * 1000) : { ok: true }; if (!perIp.ok || !perEmail.ok) { return tooManyRequests(perIp.retryAfterSeconds ?? perEmail.retryAfterSeconds); } if (!email || !code) { return NextResponse.json( { error: 'Email and code are required' }, { status: 400 } ); } const user = await prisma.user.findUnique({ where: { email }, select: { id: true, emailVerified: true, emailVerificationCode: true, emailVerificationExpires: true, }, }); if (!user) { return NextResponse.json( { error: 'User not found' }, { status: 404 } ); } if (user.emailVerified) { return NextResponse.json({ message: 'Email already verified' }); } if (!user.emailVerificationCode || !user.emailVerificationExpires) { return NextResponse.json( { error: 'No verification code found. Please request a new one.' }, { status: 400 } ); } if (new Date() > user.emailVerificationExpires) { return NextResponse.json( { error: 'Verification code expired. Please request a new one.' }, { status: 400 } ); } if (user.emailVerificationCode !== code) { return NextResponse.json( { error: 'Invalid verification code' }, { status: 400 } ); } // Verify the user await prisma.user.update({ where: { id: user.id }, data: { emailVerified: new Date(), verifiedAt: new Date(), emailVerificationCode: null, emailVerificationToken: null, emailVerificationExpires: null, }, }); return NextResponse.json({ message: 'Email verified successfully' }); } catch (error) { console.error('Verification error:', error); return NextResponse.json( { error: 'Failed to verify email' }, { status: 500 } ); } } // GET: Legacy one-click link. // // This used to verify-and-redirect on a single GET, but corporate inbox // scanners (M365 Safe Links, etc.) pre-fetch URLs to scan them — which // silently consumed the one-shot token before the recipient ever saw the // email (the bug behind point #2). New emails point straight at the client // page; this handler only redirects there for backward compatibility with // emails already in transit. export async function GET(request: NextRequest) { const token = request.nextUrl.searchParams.get('token'); const target = new URL('/auth/verify-email', request.url); if (token) target.searchParams.set('token', token); return NextResponse.redirect(target); } // PATCH: Verify via token from the email link (called by the client page after // the user actually clicks the button — survives scanner pre-fetches). export async function PATCH(request: NextRequest) { try { const ip = clientIp(request); const limited = rateLimit(`verify-email-token:${ip}`, 10, 15 * 60 * 1000); if (!limited.ok) { return tooManyRequests(limited.retryAfterSeconds); } const { token } = await request.json(); if (!token) { return NextResponse.json({ error: 'Token is required' }, { status: 400 }); } const user = await prisma.user.findFirst({ where: { emailVerificationToken: token, }, select: { id: true, email: true, emailVerified: true, emailVerificationExpires: true, }, }); // Friendly path: token already consumed but the email is verified — treat as success. if (!user) { return NextResponse.json({ error: 'Invalid token. It may have already been used.' }, { status: 400 }); } if (user.emailVerified) { return NextResponse.json({ message: 'Email already verified', email: user.email }); } if (!user.emailVerificationExpires || new Date() > user.emailVerificationExpires) { return NextResponse.json( { error: 'Verification link expired. Please request a new code.', email: user.email }, { status: 400 } ); } await prisma.user.update({ where: { id: user.id }, data: { emailVerified: new Date(), verifiedAt: new Date(), emailVerificationCode: null, emailVerificationToken: null, emailVerificationExpires: null, }, }); return NextResponse.json({ message: 'Email verified successfully', email: user.email }); } catch (error) { console.error('Token verification error:', error); return NextResponse.json({ error: 'Failed to verify email' }, { status: 500 }); } }