Spaces:
Runtime error
Runtime error
| import { NextResponse } from "next/server"; | |
| import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; | |
| import { getSettings } from "@/lib/localDb"; | |
| import { SignJWT } from "jose"; | |
| import { cookies } from "next/headers"; | |
| import { | |
| ensurePersistentManagementPasswordHash, | |
| getStoredManagementPassword, | |
| verifyManagementPassword, | |
| } from "@/lib/auth/managementPassword"; | |
| import { loginSchema } from "@/shared/validation/schemas"; | |
| import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; | |
| import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard"; | |
| // SECURITY: No hardcoded fallback — JWT_SECRET must be configured. | |
| if (!process.env.JWT_SECRET) { | |
| console.error("[SECURITY] FATAL: JWT_SECRET is not set. Login authentication is disabled."); | |
| } | |
| function getJwtSecret(): Uint8Array { | |
| return new TextEncoder().encode(process.env.JWT_SECRET || ""); | |
| } | |
| // Test seam for cookie store injection without affecting runtime behavior. | |
| export const authRouteInternals = { | |
| getCookieStore: cookies, | |
| }; | |
| export async function POST(request) { | |
| const auditContext = getAuditRequestContext(request); | |
| try { | |
| // Fail-fast if JWT_SECRET is not configured | |
| if (!process.env.JWT_SECRET) { | |
| logAuditEvent({ | |
| action: "auth.login.misconfigured", | |
| actor: "system", | |
| target: "dashboard-auth", | |
| resourceType: "auth_session", | |
| status: "failed", | |
| ipAddress: auditContext.ipAddress || undefined, | |
| requestId: auditContext.requestId, | |
| metadata: { reason: "missing_jwt_secret" }, | |
| }); | |
| return NextResponse.json( | |
| { error: "Server misconfigured: JWT_SECRET not set. Contact administrator." }, | |
| { status: 500 } | |
| ); | |
| } | |
| let rawBody; | |
| try { | |
| rawBody = await request.json(); | |
| } catch { | |
| return NextResponse.json( | |
| { | |
| error: { | |
| message: "Invalid request", | |
| details: [{ field: "body", message: "Invalid JSON body" }], | |
| }, | |
| }, | |
| { status: 400 } | |
| ); | |
| } | |
| // Zod validation | |
| const validation = validateBody(loginSchema, rawBody); | |
| if (isValidationFailure(validation)) { | |
| return NextResponse.json({ error: validation.error }, { status: 400 }); | |
| } | |
| const password = typeof validation.data.password === "string" ? validation.data.password : ""; | |
| if (!password) { | |
| return NextResponse.json({ error: "Invalid password payload" }, { status: 400 }); | |
| } | |
| const settings = await getSettings(); | |
| const bruteForceEnabled = settings.bruteForceProtection !== false; | |
| const clientIp = auditContext.ipAddress || null; | |
| const guardCheck = checkLoginGuard(clientIp, { enabled: bruteForceEnabled }); | |
| if (!guardCheck.allowed) { | |
| logAuditEvent({ | |
| action: "auth.login.locked", | |
| actor: "anonymous", | |
| target: "dashboard-auth", | |
| resourceType: "auth_session", | |
| status: "failed", | |
| ipAddress: clientIp || undefined, | |
| requestId: auditContext.requestId, | |
| metadata: { retryAfterSeconds: guardCheck.retryAfterSeconds || 0 }, | |
| }); | |
| return NextResponse.json( | |
| { error: "Too many failed attempts. Try again later." }, | |
| { | |
| status: 429, | |
| headers: guardCheck.retryAfterSeconds | |
| ? { "Retry-After": String(guardCheck.retryAfterSeconds) } | |
| : {}, | |
| } | |
| ); | |
| } | |
| const passwordState = await ensurePersistentManagementPasswordHash({ | |
| settings, | |
| source: "auth.login", | |
| }); | |
| const storedHash = getStoredManagementPassword(passwordState.settings); | |
| if (!storedHash) { | |
| logAuditEvent({ | |
| action: "auth.login.setup_required", | |
| actor: "anonymous", | |
| target: "dashboard-auth", | |
| resourceType: "auth_session", | |
| status: "failed", | |
| ipAddress: auditContext.ipAddress || undefined, | |
| requestId: auditContext.requestId, | |
| metadata: { reason: "missing_persisted_password" }, | |
| }); | |
| return NextResponse.json( | |
| { error: "No password configured. Complete onboarding first.", needsSetup: true }, | |
| { status: 403 } | |
| ); | |
| } | |
| const isValid = await verifyManagementPassword(password, storedHash); | |
| if (isValid) { | |
| const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true"; | |
| const forwardedProtoHeader = request.headers.get("x-forwarded-proto") || ""; | |
| const forwardedProto = forwardedProtoHeader.split(",")[0].trim().toLowerCase(); | |
| const isHttpsRequest = forwardedProto === "https" || request.nextUrl?.protocol === "https:"; | |
| const useSecureCookie = forceSecureCookie || isHttpsRequest; | |
| const token = await new SignJWT({ authenticated: true }) | |
| .setProtectedHeader({ alg: "HS256" }) | |
| .setExpirationTime("30d") | |
| .sign(getJwtSecret()); | |
| const cookieStore = await authRouteInternals.getCookieStore(); | |
| cookieStore.set("auth_token", token, { | |
| httpOnly: true, | |
| secure: useSecureCookie, | |
| sameSite: "lax", | |
| path: "/", | |
| }); | |
| logAuditEvent({ | |
| action: "auth.login.success", | |
| actor: "admin", | |
| target: "dashboard-auth", | |
| resourceType: "auth_session", | |
| status: "success", | |
| ipAddress: auditContext.ipAddress || undefined, | |
| requestId: auditContext.requestId, | |
| metadata: { | |
| hasStoredPassword: Boolean(storedHash), | |
| passwordMigrated: passwordState.migrated, | |
| secureCookie: useSecureCookie, | |
| }, | |
| }); | |
| clearLoginAttempts(clientIp); | |
| return NextResponse.json({ success: true }); | |
| } | |
| const failureDecision = recordLoginFailure(clientIp, { enabled: bruteForceEnabled }); | |
| logAuditEvent({ | |
| action: "auth.login.failed", | |
| actor: "anonymous", | |
| target: "dashboard-auth", | |
| resourceType: "auth_session", | |
| status: "failed", | |
| ipAddress: auditContext.ipAddress || undefined, | |
| requestId: auditContext.requestId, | |
| metadata: { reason: "invalid_password", lockedOut: failureDecision.allowed === false }, | |
| }); | |
| if (!failureDecision.allowed) { | |
| return NextResponse.json( | |
| { error: "Too many failed attempts. Try again later." }, | |
| { | |
| status: 429, | |
| headers: failureDecision.retryAfterSeconds | |
| ? { "Retry-After": String(failureDecision.retryAfterSeconds) } | |
| : {}, | |
| } | |
| ); | |
| } | |
| return NextResponse.json({ error: "Invalid password" }, { status: 401 }); | |
| } catch (error) { | |
| console.error("[AUTH] Login failed:", error); | |
| logAuditEvent({ | |
| action: "auth.login.error", | |
| actor: "system", | |
| target: "dashboard-auth", | |
| resourceType: "auth_session", | |
| status: "failed", | |
| ipAddress: auditContext.ipAddress || undefined, | |
| requestId: auditContext.requestId, | |
| metadata: { | |
| message: error instanceof Error ? error.message : "unknown_error", | |
| }, | |
| }); | |
| return NextResponse.json({ error: "Internal server error" }, { status: 500 }); | |
| } | |
| } | |