- Signup now creates unverified users and sends a verification email (Resend); dev falls back to [VERIFY-LINK] console log - /api/auth/verify-email: single-use token handler, mints tia_session on success, redirects to /onboarding - /api/auth/resend-verification: rate-limited (3/hr), enumeration-safe - Sign-in gated on email_verified — unverified accounts get 403 with needsVerification flag so the UI can show the resend button - Google OAuth via arctic v3: PKCE + state anti-CSRF, find-or-create user, writes accounts row, mints tia_session - Login page: Google button, check-email screen, resend link on 403 - drizzle/0005_email_verification.sql: creates email_verifications table + backfills all existing users as verified (runs automatically on container start before app boots) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
214 lines
No EOL
6.5 KiB
TypeScript
214 lines
No EOL
6.5 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { sql } from "@/db";
|
|
import { cookies } from "next/headers";
|
|
import bcrypt from "bcryptjs";
|
|
import { randomBytes } from "crypto";
|
|
import { rateLimit, getClientIp, getRateLimitKey } from "@/lib/rate-limit";
|
|
import { logAudit } from "@/lib/audit";
|
|
import { sendVerificationEmail } from "@/lib/email";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
// GET - check current session
|
|
export async function GET(request: Request) {
|
|
try {
|
|
const cookieStore = await cookies();
|
|
const sessionToken = cookieStore.get("tia_session")?.value;
|
|
|
|
if (!sessionToken) {
|
|
return NextResponse.json({ authenticated: false });
|
|
}
|
|
|
|
// Verify session
|
|
const sessions = await sql`
|
|
SELECT s.user_id, s.expires, u.email, fm.family_id as family_id
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
LEFT JOIN family_members fm ON fm.user_id::text = s.user_id::text
|
|
WHERE s.session_token = ${sessionToken}
|
|
AND s.expires > NOW()
|
|
LIMIT 1
|
|
`;
|
|
|
|
if (!sessions || sessions.length === 0) {
|
|
return NextResponse.json({ authenticated: false });
|
|
}
|
|
|
|
const session = sessions[0];
|
|
let family = null;
|
|
|
|
if (session.family_id) {
|
|
const families = await sql`
|
|
SELECT id, name, tier, max_children, max_members
|
|
FROM families WHERE id = ${session.family_id}
|
|
`;
|
|
family = families?.[0];
|
|
}
|
|
|
|
return NextResponse.json({
|
|
authenticated: true,
|
|
userId: session.user_id,
|
|
email: session.email,
|
|
familyId: session.family_id,
|
|
familyName: family?.name,
|
|
tier: family?.tier,
|
|
});
|
|
} catch (error) {
|
|
console.error("Session check error:", error);
|
|
return NextResponse.json({ authenticated: false });
|
|
}
|
|
}
|
|
|
|
// bcrypt password functions
|
|
async function hashPassword(password: string): Promise<string> {
|
|
return await bcrypt.hash(password, 12);
|
|
}
|
|
|
|
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
// Handle old hash format (migrate to bcrypt on success)
|
|
if (hash.startsWith("hash_")) {
|
|
return hashPassword(password).then(h => h === hash).catch(() => false);
|
|
}
|
|
return await bcrypt.compare(password, hash);
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const body = await request.json();
|
|
const { email, password, action } = body;
|
|
|
|
if (!email || !password) {
|
|
return NextResponse.json({ error: "Email and password required" }, { status: 400 });
|
|
}
|
|
|
|
// Rate limiting - enable via RATE_LIMIT_ENABLED env var
|
|
if (process.env.RATE_LIMIT_ENABLED !== "false") {
|
|
const ip = getClientIp(request);
|
|
const isSignup = action === "signup";
|
|
const rateLimitResult = await rateLimit(
|
|
isSignup ? getRateLimitKey("auth-signup", ip) : getRateLimitKey("auth-signin", ip),
|
|
{ max: isSignup ? 3 : 5, windowSec: isSignup ? 3600 : 900 }
|
|
);
|
|
if (!rateLimitResult.success) {
|
|
return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Find user
|
|
const users = await sql`
|
|
SELECT u.id, u.email, u.password_hash, u.email_verified, fm.family_id as family_id
|
|
FROM users u
|
|
LEFT JOIN family_members fm ON fm.user_id::text = u.id::text
|
|
WHERE u.email = ${email}
|
|
LIMIT 1
|
|
`;
|
|
|
|
const user = users?.[0];
|
|
|
|
// Sign up - create new account
|
|
if (action === "signup") {
|
|
if (user) {
|
|
return NextResponse.json({ error: "Email already exists" }, { status: 400 });
|
|
}
|
|
const newUserId = crypto.randomUUID();
|
|
const passwordHash = await hashPassword(password);
|
|
|
|
// Create the user UNVERIFIED. No tia_session is set here anymore —
|
|
// the user is logged in only after clicking the verification link.
|
|
await sql`
|
|
INSERT INTO users (id, email, password_hash, password_updated_at, created_at, updated_at)
|
|
VALUES (${newUserId}, ${email}, ${passwordHash}, NOW(), NOW(), NOW())
|
|
`;
|
|
await logAudit({ userId: newUserId, action: "signup", request });
|
|
|
|
// Single-use verification token, 24h validity.
|
|
const verifyToken = randomBytes(32).toString("hex");
|
|
const expiresAt = new Date();
|
|
expiresAt.setHours(expiresAt.getHours() + 24);
|
|
await sql`
|
|
INSERT INTO email_verifications (user_id, token, expires_at)
|
|
VALUES (${newUserId}, ${verifyToken}, ${expiresAt.toISOString()})
|
|
`;
|
|
await sendVerificationEmail(email, verifyToken, newUserId);
|
|
|
|
return NextResponse.json({ success: true, needsVerification: true, email });
|
|
}
|
|
|
|
// Sign in - require existing user with password
|
|
if (!user) {
|
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
if (!user.password_hash) {
|
|
return NextResponse.json({ error: "Set password first at /login", status: 400 });
|
|
}
|
|
|
|
const valid = await verifyPassword(password, user.password_hash);
|
|
if (!valid) {
|
|
// Audit log
|
|
await logAudit({
|
|
userId: user.id,
|
|
action: "login_failed",
|
|
request,
|
|
metadata: { reason: "invalid_password" },
|
|
});
|
|
|
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
|
}
|
|
|
|
// Gate: unverified accounts cannot sign in.
|
|
if (!user.email_verified) {
|
|
return NextResponse.json(
|
|
{ error: "Please verify your email before signing in.", needsVerification: true },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
// Create session
|
|
const sessionToken = crypto.randomUUID();
|
|
const expires = new Date();
|
|
expires.setDate(expires.getDate() + 30);
|
|
await sql`
|
|
INSERT INTO sessions (session_token, user_id, expires)
|
|
VALUES (${sessionToken}, ${user.id}, ${expires.toISOString()})
|
|
`;
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
familyId: user.family_id,
|
|
userId: user.id,
|
|
action: "login",
|
|
request,
|
|
});
|
|
|
|
// Get family info
|
|
let family = null;
|
|
if (user.family_id) {
|
|
const families = await sql`
|
|
SELECT id, name, tier, max_children, max_members
|
|
FROM families WHERE id = ${user.family_id}
|
|
`;
|
|
family = families?.[0];
|
|
}
|
|
|
|
const response = NextResponse.json({
|
|
success: true,
|
|
userId: user.id,
|
|
email: user.email,
|
|
familyId: user.family_id,
|
|
family,
|
|
});
|
|
|
|
response.cookies.set("tia_session", sessionToken, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 60 * 60 * 24 * 30,
|
|
path: "/",
|
|
});
|
|
return response;
|
|
} catch (error) {
|
|
console.error("Auth error:", error);
|
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
|
}
|
|
} |