feat(auth): implement Android refresh token handling and session management
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

- 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:
Torsten Schulz (local)
2026-05-27 19:34:32 +02:00
parent 755442fb70
commit 58fd7fa5c6
32 changed files with 1477 additions and 180 deletions

View File

@@ -98,16 +98,16 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
- [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
- [ ] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
- [x] Login/Logout und verschlüsselte Token-Speicherung
- [x] Registrierung und Passwort-Reset
- [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren
- [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
- [ ] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
- [ ] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- [ ] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
@@ -127,14 +127,14 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- [x] A. Auth (Login/Logout)
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
- [ ] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
- [x] C. Kontaktformular (absenden)
- [ ] D. Bildanzeige + Caching
- [x] E. Theme & Fonts
6) Nächste Aktionen (sofort)
- Dauerhaftes Android-Login umsetzen: Backend-Refresh-Sitzungen, Token-Rotation, serverseitigen Widerruf und App-Refresh-Flow ergänzen.
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
- Passkey-Anmeldung über Android Credential Manager anbinden.
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
@@ -158,6 +158,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
8) Android-Testumgebungen
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
@@ -167,7 +168,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
9) Dauerhaftes Android-Login: Architektur und Umsetzung
- Ausgangslage: Das Backend gibt derzeit ein sieben Tage gültiges JWT aus. Die App speichert es bereits verschlüsselt und sendet es als Bearer-Token. Die vorhandene serverseitige Sessiondatei wird beim Authentifizieren geschützter Requests derzeit nicht zur Widerrufsprüfung herangezogen.
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
- Token-Modell:
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.

View File

@@ -0,0 +1,39 @@
package de.harheimertc.data
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccessTokenAuthenticator @Inject constructor(
private val sessionRefresher: SessionRefresher,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
return null
}
val currentAccessToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
?.takeIf(String::isNotBlank)
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 0
while (current != null) {
count += 1
current = current.priorResponse
}
return count
}
}

View File

@@ -106,7 +106,12 @@ data class MembershipResponse(
val message: String? = null,
val downloadUrl: String? = null,
)
data class LoginRequest(val email: String, val password: String)
data class LoginRequest(
val email: String,
val password: String,
val client: String = "android",
val deviceName: String = "Harheimer TC Android-App",
)
data class AuthUserDto(
val id: String? = null,
val email: String = "",
@@ -116,9 +121,14 @@ data class AuthUserDto(
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val sessionId: String? = null,
val user: AuthUserDto? = null,
val role: String? = null,
)
data class RefreshRequest(val refreshToken: String)
data class LogoutRequest(val refreshToken: String? = null)
data class AuthStatusResponse(
val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null,
@@ -256,7 +266,10 @@ interface ApiService {
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("/api/auth/logout")
suspend fun logout(): Response<Unit>
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
@POST("/api/auth/refresh")
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
@GET("/api/auth/status")
suspend fun authStatus(): Response<AuthStatusResponse>

View File

@@ -27,7 +27,7 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BASIC
val cookies = CookieManager().apply {
@@ -36,6 +36,7 @@ object NetworkModule {
return OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookies))
.addInterceptor(authInterceptor)
.authenticator(accessTokenAuthenticator)
.addInterceptor(logging)
.build()
}

View File

@@ -0,0 +1,64 @@
package de.harheimertc.data
import com.squareup.moshi.Moshi
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SessionRefresher @Inject constructor(
private val authRepository: AuthRepository,
moshi: Moshi,
) {
private val lock = Any()
private val client = OkHttpClient.Builder().build()
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
refreshAccessTokenBlocking() != null
}
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
val currentToken = authRepository.getToken()
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
return@synchronized currentToken
}
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
?: return@synchronized null
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
val request = Request.Builder()
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
.post(payload.toRequestBody("application/json".toMediaType()))
.build()
try {
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
authRepository.clearSession()
return@synchronized null
}
if (!response.isSuccessful) return@synchronized null
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
?: return@synchronized null
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
?: return@synchronized null
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized null
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
accessToken
}
} catch (_: Exception) {
null
}
}
}

View File

@@ -1,8 +1,9 @@
package de.harheimertc.repositories
import kotlinx.coroutines.flow.StateFlow
interface AuthRepository {
fun getToken(): String?
fun setToken(token: String?)
fun getRefreshToken(): String?
fun getSessionId(): String?
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
fun clearSession()
}

View File

@@ -10,6 +10,8 @@ import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id"
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -25,9 +27,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
override fun getToken(): String? = preferences.getString(tokenKey, null)
override fun setToken(token: String?) {
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
preferences.edit().apply {
if (token == null) remove(tokenKey) else putString(tokenKey, token)
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
}.apply()
}
override fun clearSession() {
preferences.edit()
.remove(tokenKey)
.remove(refreshTokenKey)
.remove(sessionIdKey)
.apply()
}
}

View File

@@ -5,8 +5,10 @@ import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,31 +16,40 @@ import javax.inject.Singleton
class LoginRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
val body = response.body() ?: error("Leere Antwort")
val token = body.token?.takeIf(String::isNotBlank)
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setToken(token)
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}
suspend fun logout(): Result<Unit> = runCatching {
try {
api.logout()
api.logout(LogoutRequest(authRepository.getRefreshToken()))
} finally {
authRepository.setToken(null)
authRepository.clearSession()
}
}
suspend fun status(): Result<AuthStatusResponse> = runCatching {
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
val response = api.authStatus()
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
return@runCatching AuthStatusResponse()
}
var response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
val status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn) authRepository.setToken(null)
var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse()
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}

View File

@@ -26,6 +26,10 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
if (!hasAccess) {
return navigateTo('/mitgliederbereich')
}
} else if (to.path.startsWith('/cms/passwort-reset-diagnose')) {
if (!roles.includes('admin')) {
return navigateTo('/cms')
}
} else if (to.path.startsWith('/cms/kontaktanfragen')) {
if (!canAccessContactRequests) {
return navigateTo('/mitgliederbereich')
@@ -42,4 +46,3 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
return navigateTo('/login?redirect=' + to.path)
}
})

View File

@@ -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()

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

View File

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

View File

