feat(auth): implement Android refresh token handling and session management
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints.
- Implemented new tests for login, logout, and refresh functionalities specific to Android clients.
- Enhanced password reset logging with normalization and masking of email addresses.
- Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs.
- Introduced a new utility for managing password reset logs with retention policies.
- Added tests for password reset log utilities to ensure proper functionality and privacy compliance.
- Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
This commit is contained in:
Torsten Schulz (local)
2026-05-27 19:34:32 +02:00
parent 755442fb70
commit 58fd7fa5c6
32 changed files with 1477 additions and 180 deletions

View File

@@ -0,0 +1,39 @@
package de.harheimertc.data
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccessTokenAuthenticator @Inject constructor(
private val sessionRefresher: SessionRefresher,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
return null
}
val currentAccessToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
?.takeIf(String::isNotBlank)
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 0
while (current != null) {
count += 1
current = current.priorResponse
}
return count
}
}

View File

@@ -106,7 +106,12 @@ data class MembershipResponse(
val message: String? = null,
val downloadUrl: String? = null,
)
data class LoginRequest(val email: String, val password: String)
data class LoginRequest(
val email: String,
val password: String,
val client: String = "android",
val deviceName: String = "Harheimer TC Android-App",
)
data class AuthUserDto(
val id: String? = null,
val email: String = "",
@@ -116,9 +121,14 @@ data class AuthUserDto(
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val sessionId: String? = null,
val user: AuthUserDto? = null,
val role: String? = null,
)
data class RefreshRequest(val refreshToken: String)
data class LogoutRequest(val refreshToken: String? = null)
data class AuthStatusResponse(
val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null,
@@ -256,7 +266,10 @@ interface ApiService {
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("/api/auth/logout")
suspend fun logout(): Response<Unit>
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
@POST("/api/auth/refresh")
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
@GET("/api/auth/status")
suspend fun authStatus(): Response<AuthStatusResponse>

View File

@@ -27,7 +27,7 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BASIC
val cookies = CookieManager().apply {
@@ -36,6 +36,7 @@ object NetworkModule {
return OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookies))
.addInterceptor(authInterceptor)
.authenticator(accessTokenAuthenticator)
.addInterceptor(logging)
.build()
}

View File

@@ -0,0 +1,64 @@
package de.harheimertc.data
import com.squareup.moshi.Moshi
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SessionRefresher @Inject constructor(
private val authRepository: AuthRepository,
moshi: Moshi,
) {
private val lock = Any()
private val client = OkHttpClient.Builder().build()
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
refreshAccessTokenBlocking() != null
}
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
val currentToken = authRepository.getToken()
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
return@synchronized currentToken
}
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
?: return@synchronized null
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
val request = Request.Builder()
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
.post(payload.toRequestBody("application/json".toMediaType()))
.build()
try {
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
authRepository.clearSession()
return@synchronized null
}
if (!response.isSuccessful) return@synchronized null
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
?: return@synchronized null
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
?: return@synchronized null
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized null
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
accessToken
}
} catch (_: Exception) {
null
}
}
}

View File

@@ -1,8 +1,9 @@
package de.harheimertc.repositories
import kotlinx.coroutines.flow.StateFlow
interface AuthRepository {
fun getToken(): String?
fun setToken(token: String?)
fun getRefreshToken(): String?
fun getSessionId(): String?
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
fun clearSession()
}

View File

@@ -10,6 +10,8 @@ import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id"
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -25,9 +27,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
override fun getToken(): String? = preferences.getString(tokenKey, null)
override fun setToken(token: String?) {
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
preferences.edit().apply {
if (token == null) remove(tokenKey) else putString(tokenKey, token)
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
}.apply()
}
override fun clearSession() {
preferences.edit()
.remove(tokenKey)
.remove(refreshTokenKey)
.remove(sessionIdKey)
.apply()
}
}

View File

@@ -5,8 +5,10 @@ import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,31 +16,40 @@ import javax.inject.Singleton
class LoginRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
val body = response.body() ?: error("Leere Antwort")
val token = body.token?.takeIf(String::isNotBlank)
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setToken(token)
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}
suspend fun logout(): Result<Unit> = runCatching {
try {
api.logout()
api.logout(LogoutRequest(authRepository.getRefreshToken()))
} finally {
authRepository.setToken(null)
authRepository.clearSession()
}
}
suspend fun status(): Result<AuthStatusResponse> = runCatching {
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
val response = api.authStatus()
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
return@runCatching AuthStatusResponse()
}
var response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
val status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn) authRepository.setToken(null)
var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse()
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}