mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(auth): harden the second-factor verify and disable surfaces
Reject any challenge that is not a login challenge before minting a session, cap the disable and confirm request bodies through safeJson, and correct the recovery-code scan comment to match its early-return-on-match behaviour.
This commit is contained in:
1 parent
6d053c3667
commit
8f56ac95d8
4 files changed
+31
-12
No files matched your search
@@ -16,7 +16,12 @@ import {
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -47,10 +52,10 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
return res;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4096,
|
||||
});
|
||||
if (jsonError) {
|
||||
throw new HttpError(400, "Invalid JSON body");
|
||||
}
|
||||
const parsed = mfaDisableSchema.safeParse(body);
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -54,10 +59,10 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
throw new HttpError(409, "Start enrollment before confirming");
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4096,
|
||||
});
|
||||
if (jsonError) {
|
||||
throw new HttpError(400, "Invalid JSON body");
|
||||
}
|
||||
const parsed = totpConfirmSchema.safeParse(body);
|
||||
|
||||
@@ -70,6 +70,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
// Only a login-issued challenge may be redeemed for a session here. A future
|
||||
// step-up or webauthn challenge kind must never mint a login session through
|
||||
// this endpoint, so reject anything but "login" with the same generic 401.
|
||||
if (challenge.kind !== "login") {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
select: {
|
||||
|
||||
@@ -117,8 +117,9 @@ export async function verifyAndConsumeRecoveryCode(
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
// Argon2id verify is constant-time; iterate the full set so the match
|
||||
// position is not observable through timing.
|
||||
// Argon2id verify is constant-time per code. A match returns early (the 200
|
||||
// already reveals validity, so match-position timing leaks nothing useful);
|
||||
// a miss scans on, so a wrong code never reveals how many remain.
|
||||
const match = await verifyPassword(row.codeHash, normalised);
|
||||
if (!match) continue;
|
||||
|
||||
|
||||
Reference in new issue
Block a user