@@ -1,4 +1,4 @@
import { readUsers, writeUsers, verifyPassword, generateToken, createSession, migrateUserRoles } from '../../utils/auth.js'
import { readUsers, writeUsers, verifyPassword, generateToken, generateAndroidAccessToken, createSession, createRefreshSession, migrateUserRoles } from '../../utils/auth.js'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { getAuthCookieOptions } from '../../utils/cookies.js'
import { writeAuditLog } from '../../utils/audit-log.js'
@@ -7,6 +7,7 @@ export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const { email, password } = body
const isAndroidClient = body.client === 'android'
if (!email || !password) {
throw createError({
@@ -72,11 +73,15 @@ export default defineEventHandler(async (event) => {
registerRateLimitSuccess(event, { name: 'auth:login:ip', keyParts: [ip] })
registerRateLimitSuccess(event, { name: 'auth:login:account', keyParts: [emailKey] })
// Generate token
const token = generateToken(user)
// Create session
await createSession(user.id, token)
let token
let refreshSession = null
if (isAndroidClient) {
refreshSession = await createRefreshSession(user.id, body.deviceName)
token = generateAndroidAccessToken(user, refreshSession.session.id)
} else {
token = generateToken(user)
await createSession(user.id, token)
}
await writeAuditLog('auth.login.success', { ip, email: emailKey, userId: user.id })
@@ -85,10 +90,13 @@ export default defineEventHandler(async (event) => {
const updatedUsers = users.map(u => u.id === user.id ? user : u)
await writeUsers(updatedUsers)
// Set cookie
setCookie(event, 'auth_token', token, {
...getAuthCookieOptions()
})
if (isAndroidClient) {
deleteCookie(event, 'auth_token')
} else {
setCookie(event, 'auth_token', token, {
...getAuthCookieOptions()
})
}
// Migriere Rollen falls nötig
const migratedUser = migrateUserRoles({ ...user })
@@ -98,6 +106,9 @@ export default defineEventHandler(async (event) => {
return {
success: true,
token: token, // Token auch im Body für externe API-Clients
accessToken: isAndroidClient ? token : undefined,
refreshToken: refreshSession?.refreshToken,
sessionId: refreshSession?.session.id,
user: {
id: user.id,
email: user.email,
@@ -112,4 +123,3 @@ export default defineEventHandler(async (event) => {
throw error
}
})

View File

@@ -1,12 +1,17 @@
import { deleteSession } from '../../utils/auth.js'
import { deleteSession, revokeRefreshSession } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const body = await readBody(event)
const refreshToken = body?.refreshToken
if (token) {
await deleteSession(token)
}
if (refreshToken) {
await revokeRefreshSession(refreshToken)
}
// Delete cookie
deleteCookie(event, 'auth_token')

View File

@@ -65,7 +65,7 @@ export default defineEventHandler(async (event) => {
})
}
const { origin, rpId, requireUV } = getWebAuthnConfig()
const { origins, rpId, requireUV } = getWebAuthnConfig()
const authenticator = {
credentialID: fromBase64Url(passkey.credentialId),
@@ -74,14 +74,20 @@ export default defineEventHandler(async (event) => {
transports: passkey.transports || undefined
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator,
requireUserVerification: requireUV
})
let verification
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
authenticator,
requireUserVerification: requireUV
})
} catch {
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_error' })
throw createError({ statusCode: 401, statusMessage: 'Passkey-Login fehlgeschlagen' })
}
if (!verification.verified) {
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_failed' })
@@ -118,4 +124,3 @@ export default defineEventHandler(async (event) => {
}
})

View File

@@ -62,15 +62,21 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' })
}
const { origin, rpId, requireUV } = getWebAuthnConfig()
const { origins, rpId, requireUV } = getWebAuthnConfig()
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpId,
requireUserVerification: requireUV
})
let verification
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserVerification: requireUV
})
} catch {
await writeAuditLog('auth.passkey.recovery.complete.failed', { ip, reason: 'verification_error', userId })
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
}
const { verified, registrationInfo } = verification
if (!verified || !registrationInfo) {
@@ -117,4 +123,3 @@ export default defineEventHandler(async (event) => {
await writeAuditLog('auth.passkey.recovery.complete.success', { ip, userId: user.id })
return { success: true, message: 'Passkey hinzugefügt. Sie können sich jetzt mit dem neuen Passkey anmelden.' }
})

View File

@@ -37,17 +37,20 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Registrierungs-Session abgelaufen. Bitte erneut versuchen.' })
}
const { origin, rpId, requireUV } = getWebAuthnConfig()
const { origins, rpId, requireUV } = getWebAuthnConfig()
let verification
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserVerification: requireUV
})
} catch {
await writeAuditLog('auth.passkey.registration.failed', { userId: user.id, reason: 'verification_error' })
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
} finally {
clearRegistrationChallenge(user.id)
}
@@ -103,4 +106,3 @@ export default defineEventHandler(async (event) => {
return { success: true, message: 'Passkey hinzugefügt.' }
})

View File

@@ -0,0 +1,47 @@
import { generateAndroidAccessToken, getUserById, revokeRefreshSession, rotateRefreshSession } from '../../utils/auth.js'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { writeAuditLog } from '../../utils/audit-log.js'
export default defineEventHandler(async (event) => {
const ip = getClientIp(event)
const body = await readBody(event)
const refreshToken = body?.refreshToken
if (!refreshToken) {
throw createError({ statusCode: 400, message: 'Refresh-Token fehlt' })
}
assertRateLimit(event, {
name: 'auth:refresh:ip',
keyParts: [ip],
windowMs: 10 * 60 * 1000,
maxAttempts: 60,
lockoutMs: 15 * 60 * 1000
})
const rotated = await rotateRefreshSession(refreshToken)
if (rotated.status !== 'rotated') {
await registerRateLimitFailure(event, { name: 'auth:refresh:ip', keyParts: [ip], delayBaseMs: 100 })
await writeAuditLog('auth.refresh.failed', { ip, reason: rotated.status })
throw createError({ statusCode: 401, message: 'Sitzung ist nicht mehr gültig' })
}
const user = await getUserById(rotated.session.userId)
if (!user || user.active === false) {
await revokeRefreshSession(rotated.refreshToken, 'inactive_or_missing_user')
await writeAuditLog('auth.refresh.failed', { ip, userId: rotated.session.userId, reason: 'inactive_or_missing_user' })
throw createError({ statusCode: 401, message: 'Sitzung ist nicht mehr gültig' })
}
const accessToken = generateAndroidAccessToken(user, rotated.session.id)
registerRateLimitSuccess(event, { name: 'auth:refresh:ip', keyParts: [ip] })
await writeAuditLog('auth.refresh.success', { ip, userId: user.id, sessionId: rotated.session.id })
return {
success: true,
token: accessToken,
accessToken,
refreshToken: rotated.refreshToken,
sessionId: rotated.session.id
}
})

View File

