Files
threadcount-community/app/api/ops/auth/login/route.ts
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

101 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp, fail, over } from "@/lib/ratelimit";
import { setOpsCookie, logOperatorEvent } from "@/lib/ops/session";
import { verifyOperatorCode } from "@/lib/ops/totp";
import { hashRecoveryCode } from "@/lib/totp";
export const dynamic = "force-dynamic";
/* The break-glass door for the operations console.
*
* Single sign-on is the front door. This is the fire escape, and a fire escape must not depend
* on the thing that is on fire — so there is no Turnstile here. The sitekey is bound to the
* product's domain and compiled in at build time; on this hostname it would refuse with a
* generic "security check failed", discovered during the exact incident this route exists for.
*
* Instead: failures are counted under the console's own buckets. Not `login-ip:` — that is the
* product's, and a public credential-stuffing run against coordinator accounts must not be able
* to lock the operator out of the console. The limits are tighter than the product's because
* there is one operator, not a ward arriving at shift change.
*
* ⛔ This route mints an OPERATOR session and nothing else. It must never call setSessionCookie
* or setStaffCookie; an operator signing in as a customer is the data plane by another door. */
// A constant to compare against when there is no such operator, so a missing address and a
// wrong password take the same time.
const DUMMY = "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u";
const WINDOW = 15 * 60 * 1000;
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: unknown; password?: unknown; code?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
const password = String(body.password ?? "").slice(0, 200);
const code = body.code === undefined || body.code === null ? "" : String(body.code).slice(0, 16);
const ip = clientIp(req.headers);
if (over("ops-login-ip:" + ip, 20, WINDOW) || (email && over("ops-login-email:" + email, 10, WINDOW))) {
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
}
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
const op = await prisma.operator.findUnique({
where: { email },
select: { id: true, name: true, passwordHash: true, inactive: true, totpSecret: true, totpEnabledAt: true },
});
const ok = await bcrypt.compare(password, op?.passwordHash ?? DUMMY);
if (!op || !ok) {
fail("ops-login-ip:" + ip, WINDOW);
if (email) fail("ops-login-email:" + email, WINDOW);
if (op) logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "password", ip });
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
if (op.inactive) {
logOperatorEvent({ operatorId: op.id, action: "ops:signin.refused", detail: "inactive", ip });
return NextResponse.json({ error: "This operator account has been deactivated." }, { status: 403 });
}
// Once a second factor is enrolled, the password alone opens nothing. `needCode` tells the form
// to ask for it; a wrong code is a counted failure like a wrong password.
let second = "";
if (op.totpEnabledAt) {
if (!code) return NextResponse.json({ error: "Enter the code from your authenticator app.", needCode: true }, { status: 401 });
// Ten or more letters and digits is a recovery code; six digits is an authenticator code — the
// same heuristic as the coordinator door. A recovery code is spent in the same conditional
// update that finds it, so it cannot be used twice, and a spent or unknown one is a counted
// failure like any other.
const looksRecovery = code.replace(/[^A-Za-z0-9]/g, "").length >= 10;
if (looksRecovery) {
const spent = await prisma.operatorRecoveryCode.updateMany({
where: { operatorId: op.id, codeHash: hashRecoveryCode(code), usedAt: null },
data: { usedAt: new Date() },
});
if (spent.count !== 1) {
fail("ops-login-ip:" + ip, WINDOW);
fail("ops-login-email:" + email, WINDOW);
logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "recovery", ip });
return NextResponse.json({ error: "That recovery code isnt right, or has already been used.", needCode: true }, { status: 401 });
}
second = "recovery";
} else if (!verifyOperatorCode(op, code)) {
fail("ops-login-ip:" + ip, WINDOW);
fail("ops-login-email:" + email, WINDOW);
logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "totp", ip });
return NextResponse.json({ error: "That code isnt right.", needCode: true }, { status: 401 });
} else {
second = "totp";
}
}
await prisma.operator.update({ where: { id: op.id }, data: { lastSeenAt: new Date() } });
await setOpsCookie(op.id, op.passwordHash, false);
logOperatorEvent({ operatorId: op.id, action: "ops:signin", detail: second ? `password+${second}` : "password", ip });
return NextResponse.json({ ok: true, name: op.name });
}