ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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 doesn’t 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 isn’t 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 isn’t 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user