@@ -96,7 +96,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 409, message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits' })
}
const { origin, rpId, requireUV } = getWebAuthnConfig()
const { origin, origins, rpId, requireUV } = getWebAuthnConfig()
// Debug: Prüfe die tatsächliche Origin aus der Response
const clientData = response?.response?.clientDataJSON
@@ -117,13 +117,11 @@ export default defineEventHandler(async (event) => {
}
console.log('[DEBUG] WebAuthn config for verification', {
expectedOrigin: origin,
expectedOriginType: typeof origin,
expectedOriginLength: origin?.length,
expectedOrigins: origins,
actualOriginFromResponse: actualOrigin,
rpId,
requireUV,
originMatch: origin === actualOrigin,
originMatch: origins.includes(actualOrigin),
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL
})
@@ -140,7 +138,7 @@ export default defineEventHandler(async (event) => {
console.log('[DEBUG] Verifying registration response...')
console.log('[DEBUG] Verification parameters', {
expectedOrigin: origin,
expectedOrigins: origins,
expectedRPID: rpId,
hasChallenge: !!challenge,
challengeLength: challenge?.length,
@@ -155,7 +153,7 @@ export default defineEventHandler(async (event) => {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserVerification: requireUV
})
@@ -165,11 +163,12 @@ export default defineEventHandler(async (event) => {
error: verifyError,
message: verifyError?.message,
cause: verifyError?.cause?.message,
expectedOrigin: origin,
expectedOrigins: origins,
actualOriginFromResponse: actualOrigin,
stack: verifyError?.stack
})
throw verifyError
await writeAuditLog('auth.passkey.prereg.failed', { email, reason: 'verification_error' })
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
}
const verifyDuration = Date.now() - verifyStart
@@ -308,4 +307,3 @@ export default defineEventHandler(async (event) => {
message: 'Registrierung erfolgreich. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
}
})

View File

@@ -1,77 +1,102 @@
import { readUsers, hashPassword, writeUsers } from '../../utils/auth.js'
import { readUsers, hashPassword, writeUsers, revokeRefreshSessionsForUser } from '../../utils/auth.js'
import nodemailer from 'nodemailer'
import crypto from 'crypto'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { writeAuditLog } from '../../utils/audit-log.js'
import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js'
export default defineEventHandler(async (event) => {
const requestId = crypto.randomUUID()
let emailKey = ''
let ip = ''
const logStep = async (step, status, detail = {}) => {
try {
await writePasswordResetLog({ requestId, email: emailKey, ip, step, status, ...detail })
} catch (logError) {
console.error('Password-Reset-Diagnoselog-Fehler:', logError)
}
}
try {
const body = await readBody(event)
const { email } = body
if (!email) {
emailKey = normalizeResetEmail(email)
ip = getClientIp(event)
await logStep('request_received', 'started')
if (!emailKey) {
await logStep('request_validation', 'failed', { reason: 'email_missing' })
throw createError({
statusCode: 400,
message: 'E-Mail-Adresse ist erforderlich'
})
}
const ip = getClientIp(event)
const emailKey = String(email || '').trim().toLowerCase()
// Rate Limiting (IP + Account)
assertRateLimit(event, {
name: 'auth:reset:ip',
keyParts: [ip],
windowMs: 60 * 60 * 1000,
maxAttempts: 20,
lockoutMs: 30 * 60 * 1000
})
assertRateLimit(event, {
name: 'auth:reset:account',
keyParts: [emailKey],
windowMs: 60 * 60 * 1000,
maxAttempts: 5,
lockoutMs: 60 * 60 * 1000
})
await logStep('rate_limit', 'checking')
try {
assertRateLimit(event, {
name: 'auth:reset:ip',
keyParts: [ip],
windowMs: 60 * 60 * 1000,
maxAttempts: 20,
lockoutMs: 30 * 60 * 1000
})
assertRateLimit(event, {
name: 'auth:reset:account',
keyParts: [emailKey],
windowMs: 60 * 60 * 1000,
maxAttempts: 5,
lockoutMs: 60 * 60 * 1000
})
} catch (error) {
await logStep('rate_limit', 'failed', { error })
throw error
}
await logStep('rate_limit', 'passed')
// Find user
const users = await readUsers()
const user = users.find(u => u.email.toLowerCase() === email.toLowerCase())
let users
try {
users = await readUsers()
} catch (error) {
await logStep('user_lookup', 'failed', { error })
throw error
}
const user = users.find(u => normalizeResetEmail(u.email) === emailKey)
// Always return success (security: don't reveal if email exists)
if (!user) {
await logStep('user_lookup', 'not_found')
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
await registerRateLimitFailure(event, { name: 'auth:reset:account', keyParts: [emailKey] })
await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: false })
await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: false, requestId })
await logStep('request_completed', 'no_account')
return {
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
}
}
await logStep('user_lookup', 'found', { userId: user.id })
// Generate temporary password
const tempPassword = crypto.randomBytes(8).toString('hex')
const hashedPassword = await hashPassword(tempPassword)
// Update user password
user.password = hashedPassword
user.passwordResetRequired = true
const updatedUsers = users.map(u => u.id === user.id ? user : u)
await writeUsers(updatedUsers)
registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: true, userId: user.id })
await logStep('temporary_password', 'generated', { userId: user.id })
// Send email with temporary password
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (!smtpUser || !smtpPass) {
await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' })
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
// Continue without sending email - security: don't reveal if email exists
throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt')
} else {
await logStep('mail_configuration', 'passed', { userId: user.id })
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
@@ -99,15 +124,50 @@ export default defineEventHandler(async (event) => {
`
}
await transporter.sendMail(mailOptions)
await logStep('mail_send', 'started', { userId: user.id })
try {
await transporter.sendMail(mailOptions)
} catch (error) {
await logStep('mail_send', 'failed', { userId: user.id, error })
throw error
}
await logStep('mail_send', 'completed', { userId: user.id })
}
// Erst nach erfolgreichem Versand das zugesandte Passwort aktivieren.
user.password = hashedPassword
user.passwordResetRequired = true
const updatedUsers = users.map(u => u.id === user.id ? user : u)
let passwordStored = false
try {
passwordStored = await writeUsers(updatedUsers)
} catch (error) {
await logStep('password_storage', 'failed', { userId: user.id, error })
throw error
}
if (!passwordStored) {
await logStep('password_storage', 'failed', { userId: user.id, reason: 'write_failed' })
throw new Error('Passwort konnte nach E-Mail-Versand nicht gespeichert werden')
}
await logStep('password_storage', 'completed', { userId: user.id })
try {
await revokeRefreshSessionsForUser(user.id, 'password_reset')
} catch (error) {
await logStep('session_revocation', 'failed', { userId: user.id, error })
throw error
}
await logStep('session_revocation', 'completed', { userId: user.id })
registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId })
await logStep('request_completed', 'success', { userId: user.id })
return {
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
}
} catch (error) {
console.error('Password-Reset-Fehler:', error)
await logStep('request_completed', 'failed', { error })
console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' })
// Don't reveal errors to prevent email enumeration
return {
success: true,
@@ -115,4 +175,3 @@ export default defineEventHandler(async (event) => {
}
}
})

View File

@@ -0,0 +1,94 @@
import { getUserFromToken, hasRole, readUsers } from '../../utils/auth.js'
import {
fingerprintResetEmail,
normalizeResetEmail,
PASSWORD_RESET_LOG_RETENTION_HOURS,
readPasswordResetLogs
} from '../../utils/password-reset-log.js'
function summarizeAttempts(entries) {
const attemptsById = new Map()
for (const entry of [...entries].reverse()) {
const attempt = attemptsById.get(entry.requestId) || {
requestId: entry.requestId,
startedAt: entry.ts,
emailMasked: entry.emailMasked,
ip: entry.ip,
userId: entry.userId || null,
steps: [],
failed: false
}
attempt.startedAt = attempt.startedAt || entry.ts
attempt.userId = attempt.userId || entry.userId || null
attempt.steps.push({
ts: entry.ts,
step: entry.step,
status: entry.status,
reason: entry.reason || null,
errorCode: entry.errorCode || entry.error || null,
errorMessage: entry.errorMessage || null
})
if (
entry.status === 'failed' ||
entry.status === 'not_found' ||
entry.status === 'no_account' ||
entry.reason === 'smtp_credentials_missing'
) {
attempt.failed = true
}
attemptsById.set(entry.requestId, attempt)
}
return [...attemptsById.values()]
.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)))
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasRole(currentUser, 'admin')) {
throw createError({ statusCode: 403, message: 'Zugriff verweigert' })
}
const query = getQuery(event)
const email = normalizeResetEmail(query.email)
const failedOnly = query.failedOnly !== 'false'
const users = await readUsers()
const logs = await readPasswordResetLogs()
const filteredLogs = email
? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email))
: logs
const attempts = summarizeAttempts(filteredLogs)
.filter(attempt => !failedOnly || attempt.failed)
let matchingUsers = []
if (email) {
const term = email.toLowerCase()
matchingUsers = users
.filter(user => {
const userEmail = normalizeResetEmail(user.email)
const name = String(user.name || '').toLowerCase()
return userEmail.includes(term) || name.includes(term)
})
.slice(0, 20)
.map(user => ({
id: user.id,
name: user.name,
email: user.email,
active: user.active !== false,
lastLogin: user.lastLogin || null
}))
}
return {
retentionHours: PASSWORD_RESET_LOG_RETENTION_HOURS,
searchedEmail: email,
matchingUsers,
attempts
}
})

View File

@@ -1,4 +1,4 @@
import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js'
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, revokeRefreshSessionsForUser } from '../../../utils/auth.js'
import { writeAuditLog } from '../../../utils/audit-log.js'
export default defineEventHandler(async (event) => {
@@ -36,6 +36,7 @@ export default defineEventHandler(async (event) => {
user.active = false
const updatedUsers = users.map(u => u.id === userId ? user : u)
await writeUsers(updatedUsers)
await revokeRefreshSessionsForUser(userId, 'account_deactivated')
await writeAuditLog('cms.user.deactivated', {
actorUserId: currentUser.id,
@@ -51,4 +52,3 @@ export default defineEventHandler(async (event) => {
throw error
}
})

View File

@@ -1,4 +1,4 @@
import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles } from '../utils/auth.js'
import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js'
import { assertPasswordNotPwned } from '../utils/hibp.js'
export default defineEventHandler(async (event) => {
@@ -42,6 +42,7 @@ export default defineEventHandler(async (event) => {
}
const user = users[userIndex]
let passwordChanged = false
// Check if email is already taken by another user
if (email !== user.email) {
@@ -91,9 +92,13 @@ export default defineEventHandler(async (event) => {
await assertPasswordNotPwned(newPassword)
user.password = await hashPassword(newPassword)
passwordChanged = true
}
await writeUsers(users)
if (passwordChanged) {
await revokeRefreshSessionsForUser(user.id, 'password_changed')
}
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
@@ -117,4 +122,3 @@ export default defineEventHandler(async (event) => {
throw error
}
})

View File

@@ -2,6 +2,7 @@ import { importSpielplan } from '../utils/spielplan-import.js'
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
const TIME_ZONE = 'Europe/Berlin'
const RUN_HOUR = 7
@@ -65,11 +66,22 @@ function nextRunAt(now = new Date()) {
return candidate
}
async function runImport(reason) {
async function runDailyJobs(reason, skipSpielplanImport = false) {
if (running) return
running = true
try {
try {
const cleanup = await cleanupPasswordResetLogs()
loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup)
} catch (error) {
loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error })
}
if (skipSpielplanImport) {
return
}
const spielplan = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
@@ -96,13 +108,13 @@ async function runImport(reason) {
}
}
function scheduleNext() {
function scheduleNext(skipSpielplanImport = false) {
const runAt = nextRunAt()
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
timer = setTimeout(async () => {
await runImport('taeglicher Lauf')
scheduleNext()
await runDailyJobs('taeglicher Lauf', skipSpielplanImport)
scheduleNext(skipSpielplanImport)
}, delay)
timer.unref?.()
@@ -110,15 +122,15 @@ function scheduleNext() {
}
export default defineNitroPlugin((nitroApp) => {
if (process.env.SPIELPLAN_IMPORT_DISABLED === 'true') {
loggerInfo('[spielplan-import] Scheduler deaktiviert')
return
const skipSpielplanImport = process.env.SPIELPLAN_IMPORT_DISABLED === 'true'
if (skipSpielplanImport) {
loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv')
}
scheduleNext()
scheduleNext(skipSpielplanImport)
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
runImport('Startlauf')
runDailyJobs('Startlauf', skipSpielplanImport)
}
nitroApp.hooks.hookOnce('close', () => {

View File

@@ -1,5 +1,6 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import { encryptObject, decryptObject } from './encryption.js'
@@ -46,6 +47,10 @@ const getDataPath = (filename) => {
const USERS_FILE = getDataPath('users.json')
const SESSIONS_FILE = getDataPath('sessions.json')
const ANDROID_ACCESS_TOKEN_TTL = '15m'
const REFRESH_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
const refreshMutationState = globalThis.__HTC_REFRESH_MUTATION_STATE__ || { tail: Promise.resolve() }
globalThis.__HTC_REFRESH_MUTATION_STATE__ = refreshMutationState
// Get encryption key from environment
function getEncryptionKey() {
@@ -146,7 +151,7 @@ export async function readUsers() {
try {
users = JSON.parse(data)
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
} catch (_parseError) {
} catch {
console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
return []
}
@@ -210,7 +215,7 @@ function isSessionsEncrypted(data) {
return false
}
return false
} catch (e) {
} catch {
return true
}
}
@@ -231,7 +236,7 @@ export async function readSessions() {
const plainData = JSON.parse(data)
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
return plainData
} catch (_parseError) {
} catch {
console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen')
return []
}
@@ -277,27 +282,36 @@ export async function verifyPassword(password, hash) {
}
// Generate JWT token
export function generateToken(user) {
export function generateToken(user, { expiresIn = '7d', sessionId = null } = {}) {
// Stelle sicher, dass Rollen migriert sind
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
const claims = {
id: user.id,
email: user.email,
roles: roles
}
if (sessionId) {
claims.sid = sessionId
}
return jwt.sign(
{
id: user.id,
email: user.email,
roles: roles
},
claims,
JWT_SECRET,
{ expiresIn: '7d' }
{ expiresIn }
)
}
export function generateAndroidAccessToken(user, sessionId) {
return generateToken(user, { expiresIn: ANDROID_ACCESS_TOKEN_TTL, sessionId })
}
// Verify JWT token
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (_error) {
} catch {
return null
}
}
@@ -343,6 +357,14 @@ export async function getUserFromToken(token) {
const decoded = verifyToken(token)
if (!decoded) return null
if (decoded.sid) {
const sessions = await readSessions()
const session = sessions.find(s => s.id === decoded.sid && s.userId === decoded.id)
if (!session || session.revokedAt || new Date(session.expiresAt).getTime() <= Date.now()) {
return null
}
}
const users = await readUsers()
const user = users.find(u => u.id === decoded.id)
@@ -376,6 +398,130 @@ export async function deleteSession(token) {
await writeSessions(filtered)
}
function hashRefreshToken(token) {
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
}
function issueRefreshToken() {
return crypto.randomBytes(48).toString('base64url')
}
function serializeRefreshMutation(operation) {
const result = refreshMutationState.tail.then(operation, operation)
refreshMutationState.tail = result.then(() => undefined, () => undefined)
return result
}
export async function createRefreshSession(userId, deviceName = 'Android') {
return serializeRefreshMutation(async () => {
const sessions = await readSessions()
const refreshToken = issueRefreshToken()
const createdAt = new Date().toISOString()
const session = {
id: crypto.randomUUID(),
familyId: crypto.randomUUID(),
type: 'android_refresh',
userId,
deviceName: String(deviceName || 'Android').slice(0, 100),
refreshTokenHash: hashRefreshToken(refreshToken),
createdAt,
lastUsedAt: createdAt,
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(),
revokedAt: null
}
sessions.push(session)
await writeSessions(sessions)
return { session, refreshToken }
})
}
export async function rotateRefreshSession(refreshToken) {
return serializeRefreshMutation(async () => {
const sessions = await readSessions()
const tokenHash = hashRefreshToken(refreshToken)
const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash)
const now = new Date()
const nowIso = now.toISOString()
if (!session) return { status: 'invalid' }
if (session.revokedAt) {
if (session.rotatedAt) {
for (const related of sessions) {
if (related.familyId === session.familyId && !related.revokedAt) {
related.revokedAt = nowIso
related.revokeReason = 'refresh_token_reuse'
}
}
await writeSessions(sessions)
return { status: 'reused', session }
}
return { status: 'revoked', session }
}
if (new Date(session.expiresAt).getTime() <= now.getTime()) {
session.revokedAt = nowIso
session.revokeReason = 'expired'
await writeSessions(sessions)
return { status: 'expired', session }
}
const nextRefreshToken = issueRefreshToken()
const nextSession = {
...session,
id: crypto.randomUUID(),
refreshTokenHash: hashRefreshToken(nextRefreshToken),
createdAt: nowIso,
lastUsedAt: nowIso,
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(),
revokedAt: null
}
session.lastUsedAt = nowIso
session.revokedAt = nowIso
session.rotatedAt = nowIso
session.replacedBy = nextSession.id
session.revokeReason = 'rotated'
sessions.push(nextSession)
await writeSessions(sessions)
return { status: 'rotated', session: nextSession, refreshToken: nextRefreshToken }
})
}
export async function revokeRefreshSession(refreshToken, reason = 'logout') {
return serializeRefreshMutation(async () => {
const sessions = await readSessions()
const tokenHash = hashRefreshToken(refreshToken)
const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash)
if (!session) return false
const revokedAt = new Date().toISOString()
for (const related of sessions) {
if (related.familyId === session.familyId && !related.revokedAt) {
related.revokedAt = revokedAt
related.revokeReason = reason
}
}
await writeSessions(sessions)
return true
})
}
export async function revokeRefreshSessionsForUser(userId, reason) {
return serializeRefreshMutation(async () => {
const sessions = await readSessions()
const revokedAt = new Date().toISOString()
let changed = false
for (const session of sessions) {
if (session.type === 'android_refresh' && session.userId === userId && !session.revokedAt) {
session.revokedAt = revokedAt
session.revokeReason = reason
changed = true
}
}
if (changed) await writeSessions(sessions)
})
}
// Clean expired sessions
export async function cleanExpiredSessions() {
const sessions = await readSessions()
@@ -383,4 +529,3 @@ export async function cleanExpiredSessions() {
const valid = sessions.filter(s => new Date(s.expiresAt) > now)
await writeSessions(valid)
}

View File

@@ -0,0 +1,136 @@
import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
const RETENTION_MS = 72 * 60 * 60 * 1000
function getDataPath(filename) {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
return path.join(cwd, '../server/data', filename)
}
return path.join(cwd, 'server/data', filename)
}
const LOG_FILE = getDataPath('password-reset.log.jsonl')
export function normalizeResetEmail(email) {
return String(email || '').trim().toLowerCase()
}
export function maskResetEmail(email) {
const normalized = normalizeResetEmail(email)
const [localPart = '', domain = ''] = normalized.split('@')
if (!domain) return normalized ? `${localPart.slice(0, 2)}***` : ''
const localVisible = localPart.slice(0, Math.min(2, localPart.length))
const domainParts = domain.split('.')
const domainName = domainParts.shift() || ''
const suffix = domainParts.length ? `.${domainParts.join('.')}` : ''
return `${localVisible}***@${domainName.slice(0, 2)}***${suffix}`
}
export function fingerprintResetEmail(email) {
return crypto.createHash('sha256').update(normalizeResetEmail(email)).digest('hex')
}
function safeText(value, max = 160) {
return String(value == null ? '' : value).slice(0, max)
}
function errorLabel(error) {
return safeText(error?.code || error?.name || 'Error', 80)
}
function sanitizedErrorMessage(error) {
return safeText(error?.message || error || '')
.replace(/[^\s<>"']+@[^\s<>"']+/gi, email => maskResetEmail(email))
.replace(/((?:pass(?:word)?|token|secret|authorization|auth)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
.replace(/(smtp:\/\/[^:\s/]+:)[^@\s/]+@/gi, '$1[redacted]@')
}
export async function writePasswordResetLog({
requestId,
email,
ip,
step,
status,
userId,
reason,
error
}) {
const normalizedEmail = normalizeResetEmail(email)
const entry = {
ts: new Date().toISOString(),
requestId: safeText(requestId, 80),
emailMasked: maskResetEmail(normalizedEmail),
emailFingerprint: fingerprintResetEmail(normalizedEmail),
ip: safeText(ip, 80),
step: safeText(step, 80),
status: safeText(status, 40)
}
if (userId) entry.userId = safeText(userId, 80)
if (reason) entry.reason = safeText(reason, 100)
if (error) {
entry.errorCode = errorLabel(error)
entry.errorMessage = sanitizedErrorMessage(error)
}
await fs.mkdir(path.dirname(LOG_FILE), { recursive: true })
await fs.appendFile(LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8')
}
export async function cleanupPasswordResetLogs(now = Date.now()) {
let contents
try {
contents = await fs.readFile(LOG_FILE, 'utf8')
} catch (error) {
if (error.code === 'ENOENT') return { retained: 0, removed: 0 }
throw error
}
const threshold = now - RETENTION_MS
const entries = contents
.split('\n')
.filter(Boolean)
.flatMap(line => {
try {
return [JSON.parse(line)]
} catch {
return []
}
})
const retained = entries.filter(entry => new Date(entry.ts).getTime() >= threshold)
const removed = entries.length - retained.length
if (removed > 0) {
const serialized = retained.map(entry => JSON.stringify(entry)).join('\n')
await fs.writeFile(LOG_FILE, serialized ? `${serialized}\n` : '', 'utf8')
}
return { retained: retained.length, removed }
}
export async function readPasswordResetLogs() {
await cleanupPasswordResetLogs()
try {
const contents = await fs.readFile(LOG_FILE, 'utf8')
return contents
.split('\n')
.filter(Boolean)
.flatMap(line => {
try {
return [JSON.parse(line)]
} catch {
return []
}
})
.sort((a, b) => String(b.ts).localeCompare(String(a.ts)))
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
}
export const PASSWORD_RESET_LOG_RETENTION_HOURS = RETENTION_MS / (60 * 60 * 1000)

View File

@@ -26,6 +26,36 @@ function deriveFromBaseUrl() {
}
}
function normalizeOrigin(value) {
try {
const u = new URL(value)
if (u.protocol === 'https:') {
return `https://${u.hostname}`
}
if (u.protocol === 'http:' && u.hostname === 'localhost') {
return `${u.protocol}//${u.host}`
}
return u.port === '80' ? `http://${u.hostname}` : `${u.protocol}//${u.host}`
} catch {
return value
}
}
function getAllowedOrigins(origin) {
const configured = String(process.env.WEBAUTHN_ALLOWED_ORIGINS || '')
.split(',')
.map(candidate => normalizeOrigin(candidate.trim()))
.filter(Boolean)
const origins = [origin, ...configured]
// Beide produktiven Hostnamen werden im Browser verwendet und gehoeren zur selben RP-ID.
if (origin === 'https://harheimertc.de' || origin === 'https://www.harheimertc.de') {
origins.push('https://harheimertc.de', 'https://www.harheimertc.de')
}
return [...new Set(origins)]
}
export function getWebAuthnConfig() {
const derived = deriveFromBaseUrl()
@@ -33,23 +63,8 @@ export function getWebAuthnConfig() {
const rpName = process.env.WEBAUTHN_RP_NAME || 'Harheimer TC'
// WEBAUTHN_ORIGIN hat Priorität, sonst von BASE_URL ableiten
let origin = process.env.WEBAUTHN_ORIGIN || derived.origin
// Sicherstellen, dass HTTPS-Origins KEINEN Port haben (auch wenn in ENV gesetzt)
if (origin.startsWith('https://')) {
try {
const u = new URL(origin)
// Port 443 oder kein Port = Standard, also Port weglassen
if (u.port === '443' || !u.port) {
origin = `https://${u.hostname}`
} else {
// Auch andere Ports bei HTTPS entfernen (nicht Standard für WebAuthn)
origin = `https://${u.hostname}`
}
} catch {
// Ignore
}
}
const origin = normalizeOrigin(process.env.WEBAUTHN_ORIGIN || derived.origin)
const origins = getAllowedOrigins(origin)
const requireUV = (process.env.WEBAUTHN_REQUIRE_UV || '').toLowerCase() === 'true'
@@ -57,13 +72,14 @@ export function getWebAuthnConfig() {
rpId,
rpName,
origin,
origins,
requireUV,
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
webauthnAllowedOriginsEnv: process.env.WEBAUTHN_ALLOWED_ORIGINS,
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL,
derivedOrigin: derived.origin
})
return { rpId, rpName, origin, requireUV }
return { rpId, rpName, origin, origins, requireUV }
}

View File

@@ -8,7 +8,13 @@ vi.mock('../server/utils/auth.js', () => {
writeUsers: vi.fn(),
verifyPassword: vi.fn(),
generateToken: vi.fn(),
generateAndroidAccessToken: vi.fn(),
createSession: vi.fn(),
createRefreshSession: vi.fn(),
rotateRefreshSession: vi.fn(),
revokeRefreshSession: vi.fn(),
revokeRefreshSessionsForUser: vi.fn(),
getUserById: vi.fn(),
hashPassword: vi.fn(),
verifyToken: vi.fn(),
deleteSession: vi.fn(),
@@ -53,11 +59,19 @@ vi.mock('nodemailer', () => {
}
})
vi.mock('../server/utils/password-reset-log.js', () => ({
normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
maskResetEmail: vi.fn(email => `masked:${String(email || '').trim().toLowerCase()}`),
writePasswordResetLog: vi.fn().mockResolvedValue(undefined)
}))
const authUtils = await import('../server/utils/auth.js')
const nodemailer = await import('nodemailer')
const passwordResetLog = await import('../server/utils/password-reset-log.js')
import loginHandler from '../server/api/auth/login.post.js'
import logoutHandler from '../server/api/auth/logout.post.js'
import refreshHandler from '../server/api/auth/refresh.post.js'
import registerHandler from '../server/api/auth/register.post.js'
import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
import statusHandler from '../server/api/auth/status.get.js'
@@ -110,6 +124,29 @@ describe('Auth API Endpoints', () => {
expect(authUtils.createSession).toHaveBeenCalledWith('1', 'jwt-token')
expect(authUtils.writeUsers).toHaveBeenCalled()
})
it('gibt Android-Clients ein Refresh-Token für eine Gerätesitzung zurück', async () => {
const event = createEvent()
const user = { id: '1', email: 'test@example.com', password: 'hash', roles: ['mitglied'], active: true }
mockSuccessReadBody({ email: user.email, password: 'plain', client: 'android', deviceName: 'Pixel' })
authUtils.readUsers.mockResolvedValue([user])
authUtils.verifyPassword.mockResolvedValue(true)
authUtils.createRefreshSession.mockResolvedValue({ session: { id: 'session-1' }, refreshToken: 'refresh-1' })
authUtils.generateAndroidAccessToken.mockReturnValue('access-1')
authUtils.writeUsers.mockResolvedValue(true)
const response = await loginHandler(event)
expect(authUtils.createRefreshSession).toHaveBeenCalledWith('1', 'Pixel')
expect(authUtils.generateAndroidAccessToken).toHaveBeenCalledWith(user, 'session-1')
expect(response).toMatchObject({
token: 'access-1',
accessToken: 'access-1',
refreshToken: 'refresh-1',
sessionId: 'session-1'
})
expect(authUtils.createSession).not.toHaveBeenCalled()
})
})
describe('POST /api/auth/logout', () => {
@@ -129,6 +166,64 @@ describe('Auth API Endpoints', () => {
await expect(logoutHandler(event)).rejects.toMatchObject({ statusCode: 500 })
})
it('widerruft beim Android-Logout das Refresh-Token', async () => {
const event = createEvent({ headers: { authorization: 'Bearer access-token' } })
mockSuccessReadBody({ refreshToken: 'refresh-token' })
authUtils.deleteSession.mockResolvedValue()
authUtils.revokeRefreshSession.mockResolvedValue(true)
const response = await logoutHandler(event)
expect(response.success).toBe(true)
expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('refresh-token')
})
})
describe('POST /api/auth/refresh', () => {
it('rotiert eine gültige Android-Sitzung und gibt ein neues Token-Paar zurück', async () => {
const event = createEvent()
mockSuccessReadBody({ refreshToken: 'old-refresh' })
const user = { id: '1', email: 'test@example.com', roles: ['mitglied'], active: true }
authUtils.rotateRefreshSession.mockResolvedValue({
status: 'rotated',
session: { id: 'session-2', userId: '1' },
refreshToken: 'new-refresh'
})
authUtils.getUserById.mockResolvedValue(user)
authUtils.generateAndroidAccessToken.mockReturnValue('new-access')
const response = await refreshHandler(event)
expect(authUtils.rotateRefreshSession).toHaveBeenCalledWith('old-refresh')
expect(response).toMatchObject({
accessToken: 'new-access',
refreshToken: 'new-refresh',
sessionId: 'session-2'
})
})
it('weist widerrufene oder erneut verwendete Refresh-Tokens zurück', async () => {
const event = createEvent()
mockSuccessReadBody({ refreshToken: 'used-refresh' })
authUtils.rotateRefreshSession.mockResolvedValue({ status: 'reused' })
await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
})
it('widerruft eine rotierte Sitzung, wenn der Benutzer nicht mehr aktiv ist', async () => {
const event = createEvent()
mockSuccessReadBody({ refreshToken: 'old-refresh' })
authUtils.rotateRefreshSession.mockResolvedValue({
status: 'rotated',
session: { id: 'session-2', userId: '1' },
refreshToken: 'new-refresh'
})
authUtils.getUserById.mockResolvedValue({ id: '1', active: false })
await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('new-refresh', 'inactive_or_missing_user')
})
})
describe('POST /api/auth/register', () => {
@@ -224,6 +319,64 @@ describe('Auth API Endpoints', () => {
const response = await resetPasswordHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled()
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset')
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
email: 'user@example.com',
step: 'mail_send',
status: 'completed'
}))
})
it('normalisiert Leerzeichen bei der Benutzersuche', async () => {
const event = createEvent()
const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' }
mockSuccessReadBody({ email: ' User@Example.com ' })
authUtils.readUsers.mockResolvedValue([user])
authUtils.hashPassword.mockResolvedValue('new-hash')
authUtils.writeUsers.mockResolvedValue(true)
await resetPasswordHandler(event)
expect(authUtils.writeUsers).toHaveBeenCalled()
expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ')
})
it('ändert das Passwort nicht, wenn SMTP nicht konfiguriert ist', async () => {
const event = createEvent()
mockSuccessReadBody({ email: 'user@example.com' })
authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
authUtils.hashPassword.mockResolvedValue('new-hash')
delete process.env.SMTP_USER
delete process.env.SMTP_PASS
const response = await resetPasswordHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).not.toHaveBeenCalled()
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
step: 'mail_configuration',
status: 'failed',
reason: 'smtp_credentials_missing'
}))
})
it('protokolliert einen Mailfehler ohne das Passwort zu aktivieren', async () => {
const event = createEvent()
mockSuccessReadBody({ email: 'user@example.com' })
authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
authUtils.hashPassword.mockResolvedValue('new-hash')
nodemailer.default.createTransport.mockReturnValueOnce({
sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' }))
})
const response = await resetPasswordHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).not.toHaveBeenCalled()
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
step: 'mail_send',
status: 'failed'
}))
})
})

