344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
101 lines
6.5 KiB
TypeScript
101 lines
6.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import bcrypt from "bcryptjs";
|
||
import { prisma } from "@/lib/db";
|
||
import { pwVersion, setSessionCookie } from "@/lib/session";
|
||
import { mintTicket } from "@/lib/twofactor";
|
||
import { sameOriginJson } from "@/lib/csrf";
|
||
import { clientIp, fail, over } from "@/lib/ratelimit";
|
||
import { signInStaff } from "@/lib/staffauth";
|
||
import { verifyTurnstile } from "@/lib/turnstile";
|
||
import { recordAuthEvent } from "@/lib/audit";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
// Simple in-memory throttle per IP+email (per process).
|
||
const attempts = new Map<string, { n: number; t: number }>();
|
||
|
||
/** The trail names the person, not the address they typed — see lib/audit.ts. */
|
||
const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) =>
|
||
({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email });
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||
let body: { email?: string; password?: string; cfToken?: string };
|
||
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);
|
||
// Spray protection independent of the per-(ip,email) counter below. Both buckets count only the
|
||
// attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a
|
||
// ceiling on attempts would have to lock that ward out to be worth anything against an attacker.
|
||
const ipKey = clientIp(req.headers);
|
||
if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) 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 });
|
||
|
||
// nginx appends the real client IP last; earlier entries are client-supplied and spoofable.
|
||
const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
|
||
const ip = xff[xff.length - 1] || "local";
|
||
if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk);
|
||
const k = `${ip}|${email}`;
|
||
const a = attempts.get(k);
|
||
if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||
|
||
const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||
const u = await prisma.user.findUnique({ where: { email } });
|
||
/* One box, both kinds of account.
|
||
*
|
||
* A wearer reaches the product the way anyone else does — the home page, then Log in — and types
|
||
* the details they set up in the staff app. So when this address has no coordinator account, the
|
||
* register is asked before the answer is called wrong.
|
||
*
|
||
* A coordinator account always wins: it is the one with the counter, the orders and the register
|
||
* behind it, and a coordinator who also wears a uniform can open their own record from inside the
|
||
* app. One address therefore has one destination, every time.
|
||
*
|
||
* This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff
|
||
* one" would score a failure against every single staff sign-in, and these ceilings count
|
||
* failures — behind one hospital's NAT address at shift change that is a locked-out ward.
|
||
*/
|
||
if (!u) {
|
||
const s = await signInStaff(email, password, ipKey, false);
|
||
if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true });
|
||
if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status });
|
||
// `none`: no staff account either, so this falls through to the answer below, which counts the
|
||
// failure once and says the same thing it has always said.
|
||
}
|
||
const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u");
|
||
if (!u || !ok) {
|
||
attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() });
|
||
fail("login-ip:" + ipKey, 15 * 60 * 1000);
|
||
if (email) fail("login-email:" + email, 15 * 60 * 1000);
|
||
// An address with no account here is recorded nowhere: there is no facility to file it under,
|
||
// and a log of attempts on addresses that don't exist would be a list of other people's email
|
||
// addresses that nobody asked us to keep.
|
||
if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey);
|
||
return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 });
|
||
}
|
||
attempts.delete(k);
|
||
if (u.inactive) {
|
||
// The right password on an account that has been taken away is worth knowing about.
|
||
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive");
|
||
return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 });
|
||
}
|
||
const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } });
|
||
if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts can’t log in here — open the demo from the home page." }, { status: 403 });
|
||
// The facility has decided its people sign in through its own identity provider. The password
|
||
// was right, and it is still refused — except for the admin the facility keeps as its fire
|
||
// escape. The box sends them on to single sign-on rather than reporting a failure.
|
||
if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) {
|
||
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required");
|
||
return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 });
|
||
}
|
||
// With a second factor on the account the password alone opens nothing. The ticket says only
|
||
// "this password was correct", is accepted by no other endpoint, and expires in five minutes.
|
||
if (u.totpEnabledAt) {
|
||
return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) });
|
||
}
|
||
|
||
await setSessionCookie(u.id, u.passwordHash);
|
||
recordAuthEvent(actorFor(u), "auth:signin", ipKey, "password");
|
||
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role });
|
||
}
|