feat(auth): implement Android refresh token handling and session management
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints. - Implemented new tests for login, logout, and refresh functionalities specific to Android clients. - Enhanced password reset logging with normalization and masking of email addresses. - Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs. - Introduced a new utility for managing password reset logs with retention policies. - Added tests for password reset log utilities to ensure proper functionality and privacy compliance. - Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
This commit is contained in:
@@ -222,13 +222,34 @@
|
||||
Benutzer freischalten und verwalten
|
||||
</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="authStore.hasRole('admin')"
|
||||
to="/cms/passwort-reset-diagnose"
|
||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center group-hover:bg-red-600 transition-colors">
|
||||
<ShieldAlert
|
||||
:size="24"
|
||||
class="text-red-600 group-hover:text-white"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||
Passwort-Reset-Diagnose
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-gray-600">
|
||||
Fehlversuche und Versandabläufe prüfen
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail } from 'lucide-vue-next'
|
||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail, ShieldAlert } from 'lucide-vue-next'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
269
pages/cms/passwort-reset-diagnose.vue
Normal file
269
pages/cms/passwort-reset-diagnose.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="min-h-full py-16 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col gap-4 mb-8 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-4xl font-display font-bold text-gray-900">
|
||||
Passwort-Reset-Diagnose
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mt-4" />
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/cms"
|
||||
class="self-start px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Zurück zum CMS
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
|
||||
<form
|
||||
class="grid gap-4 lg:grid-cols-[minmax(240px,1fr)_auto_auto]"
|
||||
@submit.prevent="loadDiagnostics"
|
||||
>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 mb-2">E-Mail oder Name</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
placeholder="z.B. user@example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-primary-600"
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm font-medium text-gray-700">
|
||||
<input
|
||||
v-model="failedOnly"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
||||
>
|
||||
Nur Auffälligkeiten
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="self-end px-5 py-2 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Search :size="17" />
|
||||
Prüfen
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4 text-sm text-gray-600">
|
||||
Diagnoseeinträge werden nach {{ retentionHours }} Stunden automatisch gelöscht. E-Mail-Adressen sind im Log maskiert.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-sm text-red-800"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="searchTerm.trim()"
|
||||
class="bg-white border border-gray-200 rounded-lg shadow-sm mb-6 overflow-hidden"
|
||||
>
|
||||
<header class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Passende Benutzerkonten
|
||||
</h2>
|
||||
</header>
|
||||
<div
|
||||
v-if="matchingUsers.length === 0"
|
||||
class="px-6 py-5 text-sm text-gray-600"
|
||||
>
|
||||
Kein Login-Benutzer zur Suche gefunden.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-gray-200"
|
||||
>
|
||||
<div
|
||||
v-for="user in matchingUsers"
|
||||
:key="user.id"
|
||||
class="p-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">
|
||||
{{ user.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ user.email }} · {{ user.active ? 'Aktiv' : 'Nicht freigeschaltet' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 border border-primary-600 text-primary-700 hover:bg-primary-50 rounded-lg text-sm font-medium transition-colors"
|
||||
@click="searchUserLogs(user.email)"
|
||||
>
|
||||
Logs dieser Adresse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<header class="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Reset-Vorgänge
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
{{ attempts.length }} Einträge im gewählten Filter
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-gray-600 hover:text-primary-700 disabled:opacity-40"
|
||||
:disabled="loading"
|
||||
title="Aktualisieren"
|
||||
@click="loadDiagnostics"
|
||||
>
|
||||
<RefreshCw :size="19" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="!loading && attempts.length === 0"
|
||||
class="px-6 py-10 text-center text-gray-600"
|
||||
>
|
||||
Keine Diagnosevorgänge gefunden.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-gray-200"
|
||||
>
|
||||
<article
|
||||
v-for="attempt in attempts"
|
||||
:key="attempt.requestId"
|
||||
class="px-6 py-5"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-medium rounded"
|
||||
:class="attempt.failed ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'"
|
||||
>
|
||||
{{ attempt.failed ? 'Auffällig' : 'Abgeschlossen' }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ attempt.emailMasked || 'Keine Adresse' }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
{{ formatDate(attempt.startedAt) }} · IP {{ attempt.ip || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="mt-4 space-y-2">
|
||||
<li
|
||||
v-for="step in attempt.steps"
|
||||
:key="step.ts + step.step + step.status"
|
||||
class="grid gap-1 text-sm sm:grid-cols-[148px_170px_130px_1fr]"
|
||||
>
|
||||
<time class="text-gray-500">{{ formatTime(step.ts) }}</time>
|
||||
<span class="text-gray-800">{{ stepLabel(step.step) }}</span>
|
||||
<span :class="stepStatusClass(step.status)">{{ statusLabel(step.status) }}</span>
|
||||
<span class="text-gray-600">{{ reasonLabel(step.reason) || errorLabel(step) }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RefreshCw, Search } from 'lucide-vue-next'
|
||||
|
||||
const searchTerm = ref('')
|
||||
const failedOnly = ref(true)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const matchingUsers = ref([])
|
||||
const attempts = ref([])
|
||||
const retentionHours = ref(72)
|
||||
|
||||
const stepLabels = {
|
||||
request_received: 'Anfrage',
|
||||
request_validation: 'Validierung',
|
||||
rate_limit: 'Rate Limit',
|
||||
user_lookup: 'Benutzersuche',
|
||||
temporary_password: 'Temporäres Passwort',
|
||||
password_storage: 'Passwortspeicherung',
|
||||
session_revocation: 'Sitzungen',
|
||||
mail_configuration: 'Mail-Konfiguration',
|
||||
mail_send: 'Mail-Versand',
|
||||
request_completed: 'Abschluss'
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
started: 'Gestartet',
|
||||
checking: 'Prüfung',
|
||||
passed: 'OK',
|
||||
found: 'Gefunden',
|
||||
not_found: 'Nicht gefunden',
|
||||
generated: 'Erzeugt',
|
||||
completed: 'Erledigt',
|
||||
success: 'Erfolgreich',
|
||||
no_account: 'Kein Konto',
|
||||
failed: 'Fehlgeschlagen'
|
||||
}
|
||||
|
||||
const reasonLabels = {
|
||||
email_missing: 'E-Mail-Adresse fehlt',
|
||||
smtp_credentials_missing: 'SMTP-Zugangsdaten fehlen',
|
||||
write_failed: 'Passwort konnte nicht gespeichert werden'
|
||||
}
|
||||
|
||||
const formatDate = value => new Date(value).toLocaleString('de-DE')
|
||||
const formatTime = value => new Date(value).toLocaleTimeString('de-DE')
|
||||
const stepLabel = step => stepLabels[step] || step
|
||||
const statusLabel = status => statusLabels[status] || status
|
||||
const reasonLabel = reason => reasonLabels[reason] || reason || ''
|
||||
const errorLabel = step => [step.errorCode, step.errorMessage].filter(Boolean).join(': ')
|
||||
const stepStatusClass = status => (
|
||||
['failed', 'not_found', 'no_account'].includes(status)
|
||||
? 'text-red-700 font-medium'
|
||||
: 'text-gray-700'
|
||||
)
|
||||
|
||||
const loadDiagnostics = async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await $fetch('/api/cms/password-reset-diagnostics', {
|
||||
query: {
|
||||
email: searchTerm.value.trim() || undefined,
|
||||
failedOnly: String(failedOnly.value)
|
||||
}
|
||||
})
|
||||
matchingUsers.value = response.matchingUsers || []
|
||||
attempts.value = response.attempts || []
|
||||
retentionHours.value = response.retentionHours || 72
|
||||
} catch (_error) {
|
||||
errorMessage.value = 'Reset-Diagnose konnte nicht geladen werden.'
|
||||
matchingUsers.value = []
|
||||
attempts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchUserLogs = async email => {
|
||||
searchTerm.value = email
|
||||
await loadDiagnostics()
|
||||
}
|
||||
|
||||
onMounted(loadDiagnostics)
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Passwort-Reset-Diagnose - CMS - Harheimer TC'
|
||||
})
|
||||
</script>
|
||||
@@ -30,25 +30,10 @@
|
||||
required
|
||||
autocomplete="email"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||
:class="{ 'border-red-500': errorMessage }"
|
||||
placeholder="ihre-email@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="bg-red-50 border border-red-200 rounded-lg p-4"
|
||||
>
|
||||
<p class="text-sm text-red-800 flex items-center">
|
||||
<AlertCircle
|
||||
:size="18"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div
|
||||
v-if="successMessage"
|
||||
@@ -92,7 +77,7 @@
|
||||
<!-- Info Box -->
|
||||
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
|
||||
<p class="text-sm text-primary-800 text-center">
|
||||
Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.
|
||||
Wenn ein Konto mit der Adresse existiert, erhalten Sie eine E-Mail mit weiteren Anweisungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,31 +86,27 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { AlertCircle, Check, Loader2 } from 'lucide-vue-next'
|
||||
import { Check, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const email = ref('')
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const genericResponse = 'Wenn ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie eine E-Mail mit weiteren Anweisungen.'
|
||||
|
||||
const handleReset = async () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/auth/reset-password', {
|
||||
await $fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: { email: email.value }
|
||||
body: { email: email.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
successMessage.value = 'Eine E-Mail mit weiteren Anweisungen wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
email.value = ''
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error.data?.message || 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.'
|
||||
} catch (_error) {
|
||||
// Öffentliche Antwort bleibt identisch, unabhängig von Kontostatus oder technischem Fehler.
|
||||
} finally {
|
||||
successMessage.value = genericResponse
|
||||
email.value = ''
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
@@ -134,4 +115,3 @@ useHead({
|
||||
title: 'Passwort vergessen - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user