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] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token - [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche - [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
- [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern - [x] `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] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences) [x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
- [x] Login/Logout und verschlüsselte Token-Speicherung - [x] Login/Logout und verschlüsselte Token-Speicherung
- [x] Registrierung und Passwort-Reset - [x] Registrierung und Passwort-Reset
- [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren - [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
- [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen - [x] 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 - [x] 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 - [x] 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] 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 - [ ] 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 [ ] 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) [ ] 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] A. Auth (Login/Logout)
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung - [x] Passwort-Login und Logout in der aktuellen App-Sitzung
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte - [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] B. Home, Termine, Spielplan, Galerie (anzeigen)
- [x] C. Kontaktformular (absenden) - [x] C. Kontaktformular (absenden)
- [ ] D. Bildanzeige + Caching - [ ] D. Bildanzeige + Caching
- [x] E. Theme & Fonts - [x] E. Theme & Fonts
6) Nächste Aktionen (sofort) 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. - Passkey-Anmeldung über Android Credential Manager anbinden.
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren. - 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. - 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: 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: 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: 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 8) Android-Testumgebungen
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`. - 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`. - Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
9) Dauerhaftes Android-Login: Architektur und Umsetzung 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. - 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: - Token-Modell:
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet. - 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 message: String? = null,
val downloadUrl: 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( data class AuthUserDto(
val id: String? = null, val id: String? = null,
val email: String = "", val email: String = "",
@@ -116,9 +121,14 @@ data class AuthUserDto(
data class LoginResponse( data class LoginResponse(
val success: Boolean = false, val success: Boolean = false,
val token: String? = null, val token: String? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val sessionId: String? = null,
val user: AuthUserDto? = null, val user: AuthUserDto? = null,
val role: String? = null, val role: String? = null,
) )
data class RefreshRequest(val refreshToken: String)
data class LogoutRequest(val refreshToken: String? = null)
data class AuthStatusResponse( data class AuthStatusResponse(
val isLoggedIn: Boolean = false, val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null, val user: AuthUserDto? = null,
@@ -256,7 +266,10 @@ interface ApiService {
suspend fun login(@Body request: LoginRequest): Response<LoginResponse> suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("/api/auth/logout") @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") @GET("/api/auth/status")
suspend fun authStatus(): Response<AuthStatusResponse> suspend fun authStatus(): Response<AuthStatusResponse>

View File

@@ -27,7 +27,7 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
val logging = HttpLoggingInterceptor() val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BASIC logging.level = HttpLoggingInterceptor.Level.BASIC
val cookies = CookieManager().apply { val cookies = CookieManager().apply {
@@ -36,6 +36,7 @@ object NetworkModule {
return OkHttpClient.Builder() return OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookies)) .cookieJar(JavaNetCookieJar(cookies))
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.authenticator(accessTokenAuthenticator)
.addInterceptor(logging) .addInterceptor(logging)
.build() .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 package de.harheimertc.repositories
import kotlinx.coroutines.flow.StateFlow
interface AuthRepository { interface AuthRepository {
fun getToken(): String? 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 @Singleton
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository { class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
private val tokenKey = "auth_token" private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id"
private val preferences by lazy { private val preferences by lazy {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .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 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 { 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() }.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.LoginResponse
import de.harheimertc.data.AuthStatusResponse import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -14,31 +16,40 @@ import javax.inject.Singleton
class LoginRepository @Inject constructor( class LoginRepository @Inject constructor(
private val api: ApiService, private val api: ApiService,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val sessionRefresher: SessionRefresher,
) { ) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching { suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val response = api.login(LoginRequest(email.trim(), password)) val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.") if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
val body = response.body() ?: error("Leere Antwort") 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.") ?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setToken(token) authRepository.setSession(token, body.refreshToken, body.sessionId)
body body
} }
suspend fun logout(): Result<Unit> = runCatching { suspend fun logout(): Result<Unit> = runCatching {
try { try {
api.logout() api.logout(LogoutRequest(authRepository.getRefreshToken()))
} finally { } finally {
authRepository.setToken(null) authRepository.clearSession()
} }
} }
suspend fun status(): Result<AuthStatusResponse> = runCatching { suspend fun status(): Result<AuthStatusResponse> = runCatching {
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse() if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
val response = api.authStatus() return@runCatching AuthStatusResponse()
}
var response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
val status = response.body() ?: AuthStatusResponse() var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn) authRepository.setToken(null) 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 status
} }

View File

@@ -26,6 +26,10 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
if (!hasAccess) { if (!hasAccess) {
return navigateTo('/mitgliederbereich') 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')) { } else if (to.path.startsWith('/cms/kontaktanfragen')) {
if (!canAccessContactRequests) { if (!canAccessContactRequests) {
return navigateTo('/mitgliederbereich') return navigateTo('/mitgliederbereich')
@@ -42,4 +46,3 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
return navigateTo('/login?redirect=' + to.path) return navigateTo('/login?redirect=' + to.path)
} }
}) })

View File

@@ -222,13 +222,34 @@
Benutzer freischalten und verwalten Benutzer freischalten und verwalten
</p> </p>
</NuxtLink> </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> </div>
</div> </div>
</template> </template>
<script setup> <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' import { ref, onMounted } from 'vue'
const authStore = useAuthStore() 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 required
autocomplete="email" 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="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" placeholder="ihre-email@example.com"
> >
</div> </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 --> <!-- Success Message -->
<div <div
v-if="successMessage" v-if="successMessage"
@@ -92,7 +77,7 @@
<!-- Info Box --> <!-- Info Box -->
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4"> <div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
<p class="text-sm text-primary-800 text-center"> <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> </p>
</div> </div>
</div> </div>
@@ -101,31 +86,27 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { AlertCircle, Check, Loader2 } from 'lucide-vue-next' import { Check, Loader2 } from 'lucide-vue-next'
const email = ref('') const email = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const errorMessage = ref('')
const successMessage = 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 () => { const handleReset = async () => {
isLoading.value = true isLoading.value = true
errorMessage.value = ''
successMessage.value = '' successMessage.value = ''
try { try {
const response = await $fetch('/api/auth/reset-password', { await $fetch('/api/auth/reset-password', {
method: 'POST', method: 'POST',
body: { email: email.value } body: { email: email.value.trim() }
}) })
} catch (_error) {
if (response.success) { // Öffentliche Antwort bleibt identisch, unabhängig von Kontostatus oder technischem Fehler.
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.'
} finally { } finally {
successMessage.value = genericResponse
email.value = ''
isLoading.value = false isLoading.value = false
} }
} }
@@ -134,4 +115,3 @@ useHead({
title: 'Passwort vergessen - Harheimer TC', title: 'Passwort vergessen - Harheimer TC',
}) })
</script> </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 { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { getAuthCookieOptions } from '../../utils/cookies.js' import { getAuthCookieOptions } from '../../utils/cookies.js'
import { writeAuditLog } from '../../utils/audit-log.js' import { writeAuditLog } from '../../utils/audit-log.js'
@@ -7,6 +7,7 @@ export default defineEventHandler(async (event) => {
try { try {
const body = await readBody(event) const body = await readBody(event)
const { email, password } = body const { email, password } = body
const isAndroidClient = body.client === 'android'
if (!email || !password) { if (!email || !password) {
throw createError({ throw createError({
@@ -72,11 +73,15 @@ export default defineEventHandler(async (event) => {
registerRateLimitSuccess(event, { name: 'auth:login:ip', keyParts: [ip] }) registerRateLimitSuccess(event, { name: 'auth:login:ip', keyParts: [ip] })
registerRateLimitSuccess(event, { name: 'auth:login:account', keyParts: [emailKey] }) registerRateLimitSuccess(event, { name: 'auth:login:account', keyParts: [emailKey] })
// Generate token let token
const token = generateToken(user) let refreshSession = null
if (isAndroidClient) {
// Create session refreshSession = await createRefreshSession(user.id, body.deviceName)
token = generateAndroidAccessToken(user, refreshSession.session.id)
} else {
token = generateToken(user)
await createSession(user.id, token) await createSession(user.id, token)
}
await writeAuditLog('auth.login.success', { ip, email: emailKey, userId: user.id }) 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) const updatedUsers = users.map(u => u.id === user.id ? user : u)
await writeUsers(updatedUsers) await writeUsers(updatedUsers)
// Set cookie if (isAndroidClient) {
deleteCookie(event, 'auth_token')
} else {
setCookie(event, 'auth_token', token, { setCookie(event, 'auth_token', token, {
...getAuthCookieOptions() ...getAuthCookieOptions()
}) })
}
// Migriere Rollen falls nötig // Migriere Rollen falls nötig
const migratedUser = migrateUserRoles({ ...user }) const migratedUser = migrateUserRoles({ ...user })
@@ -98,6 +106,9 @@ export default defineEventHandler(async (event) => {
return { return {
success: true, success: true,
token: token, // Token auch im Body für externe API-Clients token: token, // Token auch im Body für externe API-Clients
accessToken: isAndroidClient ? token : undefined,
refreshToken: refreshSession?.refreshToken,
sessionId: refreshSession?.session.id,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
@@ -112,4 +123,3 @@ export default defineEventHandler(async (event) => {
throw error 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) => { export default defineEventHandler(async (event) => {
try { try {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const body = await readBody(event)
const refreshToken = body?.refreshToken
if (token) { if (token) {
await deleteSession(token) await deleteSession(token)
} }
if (refreshToken) {
await revokeRefreshSession(refreshToken)
}
// Delete cookie // Delete cookie
deleteCookie(event, 'auth_token') 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 = { const authenticator = {
credentialID: fromBase64Url(passkey.credentialId), credentialID: fromBase64Url(passkey.credentialId),
@@ -74,14 +74,20 @@ export default defineEventHandler(async (event) => {
transports: passkey.transports || undefined transports: passkey.transports || undefined
} }
const verification = await verifyAuthenticationResponse({ let verification
try {
verification = await verifyAuthenticationResponse({
response, response,
expectedChallenge: challenge, expectedChallenge: challenge,
expectedOrigin: origin, expectedOrigin: origins,
expectedRPID: rpId, expectedRPID: rpId,
authenticator, authenticator,
requireUserVerification: requireUV 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) { if (!verification.verified) {
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_failed' }) 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' }) throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' })
} }
const { origin, rpId, requireUV } = getWebAuthnConfig() const { origins, rpId, requireUV } = getWebAuthnConfig()
const verification = await verifyRegistrationResponse({ let verification
try {
verification = await verifyRegistrationResponse({
response, response,
expectedChallenge: challenge, expectedChallenge: challenge,
expectedOrigin: origin, expectedOrigin: origins,
expectedRPID: rpId, expectedRPID: rpId,
requireUserVerification: requireUV 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 const { verified, registrationInfo } = verification
if (!verified || !registrationInfo) { if (!verified || !registrationInfo) {
@@ -117,4 +123,3 @@ export default defineEventHandler(async (event) => {
await writeAuditLog('auth.passkey.recovery.complete.success', { ip, userId: user.id }) 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.' } 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.' }) throw createError({ statusCode: 400, statusMessage: 'Registrierungs-Session abgelaufen. Bitte erneut versuchen.' })
} }
const { origin, rpId, requireUV } = getWebAuthnConfig() const { origins, rpId, requireUV } = getWebAuthnConfig()
let verification let verification
try { try {
verification = await verifyRegistrationResponse({ verification = await verifyRegistrationResponse({
response, response,
expectedChallenge, expectedChallenge,
expectedOrigin: origin, expectedOrigin: origins,
expectedRPID: rpId, expectedRPID: rpId,
requireUserVerification: requireUV requireUserVerification: requireUV
}) })
} catch {
await writeAuditLog('auth.passkey.registration.failed', { userId: user.id, reason: 'verification_error' })
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
} finally { } finally {
clearRegistrationChallenge(user.id) clearRegistrationChallenge(user.id)
} }
@@ -103,4 +106,3 @@ export default defineEventHandler(async (event) => {
return { success: true, message: 'Passkey hinzugefügt.' } 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' }) 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 // Debug: Prüfe die tatsächliche Origin aus der Response
const clientData = response?.response?.clientDataJSON const clientData = response?.response?.clientDataJSON
@@ -117,13 +117,11 @@ export default defineEventHandler(async (event) => {
} }
console.log('[DEBUG] WebAuthn config for verification', { console.log('[DEBUG] WebAuthn config for verification', {
expectedOrigin: origin, expectedOrigins: origins,
expectedOriginType: typeof origin,
expectedOriginLength: origin?.length,
actualOriginFromResponse: actualOrigin, actualOriginFromResponse: actualOrigin,
rpId, rpId,
requireUV, requireUV,
originMatch: origin === actualOrigin, originMatch: origins.includes(actualOrigin),
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN, webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL 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] Verifying registration response...')
console.log('[DEBUG] Verification parameters', { console.log('[DEBUG] Verification parameters', {
expectedOrigin: origin, expectedOrigins: origins,
expectedRPID: rpId, expectedRPID: rpId,
hasChallenge: !!challenge, hasChallenge: !!challenge,
challengeLength: challenge?.length, challengeLength: challenge?.length,
@@ -155,7 +153,7 @@ export default defineEventHandler(async (event) => {
verification = await verifyRegistrationResponse({ verification = await verifyRegistrationResponse({
response, response,
expectedChallenge: challenge, expectedChallenge: challenge,
expectedOrigin: origin, expectedOrigin: origins,
expectedRPID: rpId, expectedRPID: rpId,
requireUserVerification: requireUV requireUserVerification: requireUV
}) })
@@ -165,11 +163,12 @@ export default defineEventHandler(async (event) => {
error: verifyError, error: verifyError,
message: verifyError?.message, message: verifyError?.message,
cause: verifyError?.cause?.message, cause: verifyError?.cause?.message,
expectedOrigin: origin, expectedOrigins: origins,
actualOriginFromResponse: actualOrigin, actualOriginFromResponse: actualOrigin,
stack: verifyError?.stack 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 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.' message: 'Registrierung erfolgreich. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
} }
}) })

View File

@@ -1,25 +1,42 @@
import { readUsers, hashPassword, writeUsers } from '../../utils/auth.js' import { readUsers, hashPassword, writeUsers, revokeRefreshSessionsForUser } from '../../utils/auth.js'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import crypto from 'crypto' import crypto from 'crypto'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { writeAuditLog } from '../../utils/audit-log.js' import { writeAuditLog } from '../../utils/audit-log.js'
import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js'
export default defineEventHandler(async (event) => { 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 { try {
const body = await readBody(event) const body = await readBody(event)
const { email } = body 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({ throw createError({
statusCode: 400, statusCode: 400,
message: 'E-Mail-Adresse ist erforderlich' message: 'E-Mail-Adresse ist erforderlich'
}) })
} }
const ip = getClientIp(event)
const emailKey = String(email || '').trim().toLowerCase()
// Rate Limiting (IP + Account) // Rate Limiting (IP + Account)
await logStep('rate_limit', 'checking')
try {
assertRateLimit(event, { assertRateLimit(event, {
name: 'auth:reset:ip', name: 'auth:reset:ip',
keyParts: [ip], keyParts: [ip],
@@ -34,44 +51,52 @@ export default defineEventHandler(async (event) => {
maxAttempts: 5, maxAttempts: 5,
lockoutMs: 60 * 60 * 1000 lockoutMs: 60 * 60 * 1000
}) })
} catch (error) {
await logStep('rate_limit', 'failed', { error })
throw error
}
await logStep('rate_limit', 'passed')
// Find user // Find user
const users = await readUsers() let users
const user = users.find(u => u.email.toLowerCase() === email.toLowerCase()) 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) // Always return success (security: don't reveal if email exists)
if (!user) { if (!user) {
await logStep('user_lookup', 'not_found')
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] }) await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
await registerRateLimitFailure(event, { name: 'auth:reset:account', keyParts: [emailKey] }) 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 { return {
success: true, success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' 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 // Generate temporary password
const tempPassword = crypto.randomBytes(8).toString('hex') const tempPassword = crypto.randomBytes(8).toString('hex')
const hashedPassword = await hashPassword(tempPassword) const hashedPassword = await hashPassword(tempPassword)
await logStep('temporary_password', 'generated', { userId: user.id })
// 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 })
// Send email with temporary password // Send email with temporary password
const smtpUser = process.env.SMTP_USER const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS const smtpPass = process.env.SMTP_PASS
if (!smtpUser || !smtpPass) { 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-Credentials fehlen! E-Mail-Versand wird übersprungen.')
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`) 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 { } else {
await logStep('mail_configuration', 'passed', { userId: user.id })
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com', host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587, port: process.env.SMTP_PORT || 587,
@@ -99,15 +124,50 @@ export default defineEventHandler(async (event) => {
` `
} }
await logStep('mail_send', 'started', { userId: user.id })
try {
await transporter.sendMail(mailOptions) 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 { return {
success: true, success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
} }
} catch (error) { } 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 // Don't reveal errors to prevent email enumeration
return { return {
success: true, 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' import { writeAuditLog } from '../../../utils/audit-log.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -36,6 +36,7 @@ export default defineEventHandler(async (event) => {
user.active = false user.active = false
const updatedUsers = users.map(u => u.id === userId ? user : u) const updatedUsers = users.map(u => u.id === userId ? user : u)
await writeUsers(updatedUsers) await writeUsers(updatedUsers)
await revokeRefreshSessionsForUser(userId, 'account_deactivated')
await writeAuditLog('cms.user.deactivated', { await writeAuditLog('cms.user.deactivated', {
actorUserId: currentUser.id, actorUserId: currentUser.id,
@@ -51,4 +52,3 @@ export default defineEventHandler(async (event) => {
throw error 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' import { assertPasswordNotPwned } from '../utils/hibp.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -42,6 +42,7 @@ export default defineEventHandler(async (event) => {
} }
const user = users[userIndex] const user = users[userIndex]
let passwordChanged = false
// Check if email is already taken by another user // Check if email is already taken by another user
if (email !== user.email) { if (email !== user.email) {
@@ -91,9 +92,13 @@ export default defineEventHandler(async (event) => {
await assertPasswordNotPwned(newPassword) await assertPasswordNotPwned(newPassword)
user.password = await hashPassword(newPassword) user.password = await hashPassword(newPassword)
passwordChanged = true
} }
await writeUsers(users) await writeUsers(users)
if (passwordChanged) {
await revokeRefreshSessionsForUser(user.id, 'password_changed')
}
const migratedUser = migrateUserRoles({ ...user }) const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied']) const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
@@ -117,4 +122,3 @@ export default defineEventHandler(async (event) => {
throw error throw error
} }
}) })

View File

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

View File

@@ -1,5 +1,6 @@
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { encryptObject, decryptObject } from './encryption.js' import { encryptObject, decryptObject } from './encryption.js'
@@ -46,6 +47,10 @@ const getDataPath = (filename) => {
const USERS_FILE = getDataPath('users.json') const USERS_FILE = getDataPath('users.json')
const SESSIONS_FILE = getDataPath('sessions.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 // Get encryption key from environment
function getEncryptionKey() { function getEncryptionKey() {
@@ -146,7 +151,7 @@ export async function readUsers() {
try { try {
users = JSON.parse(data) users = JSON.parse(data)
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen') 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') console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
return [] return []
} }
@@ -210,7 +215,7 @@ function isSessionsEncrypted(data) {
return false return false
} }
return false return false
} catch (e) { } catch {
return true return true
} }
} }
@@ -231,7 +236,7 @@ export async function readSessions() {
const plainData = JSON.parse(data) const plainData = JSON.parse(data)
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen') console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
return plainData return plainData
} catch (_parseError) { } catch {
console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen') console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen')
return [] return []
} }
@@ -277,27 +282,36 @@ export async function verifyPassword(password, hash) {
} }
// Generate JWT token // Generate JWT token
export function generateToken(user) { export function generateToken(user, { expiresIn = '7d', sessionId = null } = {}) {
// Stelle sicher, dass Rollen migriert sind // Stelle sicher, dass Rollen migriert sind
const migratedUser = migrateUserRoles({ ...user }) const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied']) const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
return jwt.sign( const claims = {
{
id: user.id, id: user.id,
email: user.email, email: user.email,
roles: roles roles: roles
}, }
if (sessionId) {
claims.sid = sessionId
}
return jwt.sign(
claims,
JWT_SECRET, JWT_SECRET,
{ expiresIn: '7d' } { expiresIn }
) )
} }
export function generateAndroidAccessToken(user, sessionId) {
return generateToken(user, { expiresIn: ANDROID_ACCESS_TOKEN_TTL, sessionId })
}
// Verify JWT token // Verify JWT token
export function verifyToken(token) { export function verifyToken(token) {
try { try {
return jwt.verify(token, JWT_SECRET) return jwt.verify(token, JWT_SECRET)
} catch (_error) { } catch {
return null return null
} }
} }
@@ -343,6 +357,14 @@ export async function getUserFromToken(token) {
const decoded = verifyToken(token) const decoded = verifyToken(token)
if (!decoded) return null 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 users = await readUsers()
const user = users.find(u => u.id === decoded.id) const user = users.find(u => u.id === decoded.id)
@@ -376,6 +398,130 @@ export async function deleteSession(token) {
await writeSessions(filtered) 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 // Clean expired sessions
export async function cleanExpiredSessions() { export async function cleanExpiredSessions() {
const sessions = await readSessions() const sessions = await readSessions()
@@ -383,4 +529,3 @@ export async function cleanExpiredSessions() {
const valid = sessions.filter(s => new Date(s.expiresAt) > now) const valid = sessions.filter(s => new Date(s.expiresAt) > now)
await writeSessions(valid) 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() { export function getWebAuthnConfig() {
const derived = deriveFromBaseUrl() const derived = deriveFromBaseUrl()
@@ -33,23 +63,8 @@ export function getWebAuthnConfig() {
const rpName = process.env.WEBAUTHN_RP_NAME || 'Harheimer TC' const rpName = process.env.WEBAUTHN_RP_NAME || 'Harheimer TC'
// WEBAUTHN_ORIGIN hat Priorität, sonst von BASE_URL ableiten // WEBAUTHN_ORIGIN hat Priorität, sonst von BASE_URL ableiten
let origin = process.env.WEBAUTHN_ORIGIN || derived.origin const origin = normalizeOrigin(process.env.WEBAUTHN_ORIGIN || derived.origin)
const origins = getAllowedOrigins(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 requireUV = (process.env.WEBAUTHN_REQUIRE_UV || '').toLowerCase() === 'true' const requireUV = (process.env.WEBAUTHN_REQUIRE_UV || '').toLowerCase() === 'true'
@@ -57,13 +72,14 @@ export function getWebAuthnConfig() {
rpId, rpId,
rpName, rpName,
origin, origin,
origins,
requireUV, requireUV,
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN, webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
webauthnAllowedOriginsEnv: process.env.WEBAUTHN_ALLOWED_ORIGINS,
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL, baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL,
derivedOrigin: derived.origin 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(), writeUsers: vi.fn(),
verifyPassword: vi.fn(), verifyPassword: vi.fn(),
generateToken: vi.fn(), generateToken: vi.fn(),
generateAndroidAccessToken: vi.fn(),
createSession: vi.fn(), createSession: vi.fn(),
createRefreshSession: vi.fn(),
rotateRefreshSession: vi.fn(),
revokeRefreshSession: vi.fn(),
revokeRefreshSessionsForUser: vi.fn(),
getUserById: vi.fn(),
hashPassword: vi.fn(), hashPassword: vi.fn(),
verifyToken: vi.fn(), verifyToken: vi.fn(),
deleteSession: 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 authUtils = await import('../server/utils/auth.js')
const nodemailer = await import('nodemailer') 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 loginHandler from '../server/api/auth/login.post.js'
import logoutHandler from '../server/api/auth/logout.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 registerHandler from '../server/api/auth/register.post.js'
import resetPasswordHandler from '../server/api/auth/reset-password.post.js' import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
import statusHandler from '../server/api/auth/status.get.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.createSession).toHaveBeenCalledWith('1', 'jwt-token')
expect(authUtils.writeUsers).toHaveBeenCalled() 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', () => { describe('POST /api/auth/logout', () => {
@@ -129,6 +166,64 @@ describe('Auth API Endpoints', () => {
await expect(logoutHandler(event)).rejects.toMatchObject({ statusCode: 500 }) 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', () => { describe('POST /api/auth/register', () => {
@@ -224,6 +319,64 @@ describe('Auth API Endpoints', () => {
const response = await resetPasswordHandler(event) const response = await resetPasswordHandler(event)
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled() 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(), getUserFromToken: vi.fn(),
readUsers: vi.fn(), readUsers: vi.fn(),
writeUsers: vi.fn(), writeUsers: vi.fn(),
revokeRefreshSessionsForUser: vi.fn(),
hasRole: vi.fn((user, role) => { hasRole: vi.fn((user, role) => {
if (!user) return false if (!user) return false
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) 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 authUtils = await import('../server/utils/auth.js')
const nodemailer = await import('nodemailer') 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 usersListHandler from '../server/api/cms/users/list.get.js'
import usersApproveHandler from '../server/api/cms/users/approve.post.js' import usersApproveHandler from '../server/api/cms/users/approve.post.js'
import usersDeactivateHandler from '../server/api/cms/users/deactivate.post.js' import usersDeactivateHandler from '../server/api/cms/users/deactivate.post.js'
import usersRejectHandler from '../server/api/cms/users/reject.post.js' import usersRejectHandler from '../server/api/cms/users/reject.post.js'
import usersUpdateRoleHandler from '../server/api/cms/users/update-role.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', () => { describe('CMS User Management Endpoints', () => {
beforeEach(() => { beforeEach(() => {
@@ -138,6 +148,7 @@ describe('CMS User Management Endpoints', () => {
const response = await usersDeactivateHandler(event) const response = await usersDeactivateHandler(event)
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled() expect(authUtils.writeUsers).toHaveBeenCalled()
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'account_deactivated')
}) })
}) })
@@ -184,4 +195,49 @@ describe('CMS User Management Endpoints', () => {
expect(authUtils.writeUsers).toHaveBeenCalled() 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(), writeUsers: vi.fn(),
verifyPassword: vi.fn(), verifyPassword: vi.fn(),
hashPassword: vi.fn(), hashPassword: vi.fn(),
revokeRefreshSessionsForUser: vi.fn(),
migrateUserRoles: vi.fn((user) => { migrateUserRoles: vi.fn((user) => {
if (!user) return user if (!user) return user
if (Array.isArray(user.roles)) return user if (Array.isArray(user.roles)) return user
@@ -202,6 +203,7 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
expect(result.user.name).toBe('Max Neu') expect(result.user.name).toBe('Max Neu')
expect(authUtils.writeUsers).toHaveBeenCalled() expect(authUtils.writeUsers).toHaveBeenCalled()
expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled()
}) })
it('prüft aktuelles Passwort bei Passwortänderung', async () => { it('prüft aktuelles Passwort bei Passwortänderung', async () => {
@@ -236,6 +238,7 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
expect(authUtils.hashPassword).toHaveBeenCalledWith(updatedPassword) 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'
])
})
})