mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
"Sign out everywhere" ended web sessions and device logins but left every
credential that works without signing in: a connected AI assistant kept its
OAuth connection and minted the next access token from it, a connector or
ingest token kept working, and a clinician share link kept opening the
record. A password change revoked the tokens but not the assistant's
connection or the share links. Anything a stolen session had minted
outlived the remedy.
- Sign out everywhere now ends every other sign-in, every AI-assistant
connection, every programmatic token except the caller's own, and every
clinician share link. Share links are revoked by default; the web offers
a checkbox to keep them (keepShareLinks=1), and a client that sends
nothing, such as the shipped app, gets the safe default.
- A password change and an admin password reset also end AI-assistant
connections, share links and step-up elevations.
- Turning on the first second factor (TOTP or a security key) ends every
other sign-in, as a password change does. Adding a further factor does
not.
destroyOtherSessions takes an explicit reach ("sign-ins" or "everything")
so each caller says how far it goes.
133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
/**
|
|
* POST /api/auth/me/mfa/disable
|
|
*
|
|
* Tear down the second factor. Double-gated:
|
|
* 1. `requireMfaManagementAuth({ freshFactor: true })` — a cookie session that
|
|
* completed a second factor within the step-up window, OR a Bearer token
|
|
* presenting a single-use elevation it minted by re-proving a factor.
|
|
* 2. A current TOTP or recovery code in the body — proves live possession at
|
|
* the moment of the destructive action, not just a stale fresh-session.
|
|
*
|
|
* On success it clears `totpSecretEncrypted` / `totpConfirmedAt` /
|
|
* `totpLastStep` and deletes every recovery code in one transaction.
|
|
*/
|
|
import {
|
|
apiHandler,
|
|
AUTH_ERROR_CODES,
|
|
requireMfaManagementAuth,
|
|
HttpError,
|
|
} from "@/lib/api-handler";
|
|
import {
|
|
apiError,
|
|
apiSuccess,
|
|
apiValidationError,
|
|
getClientIp,
|
|
safeJson,
|
|
sanitiseZodIssues,
|
|
} from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { prisma } from "@/lib/db";
|
|
import { destroyOtherSessions } from "@/lib/auth/session";
|
|
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
|
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
|
import { mfaDisableSchema } from "@/lib/validations/mfa";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const DISABLE_RATE_LIMIT = 5;
|
|
const DISABLE_WINDOW_MS = 15 * 60 * 1000;
|
|
|
|
export const POST = apiHandler(async (req: Request) => {
|
|
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
|
// session is not freshly second-factor-verified.
|
|
const auth = await requireMfaManagementAuth({ freshFactor: true });
|
|
const { user } = auth;
|
|
|
|
const rl = await checkRateLimit(
|
|
`mfa:disable:${user.id}`,
|
|
DISABLE_RATE_LIMIT,
|
|
DISABLE_WINDOW_MS,
|
|
);
|
|
if (!rl.allowed) {
|
|
const res = apiError("Too many requests", 429);
|
|
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
|
res.headers.set(k, v);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
const { data: body, error: jsonError } = await safeJson(req, {
|
|
maxBytes: 4096,
|
|
});
|
|
if (jsonError) {
|
|
throw new HttpError(400, "Invalid JSON body");
|
|
}
|
|
const parsed = mfaDisableSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return apiValidationError(
|
|
"Invalid request",
|
|
sanitiseZodIssues(parsed.error.issues),
|
|
422,
|
|
);
|
|
}
|
|
|
|
const factor = await verifyMfaFactor(
|
|
user,
|
|
parsed.data.method,
|
|
parsed.data.code,
|
|
);
|
|
if (!factor.ok) {
|
|
await auditLog("auth.mfa.failed", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(req),
|
|
details: {
|
|
stage: "disable",
|
|
method: parsed.data.method,
|
|
replay: factor.replay,
|
|
},
|
|
});
|
|
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
|
|
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
|
|
}
|
|
|
|
// Every cheap check has passed and the teardown is next, so spend the
|
|
// elevation now. Placing it here rather than at the gate means a wrong code or
|
|
// a 429 above costs the caller nothing.
|
|
await auth.commitElevation();
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
totpSecretEncrypted: null,
|
|
totpConfirmedAt: null,
|
|
totpLastStep: null,
|
|
},
|
|
});
|
|
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
|
});
|
|
|
|
// v1.23 (M-review L3) — removing the second factor is a security-state
|
|
// change: drop every OTHER web session, every native refresh token, and
|
|
// every "remember this device" trusted device (all handled by
|
|
// destroyOtherSessions), keeping only the caller's current session. A stale
|
|
// session or a trusted-device cookie must not survive the factor's removal.
|
|
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.disabled", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(req),
|
|
details: { factor: parsed.data.method },
|
|
});
|
|
annotate({ action: { name: "auth.mfa.disabled" } });
|
|
|
|
return apiSuccess({ enabled: false });
|
|
});
|