feat: ACN Application Security Standard V7.30 compliance (19/23 items)

CRITICAL — Authentication & Access:
- TOTP MFA: otpauth-based, QR setup UI, sign-in flow integration,
  admin disable override, /account/security self-service page
- Session Timeouts: 8h absolute (maxAge), 30min idle (updateAge)
- Failed Auth Logging: Pino warn for invalid password/user/totp,
  info for successful login, audit entries for all auth events
- Concurrent Session Limit: ActiveSession model, oldest-kick strategy,
  max 3 per user (configurable in SystemSettings)

CRITICAL — HTTP Security:
- HSTS: max-age=31536000; includeSubDomains
- CSP: script/style/img/font/connect-src with Gemini/OpenAI whitelist
- X-XSS-Protection: 0 (CSP replaces legacy)
- Auth page cache: no-store, no-cache, must-revalidate
- Rate Limiting: 100/15min general API, 5/15min auth (Map-based)

Data Protection:
- XSS Sanitization: DOMPurify on comment bodies
- autocomplete="new-password" on all password/secret fields
- SameSite=Strict on all cookies (Credentials-only, no OAuth)
- File Upload Magic Bytes validation (PNG/JPEG/WebP/GIF/BMP/TIFF)

Logging & Monitoring:
- Login/Logout audit entries (Auth entityType)
- External API call logging with timing (OpenAI, Gemini)
- Input validation failure logging at warn level
- Concurrent session tracking in ActiveSession table

Documentation:
- docs/security-architecture.md (11 sections)
- docs/sdlc.md (CI pipeline, security gates, incident response)
- .gitea/PULL_REQUEST_TEMPLATE.md (security checklist)

Schema: User.totpSecret/totpEnabled, SystemSettings.sessionMaxAge/
sessionIdleTimeout/maxConcurrentSessions, ActiveSession model

