mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'fix/step-up-and-session-revocation' into release/v1.39.3
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/pl.json
This commit is contained in:
commit
03ccb21227
96 files changed
+3238
-495
No files matched your search
@@ -44,6 +44,8 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
ip: "1.2.3.4",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true, remaining: 9 })),
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, ip: "1.2.3.4" }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
refundRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -21,6 +26,15 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
|
||||
|
||||
/**
|
||||
* Failed password sign-ins one account takes per window, from any number of
|
||||
* addresses. Twice the per-IP ceiling, so the owner mistyping from one network
|
||||
* meets that one first, and small enough that guessing is hopeless.
|
||||
*/
|
||||
const LOGIN_ACCOUNT_BUCKET = "auth:login:account";
|
||||
const LOGIN_ACCOUNT_LIMIT = 10;
|
||||
const LOGIN_ACCOUNT_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
// button — otherwise a pre-existing password (or a leaked/reused one)
|
||||
@@ -91,6 +105,42 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// A per-account ceiling beside the per-IP one. The IP bucket alone is only
|
||||
// as good as the address it is keyed on, and a guesser with many addresses
|
||||
// (or a proxy that forwards whatever address the caller claims) could try
|
||||
// one account's password without end. This bucket follows the account
|
||||
// wherever the guesses come from.
|
||||
//
|
||||
// Keyed on the account when the identifier resolves and on the identifier's
|
||||
// hash when it does not, with the same ceiling, so a locked answer says
|
||||
// nothing about whether the account exists. Charged before the Argon2id
|
||||
// verification (a locked account costs no hashing) and refunded on a
|
||||
// successful sign-in, so it counts failures: the owner signing in does not
|
||||
// use it up. While it is spent, a passkey or single sign-on still works.
|
||||
const accountBucket = user
|
||||
? `${LOGIN_ACCOUNT_BUCKET}:u:${user.id}`
|
||||
: `${LOGIN_ACCOUNT_BUCKET}:i:${hashToken(identifier.toLowerCase())}`;
|
||||
const accountRl = await checkRateLimit(
|
||||
accountBucket,
|
||||
LOGIN_ACCOUNT_LIMIT,
|
||||
LOGIN_ACCOUNT_WINDOW_MS,
|
||||
);
|
||||
if (!accountRl.allowed) {
|
||||
await auditLog("auth.login.account_throttled", {
|
||||
userId: user?.id,
|
||||
ipAddress: ip,
|
||||
details: { identifierHash },
|
||||
});
|
||||
annotate({ action: { name: "auth.login.account_throttled" } });
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(accountRl) },
|
||||
);
|
||||
}
|
||||
|
||||
// One verification for every outcome. An unknown identifier and an
|
||||
// account carrying no password hash (a passkey-only account) used to
|
||||
// return here before any hashing, so the three answers cost one indexed
|
||||
@@ -132,6 +182,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// The password was right: this attempt was not a failed guess.
|
||||
await refundRateLimit(accountBucket);
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
|
||||
@@ -76,10 +76,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
|
||||
it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => {
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
{
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
},
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
});
|
||||
|
||||
it("spares the CALLER's own device login on the Bearer path", async () => {
|
||||
@@ -99,10 +103,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
|
||||
const res = await POST(req());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "hash-of-caller-access-token",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
{
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "hash-of-caller-access-token",
|
||||
},
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
});
|
||||
|
||||
it("spends the elevation before tearing the factor down", async () => {
|
||||
|
||||
@@ -118,6 +118,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
|
||||
await auditLog("auth.mfa.disabled", {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyTotp } from "@/lib/auth/mfa/totp";
|
||||
@@ -110,6 +111,12 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
// A confirmed TOTP secret was refused above, so the only second factor the
|
||||
// account can already hold is a security key.
|
||||
const hadSecondFactor =
|
||||
(await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
})) > 0;
|
||||
|
||||
// The code verified and the activation is next — spend the elevation here so
|
||||
// a wrong code above costs the caller nothing.
|
||||
@@ -141,6 +148,19 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
// admin-enforced forced-enrollment redirect must clear immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// Turning on the first second factor ends every other sign-in, as a password
|
||||
// change does: a session opened before the factor existed, perhaps by
|
||||
// whoever made the owner turn it on, must not keep going without it.
|
||||
if (!hadSecondFactor) {
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.enabled", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
@@ -57,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// failed ceremony above must not have cost the caller their elevation.
|
||||
await auth.commitElevation();
|
||||
|
||||
const hadSecondFactor =
|
||||
user.totpConfirmedAt !== null ||
|
||||
(await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
})) > 0;
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
const created = await prisma.webauthnMfaCredential.create({
|
||||
data: {
|
||||
@@ -76,6 +83,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// so clear any forced-enrollment redirect immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// Turning on the first second factor ends every other sign-in, as a password
|
||||
// change does: a session opened before the factor existed, perhaps by
|
||||
// whoever made the owner turn it on, must not keep going without it.
|
||||
if (!hadSecondFactor) {
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
auth.transport === "cookie"
|
||||
? { kind: "session", sessionId: auth.session.id }
|
||||
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.webauthn.register", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -163,16 +163,21 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 3,
|
||||
accessTokensRevoked: 0,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.sessionsRevoked).toBe(3);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
|
||||
kind: "session",
|
||||
sessionId: "sess-current",
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ kind: "session", sessionId: "sess-current" },
|
||||
// Everything, share links included, when the client says nothing: the
|
||||
// shipped app sends no parameter and gets the safe default.
|
||||
{ reach: "everything", keepShareLinks: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("names a Bearer caller by its access token so its own device login is spared", async () => {
|
||||
@@ -198,15 +203,18 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 1,
|
||||
accessTokensRevoked: 1,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: hashToken(raw),
|
||||
});
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ kind: "accessToken", accessTokenHash: hashToken(raw) },
|
||||
{ reach: "everything", keepShareLinks: false },
|
||||
);
|
||||
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* GET /api/auth/me/sessions — list the user's active web sessions.
|
||||
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
|
||||
* session (and native refresh tokens), keeping
|
||||
* the caller's current session. Closes #64.
|
||||
* session, device login, AI-assistant
|
||||
* connection and token, and (unless
|
||||
* `?keepShareLinks=1`) every clinician share
|
||||
* link, keeping the caller's current
|
||||
* credential. Closes #64.
|
||||
*
|
||||
* v1.23 — the user-facing session/device-management surface. Distinct from
|
||||
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
|
||||
@@ -114,15 +117,35 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
: { kind: "session", sessionId: auth.session.id };
|
||||
|
||||
const { sessionsRevoked, accessTokensRevoked } = await destroyOtherSessions(
|
||||
user.id,
|
||||
current,
|
||||
);
|
||||
// "Everywhere" reaches every credential that works without signing in: AI
|
||||
// assistant connections, programmatic tokens and, unless the caller asks to
|
||||
// keep them, clinician share links. The default is to revoke, because the
|
||||
// person pressing this is often doing it after losing a device or a session,
|
||||
// and a share link made by whoever held it would otherwise keep working. A
|
||||
// client that sends nothing (the shipped app) gets the safe default.
|
||||
const keepShareLinks =
|
||||
new URL(request.url).searchParams.get("keepShareLinks") === "1";
|
||||
|
||||
const {
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
} = await destroyOtherSessions(user.id, current, {
|
||||
reach: "everything",
|
||||
keepShareLinks,
|
||||
});
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sessionsRevoked, accessTokensRevoked },
|
||||
details: {
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
keepShareLinks,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -130,10 +153,17 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
sessions_revoked: sessionsRevoked,
|
||||
access_tokens_revoked: accessTokensRevoked,
|
||||
connectors_revoked: connectorsRevoked,
|
||||
share_links_revoked: shareLinksRevoked,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ sessionsRevoked });
|
||||
return apiSuccess({
|
||||
sessionsRevoked,
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* POST /api/auth/reproof
|
||||
*
|
||||
* A browser session re-proves a credential the account holds, so that for the
|
||||
* next five minutes it may export the whole record, make a share link, mint a
|
||||
* token, or run an admin backup, restore, wipe or reset. Those routes answer
|
||||
* 401 `auth.reproof.required` with `meta.methods` when the session carries no
|
||||
* recent proof; the web opens the re-proof dialog, posts the proof here, and
|
||||
* retries.
|
||||
*
|
||||
* What a proof stamps on the session row:
|
||||
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
|
||||
* same stamp a completed second factor or passkey sign-in writes, from the
|
||||
* same factor, so it also clears the fresh-factor routes, exactly as
|
||||
* signing in again would.
|
||||
* - `password`: `reproofAt` only. Accepted only on an account without a
|
||||
* second factor; an account with one is told which proofs it can give
|
||||
* instead (422 `auth.reproof.too_weak`), because a password has never
|
||||
* stood in for the second factor.
|
||||
*
|
||||
* Cookie-only. The Bearer transport has its own proof flow (`POST
|
||||
* /api/auth/step-up`, token-bound single-use elevations), and a stamp on a
|
||||
* session row means nothing to a token.
|
||||
*
|
||||
* GET asks the same question the gated routes ask, without acting: 200 when
|
||||
* the session's proof is recent, otherwise the same 401 `auth.reproof.required`
|
||||
* with `meta.methods`. The web calls it before a large upload, so the file is
|
||||
* not sent twice around a re-proof.
|
||||
*
|
||||
* Proofs draw on the account's shared re-proof budget (five per fifteen
|
||||
* minutes, the same bucket as the step-up mint and the enrollment routes) and
|
||||
* every refusal is audited. Failures are indistinguishable on the wire.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
recentProofMethods,
|
||||
requireCookieAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
recordReproofFailure,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
const ip = getClientIp(request);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const proof = parsed.data;
|
||||
|
||||
// Refuse a proof that could not satisfy the gate BEFORE spending a guess on
|
||||
// it: a password on an account with a second factor would verify and still
|
||||
// leave every gated action refused.
|
||||
const methods = await recentProofMethods(user);
|
||||
if (!methods.includes(proof.method)) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.method_refused" },
|
||||
meta: { method: proof.method },
|
||||
});
|
||||
return apiError("Use one of the listed ways to confirm it is you", 422, {
|
||||
errorCode: "auth.reproof.too_weak",
|
||||
methods,
|
||||
});
|
||||
}
|
||||
|
||||
const limited = await throttleReproof(user.id, ip, "reproof");
|
||||
if (limited) return limited;
|
||||
|
||||
const outcome = await verifyExistingFactorProof(user, proof);
|
||||
if (!outcome.ok) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ip,
|
||||
"reproof",
|
||||
proof.method,
|
||||
outcome.reason,
|
||||
);
|
||||
return apiError("Verification failed", 401, {
|
||||
errorCode: REPROOF_FAILED_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data:
|
||||
proof.method === "password"
|
||||
? { reproofAt: now }
|
||||
: { reproofAt: now, mfaVerifiedAt: now },
|
||||
});
|
||||
|
||||
await auditLog("auth.reproof.succeeded", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: proof.method },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.succeeded" },
|
||||
meta: { method: proof.method },
|
||||
});
|
||||
|
||||
return apiSuccess({ method: proof.method, verifiedAt: now.toISOString() });
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
return apiSuccess({ recent: true });
|
||||
});
|
||||
Reference in new issue
Block a user