import { NextResponse } from "next/server"; import { changePasswordForUser } from "@/lib/auth/accounts"; import { requireCsrf } from "@/lib/auth/csrf"; import { authError, rateLimited, requireDatabase } from "@/lib/auth/http"; import { clientIp, rateLimit } from "@/lib/auth/rateLimit"; import { logSecurityEventAsync, SecurityEventType, } from "@/lib/auth/securityEvents"; import { createSession, getCurrentUser } from "@/lib/auth/session"; import { validatePassword } from "@/lib/auth/validation"; import { MAX_JSON_BODY_BYTES } from "@/lib/limits"; import { readJsonSized } from "@/lib/http/requestSize"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; interface ChangePasswordBody { currentPassword?: string; newPassword?: string; remember?: boolean; } /** * Logged-in password change with full session rotation. * * Security semantics — why every old token dies: * * 1. The CURRENT password is re-checked server-side (`changePasswordForUser`), * so a leaked session cookie alone cannot change the credential: the caller * must prove they know the existing password. * 2. On success `changePasswordForUser` deletes EVERY session row for the * account. Any device that was already signed in — including one an * attacker gained access through — is signed out instantly, with no * re-login required to evict it. * 3. The current device is then handed a BRAND-NEW session token via * `createSession`. The user stays signed in where they are, but their old * cookie value is dead; only the freshly issued one authenticates. * 4. This endpoint mutates credentials, so it is CSRF-guarded (`requireCsrf`: * origin allow-list + double-submit token) before anything else runs. * * `remember: true` is chosen deliberately: a password change keeps the same * long-lived session policy as a "keep me signed in" login instead of silently * downgrading the user's session lifetime to the default 12h. (The body may * carry a `remember` flag, but the server keeps the decision deterministic.) */ export async function POST(request: Request) { const csrfBlocked = requireCsrf(request); if (csrfBlocked) return csrfBlocked; const dbDown = await requireDatabase(); if (dbDown) return dbDown; try { const user = await getCurrentUser(request); if (!user) return authError("unauthorized", 401); const parsed = await readJsonSized(request, MAX_JSON_BODY_BYTES); if (!parsed.ok) { // Oversized bodies are rejected with 413 before anything is buffered; // parse failures keep the existing invalid_request/400 contract. return parsed.response.status === 413 ? parsed.response : authError("invalid_request", 400); } const body = parsed.body; if (!body?.currentPassword || !body?.newPassword) { return authError("invalid_request", 400); } // Server-side re-validation: the client checks are UX, never a control. if (validatePassword(body.newPassword, false, { requireStrength: true })) { return authError("weak_password", 400); } // Wrong-password guessing is itself a credential attack — slow it down. const ip = clientIp(request); const ipLimit = rateLimit(`change:ip:${ip}`, 10, 15 * 60 * 1000); if (!ipLimit.allowed) return rateLimited(ipLimit.retryAfter); const outcome = await changePasswordForUser( user.id, body.currentPassword, body.newPassword ); // Wire codes come from the shared AuthErrorCode vocabulary (errors.ts): // "wrong_password" (400) and "unauthorized" (401) are both defined there. if (outcome.status === "no_password") return authError("use_google", 409); if (outcome.status === "wrong_password") { // Wrong-current-password attempts are credential guessing, so they land // in the audit trail as failed-login events for monitoring. logSecurityEventAsync({ type: SecurityEventType.LOGIN_FAILED, userId: user.id, email: user.email, ip, userAgent: request.headers.get("user-agent"), metadata: { route: "/api/auth/change-password", reason: "wrong_password" }, }); return authError("wrong_password", 400); } if (outcome.status === "invalid") return authError("unauthorized", 401); logSecurityEventAsync({ type: SecurityEventType.PASSWORD_CHANGED, userId: user.id, email: user.email, ip, userAgent: request.headers.get("user-agent"), metadata: { route: "/api/auth/change-password" }, }); // Session rotation: every old token was deleted above; re-issue a fresh one // for the current device so the user is not signed out of the browser they // are sitting in front of. await createSession(user.id, { remember: true, userAgent: request.headers.get("user-agent"), ip, }); return NextResponse.json({ status: "password_changed" }); } catch (error) { console.error("[auth] password change failed", error); return authError("server_error", 500); } }