Tests: 310 engine + 37 staffing pass. TypeScript clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-27 14:16:39 +01:00
parent 70ae830623
commit 9d43e4b113
31 changed files with 1337 additions and 107 deletions
@@ -1142,6 +1142,7 @@ export function SystemSettingsClient() {
value={dalleApiKey}
onChange={(e) => setDalleApiKey(e.target.value)}
placeholder="Leave empty to use same API key as chat"
autoComplete="new-password"
/>
</div>
</>
@@ -1170,6 +1171,7 @@ export function SystemSettingsClient() {
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder={settings?.hasGeminiApiKey ? "•••••••• (key is stored)" : "Enter Gemini API key"}
autoComplete="new-password"
/>
{settings?.hasGeminiApiKey && !geminiApiKey && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">API key is stored.</p>
+46 -11
View File
@@ -61,6 +61,7 @@ type UserRow = {
lastLoginAt: Date | null;
lastActiveAt: Date | null;
permissionOverrides: PermissionOverrides | null;
totpEnabled: boolean;
};
type EditState = {
@@ -196,6 +197,14 @@ export function UsersClient() {
onError: (err) => setActionError(err.message),
});
const disableTotpMutation = trpc.user.disableTotp.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
setActionError(null);
},
onError: (err) => setActionError(err.message),
});
function openSetPassword(user: UserRow) {
setPasswordTarget({ userId: user.id, userName: user.name ?? user.email });
setNewPassword("");
@@ -519,17 +528,24 @@ export function UsersClient() {
</span>
</td>
<td className="px-4 py-3 text-center">
{isOnline(user) ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Online
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500">
<span className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
Offline
</span>
)}
<div className="flex items-center justify-center gap-1.5">
{isOnline(user) ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Online
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500">
<span className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
Offline
</span>
)}
{user.totpEnabled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400" title="TOTP MFA enabled">
MFA
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{formatRelativeTime(user.lastLoginAt)}
@@ -550,6 +566,24 @@ export function UsersClient() {
</svg>
Password
</button>
{user.totpEnabled && (
<button
type="button"
onClick={() => {
if (confirm(`Disable MFA for ${user.name ?? user.email}?`)) {
void disableTotpMutation.mutateAsync({ userId: user.id });
}
}}
disabled={disableTotpMutation.isPending}
className="inline-flex items-center gap-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 font-medium"
title="Disable TOTP MFA for this user"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Disable MFA
</button>
)}
<button
type="button"
onClick={() => openEdit(user)}
@@ -700,6 +734,7 @@ export function UsersClient() {
onChange={(e) => setCreateState({ ...createState, password: e.target.value })}
placeholder="Min. 8 characters"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
autoComplete="new-password"
/>
</div>
@@ -344,6 +344,7 @@ export function WebhooksClient() {
value={form.secret}
onChange={(e) => setForm((prev) => ({ ...prev, secret: e.target.value }))}
placeholder="HMAC signing secret"
autoComplete="new-password"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
If set, requests include an X-Webhook-Signature header (HMAC-SHA256).
@@ -5,6 +5,7 @@ import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { CommentInput } from "./CommentInput.js";
import { sanitizeHtml } from "~/lib/sanitize.js";
interface CommentAuthor {
id: string;
@@ -72,21 +73,22 @@ function AuthorAvatar({ author }: { author: CommentAuthor }) {
* Transforms @[Name](userId) into styled spans.
*/
function CommentBody({ body }: { body: string }) {
const cleanBody = sanitizeHtml(body);
const parts: Array<{ type: "text" | "mention"; value: string }> = [];
const regex = /@\[([^\]]+)\]\([^)]+\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(body)) !== null) {
while ((match = regex.exec(cleanBody)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: "text", value: body.slice(lastIndex, match.index) });
parts.push({ type: "text", value: cleanBody.slice(lastIndex, match.index) });
}
parts.push({ type: "mention", value: `@${match[1]}` });
lastIndex = match.index + match[0].length;
}
if (lastIndex < body.length) {
parts.push({ type: "text", value: body.slice(lastIndex) });
if (lastIndex < cleanBody.length) {
parts.push({ type: "text", value: cleanBody.slice(lastIndex) });
}
return (
@@ -112,6 +112,9 @@ function UsersIcon() {
function SystemRolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} 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>;
}
function SecurityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} 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>;
}
function SettingsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>;
}
@@ -197,6 +200,12 @@ const navSections: NavSection[] = [
{ href: "/vacations", label: "Vacation Mgmt", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
],
},
{
label: "Account",
items: [
{ href: "/account/security", label: "Security", icon: <SecurityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
],
},
];
type AdminNavItem = { href: string; label: string; icon: ReactNode };
@@ -0,0 +1,193 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type SetupStep = "idle" | "show-secret" | "verify" | "done";
export function MfaSetup() {
const [step, setStep] = useState<SetupStep>("idle");
const [secret, setSecret] = useState("");
const [uri, setUri] = useState("");
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
const generateMutation = trpc.user.generateTotpSecret.useMutation();
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
async function handleGenerate() {
setError(null);
try {
const result = await generateMutation.mutateAsync();
setSecret(result.secret);
setUri(result.uri);
setStep("show-secret");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate TOTP secret");
}
}
async function handleVerify() {
setError(null);
try {
await verifyMutation.mutateAsync({ token });
setStep("done");
setSuccess("MFA has been enabled successfully.");
setSecret("");
setUri("");
setToken("");
await refetch();
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed");
}
}
if (mfaStatus?.totpEnabled && step !== "done") {
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="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
<svg className="h-5 w-5 text-green-600 dark:text-green-400" fill="none" 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>
);
}
return (
<div className="space-y-4">
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
{success}
</div>
)}
{step === "idle" && (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Two-Factor Authentication (TOTP)</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Add an extra layer of security by requiring a code from your authenticator app when signing in.
</p>
<button
type="button"
onClick={handleGenerate}
disabled={generateMutation.isPending}
className="mt-4 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 transition-colors disabled:opacity-50"
>
{generateMutation.isPending ? "Generating..." : "Set up MFA"}
</button>
</div>
</div>
</div>
)}
{step === "show-secret" && (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1: Scan the QR code</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
</p>
{/* QR Code via public Google Charts API (otpauth URI) */}
<div className="flex justify-center">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(uri)}`}
alt="TOTP QR Code"
width={200}
height={200}
className="rounded"
/>
</div>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Or enter this key manually:
</p>
<code className="block rounded-lg bg-gray-100 dark:bg-gray-800 px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 break-all select-all">
{secret}
</code>
</div>
<button
type="button"
onClick={() => { setStep("verify"); setError(null); }}
className="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 transition-colors"
>
Continue
</button>
</div>
)}
{step === "verify" && (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 2: Verify your code</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Enter the 6-digit code from your authenticator app to confirm setup.
</p>
<div>
<label htmlFor="mfa-verify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Verification Code
</label>
<input
id="mfa-verify-token"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
pattern="[0-9]{6}"
value={token}
onChange={(e) => setToken(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="w-48 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2 text-center text-xl font-mono tracking-[0.3em] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="000000"
autoFocus
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleVerify}
disabled={token.length !== 6 || verifyMutation.isPending}
className="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 transition-colors disabled:opacity-50"
>
{verifyMutation.isPending ? "Verifying..." : "Enable MFA"}
</button>
<button
type="button"
onClick={() => { setStep("show-secret"); setToken(""); setError(null); }}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Back
</button>
</div>
</div>
)}
</div>
);
}