Files
HealthLog/src/app/api/auth/register/route.ts
T
Marc-André Bombeck d2f47e3bb5 fix(auth): the refusal code joins the published catalogue
Two structural guards caught the new gate. The error-code catalogue is derived
from the route tree on every run and fails in both directions, so a code the
server emits and does not publish is a client that cannot branch on it; the
session-surface guard freezes the short list of files under `src/app` that
resolve a caller without `requireAuth`, and the register route now belongs on
it — `requireAuth` is the wrong gate there twice over, since it would throw at
the anonymous caller the route exists for and this route wants the ABSENCE of a
session.

The code is `auth.already_authenticated` rather than the bare
`already_authenticated`: the catalogue's own header rules that new codes are
dot.case, and it sits beside `auth.mfa.code_invalid` in the same family. The
OpenAPI document renders the catalogue into the ErrorEnvelope description, so
it is regenerated.
2026-09-11 09:51:32 +02:00

297 lines
11 KiB
TypeScript

import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import {
consumeInviteToken,
recordInviteConsumer,
} from "@/lib/auth/invite-token";
import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession, getSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block self-registration too — otherwise anyone can
// self-provision a fresh password account and bypass the operator's
// SSO-only policy outright, invite token or not.
if (isOidcOnly()) {
return apiError("Registration is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.38.19 — an invitation creates a NEW account, so it can never be
// accepted from inside a live session.
//
// The route used to read no session at all: a request carrying a valid
// `healthlog_session` was handled exactly like an anonymous one, and the
// unconditional `createSession` at the end overwrote the caller's cookie
// with the fresh account's. An admin opening his own invite link to check it
// was signed out of his admin account and into a stranger's onboarding, his
// previous session row still valid server-side and no longer reachable.
//
// Refused BEFORE the rate limit on purpose: this is a state, not an attempt,
// and spending the 5-per-15-minutes bucket on it would lock the door for the
// person the invitation is actually for, who may well share the IP.
// `getSession` is the optional resolver — it answers `null` for an anonymous
// caller instead of throwing, which `requireAuth` would.
const existingSession = await getSession();
if (existingSession) {
annotate({ action: { name: "auth.register.refused_authenticated" } });
return apiError(
"Already signed in — sign out first to use an invitation",
409,
{ errorCode: "auth.already_authenticated" },
);
}
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:register",
5,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{
data: null,
error: "Too many registration attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
await ensureDbCompatibility();
const userCount = await prisma.user.count();
// Check if registration is enabled.
// Safety valve: when there are zero users, always allow bootstrap registration.
let registrationEnabled = true;
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (settings && !settings.registrationEnabled && userCount > 0) {
registrationEnabled = false;
}
} catch {
// Table may not exist yet; allow registration
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const {
email,
username,
password,
timezone: timezoneInput,
inviteToken,
} = parsed.data;
// v1.15.20 — closed registration still admits a valid invite. The
// token itself is 32 random bytes behind a keyed hash and the surface
// is rate-limited (5 / 15 min / IP), so a distinct "invalid invite"
// message is a UX win, not an oracle.
if (!registrationEnabled && !inviteToken) {
return apiError("Registration is disabled", 403);
}
// v1.4.25 W7 — accept a browser-detected timezone from the
// registration form. Validate against the runtime IANA list; on
// any invalid value (or absence), fall back to the admin-
// configured server default. The `User.timezone` column has a
// hard-coded "Europe/Berlin" default at the schema layer, so the
// worst-case chain still produces a usable string.
let timezone: string;
const trimmedTimezone = timezoneInput?.trim() ?? "";
if (trimmedTimezone && isValidTimezone(trimmedTimezone)) {
timezone = trimmedTimezone;
} else {
timezone = await resolveServerDefaultTimezone();
}
// Check if email or username already taken (unified message to prevent enumeration)
const [existingEmail, existingUsername] = await Promise.all([
prisma.user.findUnique({ where: { email } }),
prisma.user.findUnique({ where: { username } }),
]);
if (existingEmail || existingUsername) {
return apiError("Username or email already taken", 409);
}
// Validate password strength (locale-aware feedback)
const locale = await resolveServerLocale({ request });
const strength = checkPasswordStrength(password, [username, email], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
// v1.23 — reject a registration password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const passwordHash = await hashPassword(password);
// v1.15.20 — consume the invite LAST, after every other validation,
// so a taken username or weak password can never burn one of the
// invite's uses. The guarded increment inside `consumeInviteToken`
// makes the last-use race safe under concurrent signups.
//
// v1.16.1 — consume whenever a token was sent, not only when the
// invite is the admission requirement. A signup through an invite
// link while registration happens to be open used to leave the
// invite untouched, so the issuer's ledger (uses, consumers) was
// silently incomplete. The consume stays a hard 403 only when
// registration is closed (the invite is the door key); under open
// registration a stale or exhausted token is annotated and the
// signup proceeds uninvited.
let inviteId: string | null = null;
if (inviteToken) {
const consumed = await consumeInviteToken(inviteToken);
if (consumed.ok) {
inviteId = consumed.inviteId;
} else {
annotate({
action: { name: "auth.register.invite_rejected" },
meta: {
reason: consumed.reason,
registration_open: registrationEnabled,
},
});
if (!registrationEnabled) {
return apiError("Invalid or expired invite", 403);
}
}
}
// v1.28.42 (M1) — the first registered user becomes ADMIN. Deriving that
// role from the early `userCount` (read at the top, before all the
// validation above) and then creating without coordination is a
// check-then-act race: on a freshly-exposed instance two registrations
// racing the empty-DB window both observe `0` and are BOTH minted ADMIN.
// Serialise the count+insert behind a transaction-scoped advisory lock
// (released on commit/rollback) and re-count *inside* the lock, so the
// second registration always observes the first's committed row and is
// minted USER. The lock scope is only the count + insert — none of the
// argon2 hash / HIBP / invite work above runs under it. Mirrors the
// advisory-lock single-flight in `drain-per-sample-cumulative.ts`.
//
// v1.28.42 (L4) — a concurrent same-email/username signup (both passed the
// findUnique probe above before either committed) surfaces the unique
// violation as `P2002`; map it to the unified 409 rather than an unhandled
// 500. The DB unique index is the real guard against duplicate accounts;
// this only fixes the status code under the race.
const user = await prisma
.$transaction(async (tx) => {
// `pg_advisory_xact_lock` returns void, which the client cannot
// deserialize as a column — selecting FROM it yields a plain int row.
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
`;
const priorUsers = await tx.user.count();
const role = priorUsers === 0 ? "ADMIN" : "USER";
return tx.user.create({
data: {
email,
username,
passwordHash,
role,
timezone,
},
});
})
.catch((err: unknown) => {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return null;
}
throw err;
});
if (user === null) {
return apiError("Username or email already taken", 409);
}
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
// `createSession` anchors the `hl_onboarding` cookie itself; fresh
// users are always pending so the proxy redirects on first
// navigation instead of waiting for hydration.
const ua = request.headers.get("user-agent");
await createSession(user.id, true, ip, ua);
// v1.23 — record the registering device silently so the account's first
// login from this same device does not immediately fire a "new device"
// alert. A genuinely new device later still alerts on its first sighting.
void recordSignInDevice({
userId: user.id,
ip,
userAgent: ua,
alertOnNew: false,
});
// Stamp the consumer onto the invite (informational, best-effort —
// the use itself was already counted atomically above).
if (inviteId) {
await recordInviteConsumer(inviteId, user.id);
}
await auditLog("auth.register", {
userId: user.id,
ipAddress: ip,
details: { method: "password", timezone, invited: inviteId !== null },
});
annotate({ action: { name: "auth.register" } });
return apiSuccess(
{
user: { id: user.id, username: user.username, email: user.email },
},
201,
);
});