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:
@@ -0,0 +1,39 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccessTokenAuthenticator @Inject constructor(
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) : Authenticator {
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
|
||||
return null
|
||||
}
|
||||
|
||||
val currentAccessToken = response.request.header("Authorization")
|
||||
?.removePrefix("Bearer ")
|
||||
?.takeIf(String::isNotBlank)
|
||||
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
|
||||
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $refreshedToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var current: Response? = response
|
||||
var count = 0
|
||||
while (current != null) {
|
||||
count += 1
|
||||
current = current.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,12 @@ data class MembershipResponse(
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
)
|
||||
data class LoginRequest(val email: String, val password: String)
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val client: String = "android",
|
||||
val deviceName: String = "Harheimer TC Android-App",
|
||||
)
|
||||
data class AuthUserDto(
|
||||
val id: String? = null,
|
||||
val email: String = "",
|
||||
@@ -116,9 +121,14 @@ data class AuthUserDto(
|
||||
data class LoginResponse(
|
||||
val success: Boolean = false,
|
||||
val token: String? = null,
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val user: AuthUserDto? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
data class RefreshRequest(val refreshToken: String)
|
||||
data class LogoutRequest(val refreshToken: String? = null)
|
||||
data class AuthStatusResponse(
|
||||
val isLoggedIn: Boolean = false,
|
||||
val user: AuthUserDto? = null,
|
||||
@@ -256,7 +266,10 @@ interface ApiService {
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("/api/auth/logout")
|
||||
suspend fun logout(): Response<Unit>
|
||||
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
|
||||
|
||||
@POST("/api/auth/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/status")
|
||||
suspend fun authStatus(): Response<AuthStatusResponse>
|
||||
|
||||
@@ -27,7 +27,7 @@ object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
||||
val cookies = CookieManager().apply {
|
||||
@@ -36,6 +36,7 @@ object NetworkModule {
|
||||
return OkHttpClient.Builder()
|
||||
.cookieJar(JavaNetCookieJar(cookies))
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(accessTokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SessionRefresher @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
moshi: Moshi,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val client = OkHttpClient.Builder().build()
|
||||
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
|
||||
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
|
||||
|
||||
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
|
||||
refreshAccessTokenBlocking() != null
|
||||
}
|
||||
|
||||
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
|
||||
val currentToken = authRepository.getToken()
|
||||
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
|
||||
return@synchronized currentToken
|
||||
}
|
||||
|
||||
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
|
||||
val request = Request.Builder()
|
||||
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
|
||||
.post(payload.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
authRepository.clearSession()
|
||||
return@synchronized null
|
||||
}
|
||||
if (!response.isSuccessful) return@synchronized null
|
||||
|
||||
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
|
||||
?: return@synchronized null
|
||||
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
|
||||
accessToken
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface AuthRepository {
|
||||
fun getToken(): String?
|
||||
fun setToken(token: String?)
|
||||
fun getRefreshToken(): String?
|
||||
fun getSessionId(): String?
|
||||
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
|
||||
fun clearSession()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
|
||||
private val tokenKey = "auth_token"
|
||||
private val refreshTokenKey = "auth_refresh_token"
|
||||
private val sessionIdKey = "auth_session_id"
|
||||
private val preferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
@@ -25,9 +27,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
|
||||
|
||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||
|
||||
override fun setToken(token: String?) {
|
||||
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
|
||||
|
||||
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
|
||||
|
||||
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||
preferences.edit().apply {
|
||||
if (token == null) remove(tokenKey) else putString(tokenKey, token)
|
||||
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
|
||||
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
|
||||
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
preferences.edit()
|
||||
.remove(tokenKey)
|
||||
.remove(refreshTokenKey)
|
||||
.remove(sessionIdKey)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -14,31 +16,40 @@ import javax.inject.Singleton
|
||||
class LoginRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val response = api.login(LoginRequest(email.trim(), password))
|
||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = body.token?.takeIf(String::isNotBlank)
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setToken(token)
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
try {
|
||||
api.logout()
|
||||
api.logout(LogoutRequest(authRepository.getRefreshToken()))
|
||||
} finally {
|
||||
authRepository.setToken(null)
|
||||
authRepository.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(): Result<AuthStatusResponse> = runCatching {
|
||||
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
|
||||
val response = api.authStatus()
|
||||
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
|
||||
return@runCatching AuthStatusResponse()
|
||||
}
|
||||
|
||||
var response = api.authStatus()
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
val status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn) authRepository.setToken(null)
|
||||
var status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||
response = api.authStatus()
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
status = response.body() ?: AuthStatusResponse()
|
||||
}
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||
status
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user