From 58fd7fa5c6a209d140bc432c75a35eabe6d7aacd Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Wed, 27 May 2026 19:34:32 +0200
Subject: [PATCH] feat(auth): implement Android refresh token handling and
session management
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints.
- Implemented new tests for login, logout, and refresh functionalities specific to Android clients.
- Enhanced password reset logging with normalization and masking of email addresses.
- Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs.
- Introduced a new utility for managing password reset logs with retention policies.
- Added tests for password reset log utilities to ensure proper functionality and privacy compliance.
- Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
---
ANDROID_KOTLIN_PLAN.md | 21 +-
.../data/AccessTokenAuthenticator.kt | 39 +++
.../java/de/harheimertc/data/ApiService.kt | 17 +-
.../java/de/harheimertc/data/NetworkModule.kt | 3 +-
.../de/harheimertc/data/SessionRefresher.kt | 64 +++++
.../repositories/AuthRepository.kt | 7 +-
.../repositories/AuthRepositoryImpl.kt | 20 +-
.../repositories/LoginRepository.kt | 27 +-
middleware/auth.js | 5 +-
pages/cms/index.vue | 23 +-
pages/cms/passwort-reset-diagnose.vue | 269 ++++++++++++++++++
pages/passwort-vergessen.vue | 38 +--
server/api/auth/login.post.js | 32 ++-
server/api/auth/logout.post.js | 7 +-
server/api/auth/passkeys/login.post.js | 25 +-
.../auth/passkeys/recovery/complete.post.js | 23 +-
server/api/auth/passkeys/register.post.js | 8 +-
server/api/auth/refresh.post.js | 47 +++
server/api/auth/register-passkey.post.js | 18 +-
server/api/auth/reset-password.post.js | 129 ++++++---
.../api/cms/password-reset-diagnostics.get.js | 94 ++++++
server/api/cms/users/deactivate.post.js | 4 +-
server/api/profile.put.js | 8 +-
server/plugins/spielplan-import-scheduler.js | 30 +-
server/utils/auth.js | 169 ++++++++++-
server/utils/password-reset-log.js | 136 +++++++++
server/utils/webauthn-config.js | 54 ++--
tests/auth-endpoints.spec.ts | 153 ++++++++++
tests/cms-users-endpoints.spec.ts | 56 ++++
tests/config-profile-endpoints.spec.ts | 3 +
tests/password-reset-log.spec.ts | 74 +++++
tests/webauthn-config.spec.ts | 54 ++++
32 files changed, 1477 insertions(+), 180 deletions(-)
create mode 100644 android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
create mode 100644 android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
create mode 100644 pages/cms/passwort-reset-diagnose.vue
create mode 100644 server/api/auth/refresh.post.js
create mode 100644 server/api/cms/password-reset-diagnostics.get.js
create mode 100644 server/utils/password-reset-log.js
create mode 100644 tests/password-reset-log.spec.ts
create mode 100644 tests/webauthn-config.spec.ts
diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md
index e43bb8b..7dc6459 100644
--- a/ANDROID_KOTLIN_PLAN.md
+++ b/ANDROID_KOTLIN_PLAN.md
@@ -98,16 +98,16 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
- - [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
- - [ ] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
+ - [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
+ - [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
- [x] Login/Logout und verschlüsselte Token-Speicherung
- [x] Registrierung und Passwort-Reset
- - [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren
- - [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
- - [ ] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
- - [ ] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- - [ ] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
+ - [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
+ - [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
+ - [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
+ - [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
+ - [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
@@ -127,14 +127,14 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- [x] A. Auth (Login/Logout)
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
- - [ ] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
+ - [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
- [x] C. Kontaktformular (absenden)
- [ ] D. Bildanzeige + Caching
- [x] E. Theme & Fonts
6) Nächste Aktionen (sofort)
-- Dauerhaftes Android-Login umsetzen: Backend-Refresh-Sitzungen, Token-Rotation, serverseitigen Widerruf und App-Refresh-Flow ergänzen.
+- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
- Passkey-Anmeldung über Android Credential Manager anbinden.
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
@@ -158,6 +158,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
+- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
8) Android-Testumgebungen
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
@@ -167,7 +168,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
9) Dauerhaftes Android-Login: Architektur und Umsetzung
-- Ausgangslage: Das Backend gibt derzeit ein sieben Tage gültiges JWT aus. Die App speichert es bereits verschlüsselt und sendet es als Bearer-Token. Die vorhandene serverseitige Sessiondatei wird beim Authentifizieren geschützter Requests derzeit nicht zur Widerrufsprüfung herangezogen.
+- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
- Token-Modell:
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
diff --git a/android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt b/android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
new file mode 100644
index 0000000..cc4d994
--- /dev/null
+++ b/android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
@@ -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
+ }
+}
diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
index f05c1f3..630d5e2 100644
--- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
+++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
@@ -106,7 +106,12 @@ data class MembershipResponse(
val message: String? = null,
val downloadUrl: String? = null,
)
-data class LoginRequest(val email: String, val password: String)
+data class LoginRequest(
+ val email: String,
+ val password: String,
+ val client: String = "android",
+ val deviceName: String = "Harheimer TC Android-App",
+)
data class AuthUserDto(
val id: String? = null,
val email: String = "",
@@ -116,9 +121,14 @@ data class AuthUserDto(
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
+ val accessToken: String? = null,
+ val refreshToken: String? = null,
+ val sessionId: String? = null,
val user: AuthUserDto? = null,
val role: String? = null,
)
+data class RefreshRequest(val refreshToken: String)
+data class LogoutRequest(val refreshToken: String? = null)
data class AuthStatusResponse(
val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null,
@@ -256,7 +266,10 @@ interface ApiService {
suspend fun login(@Body request: LoginRequest): Response
@POST("/api/auth/logout")
- suspend fun logout(): Response
+ suspend fun logout(@Body request: LogoutRequest): Response
+
+ @POST("/api/auth/refresh")
+ suspend fun refresh(@Body request: RefreshRequest): Response
@GET("/api/auth/status")
suspend fun authStatus(): Response
diff --git a/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt b/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
index 6b19ccc..4708b47 100644
--- a/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
+++ b/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
@@ -27,7 +27,7 @@ object NetworkModule {
@Provides
@Singleton
- fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
+ fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BASIC
val cookies = CookieManager().apply {
@@ -36,6 +36,7 @@ object NetworkModule {
return OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookies))
.addInterceptor(authInterceptor)
+ .authenticator(accessTokenAuthenticator)
.addInterceptor(logging)
.build()
}
diff --git a/android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt b/android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
new file mode 100644
index 0000000..851559e
--- /dev/null
+++ b/android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
@@ -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
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
index c16a08d..ef98732 100644
--- a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
+++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
@@ -1,8 +1,9 @@
package de.harheimertc.repositories
-import kotlinx.coroutines.flow.StateFlow
-
interface AuthRepository {
fun getToken(): String?
- fun setToken(token: String?)
+ fun getRefreshToken(): String?
+ fun getSessionId(): String?
+ fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
+ fun clearSession()
}
diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
index 8380e5e..6142266 100644
--- a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
+++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
@@ -10,6 +10,8 @@ import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
private val tokenKey = "auth_token"
+ private val refreshTokenKey = "auth_refresh_token"
+ private val sessionIdKey = "auth_session_id"
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -25,9 +27,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
override fun getToken(): String? = preferences.getString(tokenKey, null)
- override fun setToken(token: String?) {
+ override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
+
+ override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
+
+ override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
preferences.edit().apply {
- if (token == null) remove(tokenKey) else putString(tokenKey, token)
+ if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
+ if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
+ if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
}.apply()
}
+
+ override fun clearSession() {
+ preferences.edit()
+ .remove(tokenKey)
+ .remove(refreshTokenKey)
+ .remove(sessionIdKey)
+ .apply()
+ }
}
diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
index 8c5ce8d..5ac3565 100644
--- a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
+++ b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
@@ -5,8 +5,10 @@ import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse
+import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
+import de.harheimertc.data.SessionRefresher
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,31 +16,40 @@ import javax.inject.Singleton
class LoginRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
+ private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result = runCatching {
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
val body = response.body() ?: error("Leere Antwort")
- val token = body.token?.takeIf(String::isNotBlank)
+ val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
- authRepository.setToken(token)
+ authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}
suspend fun logout(): Result = runCatching {
try {
- api.logout()
+ api.logout(LogoutRequest(authRepository.getRefreshToken()))
} finally {
- authRepository.setToken(null)
+ authRepository.clearSession()
}
}
suspend fun status(): Result = runCatching {
- if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
- val response = api.authStatus()
+ if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
+ return@runCatching AuthStatusResponse()
+ }
+
+ var response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
- val status = response.body() ?: AuthStatusResponse()
- if (!status.isLoggedIn) authRepository.setToken(null)
+ var status = response.body() ?: AuthStatusResponse()
+ if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
+ response = api.authStatus()
+ if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
+ status = response.body() ?: AuthStatusResponse()
+ }
+ if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}
diff --git a/middleware/auth.js b/middleware/auth.js
index 1382645..243dfd2 100644
--- a/middleware/auth.js
+++ b/middleware/auth.js
@@ -26,6 +26,10 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
if (!hasAccess) {
return navigateTo('/mitgliederbereich')
}
+ } else if (to.path.startsWith('/cms/passwort-reset-diagnose')) {
+ if (!roles.includes('admin')) {
+ return navigateTo('/cms')
+ }
} else if (to.path.startsWith('/cms/kontaktanfragen')) {
if (!canAccessContactRequests) {
return navigateTo('/mitgliederbereich')
@@ -42,4 +46,3 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
return navigateTo('/login?redirect=' + to.path)
}
})
-
diff --git a/pages/cms/index.vue b/pages/cms/index.vue
index daaec70..16d98a8 100644
--- a/pages/cms/index.vue
+++ b/pages/cms/index.vue
@@ -222,13 +222,34 @@
Benutzer freischalten und verwalten
+
+
+
+
+
+
+
+ Passwort-Reset-Diagnose
+
+
+
+ Fehlversuche und Versandabläufe prüfen
+
+
diff --git a/pages/passwort-vergessen.vue b/pages/passwort-vergessen.vue
index 67fc1ea..6101a32 100644
--- a/pages/passwort-vergessen.vue
+++ b/pages/passwort-vergessen.vue
@@ -30,25 +30,10 @@
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
- :class="{ 'border-red-500': errorMessage }"
placeholder="ihre-email@example.com"
>
-
-
-
-
- {{ errorMessage }}
-
-
-
- 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.
@@ -101,31 +86,27 @@
-
diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js
index f9e1d58..c8b50dd 100644
--- a/server/api/auth/login.post.js
+++ b/server/api/auth/login.post.js
@@ -1,4 +1,4 @@
-import { readUsers, writeUsers, verifyPassword, generateToken, createSession, migrateUserRoles } from '../../utils/auth.js'
+import { readUsers, writeUsers, verifyPassword, generateToken, generateAndroidAccessToken, createSession, createRefreshSession, migrateUserRoles } from '../../utils/auth.js'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { getAuthCookieOptions } from '../../utils/cookies.js'
import { writeAuditLog } from '../../utils/audit-log.js'
@@ -7,6 +7,7 @@ export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const { email, password } = body
+ const isAndroidClient = body.client === 'android'
if (!email || !password) {
throw createError({
@@ -72,11 +73,15 @@ export default defineEventHandler(async (event) => {
registerRateLimitSuccess(event, { name: 'auth:login:ip', keyParts: [ip] })
registerRateLimitSuccess(event, { name: 'auth:login:account', keyParts: [emailKey] })
- // Generate token
- const token = generateToken(user)
-
- // Create session
- await createSession(user.id, token)
+ let token
+ let refreshSession = null
+ if (isAndroidClient) {
+ refreshSession = await createRefreshSession(user.id, body.deviceName)
+ token = generateAndroidAccessToken(user, refreshSession.session.id)
+ } else {
+ token = generateToken(user)
+ await createSession(user.id, token)
+ }
await writeAuditLog('auth.login.success', { ip, email: emailKey, userId: user.id })
@@ -85,10 +90,13 @@ export default defineEventHandler(async (event) => {
const updatedUsers = users.map(u => u.id === user.id ? user : u)
await writeUsers(updatedUsers)
- // Set cookie
- setCookie(event, 'auth_token', token, {
- ...getAuthCookieOptions()
- })
+ if (isAndroidClient) {
+ deleteCookie(event, 'auth_token')
+ } else {
+ setCookie(event, 'auth_token', token, {
+ ...getAuthCookieOptions()
+ })
+ }
// Migriere Rollen falls nötig
const migratedUser = migrateUserRoles({ ...user })
@@ -98,6 +106,9 @@ export default defineEventHandler(async (event) => {
return {
success: true,
token: token, // Token auch im Body für externe API-Clients
+ accessToken: isAndroidClient ? token : undefined,
+ refreshToken: refreshSession?.refreshToken,
+ sessionId: refreshSession?.session.id,
user: {
id: user.id,
email: user.email,
@@ -112,4 +123,3 @@ export default defineEventHandler(async (event) => {
throw error
}
})
-
diff --git a/server/api/auth/logout.post.js b/server/api/auth/logout.post.js
index bba2234..b9f0eae 100644
--- a/server/api/auth/logout.post.js
+++ b/server/api/auth/logout.post.js
@@ -1,12 +1,17 @@
-import { deleteSession } from '../../utils/auth.js'
+import { deleteSession, revokeRefreshSession } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
+ const body = await readBody(event)
+ const refreshToken = body?.refreshToken
if (token) {
await deleteSession(token)
}
+ if (refreshToken) {
+ await revokeRefreshSession(refreshToken)
+ }
// Delete cookie
deleteCookie(event, 'auth_token')
diff --git a/server/api/auth/passkeys/login.post.js b/server/api/auth/passkeys/login.post.js
index 71b90c1..18392b2 100644
--- a/server/api/auth/passkeys/login.post.js
+++ b/server/api/auth/passkeys/login.post.js
@@ -65,7 +65,7 @@ export default defineEventHandler(async (event) => {
})
}
- const { origin, rpId, requireUV } = getWebAuthnConfig()
+ const { origins, rpId, requireUV } = getWebAuthnConfig()
const authenticator = {
credentialID: fromBase64Url(passkey.credentialId),
@@ -74,14 +74,20 @@ export default defineEventHandler(async (event) => {
transports: passkey.transports || undefined
}
- const verification = await verifyAuthenticationResponse({
- response,
- expectedChallenge: challenge,
- expectedOrigin: origin,
- expectedRPID: rpId,
- authenticator,
- requireUserVerification: requireUV
- })
+ let verification
+ try {
+ verification = await verifyAuthenticationResponse({
+ response,
+ expectedChallenge: challenge,
+ expectedOrigin: origins,
+ expectedRPID: rpId,
+ authenticator,
+ requireUserVerification: requireUV
+ })
+ } catch {
+ await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_error' })
+ throw createError({ statusCode: 401, statusMessage: 'Passkey-Login fehlgeschlagen' })
+ }
if (!verification.verified) {
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_failed' })
@@ -118,4 +124,3 @@ export default defineEventHandler(async (event) => {
}
})
-
diff --git a/server/api/auth/passkeys/recovery/complete.post.js b/server/api/auth/passkeys/recovery/complete.post.js
index 948fa29..b5416e1 100644
--- a/server/api/auth/passkeys/recovery/complete.post.js
+++ b/server/api/auth/passkeys/recovery/complete.post.js
@@ -62,15 +62,21 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' })
}
- const { origin, rpId, requireUV } = getWebAuthnConfig()
+ const { origins, rpId, requireUV } = getWebAuthnConfig()
- const verification = await verifyRegistrationResponse({
- response,
- expectedChallenge: challenge,
- expectedOrigin: origin,
- expectedRPID: rpId,
- requireUserVerification: requireUV
- })
+ let verification
+ try {
+ verification = await verifyRegistrationResponse({
+ response,
+ expectedChallenge: challenge,
+ expectedOrigin: origins,
+ expectedRPID: rpId,
+ requireUserVerification: requireUV
+ })
+ } catch {
+ await writeAuditLog('auth.passkey.recovery.complete.failed', { ip, reason: 'verification_error', userId })
+ throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
+ }
const { verified, registrationInfo } = verification
if (!verified || !registrationInfo) {
@@ -117,4 +123,3 @@ export default defineEventHandler(async (event) => {
await writeAuditLog('auth.passkey.recovery.complete.success', { ip, userId: user.id })
return { success: true, message: 'Passkey hinzugefügt. Sie können sich jetzt mit dem neuen Passkey anmelden.' }
})
-
diff --git a/server/api/auth/passkeys/register.post.js b/server/api/auth/passkeys/register.post.js
index 534b296..e974953 100644
--- a/server/api/auth/passkeys/register.post.js
+++ b/server/api/auth/passkeys/register.post.js
@@ -37,17 +37,20 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Registrierungs-Session abgelaufen. Bitte erneut versuchen.' })
}
- const { origin, rpId, requireUV } = getWebAuthnConfig()
+ const { origins, rpId, requireUV } = getWebAuthnConfig()
let verification
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
- expectedOrigin: origin,
+ expectedOrigin: origins,
expectedRPID: rpId,
requireUserVerification: requireUV
})
+ } catch {
+ await writeAuditLog('auth.passkey.registration.failed', { userId: user.id, reason: 'verification_error' })
+ throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
} finally {
clearRegistrationChallenge(user.id)
}
@@ -103,4 +106,3 @@ export default defineEventHandler(async (event) => {
return { success: true, message: 'Passkey hinzugefügt.' }
})
-
diff --git a/server/api/auth/refresh.post.js b/server/api/auth/refresh.post.js
new file mode 100644
index 0000000..591c75b
--- /dev/null
+++ b/server/api/auth/refresh.post.js
@@ -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
+ }
+})
diff --git a/server/api/auth/register-passkey.post.js b/server/api/auth/register-passkey.post.js
index 4f288a1..6c98488 100644
--- a/server/api/auth/register-passkey.post.js
+++ b/server/api/auth/register-passkey.post.js
@@ -96,7 +96,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 409, message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits' })
}
- const { origin, rpId, requireUV } = getWebAuthnConfig()
+ const { origin, origins, rpId, requireUV } = getWebAuthnConfig()
// Debug: Prüfe die tatsächliche Origin aus der Response
const clientData = response?.response?.clientDataJSON
@@ -117,13 +117,11 @@ export default defineEventHandler(async (event) => {
}
console.log('[DEBUG] WebAuthn config for verification', {
- expectedOrigin: origin,
- expectedOriginType: typeof origin,
- expectedOriginLength: origin?.length,
+ expectedOrigins: origins,
actualOriginFromResponse: actualOrigin,
rpId,
requireUV,
- originMatch: origin === actualOrigin,
+ originMatch: origins.includes(actualOrigin),
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL
})
@@ -140,7 +138,7 @@ export default defineEventHandler(async (event) => {
console.log('[DEBUG] Verifying registration response...')
console.log('[DEBUG] Verification parameters', {
- expectedOrigin: origin,
+ expectedOrigins: origins,
expectedRPID: rpId,
hasChallenge: !!challenge,
challengeLength: challenge?.length,
@@ -155,7 +153,7 @@ export default defineEventHandler(async (event) => {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
- expectedOrigin: origin,
+ expectedOrigin: origins,
expectedRPID: rpId,
requireUserVerification: requireUV
})
@@ -165,11 +163,12 @@ export default defineEventHandler(async (event) => {
error: verifyError,
message: verifyError?.message,
cause: verifyError?.cause?.message,
- expectedOrigin: origin,
+ expectedOrigins: origins,
actualOriginFromResponse: actualOrigin,
stack: verifyError?.stack
})
- throw verifyError
+ await writeAuditLog('auth.passkey.prereg.failed', { email, reason: 'verification_error' })
+ throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
}
const verifyDuration = Date.now() - verifyStart
@@ -308,4 +307,3 @@ export default defineEventHandler(async (event) => {
message: 'Registrierung erfolgreich. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
}
})
-
diff --git a/server/api/auth/reset-password.post.js b/server/api/auth/reset-password.post.js
index 4cb4aef..1541ff7 100644
--- a/server/api/auth/reset-password.post.js
+++ b/server/api/auth/reset-password.post.js
@@ -1,77 +1,102 @@
-import { readUsers, hashPassword, writeUsers } from '../../utils/auth.js'
+import { readUsers, hashPassword, writeUsers, revokeRefreshSessionsForUser } from '../../utils/auth.js'
import nodemailer from 'nodemailer'
import crypto from 'crypto'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { writeAuditLog } from '../../utils/audit-log.js'
+import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js'
export default defineEventHandler(async (event) => {
+ const requestId = crypto.randomUUID()
+ let emailKey = ''
+ let ip = ''
+
+ const logStep = async (step, status, detail = {}) => {
+ try {
+ await writePasswordResetLog({ requestId, email: emailKey, ip, step, status, ...detail })
+ } catch (logError) {
+ console.error('Password-Reset-Diagnoselog-Fehler:', logError)
+ }
+ }
+
try {
const body = await readBody(event)
const { email } = body
- if (!email) {
+ emailKey = normalizeResetEmail(email)
+ ip = getClientIp(event)
+ await logStep('request_received', 'started')
+
+ if (!emailKey) {
+ await logStep('request_validation', 'failed', { reason: 'email_missing' })
throw createError({
statusCode: 400,
message: 'E-Mail-Adresse ist erforderlich'
})
}
- const ip = getClientIp(event)
- const emailKey = String(email || '').trim().toLowerCase()
-
// Rate Limiting (IP + Account)
- assertRateLimit(event, {
- name: 'auth:reset:ip',
- keyParts: [ip],
- windowMs: 60 * 60 * 1000,
- maxAttempts: 20,
- lockoutMs: 30 * 60 * 1000
- })
- assertRateLimit(event, {
- name: 'auth:reset:account',
- keyParts: [emailKey],
- windowMs: 60 * 60 * 1000,
- maxAttempts: 5,
- lockoutMs: 60 * 60 * 1000
- })
+ await logStep('rate_limit', 'checking')
+ try {
+ assertRateLimit(event, {
+ name: 'auth:reset:ip',
+ keyParts: [ip],
+ windowMs: 60 * 60 * 1000,
+ maxAttempts: 20,
+ lockoutMs: 30 * 60 * 1000
+ })
+ assertRateLimit(event, {
+ name: 'auth:reset:account',
+ keyParts: [emailKey],
+ windowMs: 60 * 60 * 1000,
+ maxAttempts: 5,
+ lockoutMs: 60 * 60 * 1000
+ })
+ } catch (error) {
+ await logStep('rate_limit', 'failed', { error })
+ throw error
+ }
+ await logStep('rate_limit', 'passed')
// Find user
- const users = await readUsers()
- const user = users.find(u => u.email.toLowerCase() === email.toLowerCase())
+ let users
+ try {
+ users = await readUsers()
+ } catch (error) {
+ await logStep('user_lookup', 'failed', { error })
+ throw error
+ }
+ const user = users.find(u => normalizeResetEmail(u.email) === emailKey)
// Always return success (security: don't reveal if email exists)
if (!user) {
+ await logStep('user_lookup', 'not_found')
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
await registerRateLimitFailure(event, { name: 'auth:reset:account', keyParts: [emailKey] })
- await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: false })
+ await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: false, requestId })
+ await logStep('request_completed', 'no_account')
return {
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
}
}
+ await logStep('user_lookup', 'found', { userId: user.id })
// Generate temporary password
const tempPassword = crypto.randomBytes(8).toString('hex')
const hashedPassword = await hashPassword(tempPassword)
-
- // Update user password
- user.password = hashedPassword
- user.passwordResetRequired = true
- const updatedUsers = users.map(u => u.id === user.id ? user : u)
- await writeUsers(updatedUsers)
-
- registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
- await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: true, userId: user.id })
+ await logStep('temporary_password', 'generated', { userId: user.id })
// Send email with temporary password
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (!smtpUser || !smtpPass) {
+ await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' })
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
- // Continue without sending email - security: don't reveal if email exists
+ throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt')
} else {
+ await logStep('mail_configuration', 'passed', { userId: user.id })
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
@@ -99,15 +124,50 @@ export default defineEventHandler(async (event) => {
`
}
- await transporter.sendMail(mailOptions)
+ await logStep('mail_send', 'started', { userId: user.id })
+ try {
+ await transporter.sendMail(mailOptions)
+ } catch (error) {
+ await logStep('mail_send', 'failed', { userId: user.id, error })
+ throw error
+ }
+ await logStep('mail_send', 'completed', { userId: user.id })
}
+ // Erst nach erfolgreichem Versand das zugesandte Passwort aktivieren.
+ user.password = hashedPassword
+ user.passwordResetRequired = true
+ const updatedUsers = users.map(u => u.id === user.id ? user : u)
+ let passwordStored = false
+ try {
+ passwordStored = await writeUsers(updatedUsers)
+ } catch (error) {
+ await logStep('password_storage', 'failed', { userId: user.id, error })
+ throw error
+ }
+ if (!passwordStored) {
+ await logStep('password_storage', 'failed', { userId: user.id, reason: 'write_failed' })
+ throw new Error('Passwort konnte nach E-Mail-Versand nicht gespeichert werden')
+ }
+ await logStep('password_storage', 'completed', { userId: user.id })
+ try {
+ await revokeRefreshSessionsForUser(user.id, 'password_reset')
+ } catch (error) {
+ await logStep('session_revocation', 'failed', { userId: user.id, error })
+ throw error
+ }
+ await logStep('session_revocation', 'completed', { userId: user.id })
+
+ registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
+ await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId })
+ await logStep('request_completed', 'success', { userId: user.id })
return {
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
}
} catch (error) {
- console.error('Password-Reset-Fehler:', error)
+ await logStep('request_completed', 'failed', { error })
+ console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' })
// Don't reveal errors to prevent email enumeration
return {
success: true,
@@ -115,4 +175,3 @@ export default defineEventHandler(async (event) => {
}
}
})
-
diff --git a/server/api/cms/password-reset-diagnostics.get.js b/server/api/cms/password-reset-diagnostics.get.js
new file mode 100644
index 0000000..f7e8872
--- /dev/null
+++ b/server/api/cms/password-reset-diagnostics.get.js
@@ -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
+ }
+})
diff --git a/server/api/cms/users/deactivate.post.js b/server/api/cms/users/deactivate.post.js
index 0e0760b..2991e6f 100644
--- a/server/api/cms/users/deactivate.post.js
+++ b/server/api/cms/users/deactivate.post.js
@@ -1,4 +1,4 @@
-import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js'
+import { getUserFromToken, readUsers, writeUsers, hasAnyRole, revokeRefreshSessionsForUser } from '../../../utils/auth.js'
import { writeAuditLog } from '../../../utils/audit-log.js'
export default defineEventHandler(async (event) => {
@@ -36,6 +36,7 @@ export default defineEventHandler(async (event) => {
user.active = false
const updatedUsers = users.map(u => u.id === userId ? user : u)
await writeUsers(updatedUsers)
+ await revokeRefreshSessionsForUser(userId, 'account_deactivated')
await writeAuditLog('cms.user.deactivated', {
actorUserId: currentUser.id,
@@ -51,4 +52,3 @@ export default defineEventHandler(async (event) => {
throw error
}
})
-
diff --git a/server/api/profile.put.js b/server/api/profile.put.js
index 3042e29..168a4ae 100644
--- a/server/api/profile.put.js
+++ b/server/api/profile.put.js
@@ -1,4 +1,4 @@
-import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles } from '../utils/auth.js'
+import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js'
import { assertPasswordNotPwned } from '../utils/hibp.js'
export default defineEventHandler(async (event) => {
@@ -42,6 +42,7 @@ export default defineEventHandler(async (event) => {
}
const user = users[userIndex]
+ let passwordChanged = false
// Check if email is already taken by another user
if (email !== user.email) {
@@ -91,9 +92,13 @@ export default defineEventHandler(async (event) => {
await assertPasswordNotPwned(newPassword)
user.password = await hashPassword(newPassword)
+ passwordChanged = true
}
await writeUsers(users)
+ if (passwordChanged) {
+ await revokeRefreshSessionsForUser(user.id, 'password_changed')
+ }
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
@@ -117,4 +122,3 @@ export default defineEventHandler(async (event) => {
throw error
}
})
-
diff --git a/server/plugins/spielplan-import-scheduler.js b/server/plugins/spielplan-import-scheduler.js
index b90c867..aaaa425 100644
--- a/server/plugins/spielplan-import-scheduler.js
+++ b/server/plugins/spielplan-import-scheduler.js
@@ -2,6 +2,7 @@ import { importSpielplan } from '../utils/spielplan-import.js'
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
+import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
const TIME_ZONE = 'Europe/Berlin'
const RUN_HOUR = 7
@@ -65,11 +66,22 @@ function nextRunAt(now = new Date()) {
return candidate
}
-async function runImport(reason) {
+async function runDailyJobs(reason, skipSpielplanImport = false) {
if (running) return
running = true
try {
+ try {
+ const cleanup = await cleanupPasswordResetLogs()
+ loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup)
+ } catch (error) {
+ loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error })
+ }
+
+ if (skipSpielplanImport) {
+ return
+ }
+
const spielplan = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
@@ -96,13 +108,13 @@ async function runImport(reason) {
}
}
-function scheduleNext() {
+function scheduleNext(skipSpielplanImport = false) {
const runAt = nextRunAt()
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
timer = setTimeout(async () => {
- await runImport('taeglicher Lauf')
- scheduleNext()
+ await runDailyJobs('taeglicher Lauf', skipSpielplanImport)
+ scheduleNext(skipSpielplanImport)
}, delay)
timer.unref?.()
@@ -110,15 +122,15 @@ function scheduleNext() {
}
export default defineNitroPlugin((nitroApp) => {
- if (process.env.SPIELPLAN_IMPORT_DISABLED === 'true') {
- loggerInfo('[spielplan-import] Scheduler deaktiviert')
- return
+ const skipSpielplanImport = process.env.SPIELPLAN_IMPORT_DISABLED === 'true'
+ if (skipSpielplanImport) {
+ loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv')
}
- scheduleNext()
+ scheduleNext(skipSpielplanImport)
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
- runImport('Startlauf')
+ runDailyJobs('Startlauf', skipSpielplanImport)
}
nitroApp.hooks.hookOnce('close', () => {
diff --git a/server/utils/auth.js b/server/utils/auth.js
index 4684a75..5a9c0fd 100644
--- a/server/utils/auth.js
+++ b/server/utils/auth.js
@@ -1,5 +1,6 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
+import crypto from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import { encryptObject, decryptObject } from './encryption.js'
@@ -46,6 +47,10 @@ const getDataPath = (filename) => {
const USERS_FILE = getDataPath('users.json')
const SESSIONS_FILE = getDataPath('sessions.json')
+const ANDROID_ACCESS_TOKEN_TTL = '15m'
+const REFRESH_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
+const refreshMutationState = globalThis.__HTC_REFRESH_MUTATION_STATE__ || { tail: Promise.resolve() }
+globalThis.__HTC_REFRESH_MUTATION_STATE__ = refreshMutationState
// Get encryption key from environment
function getEncryptionKey() {
@@ -146,7 +151,7 @@ export async function readUsers() {
try {
users = JSON.parse(data)
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
- } catch (_parseError) {
+ } catch {
console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
return []
}
@@ -210,7 +215,7 @@ function isSessionsEncrypted(data) {
return false
}
return false
- } catch (e) {
+ } catch {
return true
}
}
@@ -231,7 +236,7 @@ export async function readSessions() {
const plainData = JSON.parse(data)
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
return plainData
- } catch (_parseError) {
+ } catch {
console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen')
return []
}
@@ -277,27 +282,36 @@ export async function verifyPassword(password, hash) {
}
// Generate JWT token
-export function generateToken(user) {
+export function generateToken(user, { expiresIn = '7d', sessionId = null } = {}) {
// Stelle sicher, dass Rollen migriert sind
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
+ const claims = {
+ id: user.id,
+ email: user.email,
+ roles: roles
+ }
+ if (sessionId) {
+ claims.sid = sessionId
+ }
+
return jwt.sign(
- {
- id: user.id,
- email: user.email,
- roles: roles
- },
+ claims,
JWT_SECRET,
- { expiresIn: '7d' }
+ { expiresIn }
)
}
+export function generateAndroidAccessToken(user, sessionId) {
+ return generateToken(user, { expiresIn: ANDROID_ACCESS_TOKEN_TTL, sessionId })
+}
+
// Verify JWT token
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
- } catch (_error) {
+ } catch {
return null
}
}
@@ -343,6 +357,14 @@ export async function getUserFromToken(token) {
const decoded = verifyToken(token)
if (!decoded) return null
+ if (decoded.sid) {
+ const sessions = await readSessions()
+ const session = sessions.find(s => s.id === decoded.sid && s.userId === decoded.id)
+ if (!session || session.revokedAt || new Date(session.expiresAt).getTime() <= Date.now()) {
+ return null
+ }
+ }
+
const users = await readUsers()
const user = users.find(u => u.id === decoded.id)
@@ -376,6 +398,130 @@ export async function deleteSession(token) {
await writeSessions(filtered)
}
+function hashRefreshToken(token) {
+ return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
+}
+
+function issueRefreshToken() {
+ return crypto.randomBytes(48).toString('base64url')
+}
+
+function serializeRefreshMutation(operation) {
+ const result = refreshMutationState.tail.then(operation, operation)
+ refreshMutationState.tail = result.then(() => undefined, () => undefined)
+ return result
+}
+
+export async function createRefreshSession(userId, deviceName = 'Android') {
+ return serializeRefreshMutation(async () => {
+ const sessions = await readSessions()
+ const refreshToken = issueRefreshToken()
+ const createdAt = new Date().toISOString()
+ const session = {
+ id: crypto.randomUUID(),
+ familyId: crypto.randomUUID(),
+ type: 'android_refresh',
+ userId,
+ deviceName: String(deviceName || 'Android').slice(0, 100),
+ refreshTokenHash: hashRefreshToken(refreshToken),
+ createdAt,
+ lastUsedAt: createdAt,
+ expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(),
+ revokedAt: null
+ }
+ sessions.push(session)
+ await writeSessions(sessions)
+ return { session, refreshToken }
+ })
+}
+
+export async function rotateRefreshSession(refreshToken) {
+ return serializeRefreshMutation(async () => {
+ const sessions = await readSessions()
+ const tokenHash = hashRefreshToken(refreshToken)
+ const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash)
+ const now = new Date()
+ const nowIso = now.toISOString()
+
+ if (!session) return { status: 'invalid' }
+
+ if (session.revokedAt) {
+ if (session.rotatedAt) {
+ for (const related of sessions) {
+ if (related.familyId === session.familyId && !related.revokedAt) {
+ related.revokedAt = nowIso
+ related.revokeReason = 'refresh_token_reuse'
+ }
+ }
+ await writeSessions(sessions)
+ return { status: 'reused', session }
+ }
+ return { status: 'revoked', session }
+ }
+
+ if (new Date(session.expiresAt).getTime() <= now.getTime()) {
+ session.revokedAt = nowIso
+ session.revokeReason = 'expired'
+ await writeSessions(sessions)
+ return { status: 'expired', session }
+ }
+
+ const nextRefreshToken = issueRefreshToken()
+ const nextSession = {
+ ...session,
+ id: crypto.randomUUID(),
+ refreshTokenHash: hashRefreshToken(nextRefreshToken),
+ createdAt: nowIso,
+ lastUsedAt: nowIso,
+ expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(),
+ revokedAt: null
+ }
+ session.lastUsedAt = nowIso
+ session.revokedAt = nowIso
+ session.rotatedAt = nowIso
+ session.replacedBy = nextSession.id
+ session.revokeReason = 'rotated'
+ sessions.push(nextSession)
+ await writeSessions(sessions)
+ return { status: 'rotated', session: nextSession, refreshToken: nextRefreshToken }
+ })
+}
+
+export async function revokeRefreshSession(refreshToken, reason = 'logout') {
+ return serializeRefreshMutation(async () => {
+ const sessions = await readSessions()
+ const tokenHash = hashRefreshToken(refreshToken)
+ const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash)
+ if (!session) return false
+
+ const revokedAt = new Date().toISOString()
+ for (const related of sessions) {
+ if (related.familyId === session.familyId && !related.revokedAt) {
+ related.revokedAt = revokedAt
+ related.revokeReason = reason
+ }
+ }
+ await writeSessions(sessions)
+ return true
+ })
+}
+
+export async function revokeRefreshSessionsForUser(userId, reason) {
+ return serializeRefreshMutation(async () => {
+ const sessions = await readSessions()
+ const revokedAt = new Date().toISOString()
+ let changed = false
+ for (const session of sessions) {
+ if (session.type === 'android_refresh' && session.userId === userId && !session.revokedAt) {
+ session.revokedAt = revokedAt
+ session.revokeReason = reason
+ changed = true
+ }
+ }
+ if (changed) await writeSessions(sessions)
+ })
+}
+
// Clean expired sessions
export async function cleanExpiredSessions() {
const sessions = await readSessions()
@@ -383,4 +529,3 @@ export async function cleanExpiredSessions() {
const valid = sessions.filter(s => new Date(s.expiresAt) > now)
await writeSessions(valid)
}
-
diff --git a/server/utils/password-reset-log.js b/server/utils/password-reset-log.js
new file mode 100644
index 0000000..0c00a49
--- /dev/null
+++ b/server/utils/password-reset-log.js
@@ -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)
diff --git a/server/utils/webauthn-config.js b/server/utils/webauthn-config.js
index 967278c..e88f929 100644
--- a/server/utils/webauthn-config.js
+++ b/server/utils/webauthn-config.js
@@ -26,6 +26,36 @@ function deriveFromBaseUrl() {
}
}
+function normalizeOrigin(value) {
+ try {
+ const u = new URL(value)
+ if (u.protocol === 'https:') {
+ return `https://${u.hostname}`
+ }
+ if (u.protocol === 'http:' && u.hostname === 'localhost') {
+ return `${u.protocol}//${u.host}`
+ }
+ return u.port === '80' ? `http://${u.hostname}` : `${u.protocol}//${u.host}`
+ } catch {
+ return value
+ }
+}
+
+function getAllowedOrigins(origin) {
+ const configured = String(process.env.WEBAUTHN_ALLOWED_ORIGINS || '')
+ .split(',')
+ .map(candidate => normalizeOrigin(candidate.trim()))
+ .filter(Boolean)
+ const origins = [origin, ...configured]
+
+ // Beide produktiven Hostnamen werden im Browser verwendet und gehoeren zur selben RP-ID.
+ if (origin === 'https://harheimertc.de' || origin === 'https://www.harheimertc.de') {
+ origins.push('https://harheimertc.de', 'https://www.harheimertc.de')
+ }
+
+ return [...new Set(origins)]
+}
+
export function getWebAuthnConfig() {
const derived = deriveFromBaseUrl()
@@ -33,23 +63,8 @@ export function getWebAuthnConfig() {
const rpName = process.env.WEBAUTHN_RP_NAME || 'Harheimer TC'
// WEBAUTHN_ORIGIN hat Priorität, sonst von BASE_URL ableiten
- let origin = process.env.WEBAUTHN_ORIGIN || derived.origin
-
- // Sicherstellen, dass HTTPS-Origins KEINEN Port haben (auch wenn in ENV gesetzt)
- if (origin.startsWith('https://')) {
- try {
- const u = new URL(origin)
- // Port 443 oder kein Port = Standard, also Port weglassen
- if (u.port === '443' || !u.port) {
- origin = `https://${u.hostname}`
- } else {
- // Auch andere Ports bei HTTPS entfernen (nicht Standard für WebAuthn)
- origin = `https://${u.hostname}`
- }
- } catch {
- // Ignore
- }
- }
+ const origin = normalizeOrigin(process.env.WEBAUTHN_ORIGIN || derived.origin)
+ const origins = getAllowedOrigins(origin)
const requireUV = (process.env.WEBAUTHN_REQUIRE_UV || '').toLowerCase() === 'true'
@@ -57,13 +72,14 @@ export function getWebAuthnConfig() {
rpId,
rpName,
origin,
+ origins,
requireUV,
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
+ webauthnAllowedOriginsEnv: process.env.WEBAUTHN_ALLOWED_ORIGINS,
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL,
derivedOrigin: derived.origin
})
- return { rpId, rpName, origin, requireUV }
+ return { rpId, rpName, origin, origins, requireUV }
}
-
diff --git a/tests/auth-endpoints.spec.ts b/tests/auth-endpoints.spec.ts
index 81c4d0b..2782d6b 100644
--- a/tests/auth-endpoints.spec.ts
+++ b/tests/auth-endpoints.spec.ts
@@ -8,7 +8,13 @@ vi.mock('../server/utils/auth.js', () => {
writeUsers: vi.fn(),
verifyPassword: vi.fn(),
generateToken: vi.fn(),
+ generateAndroidAccessToken: vi.fn(),
createSession: vi.fn(),
+ createRefreshSession: vi.fn(),
+ rotateRefreshSession: vi.fn(),
+ revokeRefreshSession: vi.fn(),
+ revokeRefreshSessionsForUser: vi.fn(),
+ getUserById: vi.fn(),
hashPassword: vi.fn(),
verifyToken: vi.fn(),
deleteSession: vi.fn(),
@@ -53,11 +59,19 @@ vi.mock('nodemailer', () => {
}
})
+vi.mock('../server/utils/password-reset-log.js', () => ({
+ normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
+ maskResetEmail: vi.fn(email => `masked:${String(email || '').trim().toLowerCase()}`),
+ writePasswordResetLog: vi.fn().mockResolvedValue(undefined)
+}))
+
const authUtils = await import('../server/utils/auth.js')
const nodemailer = await import('nodemailer')
+const passwordResetLog = await import('../server/utils/password-reset-log.js')
import loginHandler from '../server/api/auth/login.post.js'
import logoutHandler from '../server/api/auth/logout.post.js'
+import refreshHandler from '../server/api/auth/refresh.post.js'
import registerHandler from '../server/api/auth/register.post.js'
import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
import statusHandler from '../server/api/auth/status.get.js'
@@ -110,6 +124,29 @@ describe('Auth API Endpoints', () => {
expect(authUtils.createSession).toHaveBeenCalledWith('1', 'jwt-token')
expect(authUtils.writeUsers).toHaveBeenCalled()
})
+
+ it('gibt Android-Clients ein Refresh-Token für eine Gerätesitzung zurück', async () => {
+ const event = createEvent()
+ const user = { id: '1', email: 'test@example.com', password: 'hash', roles: ['mitglied'], active: true }
+ mockSuccessReadBody({ email: user.email, password: 'plain', client: 'android', deviceName: 'Pixel' })
+ authUtils.readUsers.mockResolvedValue([user])
+ authUtils.verifyPassword.mockResolvedValue(true)
+ authUtils.createRefreshSession.mockResolvedValue({ session: { id: 'session-1' }, refreshToken: 'refresh-1' })
+ authUtils.generateAndroidAccessToken.mockReturnValue('access-1')
+ authUtils.writeUsers.mockResolvedValue(true)
+
+ const response = await loginHandler(event)
+
+ expect(authUtils.createRefreshSession).toHaveBeenCalledWith('1', 'Pixel')
+ expect(authUtils.generateAndroidAccessToken).toHaveBeenCalledWith(user, 'session-1')
+ expect(response).toMatchObject({
+ token: 'access-1',
+ accessToken: 'access-1',
+ refreshToken: 'refresh-1',
+ sessionId: 'session-1'
+ })
+ expect(authUtils.createSession).not.toHaveBeenCalled()
+ })
})
describe('POST /api/auth/logout', () => {
@@ -129,6 +166,64 @@ describe('Auth API Endpoints', () => {
await expect(logoutHandler(event)).rejects.toMatchObject({ statusCode: 500 })
})
+
+ it('widerruft beim Android-Logout das Refresh-Token', async () => {
+ const event = createEvent({ headers: { authorization: 'Bearer access-token' } })
+ mockSuccessReadBody({ refreshToken: 'refresh-token' })
+ authUtils.deleteSession.mockResolvedValue()
+ authUtils.revokeRefreshSession.mockResolvedValue(true)
+
+ const response = await logoutHandler(event)
+
+ expect(response.success).toBe(true)
+ expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('refresh-token')
+ })
+ })
+
+ describe('POST /api/auth/refresh', () => {
+ it('rotiert eine gültige Android-Sitzung und gibt ein neues Token-Paar zurück', async () => {
+ const event = createEvent()
+ mockSuccessReadBody({ refreshToken: 'old-refresh' })
+ const user = { id: '1', email: 'test@example.com', roles: ['mitglied'], active: true }
+ authUtils.rotateRefreshSession.mockResolvedValue({
+ status: 'rotated',
+ session: { id: 'session-2', userId: '1' },
+ refreshToken: 'new-refresh'
+ })
+ authUtils.getUserById.mockResolvedValue(user)
+ authUtils.generateAndroidAccessToken.mockReturnValue('new-access')
+
+ const response = await refreshHandler(event)
+
+ expect(authUtils.rotateRefreshSession).toHaveBeenCalledWith('old-refresh')
+ expect(response).toMatchObject({
+ accessToken: 'new-access',
+ refreshToken: 'new-refresh',
+ sessionId: 'session-2'
+ })
+ })
+
+ it('weist widerrufene oder erneut verwendete Refresh-Tokens zurück', async () => {
+ const event = createEvent()
+ mockSuccessReadBody({ refreshToken: 'used-refresh' })
+ authUtils.rotateRefreshSession.mockResolvedValue({ status: 'reused' })
+
+ await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ })
+
+ it('widerruft eine rotierte Sitzung, wenn der Benutzer nicht mehr aktiv ist', async () => {
+ const event = createEvent()
+ mockSuccessReadBody({ refreshToken: 'old-refresh' })
+ authUtils.rotateRefreshSession.mockResolvedValue({
+ status: 'rotated',
+ session: { id: 'session-2', userId: '1' },
+ refreshToken: 'new-refresh'
+ })
+ authUtils.getUserById.mockResolvedValue({ id: '1', active: false })
+
+ await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('new-refresh', 'inactive_or_missing_user')
+ })
})
describe('POST /api/auth/register', () => {
@@ -224,6 +319,64 @@ describe('Auth API Endpoints', () => {
const response = await resetPasswordHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled()
+ expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset')
+ expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
+ email: 'user@example.com',
+ step: 'mail_send',
+ status: 'completed'
+ }))
+ })
+
+ it('normalisiert Leerzeichen bei der Benutzersuche', async () => {
+ const event = createEvent()
+ const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' }
+ mockSuccessReadBody({ email: ' User@Example.com ' })
+ authUtils.readUsers.mockResolvedValue([user])
+ authUtils.hashPassword.mockResolvedValue('new-hash')
+ authUtils.writeUsers.mockResolvedValue(true)
+
+ await resetPasswordHandler(event)
+
+ expect(authUtils.writeUsers).toHaveBeenCalled()
+ expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ')
+ })
+
+ it('ändert das Passwort nicht, wenn SMTP nicht konfiguriert ist', async () => {
+ const event = createEvent()
+ mockSuccessReadBody({ email: 'user@example.com' })
+ authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
+ authUtils.hashPassword.mockResolvedValue('new-hash')
+ delete process.env.SMTP_USER
+ delete process.env.SMTP_PASS
+
+ const response = await resetPasswordHandler(event)
+
+ expect(response.success).toBe(true)
+ expect(authUtils.writeUsers).not.toHaveBeenCalled()
+ expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
+ step: 'mail_configuration',
+ status: 'failed',
+ reason: 'smtp_credentials_missing'
+ }))
+ })
+
+ it('protokolliert einen Mailfehler ohne das Passwort zu aktivieren', async () => {
+ const event = createEvent()
+ mockSuccessReadBody({ email: 'user@example.com' })
+ authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
+ authUtils.hashPassword.mockResolvedValue('new-hash')
+ nodemailer.default.createTransport.mockReturnValueOnce({
+ sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' }))
+ })
+
+ const response = await resetPasswordHandler(event)
+
+ expect(response.success).toBe(true)
+ expect(authUtils.writeUsers).not.toHaveBeenCalled()
+ expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
+ step: 'mail_send',
+ status: 'failed'
+ }))
})
})
diff --git a/tests/cms-users-endpoints.spec.ts b/tests/cms-users-endpoints.spec.ts
index 4d1e124..ef1cbbd 100644
--- a/tests/cms-users-endpoints.spec.ts
+++ b/tests/cms-users-endpoints.spec.ts
@@ -5,6 +5,7 @@ vi.mock('../server/utils/auth.js', () => ({
getUserFromToken: vi.fn(),
readUsers: vi.fn(),
writeUsers: vi.fn(),
+ revokeRefreshSessionsForUser: vi.fn(),
hasRole: vi.fn((user, role) => {
if (!user) return false
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
@@ -37,14 +38,23 @@ vi.mock('nodemailer', () => {
}
})
+vi.mock('../server/utils/password-reset-log.js', () => ({
+ fingerprintResetEmail: vi.fn(email => `fingerprint:${String(email || '').trim().toLowerCase()}`),
+ normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
+ PASSWORD_RESET_LOG_RETENTION_HOURS: 72,
+ readPasswordResetLogs: vi.fn()
+}))
+
const authUtils = await import('../server/utils/auth.js')
const nodemailer = await import('nodemailer')
+const passwordResetLog = await import('../server/utils/password-reset-log.js')
import usersListHandler from '../server/api/cms/users/list.get.js'
import usersApproveHandler from '../server/api/cms/users/approve.post.js'
import usersDeactivateHandler from '../server/api/cms/users/deactivate.post.js'
import usersRejectHandler from '../server/api/cms/users/reject.post.js'
import usersUpdateRoleHandler from '../server/api/cms/users/update-role.post.js'
+import passwordResetDiagnosticsHandler from '../server/api/cms/password-reset-diagnostics.get.js'
describe('CMS User Management Endpoints', () => {
beforeEach(() => {
@@ -138,6 +148,7 @@ describe('CMS User Management Endpoints', () => {
const response = await usersDeactivateHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled()
+ expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'account_deactivated')
})
})
@@ -184,4 +195,49 @@ describe('CMS User Management Endpoints', () => {
expect(authUtils.writeUsers).toHaveBeenCalled()
})
})
+
+ describe('GET /api/cms/password-reset-diagnostics', () => {
+ it('verweigert Zugriff für Vorstand ohne Admin-Rolle', async () => {
+ const event = createEvent({ cookies: { auth_token: 'token' } })
+ authUtils.getUserFromToken.mockResolvedValue({ id: 'vorstand', roles: ['vorstand'] })
+ authUtils.hasRole.mockReturnValue(false)
+
+ await expect(passwordResetDiagnosticsHandler(event)).rejects.toMatchObject({ statusCode: 403 })
+ })
+
+ it('findet Benutzer und gefilterte fehlgeschlagene Reset-Abläufe', async () => {
+ const event = adminEvent()
+ event.__query = { email: ' User@Example.com ', failedOnly: 'true' }
+ authUtils.hasRole.mockReturnValue(true)
+ authUtils.readUsers.mockResolvedValue([
+ { id: '1', email: 'user@example.com', name: 'User Beispiel', active: true }
+ ])
+ passwordResetLog.readPasswordResetLogs.mockResolvedValue([
+ {
+ requestId: 'r1',
+ ts: '2026-05-27T10:00:01.000Z',
+ emailMasked: 'us***@ex***.com',
+ emailFingerprint: 'fingerprint:user@example.com',
+ ip: '127.0.0.1',
+ step: 'request_completed',
+ status: 'no_account'
+ },
+ {
+ requestId: 'r2',
+ ts: '2026-05-27T10:00:02.000Z',
+ emailMasked: 'ot***@ex***.com',
+ emailFingerprint: 'fingerprint:other@example.com',
+ ip: '127.0.0.1',
+ step: 'request_completed',
+ status: 'failed'
+ }
+ ])
+
+ const response = await passwordResetDiagnosticsHandler(event)
+
+ expect(response.matchingUsers).toHaveLength(1)
+ expect(response.attempts).toHaveLength(1)
+ expect(response.attempts[0]).toMatchObject({ requestId: 'r1', failed: true })
+ })
+ })
})
diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts
index a329630..7481b25 100644
--- a/tests/config-profile-endpoints.spec.ts
+++ b/tests/config-profile-endpoints.spec.ts
@@ -10,6 +10,7 @@ vi.mock('../server/utils/auth.js', () => ({
writeUsers: vi.fn(),
verifyPassword: vi.fn(),
hashPassword: vi.fn(),
+ revokeRefreshSessionsForUser: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user
if (Array.isArray(user.roles)) return user
@@ -202,6 +203,7 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true)
expect(result.user.name).toBe('Max Neu')
expect(authUtils.writeUsers).toHaveBeenCalled()
+ expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled()
})
it('prüft aktuelles Passwort bei Passwortänderung', async () => {
@@ -236,6 +238,7 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true)
expect(authUtils.hashPassword).toHaveBeenCalledWith(updatedPassword)
+ expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_changed')
})
})
})
diff --git a/tests/password-reset-log.spec.ts b/tests/password-reset-log.spec.ts
new file mode 100644
index 0000000..dafb929
--- /dev/null
+++ b/tests/password-reset-log.spec.ts
@@ -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')
+ })
+})
diff --git a/tests/webauthn-config.spec.ts b/tests/webauthn-config.spec.ts
new file mode 100644
index 0000000..15f0ebf
--- /dev/null
+++ b/tests/webauthn-config.spec.ts
@@ -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'
+ ])
+ })
+})