View File

@@ -5,6 +5,7 @@ vi.mock('../server/utils/auth.js', () => ({
getUserFromToken: vi.fn(),
readUsers: vi.fn(),
writeUsers: vi.fn(),
revokeRefreshSessionsForUser: vi.fn(),
hasRole: vi.fn((user, role) => {
if (!user) return false
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
@@ -37,14 +38,23 @@ vi.mock('nodemailer', () => {
}
})
vi.mock('../server/utils/password-reset-log.js', () => ({
fingerprintResetEmail: vi.fn(email => `fingerprint:${String(email || '').trim().toLowerCase()}`),
normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
PASSWORD_RESET_LOG_RETENTION_HOURS: 72,
readPasswordResetLogs: vi.fn()
}))
const authUtils = await import('../server/utils/auth.js')
const nodemailer = await import('nodemailer')
const passwordResetLog = await import('../server/utils/password-reset-log.js')
import usersListHandler from '../server/api/cms/users/list.get.js'
import usersApproveHandler from '../server/api/cms/users/approve.post.js'
import usersDeactivateHandler from '../server/api/cms/users/deactivate.post.js'
import usersRejectHandler from '../server/api/cms/users/reject.post.js'
import usersUpdateRoleHandler from '../server/api/cms/users/update-role.post.js'
import passwordResetDiagnosticsHandler from '../server/api/cms/password-reset-diagnostics.get.js'
describe('CMS User Management Endpoints', () => {
beforeEach(() => {
@@ -138,6 +148,7 @@ describe('CMS User Management Endpoints', () => {
const response = await usersDeactivateHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled()
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'account_deactivated')
})
})
@@ -184,4 +195,49 @@ describe('CMS User Management Endpoints', () => {
expect(authUtils.writeUsers).toHaveBeenCalled()
})
})
describe('GET /api/cms/password-reset-diagnostics', () => {
it('verweigert Zugriff für Vorstand ohne Admin-Rolle', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.getUserFromToken.mockResolvedValue({ id: 'vorstand', roles: ['vorstand'] })
authUtils.hasRole.mockReturnValue(false)
await expect(passwordResetDiagnosticsHandler(event)).rejects.toMatchObject({ statusCode: 403 })
})
it('findet Benutzer und gefilterte fehlgeschlagene Reset-Abläufe', async () => {
const event = adminEvent()
event.__query = { email: ' User@Example.com ', failedOnly: 'true' }
authUtils.hasRole.mockReturnValue(true)
authUtils.readUsers.mockResolvedValue([
{ id: '1', email: 'user@example.com', name: 'User Beispiel', active: true }
])
passwordResetLog.readPasswordResetLogs.mockResolvedValue([
{
requestId: 'r1',
ts: '2026-05-27T10:00:01.000Z',
emailMasked: 'us***@ex***.com',
emailFingerprint: 'fingerprint:user@example.com',
ip: '127.0.0.1',
step: 'request_completed',
status: 'no_account'
},
{
requestId: 'r2',
ts: '2026-05-27T10:00:02.000Z',
emailMasked: 'ot***@ex***.com',
emailFingerprint: 'fingerprint:other@example.com',
ip: '127.0.0.1',
step: 'request_completed',
status: 'failed'
}
])
const response = await passwordResetDiagnosticsHandler(event)
expect(response.matchingUsers).toHaveLength(1)
expect(response.attempts).toHaveLength(1)
expect(response.attempts[0]).toMatchObject({ requestId: 'r1', failed: true })
})
})
})

