mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
11ab265d55
commit
0dfd603892
9 changed files with 1061 additions and 2 deletions
|
|
@ -202,6 +202,12 @@ export const authStore = {
|
|||
return authService.renamePasskey(passkeyId, friendlyName);
|
||||
},
|
||||
|
||||
async getSecurityEvents() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return [];
|
||||
return authService.getSecurityEvents();
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button, Input, Card, PageHeader, GlobalSettingsSection } from '@manacore/shared-ui';
|
||||
import { PasskeyManager, TwoFactorSetup } from '@manacore/shared-auth-ui';
|
||||
import { PasskeyManager, TwoFactorSetup, AuditLog } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { creditsService } from '$lib/api/credits';
|
||||
import type { CreditBalance } from '$lib/api/credits';
|
||||
|
|
@ -23,6 +23,10 @@
|
|||
// Credits data
|
||||
let creditBalance = $state<CreditBalance | null>(null);
|
||||
|
||||
// Security events
|
||||
let securityEvents = $state<any[]>([]);
|
||||
let securityEventsLoading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
try {
|
||||
|
|
@ -30,6 +34,10 @@
|
|||
passkeys = await authStore.listPasskeys();
|
||||
// Load user settings from server
|
||||
await userSettings.load();
|
||||
// Load security events
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e);
|
||||
}
|
||||
|
|
@ -307,6 +315,22 @@
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Security Log Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<AuditLog
|
||||
events={securityEvents}
|
||||
loading={securityEventsLoading}
|
||||
onRefresh={async () => {
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- My Data & Danger Zone -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
|
|
|
|||
414
packages/shared-auth-ui/src/components/AuditLog.svelte
Normal file
414
packages/shared-auth-ui/src/components/AuditLog.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -818,6 +818,25 @@ export class AuthController {
|
|||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Security Events
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get user security events (audit log)
|
||||
*
|
||||
* Returns the authenticated user's security events ordered by most recent first.
|
||||
*/
|
||||
@Get('security-events')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Get user security events (audit log)' })
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiResponse({ status: 200, description: 'Returns security events' })
|
||||
@ApiResponse({ status: 401, description: 'Not authenticated' })
|
||||
async getSecurityEvents(@CurrentUser() user: CurrentUserData, @Req() req: Request) {
|
||||
return this.betterAuthService.getSecurityEvents(user.userId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Passkey (WebAuthn) Endpoints
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -2117,4 +2117,29 @@ export class BetterAuthService {
|
|||
throw new UnauthorizedException('Failed to exchange session for tokens');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events for a user (audit log)
|
||||
*/
|
||||
async getSecurityEvents(userId: string, limit = 50) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { securityEvents } = await import('../../db/schema');
|
||||
const { eq, desc } = await import('drizzle-orm');
|
||||
|
||||
const events = await db
|
||||
.select({
|
||||
id: securityEvents.id,
|
||||
eventType: securityEvents.eventType,
|
||||
ipAddress: securityEvents.ipAddress,
|
||||
userAgent: securityEvents.userAgent,
|
||||
metadata: securityEvents.metadata,
|
||||
createdAt: securityEvents.createdAt,
|
||||
})
|
||||
.from(securityEvents)
|
||||
.where(eq(securityEvents.userId, userId))
|
||||
.orderBy(desc(securityEvents.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
523
services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts
Normal file
523
services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
/**
|
||||
* Passkey & 2FA E2E Tests
|
||||
*
|
||||
* Tests the HTTP layer for:
|
||||
* 1. Passkey registration flow (auth required)
|
||||
* 2. Passkey authentication flow (public)
|
||||
* 3. Passkey management (list, rename, delete)
|
||||
* 4. Auth guard enforcement on passkey endpoints
|
||||
* 5. 2FA redirect detection during sign-in
|
||||
* 6. Session-to-token exchange after 2FA verification
|
||||
*
|
||||
* WebAuthn crypto is handled by @simplewebauthn/server which is mocked
|
||||
* at the module level (via jest-e2e.json moduleNameMapper). These tests
|
||||
* focus on request/response shapes, status codes, and auth guard behavior.
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
|
||||
describe('Passkey & 2FA (E2E)', () => {
|
||||
let app: INestApplication;
|
||||
let accessToken: string;
|
||||
let refreshToken: string;
|
||||
let userId: string;
|
||||
|
||||
const testEmail = `passkey-e2e-${Date.now()}@example.com`;
|
||||
const testPassword = 'SecurePassword123!';
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
await app.init();
|
||||
|
||||
// Register and login a test user for authenticated passkey operations
|
||||
const registerResponse = await request(app.getHttpServer()).post('/auth/register').send({
|
||||
email: testEmail,
|
||||
password: testPassword,
|
||||
name: 'Passkey E2E User',
|
||||
});
|
||||
|
||||
userId = registerResponse.body.id;
|
||||
|
||||
const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({
|
||||
email: testEmail,
|
||||
password: testPassword,
|
||||
});
|
||||
|
||||
accessToken = loginResponse.body.accessToken;
|
||||
refreshToken = loginResponse.body.refreshToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Passkey Registration Flow
|
||||
// =========================================================================
|
||||
|
||||
describe('Passkey Registration Flow', () => {
|
||||
it('should generate registration options for authenticated user', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/options')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect((res) => {
|
||||
expect([200, 201]).toContain(res.status);
|
||||
});
|
||||
|
||||
expect(response.body).toHaveProperty('options');
|
||||
expect(response.body).toHaveProperty('challengeId');
|
||||
expect(response.body.options).toHaveProperty('challenge');
|
||||
expect(typeof response.body.options.challenge).toBe('string');
|
||||
expect(response.body.options.challenge.length).toBeGreaterThan(0);
|
||||
expect(typeof response.body.challengeId).toBe('string');
|
||||
});
|
||||
|
||||
it('should include RP info in registration options', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/options')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect((res) => {
|
||||
expect([200, 201]).toContain(res.status);
|
||||
});
|
||||
|
||||
const { options } = response.body;
|
||||
expect(options).toHaveProperty('rp');
|
||||
expect(options.rp).toHaveProperty('name');
|
||||
expect(options.rp).toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('should include user info in registration options', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/options')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect((res) => {
|
||||
expect([200, 201]).toContain(res.status);
|
||||
});
|
||||
|
||||
const { options } = response.body;
|
||||
expect(options).toHaveProperty('user');
|
||||
expect(options.user).toHaveProperty('name');
|
||||
expect(options.user).toHaveProperty('displayName');
|
||||
});
|
||||
|
||||
it('should reject registration verify with invalid challenge', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/verify')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
challengeId: 'invalid-challenge-id',
|
||||
credential: {
|
||||
id: 'fake-credential-id',
|
||||
rawId: 'fake-raw-id',
|
||||
response: {
|
||||
clientDataJSON: 'fake-client-data',
|
||||
attestationObject: 'fake-attestation',
|
||||
},
|
||||
type: 'public-key',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('message');
|
||||
expect(response.body.message).toMatch(/invalid|expired/i);
|
||||
});
|
||||
|
||||
it('should reject registration verify with expired challenge', async () => {
|
||||
// Get valid options but use a bogus challengeId
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/options')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/verify')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
challengeId: 'non-existent-challenge-id',
|
||||
credential: {
|
||||
id: 'fake-credential',
|
||||
rawId: 'fake-raw',
|
||||
response: {
|
||||
clientDataJSON: 'fake',
|
||||
attestationObject: 'fake',
|
||||
},
|
||||
type: 'public-key',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toMatch(/invalid|expired/i);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Passkey Authentication Flow (Public Endpoints)
|
||||
// =========================================================================
|
||||
|
||||
describe('Passkey Authentication Flow', () => {
|
||||
it('should generate authentication options without auth', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/authenticate/options')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('options');
|
||||
expect(response.body).toHaveProperty('challengeId');
|
||||
expect(response.body.options).toHaveProperty('challenge');
|
||||
expect(typeof response.body.options.challenge).toBe('string');
|
||||
expect(response.body.options.challenge.length).toBeGreaterThan(0);
|
||||
expect(typeof response.body.challengeId).toBe('string');
|
||||
});
|
||||
|
||||
it('should include rpId in authentication options', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/authenticate/options')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.options).toHaveProperty('rpId');
|
||||
});
|
||||
|
||||
it('should reject authentication verify with invalid challenge', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/authenticate/verify')
|
||||
.send({
|
||||
challengeId: 'invalid-challenge-id',
|
||||
credential: {
|
||||
id: 'fake-credential-id',
|
||||
rawId: 'fake-raw-id',
|
||||
response: {
|
||||
clientDataJSON: 'fake-client-data',
|
||||
authenticatorData: 'fake-auth-data',
|
||||
signature: 'fake-signature',
|
||||
},
|
||||
type: 'public-key',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('message');
|
||||
expect(response.body.message).toMatch(/invalid|expired/i);
|
||||
});
|
||||
|
||||
it('should reject authentication verify without challengeId', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/authenticate/verify')
|
||||
.send({
|
||||
credential: {
|
||||
id: 'fake-credential',
|
||||
response: {},
|
||||
type: 'public-key',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Passkey Management (List, Rename, Delete)
|
||||
// =========================================================================
|
||||
|
||||
describe('Passkey Management', () => {
|
||||
it('should list passkeys for authenticated user (initially empty)', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/auth/passkeys')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
// New user should have no passkeys initially
|
||||
expect(response.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 404 when deleting non-existent passkey', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete('/auth/passkeys/non-existent-id')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 404 when renaming non-existent passkey', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/auth/passkeys/non-existent-id')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ friendlyName: 'My Key' })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Auth Guard Enforcement
|
||||
// =========================================================================
|
||||
|
||||
describe('Auth Guard Enforcement', () => {
|
||||
describe('Protected endpoints require JWT', () => {
|
||||
it('POST /auth/passkeys/register/options requires auth', async () => {
|
||||
await request(app.getHttpServer()).post('/auth/passkeys/register/options').expect(401);
|
||||
});
|
||||
|
||||
it('POST /auth/passkeys/register/verify requires auth', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/verify')
|
||||
.send({
|
||||
challengeId: 'test',
|
||||
credential: { id: 'test', response: {} },
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('GET /auth/passkeys requires auth', async () => {
|
||||
await request(app.getHttpServer()).get('/auth/passkeys').expect(401);
|
||||
});
|
||||
|
||||
it('DELETE /auth/passkeys/:id requires auth', async () => {
|
||||
await request(app.getHttpServer()).delete('/auth/passkeys/some-id').expect(401);
|
||||
});
|
||||
|
||||
it('PATCH /auth/passkeys/:id requires auth', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/auth/passkeys/some-id')
|
||||
.send({ friendlyName: 'test' })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public endpoints do not require JWT', () => {
|
||||
it('POST /auth/passkeys/authenticate/options is public', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/authenticate/options')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('options');
|
||||
});
|
||||
|
||||
it('POST /auth/passkeys/authenticate/verify is public (fails on invalid data, not auth)', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/authenticate/verify')
|
||||
.send({
|
||||
challengeId: 'invalid',
|
||||
credential: { id: 'test', response: {} },
|
||||
});
|
||||
|
||||
// Should get 400 (bad request) not 401 (unauthorized)
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid token handling', () => {
|
||||
it('should reject passkey endpoints with invalid token', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/options')
|
||||
.set('Authorization', 'Bearer invalid-jwt-token')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should reject passkey endpoints with malformed auth header', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/auth/passkeys')
|
||||
.set('Authorization', 'NotBearer token')
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 2FA Flow via Sign-In
|
||||
// =========================================================================
|
||||
|
||||
describe('2FA Flow', () => {
|
||||
it('should return standard login response when 2FA is not enabled', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: testEmail,
|
||||
password: testPassword,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// Normal user without 2FA should get tokens
|
||||
expect(response.body).toHaveProperty('accessToken');
|
||||
expect(response.body).toHaveProperty('refreshToken');
|
||||
expect(response.body).not.toHaveProperty('twoFactorRedirect');
|
||||
});
|
||||
|
||||
it('session-to-token endpoint should exist', async () => {
|
||||
// Without a valid session cookie, this should return 401
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/session-to-token')
|
||||
.expect((res) => {
|
||||
// Should be 401 (no session cookie) not 404 (endpoint missing)
|
||||
expect(res.status).not.toBe(404);
|
||||
expect([200, 401]).toContain(res.status);
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
expect(response.body).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
|
||||
it('session-to-token should reject request without session cookie', async () => {
|
||||
await request(app.getHttpServer()).post('/auth/session-to-token').expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 2FA Passthrough Endpoints
|
||||
// =========================================================================
|
||||
|
||||
describe('2FA Passthrough Routes', () => {
|
||||
it('should expose two-factor enable endpoint (requires session)', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/auth/two-factor/enable')
|
||||
.send({});
|
||||
|
||||
// Should not be 404 - the route exists even if auth fails
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('should expose two-factor verify-totp endpoint', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/auth/two-factor/verify-totp')
|
||||
.send({ code: '000000' });
|
||||
|
||||
// Should not be 404 - the route exists
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('should expose two-factor disable endpoint', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/auth/two-factor/disable')
|
||||
.send({});
|
||||
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('should expose two-factor get-totp-uri endpoint', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/auth/two-factor/get-totp-uri')
|
||||
.send({});
|
||||
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('should expose two-factor generate-backup-codes endpoint', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/auth/two-factor/generate-backup-codes')
|
||||
.send({});
|
||||
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('should expose two-factor verify-backup-code endpoint', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/auth/two-factor/verify-backup-code')
|
||||
.send({ code: 'fake-backup-code' });
|
||||
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Passkey + Login Token Shape Consistency
|
||||
// =========================================================================
|
||||
|
||||
describe('Token Response Shape Consistency', () => {
|
||||
it('login and passkey-auth-verify should share the same token response shape', async () => {
|
||||
// Login response shape
|
||||
const loginResponse = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: testEmail,
|
||||
password: testPassword,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// Verify the login token shape (passkey auth verify returns the same shape)
|
||||
const tokenKeys = Object.keys(loginResponse.body);
|
||||
expect(tokenKeys).toContain('user');
|
||||
expect(tokenKeys).toContain('accessToken');
|
||||
expect(tokenKeys).toContain('refreshToken');
|
||||
expect(tokenKeys).toContain('expiresIn');
|
||||
|
||||
expect(loginResponse.body.user).toHaveProperty('id');
|
||||
expect(loginResponse.body.user).toHaveProperty('email');
|
||||
expect(typeof loginResponse.body.accessToken).toBe('string');
|
||||
expect(typeof loginResponse.body.refreshToken).toBe('string');
|
||||
expect(typeof loginResponse.body.expiresIn).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty body on register/options gracefully', async () => {
|
||||
// The endpoint reads user from JWT, so empty body is fine
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/options')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({});
|
||||
|
||||
expect([200, 201]).toContain(response.status);
|
||||
expect(response.body).toHaveProperty('challengeId');
|
||||
});
|
||||
|
||||
it('should handle missing credential field on register/verify', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/register/verify')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ challengeId: 'some-challenge' });
|
||||
|
||||
expect([400, 500]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should handle missing body on authenticate/verify', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/passkeys/authenticate/verify')
|
||||
.send({});
|
||||
|
||||
expect([400, 500]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should not allow cross-user passkey deletion', async () => {
|
||||
// Create a second user
|
||||
const otherEmail = `passkey-other-${Date.now()}@example.com`;
|
||||
await request(app.getHttpServer()).post('/auth/register').send({
|
||||
email: otherEmail,
|
||||
password: testPassword,
|
||||
name: 'Other User',
|
||||
});
|
||||
|
||||
const otherLogin = await request(app.getHttpServer()).post('/auth/login').send({
|
||||
email: otherEmail,
|
||||
password: testPassword,
|
||||
});
|
||||
|
||||
const otherToken = otherLogin.body.accessToken;
|
||||
|
||||
// Try to delete a non-existent passkey with other user's token
|
||||
// This should return 404 (not found for this user) not 204
|
||||
await request(app.getHttpServer())
|
||||
.delete('/auth/passkeys/some-passkey-id')
|
||||
.set('Authorization', `Bearer ${otherToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should generate unique challenge IDs across requests', async () => {
|
||||
const [res1, res2] = await Promise.all([
|
||||
request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(),
|
||||
request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(),
|
||||
]);
|
||||
|
||||
expect(res1.body.challengeId).not.toBe(res2.body.challengeId);
|
||||
expect(res1.body.options.challenge).not.toBe(res2.body.options.challenge);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue