security: implement tickets #28-#35 + architecture decision #30
#28 - TOTP rate limiting (verifyTotp): added totpRateLimiter (10 req/30s), throws TOO_MANY_REQUESTS before DB hit; 16 unit tests including rate-limit exceeded + userId key isolation. #29 - /api/reports/allocations role check: only ADMIN/MANAGER/CONTROLLER may access; returns 403 otherwise; 9 unit tests (401 unauthenticated, 403 for USER/VIEWER, 200 for allowed roles + xlsx format). #31 - pgAdmin credentials moved out of docker-compose.yml into env vars; PGADMIN_PASSWORD is now required (:?) to prevent accidental plaintext exposure in committed files. #34 - Server-side HTML sanitization for comment bodies via stripHtml(): strips all tags + decodes safe entities before persistence; 16 unit tests covering passthrough, injection patterns, entity decoding. #35 - MFA setup prompt banner (MfaPromptBanner): shown to ADMIN/MANAGER users without TOTP enabled; user-scoped localStorage snooze (7 days); links to /account/security; accessibility role=alert; 7 structural unit tests. #33 - Auth anomaly alerting cron (/api/cron/auth-anomaly-check): detects HIGH_GLOBAL_FAILURE_RATE and CONCENTRATED_FAILURES in 30-minute window; CRITICAL notification to ADMINs; fail-closed via verifyCronSecret; 10 unit tests. #32 - MFA enforcement policy: added requireMfaForRoles field to SystemSettings schema + Prisma migration; auth.ts blocks login with MFA_REQUIRED_SETUP signal if role is enforced but TOTP not set up; signin page redirects to /account/security?mfa_required=1; settings schema + view model updated; 11 unit tests. #30 - API keys architecture decision documented in LEARNINGS.md; no code written — product decision required before implementation. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* MfaPromptBanner security contract tests.
|
||||
*
|
||||
* Verifies structural properties of MfaPromptBanner.tsx via source analysis:
|
||||
* - Snooze is user-scoped (userId in the localStorage key)
|
||||
* - Default snooze is at least 1 day
|
||||
* - Banner always links to /account/security
|
||||
* - Component does not persist sensitive data (MFA secret or token) to localStorage
|
||||
*
|
||||
* The vitest environment for apps/web is "node" so source analysis is the
|
||||
* correct testing approach here (same pattern as MfaSetup.test.ts).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SOURCE_PATH = resolve(__dirname, "./MfaPromptBanner.tsx");
|
||||
const source = readFileSync(SOURCE_PATH, "utf-8");
|
||||
|
||||
describe("MfaPromptBanner — localStorage user-scoping", () => {
|
||||
it("includes userId in the localStorage key to prevent cross-user snooze leakage", () => {
|
||||
// The key must interpolate userId, e.g. `${SNOOZE_KEY}_${userId}`
|
||||
expect(source).toMatch(/\$\{.*userId\}/);
|
||||
});
|
||||
|
||||
it("persists snooze timestamp as a number, not session/token data", () => {
|
||||
// localStorage.setItem should write a numeric timestamp (Date.now() + offset)
|
||||
expect(source).toMatch(/Date\.now\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MfaPromptBanner — snooze duration", () => {
|
||||
it("snoozed for at least 1 day (86 400 000 ms)", () => {
|
||||
// Extract the SNOOZE_DAYS constant value from the source
|
||||
const match = source.match(/SNOOZE_DAYS\s*=\s*(\d+)/);
|
||||
expect(match).not.toBeNull();
|
||||
const days = Number(match![1]);
|
||||
expect(days).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MfaPromptBanner — navigation target", () => {
|
||||
it("links to /account/security for MFA setup", () => {
|
||||
expect(source).toMatch(/\/account\/security/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MfaPromptBanner — no sensitive data in localStorage", () => {
|
||||
it("does not store TOTP secret in localStorage", () => {
|
||||
expect(source).not.toMatch(/localStorage.*secret/i);
|
||||
expect(source).not.toMatch(/localStorage.*totp/i);
|
||||
});
|
||||
|
||||
it("does not store authentication tokens in localStorage", () => {
|
||||
expect(source).not.toMatch(/localStorage.*token/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MfaPromptBanner — accessibility", () => {
|
||||
it("has a role=alert or aria-label to be announced by screen readers", () => {
|
||||
expect(source).toMatch(/role=["']alert["']|aria-label/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SNOOZE_KEY = "capakraken_mfa_prompt_snoozed_until";
|
||||
const SNOOZE_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Banner shown to ADMIN / MANAGER users who have not yet enabled TOTP MFA.
|
||||
* The user can dismiss it for SNOOZE_DAYS days ("Remind me later").
|
||||
*
|
||||
* Props are resolved server-side in the layout, so no client-side DB fetch
|
||||
* is needed here.
|
||||
*/
|
||||
export function MfaPromptBanner({ userId }: { userId: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${SNOOZE_KEY}_${userId}`);
|
||||
if (raw) {
|
||||
const until = Number(raw);
|
||||
if (!isNaN(until) && Date.now() < until) {
|
||||
return; // still snoozed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (SSR guard — should not happen in a client component)
|
||||
}
|
||||
setVisible(true);
|
||||
}, [userId]);
|
||||
|
||||
function snooze() {
|
||||
try {
|
||||
const until = Date.now() + SNOOZE_DAYS * 24 * 60 * 60 * 1000;
|
||||
localStorage.setItem(`${SNOOZE_KEY}_${userId}`, String(until));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label="MFA setup recommendation"
|
||||
className="flex items-center justify-between gap-4 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 dark:bg-amber-900/20 dark:text-amber-200 border-b border-amber-200 dark:border-amber-700/50"
|
||||
>
|
||||
<span>
|
||||
<strong className="font-semibold">Protect your account:</strong>{" "}
|
||||
Your role has elevated permissions. We recommend enabling multi-factor authentication (MFA).
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Link
|
||||
href="/account/security"
|
||||
className="rounded-md bg-amber-600 px-3 py-1 text-xs font-semibold text-white hover:bg-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-amber-600 dark:bg-amber-500 dark:hover:bg-amber-400"
|
||||
>
|
||||
Set up MFA
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={snooze}
|
||||
className="rounded-md px-2 py-1 text-xs text-amber-700 hover:bg-amber-100 dark:text-amber-300 dark:hover:bg-amber-800/50"
|
||||
>
|
||||
Remind me later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user