Files
whatsapp-inbox-platform/app/auth/login/route.ts
wirabasalamah 137edc12b7
Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
fix: lates
2026-04-21 20:37:59 +07:00

230 lines
6.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import {
SESSION_COOKIE,
SESSION_COOKIE_SECURE_ENV,
getSessionCookieDomain,
getSessionTtlSeconds,
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 = SESSION_COOKIE_SECURE_ENV;
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: "/",
domain: getSessionCookieDomain(),
maxAge: sessionMaxAgeSeconds
});
if (AUTH_DEBUG) {
console.warn("[AUTH] session_cookie_issued", {
userId: session.userId,
role: session.role,
sessionExpiresAt: session.expiresAt,
sessionMaxAgeFromEnv: getSessionTtlSeconds(),
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;
}