feat(auth): rate limit feedback, audit log UI, and E2E tests

Rate-limiting feedback:
- LoginPage detects 429/account-locked errors and shows countdown timer
- Submit button disabled during cooldown period

Audit log:
- GET /auth/security-events endpoint (JWT-protected) in auth controller
- getSecurityEvents() in BetterAuthService + shared-auth client
- AuditLog component with event type labels, relative dates, UA parsing
- Integrated in ManaCore settings page

E2E tests (passkey-2fa.e2e-spec.ts):
- Passkey registration/authentication flow tests
- Auth guard enforcement (protected vs public endpoints)
- 2FA passthrough route existence tests
- Edge cases (cross-user access, missing fields, token shape)

CSRF note: Already covered by Better Auth (SameSite + HttpOnly +
Trusted Origins). Token refresh already has 4-retry + offline detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 21:58:56 +01:00
parent 11ab265d55
commit 0dfd603892
9 changed files with 1061 additions and 2 deletions

View file

@ -0,0 +1,414 @@
<script lang="ts">
interface SecurityEvent {
id: string;
eventType: string;
ipAddress: string | null;
userAgent: string | null;
metadata: any;
createdAt: string;
}
interface Props {
events: SecurityEvent[];
onRefresh: () => Promise<void>;
loading?: boolean;
primaryColor?: string;
}
let { events, onRefresh, loading = false, primaryColor = '#6366f1' }: Props = $props();
interface EventInfo {
label: string;
badgeClass: string;
badgeText: string;
}
function getEventInfo(eventType: string): EventInfo {
switch (eventType) {
case 'login_success':
return { label: 'Anmeldung erfolgreich', badgeClass: 'badge-success', badgeText: '' };
case 'login_failure':
return { label: 'Anmeldung fehlgeschlagen', badgeClass: 'badge-danger', badgeText: '' };
case 'register':
return { label: 'Konto erstellt', badgeClass: 'badge-info', badgeText: 'Neu' };
case 'logout':
return { label: 'Abgemeldet', badgeClass: 'badge-neutral', badgeText: '' };
case 'password_changed':
return { label: 'Passwort geändert', badgeClass: 'badge-warning', badgeText: '' };
case 'password_reset_requested':
return { label: 'Passwort-Reset angefordert', badgeClass: 'badge-warning', badgeText: '' };
case 'password_reset_completed':
return { label: 'Passwort zurückgesetzt', badgeClass: 'badge-warning', badgeText: '' };
case 'passkey_registered':
return { label: 'Passkey registriert', badgeClass: 'badge-warning', badgeText: '' };
case 'passkey_login_success':
return { label: 'Passkey-Anmeldung', badgeClass: 'badge-success', badgeText: '' };
case 'passkey_deleted':
return { label: 'Passkey gelöscht', badgeClass: 'badge-danger', badgeText: '' };
case 'two_factor_enabled':
return { label: '2FA aktiviert', badgeClass: 'badge-success', badgeText: '' };
case 'two_factor_disabled':
return { label: '2FA deaktiviert', badgeClass: 'badge-warning', badgeText: '' };
case 'account_locked':
return { label: 'Konto gesperrt', badgeClass: 'badge-danger', badgeText: '' };
case 'account_deleted':
return { label: 'Konto gelöscht', badgeClass: 'badge-danger', badgeText: '' };
case 'sso_token_exchange':
return { label: 'SSO-Anmeldung', badgeClass: 'badge-success', badgeText: '' };
default:
return { label: eventType, badgeClass: 'badge-neutral', badgeText: '' };
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const timeStr = date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
if (diffDays === 0) {
return `Heute, ${timeStr}`;
} else if (diffDays === 1) {
return `Gestern, ${timeStr}`;
} else {
const dateFormatted = date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return `${dateFormatted}, ${timeStr}`;
}
}
function parseUserAgent(ua: string | null): string {
if (!ua) return '';
let browser = '';
let os = '';
// Detect browser
if (ua.includes('Firefox/')) browser = 'Firefox';
else if (ua.includes('Edg/')) browser = 'Edge';
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome';
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari';
else if (ua.includes('Opera/') || ua.includes('OPR/')) browser = 'Opera';
// Detect OS
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
else if (ua.includes('Linux') && !ua.includes('Android')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
const parts = [browser, os].filter(Boolean);
return parts.length > 0 ? parts.join(' · ') : '';
}
</script>
<div class="audit-log" style:--primary-color={primaryColor}>
<div class="audit-header">
<div class="audit-header-left">
<div class="audit-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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 class="audit-title">Sicherheitsprotokoll</h3>
<p class="audit-subtitle">Letzte Aktivitäten deines Kontos</p>
</div>
</div>
<button
type="button"
class="refresh-button"
onclick={onRefresh}
disabled={loading}
aria-label="Aktualisieren"
>
<svg
class="refresh-icon"
class:spinning={loading}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
{#if loading && events.length === 0}
<div class="loading-state">
<div class="loading-spinner"></div>
</div>
{:else if events.length === 0}
<p class="empty-state">Keine Sicherheitsereignisse vorhanden.</p>
{:else}
<div class="event-list">
{#each events as event (event.id)}
{@const info = getEventInfo(event.eventType)}
<div class="event-item">
<div class="event-badge {info.badgeClass}"></div>
<div class="event-content">
<div class="event-label">
{info.label}
{#if info.badgeText}
<span class="event-tag">{info.badgeText}</span>
{/if}
</div>
<div class="event-meta">
<span>{formatDate(event.createdAt)}</span>
{#if event.ipAddress}
<span class="meta-separator">·</span>
<span>{event.ipAddress}</span>
{/if}
</div>
{#if parseUserAgent(event.userAgent)}
<div class="event-device">
{parseUserAgent(event.userAgent)}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.audit-log {
width: 100%;
}
.audit-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.audit-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.audit-icon {
display: flex;
height: 2.5rem;
width: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
}
.audit-icon .icon {
height: 1.25rem;
width: 1.25rem;
}
.audit-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.audit-subtitle {
font-size: 0.875rem;
color: var(--text-muted, #9ca3af);
margin: 0;
}
.refresh-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color, #e5e7eb);
background: transparent;
color: var(--text-muted, #9ca3af);
cursor: pointer;
transition: all 0.2s;
}
.refresh-button:hover:not(:disabled) {
background: var(--hover-bg, #f3f4f6);
color: var(--text-primary, #111827);
}
.refresh-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .refresh-button {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .refresh-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.refresh-icon {
width: 1.125rem;
height: 1.125rem;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state {
display: flex;
justify-content: center;
padding: 2rem;
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--border-color, #e5e7eb);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
}
.event-list {
display: flex;
flex-direction: column;
max-height: 28rem;
overflow-y: auto;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.75rem;
}
:global(.dark) .event-list {
border-color: rgba(255, 255, 255, 0.1);
}
.event-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
:global(.dark) .event-item {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.event-item:last-child {
border-bottom: none;
}
.event-badge {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
margin-top: 0.375rem;
}
.badge-success {
background-color: #22c55e;
}
.badge-danger {
background-color: #ef4444;
}
.badge-warning {
background-color: #f59e0b;
}
.badge-info {
background-color: #3b82f6;
}
.badge-neutral {
background-color: #9ca3af;
}
.event-content {
flex: 1;
min-width: 0;
}
.event-label {
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.event-tag {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
}
.event-meta {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
margin-top: 0.125rem;
}
.meta-separator {
margin: 0 0.25rem;
}
.event-device {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
opacity: 0.8;
}
@media (prefers-reduced-motion: reduce) {
.spinning {
animation: none;
}
.loading-spinner {
animation: none;
}
}
</style>

View file

@ -12,6 +12,7 @@ export { default as PasskeyManager } from './components/PasskeyManager.svelte';
export { default as TwoFactorSetup } from './components/TwoFactorSetup.svelte';
export { default as SecurityOnboarding } from './components/SecurityOnboarding.svelte';
export { default as ChangePassword } from './components/ChangePassword.svelte';
export { default as AuditLog } from './components/AuditLog.svelte';
// Utilities
export {

View file

@ -145,6 +145,16 @@
let twoFactorCode = $state('');
let useBackupCode = $state(false);
let trustDevice = $state(false);
let rateLimitCountdown = $state(0);
$effect(() => {
if (rateLimitCountdown > 0) {
const timer = setTimeout(() => {
rateLimitCountdown--;
}, 1000);
return () => clearTimeout(timer);
}
});
// Theme state - can be toggled manually, defaults to system preference
let userThemePreference = $state<'light' | 'dark' | null>(null);
@ -252,6 +262,16 @@
setError(t.emailNotVerified || 'Email not verified.', 'general');
} else {
setError(result.error || t.signInFailed, 'general');
// Detect rate limiting
if (result.error?.includes('Too Many') || result.error?.includes('rate limit')) {
rateLimitCountdown = 60; // 1 minute cooldown
} else if (
result.error?.includes('temporarily locked') ||
result.error === 'ACCOUNT_LOCKED'
) {
rateLimitCountdown = (result as any).retryAfter || 300; // 5 min default
}
}
}
@ -568,6 +588,9 @@
{resendingVerification ? t.resendingVerification : t.resendVerification}
</button>
{/if}
{#if rateLimitCountdown > 0}
<p class="retry-countdown">Erneut versuchen in {rateLimitCountdown}s</p>
{/if}
</div>
</div>
{/if}
@ -652,7 +675,7 @@
<!-- Submit -->
<button
type="submit"
disabled={loading || showSuccess}
disabled={loading || showSuccess || rateLimitCountdown > 0}
class="submit-button"
style:background-color={showSuccess ? '#22c55e' : primaryColor + '60'}
style:border-color={showSuccess ? '#22c55e' : primaryColor}
@ -934,6 +957,11 @@
gap: 0.25rem;
}
.retry-countdown {
font-weight: 600;
margin-top: 0.25rem;
}
.resend-link {
background: none;
border: none;

View file

@ -778,6 +778,25 @@ export function createAuthService(config: AuthServiceConfig) {
}
},
/**
* Get security events (audit log)
*/
async getSecurityEvents(limit = 50): Promise<any[]> {
try {
const appToken = await service.getAppToken();
if (!appToken) return [];
const res = await fetch(`${baseUrl}/api/v1/auth/security-events?limit=${limit}`, {
headers: { Authorization: `Bearer ${appToken}` },
});
if (!res.ok) return [];
return await res.json();
} catch {
return [];
}
},
/**
* Get the current app token
*/