Files
threadcount-community/app/api/ops/auth/totp/route.ts
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

113 lines
5.8 KiB
TypeScript

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 });
}