Files
whatsapp-inbox-platform/app/auth/login/route.ts
Wira Basalamah 90f794bfe2
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
fix: validate login redirect target by role
2026-04-21 13:34:48 +07:00

147 lines
4.0 KiB
TypeScript

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;
}
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 (!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);
}
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 response = NextResponse.redirect(new URL(safeDestination, baseUrl));
response.cookies.set(SESSION_COOKIE, await serializeSession(session), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: Math.max(1, Math.floor(session.expiresAt - Date.now() / 1000))
});
return response;
}