feat(auth): implement Android refresh token handling and session management
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints. - Implemented new tests for login, logout, and refresh functionalities specific to Android clients. - Enhanced password reset logging with normalization and masking of email addresses. - Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs. - Introduced a new utility for managing password reset logs with retention policies. - Added tests for password reset log utilities to ensure proper functionality and privacy compliance. - Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
This commit is contained in:
@@ -98,16 +98,16 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
|
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
|
||||||
- [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||||
- [ ] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||||
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
||||||
- [x] Registrierung und Passwort-Reset
|
- [x] Registrierung und Passwort-Reset
|
||||||
- [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren
|
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
|
||||||
- [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
||||||
- [ ] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
||||||
- [ ] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||||
- [ ] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||||
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||||
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||||
@@ -127,14 +127,14 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] A. Auth (Login/Logout)
|
- [x] A. Auth (Login/Logout)
|
||||||
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
||||||
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
||||||
- [ ] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||||
- [x] C. Kontaktformular (absenden)
|
- [x] C. Kontaktformular (absenden)
|
||||||
- [ ] D. Bildanzeige + Caching
|
- [ ] D. Bildanzeige + Caching
|
||||||
- [x] E. Theme & Fonts
|
- [x] E. Theme & Fonts
|
||||||
|
|
||||||
6) Nächste Aktionen (sofort)
|
6) Nächste Aktionen (sofort)
|
||||||
- Dauerhaftes Android-Login umsetzen: Backend-Refresh-Sitzungen, Token-Rotation, serverseitigen Widerruf und App-Refresh-Flow ergänzen.
|
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
|
||||||
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
||||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||||
@@ -158,6 +158,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
||||||
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
||||||
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
||||||
|
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
|
||||||
|
|
||||||
8) Android-Testumgebungen
|
8) Android-Testumgebungen
|
||||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||||
@@ -167,7 +168,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||||
|
|
||||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||||
- Ausgangslage: Das Backend gibt derzeit ein sieben Tage gültiges JWT aus. Die App speichert es bereits verschlüsselt und sendet es als Bearer-Token. Die vorhandene serverseitige Sessiondatei wird beim Authentifizieren geschützter Requests derzeit nicht zur Widerrufsprüfung herangezogen.
|
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
|
||||||
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||||
- Token-Modell:
|
- Token-Modell:
|
||||||
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
||||||
|
|||||||
@@ -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 message: String? = null,
|
||||||
val downloadUrl: String? = null,
|
val downloadUrl: String? = null,
|
||||||
)
|
)
|
||||||
data class LoginRequest(val email: String, val password: String)
|
data class LoginRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val client: String = "android",
|
||||||
|
val deviceName: String = "Harheimer TC Android-App",
|
||||||
|
)
|
||||||
data class AuthUserDto(
|
data class AuthUserDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val email: String = "",
|
val email: String = "",
|
||||||
@@ -116,9 +121,14 @@ data class AuthUserDto(
|
|||||||
data class LoginResponse(
|
data class LoginResponse(
|
||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
val token: String? = null,
|
val token: String? = null,
|
||||||
|
val accessToken: String? = null,
|
||||||
|
val refreshToken: String? = null,
|
||||||
|
val sessionId: String? = null,
|
||||||
val user: AuthUserDto? = null,
|
val user: AuthUserDto? = null,
|
||||||
val role: String? = null,
|
val role: String? = null,
|
||||||
)
|
)
|
||||||
|
data class RefreshRequest(val refreshToken: String)
|
||||||
|
data class LogoutRequest(val refreshToken: String? = null)
|
||||||
data class AuthStatusResponse(
|
data class AuthStatusResponse(
|
||||||
val isLoggedIn: Boolean = false,
|
val isLoggedIn: Boolean = false,
|
||||||
val user: AuthUserDto? = null,
|
val user: AuthUserDto? = null,
|
||||||
@@ -256,7 +266,10 @@ interface ApiService {
|
|||||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||||
|
|
||||||
@POST("/api/auth/logout")
|
@POST("/api/auth/logout")
|
||||||
suspend fun logout(): Response<Unit>
|
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("/api/auth/refresh")
|
||||||
|
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
|
||||||
|
|
||||||
@GET("/api/auth/status")
|
@GET("/api/auth/status")
|
||||||
suspend fun authStatus(): Response<AuthStatusResponse>
|
suspend fun authStatus(): Response<AuthStatusResponse>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ object NetworkModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
|
||||||
val logging = HttpLoggingInterceptor()
|
val logging = HttpLoggingInterceptor()
|
||||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
logging.level = HttpLoggingInterceptor.Level.BASIC
|
||||||
val cookies = CookieManager().apply {
|
val cookies = CookieManager().apply {
|
||||||
@@ -36,6 +36,7 @@ object NetworkModule {
|
|||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.cookieJar(JavaNetCookieJar(cookies))
|
.cookieJar(JavaNetCookieJar(cookies))
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
|
.authenticator(accessTokenAuthenticator)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
interface AuthRepository {
|
interface AuthRepository {
|
||||||
fun getToken(): String?
|
fun getToken(): String?
|
||||||
fun setToken(token: String?)
|
fun getRefreshToken(): String?
|
||||||
|
fun getSessionId(): String?
|
||||||
|
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
|
||||||
|
fun clearSession()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
|
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
|
||||||
private val tokenKey = "auth_token"
|
private val tokenKey = "auth_token"
|
||||||
|
private val refreshTokenKey = "auth_refresh_token"
|
||||||
|
private val sessionIdKey = "auth_session_id"
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
@@ -25,9 +27,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
|
|||||||
|
|
||||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||||
|
|
||||||
override fun setToken(token: String?) {
|
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
|
||||||
|
|
||||||
|
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
|
||||||
|
|
||||||
|
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||||
preferences.edit().apply {
|
preferences.edit().apply {
|
||||||
if (token == null) remove(tokenKey) else putString(tokenKey, token)
|
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
|
||||||
|
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
|
||||||
|
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
|
||||||
}.apply()
|
}.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearSession() {
|
||||||
|
preferences.edit()
|
||||||
|
.remove(tokenKey)
|
||||||
|
.remove(refreshTokenKey)
|
||||||
|
.remove(sessionIdKey)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import de.harheimertc.data.LoginRequest
|
|||||||
import de.harheimertc.data.LoginResponse
|
import de.harheimertc.data.LoginResponse
|
||||||
import de.harheimertc.data.AuthStatusResponse
|
import de.harheimertc.data.AuthStatusResponse
|
||||||
import de.harheimertc.data.AuthMessageResponse
|
import de.harheimertc.data.AuthMessageResponse
|
||||||
|
import de.harheimertc.data.LogoutRequest
|
||||||
import de.harheimertc.data.RegistrationRequest
|
import de.harheimertc.data.RegistrationRequest
|
||||||
import de.harheimertc.data.ResetPasswordRequest
|
import de.harheimertc.data.ResetPasswordRequest
|
||||||
|
import de.harheimertc.data.SessionRefresher
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -14,31 +16,40 @@ import javax.inject.Singleton
|
|||||||
class LoginRepository @Inject constructor(
|
class LoginRepository @Inject constructor(
|
||||||
private val api: ApiService,
|
private val api: ApiService,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
|
private val sessionRefresher: SessionRefresher,
|
||||||
) {
|
) {
|
||||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||||
val response = api.login(LoginRequest(email.trim(), password))
|
val response = api.login(LoginRequest(email.trim(), password))
|
||||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
val token = body.token?.takeIf(String::isNotBlank)
|
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||||
authRepository.setToken(token)
|
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||||
body
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout(): Result<Unit> = runCatching {
|
suspend fun logout(): Result<Unit> = runCatching {
|
||||||
try {
|
try {
|
||||||
api.logout()
|
api.logout(LogoutRequest(authRepository.getRefreshToken()))
|
||||||
} finally {
|
} finally {
|
||||||
authRepository.setToken(null)
|
authRepository.clearSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun status(): Result<AuthStatusResponse> = runCatching {
|
suspend fun status(): Result<AuthStatusResponse> = runCatching {
|
||||||
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
|
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
|
||||||
val response = api.authStatus()
|
return@runCatching AuthStatusResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = api.authStatus()
|
||||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
val status = response.body() ?: AuthStatusResponse()
|
var status = response.body() ?: AuthStatusResponse()
|
||||||
if (!status.isLoggedIn) authRepository.setToken(null)
|
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||||
|
response = api.authStatus()
|
||||||
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
|
status = response.body() ?: AuthStatusResponse()
|
||||||
|
}
|
||||||
|
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||||
status
|
status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
|||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return navigateTo('/mitgliederbereich')
|
return navigateTo('/mitgliederbereich')
|
||||||
}
|
}
|
||||||
|
} else if (to.path.startsWith('/cms/passwort-reset-diagnose')) {
|
||||||
|
if (!roles.includes('admin')) {
|
||||||
|
return navigateTo('/cms')
|
||||||
|
}
|
||||||
} else if (to.path.startsWith('/cms/kontaktanfragen')) {
|
} else if (to.path.startsWith('/cms/kontaktanfragen')) {
|
||||||
if (!canAccessContactRequests) {
|
if (!canAccessContactRequests) {
|
||||||
return navigateTo('/mitgliederbereich')
|
return navigateTo('/mitgliederbereich')
|
||||||
@@ -42,4 +46,3 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
|||||||
return navigateTo('/login?redirect=' + to.path)
|
return navigateTo('/login?redirect=' + to.path)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -222,13 +222,34 @@
|
|||||||
Benutzer freischalten und verwalten
|
Benutzer freischalten und verwalten
|
||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="authStore.hasRole('admin')"
|
||||||
|
to="/cms/passwort-reset-diagnose"
|
||||||
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||||
|
>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center group-hover:bg-red-600 transition-colors">
|
||||||
|
<ShieldAlert
|
||||||
|
:size="24"
|
||||||
|
class="text-red-600 group-hover:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||||
|
Passwort-Reset-Diagnose
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Fehlversuche und Versandabläufe prüfen
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail } from 'lucide-vue-next'
|
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail, ShieldAlert } from 'lucide-vue-next'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|||||||
269
pages/cms/passwort-reset-diagnose.vue
Normal file
269
pages/cms/passwort-reset-diagnose.vue
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full py-16 bg-gray-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex flex-col gap-4 mb-8 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-display font-bold text-gray-900">
|
||||||
|
Passwort-Reset-Diagnose
|
||||||
|
</h1>
|
||||||
|
<div class="w-24 h-1 bg-primary-600 mt-4" />
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/cms"
|
||||||
|
class="self-start px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Zurück zum CMS
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
|
||||||
|
<form
|
||||||
|
class="grid gap-4 lg:grid-cols-[minmax(240px,1fr)_auto_auto]"
|
||||||
|
@submit.prevent="loadDiagnostics"
|
||||||
|
>
|
||||||
|
<label class="block">
|
||||||
|
<span class="block text-sm font-medium text-gray-700 mb-2">E-Mail oder Name</span>
|
||||||
|
<input
|
||||||
|
v-model="searchTerm"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="z.B. user@example.com"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-primary-600"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-end gap-2 pb-2 text-sm font-medium text-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="failedOnly"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
Nur Auffälligkeiten
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="self-end px-5 py-2 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Search :size="17" />
|
||||||
|
Prüfen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-sm text-gray-600">
|
||||||
|
Diagnoseeinträge werden nach {{ retentionHours }} Stunden automatisch gelöscht. E-Mail-Adressen sind im Log maskiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-sm text-red-800"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="searchTerm.trim()"
|
||||||
|
class="bg-white border border-gray-200 rounded-lg shadow-sm mb-6 overflow-hidden"
|
||||||
|
>
|
||||||
|
<header class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">
|
||||||
|
Passende Benutzerkonten
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
v-if="matchingUsers.length === 0"
|
||||||
|
class="px-6 py-5 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
Kein Login-Benutzer zur Suche gefunden.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="divide-y divide-gray-200"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="user in matchingUsers"
|
||||||
|
:key="user.id"
|
||||||
|
class="p-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">
|
||||||
|
{{ user.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{{ user.email }} · {{ user.active ? 'Aktiv' : 'Nicht freigeschaltet' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 border border-primary-600 text-primary-700 hover:bg-primary-50 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
@click="searchUserLogs(user.email)"
|
||||||
|
>
|
||||||
|
Logs dieser Adresse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<header class="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">
|
||||||
|
Reset-Vorgänge
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
{{ attempts.length }} Einträge im gewählten Filter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 text-gray-600 hover:text-primary-700 disabled:opacity-40"
|
||||||
|
:disabled="loading"
|
||||||
|
title="Aktualisieren"
|
||||||
|
@click="loadDiagnostics"
|
||||||
|
>
|
||||||
|
<RefreshCw :size="19" :class="{ 'animate-spin': loading }" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!loading && attempts.length === 0"
|
||||||
|
class="px-6 py-10 text-center text-gray-600"
|
||||||
|
>
|
||||||
|
Keine Diagnosevorgänge gefunden.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="divide-y divide-gray-200"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="attempt in attempts"
|
||||||
|
:key="attempt.requestId"
|
||||||
|
class="px-6 py-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex px-2 py-1 text-xs font-medium rounded"
|
||||||
|
:class="attempt.failed ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'"
|
||||||
|
>
|
||||||
|
{{ attempt.failed ? 'Auffällig' : 'Abgeschlossen' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{ attempt.emailMasked || 'Keine Adresse' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
{{ formatDate(attempt.startedAt) }} · IP {{ attempt.ip || '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="mt-4 space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="step in attempt.steps"
|
||||||
|
:key="step.ts + step.step + step.status"
|
||||||
|
class="grid gap-1 text-sm sm:grid-cols-[148px_170px_130px_1fr]"
|
||||||
|
>
|
||||||
|
<time class="text-gray-500">{{ formatTime(step.ts) }}</time>
|
||||||
|
<span class="text-gray-800">{{ stepLabel(step.step) }}</span>
|
||||||
|
<span :class="stepStatusClass(step.status)">{{ statusLabel(step.status) }}</span>
|
||||||
|
<span class="text-gray-600">{{ reasonLabel(step.reason) || errorLabel(step) }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RefreshCw, Search } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const searchTerm = ref('')
|
||||||
|
const failedOnly = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const matchingUsers = ref([])
|
||||||
|
const attempts = ref([])
|
||||||
|
const retentionHours = ref(72)
|
||||||
|
|
||||||
|
const stepLabels = {
|
||||||
|
request_received: 'Anfrage',
|
||||||
|
request_validation: 'Validierung',
|
||||||
|
rate_limit: 'Rate Limit',
|
||||||
|
user_lookup: 'Benutzersuche',
|
||||||
|
temporary_password: 'Temporäres Passwort',
|
||||||
|
password_storage: 'Passwortspeicherung',
|
||||||
|
session_revocation: 'Sitzungen',
|
||||||
|
mail_configuration: 'Mail-Konfiguration',
|
||||||
|
mail_send: 'Mail-Versand',
|
||||||
|
request_completed: 'Abschluss'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
started: 'Gestartet',
|
||||||
|
checking: 'Prüfung',
|
||||||
|
passed: 'OK',
|
||||||
|
found: 'Gefunden',
|
||||||
|
not_found: 'Nicht gefunden',
|
||||||
|
generated: 'Erzeugt',
|
||||||
|
completed: 'Erledigt',
|
||||||
|
success: 'Erfolgreich',
|
||||||
|
no_account: 'Kein Konto',
|
||||||
|
failed: 'Fehlgeschlagen'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonLabels = {
|
||||||
|
email_missing: 'E-Mail-Adresse fehlt',
|
||||||
|
smtp_credentials_missing: 'SMTP-Zugangsdaten fehlen',
|
||||||
|
write_failed: 'Passwort konnte nicht gespeichert werden'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = value => new Date(value).toLocaleString('de-DE')
|
||||||
|
const formatTime = value => new Date(value).toLocaleTimeString('de-DE')
|
||||||
|
const stepLabel = step => stepLabels[step] || step
|
||||||
|
const statusLabel = status => statusLabels[status] || status
|
||||||
|
const reasonLabel = reason => reasonLabels[reason] || reason || ''
|
||||||
|
const errorLabel = step => [step.errorCode, step.errorMessage].filter(Boolean).join(': ')
|
||||||
|
const stepStatusClass = status => (
|
||||||
|
['failed', 'not_found', 'no_account'].includes(status)
|
||||||
|
? 'text-red-700 font-medium'
|
||||||
|
: 'text-gray-700'
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadDiagnostics = async () => {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/cms/password-reset-diagnostics', {
|
||||||
|
query: {
|
||||||
|
email: searchTerm.value.trim() || undefined,
|
||||||
|
failedOnly: String(failedOnly.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
matchingUsers.value = response.matchingUsers || []
|
||||||
|
attempts.value = response.attempts || []
|
||||||
|
retentionHours.value = response.retentionHours || 72
|
||||||
|
} catch (_error) {
|
||||||
|
errorMessage.value = 'Reset-Diagnose konnte nicht geladen werden.'
|
||||||
|
matchingUsers.value = []
|
||||||
|
attempts.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchUserLogs = async email => {
|
||||||
|
searchTerm.value = email
|
||||||
|
await loadDiagnostics()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDiagnostics)
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Passwort-Reset-Diagnose - CMS - Harheimer TC'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -30,25 +30,10 @@
|
|||||||
required
|
required
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||||
:class="{ 'border-red-500': errorMessage }"
|
|
||||||
placeholder="ihre-email@example.com"
|
placeholder="ihre-email@example.com"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
|
||||||
<div
|
|
||||||
v-if="errorMessage"
|
|
||||||
class="bg-red-50 border border-red-200 rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-red-800 flex items-center">
|
|
||||||
<AlertCircle
|
|
||||||
:size="18"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
<div
|
<div
|
||||||
v-if="successMessage"
|
v-if="successMessage"
|
||||||
@@ -92,7 +77,7 @@
|
|||||||
<!-- Info Box -->
|
<!-- Info Box -->
|
||||||
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
|
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
|
||||||
<p class="text-sm text-primary-800 text-center">
|
<p class="text-sm text-primary-800 text-center">
|
||||||
Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.
|
Wenn ein Konto mit der Adresse existiert, erhalten Sie eine E-Mail mit weiteren Anweisungen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,31 +86,27 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { AlertCircle, Check, Loader2 } from 'lucide-vue-next'
|
import { Check, Loader2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const errorMessage = ref('')
|
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
|
const genericResponse = 'Wenn ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie eine E-Mail mit weiteren Anweisungen.'
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
errorMessage.value = ''
|
|
||||||
successMessage.value = ''
|
successMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/api/auth/reset-password', {
|
await $fetch('/api/auth/reset-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email: email.value }
|
body: { email: email.value.trim() }
|
||||||
})
|
})
|
||||||
|
} catch (_error) {
|
||||||
if (response.success) {
|
// Öffentliche Antwort bleibt identisch, unabhängig von Kontostatus oder technischem Fehler.
|
||||||
successMessage.value = 'Eine E-Mail mit weiteren Anweisungen wurde an Ihre E-Mail-Adresse gesendet.'
|
|
||||||
email.value = ''
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error.data?.message || 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.'
|
|
||||||
} finally {
|
} finally {
|
||||||
|
successMessage.value = genericResponse
|
||||||
|
email.value = ''
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,4 +115,3 @@ useHead({
|
|||||||
title: 'Passwort vergessen - Harheimer TC',
|
title: 'Passwort vergessen - Harheimer TC',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readUsers, writeUsers, verifyPassword, generateToken, createSession, migrateUserRoles } from '../../utils/auth.js'
|
import { readUsers, writeUsers, verifyPassword, generateToken, generateAndroidAccessToken, createSession, createRefreshSession, migrateUserRoles } from '../../utils/auth.js'
|
||||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
||||||
import { getAuthCookieOptions } from '../../utils/cookies.js'
|
import { getAuthCookieOptions } from '../../utils/cookies.js'
|
||||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||||
@@ -7,6 +7,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
try {
|
try {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { email, password } = body
|
const { email, password } = body
|
||||||
|
const isAndroidClient = body.client === 'android'
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -72,11 +73,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
registerRateLimitSuccess(event, { name: 'auth:login:ip', keyParts: [ip] })
|
registerRateLimitSuccess(event, { name: 'auth:login:ip', keyParts: [ip] })
|
||||||
registerRateLimitSuccess(event, { name: 'auth:login:account', keyParts: [emailKey] })
|
registerRateLimitSuccess(event, { name: 'auth:login:account', keyParts: [emailKey] })
|
||||||
|
|
||||||
// Generate token
|
let token
|
||||||
const token = generateToken(user)
|
let refreshSession = null
|
||||||
|
if (isAndroidClient) {
|
||||||
// Create session
|
refreshSession = await createRefreshSession(user.id, body.deviceName)
|
||||||
|
token = generateAndroidAccessToken(user, refreshSession.session.id)
|
||||||
|
} else {
|
||||||
|
token = generateToken(user)
|
||||||
await createSession(user.id, token)
|
await createSession(user.id, token)
|
||||||
|
}
|
||||||
|
|
||||||
await writeAuditLog('auth.login.success', { ip, email: emailKey, userId: user.id })
|
await writeAuditLog('auth.login.success', { ip, email: emailKey, userId: user.id })
|
||||||
|
|
||||||
@@ -85,10 +90,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
const updatedUsers = users.map(u => u.id === user.id ? user : u)
|
const updatedUsers = users.map(u => u.id === user.id ? user : u)
|
||||||
await writeUsers(updatedUsers)
|
await writeUsers(updatedUsers)
|
||||||
|
|
||||||
// Set cookie
|
if (isAndroidClient) {
|
||||||
|
deleteCookie(event, 'auth_token')
|
||||||
|
} else {
|
||||||
setCookie(event, 'auth_token', token, {
|
setCookie(event, 'auth_token', token, {
|
||||||
...getAuthCookieOptions()
|
...getAuthCookieOptions()
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Migriere Rollen falls nötig
|
// Migriere Rollen falls nötig
|
||||||
const migratedUser = migrateUserRoles({ ...user })
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
@@ -98,6 +106,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
token: token, // Token auch im Body für externe API-Clients
|
token: token, // Token auch im Body für externe API-Clients
|
||||||
|
accessToken: isAndroidClient ? token : undefined,
|
||||||
|
refreshToken: refreshSession?.refreshToken,
|
||||||
|
sessionId: refreshSession?.session.id,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -112,4 +123,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { deleteSession } from '../../utils/auth.js'
|
import { deleteSession, revokeRefreshSession } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const refreshToken = body?.refreshToken
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
await deleteSession(token)
|
await deleteSession(token)
|
||||||
}
|
}
|
||||||
|
if (refreshToken) {
|
||||||
|
await revokeRefreshSession(refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete cookie
|
// Delete cookie
|
||||||
deleteCookie(event, 'auth_token')
|
deleteCookie(event, 'auth_token')
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { origin, rpId, requireUV } = getWebAuthnConfig()
|
const { origins, rpId, requireUV } = getWebAuthnConfig()
|
||||||
|
|
||||||
const authenticator = {
|
const authenticator = {
|
||||||
credentialID: fromBase64Url(passkey.credentialId),
|
credentialID: fromBase64Url(passkey.credentialId),
|
||||||
@@ -74,14 +74,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
transports: passkey.transports || undefined
|
transports: passkey.transports || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const verification = await verifyAuthenticationResponse({
|
let verification
|
||||||
|
try {
|
||||||
|
verification = await verifyAuthenticationResponse({
|
||||||
response,
|
response,
|
||||||
expectedChallenge: challenge,
|
expectedChallenge: challenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origins,
|
||||||
expectedRPID: rpId,
|
expectedRPID: rpId,
|
||||||
authenticator,
|
authenticator,
|
||||||
requireUserVerification: requireUV
|
requireUserVerification: requireUV
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_error' })
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Passkey-Login fehlgeschlagen' })
|
||||||
|
}
|
||||||
|
|
||||||
if (!verification.verified) {
|
if (!verification.verified) {
|
||||||
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_failed' })
|
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_failed' })
|
||||||
@@ -118,4 +124,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,15 +62,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { origin, rpId, requireUV } = getWebAuthnConfig()
|
const { origins, rpId, requireUV } = getWebAuthnConfig()
|
||||||
|
|
||||||
const verification = await verifyRegistrationResponse({
|
let verification
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
response,
|
response,
|
||||||
expectedChallenge: challenge,
|
expectedChallenge: challenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origins,
|
||||||
expectedRPID: rpId,
|
expectedRPID: rpId,
|
||||||
requireUserVerification: requireUV
|
requireUserVerification: requireUV
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
await writeAuditLog('auth.passkey.recovery.complete.failed', { ip, reason: 'verification_error', userId })
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
|
||||||
|
}
|
||||||
|
|
||||||
const { verified, registrationInfo } = verification
|
const { verified, registrationInfo } = verification
|
||||||
if (!verified || !registrationInfo) {
|
if (!verified || !registrationInfo) {
|
||||||
@@ -117,4 +123,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
await writeAuditLog('auth.passkey.recovery.complete.success', { ip, userId: user.id })
|
await writeAuditLog('auth.passkey.recovery.complete.success', { ip, userId: user.id })
|
||||||
return { success: true, message: 'Passkey hinzugefügt. Sie können sich jetzt mit dem neuen Passkey anmelden.' }
|
return { success: true, message: 'Passkey hinzugefügt. Sie können sich jetzt mit dem neuen Passkey anmelden.' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,17 +37,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, statusMessage: 'Registrierungs-Session abgelaufen. Bitte erneut versuchen.' })
|
throw createError({ statusCode: 400, statusMessage: 'Registrierungs-Session abgelaufen. Bitte erneut versuchen.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { origin, rpId, requireUV } = getWebAuthnConfig()
|
const { origins, rpId, requireUV } = getWebAuthnConfig()
|
||||||
|
|
||||||
let verification
|
let verification
|
||||||
try {
|
try {
|
||||||
verification = await verifyRegistrationResponse({
|
verification = await verifyRegistrationResponse({
|
||||||
response,
|
response,
|
||||||
expectedChallenge,
|
expectedChallenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origins,
|
||||||
expectedRPID: rpId,
|
expectedRPID: rpId,
|
||||||
requireUserVerification: requireUV
|
requireUserVerification: requireUV
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
await writeAuditLog('auth.passkey.registration.failed', { userId: user.id, reason: 'verification_error' })
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
|
||||||
} finally {
|
} finally {
|
||||||
clearRegistrationChallenge(user.id)
|
clearRegistrationChallenge(user.id)
|
||||||
}
|
}
|
||||||
@@ -103,4 +106,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
return { success: true, message: 'Passkey hinzugefügt.' }
|
return { success: true, message: 'Passkey hinzugefügt.' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -96,7 +96,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 409, message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits' })
|
throw createError({ statusCode: 409, message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { origin, rpId, requireUV } = getWebAuthnConfig()
|
const { origin, origins, rpId, requireUV } = getWebAuthnConfig()
|
||||||
|
|
||||||
// Debug: Prüfe die tatsächliche Origin aus der Response
|
// Debug: Prüfe die tatsächliche Origin aus der Response
|
||||||
const clientData = response?.response?.clientDataJSON
|
const clientData = response?.response?.clientDataJSON
|
||||||
@@ -117,13 +117,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DEBUG] WebAuthn config for verification', {
|
console.log('[DEBUG] WebAuthn config for verification', {
|
||||||
expectedOrigin: origin,
|
expectedOrigins: origins,
|
||||||
expectedOriginType: typeof origin,
|
|
||||||
expectedOriginLength: origin?.length,
|
|
||||||
actualOriginFromResponse: actualOrigin,
|
actualOriginFromResponse: actualOrigin,
|
||||||
rpId,
|
rpId,
|
||||||
requireUV,
|
requireUV,
|
||||||
originMatch: origin === actualOrigin,
|
originMatch: origins.includes(actualOrigin),
|
||||||
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
|
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
|
||||||
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL
|
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL
|
||||||
})
|
})
|
||||||
@@ -140,7 +138,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
console.log('[DEBUG] Verifying registration response...')
|
console.log('[DEBUG] Verifying registration response...')
|
||||||
console.log('[DEBUG] Verification parameters', {
|
console.log('[DEBUG] Verification parameters', {
|
||||||
expectedOrigin: origin,
|
expectedOrigins: origins,
|
||||||
expectedRPID: rpId,
|
expectedRPID: rpId,
|
||||||
hasChallenge: !!challenge,
|
hasChallenge: !!challenge,
|
||||||
challengeLength: challenge?.length,
|
challengeLength: challenge?.length,
|
||||||
@@ -155,7 +153,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
verification = await verifyRegistrationResponse({
|
verification = await verifyRegistrationResponse({
|
||||||
response,
|
response,
|
||||||
expectedChallenge: challenge,
|
expectedChallenge: challenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origins,
|
||||||
expectedRPID: rpId,
|
expectedRPID: rpId,
|
||||||
requireUserVerification: requireUV
|
requireUserVerification: requireUV
|
||||||
})
|
})
|
||||||
@@ -165,11 +163,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
error: verifyError,
|
error: verifyError,
|
||||||
message: verifyError?.message,
|
message: verifyError?.message,
|
||||||
cause: verifyError?.cause?.message,
|
cause: verifyError?.cause?.message,
|
||||||
expectedOrigin: origin,
|
expectedOrigins: origins,
|
||||||
actualOriginFromResponse: actualOrigin,
|
actualOriginFromResponse: actualOrigin,
|
||||||
stack: verifyError?.stack
|
stack: verifyError?.stack
|
||||||
})
|
})
|
||||||
throw verifyError
|
await writeAuditLog('auth.passkey.prereg.failed', { email, reason: 'verification_error' })
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const verifyDuration = Date.now() - verifyStart
|
const verifyDuration = Date.now() - verifyStart
|
||||||
@@ -308,4 +307,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
message: 'Registrierung erfolgreich. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
|
message: 'Registrierung erfolgreich. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
import { readUsers, hashPassword, writeUsers } from '../../utils/auth.js'
|
import { readUsers, hashPassword, writeUsers, revokeRefreshSessionsForUser } from '../../utils/auth.js'
|
||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
||||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||||
|
import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
let emailKey = ''
|
||||||
|
let ip = ''
|
||||||
|
|
||||||
|
const logStep = async (step, status, detail = {}) => {
|
||||||
|
try {
|
||||||
|
await writePasswordResetLog({ requestId, email: emailKey, ip, step, status, ...detail })
|
||||||
|
} catch (logError) {
|
||||||
|
console.error('Password-Reset-Diagnoselog-Fehler:', logError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { email } = body
|
const { email } = body
|
||||||
|
|
||||||
if (!email) {
|
emailKey = normalizeResetEmail(email)
|
||||||
|
ip = getClientIp(event)
|
||||||
|
await logStep('request_received', 'started')
|
||||||
|
|
||||||
|
if (!emailKey) {
|
||||||
|
await logStep('request_validation', 'failed', { reason: 'email_missing' })
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'E-Mail-Adresse ist erforderlich'
|
message: 'E-Mail-Adresse ist erforderlich'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = getClientIp(event)
|
|
||||||
const emailKey = String(email || '').trim().toLowerCase()
|
|
||||||
|
|
||||||
// Rate Limiting (IP + Account)
|
// Rate Limiting (IP + Account)
|
||||||
|
await logStep('rate_limit', 'checking')
|
||||||
|
try {
|
||||||
assertRateLimit(event, {
|
assertRateLimit(event, {
|
||||||
name: 'auth:reset:ip',
|
name: 'auth:reset:ip',
|
||||||
keyParts: [ip],
|
keyParts: [ip],
|
||||||
@@ -34,44 +51,52 @@ export default defineEventHandler(async (event) => {
|
|||||||
maxAttempts: 5,
|
maxAttempts: 5,
|
||||||
lockoutMs: 60 * 60 * 1000
|
lockoutMs: 60 * 60 * 1000
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
await logStep('rate_limit', 'failed', { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await logStep('rate_limit', 'passed')
|
||||||
|
|
||||||
// Find user
|
// Find user
|
||||||
const users = await readUsers()
|
let users
|
||||||
const user = users.find(u => u.email.toLowerCase() === email.toLowerCase())
|
try {
|
||||||
|
users = await readUsers()
|
||||||
|
} catch (error) {
|
||||||
|
await logStep('user_lookup', 'failed', { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const user = users.find(u => normalizeResetEmail(u.email) === emailKey)
|
||||||
|
|
||||||
// Always return success (security: don't reveal if email exists)
|
// Always return success (security: don't reveal if email exists)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
await logStep('user_lookup', 'not_found')
|
||||||
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
|
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
|
||||||
await registerRateLimitFailure(event, { name: 'auth:reset:account', keyParts: [emailKey] })
|
await registerRateLimitFailure(event, { name: 'auth:reset:account', keyParts: [emailKey] })
|
||||||
await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: false })
|
await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: false, requestId })
|
||||||
|
await logStep('request_completed', 'no_account')
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
|
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await logStep('user_lookup', 'found', { userId: user.id })
|
||||||
|
|
||||||
// Generate temporary password
|
// Generate temporary password
|
||||||
const tempPassword = crypto.randomBytes(8).toString('hex')
|
const tempPassword = crypto.randomBytes(8).toString('hex')
|
||||||
const hashedPassword = await hashPassword(tempPassword)
|
const hashedPassword = await hashPassword(tempPassword)
|
||||||
|
await logStep('temporary_password', 'generated', { userId: user.id })
|
||||||
// Update user password
|
|
||||||
user.password = hashedPassword
|
|
||||||
user.passwordResetRequired = true
|
|
||||||
const updatedUsers = users.map(u => u.id === user.id ? user : u)
|
|
||||||
await writeUsers(updatedUsers)
|
|
||||||
|
|
||||||
registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
|
|
||||||
await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: true, userId: user.id })
|
|
||||||
|
|
||||||
// Send email with temporary password
|
// Send email with temporary password
|
||||||
const smtpUser = process.env.SMTP_USER
|
const smtpUser = process.env.SMTP_USER
|
||||||
const smtpPass = process.env.SMTP_PASS
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
if (!smtpUser || !smtpPass) {
|
if (!smtpUser || !smtpPass) {
|
||||||
|
await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' })
|
||||||
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
|
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
|
||||||
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
||||||
// Continue without sending email - security: don't reveal if email exists
|
throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt')
|
||||||
} else {
|
} else {
|
||||||
|
await logStep('mail_configuration', 'passed', { userId: user.id })
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
@@ -99,15 +124,50 @@ export default defineEventHandler(async (event) => {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await logStep('mail_send', 'started', { userId: user.id })
|
||||||
|
try {
|
||||||
await transporter.sendMail(mailOptions)
|
await transporter.sendMail(mailOptions)
|
||||||
|
} catch (error) {
|
||||||
|
await logStep('mail_send', 'failed', { userId: user.id, error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await logStep('mail_send', 'completed', { userId: user.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Erst nach erfolgreichem Versand das zugesandte Passwort aktivieren.
|
||||||
|
user.password = hashedPassword
|
||||||
|
user.passwordResetRequired = true
|
||||||
|
const updatedUsers = users.map(u => u.id === user.id ? user : u)
|
||||||
|
let passwordStored = false
|
||||||
|
try {
|
||||||
|
passwordStored = await writeUsers(updatedUsers)
|
||||||
|
} catch (error) {
|
||||||
|
await logStep('password_storage', 'failed', { userId: user.id, error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!passwordStored) {
|
||||||
|
await logStep('password_storage', 'failed', { userId: user.id, reason: 'write_failed' })
|
||||||
|
throw new Error('Passwort konnte nach E-Mail-Versand nicht gespeichert werden')
|
||||||
|
}
|
||||||
|
await logStep('password_storage', 'completed', { userId: user.id })
|
||||||
|
try {
|
||||||
|
await revokeRefreshSessionsForUser(user.id, 'password_reset')
|
||||||
|
} catch (error) {
|
||||||
|
await logStep('session_revocation', 'failed', { userId: user.id, error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await logStep('session_revocation', 'completed', { userId: user.id })
|
||||||
|
|
||||||
|
registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
|
||||||
|
await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId })
|
||||||
|
await logStep('request_completed', 'success', { userId: user.id })
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
|
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Password-Reset-Fehler:', error)
|
await logStep('request_completed', 'failed', { error })
|
||||||
|
console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' })
|
||||||
// Don't reveal errors to prevent email enumeration
|
// Don't reveal errors to prevent email enumeration
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -115,4 +175,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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'
|
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -36,6 +36,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
user.active = false
|
user.active = false
|
||||||
const updatedUsers = users.map(u => u.id === userId ? user : u)
|
const updatedUsers = users.map(u => u.id === userId ? user : u)
|
||||||
await writeUsers(updatedUsers)
|
await writeUsers(updatedUsers)
|
||||||
|
await revokeRefreshSessionsForUser(userId, 'account_deactivated')
|
||||||
|
|
||||||
await writeAuditLog('cms.user.deactivated', {
|
await writeAuditLog('cms.user.deactivated', {
|
||||||
actorUserId: currentUser.id,
|
actorUserId: currentUser.id,
|
||||||
@@ -51,4 +52,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles } from '../utils/auth.js'
|
import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js'
|
||||||
import { assertPasswordNotPwned } from '../utils/hibp.js'
|
import { assertPasswordNotPwned } from '../utils/hibp.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -42,6 +42,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = users[userIndex]
|
const user = users[userIndex]
|
||||||
|
let passwordChanged = false
|
||||||
|
|
||||||
// Check if email is already taken by another user
|
// Check if email is already taken by another user
|
||||||
if (email !== user.email) {
|
if (email !== user.email) {
|
||||||
@@ -91,9 +92,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
await assertPasswordNotPwned(newPassword)
|
await assertPasswordNotPwned(newPassword)
|
||||||
user.password = await hashPassword(newPassword)
|
user.password = await hashPassword(newPassword)
|
||||||
|
passwordChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeUsers(users)
|
await writeUsers(users)
|
||||||
|
if (passwordChanged) {
|
||||||
|
await revokeRefreshSessionsForUser(user.id, 'password_changed')
|
||||||
|
}
|
||||||
|
|
||||||
const migratedUser = migrateUserRoles({ ...user })
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
@@ -117,4 +122,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { importSpielplan } from '../utils/spielplan-import.js'
|
|||||||
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
|
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
|
||||||
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
|
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
|
||||||
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||||
|
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
|
||||||
|
|
||||||
const TIME_ZONE = 'Europe/Berlin'
|
const TIME_ZONE = 'Europe/Berlin'
|
||||||
const RUN_HOUR = 7
|
const RUN_HOUR = 7
|
||||||
@@ -65,11 +66,22 @@ function nextRunAt(now = new Date()) {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runImport(reason) {
|
async function runDailyJobs(reason, skipSpielplanImport = false) {
|
||||||
if (running) return
|
if (running) return
|
||||||
|
|
||||||
running = true
|
running = true
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
const cleanup = await cleanupPasswordResetLogs()
|
||||||
|
loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup)
|
||||||
|
} catch (error) {
|
||||||
|
loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipSpielplanImport) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const spielplan = await importSpielplan()
|
const spielplan = await importSpielplan()
|
||||||
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
|
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
|
||||||
|
|
||||||
@@ -96,13 +108,13 @@ async function runImport(reason) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNext() {
|
function scheduleNext(skipSpielplanImport = false) {
|
||||||
const runAt = nextRunAt()
|
const runAt = nextRunAt()
|
||||||
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
|
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
|
||||||
|
|
||||||
timer = setTimeout(async () => {
|
timer = setTimeout(async () => {
|
||||||
await runImport('taeglicher Lauf')
|
await runDailyJobs('taeglicher Lauf', skipSpielplanImport)
|
||||||
scheduleNext()
|
scheduleNext(skipSpielplanImport)
|
||||||
}, delay)
|
}, delay)
|
||||||
|
|
||||||
timer.unref?.()
|
timer.unref?.()
|
||||||
@@ -110,15 +122,15 @@ function scheduleNext() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineNitroPlugin((nitroApp) => {
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
if (process.env.SPIELPLAN_IMPORT_DISABLED === 'true') {
|
const skipSpielplanImport = process.env.SPIELPLAN_IMPORT_DISABLED === 'true'
|
||||||
loggerInfo('[spielplan-import] Scheduler deaktiviert')
|
if (skipSpielplanImport) {
|
||||||
return
|
loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv')
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleNext()
|
scheduleNext(skipSpielplanImport)
|
||||||
|
|
||||||
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
|
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
|
||||||
runImport('Startlauf')
|
runDailyJobs('Startlauf', skipSpielplanImport)
|
||||||
}
|
}
|
||||||
|
|
||||||
nitroApp.hooks.hookOnce('close', () => {
|
nitroApp.hooks.hookOnce('close', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { encryptObject, decryptObject } from './encryption.js'
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
@@ -46,6 +47,10 @@ const getDataPath = (filename) => {
|
|||||||
|
|
||||||
const USERS_FILE = getDataPath('users.json')
|
const USERS_FILE = getDataPath('users.json')
|
||||||
const SESSIONS_FILE = getDataPath('sessions.json')
|
const SESSIONS_FILE = getDataPath('sessions.json')
|
||||||
|
const ANDROID_ACCESS_TOKEN_TTL = '15m'
|
||||||
|
const REFRESH_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
|
||||||
|
const refreshMutationState = globalThis.__HTC_REFRESH_MUTATION_STATE__ || { tail: Promise.resolve() }
|
||||||
|
globalThis.__HTC_REFRESH_MUTATION_STATE__ = refreshMutationState
|
||||||
|
|
||||||
// Get encryption key from environment
|
// Get encryption key from environment
|
||||||
function getEncryptionKey() {
|
function getEncryptionKey() {
|
||||||
@@ -146,7 +151,7 @@ export async function readUsers() {
|
|||||||
try {
|
try {
|
||||||
users = JSON.parse(data)
|
users = JSON.parse(data)
|
||||||
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
||||||
} catch (_parseError) {
|
} catch {
|
||||||
console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
|
console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -210,7 +215,7 @@ function isSessionsEncrypted(data) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} catch (e) {
|
} catch {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,7 +236,7 @@ export async function readSessions() {
|
|||||||
const plainData = JSON.parse(data)
|
const plainData = JSON.parse(data)
|
||||||
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
||||||
return plainData
|
return plainData
|
||||||
} catch (_parseError) {
|
} catch {
|
||||||
console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen')
|
console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -277,27 +282,36 @@ export async function verifyPassword(password, hash) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
export function generateToken(user) {
|
export function generateToken(user, { expiresIn = '7d', sessionId = null } = {}) {
|
||||||
// Stelle sicher, dass Rollen migriert sind
|
// Stelle sicher, dass Rollen migriert sind
|
||||||
const migratedUser = migrateUserRoles({ ...user })
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
|
|
||||||
return jwt.sign(
|
const claims = {
|
||||||
{
|
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
roles: roles
|
roles: roles
|
||||||
},
|
}
|
||||||
|
if (sessionId) {
|
||||||
|
claims.sid = sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.sign(
|
||||||
|
claims,
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateAndroidAccessToken(user, sessionId) {
|
||||||
|
return generateToken(user, { expiresIn: ANDROID_ACCESS_TOKEN_TTL, sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
// Verify JWT token
|
// Verify JWT token
|
||||||
export function verifyToken(token) {
|
export function verifyToken(token) {
|
||||||
try {
|
try {
|
||||||
return jwt.verify(token, JWT_SECRET)
|
return jwt.verify(token, JWT_SECRET)
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,6 +357,14 @@ export async function getUserFromToken(token) {
|
|||||||
const decoded = verifyToken(token)
|
const decoded = verifyToken(token)
|
||||||
if (!decoded) return null
|
if (!decoded) return null
|
||||||
|
|
||||||
|
if (decoded.sid) {
|
||||||
|
const sessions = await readSessions()
|
||||||
|
const session = sessions.find(s => s.id === decoded.sid && s.userId === decoded.id)
|
||||||
|
if (!session || session.revokedAt || new Date(session.expiresAt).getTime() <= Date.now()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
const user = users.find(u => u.id === decoded.id)
|
const user = users.find(u => u.id === decoded.id)
|
||||||
|
|
||||||
@@ -376,6 +398,130 @@ export async function deleteSession(token) {
|
|||||||
await writeSessions(filtered)
|
await writeSessions(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashRefreshToken(token) {
|
||||||
|
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueRefreshToken() {
|
||||||
|
return crypto.randomBytes(48).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRefreshMutation(operation) {
|
||||||
|
const result = refreshMutationState.tail.then(operation, operation)
|
||||||
|
refreshMutationState.tail = result.then(() => undefined, () => undefined)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRefreshSession(userId, deviceName = 'Android') {
|
||||||
|
return serializeRefreshMutation(async () => {
|
||||||
|
const sessions = await readSessions()
|
||||||
|
const refreshToken = issueRefreshToken()
|
||||||
|
const createdAt = new Date().toISOString()
|
||||||
|
const session = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
familyId: crypto.randomUUID(),
|
||||||
|
type: 'android_refresh',
|
||||||
|
userId,
|
||||||
|
deviceName: String(deviceName || 'Android').slice(0, 100),
|
||||||
|
refreshTokenHash: hashRefreshToken(refreshToken),
|
||||||
|
createdAt,
|
||||||
|
lastUsedAt: createdAt,
|
||||||
|
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(),
|
||||||
|
revokedAt: null
|
||||||
|
}
|
||||||
|
sessions.push(session)
|
||||||
|
await writeSessions(sessions)
|
||||||
|
return { session, refreshToken }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateRefreshSession(refreshToken) {
|
||||||
|
return serializeRefreshMutation(async () => {
|
||||||
|
const sessions = await readSessions()
|
||||||
|
const tokenHash = hashRefreshToken(refreshToken)
|
||||||
|
const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash)
|
||||||
|
const now = new Date()
|
||||||
|
const nowIso = now.toISOString()
|
||||||
|
|
||||||
|
if (!session) return { status: 'invalid' }
|
||||||
|
|
||||||
|
if (session.revokedAt) {
|
||||||
|
if (session.rotatedAt) {
|
||||||
|
for (const related of sessions) {
|
||||||
|
if (related.familyId === session.familyId && !related.revokedAt) {
|
||||||
|
related.revokedAt = nowIso
|
||||||
|
related.revokeReason = 'refresh_token_reuse'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeSessions(sessions)
|
||||||
|
return { status: 'reused', session }
|
||||||
|
}
|
||||||
|
return { status: 'revoked', session }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(session.expiresAt).getTime() <= now.getTime()) {
|
||||||
|
session.revokedAt = nowIso
|
||||||
|
session.revokeReason = 'expired'
|
||||||
|
await writeSessions(sessions)
|
||||||
|
return { status: 'expired', session }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRefreshToken = issueRefreshToken()
|
||||||
|
const nextSession = {
|
||||||
|
...session,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
refreshTokenHash: hashRefreshToken(nextRefreshToken),
|
||||||
|
createdAt: nowIso,
|
||||||
|
lastUsedAt: nowIso,
|
||||||
|
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS).toISOString(),
|
||||||
|
revokedAt: null
|
||||||
|
}
|
||||||
|
session.lastUsedAt = nowIso
|
||||||
|
session.revokedAt = nowIso
|
||||||
|
session.rotatedAt = nowIso
|
||||||
|
session.replacedBy = nextSession.id
|
||||||
|
session.revokeReason = 'rotated'
|
||||||
|
sessions.push(nextSession)
|
||||||
|
await writeSessions(sessions)
|
||||||
|
return { status: 'rotated', session: nextSession, refreshToken: nextRefreshToken }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeRefreshSession(refreshToken, reason = 'logout') {
|
||||||
|
return serializeRefreshMutation(async () => {
|
||||||
|
const sessions = await readSessions()
|
||||||
|
const tokenHash = hashRefreshToken(refreshToken)
|
||||||
|
const session = sessions.find(s => s.type === 'android_refresh' && s.refreshTokenHash === tokenHash)
|
||||||
|
if (!session) return false
|
||||||
|
|
||||||
|
const revokedAt = new Date().toISOString()
|
||||||
|
for (const related of sessions) {
|
||||||
|
if (related.familyId === session.familyId && !related.revokedAt) {
|
||||||
|
related.revokedAt = revokedAt
|
||||||
|
related.revokeReason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeSessions(sessions)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeRefreshSessionsForUser(userId, reason) {
|
||||||
|
return serializeRefreshMutation(async () => {
|
||||||
|
const sessions = await readSessions()
|
||||||
|
const revokedAt = new Date().toISOString()
|
||||||
|
let changed = false
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.type === 'android_refresh' && session.userId === userId && !session.revokedAt) {
|
||||||
|
session.revokedAt = revokedAt
|
||||||
|
session.revokeReason = reason
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) await writeSessions(sessions)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Clean expired sessions
|
// Clean expired sessions
|
||||||
export async function cleanExpiredSessions() {
|
export async function cleanExpiredSessions() {
|
||||||
const sessions = await readSessions()
|
const sessions = await readSessions()
|
||||||
@@ -383,4 +529,3 @@ export async function cleanExpiredSessions() {
|
|||||||
const valid = sessions.filter(s => new Date(s.expiresAt) > now)
|
const valid = sessions.filter(s => new Date(s.expiresAt) > now)
|
||||||
await writeSessions(valid)
|
await writeSessions(valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
server/utils/password-reset-log.js
Normal file
136
server/utils/password-reset-log.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const RETENTION_MS = 72 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function getDataPath(filename) {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_FILE = getDataPath('password-reset.log.jsonl')
|
||||||
|
|
||||||
|
export function normalizeResetEmail(email) {
|
||||||
|
return String(email || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskResetEmail(email) {
|
||||||
|
const normalized = normalizeResetEmail(email)
|
||||||
|
const [localPart = '', domain = ''] = normalized.split('@')
|
||||||
|
if (!domain) return normalized ? `${localPart.slice(0, 2)}***` : ''
|
||||||
|
|
||||||
|
const localVisible = localPart.slice(0, Math.min(2, localPart.length))
|
||||||
|
const domainParts = domain.split('.')
|
||||||
|
const domainName = domainParts.shift() || ''
|
||||||
|
const suffix = domainParts.length ? `.${domainParts.join('.')}` : ''
|
||||||
|
return `${localVisible}***@${domainName.slice(0, 2)}***${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprintResetEmail(email) {
|
||||||
|
return crypto.createHash('sha256').update(normalizeResetEmail(email)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeText(value, max = 160) {
|
||||||
|
return String(value == null ? '' : value).slice(0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorLabel(error) {
|
||||||
|
return safeText(error?.code || error?.name || 'Error', 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedErrorMessage(error) {
|
||||||
|
return safeText(error?.message || error || '')
|
||||||
|
.replace(/[^\s<>"']+@[^\s<>"']+/gi, email => maskResetEmail(email))
|
||||||
|
.replace(/((?:pass(?:word)?|token|secret|authorization|auth)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
|
||||||
|
.replace(/(smtp:\/\/[^:\s/]+:)[^@\s/]+@/gi, '$1[redacted]@')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writePasswordResetLog({
|
||||||
|
requestId,
|
||||||
|
email,
|
||||||
|
ip,
|
||||||
|
step,
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
reason,
|
||||||
|
error
|
||||||
|
}) {
|
||||||
|
const normalizedEmail = normalizeResetEmail(email)
|
||||||
|
const entry = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
requestId: safeText(requestId, 80),
|
||||||
|
emailMasked: maskResetEmail(normalizedEmail),
|
||||||
|
emailFingerprint: fingerprintResetEmail(normalizedEmail),
|
||||||
|
ip: safeText(ip, 80),
|
||||||
|
step: safeText(step, 80),
|
||||||
|
status: safeText(status, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) entry.userId = safeText(userId, 80)
|
||||||
|
if (reason) entry.reason = safeText(reason, 100)
|
||||||
|
if (error) {
|
||||||
|
entry.errorCode = errorLabel(error)
|
||||||
|
entry.errorMessage = sanitizedErrorMessage(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(LOG_FILE), { recursive: true })
|
||||||
|
await fs.appendFile(LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupPasswordResetLogs(now = Date.now()) {
|
||||||
|
let contents
|
||||||
|
try {
|
||||||
|
contents = await fs.readFile(LOG_FILE, 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return { retained: 0, removed: 0 }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = now - RETENTION_MS
|
||||||
|
const entries = contents
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap(line => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(line)]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const retained = entries.filter(entry => new Date(entry.ts).getTime() >= threshold)
|
||||||
|
const removed = entries.length - retained.length
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
const serialized = retained.map(entry => JSON.stringify(entry)).join('\n')
|
||||||
|
await fs.writeFile(LOG_FILE, serialized ? `${serialized}\n` : '', 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { retained: retained.length, removed }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPasswordResetLogs() {
|
||||||
|
await cleanupPasswordResetLogs()
|
||||||
|
try {
|
||||||
|
const contents = await fs.readFile(LOG_FILE, 'utf8')
|
||||||
|
return contents
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap(line => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(line)]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => String(b.ts).localeCompare(String(a.ts)))
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PASSWORD_RESET_LOG_RETENTION_HOURS = RETENTION_MS / (60 * 60 * 1000)
|
||||||
@@ -26,6 +26,36 @@ function deriveFromBaseUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(value) {
|
||||||
|
try {
|
||||||
|
const u = new URL(value)
|
||||||
|
if (u.protocol === 'https:') {
|
||||||
|
return `https://${u.hostname}`
|
||||||
|
}
|
||||||
|
if (u.protocol === 'http:' && u.hostname === 'localhost') {
|
||||||
|
return `${u.protocol}//${u.host}`
|
||||||
|
}
|
||||||
|
return u.port === '80' ? `http://${u.hostname}` : `${u.protocol}//${u.host}`
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedOrigins(origin) {
|
||||||
|
const configured = String(process.env.WEBAUTHN_ALLOWED_ORIGINS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(candidate => normalizeOrigin(candidate.trim()))
|
||||||
|
.filter(Boolean)
|
||||||
|
const origins = [origin, ...configured]
|
||||||
|
|
||||||
|
// Beide produktiven Hostnamen werden im Browser verwendet und gehoeren zur selben RP-ID.
|
||||||
|
if (origin === 'https://harheimertc.de' || origin === 'https://www.harheimertc.de') {
|
||||||
|
origins.push('https://harheimertc.de', 'https://www.harheimertc.de')
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(origins)]
|
||||||
|
}
|
||||||
|
|
||||||
export function getWebAuthnConfig() {
|
export function getWebAuthnConfig() {
|
||||||
const derived = deriveFromBaseUrl()
|
const derived = deriveFromBaseUrl()
|
||||||
|
|
||||||
@@ -33,23 +63,8 @@ export function getWebAuthnConfig() {
|
|||||||
const rpName = process.env.WEBAUTHN_RP_NAME || 'Harheimer TC'
|
const rpName = process.env.WEBAUTHN_RP_NAME || 'Harheimer TC'
|
||||||
|
|
||||||
// WEBAUTHN_ORIGIN hat Priorität, sonst von BASE_URL ableiten
|
// WEBAUTHN_ORIGIN hat Priorität, sonst von BASE_URL ableiten
|
||||||
let origin = process.env.WEBAUTHN_ORIGIN || derived.origin
|
const origin = normalizeOrigin(process.env.WEBAUTHN_ORIGIN || derived.origin)
|
||||||
|
const origins = getAllowedOrigins(origin)
|
||||||
// Sicherstellen, dass HTTPS-Origins KEINEN Port haben (auch wenn in ENV gesetzt)
|
|
||||||
if (origin.startsWith('https://')) {
|
|
||||||
try {
|
|
||||||
const u = new URL(origin)
|
|
||||||
// Port 443 oder kein Port = Standard, also Port weglassen
|
|
||||||
if (u.port === '443' || !u.port) {
|
|
||||||
origin = `https://${u.hostname}`
|
|
||||||
} else {
|
|
||||||
// Auch andere Ports bei HTTPS entfernen (nicht Standard für WebAuthn)
|
|
||||||
origin = `https://${u.hostname}`
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requireUV = (process.env.WEBAUTHN_REQUIRE_UV || '').toLowerCase() === 'true'
|
const requireUV = (process.env.WEBAUTHN_REQUIRE_UV || '').toLowerCase() === 'true'
|
||||||
|
|
||||||
@@ -57,13 +72,14 @@ export function getWebAuthnConfig() {
|
|||||||
rpId,
|
rpId,
|
||||||
rpName,
|
rpName,
|
||||||
origin,
|
origin,
|
||||||
|
origins,
|
||||||
requireUV,
|
requireUV,
|
||||||
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
|
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
|
||||||
|
webauthnAllowedOriginsEnv: process.env.WEBAUTHN_ALLOWED_ORIGINS,
|
||||||
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL,
|
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL,
|
||||||
derivedOrigin: derived.origin
|
derivedOrigin: derived.origin
|
||||||
})
|
})
|
||||||
|
|
||||||
return { rpId, rpName, origin, requireUV }
|
return { rpId, rpName, origin, origins, requireUV }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ vi.mock('../server/utils/auth.js', () => {
|
|||||||
writeUsers: vi.fn(),
|
writeUsers: vi.fn(),
|
||||||
verifyPassword: vi.fn(),
|
verifyPassword: vi.fn(),
|
||||||
generateToken: vi.fn(),
|
generateToken: vi.fn(),
|
||||||
|
generateAndroidAccessToken: vi.fn(),
|
||||||
createSession: vi.fn(),
|
createSession: vi.fn(),
|
||||||
|
createRefreshSession: vi.fn(),
|
||||||
|
rotateRefreshSession: vi.fn(),
|
||||||
|
revokeRefreshSession: vi.fn(),
|
||||||
|
revokeRefreshSessionsForUser: vi.fn(),
|
||||||
|
getUserById: vi.fn(),
|
||||||
hashPassword: vi.fn(),
|
hashPassword: vi.fn(),
|
||||||
verifyToken: vi.fn(),
|
verifyToken: vi.fn(),
|
||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
@@ -53,11 +59,19 @@ vi.mock('nodemailer', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('../server/utils/password-reset-log.js', () => ({
|
||||||
|
normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
|
||||||
|
maskResetEmail: vi.fn(email => `masked:${String(email || '').trim().toLowerCase()}`),
|
||||||
|
writePasswordResetLog: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}))
|
||||||
|
|
||||||
const authUtils = await import('../server/utils/auth.js')
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
const nodemailer = await import('nodemailer')
|
const nodemailer = await import('nodemailer')
|
||||||
|
const passwordResetLog = await import('../server/utils/password-reset-log.js')
|
||||||
|
|
||||||
import loginHandler from '../server/api/auth/login.post.js'
|
import loginHandler from '../server/api/auth/login.post.js'
|
||||||
import logoutHandler from '../server/api/auth/logout.post.js'
|
import logoutHandler from '../server/api/auth/logout.post.js'
|
||||||
|
import refreshHandler from '../server/api/auth/refresh.post.js'
|
||||||
import registerHandler from '../server/api/auth/register.post.js'
|
import registerHandler from '../server/api/auth/register.post.js'
|
||||||
import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
|
import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
|
||||||
import statusHandler from '../server/api/auth/status.get.js'
|
import statusHandler from '../server/api/auth/status.get.js'
|
||||||
@@ -110,6 +124,29 @@ describe('Auth API Endpoints', () => {
|
|||||||
expect(authUtils.createSession).toHaveBeenCalledWith('1', 'jwt-token')
|
expect(authUtils.createSession).toHaveBeenCalledWith('1', 'jwt-token')
|
||||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('gibt Android-Clients ein Refresh-Token für eine Gerätesitzung zurück', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const user = { id: '1', email: 'test@example.com', password: 'hash', roles: ['mitglied'], active: true }
|
||||||
|
mockSuccessReadBody({ email: user.email, password: 'plain', client: 'android', deviceName: 'Pixel' })
|
||||||
|
authUtils.readUsers.mockResolvedValue([user])
|
||||||
|
authUtils.verifyPassword.mockResolvedValue(true)
|
||||||
|
authUtils.createRefreshSession.mockResolvedValue({ session: { id: 'session-1' }, refreshToken: 'refresh-1' })
|
||||||
|
authUtils.generateAndroidAccessToken.mockReturnValue('access-1')
|
||||||
|
authUtils.writeUsers.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const response = await loginHandler(event)
|
||||||
|
|
||||||
|
expect(authUtils.createRefreshSession).toHaveBeenCalledWith('1', 'Pixel')
|
||||||
|
expect(authUtils.generateAndroidAccessToken).toHaveBeenCalledWith(user, 'session-1')
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
token: 'access-1',
|
||||||
|
accessToken: 'access-1',
|
||||||
|
refreshToken: 'refresh-1',
|
||||||
|
sessionId: 'session-1'
|
||||||
|
})
|
||||||
|
expect(authUtils.createSession).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('POST /api/auth/logout', () => {
|
describe('POST /api/auth/logout', () => {
|
||||||
@@ -129,6 +166,64 @@ describe('Auth API Endpoints', () => {
|
|||||||
|
|
||||||
await expect(logoutHandler(event)).rejects.toMatchObject({ statusCode: 500 })
|
await expect(logoutHandler(event)).rejects.toMatchObject({ statusCode: 500 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('widerruft beim Android-Logout das Refresh-Token', async () => {
|
||||||
|
const event = createEvent({ headers: { authorization: 'Bearer access-token' } })
|
||||||
|
mockSuccessReadBody({ refreshToken: 'refresh-token' })
|
||||||
|
authUtils.deleteSession.mockResolvedValue()
|
||||||
|
authUtils.revokeRefreshSession.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const response = await logoutHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('refresh-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/auth/refresh', () => {
|
||||||
|
it('rotiert eine gültige Android-Sitzung und gibt ein neues Token-Paar zurück', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ refreshToken: 'old-refresh' })
|
||||||
|
const user = { id: '1', email: 'test@example.com', roles: ['mitglied'], active: true }
|
||||||
|
authUtils.rotateRefreshSession.mockResolvedValue({
|
||||||
|
status: 'rotated',
|
||||||
|
session: { id: 'session-2', userId: '1' },
|
||||||
|
refreshToken: 'new-refresh'
|
||||||
|
})
|
||||||
|
authUtils.getUserById.mockResolvedValue(user)
|
||||||
|
authUtils.generateAndroidAccessToken.mockReturnValue('new-access')
|
||||||
|
|
||||||
|
const response = await refreshHandler(event)
|
||||||
|
|
||||||
|
expect(authUtils.rotateRefreshSession).toHaveBeenCalledWith('old-refresh')
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
accessToken: 'new-access',
|
||||||
|
refreshToken: 'new-refresh',
|
||||||
|
sessionId: 'session-2'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist widerrufene oder erneut verwendete Refresh-Tokens zurück', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ refreshToken: 'used-refresh' })
|
||||||
|
authUtils.rotateRefreshSession.mockResolvedValue({ status: 'reused' })
|
||||||
|
|
||||||
|
await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('widerruft eine rotierte Sitzung, wenn der Benutzer nicht mehr aktiv ist', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ refreshToken: 'old-refresh' })
|
||||||
|
authUtils.rotateRefreshSession.mockResolvedValue({
|
||||||
|
status: 'rotated',
|
||||||
|
session: { id: 'session-2', userId: '1' },
|
||||||
|
refreshToken: 'new-refresh'
|
||||||
|
})
|
||||||
|
authUtils.getUserById.mockResolvedValue({ id: '1', active: false })
|
||||||
|
|
||||||
|
await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('new-refresh', 'inactive_or_missing_user')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('POST /api/auth/register', () => {
|
describe('POST /api/auth/register', () => {
|
||||||
@@ -224,6 +319,64 @@ describe('Auth API Endpoints', () => {
|
|||||||
const response = await resetPasswordHandler(event)
|
const response = await resetPasswordHandler(event)
|
||||||
expect(response.success).toBe(true)
|
expect(response.success).toBe(true)
|
||||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||||
|
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset')
|
||||||
|
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
email: 'user@example.com',
|
||||||
|
step: 'mail_send',
|
||||||
|
status: 'completed'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalisiert Leerzeichen bei der Benutzersuche', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' }
|
||||||
|
mockSuccessReadBody({ email: ' User@Example.com ' })
|
||||||
|
authUtils.readUsers.mockResolvedValue([user])
|
||||||
|
authUtils.hashPassword.mockResolvedValue('new-hash')
|
||||||
|
authUtils.writeUsers.mockResolvedValue(true)
|
||||||
|
|
||||||
|
await resetPasswordHandler(event)
|
||||||
|
|
||||||
|
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||||
|
expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ändert das Passwort nicht, wenn SMTP nicht konfiguriert ist', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ email: 'user@example.com' })
|
||||||
|
authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
|
||||||
|
authUtils.hashPassword.mockResolvedValue('new-hash')
|
||||||
|
delete process.env.SMTP_USER
|
||||||
|
delete process.env.SMTP_PASS
|
||||||
|
|
||||||
|
const response = await resetPasswordHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(authUtils.writeUsers).not.toHaveBeenCalled()
|
||||||
|
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
step: 'mail_configuration',
|
||||||
|
status: 'failed',
|
||||||
|
reason: 'smtp_credentials_missing'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('protokolliert einen Mailfehler ohne das Passwort zu aktivieren', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ email: 'user@example.com' })
|
||||||
|
authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
|
||||||
|
authUtils.hashPassword.mockResolvedValue('new-hash')
|
||||||
|
nodemailer.default.createTransport.mockReturnValueOnce({
|
||||||
|
sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await resetPasswordHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(authUtils.writeUsers).not.toHaveBeenCalled()
|
||||||
|
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
step: 'mail_send',
|
||||||
|
status: 'failed'
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ vi.mock('../server/utils/auth.js', () => ({
|
|||||||
getUserFromToken: vi.fn(),
|
getUserFromToken: vi.fn(),
|
||||||
readUsers: vi.fn(),
|
readUsers: vi.fn(),
|
||||||
writeUsers: vi.fn(),
|
writeUsers: vi.fn(),
|
||||||
|
revokeRefreshSessionsForUser: vi.fn(),
|
||||||
hasRole: vi.fn((user, role) => {
|
hasRole: vi.fn((user, role) => {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
@@ -37,14 +38,23 @@ vi.mock('nodemailer', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('../server/utils/password-reset-log.js', () => ({
|
||||||
|
fingerprintResetEmail: vi.fn(email => `fingerprint:${String(email || '').trim().toLowerCase()}`),
|
||||||
|
normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
|
||||||
|
PASSWORD_RESET_LOG_RETENTION_HOURS: 72,
|
||||||
|
readPasswordResetLogs: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
const authUtils = await import('../server/utils/auth.js')
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
const nodemailer = await import('nodemailer')
|
const nodemailer = await import('nodemailer')
|
||||||
|
const passwordResetLog = await import('../server/utils/password-reset-log.js')
|
||||||
|
|
||||||
import usersListHandler from '../server/api/cms/users/list.get.js'
|
import usersListHandler from '../server/api/cms/users/list.get.js'
|
||||||
import usersApproveHandler from '../server/api/cms/users/approve.post.js'
|
import usersApproveHandler from '../server/api/cms/users/approve.post.js'
|
||||||
import usersDeactivateHandler from '../server/api/cms/users/deactivate.post.js'
|
import usersDeactivateHandler from '../server/api/cms/users/deactivate.post.js'
|
||||||
import usersRejectHandler from '../server/api/cms/users/reject.post.js'
|
import usersRejectHandler from '../server/api/cms/users/reject.post.js'
|
||||||
import usersUpdateRoleHandler from '../server/api/cms/users/update-role.post.js'
|
import usersUpdateRoleHandler from '../server/api/cms/users/update-role.post.js'
|
||||||
|
import passwordResetDiagnosticsHandler from '../server/api/cms/password-reset-diagnostics.get.js'
|
||||||
|
|
||||||
describe('CMS User Management Endpoints', () => {
|
describe('CMS User Management Endpoints', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -138,6 +148,7 @@ describe('CMS User Management Endpoints', () => {
|
|||||||
const response = await usersDeactivateHandler(event)
|
const response = await usersDeactivateHandler(event)
|
||||||
expect(response.success).toBe(true)
|
expect(response.success).toBe(true)
|
||||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||||
|
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'account_deactivated')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -184,4 +195,49 @@ describe('CMS User Management Endpoints', () => {
|
|||||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GET /api/cms/password-reset-diagnostics', () => {
|
||||||
|
it('verweigert Zugriff für Vorstand ohne Admin-Rolle', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: 'vorstand', roles: ['vorstand'] })
|
||||||
|
authUtils.hasRole.mockReturnValue(false)
|
||||||
|
|
||||||
|
await expect(passwordResetDiagnosticsHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findet Benutzer und gefilterte fehlgeschlagene Reset-Abläufe', async () => {
|
||||||
|
const event = adminEvent()
|
||||||
|
event.__query = { email: ' User@Example.com ', failedOnly: 'true' }
|
||||||
|
authUtils.hasRole.mockReturnValue(true)
|
||||||
|
authUtils.readUsers.mockResolvedValue([
|
||||||
|
{ id: '1', email: 'user@example.com', name: 'User Beispiel', active: true }
|
||||||
|
])
|
||||||
|
passwordResetLog.readPasswordResetLogs.mockResolvedValue([
|
||||||
|
{
|
||||||
|
requestId: 'r1',
|
||||||
|
ts: '2026-05-27T10:00:01.000Z',
|
||||||
|
emailMasked: 'us***@ex***.com',
|
||||||
|
emailFingerprint: 'fingerprint:user@example.com',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
step: 'request_completed',
|
||||||
|
status: 'no_account'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestId: 'r2',
|
||||||
|
ts: '2026-05-27T10:00:02.000Z',
|
||||||
|
emailMasked: 'ot***@ex***.com',
|
||||||
|
emailFingerprint: 'fingerprint:other@example.com',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
step: 'request_completed',
|
||||||
|
status: 'failed'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const response = await passwordResetDiagnosticsHandler(event)
|
||||||
|
|
||||||
|
expect(response.matchingUsers).toHaveLength(1)
|
||||||
|
expect(response.attempts).toHaveLength(1)
|
||||||
|
expect(response.attempts[0]).toMatchObject({ requestId: 'r1', failed: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ vi.mock('../server/utils/auth.js', () => ({
|
|||||||
writeUsers: vi.fn(),
|
writeUsers: vi.fn(),
|
||||||
verifyPassword: vi.fn(),
|
verifyPassword: vi.fn(),
|
||||||
hashPassword: vi.fn(),
|
hashPassword: vi.fn(),
|
||||||
|
revokeRefreshSessionsForUser: vi.fn(),
|
||||||
migrateUserRoles: vi.fn((user) => {
|
migrateUserRoles: vi.fn((user) => {
|
||||||
if (!user) return user
|
if (!user) return user
|
||||||
if (Array.isArray(user.roles)) return user
|
if (Array.isArray(user.roles)) return user
|
||||||
@@ -202,6 +203,7 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.user.name).toBe('Max Neu')
|
expect(result.user.name).toBe('Max Neu')
|
||||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||||
|
expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prüft aktuelles Passwort bei Passwortänderung', async () => {
|
it('prüft aktuelles Passwort bei Passwortänderung', async () => {
|
||||||
@@ -236,6 +238,7 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(authUtils.hashPassword).toHaveBeenCalledWith(updatedPassword)
|
expect(authUtils.hashPassword).toHaveBeenCalledWith(updatedPassword)
|
||||||
|
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_changed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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