View File

@@ -10,6 +10,7 @@ vi.mock('../server/utils/auth.js', () => ({
writeUsers: vi.fn(),
verifyPassword: vi.fn(),
hashPassword: vi.fn(),
revokeRefreshSessionsForUser: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user
if (Array.isArray(user.roles)) return user
@@ -202,6 +203,7 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true)
expect(result.user.name).toBe('Max Neu')
expect(authUtils.writeUsers).toHaveBeenCalled()
expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled()
})
it('prüft aktuelles Passwort bei Passwortänderung', async () => {
@@ -236,6 +238,7 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true)
expect(authUtils.hashPassword).toHaveBeenCalledWith(updatedPassword)
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_changed')
})
})
})

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const filesystem = vi.hoisted(() => ({
mkdir: vi.fn(),
appendFile: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn()
}))
vi.mock('fs/promises', () => ({
default: filesystem
}))
import {
cleanupPasswordResetLogs,
fingerprintResetEmail,
maskResetEmail,
normalizeResetEmail,
writePasswordResetLog
} from '../server/utils/password-reset-log.js'
describe('Password reset diagnostic log privacy helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('normalisiert E-Mail-Adressen für Lookup und Korrelation', () => {
expect(normalizeResetEmail(' User@Example.com ')).toBe('user@example.com')
expect(fingerprintResetEmail(' User@Example.com ')).toBe(fingerprintResetEmail('user@example.com'))
})
it('maskiert die E-Mail-Adresse für Diagnoseausgaben', () => {
const masked = maskResetEmail('ag2608@googlemail.com')
expect(masked).toBe('ag***@go***.com')
expect(masked).not.toContain('ag2608')
expect(masked).not.toContain('googlemail')
})
it('entfernt Diagnoseeinträge nach 72 Stunden', async () => {
const now = Date.parse('2026-05-27T12:00:00.000Z')
filesystem.readFile.mockResolvedValue([
JSON.stringify({ ts: '2026-05-24T11:59:59.000Z', requestId: 'alt' }),
JSON.stringify({ ts: '2026-05-24T12:00:00.000Z', requestId: 'neu' }),
''
].join('\n'))
const result = await cleanupPasswordResetLogs(now)
expect(result).toEqual({ retained: 1, removed: 1 })
expect(filesystem.writeFile).toHaveBeenCalledWith(
expect.any(String),
`${JSON.stringify({ ts: '2026-05-24T12:00:00.000Z', requestId: 'neu' })}\n`,
'utf8'
)
})
it('schreibt bereinigte Fehlerdetails ohne E-Mail oder Credentials', async () => {
await writePasswordResetLog({
requestId: 'r1',
email: 'ag2608@googlemail.com',
step: 'mail_send',
status: 'failed',
error: Object.assign(new Error('Versand an ag2608@googlemail.com fehlgeschlagen password=geheim'), { code: 'EAUTH' })
})
const payload = filesystem.appendFile.mock.calls[0][1]
expect(payload).toContain('"errorCode":"EAUTH"')
expect(payload).toContain('ag***@go***.com')
expect(payload).toContain('password=[redacted]')
expect(payload).not.toContain('ag2608@googlemail.com')
expect(payload).not.toContain('geheim')
})
})

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getWebAuthnConfig } from '../server/utils/webauthn-config.js'
const envNames = [
'NUXT_PUBLIC_BASE_URL',
'WEBAUTHN_RP_ID',
'WEBAUTHN_ORIGIN',
'WEBAUTHN_ALLOWED_ORIGINS'
]
const originalEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]]))
afterEach(() => {
for (const name of envNames) {
const originalValue = originalEnv[name]
if (originalValue === undefined) {
delete process.env[name]
} else {
process.env[name] = originalValue
}
}
vi.restoreAllMocks()
})
describe('WebAuthn origin configuration', () => {
it('accepts both production hosts when the public URL is the apex domain', () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
process.env.NUXT_PUBLIC_BASE_URL = 'https://harheimertc.de'
process.env.WEBAUTHN_RP_ID = 'harheimertc.de'
delete process.env.WEBAUTHN_ORIGIN
delete process.env.WEBAUTHN_ALLOWED_ORIGINS
const config = getWebAuthnConfig()
expect(config.origin).toBe('https://harheimertc.de')
expect(config.origins).toEqual([
'https://harheimertc.de',
'https://www.harheimertc.de'
])
})
it('adds explicitly allowed origins without widening test installations implicitly', () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
process.env.WEBAUTHN_ORIGIN = 'https://harheimertc.tsschulz.de'
process.env.WEBAUTHN_ALLOWED_ORIGINS = ' https://alias.tsschulz.de/ , https://alias.tsschulz.de '
const config = getWebAuthnConfig()
expect(config.origins).toEqual([
'https://harheimertc.tsschulz.de',
'https://alias.tsschulz.de'
])
})
})