ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "./db";
|
||||
import { fail, over } from "./ratelimit";
|
||||
import { setStaffCookie } from "./staffsession";
|
||||
import { recordAuthEvent } from "./audit";
|
||||
|
||||
/* Signing a wearer in, from either door.
|
||||
*
|
||||
* There are two ways into the staff app now: /my/signin, which is where the printed slip and the
|
||||
* Play app send people, and the ordinary Log in box on the website, which a wearer reaches by
|
||||
* clicking Log in on the home page like anybody else. Both end in the same session, so both go
|
||||
* through here rather than through two copies of the same twenty lines — the refusal wording, the
|
||||
* failure buckets and the audit events have to say the same thing whichever door was used, and the
|
||||
* way that stops being true is by living in two places.
|
||||
*/
|
||||
|
||||
// A constant to compare against when there is no account, so a missing email and a wrong password
|
||||
// take the same time.
|
||||
const DUMMY = "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u";
|
||||
|
||||
/** The trail names the person on the register, not the account — see lib/audit.ts. */
|
||||
type Acc = { facilityId: string; staff: { id: string; first: string; last: string } };
|
||||
const actorFor = (a: Acc, email: string) =>
|
||||
({ facilityId: a.facilityId, userId: a.staff.id, userName: `${a.staff.first} ${a.staff.last}`.trim() || email });
|
||||
|
||||
/* Failures only, not attempts: every wearer in the hospital arrives from one NAT address at shift
|
||||
* change, so a ceiling on attempts would lock out the ward it is meant to protect.
|
||||
*
|
||||
* Its own function because the staff door asks it *before* verifying Turnstile, and a token is good
|
||||
* for one use — asking afterwards would spend somebody's check to tell them they are throttled.
|
||||
* `over` only reads the bucket, so asking twice on one request costs nothing. */
|
||||
export function staffThrottled(email: string, ip: string): boolean {
|
||||
return over("staff-login-ip:" + ip, 40, 15 * 60 * 1000) || (!!email && over("staff-login-email:" + email, 25, 15 * 60 * 1000));
|
||||
}
|
||||
|
||||
export type StaffSignIn =
|
||||
/** No staff account with this address. The caller decides what that means. */
|
||||
| { kind: "none" }
|
||||
| { kind: "error"; error: string; status: number }
|
||||
| { kind: "ok"; name: string };
|
||||
|
||||
/**
|
||||
* Check an address and password against the staff register and, if they match, set the session.
|
||||
*
|
||||
* `ownDoor` says whether this is /api/staff/login itself. It decides one thing only: what happens
|
||||
* when the address has no staff account at all. At the staff door that is a plain wrong answer, so
|
||||
* it costs a dummy compare and a counted failure like any other. At the shared Log in box it is
|
||||
* ordinary — most people typing there are coordinators — so it returns `none` having touched
|
||||
* nothing, and the coordinator path does its own compare and counts its own failure. Counting in
|
||||
* both places would spend two of somebody's eight attempts on one wrong password.
|
||||
*/
|
||||
export async function signInStaff(email: string, password: string, ip: string, ownDoor: boolean): Promise<StaffSignIn> {
|
||||
const acc = await prisma.staffAccount.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } },
|
||||
});
|
||||
if (!acc && !ownDoor) return { kind: "none" };
|
||||
|
||||
if (staffThrottled(email, ip)) {
|
||||
return { kind: "error", error: "Too many attempts — try again in 15 minutes.", status: 429 };
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(password, acc?.passwordHash ?? DUMMY);
|
||||
if (!acc || !ok) {
|
||||
fail("staff-login-ip:" + ip, 15 * 60 * 1000);
|
||||
if (email) fail("staff-login-email:" + email, 15 * 60 * 1000);
|
||||
// An address with no account here goes unrecorded: nothing names a facility to file it under.
|
||||
if (acc) recordAuthEvent(actorFor(acc, email), "staff:signin.failed", ip);
|
||||
return { kind: "error", error: "Email or password doesn’t match.", status: 401 };
|
||||
}
|
||||
if (acc.staff.inactive) {
|
||||
recordAuthEvent(actorFor(acc, email), "staff:signin.refused", ip, "inactive");
|
||||
return { kind: "error", error: "You're no longer on the register at this facility. Ask the linen room.", status: 403 };
|
||||
}
|
||||
|
||||
await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } });
|
||||
await setStaffCookie(acc.id, acc.passwordHash);
|
||||
recordAuthEvent(actorFor(acc, email), "staff:signin", ip, "password");
|
||||
return { kind: "ok", name: `${acc.staff.first} ${acc.staff.last}` };
|
||||
}
|
||||
Reference in New Issue
Block a user