7d650e4c10
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
81 lines
4.2 KiB
TypeScript
81 lines
4.2 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/db";
|
|
import { cookies } from "next/headers";
|
|
import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session";
|
|
import { sameOriginJson } from "@/lib/csrf";
|
|
import { allow, clientIp } from "@/lib/ratelimit";
|
|
import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp";
|
|
import { TRUST_COOKIE, TRUST_TTL_MS, mintTrust, readTicket } from "@/lib/twofactor";
|
|
import { recordAuthEvent } from "@/lib/audit";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/* Second step of sign-in: the code from the authenticator, or one recovery code.
|
|
*
|
|
* Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if
|
|
* guessing is expensive — unthrottled, a million tries is minutes of work. */
|
|
export async function POST(req: NextRequest) {
|
|
const csrf = sameOriginJson(req);
|
|
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
|
|
|
const ip = clientIp(req.headers);
|
|
let body: { ticket?: unknown; code?: unknown; trust?: unknown; remember?: unknown };
|
|
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
|
|
|
const t = readTicket(String(body.ticket ?? ""));
|
|
if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
|
|
|
// Per account and per address: one stolen ticket can't be brute-forced, and one machine can't
|
|
// work through several accounts at once.
|
|
if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) {
|
|
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
|
}
|
|
|
|
const u = await prisma.user.findUnique({
|
|
where: { id: t.uid },
|
|
select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
|
|
});
|
|
if (!u || u.inactive || !u.totpEnabledAt) {
|
|
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
|
}
|
|
// The password changed between the two steps — the ticket is stale for the same reason a session
|
|
// would be.
|
|
if (pwVersion(u.passwordHash) !== t.pv) {
|
|
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
|
}
|
|
|
|
const raw = String(body.code ?? "").trim();
|
|
const secret = decryptSecret(u.totpSecret);
|
|
let good = !!secret && totpVerify(secret, raw);
|
|
let usedRecovery = false;
|
|
|
|
if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) {
|
|
// A recovery code. Single use: consumed in the same conditional update that finds it, so two
|
|
// simultaneous attempts can't both spend it.
|
|
const hash = hashRecoveryCode(raw);
|
|
const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } });
|
|
if (hit) {
|
|
const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } });
|
|
good = consumed.count === 1;
|
|
usedRecovery = good;
|
|
}
|
|
}
|
|
|
|
if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 });
|
|
|
|
await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined);
|
|
// "Trust this computer": only ever set here, after a real code, never from the password step.
|
|
if (body.trust === true) {
|
|
const jar = await cookies();
|
|
jar.set(TRUST_COOKIE, mintTrust(u.id, pwVersion(u.passwordHash)), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/api/auth", maxAge: Math.floor(TRUST_TTL_MS / 1000) });
|
|
}
|
|
// How they got in matters more here than anywhere else: a recovery code means the phone is gone,
|
|
// and a run of them means something else is going on.
|
|
recordAuthEvent(
|
|
{ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email },
|
|
"auth:signin", ip, usedRecovery ? "recovery" : "totp",
|
|
);
|
|
const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } });
|
|
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left });
|
|
}
|