ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||
import { prisma } from "../db";
|
||||
|
||||
/* Sessions for the operations console.
|
||||
*
|
||||
* A third kind of session, kept apart from the other two the same way lib/staffsession.ts keeps
|
||||
* the wearer's apart from the coordinator's: its own cookie, a signing key derived from a
|
||||
* separate secret with a separate domain string, and a payload claim named `oid` where the others
|
||||
* carry `uid` and `sid`. A coordinator or staff cookie pasted into tc_ops fails signature
|
||||
* verification, and even if it somehow didn't, readOpsToken() rejects a payload with no `oid`.
|
||||
* There is no arrangement of the other two tokens that becomes this one — not because a condition
|
||||
* says no, but because the three are not the same shape.
|
||||
*
|
||||
* ⛔ The secret is OPS_SESSION_SECRET, never SESSION_SECRET. SESSION_SECRET also derives the key
|
||||
* that encrypts every customer's TOTP secret, so if this console shared it, a compromised operator
|
||||
* credential would force a rotation that destroys every facility's second factor. The console
|
||||
* must be revocable on its own.
|
||||
*
|
||||
* ⛔ The cookie is host-only — no Domain attribute — so it is sent to ops.threadcount.tech and
|
||||
* nowhere else. Domain=.threadcount.tech would hand it to analytics. and errors.threadcount.tech,
|
||||
* both real applications this product's CSP already trusts.
|
||||
*
|
||||
* ⛔ Nothing in here, and nothing in the console, may mint a coordinator or staff session. The
|
||||
* demo route shows how few lines that takes; an operator signing in as a customer is the data
|
||||
* plane by another door, and the product's privacy page says it never happens.
|
||||
*/
|
||||
|
||||
export const OPS_COOKIE = "tc_ops";
|
||||
// Eight hours: an owner console is tuned the opposite way from the two convenience sessions.
|
||||
const MAX_AGE = 60 * 60 * 8;
|
||||
|
||||
function secret() {
|
||||
const s = process.env.OPS_SESSION_SECRET;
|
||||
if (!s) throw new Error("OPS_SESSION_SECRET not set");
|
||||
// Domain separation, and a different master secret underneath it.
|
||||
return createHash("sha256").update("threadcount:ops:v1:" + s).digest();
|
||||
}
|
||||
|
||||
function b64url(buf: Buffer) {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/** Short fingerprint of the password hash: a password change invalidates every older token. */
|
||||
export function opsPwVersion(passwordHash: string) {
|
||||
return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12);
|
||||
}
|
||||
|
||||
/** `sso` marks a session that Cloudflare Access already put a second factor behind. */
|
||||
export function signOpsSession(oid: string, passwordHash: string, sso = false, maxAge = MAX_AGE) {
|
||||
const payload = b64url(Buffer.from(JSON.stringify({ oid, pv: opsPwVersion(passwordHash), sso, exp: Date.now() + maxAge * 1000 })));
|
||||
const sig = b64url(createHmac("sha256", secret()).update(payload).digest());
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
export function readOpsToken(raw: string | undefined): { oid: string; pv: string; sso: boolean } | null {
|
||||
if (!raw) return null;
|
||||
const [payload, sig] = raw.split(".");
|
||||
if (!payload || !sig) return null;
|
||||
const expect = b64url(createHmac("sha256", secret()).update(payload).digest());
|
||||
const a = Buffer.from(sig), b = Buffer.from(expect);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
|
||||
if (!data.oid || !data.exp || data.exp < Date.now()) return null;
|
||||
return { oid: data.oid, pv: String(data.pv || ""), sso: data.sso === true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setOpsCookie(oid: string, passwordHash: string, sso = false) {
|
||||
const jar = await cookies();
|
||||
jar.set(OPS_COOKIE, signOpsSession(oid, passwordHash, sso), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: MAX_AGE,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearOpsCookie() {
|
||||
const jar = await cookies();
|
||||
jar.set(OPS_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 });
|
||||
}
|
||||
|
||||
export type OperatorRole = "OWNER" | "SUPPORT";
|
||||
|
||||
export type OperatorSession = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: OperatorRole;
|
||||
/** The second factor was proven at the identity provider, not here. */
|
||||
viaSso: boolean;
|
||||
totpEnabled: boolean;
|
||||
};
|
||||
|
||||
/** Re-read per request rather than trusting the token beyond an id, like currentUser(). */
|
||||
export async function currentOperator(): Promise<OperatorSession | null> {
|
||||
const jar = await cookies();
|
||||
const tok = readOpsToken(jar.get(OPS_COOKIE)?.value);
|
||||
if (!tok) return null;
|
||||
const o = await prisma.operator.findUnique({
|
||||
where: { id: tok.oid },
|
||||
select: { id: true, email: true, name: true, role: true, inactive: true, passwordHash: true, totpEnabledAt: true },
|
||||
});
|
||||
if (!o || o.inactive) return null;
|
||||
if (tok.pv !== opsPwVersion(o.passwordHash)) return null; // password changed since this token was minted
|
||||
return { id: o.id, email: o.email, name: o.name, role: o.role, viaSso: tok.sso, totpEnabled: !!o.totpEnabledAt };
|
||||
}
|
||||
|
||||
const RANK: Record<OperatorRole, number> = { SUPPORT: 1, OWNER: 2 };
|
||||
|
||||
/** Throws rather than returns null so a route cannot forget to check. */
|
||||
export async function requireOperator(minRole: OperatorRole = "SUPPORT"): Promise<OperatorSession> {
|
||||
const o = await currentOperator();
|
||||
if (!o) throw new Error("UNAUTHENTICATED");
|
||||
if (RANK[o.role] < RANK[minRole]) throw new Error("FORBIDDEN");
|
||||
return o;
|
||||
}
|
||||
|
||||
/* The trail. Every write the console makes goes through here, and so does every reveal and every
|
||||
* sign-in. Never awaited by callers and never throws — a trail that could fail the action it
|
||||
* records would be a reason to skip recording it. `facilityId` is a plain string on purpose (see
|
||||
* the schema): a facility that leaves cannot take the record of what was looked at with it. */
|
||||
export function logOperatorEvent(e: { operatorId: string; action: string; facilityId?: string; subject?: string; detail?: string; ip?: string }): void {
|
||||
prisma.operatorEvent.create({
|
||||
data: {
|
||||
operatorId: e.operatorId,
|
||||
action: e.action.slice(0, 60),
|
||||
facilityId: (e.facilityId || "").slice(0, 40),
|
||||
subject: (e.subject || "").slice(0, 200),
|
||||
detail: (e.detail || "").slice(0, 400),
|
||||
ip: (e.ip || "").slice(0, 60),
|
||||
},
|
||||
}).catch(() => { /* the trail must not fail the action */ });
|
||||
}
|
||||
Reference in New Issue
Block a user