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' + ]) + }) +})