mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
297 lines
11 KiB
TypeScript
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,
|
|
);
|
|
});
|