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 });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { clearOpsCookie, currentOperator, logOperatorEvent } from "@/lib/ops/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Ends the operator session. Reached by a plain form post from the console, so it answers with a
|
||||
* redirect rather than JSON. Recorded in the trail when there was a session to end. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const op = await currentOperator();
|
||||
if (op) logOperatorEvent({ operatorId: op.id, action: "ops:signout", ip: clientIp(req.headers) });
|
||||
await clearOpsCookie();
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/ops/login"; url.search = "";
|
||||
return NextResponse.redirect(url, { status: 303 });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { safeOpsNext, verifyAccessJwt } from "@/lib/ops/cfAccess";
|
||||
import { logOperatorEvent, setOpsCookie } from "@/lib/ops/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Single sign-on entry. Reached when a browser arrives with a Cloudflare Access assertion but no
|
||||
* operator session (the sign-in page hands off here). The assertion is verified fail-closed
|
||||
* (lib/ops/cfAccess.ts), the verified email is mapped to an EXISTING, ACTIVE operator, and the
|
||||
* ordinary tc_ops session is minted, marked `sso`. Never creates an operator: an email Access
|
||||
* admits but the console does not know falls back to the password door. Every failure lands on
|
||||
* /ops/login?sso=failed — never a bypass, never a loop (the sign-in page does not re-trigger SSO
|
||||
* when ?sso is present).
|
||||
*
|
||||
* Relative Location on purpose: an absolute URL built from the request would carry the origin
|
||||
* nginx sees (127.0.0.1:3000), not the public host. */
|
||||
function seeOther(location: string): NextResponse {
|
||||
return new NextResponse(null, { status: 303, headers: { Location: location } });
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const failed = seeOther("/ops/login?sso=failed");
|
||||
const next = safeOpsNext(req.nextUrl.searchParams.get("next"));
|
||||
const ip = clientIp(req.headers);
|
||||
|
||||
const email = await verifyAccessJwt(req.headers.get("cf-access-jwt-assertion"));
|
||||
if (!email) return failed;
|
||||
|
||||
const op = await prisma.operator.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, inactive: true, passwordHash: true },
|
||||
});
|
||||
if (!op || op.inactive) {
|
||||
// The trail cannot name an operator it does not have; the address goes in the detail, since
|
||||
// "who Access let in that the console refused" is the fact worth keeping.
|
||||
console.warn("[ops sso] no active operator for", email, "from", ip);
|
||||
return failed;
|
||||
}
|
||||
|
||||
await setOpsCookie(op.id, op.passwordHash, true);
|
||||
logOperatorEvent({ operatorId: op.id, action: "ops:signin.sso", ip });
|
||||
prisma.operator.update({ where: { id: op.id }, data: { lastSeenAt: new Date() } }).catch(() => {});
|
||||
return seeOther(next);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import QRCode from "qrcode";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify } from "@/lib/totp";
|
||||
import { currentOperator, logOperatorEvent } from "@/lib/ops/session";
|
||||
import { decryptOpsSecret, encryptOpsSecret } from "@/lib/ops/totp";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* An operator's second factor: the same three steps as a coordinator's (app/api/2fa/route.ts),
|
||||
* for the same reason — a secret stored the moment it is generated leaves an account
|
||||
* half-enrolled if the person never finishes, and their next sign-in asks for codes from an app
|
||||
* they never set up.
|
||||
*
|
||||
* setup — generate a secret and show the QR. Stored, but not yet in force.
|
||||
* enable — prove a code from it works, switch it on, hand back recovery codes once.
|
||||
* disable — password required; turning a factor off is a privileged act.
|
||||
* regenerate — password required; new recovery codes, old ones gone.
|
||||
*
|
||||
* The secret is encrypted under the console's own key (lib/ops/totp.ts), never the product's.
|
||||
* The QR is generated here as SVG, as the product does it: the secret never has to be handed to
|
||||
* client-side code to render. */
|
||||
export async function GET() {
|
||||
const op = await currentOperator();
|
||||
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const left = await prisma.operatorRecoveryCode.count({ where: { operatorId: op.id, usedAt: null } });
|
||||
return NextResponse.json({ enabled: op.totpEnabled, recoveryLeft: left, viaSso: op.viaSso });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
const op = await currentOperator();
|
||||
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("ops-2fa-manage:" + op.id, 30, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { action?: unknown; code?: unknown; password?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const action = String(body.action ?? "");
|
||||
|
||||
const o = await prisma.operator.findUnique({
|
||||
where: { id: op.id },
|
||||
select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
|
||||
});
|
||||
if (!o) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
if (action === "setup") {
|
||||
if (o.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 });
|
||||
const secret = newTotpSecret();
|
||||
await prisma.operator.update({ where: { id: o.id }, data: { totpSecret: encryptOpsSecret(secret) } });
|
||||
const url = otpauthUrl(secret, o.email, "ThreadCount ops");
|
||||
const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
|
||||
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.setup", ip });
|
||||
return NextResponse.json({ ok: true, secret, url, qr });
|
||||
}
|
||||
|
||||
if (action === "enable") {
|
||||
if (o.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 });
|
||||
const secret = decryptOpsSecret(o.totpSecret);
|
||||
if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 });
|
||||
if (!totpVerify(secret, String(body.code ?? "").replace(/\s+/g, ""))) {
|
||||
return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 });
|
||||
}
|
||||
const codes = newRecoveryCodes();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.operator.update({ where: { id: o.id }, data: { totpEnabledAt: new Date() } });
|
||||
await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } });
|
||||
await tx.operatorRecoveryCode.createMany({ data: codes.map((c) => ({ operatorId: o.id, codeHash: hashRecoveryCode(c) })) });
|
||||
});
|
||||
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.enable", ip });
|
||||
// The only time these are ever readable. Stored hashed, so there is no second chance.
|
||||
return NextResponse.json({ ok: true, codes });
|
||||
}
|
||||
|
||||
if (action === "disable") {
|
||||
if (!o.totpEnabledAt) return NextResponse.json({ ok: true });
|
||||
const pw = String(body.password ?? "");
|
||||
if (!pw || !(await bcrypt.compare(pw, o.passwordHash))) {
|
||||
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.operator.update({ where: { id: o.id }, data: { totpEnabledAt: null, totpSecret: "" } });
|
||||
await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } });
|
||||
});
|
||||
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.disable", ip });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (action === "regenerate") {
|
||||
if (!o.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 });
|
||||
const pw = String(body.password ?? "");
|
||||
if (!pw || !(await bcrypt.compare(pw, o.passwordHash))) {
|
||||
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
|
||||
}
|
||||
const codes = newRecoveryCodes();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } });
|
||||
await tx.operatorRecoveryCode.createMany({ data: codes.map((c) => ({ operatorId: o.id, codeHash: hashRecoveryCode(c) })) });
|
||||
});
|
||||
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.regenerate", ip });
|
||||
return NextResponse.json({ ok: true, codes });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { currentOperator } from "@/lib/ops/session";
|
||||
import { ControlError, deleteFacility, PLAN_NOTE_MAX, planControl, resetDemoNow, setSwitch, type PlanAct } from "@/lib/ops/controls";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The console's one write endpoint. Each action is a function in lib/ops/controls.ts; this route
|
||||
* checks the operator, shapes the input and turns a ControlError into a status. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
const op = await currentOperator();
|
||||
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("ops-controls:" + op.id, 30, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many changes — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const s = (k: string, max = 200) => String(body[k] ?? "").slice(0, max);
|
||||
const facilityId = s("facilityId", 40);
|
||||
const idOk = /^[a-z0-9]{20,40}$/.test(facilityId);
|
||||
|
||||
try {
|
||||
switch (s("action", 40)) {
|
||||
case "switch": {
|
||||
const key = s("key", 40);
|
||||
if (key !== "signupsDisabled" && key !== "demoDisabled" && key !== "plansLive") return NextResponse.json({ error: "Unknown switch" }, { status: 400 });
|
||||
await setSwitch(op, key, body.value === true, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
case "demo.reset":
|
||||
await resetDemoNow(op, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
case "plan": {
|
||||
if (!idOk) return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
||||
const num = (k: string) => Number(body[k]);
|
||||
let a: PlanAct;
|
||||
switch (s("act", 20)) {
|
||||
case "set": a = { act: "set", plan: s("plan", 40), planNote: s("planNote", PLAN_NOTE_MAX + 1), grandfathered: typeof body.grandfathered === "boolean" ? body.grandfathered : undefined }; break;
|
||||
case "trial": a = { act: "trial", days: num("days") }; break;
|
||||
case "paid": a = { act: "paid", months: num("months") }; break;
|
||||
case "readonly": a = { act: "readonly", on: body.on === true }; break;
|
||||
case "free": a = { act: "free" }; break;
|
||||
default: return NextResponse.json({ error: "Unknown plan action" }, { status: 400 });
|
||||
}
|
||||
await planControl(op, facilityId, a, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
case "facility.delete": {
|
||||
if (!idOk) return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
||||
const r = await deleteFacility(op, facilityId, s("confirm", 200), s("code", 20), ip);
|
||||
return NextResponse.json({ ok: true, deleted: r.name });
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ControlError) return NextResponse.json({ error: e.message }, { status: e.status });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { currentOperator } from "@/lib/ops/session";
|
||||
import { opsDb } from "@/lib/ops/db";
|
||||
import { grantReveal, REASON_MIN, REASON_MAX, REVEAL_MINUTES } from "@/lib/ops/reveal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Open a thirty-minute window on one facility's coordinator contacts. The whole act — grant row,
|
||||
* trail row, email — is lib/ops/reveal.ts; this route only checks the operator, the facility and
|
||||
* the reason, and answers. The contacts are not in the response: the page reads them, through the
|
||||
* reveal role, on its next render. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
const op = await currentOperator();
|
||||
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
// Ten an hour: a reveal is a considered act, and a run of them across facilities is exactly the
|
||||
// pattern the limit exists to slow down.
|
||||
if (!allow("ops-reveal:" + op.id, 10, 60 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many reveals in the last hour." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { facilityId?: unknown; reason?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const facilityId = String(body.facilityId ?? "").trim();
|
||||
const reason = String(body.reason ?? "").trim();
|
||||
if (!/^[a-z0-9]{20,40}$/.test(facilityId)) return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
||||
if (reason.length < REASON_MIN) {
|
||||
return NextResponse.json({ error: `Give a reason — at least ${REASON_MIN} characters. It goes in the trail and in the email.` }, { status: 400 });
|
||||
}
|
||||
if (reason.length > REASON_MAX) return NextResponse.json({ error: `Keep the reason under ${REASON_MAX} characters.` }, { status: 400 });
|
||||
|
||||
// The facility's name and kind come from the ordinary role; nothing here reads a contact.
|
||||
const f = await opsDb().facility.findUnique({ where: { id: facilityId }, select: { id: true, name: true, isDemo: true } });
|
||||
if (!f) return NextResponse.json({ error: "No such facility" }, { status: 404 });
|
||||
if (f.isDemo) return NextResponse.json({ error: "The demo facility has no real contacts to reveal." }, { status: 400 });
|
||||
|
||||
const r = await grantReveal({ operator: op, facilityId: f.id, facilityName: f.name, reason, ip });
|
||||
return NextResponse.json({ ok: true, minutes: REVEAL_MINUTES, expiresAt: r.expiresAt.toISOString(), mailed: r.mailed });
|
||||
}
|
||||
Reference in New Issue
Block a user