Magic-link sign in UI and APIs
This commit is contained in:
@@ -4,13 +4,17 @@ import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashOtpCode } from "../../../../../lib/server/hash";
|
||||
import { sendOtpEmail } from "../../../../../lib/server/mail";
|
||||
import {
|
||||
hashSessionToken,
|
||||
newSessionToken,
|
||||
} from "../../../../../lib/server/hash";
|
||||
import { sendMagicLinkEmail } from "../../../../../lib/server/mail";
|
||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import { logger } from "../../../../../lib/logger";
|
||||
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
||||
|
||||
const OTP_TTL_MS = 10 * 60 * 1000;
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||
|
||||
@@ -21,6 +25,13 @@ function normalizeEmail(raw: unknown): string | null {
|
||||
return email;
|
||||
}
|
||||
|
||||
function readNextPath(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object" || !("next" in body)) return null;
|
||||
const n = (body as { next: unknown }).next;
|
||||
if (typeof n !== "string") return null;
|
||||
return safeInternalPath(n);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
@@ -39,7 +50,10 @@ export async function POST(request: NextRequest) {
|
||||
: null,
|
||||
);
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Valid email required" }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "Valid email required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip =
|
||||
@@ -47,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
const rlEmail = rateLimitKey(`otp-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
||||
const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
||||
if (rlEmail.ok === false) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs },
|
||||
@@ -55,7 +69,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const rlIp = rateLimitKey(`otp-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||
const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||
if (rlIp.ok === false) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests", retryAfterMs: rlIp.retryAfterMs },
|
||||
@@ -73,20 +87,29 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
const codeHash = hashOtpCode(code, pepper);
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
|
||||
const nextPath = readNextPath(body);
|
||||
|
||||
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||
await prisma.otpChallenge.create({
|
||||
data: { email, codeHash, expiresAt },
|
||||
await prisma.magicLinkToken.deleteMany({ where: { email } });
|
||||
await prisma.magicLinkToken.create({
|
||||
data: {
|
||||
email,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
nextPath: nextPath ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const origin = request.nextUrl.origin;
|
||||
const verifyUrl = `${origin}/api/auth/magic-link/verify?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
await sendOtpEmail(email, code);
|
||||
await sendMagicLinkEmail(email, verifyUrl);
|
||||
} catch (err) {
|
||||
logger.error("sendOtpEmail failed:", err);
|
||||
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||
logger.error("sendMagicLinkEmail failed:", err);
|
||||
await prisma.magicLinkToken.deleteMany({ where: { email } });
|
||||
return NextResponse.json(
|
||||
{ error: "Could not send email" },
|
||||
{ status: 502 },
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashSessionToken } from "../../../../../lib/server/hash";
|
||||
import {
|
||||
createSessionForUser,
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token || token.length < 10) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=invalid_link", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL("/login?error=server", request.url));
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
|
||||
const row = await prisma.magicLinkToken.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!row || row.expiresAt < new Date()) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=expired_link", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.magicLinkToken.delete({ where: { id: row.id } });
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: row.email },
|
||||
create: { email: row.email },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
||||
user.id,
|
||||
);
|
||||
await setSessionCookie(sessionToken, expiresAt);
|
||||
|
||||
const dest = safeInternalPath(row.nextPath);
|
||||
return NextResponse.redirect(new URL(dest, request.url));
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashOtpCode } from "../../../../../lib/server/hash";
|
||||
import {
|
||||
createSessionForUser,
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
function normalizeEmail(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const email = raw.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
|
||||
return email;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { email: rawEmail, code: rawCode } = body as {
|
||||
email?: unknown;
|
||||
code?: unknown;
|
||||
};
|
||||
|
||||
const email = normalizeEmail(rawEmail);
|
||||
const code =
|
||||
typeof rawCode === "string"
|
||||
? rawCode.replace(/\s/g, "")
|
||||
: String(rawCode ?? "");
|
||||
|
||||
if (!email || !/^\d{6}$/.test(code)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Valid email and 6-digit code required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfiguration" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const challenge = await prisma.otpChallenge.findFirst({
|
||||
where: {
|
||||
email,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!challenge) {
|
||||
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (challenge.attempts >= MAX_ATTEMPTS) {
|
||||
await prisma.otpChallenge.delete({ where: { id: challenge.id } });
|
||||
return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
|
||||
}
|
||||
|
||||
const expectedHash = hashOtpCode(code, pepper);
|
||||
if (expectedHash !== challenge.codeHash) {
|
||||
await prisma.otpChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.otpChallenge.deleteMany({ where: { email } });
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
create: { email },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const { token, expiresAt } = await createSessionForUser(user.id);
|
||||
await setSessionCookie(token, expiresAt);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: user.id, email: user.email },
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,9 @@ export async function GET() {
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
draft: draft ? { payload: draft.payload, updatedAt: draft.updatedAt } : null,
|
||||
draft: draft
|
||||
? { payload: draft.payload, updatedAt: draft.updatedAt }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@ export async function GET() {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ ok: true, database: "connected" });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ ok: false, database: "error" },
|
||||
{ status: 503 },
|
||||
);
|
||||
return NextResponse.json({ ok: false, database: "error" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user