Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
230 lines
6.4 KiB
TypeScript
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;
|
|
}
|