Files
HealthLog/src/app/api/auth/password/route.ts
T
Marc-André BombeckandClaude Opus 4.7 b1ce6ebe81 refactor(auth): fold onboarding-cookie write into createSession + destroySession
The `setOnboardingPendingCookie` helper was called from six different
auth routes (login, passkey-verify, register, password, /me,
/onboarding/complete) by convention. A future auth surface (SSO,
magic link, social login) added without remembering the helper would
silently re-introduce the dashboard flash that v1.4.22 C4 was
designed to fix.

Make `onboardingPending` a required parameter on `createSession`
(not optional) so issuing a session without anchoring the cookie is
type-impossible. The five primary auth surfaces now thread the
user's `onboardingCompletedAt == null` value through; the
`/api/auth/me` mirror remains as a fall-back for legacy sessions
that predate the cookie. The `/api/onboarding/complete` clear stays
where it is — it is a value flip after a DB write, not a session
issuance.

Three new integration tests pin: createSession sets the cookie when
pending, clears it when completed, and destroySession clears it
alongside the session cookie.

Closes Sr-H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:11:07 +02:00

98 lines
2.7 KiB
TypeScript

import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
verifyPassword,
hashPassword,
checkPasswordStrength,
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`auth:password:${user.id}`,
5,
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError("New password must differ from current password", 422);
}
const locale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
});
const strength = checkPasswordStrength(
newPassword,
[user.username, user.email ?? ""],
locale,
);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const newHash = await hashPassword(newPassword);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: newHash },
});
// Invalidate all existing sessions and create a fresh one.
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the
// `hl_onboarding` cookie itself.
await destroyAllSessions(user.id);
await createSession(
user.id,
user.onboardingCompletedAt == null,
getClientIp(request),
request.headers.get("user-agent"),
);
await auditLog("auth.password.change", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "auth.password.change" } });
return apiSuccess({ changed: true });
});