@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,12 @@ data class MembershipResponse(
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
)
|
||||
data class LoginRequest(val email: String, val password: String)
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val client: String = "android",
|
||||
val deviceName: String = "Harheimer TC Android-App",
|
||||
)
|
||||
data class AuthUserDto(
|
||||
val id: String? = null,
|
||||
val email: String = "",
|
||||
@@ -116,9 +121,14 @@ data class AuthUserDto(
|
||||
data class LoginResponse(
|
||||
val success: Boolean = false,
|
||||
val token: String? = null,
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val user: AuthUserDto? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
data class RefreshRequest(val refreshToken: String)
|
||||
data class LogoutRequest(val refreshToken: String? = null)
|
||||
data class AuthStatusResponse(
|
||||
val isLoggedIn: Boolean = false,
|
||||
val user: AuthUserDto? = null,
|
||||
@@ -256,7 +266,10 @@ interface ApiService {
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("/api/auth/logout")
|
||||
suspend fun logout(): Response<Unit>
|
||||
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
|
||||
|
||||
@POST("/api/auth/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/status")
|
||||
suspend fun authStatus(): Response<AuthStatusResponse>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -14,31 +16,40 @@ import javax.inject.Singleton
|
||||
class LoginRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val response = api.login(LoginRequest(email.trim(), password))
|
||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = body.token?.takeIf(String::isNotBlank)
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setToken(token)
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
try {
|
||||
api.logout()
|
||||
api.logout(LogoutRequest(authRepository.getRefreshToken()))
|
||||
} finally {
|
||||
authRepository.setToken(null)
|
||||
authRepository.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(): Result<AuthStatusResponse> = runCatching {
|
||||
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
|
||||
val response = api.authStatus()
|
||||
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
|
||||
return@runCatching AuthStatusResponse()
|
||||
}
|
||||
|
||||
var response = api.authStatus()
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
val status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn) authRepository.setToken(null)
|
||||
var status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||
response = api.authStatus()
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
status = response.body() ?: AuthStatusResponse()
|
||||
}
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||
status
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -222,13 +222,34 @@
|
||||
Benutzer freischalten und verwalten
|
||||
</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="authStore.hasRole('admin')"
|
||||
to="/cms/passwort-reset-diagnose"
|
||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center group-hover:bg-red-600 transition-colors">
|
||||
<ShieldAlert
|
||||
:size="24"
|
||||
class="text-red-600 group-hover:text-white"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||
Passwort-Reset-Diagnose
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-gray-600">
|
||||
Fehlversuche und Versandabläufe prüfen
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail } from 'lucide-vue-next'
|
||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail, ShieldAlert } from 'lucide-vue-next'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
272
pages/cms/passwort-reset-diagnose.vue
Normal file
272
pages/cms/passwort-reset-diagnose.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="min-h-full py-16 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col gap-4 mb-8 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-4xl font-display font-bold text-gray-900">
|
||||
Passwort-Reset-Diagnose
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mt-4" />
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/cms"
|
||||
class="self-start px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Zurück zum CMS
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
|
||||
<form
|
||||
class="grid gap-4 lg:grid-cols-[minmax(240px,1fr)_auto_auto]"
|
||||
@submit.prevent="loadDiagnostics"
|
||||
>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 mb-2">E-Mail oder Name</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
placeholder="z.B. user@example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-primary-600"
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm font-medium text-gray-700">
|
||||
<input
|
||||
v-model="failedOnly"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
||||
>
|
||||
Nur Auffälligkeiten
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="self-end px-5 py-2 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Search :size="17" />
|
||||
Prüfen
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4 text-sm text-gray-600">
|
||||
Diagnoseeinträge werden nach {{ retentionHours }} Stunden automatisch gelöscht. E-Mail-Adressen sind im Log maskiert.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-sm text-red-800"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="searchTerm.trim()"
|
||||
class="bg-white border border-gray-200 rounded-lg shadow-sm mb-6 overflow-hidden"
|
||||
>
|
||||
<header class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Passende Benutzerkonten
|
||||
</h2>
|
||||
</header>
|
||||
<div
|
||||
v-if="matchingUsers.length === 0"
|
||||
class="px-6 py-5 text-sm text-gray-600"
|
||||
>
|
||||
Kein Login-Benutzer zur Suche gefunden.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-gray-200"
|
||||
>
|
||||
<div
|
||||
v-for="user in matchingUsers"
|
||||
:key="user.id"
|
||||
class="p-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">
|
||||
{{ user.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ user.email }} · {{ user.active ? 'Aktiv' : 'Nicht freigeschaltet' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 border border-primary-600 text-primary-700 hover:bg-primary-50 rounded-lg text-sm font-medium transition-colors"
|
||||
@click="searchUserLogs(user.email)"
|
||||
>
|
||||
Logs dieser Adresse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<header class="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Reset-Vorgänge
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
{{ attempts.length }} Einträge im gewählten Filter
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-gray-600 hover:text-primary-700 disabled:opacity-40"
|
||||
:disabled="loading"
|
||||
title="Aktualisieren"
|
||||
@click="loadDiagnostics"
|
||||
>
|
||||
<RefreshCw
|
||||
:size="19"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="!loading && attempts.length === 0"
|
||||
class="px-6 py-10 text-center text-gray-600"
|
||||
>
|
||||
Keine Diagnosevorgänge gefunden.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-gray-200"
|
||||
>
|
||||
<article
|
||||
v-for="attempt in attempts"
|
||||
:key="attempt.requestId"
|
||||
class="px-6 py-5"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-medium rounded"
|
||||
:class="attempt.failed ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'"
|
||||
>
|
||||
{{ attempt.failed ? 'Auffällig' : 'Abgeschlossen' }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ attempt.emailMasked || 'Keine Adresse' }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
{{ formatDate(attempt.startedAt) }} · IP {{ attempt.ip || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="mt-4 space-y-2">
|
||||
<li
|
||||
v-for="step in attempt.steps"
|
||||
:key="step.ts + step.step + step.status"
|
||||
class="grid gap-1 text-sm sm:grid-cols-[148px_170px_130px_1fr]"
|
||||
>
|
||||
<time class="text-gray-500">{{ formatTime(step.ts) }}</time>
|
||||
<span class="text-gray-800">{{ stepLabel(step.step) }}</span>
|
||||
<span :class="stepStatusClass(step.status)">{{ statusLabel(step.status) }}</span>
|
||||
<span class="text-gray-600">{{ reasonLabel(step.reason) || errorLabel(step) }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RefreshCw, Search } from 'lucide-vue-next'
|
||||
|
||||
const searchTerm = ref('')
|
||||
const failedOnly = ref(true)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const matchingUsers = ref([])
|
||||
const attempts = ref([])
|
||||
const retentionHours = ref(72)
|
||||
|
||||
const stepLabels = {
|
||||
request_received: 'Anfrage',
|
||||
request_validation: 'Validierung',
|
||||
rate_limit: 'Rate Limit',
|
||||
user_lookup: 'Benutzersuche',
|
||||
temporary_password: 'Temporäres Passwort',
|
||||
password_storage: 'Passwortspeicherung',
|
||||
session_revocation: 'Sitzungen',
|
||||
mail_configuration: 'Mail-Konfiguration',
|
||||
mail_send: 'Mail-Versand',
|
||||
request_completed: 'Abschluss'
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
started: 'Gestartet',
|
||||
checking: 'Prüfung',
|
||||
passed: 'OK',
|
||||
found: 'Gefunden',
|
||||
not_found: 'Nicht gefunden',
|
||||
generated: 'Erzeugt',
|
||||
completed: 'Erledigt',
|
||||
success: 'Erfolgreich',
|
||||
no_account: 'Kein Konto',
|
||||
failed: 'Fehlgeschlagen'
|
||||
}
|
||||
|
||||
const reasonLabels = {
|
||||
email_missing: 'E-Mail-Adresse fehlt',
|
||||
smtp_credentials_missing: 'SMTP-Zugangsdaten fehlen',
|
||||
write_failed: 'Passwort konnte nicht gespeichert werden'
|
||||
}
|
||||
|
||||
const formatDate = value => new Date(value).toLocaleString('de-DE')
|
||||
const formatTime = value => new Date(value).toLocaleTimeString('de-DE')
|
||||
const stepLabel = step => stepLabels[step] || step
|
||||
const statusLabel = status => statusLabels[status] || status
|
||||
const reasonLabel = reason => reasonLabels[reason] || reason || ''
|
||||
const errorLabel = step => [step.errorCode, step.errorMessage].filter(Boolean).join(': ')
|
||||
const stepStatusClass = status => (
|
||||
['failed', 'not_found', 'no_account'].includes(status)
|
||||
? 'text-red-700 font-medium'
|
||||
: 'text-gray-700'
|
||||
)
|
||||
|
||||
const loadDiagnostics = async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await $fetch('/api/cms/password-reset-diagnostics', {
|
||||
query: {
|
||||
email: searchTerm.value.trim() || undefined,
|
||||
failedOnly: String(failedOnly.value)
|
||||
}
|
||||
})
|
||||
matchingUsers.value = response.matchingUsers || []
|
||||
attempts.value = response.attempts || []
|
||||
retentionHours.value = response.retentionHours || 72
|
||||
} catch (_error) {
|
||||
errorMessage.value = 'Reset-Diagnose konnte nicht geladen werden.'
|
||||
matchingUsers.value = []
|
||||
attempts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchUserLogs = async email => {
|
||||
searchTerm.value = email
|
||||
await loadDiagnostics()
|
||||
}
|
||||
|
||||
onMounted(loadDiagnostics)
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Passwort-Reset-Diagnose - CMS - Harheimer TC'
|
||||
})
|
||||
</script>
|
||||
@@ -30,25 +30,10 @@
|
||||
required
|
||||
autocomplete="email"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||
:class="{ 'border-red-500': errorMessage }"
|
||||
placeholder="ihre-email@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="bg-red-50 border border-red-200 rounded-lg p-4"
|
||||
>
|
||||
<p class="text-sm text-red-800 flex items-center">
|
||||
<AlertCircle
|
||||
:size="18"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div
|
||||
v-if="successMessage"
|
||||
@@ -92,7 +77,7 @@
|
||||
<!-- Info Box -->
|
||||
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
|
||||
<p class="text-sm text-primary-800 text-center">
|
||||
Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.
|
||||
Wenn ein Konto mit der Adresse existiert, erhalten Sie eine E-Mail mit weiteren Anweisungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,31 +86,27 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { AlertCircle, Check, Loader2 } from 'lucide-vue-next'
|
||||
import { Check, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const email = ref('')
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const genericResponse = 'Wenn ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie eine E-Mail mit weiteren Anweisungen.'
|
||||
|
||||
const handleReset = async () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/auth/reset-password', {
|
||||
await $fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: { email: email.value }
|
||||
body: { email: email.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
successMessage.value = 'Eine E-Mail mit weiteren Anweisungen wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
email.value = ''
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error.data?.message || 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.'
|
||||
} catch (_error) {
|
||||
// Öffentliche Antwort bleibt identisch, unabhängig von Kontostatus oder technischem Fehler.
|
||||
} finally {
|
||||
successMessage.value = genericResponse
|
||||
email.value = ''
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
@@ -134,4 +115,3 @@ useHead({
|
||||
title: 'Passwort vergessen - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ async function readPackageVersion() {
|
||||
if (packageJson?.version) {
|
||||
return String(packageJson.version)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// Try next candidate path (e.g. .output runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -13,7 +13,9 @@ export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
}
|
||||
|
||||
if (getMethod(event) === 'OPTIONS') {
|
||||
// Provide a safe fallback for getMethod when linting/runtime doesn't expose it
|
||||
const _getMethod = typeof globalThis.getMethod === 'function' ? globalThis.getMethod : (e) => (e?.req?.method || e?.method || 'GET')
|
||||
if (_getMethod(event) === 'OPTIONS') {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import { getAuthCookieOptions } from '../../../utils/cookies.js'
|
||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||
import { getClientIp } from '../../../utils/rate-limit.js'
|
||||
|
||||
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||
const getRequestURL = globalThis.getRequestURL ?? ((e) => {
|
||||
try { return new URL(e?.req?.url, 'http://localhost') } catch { return { href: String(e?.req?.url || ''), pathname: String(e?.req?.url || '').split('?')[0] || '' } }
|
||||
})
|
||||
|
||||
function findUserByCredentialId(users, credentialId) {
|
||||
const cid = String(credentialId || '')
|
||||
for (const u of users) {
|
||||
@@ -65,7 +71,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const { origin, rpId, requireUV } = getWebAuthnConfig()
|
||||
const { origins, rpId, requireUV } = getWebAuthnConfig()
|
||||
|
||||
const authenticator = {
|
||||
credentialID: fromBase64Url(passkey.credentialId),
|
||||
@@ -74,14 +80,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 +130,3 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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.' }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||
|
||||
// Falls Nitro/H3-Globals fehlen: sichere Fallbacks auf globalThis setzen (vermeidet ESLint "no-redeclare")
|
||||
if (typeof globalThis.getMethod === 'undefined') {
|
||||
globalThis.getMethod = (e) => (e?.req?.method || e?.method || 'GET')
|
||||
}
|
||||
if (typeof globalThis.getHeader === 'undefined') {
|
||||
globalThis.getHeader = (e, name) => (e?.req?.headers?.[String(name).toLowerCase()] ?? null)
|
||||
}
|
||||
if (typeof globalThis.setHeader === 'undefined') {
|
||||
globalThis.setHeader = (e, name, value) => { try { if (e?.res?.setHeader) e.res.setHeader(name, value); else if (e?.node?.res?.setHeader) e.node.res.setHeader(name, value) } catch (err) { void 0 } }
|
||||
}
|
||||
if (typeof globalThis.readBody === 'undefined') {
|
||||
globalThis.readBody = async (e) => (e?.body ?? null)
|
||||
}
|
||||
import { clearRegistrationChallenge, getRegistrationChallenge } from '../../../utils/webauthn-challenges.js'
|
||||
import { toBase64Url } from '../../../utils/webauthn-encoding.js'
|
||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||
@@ -37,17 +51,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 +120,3 @@ export default defineEventHandler(async (event) => {
|
||||
return { success: true, message: 'Passkey hinzugefügt.' }
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server'
|
||||
|
||||
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||
import { setRegistrationChallenge } from '../../../utils/webauthn-challenges.js'
|
||||
|
||||
47
server/api/auth/refresh.post.js
Normal file
47
server/api/auth/refresh.post.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { generateAndroidAccessToken, getUserById, revokeRefreshSession, rotateRefreshSession } from '../../utils/auth.js'
|
||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ip = getClientIp(event)
|
||||
const body = await readBody(event)
|
||||
const refreshToken = body?.refreshToken
|
||||
|
||||
if (!refreshToken) {
|
||||
throw createError({ statusCode: 400, message: 'Refresh-Token fehlt' })
|
||||
}
|
||||
|
||||
assertRateLimit(event, {
|
||||
name: 'auth:refresh:ip',
|
||||
keyParts: [ip],
|
||||
windowMs: 10 * 60 * 1000,
|
||||
maxAttempts: 60,
|
||||
lockoutMs: 15 * 60 * 1000
|
||||
})
|
||||
|
||||
const rotated = await rotateRefreshSession(refreshToken)
|
||||
if (rotated.status !== 'rotated') {
|
||||
await registerRateLimitFailure(event, { name: 'auth:refresh:ip', keyParts: [ip], delayBaseMs: 100 })
|
||||
await writeAuditLog('auth.refresh.failed', { ip, reason: rotated.status })
|
||||
throw createError({ statusCode: 401, message: 'Sitzung ist nicht mehr gültig' })
|
||||
}
|
||||
|
||||
const user = await getUserById(rotated.session.userId)
|
||||
if (!user || user.active === false) {
|
||||
await revokeRefreshSession(rotated.refreshToken, 'inactive_or_missing_user')
|
||||
await writeAuditLog('auth.refresh.failed', { ip, userId: rotated.session.userId, reason: 'inactive_or_missing_user' })
|
||||
throw createError({ statusCode: 401, message: 'Sitzung ist nicht mehr gültig' })
|
||||
}
|
||||
|
||||
const accessToken = generateAndroidAccessToken(user, rotated.session.id)
|
||||
registerRateLimitSuccess(event, { name: 'auth:refresh:ip', keyParts: [ip] })
|
||||
await writeAuditLog('auth.refresh.success', { ip, userId: user.id, sessionId: rotated.session.id })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token: accessToken,
|
||||
accessToken,
|
||||
refreshToken: rotated.refreshToken,
|
||||
sessionId: rotated.session.id
|
||||
}
|
||||
})
|
||||
@@ -5,6 +5,9 @@ import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
|
||||
import { setPreRegistration } from '../../utils/webauthn-challenges.js'
|
||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||
|
||||
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||
|
||||
function isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || ''))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getPreRegistration } from '../../../utils/webauthn-challenges.js'
|
||||
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server'
|
||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
|
||||
|
||||
// Falls Nitro/H3-Globals fehlen: sichere Fallbacks auf globalThis setzen (vermeidet ESLint "no-redeclare")
|
||||
if (typeof globalThis.getHeader === 'undefined') {
|
||||
globalThis.getHeader = (e, name) => (e?.req?.headers?.[String(name).toLowerCase()] ?? null)
|
||||
}
|
||||
if (typeof globalThis.setHeader === 'undefined') {
|
||||
globalThis.setHeader = (e, name, value) => { try { if (e?.res?.setHeader) e.res.setHeader(name, value); else if (e?.node?.res?.setHeader) e.node.res.setHeader(name, value) } catch (err) { void 0 } }
|
||||
}
|
||||
if (typeof globalThis.setResponseStatus === 'undefined') {
|
||||
globalThis.setResponseStatus = (e, status) => { try { if (e?.res) e.res.statusCode = status; else if (e?.node?.res) e.node.res.statusCode = status } catch (err) { void 0 } }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const requestOrigin = getHeader(event, 'origin')
|
||||
const { origin: webauthnOrigin } = getWebAuthnConfig()
|
||||
@@ -34,6 +45,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// OPTIONS Preflight-Request: 204 No Content
|
||||
setResponseStatus(event, 204)
|
||||
globalThis.setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -9,6 +9,12 @@ import { writeAuditLog } from '../../utils/audit-log.js'
|
||||
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
||||
import { getClientIp } from '../../utils/rate-limit.js'
|
||||
|
||||
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||
const getRequestURL = globalThis.getRequestURL ?? ((e) => {
|
||||
try { return new URL(e?.req?.url, 'http://localhost') } catch { return { href: String(e?.req?.url || ''), pathname: String(e?.req?.url || '').split('?')[0] || '' } }
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const requestStart = Date.now()
|
||||
const requestOrigin = getHeader(event, 'origin')
|
||||
@@ -96,7 +102,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 +123,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 +144,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 +159,7 @@ export default defineEventHandler(async (event) => {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
requireUserVerification: requireUV
|
||||
})
|
||||
@@ -165,11 +169,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 +313,3 @@ export default defineEventHandler(async (event) => {
|
||||
message: 'Registrierung erfolgreich. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
94
server/api/cms/password-reset-diagnostics.get.js
Normal file
94
server/api/cms/password-reset-diagnostics.get.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { getUserFromToken, hasRole, readUsers } from '../../utils/auth.js'
|
||||
import {
|
||||
fingerprintResetEmail,
|
||||
normalizeResetEmail,
|
||||
PASSWORD_RESET_LOG_RETENTION_HOURS,
|
||||
readPasswordResetLogs
|
||||
} from '../../utils/password-reset-log.js'
|
||||
|
||||
function summarizeAttempts(entries) {
|
||||
const attemptsById = new Map()
|
||||
|
||||
for (const entry of [...entries].reverse()) {
|
||||
const attempt = attemptsById.get(entry.requestId) || {
|
||||
requestId: entry.requestId,
|
||||
startedAt: entry.ts,
|
||||
emailMasked: entry.emailMasked,
|
||||
ip: entry.ip,
|
||||
userId: entry.userId || null,
|
||||
steps: [],
|
||||
failed: false
|
||||
}
|
||||
|
||||
attempt.startedAt = attempt.startedAt || entry.ts
|
||||
attempt.userId = attempt.userId || entry.userId || null
|
||||
attempt.steps.push({
|
||||
ts: entry.ts,
|
||||
step: entry.step,
|
||||
status: entry.status,
|
||||
reason: entry.reason || null,
|
||||
errorCode: entry.errorCode || entry.error || null,
|
||||
errorMessage: entry.errorMessage || null
|
||||
})
|
||||
|
||||
if (
|
||||
entry.status === 'failed' ||
|
||||
entry.status === 'not_found' ||
|
||||
entry.status === 'no_account' ||
|
||||
entry.reason === 'smtp_credentials_missing'
|
||||
) {
|
||||
attempt.failed = true
|
||||
}
|
||||
|
||||
attemptsById.set(entry.requestId, attempt)
|
||||
}
|
||||
|
||||
return [...attemptsById.values()]
|
||||
.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)))
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const currentUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!currentUser || !hasRole(currentUser, 'admin')) {
|
||||
throw createError({ statusCode: 403, message: 'Zugriff verweigert' })
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const email = normalizeResetEmail(query.email)
|
||||
const failedOnly = query.failedOnly !== 'false'
|
||||
const users = await readUsers()
|
||||
const logs = await readPasswordResetLogs()
|
||||
const filteredLogs = email
|
||||
? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email))
|
||||
: logs
|
||||
const attempts = summarizeAttempts(filteredLogs)
|
||||
.filter(attempt => !failedOnly || attempt.failed)
|
||||
|
||||
let matchingUsers = []
|
||||
if (email) {
|
||||
const term = email.toLowerCase()
|
||||
matchingUsers = users
|
||||
.filter(user => {
|
||||
const userEmail = normalizeResetEmail(user.email)
|
||||
const name = String(user.name || '').toLowerCase()
|
||||
return userEmail.includes(term) || name.includes(term)
|
||||
})
|
||||
.slice(0, 20)
|
||||
.map(user => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
active: user.active !== false,
|
||||
lastLogin: user.lastLogin || null
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
retentionHours: PASSWORD_RESET_LOG_RETENTION_HOURS,
|
||||
searchedEmail: email,
|
||||
matchingUsers,
|
||||
attempts
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
*/
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const getRequestURL = globalThis.getRequestURL ?? ((e) => {
|
||||
try { return new URL(e?.req?.url, 'http://localhost') } catch { return { href: String(e?.req?.url || ''), pathname: String(e?.req?.url || '').split('?')[0] || '' } }
|
||||
})
|
||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||
const url = getRequestURL(event)
|
||||
const method = getMethod(event)
|
||||
const path = url.pathname
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
129
server/utils/password-reset-log.js
Normal file
129
server/utils/password-reset-log.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
|
||||
const RETENTION_MS = 72 * 60 * 60 * 1000
|
||||
|
||||
const LOG_FILE = getServerDataPath('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)
|
||||
@@ -28,5 +28,21 @@ export function getProjectPath(...segments) {
|
||||
}
|
||||
|
||||
export function getServerDataPath(...segments) {
|
||||
return getProjectPath('server', 'data', ...segments)
|
||||
// Validate segments: only allow simple filenames/dirnames (no path separators)
|
||||
const SEGMENT_RE = /^[a-zA-Z0-9._-]+$/
|
||||
for (const s of segments) {
|
||||
if (!SEGMENT_RE.test(String(s || ''))) {
|
||||
throw new Error(`Invalid data path segment: ${String(s)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const dataDir = getProjectPath('server', 'data')
|
||||
const candidate = path.join(dataDir, ...segments)
|
||||
const resolved = path.resolve(candidate)
|
||||
const resolvedDataDir = path.resolve(dataDir)
|
||||
if (!resolved.startsWith(resolvedDataDir + path.sep) && resolved !== resolvedDataDir) {
|
||||
throw new Error('Resolved data path is outside server/data')
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
74
tests/password-reset-log.spec.ts
Normal file
74
tests/password-reset-log.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const filesystem = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
appendFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: filesystem
|
||||
}))
|
||||
|
||||
import {
|
||||
cleanupPasswordResetLogs,
|
||||
fingerprintResetEmail,
|
||||
maskResetEmail,
|
||||
normalizeResetEmail,
|
||||
writePasswordResetLog
|
||||
} from '../server/utils/password-reset-log.js'
|
||||
|
||||
describe('Password reset diagnostic log privacy helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalisiert E-Mail-Adressen für Lookup und Korrelation', () => {
|
||||
expect(normalizeResetEmail(' User@Example.com ')).toBe('user@example.com')
|
||||
expect(fingerprintResetEmail(' User@Example.com ')).toBe(fingerprintResetEmail('user@example.com'))
|
||||
})
|
||||
|
||||
it('maskiert die E-Mail-Adresse für Diagnoseausgaben', () => {
|
||||
const masked = maskResetEmail('ag2608@googlemail.com')
|
||||
|
||||
expect(masked).toBe('ag***@go***.com')
|
||||
expect(masked).not.toContain('ag2608')
|
||||
expect(masked).not.toContain('googlemail')
|
||||
})
|
||||
|
||||
it('entfernt Diagnoseeinträge nach 72 Stunden', async () => {
|
||||
const now = Date.parse('2026-05-27T12:00:00.000Z')
|
||||
filesystem.readFile.mockResolvedValue([
|
||||
JSON.stringify({ ts: '2026-05-24T11:59:59.000Z', requestId: 'alt' }),
|
||||
JSON.stringify({ ts: '2026-05-24T12:00:00.000Z', requestId: 'neu' }),
|
||||
''
|
||||
].join('\n'))
|
||||
|
||||
const result = await cleanupPasswordResetLogs(now)
|
||||
|
||||
expect(result).toEqual({ retained: 1, removed: 1 })
|
||||
expect(filesystem.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
`${JSON.stringify({ ts: '2026-05-24T12:00:00.000Z', requestId: 'neu' })}\n`,
|
||||
'utf8'
|
||||
)
|
||||
})
|
||||
|
||||
it('schreibt bereinigte Fehlerdetails ohne E-Mail oder Credentials', async () => {
|
||||
await writePasswordResetLog({
|
||||
requestId: 'r1',
|
||||
email: 'ag2608@googlemail.com',
|
||||
step: 'mail_send',
|
||||
status: 'failed',
|
||||
error: Object.assign(new Error('Versand an ag2608@googlemail.com fehlgeschlagen password=geheim'), { code: 'EAUTH' })
|
||||
})
|
||||
|
||||
const payload = filesystem.appendFile.mock.calls[0][1]
|
||||
expect(payload).toContain('"errorCode":"EAUTH"')
|
||||
expect(payload).toContain('ag***@go***.com')
|
||||
expect(payload).toContain('password=[redacted]')
|
||||
expect(payload).not.toContain('ag2608@googlemail.com')
|
||||
expect(payload).not.toContain('geheim')
|
||||
})
|
||||
})
|
||||
54
tests/webauthn-config.spec.ts
Normal file
54
tests/webauthn-config.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getWebAuthnConfig } from '../server/utils/webauthn-config.js'
|
||||
|
||||
const envNames = [
|
||||
'NUXT_PUBLIC_BASE_URL',
|
||||
'WEBAUTHN_RP_ID',
|
||||
'WEBAUTHN_ORIGIN',
|
||||
'WEBAUTHN_ALLOWED_ORIGINS'
|
||||
]
|
||||
|
||||
const originalEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]]))
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of envNames) {
|
||||
const originalValue = originalEnv[name]
|
||||
if (originalValue === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = originalValue
|
||||
}
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('WebAuthn origin configuration', () => {
|
||||
it('accepts both production hosts when the public URL is the apex domain', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
process.env.NUXT_PUBLIC_BASE_URL = 'https://harheimertc.de'
|
||||
process.env.WEBAUTHN_RP_ID = 'harheimertc.de'
|
||||
delete process.env.WEBAUTHN_ORIGIN
|
||||
delete process.env.WEBAUTHN_ALLOWED_ORIGINS
|
||||
|
||||
const config = getWebAuthnConfig()
|
||||
|
||||
expect(config.origin).toBe('https://harheimertc.de')
|
||||
expect(config.origins).toEqual([
|
||||
'https://harheimertc.de',
|
||||
'https://www.harheimertc.de'
|
||||
])
|
||||
})
|
||||
|
||||
it('adds explicitly allowed origins without widening test installations implicitly', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
process.env.WEBAUTHN_ORIGIN = 'https://harheimertc.tsschulz.de'
|
||||
process.env.WEBAUTHN_ALLOWED_ORIGINS = ' https://alias.tsschulz.de/ , https://alias.tsschulz.de '
|
||||
|
||||
const config = getWebAuthnConfig()
|
||||
|
||||
expect(config.origins).toEqual([
|
||||
'https://harheimertc.tsschulz.de',
|
||||
'https://alias.tsschulz.de'
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user