tia/src/app/api/auth/signin/route.ts
Mannu 6bdaade777 feat: email verification + Google OAuth
- 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>
2026-05-24 12:56:02 +05:30

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