chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
129
app/auth/login/route.ts
Normal file
129
app/auth/login/route.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { SESSION_COOKIE, UserRole, authenticateUser, getDefaultPathForRole, serializeSession } from "@/lib/auth";
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function getSafePath(value: string | null) {
|
||||
if (!value) {
|
||||
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 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", request.url);
|
||||
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", request.url);
|
||||
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", request.url);
|
||||
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 response = NextResponse.redirect(new URL(destination, request.url));
|
||||
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;
|
||||
}
|
||||
26
app/auth/logout/route.ts
Normal file
26
app/auth/logout/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { getSession, SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession();
|
||||
const { ipAddress, userAgent } = await getRequestAuditContext();
|
||||
|
||||
if (session) {
|
||||
await writeAuditTrail({
|
||||
tenantId: session.tenantId,
|
||||
actorUserId: session.userId,
|
||||
entityType: "user",
|
||||
entityId: session.userId,
|
||||
action: "user_logout",
|
||||
metadata: { email: session.email },
|
||||
ipAddress,
|
||||
userAgent
|
||||
});
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(new URL("/login", request.url));
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user