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:
@@ -25,6 +25,16 @@ const nextConfig: NextConfig = {
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
||||
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" },
|
||||
{ key: "X-XSS-Protection", value: "0" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/auth/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "no-store, no-cache, must-revalidate" },
|
||||
{ key: "Pragma", value: "no-cache" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@capakraken/api": "workspace:*",
|
||||
"@capakraken/application": "workspace:*",
|
||||
"@capakraken/db": "workspace:*",
|
||||
"@capakraken/engine": "workspace:*",
|
||||
"@capakraken/shared": "workspace:*",
|
||||
"@capakraken/ui": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/nextjs": "^10.45.0",
|
||||
"@tanstack/react-query": "^5.62.16",
|
||||
@@ -29,9 +29,11 @@
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "^15.1.7",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"otpauth": "^9.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-force-graph-3d": "^1.29.1",
|
||||
@@ -46,6 +48,7 @@
|
||||
"devDependencies": {
|
||||
"@capakraken/tsconfig": "workspace:*",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.6",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MfaSetup } from "~/components/security/MfaSetup.js";
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Account Security</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage two-factor authentication and other security settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MfaSetup />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export default function SignInPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mfaRequired, setMfaRequired] = useState(false);
|
||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -19,11 +22,35 @@ export default function SignInPage() {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
...(mfaRequired ? { totp } : {}),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password");
|
||||
// Auth.js wraps authorize() errors in the error field
|
||||
if (result.error.includes("MFA_REQUIRED")) {
|
||||
setMfaRequired(true);
|
||||
setLoading(false);
|
||||
// Focus the TOTP input after render
|
||||
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||
return;
|
||||
}
|
||||
if (result.error.includes("INVALID_TOTP")) {
|
||||
setError("Invalid verification code. Please try again.");
|
||||
setTotp("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (result.error.includes("Too many login attempts")) {
|
||||
setError("Too many login attempts. Please try again later.");
|
||||
} else {
|
||||
setError("Invalid email or password");
|
||||
}
|
||||
// Reset MFA state on credential error
|
||||
if (mfaRequired) {
|
||||
setMfaRequired(false);
|
||||
setTotp("");
|
||||
}
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
@@ -31,6 +58,12 @@ export default function SignInPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleBackToLogin() {
|
||||
setMfaRequired(false);
|
||||
setTotp("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_26rem),linear-gradient(135deg,rgba(240,249,255,1),rgba(232,245,255,0.85)_40%,rgba(255,255,255,1))] px-4 py-12 dark:bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.14),transparent_24rem),linear-gradient(180deg,rgba(12,17,29,1),rgba(10,15,25,1))]">
|
||||
<div className="mx-auto grid w-full max-w-6xl gap-8 lg:grid-cols-[1.05fr,0.95fr]">
|
||||
@@ -66,8 +99,14 @@ export default function SignInPage() {
|
||||
<div className="app-surface-strong p-8">
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p>
|
||||
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to CapaKraken</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">Resource Planning, staffing, and forecasting.</p>
|
||||
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{mfaRequired
|
||||
? "Enter the 6-digit code from your authenticator app."
|
||||
: "Resource Planning, staffing, and forecasting."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -77,43 +116,83 @@ export default function SignInPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="app-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="app-input"
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{!mfaRequired && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="email" className="app-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="app-input"
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="app-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="app-input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="app-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="app-input"
|
||||
placeholder="--------"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mfaRequired && (
|
||||
<div>
|
||||
<label htmlFor="totp" className="app-label">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
ref={totpInputRef}
|
||||
id="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
className="app-input text-center text-2xl font-mono tracking-[0.4em]"
|
||||
placeholder="000000"
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Open your authenticator app (e.g. Google Authenticator, Authy) and enter the current code.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || (mfaRequired && totp.length !== 6)}
|
||||
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..." : "Sign in"}
|
||||
{loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
|
||||
</button>
|
||||
|
||||
{mfaRequired && (
|
||||
<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>
|
||||
)}
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
/**
|
||||
* Strip all HTML tags and attributes from a string.
|
||||
* Returns plain text only (no tags, no attributes).
|
||||
* SSR-safe: returns the input unchanged on the server.
|
||||
*/
|
||||
export function sanitizeHtml(dirty: string): string {
|
||||
if (typeof window === "undefined") return dirty;
|
||||
return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
|
||||
}
|
||||
+191
-3
@@ -1,4 +1,7 @@
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
|
||||
import { createAuditEntry } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { verify } from "@node-rs/argon2";
|
||||
@@ -7,6 +10,7 @@ import { z } from "zod";
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
totp: z.string().optional(),
|
||||
});
|
||||
|
||||
const authConfig = {
|
||||
@@ -16,17 +20,96 @@ const authConfig = {
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
totp: { label: "TOTP", type: "text" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const parsed = LoginSchema.safeParse(credentials);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const { email, password, totp } = parsed.data;
|
||||
|
||||
// Rate limit: 5 login attempts per 15 minutes per email
|
||||
const rateLimitResult = authRateLimiter(email.toLowerCase());
|
||||
if (!rateLimitResult.allowed) {
|
||||
// Audit failed login (rate limited)
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
entityType: "Auth",
|
||||
entityId: email.toLowerCase(),
|
||||
entityName: email,
|
||||
action: "CREATE",
|
||||
summary: "Login blocked — rate limit exceeded",
|
||||
source: "ui",
|
||||
});
|
||||
throw new Error("Too many login attempts. Please try again later.");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user?.passwordHash) return null;
|
||||
if (!user?.passwordHash) {
|
||||
logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
|
||||
// Audit failed login (unknown user)
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
entityType: "Auth",
|
||||
entityId: email.toLowerCase(),
|
||||
entityName: email,
|
||||
action: "CREATE",
|
||||
summary: "Login failed — user not found",
|
||||
source: "ui",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await verify(user.passwordHash, password);
|
||||
if (!isValid) return null;
|
||||
if (!isValid) {
|
||||
logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
|
||||
// Audit failed login (bad password)
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
entityType: "Auth",
|
||||
entityId: user.id,
|
||||
entityName: user.email,
|
||||
action: "CREATE",
|
||||
userId: user.id,
|
||||
summary: "Login failed — invalid password",
|
||||
source: "ui",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// MFA check: if TOTP is enabled, require the token
|
||||
if (user.totpEnabled && user.totpSecret) {
|
||||
if (!totp) {
|
||||
// Signal to the client that MFA is required (include userId for re-submission)
|
||||
throw new Error("MFA_REQUIRED:" + user.id);
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
const delta = totpInstance.validate({ token: totp, window: 1 });
|
||||
if (delta === null) {
|
||||
logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification");
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
entityType: "Auth",
|
||||
entityId: user.id,
|
||||
entityName: user.email,
|
||||
action: "CREATE",
|
||||
userId: user.id,
|
||||
summary: "Login failed — invalid TOTP token",
|
||||
source: "ui",
|
||||
});
|
||||
throw new Error("INVALID_TOTP");
|
||||
}
|
||||
}
|
||||
|
||||
// Track last login time
|
||||
await prisma.user.update({
|
||||
@@ -34,6 +117,19 @@ const authConfig = {
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
logger.info({ email, userId: user.id }, "Successful login");
|
||||
// Audit successful login
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
entityType: "Auth",
|
||||
entityId: user.id,
|
||||
entityName: user.email,
|
||||
action: "CREATE",
|
||||
userId: user.id,
|
||||
summary: "User logged in",
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -56,15 +152,107 @@ const authConfig = {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = (user as typeof user & { role: string }).role;
|
||||
|
||||
// Generate a unique JWT ID for session tracking
|
||||
const jti = crypto.randomUUID();
|
||||
token.jti = jti;
|
||||
|
||||
// Enforce concurrent session limit (kick-oldest strategy)
|
||||
try {
|
||||
const settings = await prisma.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { maxConcurrentSessions: true },
|
||||
});
|
||||
const maxSessions = settings?.maxConcurrentSessions ?? 3;
|
||||
|
||||
// Register this new session
|
||||
await prisma.activeSession.create({
|
||||
data: { userId: user.id!, jti },
|
||||
});
|
||||
|
||||
// Count active sessions and delete the oldest if over the limit
|
||||
const activeSessions = await prisma.activeSession.findMany({
|
||||
where: { userId: user.id! },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (activeSessions.length > maxSessions) {
|
||||
const toDelete = activeSessions.slice(0, activeSessions.length - maxSessions);
|
||||
await prisma.activeSession.deleteMany({
|
||||
where: { id: { in: toDelete.map((s) => s.id) } },
|
||||
});
|
||||
logger.info({ userId: user.id, kicked: toDelete.length, maxSessions }, "Kicked oldest sessions");
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-blocking: don't prevent login if session tracking fails
|
||||
logger.error({ err }, "Failed to enforce concurrent session limit");
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async signOut(message) {
|
||||
// Auth.js fires this event on sign-out; extract userId from the JWT token
|
||||
const token = "token" in message ? message.token : null;
|
||||
const userId = token?.sub ?? null;
|
||||
const email = token?.email ?? "unknown";
|
||||
const jti = token?.jti as string | undefined;
|
||||
|
||||
// Remove from active session registry
|
||||
if (jti) {
|
||||
void prisma.activeSession.delete({ where: { jti } }).catch(() => { /* already gone */ });
|
||||
}
|
||||
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
entityType: "Auth",
|
||||
entityId: userId ?? email,
|
||||
entityName: email,
|
||||
action: "DELETE",
|
||||
...(userId ? { userId } : {}),
|
||||
summary: "User logged out",
|
||||
source: "ui",
|
||||
});
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: "authjs.session-token",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "strict" as const,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
callbackUrl: {
|
||||
name: "authjs.callback-url",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "strict" as const,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
csrfToken: {
|
||||
name: "authjs.csrf-token",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "strict" as const,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 28800, // 8 hours absolute timeout
|
||||
updateAge: 1800, // Refresh token every 30 minutes (idle timeout)
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user