Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
128
src/app/api/auth/change-password/route.ts
Normal file
128
src/app/api/auth/change-password/route.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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();
|
||||
if (!user) return authError("unauthorized", 401);
|
||||
|
||||
const parsed = await readJsonSized<ChangePasswordBody>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user