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
+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>