import { NextRequest, NextResponse } from "next/server"; import { SESSION_COOKIE, UserRole, canAccessPath, authenticateUser, getDefaultPathForRole, serializeSession } from "@/lib/auth"; import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; import { getRequestBaseUrl } from "@/lib/request-url"; function getSafePath(value: string | null) { if (!value) { return null; } if (!value.startsWith("/")) { return null; } if (value.startsWith("//")) { return null; } return value; } function resolveNumber(raw: string | undefined, fallback: number) { const value = Number(raw?.trim()); if (!Number.isInteger(value) || value <= 0) { return fallback; } return value; } const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1"; function maskEmail(email: string) { if (!email) { return ""; } const [name, domain] = email.split("@"); if (!domain) { return "*****"; } if (name.length <= 2) { return `${name[0]}***@${domain}`; } return `${name.slice(0, 2)}***@${domain}`; } function shouldUseSecureCookies(request: NextRequest) { const explicit = process.env.COOKIE_SECURE?.toLowerCase() ?? ""; if (explicit === "true" || explicit === "1") { return true; } if (explicit === "false" || explicit === "0") { return false; } const forwardedProto = request.headers.get("x-forwarded-proto"); return request.nextUrl.protocol === "https:" || (forwardedProto?.split(",")[0]?.toLowerCase() === "https"); } export async function POST(request: NextRequest) { const { ipAddress, userAgent } = await getRequestAuditContext(); const baseUrl = getRequestBaseUrl(request); const retryControl = consumeRateLimit(ipAddress || "unknown", { scope: "auth_login", limit: resolveNumber(process.env.LOGIN_RATE_LIMIT_ATTEMPTS, 10), windowMs: resolveNumber(process.env.LOGIN_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000) }); if (!retryControl.allowed) { const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("error", "rate_limited"); const response = NextResponse.redirect(loginUrl); const headers = getRateLimitHeaders(retryControl); Object.entries(headers).forEach(([headerName, headerValue]) => { response.headers.set(headerName, headerValue); }); return response; } const form = await request.formData(); const rawEmail = form.get("email"); const rawPassword = form.get("password"); const rawNext = form.get("next"); const next = getSafePath(typeof rawNext === "string" ? rawNext : null); const email = typeof rawEmail === "string" ? rawEmail.trim() : ""; const password = typeof rawPassword === "string" ? rawPassword : ""; if (AUTH_DEBUG) { console.warn("[AUTH] login_attempt", { email: maskEmail(email), hasPassword: password.length > 0, next, ipAddress, userAgent }); } if (!email || !password) { const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("error", "credentials_required"); if (next) { loginUrl.searchParams.set("next", next); } return NextResponse.redirect(loginUrl); } const session = await authenticateUser(email, password); if (!session) { const attemptedUser = await prisma.user.findUnique({ where: { email }, select: { id: true, tenantId: true, status: true } }); if (attemptedUser) { await writeAuditTrail({ tenantId: attemptedUser.tenantId, actorUserId: attemptedUser.id, entityType: "user", entityId: attemptedUser.id, action: "user_login_failed", metadata: { email, status: attemptedUser.status, source: "web" }, ipAddress, userAgent }); } const loginUrl = new URL("/login", baseUrl); loginUrl.searchParams.set("error", "invalid_credentials"); if (next) { loginUrl.searchParams.set("next", next); } if (AUTH_DEBUG) { console.warn("[AUTH] login_failed", { email: maskEmail(email), ipAddress, userAgent }); } return NextResponse.redirect(loginUrl); } await prisma.user.update({ where: { id: session.userId }, data: { lastLoginAt: new Date() } }); await writeAuditTrail({ tenantId: session.tenantId, actorUserId: session.userId, entityType: "user", entityId: session.userId, action: "user_login", metadata: { email }, ipAddress, userAgent }); const destination = next ?? getDefaultPathForRole(session.role as UserRole); const safeDestination = destination && canAccessPath(session.role as UserRole, destination) ? destination : getDefaultPathForRole(session.role as UserRole); const sessionMaxAgeSeconds = Math.max( 60, Math.floor(session.expiresAt - Math.floor(Date.now() / 1000)) ); const response = NextResponse.redirect(new URL(safeDestination, baseUrl)); response.cookies.set(SESSION_COOKIE, await serializeSession(session), { httpOnly: true, sameSite: "lax", secure: shouldUseSecureCookies(request), path: "/", maxAge: sessionMaxAgeSeconds }); if (AUTH_DEBUG) { console.warn("[AUTH] session_cookie_issued", { userId: session.userId, role: session.role, sessionExpiresAt: session.expiresAt, maxAge: sessionMaxAgeSeconds, host: request.headers.get("host") || "unknown", protocol: request.nextUrl.protocol, forwardedProto: request.headers.get("x-forwarded-proto") || "unknown", secureCookies: shouldUseSecureCookies(request) }); console.warn("[AUTH] login_success_redirect", { userId: session.userId, destination: safeDestination, setCookie: response.headers.get("set-cookie") }); } response.headers.set("X-Auth-Session", "issued"); response.headers.set("X-Auth-Session-User", session.userId); response.headers.set("X-Auth-Session-Role", session.role); response.headers.set("X-Auth-Session-Base-Url", baseUrl.toString()); response.headers.set("X-Auth-Session-Max-Age", String(sessionMaxAgeSeconds)); response.headers.set("X-Auth-Session-Secure", String(shouldUseSecureCookies(request))); return response; }