My-Weekly-ToDo-List/src/app/api/auth/verify-email/route.ts
mARTin-B78 f6c4a21215 feat: add rate limiting and harden auth/calendar endpoints
Add an in-memory sliding-window rate limiter and wire it into signup,
resend-verification, reset-password, and verify-email routes. Store
password reset tokens as SHA-256 hashes, raise bcrypt cost to 12, and
switch verification codes to a CSPRNG. Stop echoing calendar
access/refresh tokens back to API clients, bound the weather cache
size, return JSON 401s for unauthenticated API calls instead of an
HTML redirect, and drop the dead mock login route.

v1.113.0
2026-07-05 00:01:52 +02:00

167 lines
5.2 KiB
TypeScript

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 });
}
}