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 { return await bcrypt.hash(password, 12); } async function verifyPassword(password: string, hash: string): Promise { // 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 }); } }