Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e01074926e | |||
| d9a7ec0338 | |||
| 17471af7f8 | |||
| f0251a654a | |||
| fe79810a85 | |||
| 9dc1ffd3ad | |||
| 656c9329f7 |
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Retrigger marker: b2d89ca (docker-deploy smoke retry)
|
# Retrigger marker: fe79810 (Build log lost — retrigger to re-observe)
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { renderToBuffer } from "@react-pdf/renderer";
|
import { renderToBuffer } from "@react-pdf/renderer";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
|
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@capakraken/db";
|
||||||
@@ -11,6 +12,17 @@ import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
|
|||||||
|
|
||||||
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
|
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
|
||||||
|
|
||||||
|
// Reject fantasy dates from clients — years outside [2000, 2100] are almost
|
||||||
|
// certainly malformed input and would generate nonsensical SQL range scans.
|
||||||
|
const DATE_MIN = new Date("2000-01-01T00:00:00.000Z");
|
||||||
|
const DATE_MAX = new Date("2100-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
const queryParamsSchema = z.object({
|
||||||
|
startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
|
||||||
|
endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
|
||||||
|
format: z.enum(["pdf", "xlsx"]).default("pdf"),
|
||||||
|
});
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -23,9 +35,20 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date();
|
const parsed = queryParamsSchema.safeParse({
|
||||||
const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
startDate: searchParams.get("startDate") ?? undefined,
|
||||||
const format = searchParams.get("format") ?? "pdf";
|
endDate: searchParams.get("endDate") ?? undefined,
|
||||||
|
format: searchParams.get("format") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return new NextResponse("Invalid query parameters", { status: 400 });
|
||||||
|
}
|
||||||
|
const startDate = parsed.data.startDate ?? new Date();
|
||||||
|
const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||||
|
if (endDate < startDate) {
|
||||||
|
return new NextResponse("endDate must be >= startDate", { status: 400 });
|
||||||
|
}
|
||||||
|
const format = parsed.data.format;
|
||||||
|
|
||||||
const [demandRequirements, assignments] = await Promise.all([
|
const [demandRequirements, assignments] = await Promise.all([
|
||||||
prisma.demandRequirement.findMany({
|
prisma.demandRequirement.findMany({
|
||||||
@@ -62,21 +85,25 @@ export async function GET(request: Request) {
|
|||||||
const assignmentRows = allocationView.assignments.slice(0, 500);
|
const assignmentRows = allocationView.assignments.slice(0, 500);
|
||||||
const directory = await getAnonymizationDirectory(prisma);
|
const directory = await getAnonymizationDirectory(prisma);
|
||||||
|
|
||||||
const rows = assignmentRows.map((a: AllocationLike & {
|
const rows = assignmentRows.map(
|
||||||
resource?: { id: string; displayName?: string | null } | null;
|
(
|
||||||
project?: { shortCode: string; name: string } | null;
|
a: AllocationLike & {
|
||||||
}) => {
|
resource?: { id: string; displayName?: string | null } | null;
|
||||||
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
|
project?: { shortCode: string; name: string } | null;
|
||||||
return {
|
},
|
||||||
resourceName: resource?.displayName ?? "Unknown",
|
) => {
|
||||||
projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project",
|
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
|
||||||
role: a.role ?? "",
|
return {
|
||||||
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
|
resourceName: resource?.displayName ?? "Unknown",
|
||||||
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
|
projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project",
|
||||||
hoursPerDay: a.hoursPerDay,
|
role: a.role ?? "",
|
||||||
dailyCostCents: a.dailyCostCents,
|
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
|
||||||
};
|
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
|
||||||
});
|
hoursPerDay: a.hoursPerDay,
|
||||||
|
dailyCostCents: a.dailyCostCents,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { auth } from "~/server/auth.js";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// Bounded connection tracking: a single user opening 100 tabs should not be
|
||||||
|
// able to pin 100 persistent subscriptions on this node.
|
||||||
|
const MAX_SSE_CONNECTIONS_PER_USER = 8;
|
||||||
|
const sseConnectionsByUser = new Map<string, number>();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
// Start lazily on the first real SSE request so builds/import-time evaluation
|
// Start lazily on the first real SSE request so builds/import-time evaluation
|
||||||
// never attempt reminder processing against a live database.
|
// never attempt reminder processing against a live database.
|
||||||
@@ -43,6 +48,24 @@ export async function GET() {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentCount = sseConnectionsByUser.get(dbUser.id) ?? 0;
|
||||||
|
if (currentCount >= MAX_SSE_CONNECTIONS_PER_USER) {
|
||||||
|
return new Response("Too many SSE connections", {
|
||||||
|
status: 429,
|
||||||
|
headers: { "Retry-After": "30" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sseConnectionsByUser.set(dbUser.id, currentCount + 1);
|
||||||
|
|
||||||
|
const releaseSlot = () => {
|
||||||
|
const next = (sseConnectionsByUser.get(dbUser.id) ?? 1) - 1;
|
||||||
|
if (next <= 0) {
|
||||||
|
sseConnectionsByUser.delete(dbUser.id);
|
||||||
|
} else {
|
||||||
|
sseConnectionsByUser.set(dbUser.id, next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const roleDefaults = await loadRoleDefaults();
|
const roleDefaults = await loadRoleDefaults();
|
||||||
const subscription = deriveUserSseSubscription(
|
const subscription = deriveUserSseSubscription(
|
||||||
{
|
{
|
||||||
@@ -85,6 +108,7 @@ export async function GET() {
|
|||||||
} catch {
|
} catch {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
releaseSlot();
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
@@ -92,8 +116,12 @@ export async function GET() {
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
releaseSlot();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cancel() {
|
||||||
|
releaseSlot();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ function extractClientIp(req: NextRequest): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hard cap on tRPC request body size to prevent memory/CPU amplification from
|
||||||
|
// a single oversized payload. Stream uploads (files, reports) don't go through
|
||||||
|
// tRPC. 2 MiB is comfortably above any legitimate tRPC batch call.
|
||||||
|
const MAX_TRPC_BODY_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
// Throttle lastActiveAt updates: max once per 60s per user
|
// Throttle lastActiveAt updates: max once per 60s per user
|
||||||
const lastActiveCache = new Map<string, number>();
|
const lastActiveCache = new Map<string, number>();
|
||||||
const ACTIVITY_THROTTLE_MS = 60_000;
|
const ACTIVITY_THROTTLE_MS = 60_000;
|
||||||
@@ -37,6 +42,23 @@ function trackActivity(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (req: NextRequest) => {
|
const handler = async (req: NextRequest) => {
|
||||||
|
// Reject oversized bodies before we touch auth, DB, or the router. A tRPC
|
||||||
|
// mutation should never exceed MAX_TRPC_BODY_BYTES. Content-Length is
|
||||||
|
// advisory — also guard against chunked requests below via length check
|
||||||
|
// on the cloned body.
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
const declaredLength = req.headers.get("content-length");
|
||||||
|
if (declaredLength) {
|
||||||
|
const parsed = Number(declaredLength);
|
||||||
|
if (Number.isFinite(parsed) && parsed > MAX_TRPC_BODY_BYTES) {
|
||||||
|
return new Response(JSON.stringify({ error: "Request body too large" }), {
|
||||||
|
status: 413,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
// Validate active session registry on every authenticated request.
|
// Validate active session registry on every authenticated request.
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ export default function SignInPage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [totp, setTotp] = useState("");
|
const [totp, setTotp] = useState("");
|
||||||
|
const [backupCode, setBackupCode] = useState("");
|
||||||
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [mfaRequired, setMfaRequired] = useState(false);
|
const [mfaRequired, setMfaRequired] = useState(false);
|
||||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const backupCodeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -23,7 +26,8 @@ export default function SignInPage() {
|
|||||||
const result = await signIn("credentials", {
|
const result = await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
...(mfaRequired ? { totp } : {}),
|
...(mfaRequired && !useBackupCode ? { totp } : {}),
|
||||||
|
...(mfaRequired && useBackupCode ? { backupCode } : {}),
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,8 +51,13 @@ export default function SignInPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (code === "INVALID_TOTP") {
|
if (code === "INVALID_TOTP") {
|
||||||
setError("Invalid verification code. Please try again.");
|
setError(
|
||||||
|
useBackupCode
|
||||||
|
? "Invalid backup code. Please try again."
|
||||||
|
: "Invalid verification code. Please try again.",
|
||||||
|
);
|
||||||
setTotp("");
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -57,6 +66,8 @@ export default function SignInPage() {
|
|||||||
if (mfaRequired) {
|
if (mfaRequired) {
|
||||||
setMfaRequired(false);
|
setMfaRequired(false);
|
||||||
setTotp("");
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
|
setUseBackupCode(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Full-page navigation instead of router.push to guarantee a fresh
|
// Full-page navigation instead of router.push to guarantee a fresh
|
||||||
@@ -76,6 +87,8 @@ export default function SignInPage() {
|
|||||||
function handleBackToLogin() {
|
function handleBackToLogin() {
|
||||||
setMfaRequired(false);
|
setMfaRequired(false);
|
||||||
setTotp("");
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
|
setUseBackupCode(false);
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +196,7 @@ export default function SignInPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaRequired && (
|
{mfaRequired && !useBackupCode && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="totp" className="app-label">
|
<label htmlFor="totp" className="app-label">
|
||||||
Verification Code
|
Verification Code
|
||||||
@@ -209,22 +222,69 @@ export default function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mfaRequired && useBackupCode && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="backup-code" className="app-label">
|
||||||
|
Backup Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={backupCodeInputRef}
|
||||||
|
id="backup-code"
|
||||||
|
type="text"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={16}
|
||||||
|
value={backupCode}
|
||||||
|
onChange={(e) => setBackupCode(e.target.value.toUpperCase().slice(0, 16))}
|
||||||
|
className="app-input text-center text-xl font-mono tracking-[0.2em] uppercase"
|
||||||
|
placeholder="XXXXX-XXXXX"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Each backup code works once. You'll need to regenerate your codes after using
|
||||||
|
one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || (mfaRequired && totp.length !== 6)}
|
disabled={
|
||||||
|
loading ||
|
||||||
|
(mfaRequired && !useBackupCode && totp.length !== 6) ||
|
||||||
|
(mfaRequired && useBackupCode && backupCode.replace(/[\s-]/g, "").length < 8)
|
||||||
|
}
|
||||||
className="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-colors hover:bg-brand-700 disabled:opacity-50"
|
className="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-colors hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
|
{loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{mfaRequired && (
|
{mfaRequired && (
|
||||||
<button
|
<div className="flex flex-col gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={handleBackToLogin}
|
type="button"
|
||||||
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
onClick={() => {
|
||||||
>
|
setUseBackupCode((v) => !v);
|
||||||
Back to login
|
setError("");
|
||||||
</button>
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (useBackupCode) totpInputRef.current?.focus();
|
||||||
|
else backupCodeInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
|
className="w-full text-center text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
{useBackupCode ? "Use authenticator code instead" : "Use a backup code instead"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBackToLogin}
|
||||||
|
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Back to login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PASSWORD_MAX_LENGTH,
|
PASSWORD_MAX_LENGTH,
|
||||||
PASSWORD_MIN_LENGTH,
|
PASSWORD_MIN_LENGTH,
|
||||||
PASSWORD_POLICY_MESSAGE,
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
checkPasswordPolicy,
|
||||||
} from "@capakraken/shared";
|
} from "@capakraken/shared";
|
||||||
|
|
||||||
export type SetupResult =
|
export type SetupResult =
|
||||||
@@ -26,6 +27,13 @@ export async function createFirstAdmin(formData: {
|
|||||||
) {
|
) {
|
||||||
return { error: "validation", message: PASSWORD_POLICY_MESSAGE };
|
return { error: "validation", message: PASSWORD_POLICY_MESSAGE };
|
||||||
}
|
}
|
||||||
|
const policy = checkPasswordPolicy(formData.password, {
|
||||||
|
email: formData.email,
|
||||||
|
name: formData.name,
|
||||||
|
});
|
||||||
|
if (!policy.ok) {
|
||||||
|
return { error: "validation", message: policy.reason };
|
||||||
|
}
|
||||||
|
|
||||||
// TOCTOU guard — check again inside the action
|
// TOCTOU guard — check again inside the action
|
||||||
const count = await prisma.user.count();
|
const count = await prisma.user.count();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
type SetupStep = "idle" | "show-secret" | "verify" | "done";
|
type SetupStep = "idle" | "show-secret" | "verify" | "show-backup-codes" | "done";
|
||||||
|
|
||||||
export function MfaSetup() {
|
export function MfaSetup() {
|
||||||
const [step, setStep] = useState<SetupStep>("idle");
|
const [step, setStep] = useState<SetupStep>("idle");
|
||||||
@@ -12,6 +12,7 @@ export function MfaSetup() {
|
|||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
const [qrDataUrl, setQrDataUrl] = useState("");
|
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export function MfaSetup() {
|
|||||||
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
|
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
|
||||||
const generateMutation = trpc.user.generateTotpSecret.useMutation();
|
const generateMutation = trpc.user.generateTotpSecret.useMutation();
|
||||||
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
|
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
|
||||||
|
const regenerateBackupCodesMutation = trpc.user.regenerateBackupCodes.useMutation();
|
||||||
|
|
||||||
async function handleGenerate() {
|
async function handleGenerate() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -49,9 +51,9 @@ export function MfaSetup() {
|
|||||||
async function handleVerify() {
|
async function handleVerify() {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await verifyMutation.mutateAsync({ token });
|
const result = await verifyMutation.mutateAsync({ token });
|
||||||
setStep("done");
|
setBackupCodes(result.backupCodes ?? null);
|
||||||
setSuccess("MFA has been enabled successfully.");
|
setStep("show-backup-codes");
|
||||||
setSecret("");
|
setSecret("");
|
||||||
setUri("");
|
setUri("");
|
||||||
setToken("");
|
setToken("");
|
||||||
@@ -61,33 +63,111 @@ export function MfaSetup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mfaStatus?.totpEnabled && step !== "done") {
|
async function handleRegenerateBackupCodes() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await regenerateBackupCodesMutation.mutateAsync();
|
||||||
|
setBackupCodes(result.codes);
|
||||||
|
setStep("show-backup-codes");
|
||||||
|
await refetch();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not regenerate backup codes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFinishBackupCodes() {
|
||||||
|
setBackupCodes(null);
|
||||||
|
setStep("done");
|
||||||
|
setSuccess("MFA is active. Keep your backup codes in a safe place.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBackupCodes() {
|
||||||
|
if (!backupCodes) return;
|
||||||
|
void navigator.clipboard.writeText(backupCodes.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBackupCodes() {
|
||||||
|
if (!backupCodes) return;
|
||||||
|
const blob = new Blob(
|
||||||
|
[
|
||||||
|
`CapaKraken MFA Backup Codes\nGenerated: ${new Date().toISOString()}\n\nEach code works exactly once. Keep this file somewhere safe.\n\n${backupCodes.join("\n")}\n`,
|
||||||
|
],
|
||||||
|
{ type: "text/plain" },
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "capakraken-backup-codes.txt";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mfaStatus?.totpEnabled && step !== "done" && step !== "show-backup-codes") {
|
||||||
|
const remaining = mfaStatus.backupCodesRemaining ?? 0;
|
||||||
|
const lowCodes = remaining <= 3;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
|
<div className="flex items-center gap-3">
|
||||||
<svg
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
|
||||||
className="h-5 w-5 text-green-600 dark:text-green-400"
|
<svg
|
||||||
fill="none"
|
className="h-5 w-5 text-green-600 dark:text-green-400"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
|
||||||
|
MFA Enabled
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-400">
|
||||||
|
Two-factor authentication is active on your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border p-6 ${
|
||||||
|
lowCodes
|
||||||
|
? "border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20"
|
||||||
|
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Backup codes
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{remaining === 0
|
||||||
|
? "You have no backup codes left. Generate a new set to avoid being locked out if you lose your device."
|
||||||
|
: `You have ${remaining} backup code${remaining === 1 ? "" : "s"} remaining.`}{" "}
|
||||||
|
{lowCodes && remaining > 0 && <span className="font-medium">Regenerate soon.</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegenerateBackupCodes}
|
||||||
|
disabled={regenerateBackupCodesMutation.isPending}
|
||||||
|
className="shrink-0 inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<path
|
{regenerateBackupCodesMutation.isPending ? "Generating…" : "Regenerate codes"}
|
||||||
strokeLinecap="round"
|
</button>
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
|
|
||||||
MFA Enabled
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-green-700 dark:text-green-400">
|
|
||||||
Two-factor authentication is active on your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-2 text-sm text-red-700 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -250,6 +330,53 @@ export function MfaSetup() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{step === "show-backup-codes" && backupCodes && (
|
||||||
|
<div className="rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||||
|
Save your backup codes
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
Each code works exactly once. Store them in a password manager or print them. You will
|
||||||
|
not see them again — regenerating invalidates the whole set.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 rounded-lg bg-white dark:bg-gray-900 p-4 font-mono text-sm">
|
||||||
|
{backupCodes.map((code) => (
|
||||||
|
<code
|
||||||
|
key={code}
|
||||||
|
className="rounded bg-gray-100 dark:bg-gray-800 px-3 py-2 text-center tracking-wider select-all"
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyBackupCodes}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Copy all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadBackupCodes}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Download .txt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFinishBackupCodes}
|
||||||
|
className="ml-auto inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
I've saved them
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-39
@@ -2,6 +2,7 @@ import { prisma } from "@capakraken/db";
|
|||||||
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
|
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
|
||||||
import { createAuditEntry } from "@capakraken/api/lib/audit";
|
import { createAuditEntry } from "@capakraken/api/lib/audit";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
|
import { redeemBackupCode } from "@capakraken/api/lib/mfa-backup-code-redeem";
|
||||||
import { consumeTotpWindow } from "@capakraken/api/lib/totp-consume";
|
import { consumeTotpWindow } from "@capakraken/api/lib/totp-consume";
|
||||||
import NextAuth, { type NextAuthConfig } from "next-auth";
|
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
@@ -39,6 +40,10 @@ const LoginSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(1).max(128),
|
password: z.string().min(1).max(128),
|
||||||
totp: z.string().max(16).optional(),
|
totp: z.string().max(16).optional(),
|
||||||
|
// Backup codes are the second-factor fallback when the user has lost
|
||||||
|
// their TOTP device. Max 32 covers the 10-char code with dashes and
|
||||||
|
// accidental whitespace; anything longer is rejected before argon2.
|
||||||
|
backupCode: z.string().max(32).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function extractClientIp(request: Request | undefined): string | null {
|
function extractClientIp(request: Request | undefined): string | null {
|
||||||
@@ -68,7 +73,7 @@ const config = {
|
|||||||
const parsed = LoginSchema.safeParse(credentials);
|
const parsed = LoginSchema.safeParse(credentials);
|
||||||
if (!parsed.success) return null;
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
const { email, password, totp } = parsed.data;
|
const { email, password, totp, backupCode } = parsed.data;
|
||||||
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
|
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
|
||||||
|
|
||||||
// Rate limit: 5 attempts per 15 min, keyed on BOTH email and
|
// Rate limit: 5 attempts per 15 min, keyed on BOTH email and
|
||||||
@@ -156,57 +161,93 @@ const config = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MFA check: if TOTP is enabled, require the token
|
// MFA check: if TOTP is enabled, require a valid TOTP *or* a
|
||||||
|
// one-shot backup code. Backup codes are the last-resort credential
|
||||||
|
// when the user has lost their TOTP device; their redemption
|
||||||
|
// deletes the row atomically (see redeemBackupCode) so replay is
|
||||||
|
// physically impossible.
|
||||||
if (user.totpEnabled && user.totpSecret) {
|
if (user.totpEnabled && user.totpSecret) {
|
||||||
if (!totp) {
|
if (!totp && !backupCode) {
|
||||||
// Signal to the client that MFA is required (include userId for re-submission)
|
|
||||||
throw new MfaRequiredError();
|
throw new MfaRequiredError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { TOTP, Secret } = await import("otpauth");
|
if (backupCode) {
|
||||||
const totpInstance = new TOTP({
|
const result = await redeemBackupCode(prisma, user.id, backupCode);
|
||||||
issuer: "CapaKraken",
|
if (!result.accepted) {
|
||||||
label: user.email,
|
logger.warn(
|
||||||
algorithm: "SHA1",
|
{ email, reason: "invalid_backup_code" },
|
||||||
digits: 6,
|
"Failed MFA verification — backup code",
|
||||||
period: 30,
|
);
|
||||||
secret: Secret.fromBase32(user.totpSecret),
|
await createAuditEntry({
|
||||||
});
|
db: prisma,
|
||||||
|
entityType: "Auth",
|
||||||
const delta = totpInstance.validate({ token: totp, window: 1 });
|
entityId: user.id,
|
||||||
if (delta === null) {
|
entityName: user.email,
|
||||||
logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification");
|
action: "CREATE",
|
||||||
|
userId: user.id,
|
||||||
|
summary: "Login failed — invalid backup code",
|
||||||
|
source: "ui",
|
||||||
|
});
|
||||||
|
throw new InvalidTotpError();
|
||||||
|
}
|
||||||
await createAuditEntry({
|
await createAuditEntry({
|
||||||
db: prisma,
|
db: prisma,
|
||||||
entityType: "Auth",
|
entityType: "Auth",
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
entityName: user.email,
|
entityName: user.email,
|
||||||
action: "CREATE",
|
action: "UPDATE",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
summary: "Login failed — invalid TOTP token",
|
summary: `Backup code redeemed (${result.remaining} remaining)`,
|
||||||
source: "ui",
|
source: "ui",
|
||||||
});
|
});
|
||||||
throw new InvalidTotpError();
|
// Successful backup-code auth skips TOTP replay-window checks
|
||||||
}
|
// entirely — the code itself is the nonce.
|
||||||
|
} else {
|
||||||
|
const { TOTP, Secret } = await import("otpauth");
|
||||||
|
const totpInstance = new TOTP({
|
||||||
|
issuer: "CapaKraken",
|
||||||
|
label: user.email,
|
||||||
|
algorithm: "SHA1",
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
secret: Secret.fromBase32(user.totpSecret),
|
||||||
|
});
|
||||||
|
|
||||||
// Atomic replay-guard: a single UPDATE ... WHERE lastTotpAt is null
|
const delta = totpInstance.validate({ token: totp!, window: 1 });
|
||||||
// OR older than 30 s both serialises concurrent logins (row lock)
|
if (delta === null) {
|
||||||
// and expresses the "unused window" precondition in SQL. count=0
|
logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification");
|
||||||
// means another request consumed this window first → replay.
|
await createAuditEntry({
|
||||||
const accepted = await consumeTotpWindow(prisma, user.id);
|
db: prisma,
|
||||||
if (!accepted) {
|
entityType: "Auth",
|
||||||
logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked");
|
entityId: user.id,
|
||||||
await createAuditEntry({
|
entityName: user.email,
|
||||||
db: prisma,
|
action: "CREATE",
|
||||||
entityType: "Auth",
|
userId: user.id,
|
||||||
entityId: user.id,
|
summary: "Login failed — invalid TOTP token",
|
||||||
entityName: user.email,
|
source: "ui",
|
||||||
action: "CREATE",
|
});
|
||||||
userId: user.id,
|
throw new InvalidTotpError();
|
||||||
summary: "Login failed — TOTP replay detected",
|
}
|
||||||
source: "ui",
|
|
||||||
});
|
// Atomic replay-guard: a single UPDATE ... WHERE lastTotpAt is null
|
||||||
throw new InvalidTotpError();
|
// OR older than 30 s both serialises concurrent logins (row lock)
|
||||||
|
// and expresses the "unused window" precondition in SQL. count=0
|
||||||
|
// means another request consumed this window first → replay.
|
||||||
|
const accepted = await consumeTotpWindow(prisma, user.id);
|
||||||
|
if (!accepted) {
|
||||||
|
logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked");
|
||||||
|
await createAuditEntry({
|
||||||
|
db: prisma,
|
||||||
|
entityType: "Auth",
|
||||||
|
entityId: user.id,
|
||||||
|
entityName: user.email,
|
||||||
|
action: "CREATE",
|
||||||
|
userId: user.id,
|
||||||
|
summary: "Login failed — TOTP replay detected",
|
||||||
|
source: "ui",
|
||||||
|
});
|
||||||
|
throw new InvalidTotpError();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,18 +37,6 @@ describe("runtime env validation", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects the CI build-time placeholder that leaks from Dockerfile ARG default", () => {
|
|
||||||
expect(
|
|
||||||
getRuntimeEnvViolations({
|
|
||||||
NODE_ENV: "production",
|
|
||||||
NEXTAUTH_SECRET: "ci-build-placeholder-secret-minimum-32-chars",
|
|
||||||
NEXTAUTH_URL: "https://capakraken.example.com",
|
|
||||||
}),
|
|
||||||
).toContain(
|
|
||||||
"AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an auth secret shorter than the minimum length in production", () => {
|
it("rejects an auth secret shorter than the minimum length in production", () => {
|
||||||
expect(
|
expect(
|
||||||
getRuntimeEnvViolations({
|
getRuntimeEnvViolations({
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { getDevBypassViolations } from "@capakraken/api/lib/runtime-security";
|
import { getDevBypassViolations } from "@capakraken/api/lib/runtime-security";
|
||||||
|
|
||||||
|
// CI-only placeholders (e.g. `ci-test-secret-minimum-32-chars-xx`) are
|
||||||
|
// intentionally NOT listed here. They are 32+ chars of low-but-nonzero entropy
|
||||||
|
// and only ever set inside the CI workflow file under our own control; the
|
||||||
|
// length + Shannon-entropy gates below still reject genuinely weak prod
|
||||||
|
// secrets, and listing the CI value here just bricked our own build job
|
||||||
|
// (#109) when the workflow set NODE_ENV=production for `next build`.
|
||||||
const DISALLOWED_PRODUCTION_SECRETS = new Set([
|
const DISALLOWED_PRODUCTION_SECRETS = new Set([
|
||||||
"dev-secret-change-in-production",
|
"dev-secret-change-in-production",
|
||||||
"changeme",
|
"changeme",
|
||||||
"change-me",
|
"change-me",
|
||||||
"default",
|
"default",
|
||||||
"secret",
|
"secret",
|
||||||
"ci-build-placeholder-secret-minimum-32-chars",
|
|
||||||
"ci-test-secret-minimum-32-chars-xx",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// A cryptographically generated secret (openssl rand -base64 32 / -hex 32)
|
// A cryptographically generated secret (openssl rand -base64 32 / -hex 32)
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@
|
|||||||
"flatted": "^3.4.2",
|
"flatted": "^3.4.2",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"lodash-es": "^4.18.0",
|
"lodash-es": "^4.18.0",
|
||||||
"brace-expansion": "^5.0.5",
|
"brace-expansion@<2.0.2": ">=2.0.2",
|
||||||
"esbuild@<0.25.0": ">=0.25.0"
|
"esbuild@<0.25.0": ">=0.25.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"./lib/logger": "./src/lib/logger.ts",
|
"./lib/logger": "./src/lib/logger.ts",
|
||||||
"./lib/runtime-security": "./src/lib/runtime-security.ts",
|
"./lib/runtime-security": "./src/lib/runtime-security.ts",
|
||||||
"./lib/totp-consume": "./src/lib/totp-consume.ts",
|
"./lib/totp-consume": "./src/lib/totp-consume.ts",
|
||||||
|
"./lib/mfa-backup-code-redeem": "./src/lib/mfa-backup-code-redeem.ts",
|
||||||
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("assistant user self-service MFA tools - enable flow", () => {
|
|||||||
it("enables TOTP through the real user router path when the token is valid", async () => {
|
it("enables TOTP through the real user router path when the token is valid", async () => {
|
||||||
totpValidateMock.mockReturnValue(0);
|
totpValidateMock.mockReturnValue(0);
|
||||||
|
|
||||||
const db = {
|
const db: Record<string, unknown> = {
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "user_1",
|
id: "user_1",
|
||||||
@@ -56,6 +56,11 @@ describe("assistant user self-service MFA tools - enable flow", () => {
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
|
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
|
||||||
},
|
},
|
||||||
|
mfaBackupCode: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
createMany: vi.fn().mockResolvedValue({ count: 10 }),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn().mockImplementation(async (ops: unknown[]) => ops.map(() => ({}))),
|
||||||
};
|
};
|
||||||
const ctx = createToolContext(db, SystemRole.ADMIN);
|
const ctx = createToolContext(db, SystemRole.ADMIN);
|
||||||
|
|
||||||
@@ -99,11 +104,14 @@ describe("assistant user self-service MFA tools - enable flow", () => {
|
|||||||
summary: "Enabled TOTP MFA",
|
summary: "Enabled TOTP MFA",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(JSON.parse(result.content)).toEqual({
|
const parsed = JSON.parse(result.content);
|
||||||
success: true,
|
expect(parsed.success).toBe(true);
|
||||||
enabled: true,
|
expect(parsed.enabled).toBe(true);
|
||||||
message: "Enabled MFA TOTP.",
|
expect(parsed.message).toBe("Enabled MFA TOTP.");
|
||||||
});
|
expect(parsed.backupCodes).toHaveLength(10);
|
||||||
|
for (const code of parsed.backupCodes) {
|
||||||
|
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
|
||||||
|
}
|
||||||
expect(result.action).toEqual({
|
expect(result.action).toEqual({
|
||||||
type: "invalidate",
|
type: "invalidate",
|
||||||
scope: ["user"],
|
scope: ["user"],
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ describe("assistant user self-service MFA tools - status", () => {
|
|||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
mfaBackupCode: {
|
||||||
|
count: vi.fn().mockResolvedValue(3),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const ctx = createToolContext(db, SystemRole.ADMIN);
|
const ctx = createToolContext(db, SystemRole.ADMIN);
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ describe("assistant user self-service MFA tools - status", () => {
|
|||||||
});
|
});
|
||||||
expect(JSON.parse(result.content)).toEqual({
|
expect(JSON.parse(result.content)).toEqual({
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
|
backupCodesRemaining: 3,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +43,9 @@ describe("assistant user self-service MFA tools - status", () => {
|
|||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
|
mfaBackupCode: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
SystemRole.ADMIN,
|
SystemRole.ADMIN,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the MFA backup-code generator, canonicalisation, and the
|
||||||
|
* atomic redemption helper. Together they cover the three guarantees that
|
||||||
|
* make backup codes safe:
|
||||||
|
*
|
||||||
|
* 1. High-entropy, distinct plaintexts (generator).
|
||||||
|
* 2. Canonical form is what gets hashed/compared — a user can paste the
|
||||||
|
* code with or without the dash, upper or lower case.
|
||||||
|
* 3. Redemption deletes the row under a WHERE-guard so a concurrent
|
||||||
|
* second redemption fails (replay race).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
BACKUP_CODE_COUNT,
|
||||||
|
generatePlaintextBackupCodes,
|
||||||
|
hashBackupCode,
|
||||||
|
normalizeBackupCode,
|
||||||
|
verifyBackupCode,
|
||||||
|
} from "../lib/mfa-backup-codes.js";
|
||||||
|
import { redeemBackupCode } from "../lib/mfa-backup-code-redeem.js";
|
||||||
|
|
||||||
|
describe("generatePlaintextBackupCodes", () => {
|
||||||
|
it("yields BACKUP_CODE_COUNT distinct codes by default", () => {
|
||||||
|
const codes = generatePlaintextBackupCodes();
|
||||||
|
expect(codes).toHaveLength(BACKUP_CODE_COUNT);
|
||||||
|
expect(new Set(codes).size).toBe(BACKUP_CODE_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats each code as five chars, dash, five chars from the Crockford alphabet", () => {
|
||||||
|
for (const code of generatePlaintextBackupCodes(20)) {
|
||||||
|
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeBackupCode", () => {
|
||||||
|
it("strips dashes and whitespace and uppercases", () => {
|
||||||
|
expect(normalizeBackupCode("ab12c-xy34z")).toBe("AB12CXY34Z");
|
||||||
|
expect(normalizeBackupCode(" AB12C XY34Z ")).toBe("AB12CXY34Z");
|
||||||
|
expect(normalizeBackupCode("ab12cxy34z")).toBe("AB12CXY34Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyBackupCode", () => {
|
||||||
|
it("accepts the plaintext (with or without dash) that produced the hash", async () => {
|
||||||
|
const hash = await hashBackupCode("ABCDE-FGHJK");
|
||||||
|
expect(await verifyBackupCode(hash, "ABCDE-FGHJK")).toBe(true);
|
||||||
|
expect(await verifyBackupCode(hash, "abcde-fghjk")).toBe(true);
|
||||||
|
expect(await verifyBackupCode(hash, "ABCDEFGHJK")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a different plaintext", async () => {
|
||||||
|
const hash = await hashBackupCode("ABCDE-FGHJK");
|
||||||
|
expect(await verifyBackupCode(hash, "ZZZZZ-ZZZZZ")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false rather than throwing on a malformed hash", async () => {
|
||||||
|
expect(await verifyBackupCode("not-a-real-hash", "anything")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redeemBackupCode", () => {
|
||||||
|
it("accepts a valid code, deletes the row, and reports remaining count", async () => {
|
||||||
|
const goodHash = await hashBackupCode("GOOD1-CODE1");
|
||||||
|
const otherHash = await hashBackupCode("OTHER-CODE2");
|
||||||
|
const db = {
|
||||||
|
mfaBackupCode: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "a", codeHash: otherHash },
|
||||||
|
{ id: "b", codeHash: goodHash },
|
||||||
|
]),
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
count: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await redeemBackupCode(db, "user_1", "GOOD1-CODE1");
|
||||||
|
expect(result).toEqual({ accepted: true, remaining: 1 });
|
||||||
|
expect(db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: "b", usedAt: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown code without deleting anything", async () => {
|
||||||
|
const db = {
|
||||||
|
mfaBackupCode: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: "a", codeHash: await hashBackupCode("REAL1-CODE1") }]),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
count: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await redeemBackupCode(db, "user_1", "WRONG-CODE");
|
||||||
|
expect(result.accepted).toBe(false);
|
||||||
|
expect(result.remaining).toBe(1);
|
||||||
|
expect(db.mfaBackupCode.deleteMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a racing delete (count=0) as an invalid code", async () => {
|
||||||
|
// Simulates the case where another login request redeemed this exact
|
||||||
|
// code a millisecond earlier. The SQL WHERE-guard (usedAt: null) stops
|
||||||
|
// us from deleting it twice — we must treat that as a failed attempt
|
||||||
|
// so the attacker cannot learn the code was valid.
|
||||||
|
const goodHash = await hashBackupCode("RACE1-CODE1");
|
||||||
|
const db = {
|
||||||
|
mfaBackupCode: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "a", codeHash: goodHash }]),
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await redeemBackupCode(db, "user_1", "RACE1-CODE1");
|
||||||
|
expect(result.accepted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns accepted:false / remaining:0 when the user has no codes", async () => {
|
||||||
|
const db = {
|
||||||
|
mfaBackupCode: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await redeemBackupCode(db, "user_1", "ANY-CODE");
|
||||||
|
expect(result).toEqual({ accepted: false, remaining: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,13 +40,15 @@ describe("user-procedure-support", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("lists assignable users with the expected lightweight selection", async () => {
|
it("lists assignable users with the expected lightweight selection", async () => {
|
||||||
const findMany = vi.fn().mockResolvedValue([
|
const findMany = vi
|
||||||
{ id: "user_1", name: "Alice", email: "alice@example.com" },
|
.fn()
|
||||||
]);
|
.mockResolvedValue([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
|
||||||
|
|
||||||
const result = await listAssignableUsers(createContext({
|
const result = await listAssignableUsers(
|
||||||
user: { findMany },
|
createContext({
|
||||||
}));
|
user: { findMany },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
|
expect(result).toEqual([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
|
||||||
expect(findMany).toHaveBeenCalledWith({
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
@@ -56,12 +58,16 @@ describe("user-procedure-support", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("counts only users active within the trailing five minute window", async () => {
|
it("counts only users active within the trailing five minute window", async () => {
|
||||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
|
const nowSpy = vi
|
||||||
|
.spyOn(Date, "now")
|
||||||
|
.mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
|
||||||
const count = vi.fn().mockResolvedValue(4);
|
const count = vi.fn().mockResolvedValue(4);
|
||||||
|
|
||||||
const result = await countActiveUsers(createContext({
|
const result = await countActiveUsers(
|
||||||
user: { count },
|
createContext({
|
||||||
}));
|
user: { count },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ count: 4 });
|
expect(result).toEqual({ count: 4 });
|
||||||
expect(count).toHaveBeenCalledWith({
|
expect(count).toHaveBeenCalledWith({
|
||||||
@@ -80,9 +86,11 @@ describe("user-procedure-support", () => {
|
|||||||
createdAt: new Date("2026-03-30T08:00:00.000Z"),
|
createdAt: new Date("2026-03-30T08:00:00.000Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getCurrentUserProfile(createContext({
|
const result = await getCurrentUserProfile(
|
||||||
user: { findUnique },
|
createContext({
|
||||||
}));
|
user: { findUnique },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: "user_admin",
|
id: "user_admin",
|
||||||
@@ -108,17 +116,21 @@ describe("user-procedure-support", () => {
|
|||||||
it("unlinks an existing resource before linking the requested one", async () => {
|
it("unlinks an existing resource before linking the requested one", async () => {
|
||||||
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
||||||
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
|
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
|
||||||
const updateMany = vi.fn()
|
const updateMany = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce({ count: 1 })
|
.mockResolvedValueOnce({ count: 1 })
|
||||||
.mockResolvedValueOnce({ count: 1 });
|
.mockResolvedValueOnce({ count: 1 });
|
||||||
|
|
||||||
const result = await linkUserResource(createContext({
|
const result = await linkUserResource(
|
||||||
user: { findUnique: userFindUnique },
|
createContext({
|
||||||
resource: { findUnique: resourceFindUnique, updateMany },
|
user: { findUnique: userFindUnique },
|
||||||
}), {
|
resource: { findUnique: resourceFindUnique, updateMany },
|
||||||
userId: "user_1",
|
}),
|
||||||
resourceId: "resource_1",
|
{
|
||||||
});
|
userId: "user_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
expect(updateMany).toHaveBeenNthCalledWith(1, {
|
expect(updateMany).toHaveBeenNthCalledWith(1, {
|
||||||
@@ -142,9 +154,11 @@ describe("user-procedure-support", () => {
|
|||||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getDashboardLayout(createContext({
|
const result = await getDashboardLayout(
|
||||||
user: { findUnique },
|
createContext({
|
||||||
}));
|
user: { findUnique },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Widgets with unknown types normalise to empty → return null so client uses default
|
// Widgets with unknown types normalise to empty → return null so client uses default
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -159,11 +173,14 @@ describe("user-procedure-support", () => {
|
|||||||
});
|
});
|
||||||
const update = vi.fn().mockResolvedValue({});
|
const update = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
const result = await toggleFavoriteProject(createContext({
|
const result = await toggleFavoriteProject(
|
||||||
user: { findUnique, update },
|
createContext({
|
||||||
}), {
|
user: { findUnique, update },
|
||||||
projectId: "project_2",
|
}),
|
||||||
});
|
{
|
||||||
|
projectId: "project_2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
favoriteProjectIds: ["project_1", "project_2"],
|
favoriteProjectIds: ["project_1", "project_2"],
|
||||||
@@ -187,12 +204,15 @@ describe("user-procedure-support", () => {
|
|||||||
});
|
});
|
||||||
const update = vi.fn().mockResolvedValue({ id: "user_admin" });
|
const update = vi.fn().mockResolvedValue({ id: "user_admin" });
|
||||||
|
|
||||||
const result = await setColumnPreferences(createContext({
|
const result = await setColumnPreferences(
|
||||||
user: { findUnique, update },
|
createContext({
|
||||||
}), {
|
user: { findUnique, update },
|
||||||
view: "resources",
|
}),
|
||||||
visible: ["name", "email"],
|
{
|
||||||
});
|
view: "resources",
|
||||||
|
visible: ["name", "email"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
expect(update).toHaveBeenCalledWith({
|
expect(update).toHaveBeenCalledWith({
|
||||||
@@ -220,11 +240,14 @@ describe("user-procedure-support", () => {
|
|||||||
permissionOverrides: overrides,
|
permissionOverrides: overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getEffectiveUserPermissions(createContext({
|
const result = await getEffectiveUserPermissions(
|
||||||
user: { findUnique },
|
createContext({
|
||||||
}), {
|
user: { findUnique },
|
||||||
userId: "user_2",
|
}),
|
||||||
});
|
{
|
||||||
|
userId: "user_2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
systemRole: SystemRole.MANAGER,
|
systemRole: SystemRole.MANAGER,
|
||||||
@@ -234,14 +257,20 @@ describe("user-procedure-support", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports MFA status for the current user and throws when the user no longer exists", async () => {
|
it("reports MFA status for the current user and throws when the user no longer exists", async () => {
|
||||||
const findUnique = vi.fn()
|
const findUnique = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce({ totpEnabled: true })
|
.mockResolvedValueOnce({ totpEnabled: true })
|
||||||
.mockResolvedValueOnce(null);
|
.mockResolvedValueOnce(null);
|
||||||
|
const count = vi.fn().mockResolvedValue(7);
|
||||||
const ctx = createContext({
|
const ctx = createContext({
|
||||||
user: { findUnique },
|
user: { findUnique },
|
||||||
|
mfaBackupCode: { count },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({ totpEnabled: true });
|
await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({
|
||||||
|
totpEnabled: true,
|
||||||
|
backupCodesRemaining: 7,
|
||||||
|
});
|
||||||
await expect(getCurrentMfaStatus(ctx)).rejects.toMatchObject({
|
await expect(getCurrentMfaStatus(ctx)).rejects.toMatchObject({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "User not found",
|
message: "User not found",
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ function createAdminCaller(db: Record<string, unknown>) {
|
|||||||
// Individual tests can override by passing their own `activeSession` key.
|
// Individual tests can override by passing their own `activeSession` key.
|
||||||
const dbWithDefaults = {
|
const dbWithDefaults = {
|
||||||
activeSession: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
|
activeSession: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
|
||||||
|
mfaBackupCode: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
createMany: vi.fn().mockResolvedValue({ count: 10 }),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (ops: unknown[]) => ops),
|
||||||
...db,
|
...db,
|
||||||
};
|
};
|
||||||
return createCaller({
|
return createCaller({
|
||||||
@@ -735,7 +741,8 @@ describe("user profile and TOTP self-service", () => {
|
|||||||
|
|
||||||
const result = await caller.verifyAndEnableTotp({ token: "123456" });
|
const result = await caller.verifyAndEnableTotp({ token: "123456" });
|
||||||
|
|
||||||
expect(result).toEqual({ enabled: true });
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.backupCodes).toHaveLength(10);
|
||||||
// lastTotpAt is written atomically by updateMany (the replay guard);
|
// lastTotpAt is written atomically by updateMany (the replay guard);
|
||||||
// user.update only toggles the enabled flag after the CAS succeeds.
|
// user.update only toggles the enabled flag after the CAS succeeds.
|
||||||
expect(updateMany).toHaveBeenCalledWith(
|
expect(updateMany).toHaveBeenCalledWith(
|
||||||
@@ -1035,11 +1042,16 @@ describe("user column preferences and MFA status", () => {
|
|||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
},
|
},
|
||||||
|
mfaBackupCode: {
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
createMany: vi.fn(),
|
||||||
|
count: vi.fn().mockResolvedValue(4),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await caller.getMfaStatus();
|
const result = await caller.getMfaStatus();
|
||||||
|
|
||||||
expect(result).toEqual({ totpEnabled: true });
|
expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 4 });
|
||||||
expect(findUnique).toHaveBeenCalledWith({
|
expect(findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "user_admin" },
|
where: { id: "user_admin" },
|
||||||
select: { totpEnabled: true },
|
select: { totpEnabled: true },
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
verifyAndEnableTotp,
|
verifyAndEnableTotp,
|
||||||
verifyTotp,
|
verifyTotp,
|
||||||
getCurrentMfaStatus,
|
getCurrentMfaStatus,
|
||||||
|
regenerateBackupCodes,
|
||||||
} from "../router/user-self-service-procedure-support.js";
|
} from "../router/user-self-service-procedure-support.js";
|
||||||
|
|
||||||
// ─── context helpers ─────────────────────────────────────────────────────────
|
// ─── context helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -74,10 +75,17 @@ function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
|
|||||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
...((dbOverrides.user as object | undefined) ?? {}),
|
...((dbOverrides.user as object | undefined) ?? {}),
|
||||||
},
|
},
|
||||||
|
mfaBackupCode: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
createMany: vi.fn().mockResolvedValue({ count: 10 }),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
...((dbOverrides.mfaBackupCode as object | undefined) ?? {}),
|
||||||
|
},
|
||||||
auditLog: {
|
auditLog: {
|
||||||
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
|
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
|
||||||
...((dbOverrides.auditLog as object | undefined) ?? {}),
|
...((dbOverrides.auditLog as object | undefined) ?? {}),
|
||||||
},
|
},
|
||||||
|
$transaction: vi.fn(async (ops: unknown[]) => ops),
|
||||||
},
|
},
|
||||||
dbUser: { id: "user_1", systemRole: "ADMIN" as const, permissionOverrides: null },
|
dbUser: { id: "user_1", systemRole: "ADMIN" as const, permissionOverrides: null },
|
||||||
session: {
|
session: {
|
||||||
@@ -145,7 +153,7 @@ describe("verifyAndEnableTotp", () => {
|
|||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("enables TOTP and returns { enabled: true } when token is valid", async () => {
|
it("enables TOTP and returns backup codes when token is valid", async () => {
|
||||||
totpValidateMock.mockReturnValue(0); // delta 0 = current window
|
totpValidateMock.mockReturnValue(0); // delta 0 = current window
|
||||||
const ctx = makeSelfServiceCtx({
|
const ctx = makeSelfServiceCtx({
|
||||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||||
@@ -153,7 +161,12 @@ describe("verifyAndEnableTotp", () => {
|
|||||||
const result = await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], {
|
const result = await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], {
|
||||||
token: "123456",
|
token: "123456",
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ enabled: true });
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.backupCodes).toHaveLength(10);
|
||||||
|
// Codes have the XXXXX-XXXXX shape (10 Crockford-base32 chars + one dash)
|
||||||
|
for (const code of result.backupCodes) {
|
||||||
|
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
|
||||||
|
}
|
||||||
expect(ctx.db.user.updateMany).toHaveBeenCalledWith(
|
expect(ctx.db.user.updateMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ data: { lastTotpAt: expect.any(Date) } }),
|
expect.objectContaining({ data: { lastTotpAt: expect.any(Date) } }),
|
||||||
);
|
);
|
||||||
@@ -161,6 +174,17 @@ describe("verifyAndEnableTotp", () => {
|
|||||||
where: { id: "user_1" },
|
where: { id: "user_1" },
|
||||||
data: { totpEnabled: true },
|
data: { totpEnabled: true },
|
||||||
});
|
});
|
||||||
|
// Exactly 10 backup code rows are created in a transaction
|
||||||
|
expect(ctx.db.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||||
|
const createCall = ctx.db.mfaBackupCode.createMany.mock.calls[0]![0] as {
|
||||||
|
data: Array<{ userId: string; codeHash: string }>;
|
||||||
|
};
|
||||||
|
expect(createCall.data).toHaveLength(10);
|
||||||
|
for (const row of createCall.data) {
|
||||||
|
expect(row.userId).toBe("user_1");
|
||||||
|
expect(row.codeHash.length).toBeGreaterThan(50); // argon2id encoded form
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws BAD_REQUEST when token is invalid", async () => {
|
it("throws BAD_REQUEST when token is invalid", async () => {
|
||||||
@@ -314,19 +338,87 @@ describe("getCurrentMfaStatus", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns totpEnabled: true when MFA is active", async () => {
|
it("returns totpEnabled and backupCodesRemaining when MFA is active", async () => {
|
||||||
const ctx = makeSelfServiceCtx({
|
const ctx = makeSelfServiceCtx({
|
||||||
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: true }) },
|
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: true }) },
|
||||||
|
mfaBackupCode: {
|
||||||
|
count: vi.fn().mockResolvedValue(7),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
createMany: vi.fn(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
||||||
expect(result).toEqual({ totpEnabled: true });
|
expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 7 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns totpEnabled: false when MFA is inactive", async () => {
|
it("returns backupCodesRemaining: 0 when MFA is inactive (skips DB count)", async () => {
|
||||||
|
const countMock = vi.fn();
|
||||||
const ctx = makeSelfServiceCtx({
|
const ctx = makeSelfServiceCtx({
|
||||||
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: false }) },
|
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: false }) },
|
||||||
|
mfaBackupCode: { count: countMock, deleteMany: vi.fn(), createMany: vi.fn() },
|
||||||
});
|
});
|
||||||
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
||||||
expect(result).toEqual({ totpEnabled: false });
|
expect(result).toEqual({ totpEnabled: false, backupCodesRemaining: 0 });
|
||||||
|
expect(countMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── regenerateBackupCodes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("regenerateBackupCodes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws BAD_REQUEST when TOTP is not enabled", async () => {
|
||||||
|
const ctx = makeSelfServiceCtx({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "user_1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
totpEnabled: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]),
|
||||||
|
).rejects.toThrow(TRPCError);
|
||||||
|
expect(ctx.db.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wipes previous codes and issues a fresh set atomically", async () => {
|
||||||
|
const ctx = makeSelfServiceCtx({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "user_1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
totpEnabled: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]);
|
||||||
|
expect(result.count).toBe(10);
|
||||||
|
expect(result.codes).toHaveLength(10);
|
||||||
|
expect(new Set(result.codes).size).toBe(10); // all distinct
|
||||||
|
expect(ctx.db.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes an audit entry on regeneration", async () => {
|
||||||
|
const ctx = makeSelfServiceCtx({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "user_1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
totpEnabled: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(ctx.db.auditLog.create).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { verifyBackupCode } from "./mfa-backup-codes.js";
|
||||||
|
|
||||||
|
// Redeem a backup code atomically. The flow is:
|
||||||
|
//
|
||||||
|
// 1. Load all still-redeemable rows (usedAt IS NULL) for the user.
|
||||||
|
// 2. Linear-scan with argon2 verify until one matches. Hashes are
|
||||||
|
// expensive by design — 10 candidates max is fine, and the cost is
|
||||||
|
// the user's own memory-hard-hash budget, not an attacker-chosen one.
|
||||||
|
// 3. The matching row is deleted under a WHERE-guard on (id, usedAt IS
|
||||||
|
// NULL). Count=0 means another request consumed the same code first
|
||||||
|
// (replay race); the caller treats it as an invalid code.
|
||||||
|
//
|
||||||
|
// Deleting (vs marking `usedAt`) keeps the table small and makes post-
|
||||||
|
// compromise forensics simpler — a used code is an absence, not a
|
||||||
|
// still-present-but-tombstoned row that could be reactivated via SQL
|
||||||
|
// injection or bad migration.
|
||||||
|
//
|
||||||
|
// Returned `remaining` lets the UI warn "3 backup codes left — generate
|
||||||
|
// more" without a second round-trip.
|
||||||
|
|
||||||
|
interface BackupCodeRow {
|
||||||
|
id: string;
|
||||||
|
codeHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedeemDb {
|
||||||
|
mfaBackupCode: {
|
||||||
|
findMany: (args: {
|
||||||
|
where: { userId: string; usedAt: null };
|
||||||
|
select: { id: true; codeHash: true };
|
||||||
|
}) => Promise<BackupCodeRow[]>;
|
||||||
|
deleteMany: (args: { where: { id: string; usedAt: null } }) => Promise<{ count: number }>;
|
||||||
|
count: (args: { where: { userId: string; usedAt: null } }) => Promise<number>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemResult {
|
||||||
|
accepted: boolean;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redeemBackupCode(
|
||||||
|
db: { mfaBackupCode: unknown },
|
||||||
|
userId: string,
|
||||||
|
plaintext: string,
|
||||||
|
): Promise<RedeemResult> {
|
||||||
|
const typed = db as unknown as RedeemDb;
|
||||||
|
|
||||||
|
const rows = await typed.mfaBackupCode.findMany({
|
||||||
|
where: { userId, usedAt: null },
|
||||||
|
select: { id: true, codeHash: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!(await verifyBackupCode(row.codeHash, plaintext))) continue;
|
||||||
|
|
||||||
|
const del = await typed.mfaBackupCode.deleteMany({
|
||||||
|
where: { id: row.id, usedAt: null },
|
||||||
|
});
|
||||||
|
if (del.count === 0) {
|
||||||
|
// Raced — another request consumed this same code. Treat as invalid
|
||||||
|
// so the attacker cannot learn it was valid; an honest user retries
|
||||||
|
// with a fresh code.
|
||||||
|
return {
|
||||||
|
accepted: false,
|
||||||
|
remaining: await typed.mfaBackupCode.count({ where: { userId, usedAt: null } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const remaining = await typed.mfaBackupCode.count({ where: { userId, usedAt: null } });
|
||||||
|
return { accepted: true, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted: false, remaining: rows.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
|
|
||||||
|
// Backup codes are the last-resort credential when a user loses their TOTP
|
||||||
|
// device. Design constraints:
|
||||||
|
//
|
||||||
|
// 1. High entropy but human-typeable. 10 chars of Crockford-base32 =
|
||||||
|
// 50 bits — well above the 20-bit floor that brute-force-proofs the
|
||||||
|
// 6 codes/15 min rate limit (2^20 / (6/900) ≈ 5000 years average).
|
||||||
|
// 2. Never logged or stored in plaintext. We hash with argon2id (same
|
||||||
|
// hasher as passwords) and delete the row on redemption, so replay is
|
||||||
|
// physically impossible even if the DB leaks post-redemption.
|
||||||
|
// 3. One-shot visibility. Plaintext is returned exactly once from the
|
||||||
|
// generate mutation — re-display is not supported; lost codes must be
|
||||||
|
// regenerated, which invalidates the full set.
|
||||||
|
//
|
||||||
|
// The formatted shape (XXXXX-XXXXX) is cosmetic only; validation strips the
|
||||||
|
// dash so users can paste either form.
|
||||||
|
|
||||||
|
export const BACKUP_CODE_COUNT = 10;
|
||||||
|
const CODE_LENGTH = 10; // chars, pre-dash
|
||||||
|
// Crockford base32 alphabet: no 0/O/1/I/L to avoid transcription errors.
|
||||||
|
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||||
|
|
||||||
|
export function generatePlaintextBackupCodes(count: number = BACKUP_CODE_COUNT): string[] {
|
||||||
|
const codes: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const bytes = randomBytes(CODE_LENGTH);
|
||||||
|
let code = "";
|
||||||
|
for (let j = 0; j < CODE_LENGTH; j++) {
|
||||||
|
code += ALPHABET[bytes[j]! % ALPHABET.length];
|
||||||
|
}
|
||||||
|
codes.push(`${code.slice(0, 5)}-${code.slice(5)}`);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users may paste the code with or without the dash, and in either case;
|
||||||
|
// store and compare the canonical form (uppercase, no dash, no whitespace)
|
||||||
|
// so accidental formatting does not reject an otherwise-valid code.
|
||||||
|
export function normalizeBackupCode(input: string): string {
|
||||||
|
return input.replace(/[\s-]+/g, "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashBackupCode(plaintext: string): Promise<string> {
|
||||||
|
return hash(normalizeBackupCode(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBackupCode(codeHash: string, plaintext: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await verify(codeHash, normalizeBackupCode(plaintext));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const auditLogListInputSchema = z.object({
|
export const auditLogListInputSchema = z.object({
|
||||||
entityType: z.string().optional(),
|
entityType: z.string().max(64).optional(),
|
||||||
entityId: z.string().optional(),
|
entityId: z.string().max(64).optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().max(64).optional(),
|
||||||
action: z.string().optional(),
|
action: z.string().max(32).optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().max(32).optional(),
|
||||||
startDate: z.date().optional(),
|
startDate: z.date().optional(),
|
||||||
endDate: z.date().optional(),
|
endDate: z.date().optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().max(200).optional(),
|
||||||
limit: z.number().min(1).max(100).default(50),
|
limit: z.number().min(1).max(100).default(50),
|
||||||
cursor: z.string().optional(),
|
cursor: z.string().max(64).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const auditLogByEntityInputSchema = z.object({
|
export const auditLogByEntityInputSchema = z.object({
|
||||||
entityType: z.string(),
|
entityType: z.string().max(64),
|
||||||
entityId: z.string(),
|
entityId: z.string().max(64),
|
||||||
limit: z.number().min(1).max(200).default(50),
|
limit: z.number().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
PASSWORD_MAX_LENGTH,
|
PASSWORD_MAX_LENGTH,
|
||||||
PASSWORD_MIN_LENGTH,
|
PASSWORD_MIN_LENGTH,
|
||||||
PASSWORD_POLICY_MESSAGE,
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
checkPasswordPolicy,
|
||||||
} from "@capakraken/shared";
|
} from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -133,6 +134,17 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject weak/common/identity-related passwords *after* the token is
|
||||||
|
// validated so attackers can't probe the policy without a valid link.
|
||||||
|
const userForPolicy = await ctx.db.user.findUnique({
|
||||||
|
where: { email: record.email },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
});
|
||||||
|
const policy = checkPasswordPolicy(input.password, userForPolicy ?? undefined);
|
||||||
|
if (!policy.ok) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const { hash } = await import("@node-rs/argon2");
|
const { hash } = await import("@node-rs/argon2");
|
||||||
const passwordHash = await hash(input.password);
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,21 @@ type ImportExportMutationContext = ImportExportReadContext & {
|
|||||||
|
|
||||||
type ImportRow = Record<string, string>;
|
type ImportRow = Record<string, string>;
|
||||||
|
|
||||||
|
const CSV_CELL_MAX = 4000;
|
||||||
|
const CSV_COLUMNS_MAX = 100;
|
||||||
|
const CSV_ROWS_MAX = 10_000;
|
||||||
|
|
||||||
export const importCsvInputSchema = z.object({
|
export const importCsvInputSchema = z.object({
|
||||||
entityType: z.enum(["resources", "projects", "allocations"]),
|
entityType: z.enum(["resources", "projects", "allocations"]),
|
||||||
rows: z.array(z.record(z.string(), z.string())),
|
rows: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.record(z.string().max(200), z.string().max(CSV_CELL_MAX))
|
||||||
|
.refine((row) => Object.keys(row).length <= CSV_COLUMNS_MAX, {
|
||||||
|
message: `CSV row exceeds ${CSV_COLUMNS_MAX} columns`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(CSV_ROWS_MAX),
|
||||||
dryRun: z.boolean().default(true),
|
dryRun: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,7 +44,10 @@ function resolveVisibleBlueprintFields(fieldDefs: unknown): BlueprintFieldDefini
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCsv(headers: unknown[], rows: unknown[][]) {
|
function buildCsv(headers: unknown[], rows: unknown[][]) {
|
||||||
return [headers.map(escapeCsvValue).join(","), ...rows.map((row) => row.map(escapeCsvValue).join(","))].join("\n");
|
return [
|
||||||
|
headers.map(escapeCsvValue).join(","),
|
||||||
|
...rows.map((row) => row.map(escapeCsvValue).join(",")),
|
||||||
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportResourcesCsv(ctx: ImportExportReadContext) {
|
export async function exportResourcesCsv(ctx: ImportExportReadContext) {
|
||||||
@@ -168,7 +183,10 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (input.entityType === "resources") {
|
if (input.entityType === "resources") {
|
||||||
const outcome = await importResourceRow({ ...ctx, db: tx as unknown as typeof ctx.db }, row);
|
const outcome = await importResourceRow(
|
||||||
|
{ ...ctx, db: tx as unknown as typeof ctx.db },
|
||||||
|
row,
|
||||||
|
);
|
||||||
if (outcome.updated) {
|
if (outcome.updated) {
|
||||||
results.updated += 1;
|
results.updated += 1;
|
||||||
} else if (outcome.error) {
|
} else if (outcome.error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PASSWORD_MAX_LENGTH,
|
PASSWORD_MAX_LENGTH,
|
||||||
PASSWORD_MIN_LENGTH,
|
PASSWORD_MIN_LENGTH,
|
||||||
PASSWORD_POLICY_MESSAGE,
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
checkPasswordPolicy,
|
||||||
} from "@capakraken/shared";
|
} from "@capakraken/shared";
|
||||||
import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js";
|
import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js";
|
||||||
import { getAppBaseUrl } from "../lib/app-base-url.js";
|
import { getAppBaseUrl } from "../lib/app-base-url.js";
|
||||||
@@ -155,6 +156,11 @@ export const inviteRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const policy = checkPasswordPolicy(input.password, { email: invite.email });
|
||||||
|
if (!policy.ok) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const { hash } = await import("@node-rs/argon2");
|
const { hash } = await import("@node-rs/argon2");
|
||||||
const passwordHash = await hash(input.password);
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { sendEmail } from "../lib/email.js";
|
|||||||
import { emitTaskAssigned } from "../sse/event-bus.js";
|
import { emitTaskAssigned } from "../sse/event-bus.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
export type NotificationProcedureContext = Pick<TRPCContext, "db" | "dbUser" | "roleDefaults" | "session">;
|
export type NotificationProcedureContext = Pick<
|
||||||
|
TRPCContext,
|
||||||
|
"db" | "dbUser" | "roleDefaults" | "session"
|
||||||
|
>;
|
||||||
|
|
||||||
export function requireNotificationDbUser(ctx: NotificationProcedureContext) {
|
export function requireNotificationDbUser(ctx: NotificationProcedureContext) {
|
||||||
if (!ctx.dbUser) {
|
if (!ctx.dbUser) {
|
||||||
@@ -89,17 +92,15 @@ export function rethrowNotificationReferenceError(
|
|||||||
recipientContext: "notification" | "task" | "broadcast" = "notification",
|
recipientContext: "notification" | "task" | "broadcast" = "notification",
|
||||||
): never {
|
): never {
|
||||||
for (const candidate of getNotificationErrorCandidates(error)) {
|
for (const candidate of getNotificationErrorCandidates(error)) {
|
||||||
const fieldName = typeof candidate.meta?.field_name === "string"
|
const fieldName =
|
||||||
? candidate.meta.field_name.toLowerCase()
|
typeof candidate.meta?.field_name === "string" ? candidate.meta.field_name.toLowerCase() : "";
|
||||||
: "";
|
const modelName =
|
||||||
const modelName = typeof candidate.meta?.modelName === "string"
|
typeof candidate.meta?.modelName === "string" ? candidate.meta.modelName.toLowerCase() : "";
|
||||||
? candidate.meta.modelName.toLowerCase()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof candidate.code === "string"
|
typeof candidate.code === "string" &&
|
||||||
&& (candidate.code === "P2003" || candidate.code === "P2025")
|
(candidate.code === "P2003" || candidate.code === "P2025") &&
|
||||||
&& fieldName.includes("assignee")
|
fieldName.includes("assignee")
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -109,9 +110,9 @@ export function rethrowNotificationReferenceError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof candidate.code === "string"
|
typeof candidate.code === "string" &&
|
||||||
&& (candidate.code === "P2003" || candidate.code === "P2025")
|
(candidate.code === "P2003" || candidate.code === "P2025") &&
|
||||||
&& fieldName.includes("sender")
|
fieldName.includes("sender")
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -121,15 +122,16 @@ export function rethrowNotificationReferenceError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof candidate.code === "string"
|
typeof candidate.code === "string" &&
|
||||||
&& (candidate.code === "P2003" || candidate.code === "P2025")
|
(candidate.code === "P2003" || candidate.code === "P2025") &&
|
||||||
&& fieldName.includes("userid")
|
fieldName.includes("userid")
|
||||||
) {
|
) {
|
||||||
const message = recipientContext === "broadcast"
|
const message =
|
||||||
? "Broadcast recipient user not found"
|
recipientContext === "broadcast"
|
||||||
: recipientContext === "task"
|
? "Broadcast recipient user not found"
|
||||||
? "Task recipient user not found"
|
: recipientContext === "task"
|
||||||
: "Notification recipient user not found";
|
? "Task recipient user not found"
|
||||||
|
: "Notification recipient user not found";
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message,
|
message,
|
||||||
@@ -138,13 +140,11 @@ export function rethrowNotificationReferenceError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof candidate.code === "string"
|
typeof candidate.code === "string" &&
|
||||||
&& (candidate.code === "P2003" || candidate.code === "P2025")
|
(candidate.code === "P2003" || candidate.code === "P2025") &&
|
||||||
&& (
|
(modelName.includes("notificationbroadcast") ||
|
||||||
modelName.includes("notificationbroadcast")
|
fieldName.includes("broadcast") ||
|
||||||
|| fieldName.includes("broadcast")
|
fieldName.includes("sourceid"))
|
||||||
|| fieldName.includes("sourceid")
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -203,11 +203,11 @@ export const ListNotificationTasksInputSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const NotificationIdInputSchema = z.object({
|
export const NotificationIdInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string().max(64),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateNotificationTaskStatusInputSchema = z.object({
|
export const UpdateNotificationTaskStatusInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string().max(64),
|
||||||
status: taskStatusEnum,
|
status: taskStatusEnum,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,13 +216,13 @@ export const CreateReminderInputSchema = z.object({
|
|||||||
body: z.string().max(2000).optional(),
|
body: z.string().max(2000).optional(),
|
||||||
remindAt: z.date(),
|
remindAt: z.date(),
|
||||||
recurrence: recurrenceEnum.optional(),
|
recurrence: recurrenceEnum.optional(),
|
||||||
entityId: z.string().optional(),
|
entityId: z.string().max(64).optional(),
|
||||||
entityType: z.string().optional(),
|
entityType: z.string().max(64).optional(),
|
||||||
link: z.string().optional(),
|
link: z.string().max(2048).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateReminderInputSchema = z.object({
|
export const UpdateReminderInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string().max(64),
|
||||||
title: z.string().min(1).max(200).optional(),
|
title: z.string().min(1).max(200).optional(),
|
||||||
body: z.string().max(2000).optional(),
|
body: z.string().max(2000).optional(),
|
||||||
remindAt: z.date().optional(),
|
remindAt: z.date().optional(),
|
||||||
@@ -236,14 +236,14 @@ export const ListRemindersInputSchema = z.object({
|
|||||||
export const CreateBroadcastInputSchema = z.object({
|
export const CreateBroadcastInputSchema = z.object({
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
body: z.string().max(2000).optional(),
|
body: z.string().max(2000).optional(),
|
||||||
link: z.string().optional(),
|
link: z.string().max(2048).optional(),
|
||||||
category: categoryEnum.default("NOTIFICATION"),
|
category: categoryEnum.default("NOTIFICATION"),
|
||||||
priority: priorityEnum.default("NORMAL"),
|
priority: priorityEnum.default("NORMAL"),
|
||||||
channel: channelEnum.default("in_app"),
|
channel: channelEnum.default("in_app"),
|
||||||
targetType: targetTypeEnum,
|
targetType: targetTypeEnum,
|
||||||
targetValue: z.string().optional(),
|
targetValue: z.string().max(200).optional(),
|
||||||
scheduledAt: z.date().optional(),
|
scheduledAt: z.date().optional(),
|
||||||
taskAction: z.string().optional(),
|
taskAction: z.string().max(64).optional(),
|
||||||
dueDate: z.date().optional(),
|
dueDate: z.date().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,21 +252,21 @@ export const ListBroadcastsInputSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const CreateTaskInputSchema = z.object({
|
export const CreateTaskInputSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string().max(64),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
body: z.string().max(2000).optional(),
|
body: z.string().max(2000).optional(),
|
||||||
priority: priorityEnum.default("NORMAL"),
|
priority: priorityEnum.default("NORMAL"),
|
||||||
dueDate: z.date().optional(),
|
dueDate: z.date().optional(),
|
||||||
taskAction: z.string().optional(),
|
taskAction: z.string().max(64).optional(),
|
||||||
entityId: z.string().optional(),
|
entityId: z.string().max(64).optional(),
|
||||||
entityType: z.string().optional(),
|
entityType: z.string().max(64).optional(),
|
||||||
link: z.string().optional(),
|
link: z.string().max(2048).optional(),
|
||||||
channel: channelEnum.default("in_app"),
|
channel: channelEnum.default("in_app"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AssignTaskInputSchema = z.object({
|
export const AssignTaskInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string().max(64),
|
||||||
assigneeId: z.string(),
|
assigneeId: z.string().max(64),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BroadcastRecipientNotification = { id: string; userId: string };
|
export type BroadcastRecipientNotification = { id: string; userId: string };
|
||||||
@@ -411,9 +411,9 @@ export async function deleteNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(existing.category === "TASK" || existing.category === "APPROVAL")
|
(existing.category === "TASK" || existing.category === "APPROVAL") &&
|
||||||
&& existing.senderId
|
existing.senderId &&
|
||||||
&& existing.senderId !== userId
|
existing.senderId !== userId
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ export const resourceMutationProcedures = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
batchHardDelete: adminProcedure
|
batchHardDelete: adminProcedure
|
||||||
.input(z.object({ ids: z.array(z.string()).min(1) }))
|
.input(z.object({ ids: z.array(z.string().max(64)).min(1).max(500) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const resources = await ctx.db.resource.findMany({
|
const resources = await ctx.db.resource.findMany({
|
||||||
where: { id: { in: input.ids } },
|
where: { id: { in: input.ids } },
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import { PermissionKey, SkillEntrySchema } from "@capakraken/shared";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
managerProcedure,
|
||||||
|
protectedProcedure,
|
||||||
|
requirePermission,
|
||||||
|
} from "../trpc.js";
|
||||||
|
|
||||||
const employeeInfoSchema = z
|
const employeeInfoSchema = z
|
||||||
.object({
|
.object({
|
||||||
roleId: z.string().optional(),
|
roleId: z.string().max(64).optional(),
|
||||||
yearsOfExperience: z.number().optional(),
|
yearsOfExperience: z.number().min(0).max(100).optional(),
|
||||||
portfolioUrl: z.string().url().optional().or(z.literal("")),
|
portfolioUrl: z.string().url().max(2048).optional().or(z.literal("")),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
@@ -16,7 +21,7 @@ export const resourceSkillImportProcedures = {
|
|||||||
importSkillMatrix: protectedProcedure
|
importSkillMatrix: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
skills: z.array(SkillEntrySchema),
|
skills: z.array(SkillEntrySchema).max(2000),
|
||||||
employeeInfo: employeeInfoSchema,
|
employeeInfo: employeeInfoSchema,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -40,7 +45,9 @@ export const resourceSkillImportProcedures = {
|
|||||||
...(input.employeeInfo?.portfolioUrl !== undefined
|
...(input.employeeInfo?.portfolioUrl !== undefined
|
||||||
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
|
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
|
||||||
: {}),
|
: {}),
|
||||||
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
|
...(input.employeeInfo?.roleId !== undefined
|
||||||
|
? { roleId: input.employeeInfo.roleId }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,8 +57,8 @@ export const resourceSkillImportProcedures = {
|
|||||||
importSkillMatrixForResource: managerProcedure
|
importSkillMatrixForResource: managerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
resourceId: z.string(),
|
resourceId: z.string().max(64),
|
||||||
skills: z.array(SkillEntrySchema),
|
skills: z.array(SkillEntrySchema).max(2000),
|
||||||
employeeInfo: employeeInfoSchema,
|
employeeInfo: employeeInfoSchema,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -70,7 +77,9 @@ export const resourceSkillImportProcedures = {
|
|||||||
...(input.employeeInfo?.portfolioUrl !== undefined
|
...(input.employeeInfo?.portfolioUrl !== undefined
|
||||||
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
|
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
|
||||||
: {}),
|
: {}),
|
||||||
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
|
...(input.employeeInfo?.roleId !== undefined
|
||||||
|
? { roleId: input.employeeInfo.roleId }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,13 +89,15 @@ export const resourceSkillImportProcedures = {
|
|||||||
batchImportSkillMatrices: adminProcedure
|
batchImportSkillMatrices: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
entries: z.array(
|
entries: z
|
||||||
z.object({
|
.array(
|
||||||
eid: z.string(),
|
z.object({
|
||||||
skills: z.array(SkillEntrySchema),
|
eid: z.string().max(64),
|
||||||
employeeInfo: employeeInfoSchema,
|
skills: z.array(SkillEntrySchema).max(2000),
|
||||||
}),
|
employeeInfo: employeeInfoSchema,
|
||||||
),
|
}),
|
||||||
|
)
|
||||||
|
.max(5000),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -110,7 +121,9 @@ export const resourceSkillImportProcedures = {
|
|||||||
...(entry.employeeInfo?.portfolioUrl !== undefined
|
...(entry.employeeInfo?.portfolioUrl !== undefined
|
||||||
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
|
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
|
||||||
: {}),
|
: {}),
|
||||||
...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}),
|
...(entry.employeeInfo?.roleId !== undefined
|
||||||
|
? { roleId: entry.employeeInfo.roleId }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -397,8 +397,8 @@ async function queryStaffingSuggestions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const GetProjectStaffingSuggestionsInputSchema = z.object({
|
const GetProjectStaffingSuggestionsInputSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1).max(64),
|
||||||
roleName: z.string().optional(),
|
roleName: z.string().max(200).optional(),
|
||||||
startDate: z.coerce.date().optional(),
|
startDate: z.coerce.date().optional(),
|
||||||
endDate: z.coerce.date().optional(),
|
endDate: z.coerce.date().optional(),
|
||||||
limit: z.number().int().min(1).max(50).optional().default(5),
|
limit: z.number().int().min(1).max(50).optional().default(5),
|
||||||
@@ -408,14 +408,14 @@ export const staffingSuggestionsReadProcedures = {
|
|||||||
getSuggestions: planningReadProcedure
|
getSuggestions: planningReadProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
requiredSkills: z.array(z.string()),
|
requiredSkills: z.array(z.string().max(200)).max(200),
|
||||||
preferredSkills: z.array(z.string()).optional(),
|
preferredSkills: z.array(z.string().max(200)).max(200).optional(),
|
||||||
startDate: z.coerce.date(),
|
startDate: z.coerce.date(),
|
||||||
endDate: z.coerce.date(),
|
endDate: z.coerce.date(),
|
||||||
hoursPerDay: z.number().min(0).max(24),
|
hoursPerDay: z.number().min(0).max(24),
|
||||||
budgetLcrCentsPerHour: z.number().optional(),
|
budgetLcrCentsPerHour: z.number().int().min(0).max(1_000_000_00).optional(),
|
||||||
chapter: z.string().optional(),
|
chapter: z.string().max(100).optional(),
|
||||||
skillCategory: z.string().optional(),
|
skillCategory: z.string().max(100).optional(),
|
||||||
mainSkillsOnly: z.boolean().optional(),
|
mainSkillsOnly: z.boolean().optional(),
|
||||||
minProficiency: z.number().min(1).max(5).optional(),
|
minProficiency: z.number().min(1).max(5).optional(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,35 +1,40 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const idFilter = () => z.array(z.string().max(64)).max(500);
|
||||||
|
const chapterFilter = () => z.array(z.string().max(100)).max(100);
|
||||||
|
const countryFilter = () => z.array(z.string().max(8)).max(300);
|
||||||
|
const dateStr = () => z.string().max(32);
|
||||||
|
|
||||||
export const TimelineWindowFiltersSchema = z.object({
|
export const TimelineWindowFiltersSchema = z.object({
|
||||||
startDate: z.coerce.date(),
|
startDate: z.coerce.date(),
|
||||||
endDate: z.coerce.date(),
|
endDate: z.coerce.date(),
|
||||||
resourceIds: z.array(z.string()).optional(),
|
resourceIds: idFilter().optional(),
|
||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: idFilter().optional(),
|
||||||
clientIds: z.array(z.string()).optional(),
|
clientIds: idFilter().optional(),
|
||||||
chapters: z.array(z.string()).optional(),
|
chapters: chapterFilter().optional(),
|
||||||
eids: z.array(z.string()).optional(),
|
eids: idFilter().optional(),
|
||||||
countryCodes: z.array(z.string()).optional(),
|
countryCodes: countryFilter().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TimelineDetailFiltersSchema = z.object({
|
export const TimelineDetailFiltersSchema = z.object({
|
||||||
startDate: z.string().optional(),
|
startDate: dateStr().optional(),
|
||||||
endDate: z.string().optional(),
|
endDate: dateStr().optional(),
|
||||||
durationDays: z.number().int().min(1).max(366).optional(),
|
durationDays: z.number().int().min(1).max(366).optional(),
|
||||||
resourceIds: z.array(z.string()).optional(),
|
resourceIds: idFilter().optional(),
|
||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: idFilter().optional(),
|
||||||
clientIds: z.array(z.string()).optional(),
|
clientIds: idFilter().optional(),
|
||||||
chapters: z.array(z.string()).optional(),
|
chapters: chapterFilter().optional(),
|
||||||
eids: z.array(z.string()).optional(),
|
eids: idFilter().optional(),
|
||||||
countryCodes: z.array(z.string()).optional(),
|
countryCodes: countryFilter().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TimelineProjectContextDetailSchema = z.object({
|
export const TimelineProjectContextDetailSchema = z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string().max(64),
|
||||||
startDate: z.string().optional(),
|
startDate: dateStr().optional(),
|
||||||
endDate: z.string().optional(),
|
endDate: dateStr().optional(),
|
||||||
durationDays: z.number().int().min(1).max(366).optional(),
|
durationDays: z.number().int().min(1).max(366).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TimelineProjectIdSchema = z.object({
|
export const TimelineProjectIdSchema = z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string().max(64),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
PASSWORD_MAX_LENGTH,
|
PASSWORD_MAX_LENGTH,
|
||||||
PASSWORD_MIN_LENGTH,
|
PASSWORD_MIN_LENGTH,
|
||||||
PASSWORD_POLICY_MESSAGE,
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
checkPasswordPolicy,
|
||||||
} from "@capakraken/shared";
|
} from "@capakraken/shared";
|
||||||
import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types";
|
import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -13,45 +14,45 @@ import type { TRPCContext } from "../trpc.js";
|
|||||||
import { invalidateRoleDefaultsCache } from "../trpc.js";
|
import { invalidateRoleDefaultsCache } from "../trpc.js";
|
||||||
|
|
||||||
export const CreateUserInputSchema = z.object({
|
export const CreateUserInputSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email().max(320),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1).max(200),
|
||||||
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
|
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
|
||||||
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SetUserPasswordInputSchema = z.object({
|
export const SetUserPasswordInputSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string().max(64),
|
||||||
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateUserRoleInputSchema = z.object({
|
export const UpdateUserRoleInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string().max(64),
|
||||||
systemRole: z.nativeEnum(SystemRole),
|
systemRole: z.nativeEnum(SystemRole),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateUserNameInputSchema = z.object({
|
export const UpdateUserNameInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string().max(64),
|
||||||
name: z.string().min(1, "Name is required").max(200),
|
name: z.string().min(1, "Name is required").max(200),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LinkUserResourceInputSchema = z.object({
|
export const LinkUserResourceInputSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string().max(64),
|
||||||
resourceId: z.string().nullable(),
|
resourceId: z.string().max(64).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SetUserPermissionsInputSchema = z.object({
|
export const SetUserPermissionsInputSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string().max(64),
|
||||||
overrides: z
|
overrides: z
|
||||||
.object({
|
.object({
|
||||||
granted: z.array(z.string()).optional(),
|
granted: z.array(z.string().max(128)).max(500).optional(),
|
||||||
denied: z.array(z.string()).optional(),
|
denied: z.array(z.string().max(128)).max(500).optional(),
|
||||||
chapterIds: z.array(z.string()).optional(),
|
chapterIds: z.array(z.string().max(64)).max(500).optional(),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserIdInputSchema = z.object({
|
export const UserIdInputSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string().max(64),
|
||||||
});
|
});
|
||||||
|
|
||||||
type UserReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
type UserReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||||
@@ -121,6 +122,11 @@ export async function createUser(
|
|||||||
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
|
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const policy = checkPasswordPolicy(input.password, { email: input.email, name: input.name });
|
||||||
|
if (!policy.ok) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const { hash } = await import("@node-rs/argon2");
|
const { hash } = await import("@node-rs/argon2");
|
||||||
const passwordHash = await hash(input.password);
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
@@ -169,6 +175,11 @@ export async function setUserPassword(
|
|||||||
"User",
|
"User",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const policy = checkPasswordPolicy(input.password, { email: user.email, name: user.name });
|
||||||
|
if (!policy.ok) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const { hash } = await import("@node-rs/argon2");
|
const { hash } = await import("@node-rs/argon2");
|
||||||
const passwordHash = await hash(input.password);
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
import {
|
||||||
|
BACKUP_CODE_COUNT,
|
||||||
|
generatePlaintextBackupCodes,
|
||||||
|
hashBackupCode,
|
||||||
|
} from "../lib/mfa-backup-codes.js";
|
||||||
import { consumeTotpWindow } from "../lib/totp-consume.js";
|
import { consumeTotpWindow } from "../lib/totp-consume.js";
|
||||||
import { totpRateLimiter } from "../middleware/rate-limit.js";
|
import { totpRateLimiter } from "../middleware/rate-limit.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
import type { TRPCContext } from "../trpc.js";
|
||||||
@@ -251,6 +256,21 @@ export async function verifyAndEnableTotp(
|
|||||||
data: { totpEnabled: true },
|
data: { totpEnabled: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue the initial backup-code set as part of the enable flow. Doing
|
||||||
|
// this here (vs making it a separate opt-in step) avoids the common
|
||||||
|
// footgun of users enabling MFA, losing their device, and being locked
|
||||||
|
// out — one of the explicit motivations for #43 part 2.
|
||||||
|
const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT);
|
||||||
|
const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p)));
|
||||||
|
await ctx.db.$transaction([
|
||||||
|
(ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
}),
|
||||||
|
(ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({
|
||||||
|
data: hashes.map((codeHash) => ({ userId: user.id, codeHash })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
entityType: "User",
|
entityType: "User",
|
||||||
@@ -262,7 +282,7 @@ export async function verifyAndEnableTotp(
|
|||||||
summary: "Enabled TOTP MFA",
|
summary: "Enabled TOTP MFA",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { enabled: true };
|
return { enabled: true, backupCodes: plaintexts };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyTotp(
|
export async function verifyTotp(
|
||||||
@@ -330,5 +350,70 @@ export async function getCurrentMfaStatus(ctx: UserSelfServiceContext) {
|
|||||||
"User",
|
"User",
|
||||||
);
|
);
|
||||||
|
|
||||||
return { totpEnabled: user.totpEnabled };
|
const backupCodesRemaining = user.totpEnabled
|
||||||
|
? await (
|
||||||
|
ctx.db as unknown as {
|
||||||
|
mfaBackupCode: {
|
||||||
|
count: (args: { where: { userId: string; usedAt: null } }) => Promise<number>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).mfaBackupCode.count({
|
||||||
|
where: { userId: ctx.dbUser!.id, usedAt: null },
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { totpEnabled: user.totpEnabled, backupCodesRemaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate (or regenerate) a user's backup-code set. Returns the plaintext
|
||||||
|
// codes exactly once — the caller MUST display them immediately; there is
|
||||||
|
// no re-display endpoint. Regeneration wipes the previous set atomically
|
||||||
|
// (deleteMany + createMany in a transaction), so a partially-regenerated
|
||||||
|
// state — some old codes still valid, some new codes issued — is not
|
||||||
|
// observable to either the user or an attacker.
|
||||||
|
//
|
||||||
|
// Requires TOTP to already be enabled: the codes are a *backup* for an
|
||||||
|
// existing second factor, not a way to bootstrap MFA.
|
||||||
|
export async function regenerateBackupCodes(ctx: UserSelfServiceContext) {
|
||||||
|
const user = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
|
where: { id: ctx.dbUser!.id },
|
||||||
|
select: { id: true, name: true, email: true, totpEnabled: true },
|
||||||
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
if (!user.totpEnabled) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Enable TOTP before generating backup codes.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT);
|
||||||
|
const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p)));
|
||||||
|
|
||||||
|
// Transaction guarantees all-or-nothing replacement: a failure after
|
||||||
|
// deleteMany but before createMany would otherwise leave the user with
|
||||||
|
// zero backup codes and a UI that thinks they have 10.
|
||||||
|
await ctx.db.$transaction([
|
||||||
|
(ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
}),
|
||||||
|
(ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({
|
||||||
|
data: hashes.map((codeHash) => ({ userId: user.id, codeHash })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
void createAuditEntry({
|
||||||
|
db: ctx.db,
|
||||||
|
entityType: "User",
|
||||||
|
entityId: user.id,
|
||||||
|
entityName: `${user.name} (${user.email})`,
|
||||||
|
action: "UPDATE",
|
||||||
|
userId: user.id,
|
||||||
|
source: "ui",
|
||||||
|
summary: "Regenerated MFA backup codes",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { codes: plaintexts, count: plaintexts.length };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
saveDashboardLayout,
|
saveDashboardLayout,
|
||||||
SetColumnPreferencesInputSchema,
|
SetColumnPreferencesInputSchema,
|
||||||
setColumnPreferences,
|
setColumnPreferences,
|
||||||
|
regenerateBackupCodes,
|
||||||
ToggleFavoriteProjectInputSchema,
|
ToggleFavoriteProjectInputSchema,
|
||||||
toggleFavoriteProject,
|
toggleFavoriteProject,
|
||||||
verifyAndEnableTotp as verifyAndEnableTotpSelfService,
|
verifyAndEnableTotp as verifyAndEnableTotpSelfService,
|
||||||
@@ -152,4 +153,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
/** Get MFA status for the current user. */
|
/** Get MFA status for the current user. */
|
||||||
getMfaStatus: protectedProcedure.query(({ ctx }) => getCurrentMfaStatus(ctx)),
|
getMfaStatus: protectedProcedure.query(({ ctx }) => getCurrentMfaStatus(ctx)),
|
||||||
|
|
||||||
|
/** Generate a fresh set of MFA backup codes, invalidating any previous set. */
|
||||||
|
regenerateBackupCodes: protectedProcedure.mutation(({ ctx }) => regenerateBackupCodes(ctx)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ export const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...
|
|||||||
|
|
||||||
export const createWebhookInputSchema = z.object({
|
export const createWebhookInputSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
url: z.string().url(),
|
url: z.string().url().max(2048),
|
||||||
secret: z.string().optional(),
|
secret: z.string().min(16).max(256).optional(),
|
||||||
events: z.array(webhookEventEnum).min(1),
|
events: z.array(webhookEventEnum).min(1).max(100),
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateWebhookInputSchema = z.object({
|
export const updateWebhookInputSchema = z.object({
|
||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200).optional(),
|
||||||
url: z.string().url().optional(),
|
url: z.string().url().max(2048).optional(),
|
||||||
secret: z.string().nullish(),
|
secret: z.string().min(16).max(256).nullish(),
|
||||||
events: z.array(webhookEventEnum).min(1).optional(),
|
events: z.array(webhookEventEnum).min(1).max(100).optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,9 +35,7 @@ type WebhookDb = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildWebhookCreateData(
|
export function buildWebhookCreateData(input: z.infer<typeof createWebhookInputSchema>) {
|
||||||
input: z.infer<typeof createWebhookInputSchema>,
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
@@ -47,9 +45,7 @@ export function buildWebhookCreateData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWebhookUpdateData(
|
export function buildWebhookUpdateData(input: z.infer<typeof updateWebhookInputSchema>) {
|
||||||
input: z.infer<typeof updateWebhookInputSchema>,
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
...(input.name !== undefined ? { name: input.name } : {}),
|
...(input.name !== undefined ? { name: input.name } : {}),
|
||||||
...(input.url !== undefined ? { url: input.url } : {}),
|
...(input.url !== undefined ? { url: input.url } : {}),
|
||||||
@@ -59,10 +55,7 @@ export function buildWebhookUpdateData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadWebhookOrThrow(
|
export async function loadWebhookOrThrow(db: WebhookDb, id: string) {
|
||||||
db: WebhookDb,
|
|
||||||
id: string,
|
|
||||||
) {
|
|
||||||
const webhook = await db.webhook.findUnique({ where: { id } });
|
const webhook = await db.webhook.findUnique({ where: { id } });
|
||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ describe("readWorksheetMatrix", () => {
|
|||||||
await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow(
|
await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow(
|
||||||
`exceeds the ${MAX_DISPO_WORKBOOK_ROWS} row import limit`,
|
`exceeds the ${MAX_DISPO_WORKBOOK_ROWS} row import limit`,
|
||||||
);
|
);
|
||||||
}, 30000);
|
}, 60000);
|
||||||
|
|
||||||
it("rejects worksheets that exceed the column limit", async () => {
|
it("rejects worksheets that exceed the column limit", async () => {
|
||||||
const directory = await makeTempDirectory();
|
const directory = await makeTempDirectory();
|
||||||
@@ -149,7 +149,7 @@ describe("readWorksheetMatrix", () => {
|
|||||||
await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow(
|
await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow(
|
||||||
`exceeds the ${MAX_DISPO_WORKBOOK_COLUMNS} column import limit`,
|
`exceeds the ${MAX_DISPO_WORKBOOK_COLUMNS} column import limit`,
|
||||||
);
|
);
|
||||||
}, 30000);
|
}, 60000);
|
||||||
|
|
||||||
describe("DISPO_IMPORT_DIR allowlist", () => {
|
describe("DISPO_IMPORT_DIR allowlist", () => {
|
||||||
it("rejects absolute paths that escape the configured import dir", async () => {
|
it("rejects absolute paths that escape the configured import dir", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "mfa_backup_codes" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "mfa_backup_codes_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "mfa_backup_codes_userId_idx"
|
||||||
|
ON "mfa_backup_codes"("userId");
|
||||||
@@ -205,6 +205,7 @@ model User {
|
|||||||
activeSessions ActiveSession[]
|
activeSessions ActiveSession[]
|
||||||
reportTemplates ReportTemplate[]
|
reportTemplates ReportTemplate[]
|
||||||
assistantApprovals AssistantApproval[]
|
assistantApprovals AssistantApproval[]
|
||||||
|
mfaBackupCodes MfaBackupCode[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -212,6 +213,24 @@ model User {
|
|||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One row per still-redeemable backup code. We store argon2id(code) — never
|
||||||
|
// the plaintext — and delete the row on redemption so replay is physically
|
||||||
|
// impossible. Generation wipes and recreates the whole set (kick-oldest
|
||||||
|
// strategy not used here: recovery codes are all-or-nothing, a partial
|
||||||
|
// set is worse than none).
|
||||||
|
model MfaBackupCode {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
codeHash String
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("mfa_backup_codes")
|
||||||
|
}
|
||||||
|
|
||||||
enum AssistantApprovalStatus {
|
enum AssistantApprovalStatus {
|
||||||
PENDING
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { checkPasswordPolicy } from "../security/password-policy.js";
|
||||||
|
|
||||||
|
describe("checkPasswordPolicy", () => {
|
||||||
|
describe("length bounds", () => {
|
||||||
|
it("rejects passwords shorter than 12 chars", () => {
|
||||||
|
const result = checkPasswordPolicy("short1!");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/at least 12/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects passwords longer than 128 chars", () => {
|
||||||
|
const result = checkPasswordPolicy("A".repeat(129));
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/no more than 128/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts passwords at the lower bound that pass other checks", () => {
|
||||||
|
const result = checkPasswordPolicy("Tr0ub4dor&3!"); // 12 chars, varied
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trivial patterns", () => {
|
||||||
|
it("rejects single char repeated", () => {
|
||||||
|
const result = checkPasswordPolicy("aaaaaaaaaaaa");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/single character/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects short patterns repeated", () => {
|
||||||
|
const result = checkPasswordPolicy("abcabcabcabc");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/short pattern/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects '1212121212121212' (2-char pattern repeated)", () => {
|
||||||
|
const result = checkPasswordPolicy("1212121212121212");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/short pattern/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects keyboard sequences like 'abcdefghijkl'", () => {
|
||||||
|
const result = checkPasswordPolicy("abcdefghijkl");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/sequence/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects numeric runs like '1234567890ab'", () => {
|
||||||
|
const result = checkPasswordPolicy("1234567890ab");
|
||||||
|
// Either matches blacklist or sequence detector — both rejections OK.
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("common-password blacklist", () => {
|
||||||
|
it("rejects 'PasswordPassword' (case-insensitive)", () => {
|
||||||
|
const result = checkPasswordPolicy("PasswordPassword");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/commonly used/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 'Welcome2026!' seasonal password", () => {
|
||||||
|
const result = checkPasswordPolicy("Welcome2026!");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/commonly used/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 'Summer2025!' regardless of case", () => {
|
||||||
|
const result = checkPasswordPolicy("SUMMER2025!");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("identity inclusion", () => {
|
||||||
|
it("rejects passwords containing the email local-part", () => {
|
||||||
|
const result = checkPasswordPolicy("hartmutSomePass1", {
|
||||||
|
email: "hartmut@example.com",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/email or name/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects passwords containing the user name", () => {
|
||||||
|
const result = checkPasswordPolicy("MyHartmutPass1!", {
|
||||||
|
name: "Hartmut Noerenberg",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/email or name/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores short email locals to avoid false positives", () => {
|
||||||
|
const result = checkPasswordPolicy("XyZmagic12345!", { email: "x@example.com" });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores short names (<4 chars)", () => {
|
||||||
|
const result = checkPasswordPolicy("XyZmagic12345!", { name: "Ed" });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("strong passwords", () => {
|
||||||
|
it("accepts a 16-char random-looking passphrase", () => {
|
||||||
|
const result = checkPasswordPolicy("Tr0ub4d0r&3-x9Q!");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts diceware-style passphrase", () => {
|
||||||
|
const result = checkPasswordPolicy("correct-horse-battery-staple-7!");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./types/index.js";
|
export * from "./types/index.js";
|
||||||
export * from "./schemas/index.js";
|
export * from "./schemas/index.js";
|
||||||
export * from "./constants/index.js";
|
export * from "./constants/index.js";
|
||||||
|
export * from "./security/index.js";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./password-policy.js";
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_MIN_LENGTH,
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
} from "../constants/index.js";
|
||||||
|
|
||||||
|
export interface PasswordContext {
|
||||||
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasswordCheckResult = { ok: true } | { ok: false; reason: string };
|
||||||
|
|
||||||
|
// Curated list of >=12-char passwords that pass the length gate but are
|
||||||
|
// known weak (rockyou top entries, predictable seasonal patterns, common
|
||||||
|
// admin defaults). Stored lower-cased for case-insensitive matching. The
|
||||||
|
// vast majority of weak passwords are <12 chars and already rejected by
|
||||||
|
// length, so this list focuses on what would otherwise *pass* min-length.
|
||||||
|
const COMMON_PASSWORDS: ReadonlySet<string> = new Set([
|
||||||
|
"passwordpassword",
|
||||||
|
"password1234",
|
||||||
|
"password12345",
|
||||||
|
"password123456",
|
||||||
|
"password1234567",
|
||||||
|
"passwordpassword1",
|
||||||
|
"passw0rdpassw0rd",
|
||||||
|
"1234567890ab",
|
||||||
|
"1234567890abc",
|
||||||
|
"12345678901",
|
||||||
|
"123456789012",
|
||||||
|
"1234567891234",
|
||||||
|
"qwertyuiop12",
|
||||||
|
"qwertyuiop123",
|
||||||
|
"qwertyuiop1234",
|
||||||
|
"qwertyuiopas",
|
||||||
|
"asdfghjkl123",
|
||||||
|
"asdfghjkl1234",
|
||||||
|
"iloveyou1234",
|
||||||
|
"iloveyou12345",
|
||||||
|
"iloveyouabc1",
|
||||||
|
"iloveyouforever",
|
||||||
|
"ilovemybabies",
|
||||||
|
"letmein123456",
|
||||||
|
"letmein12345",
|
||||||
|
"welcome12345",
|
||||||
|
"welcome123456",
|
||||||
|
"welcome1234567",
|
||||||
|
"admin1234567",
|
||||||
|
"admin12345678",
|
||||||
|
"administrator1",
|
||||||
|
"administrator123",
|
||||||
|
"changeme1234",
|
||||||
|
"changeme12345",
|
||||||
|
"default1234567",
|
||||||
|
"football1234",
|
||||||
|
"baseball1234",
|
||||||
|
"trustno112345",
|
||||||
|
"summer2023!",
|
||||||
|
"summer2024!",
|
||||||
|
"summer2025!",
|
||||||
|
"summer2026!",
|
||||||
|
"winter2023!",
|
||||||
|
"winter2024!",
|
||||||
|
"winter2025!",
|
||||||
|
"winter2026!",
|
||||||
|
"spring2023!",
|
||||||
|
"spring2024!",
|
||||||
|
"spring2025!",
|
||||||
|
"spring2026!",
|
||||||
|
"autumn2023!",
|
||||||
|
"autumn2024!",
|
||||||
|
"autumn2025!",
|
||||||
|
"autumn2026!",
|
||||||
|
"welcome2023!",
|
||||||
|
"welcome2024!",
|
||||||
|
"welcome2025!",
|
||||||
|
"welcome2026!",
|
||||||
|
"password2023!",
|
||||||
|
"password2024!",
|
||||||
|
"password2025!",
|
||||||
|
"password2026!",
|
||||||
|
"p@ssw0rd1234",
|
||||||
|
"p@ssword1234",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isSingleCharRepeated(pw: string): boolean {
|
||||||
|
if (pw.length === 0) return false;
|
||||||
|
const first = pw[0]!;
|
||||||
|
for (let i = 1; i < pw.length; i++) {
|
||||||
|
if (pw[i] !== first) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect monotonically +/-1 character runs. A password where >=60% of
|
||||||
|
// characters belong to a run of length >=5 (e.g. "abcdefgh1234") is
|
||||||
|
// rejected as a keyboard / numeric sequence. The 60% threshold lets
|
||||||
|
// passwords with a sequential prefix and a long random suffix pass.
|
||||||
|
function isSequentialPassword(pw: string): boolean {
|
||||||
|
if (pw.length < 8) return false;
|
||||||
|
const lower = pw.toLowerCase();
|
||||||
|
let runChars = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < lower.length) {
|
||||||
|
let runLen = 1;
|
||||||
|
while (i + runLen < lower.length) {
|
||||||
|
const delta = lower.charCodeAt(i + runLen) - lower.charCodeAt(i + runLen - 1);
|
||||||
|
if (delta === 1 || delta === -1) {
|
||||||
|
runLen++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runLen >= 5) runChars += runLen;
|
||||||
|
i += runLen;
|
||||||
|
}
|
||||||
|
return runChars * 100 >= lower.length * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject passwords that are a 2-6 char pattern repeated >=3 times
|
||||||
|
// (e.g. "abcabcabcabc", "12121212"). Length divisibility check first
|
||||||
|
// keeps the constant-factor work tiny.
|
||||||
|
function isShortPatternRepeated(pw: string): boolean {
|
||||||
|
for (let patLen = 2; patLen <= 6; patLen++) {
|
||||||
|
if (pw.length % patLen !== 0) continue;
|
||||||
|
const repeats = pw.length / patLen;
|
||||||
|
if (repeats < 3) continue;
|
||||||
|
const pattern = pw.slice(0, patLen);
|
||||||
|
let allMatch = true;
|
||||||
|
for (let r = 1; r < repeats; r++) {
|
||||||
|
if (pw.slice(r * patLen, (r + 1) * patLen) !== pattern) {
|
||||||
|
allMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allMatch) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substrings shorter than 4 chars are ignored: short names ("ed") or
|
||||||
|
// generic email locals ("ab") would otherwise reject too many legitimate
|
||||||
|
// passwords by coincidence.
|
||||||
|
function containsIdentity(pw: string, ctx: PasswordContext | undefined): boolean {
|
||||||
|
if (!ctx) return false;
|
||||||
|
const lower = pw.toLowerCase();
|
||||||
|
if (ctx.email) {
|
||||||
|
const local = ctx.email.split("@")[0]?.toLowerCase().trim() ?? "";
|
||||||
|
if (local.length >= 4 && lower.includes(local)) return true;
|
||||||
|
}
|
||||||
|
if (ctx.name) {
|
||||||
|
const lowered = ctx.name.toLowerCase().trim();
|
||||||
|
const fullNoSpaces = lowered.replace(/\s+/g, "");
|
||||||
|
if (fullNoSpaces.length >= 4 && lower.includes(fullNoSpaces)) return true;
|
||||||
|
// Also reject if the password embeds an individual name component
|
||||||
|
// (e.g. given-name or surname). "Hartmut Noerenberg" → check both
|
||||||
|
// "hartmut" and "noerenberg" so a password like "MyHartmutPass1!"
|
||||||
|
// is rejected, not only the unrealistic "MyHartmutNoerenbergPass1!".
|
||||||
|
for (const part of lowered.split(/\s+/)) {
|
||||||
|
if (part.length >= 4 && lower.includes(part)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a password against the policy. Pure synchronous function so it
|
||||||
|
* can be called from server (tRPC mutations, server actions) and client
|
||||||
|
* (pre-submit validation) with identical results.
|
||||||
|
*/
|
||||||
|
export function checkPasswordPolicy(password: string, ctx?: PasswordContext): PasswordCheckResult {
|
||||||
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||||
|
return { ok: false, reason: PASSWORD_POLICY_MESSAGE };
|
||||||
|
}
|
||||||
|
if (password.length > PASSWORD_MAX_LENGTH) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `Password must be no more than ${PASSWORD_MAX_LENGTH} characters.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isSingleCharRepeated(password)) {
|
||||||
|
return { ok: false, reason: "Password must not be a single character repeated." };
|
||||||
|
}
|
||||||
|
if (isShortPatternRepeated(password)) {
|
||||||
|
return { ok: false, reason: "Password must not be a short pattern repeated." };
|
||||||
|
}
|
||||||
|
if (isSequentialPassword(password)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "Password must not be a keyboard or numeric sequence.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (COMMON_PASSWORDS.has(password.toLowerCase())) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "Password is in the list of commonly used or breached passwords.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (containsIdentity(password, ctx)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "Password must not contain your email or name.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
Generated
+14
-2
@@ -8,7 +8,7 @@ overrides:
|
|||||||
flatted: ^3.4.2
|
flatted: ^3.4.2
|
||||||
picomatch: ^4.0.4
|
picomatch: ^4.0.4
|
||||||
lodash-es: ^4.18.0
|
lodash-es: ^4.18.0
|
||||||
brace-expansion: ^5.0.5
|
brace-expansion@<2.0.2: '>=2.0.2'
|
||||||
esbuild@<0.25.0: '>=0.25.0'
|
esbuild@<0.25.0: '>=0.25.0'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@@ -2557,6 +2557,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
balanced-match@1.0.2:
|
||||||
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
balanced-match@4.0.4:
|
balanced-match@4.0.4:
|
||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -2593,6 +2596,9 @@ packages:
|
|||||||
bluebird@3.4.7:
|
bluebird@3.4.7:
|
||||||
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
|
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
|
||||||
|
|
||||||
|
brace-expansion@2.1.0:
|
||||||
|
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
|
||||||
|
|
||||||
brace-expansion@5.0.5:
|
brace-expansion@5.0.5:
|
||||||
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
|
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -7500,6 +7506,8 @@ snapshots:
|
|||||||
|
|
||||||
axobject-query@4.1.0: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
base64-js@0.0.8: {}
|
base64-js@0.0.8: {}
|
||||||
@@ -7529,6 +7537,10 @@ snapshots:
|
|||||||
|
|
||||||
bluebird@3.4.7: {}
|
bluebird@3.4.7: {}
|
||||||
|
|
||||||
|
brace-expansion@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
brace-expansion@5.0.5:
|
brace-expansion@5.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
@@ -9041,7 +9053,7 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@9.0.9:
|
minimatch@9.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.5
|
brace-expansion: 2.1.0
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user