diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index c04d354..2fd3952 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index e5f550c..0ee66e1 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index e7f978b..4b174ad 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 3d161e4..317ccbd 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index f71c130..29b3287 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 33b05b9..d07e95f 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index f8b929d..e9010d6 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/auth/data/remote/api/AuthApi.kt b/client-mobile/auth/data/remote/api/AuthApi.kt index 9a68f1e..07a5104 100644 --- a/client-mobile/auth/data/remote/api/AuthApi.kt +++ b/client-mobile/auth/data/remote/api/AuthApi.kt @@ -2,6 +2,7 @@ package auth.data.remote.api import auth.data.remote.dto.AuthRequest import auth.data.remote.dto.AuthResponse +import auth.data.remote.dto.RefreshTokenRequest import core.domain.model.ServerConfigModel import retrofit2.http.Body import retrofit2.http.GET @@ -10,13 +11,14 @@ import retrofit2.http.POST interface AuthApi { @POST("auth/login") - @Headers("Cache-Control: no-cache") suspend fun login(@Body request: AuthRequest): AuthResponse @POST("auth/register") - @Headers("Cache-Control: no-cache") suspend fun register(@Body request: AuthRequest): AuthResponse + @POST("auth/refresh") + suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse + @GET("config") @Headers("Cache-Control: no-cache") suspend fun getConfig(): ServerConfigModel diff --git a/client-mobile/auth/data/remote/dto/AuthDtos.kt b/client-mobile/auth/data/remote/dto/AuthDtos.kt index 869964a..cac9089 100644 --- a/client-mobile/auth/data/remote/dto/AuthDtos.kt +++ b/client-mobile/auth/data/remote/dto/AuthDtos.kt @@ -9,6 +9,7 @@ data class AuthRequest( data class AuthResponse( @SerializedName("accessToken") val accessToken: String?, + @SerializedName("refreshToken") val refreshToken: String?, @SerializedName("user") val user: UserDto?, @SerializedName("userId") val userId: String?, @SerializedName("username") val username: String?, @@ -21,3 +22,7 @@ data class UserDto( @SerializedName("displayName") val displayName: String?, @SerializedName("avatarUrl") val avatarUrl: String? ) + +data class RefreshTokenRequest( + @SerializedName("refreshToken") val refreshToken: String +) diff --git a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt index 5eaf0fb..8b6fae5 100644 --- a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt +++ b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt @@ -2,6 +2,7 @@ package auth.data.repository import auth.data.remote.api.AuthApi import auth.data.remote.dto.AuthRequest +import auth.data.remote.dto.RefreshTokenRequest import auth.domain.model.AuthResult import auth.domain.repository.AuthRepository import core.network.ServerConfig @@ -25,12 +26,13 @@ class AuthRepositoryImpl @Inject constructor( val token = response.accessToken ?: return Result.failure(Exception("Token is null")) val userId = response.userId ?: "" - tokenManager.saveToken(token, userId) + tokenManager.saveToken(token, userId, response.refreshToken) _isAuthenticated.value = true fetchConfig() Result.success( AuthResult( token = token, + refreshToken = response.refreshToken, userId = userId, userName = response.username ?: userName, displayName = response.displayName ?: response.username ?: userName, @@ -48,12 +50,13 @@ class AuthRepositoryImpl @Inject constructor( val token = response.accessToken ?: return Result.failure(Exception("Token is null")) val userId = response.userId ?: "" - tokenManager.saveToken(token, userId) + tokenManager.saveToken(token, userId, response.refreshToken) _isAuthenticated.value = true fetchConfig() Result.success( AuthResult( token = token, + refreshToken = response.refreshToken, userId = userId, userName = response.username ?: userName, displayName = response.displayName ?: response.username ?: userName, @@ -95,4 +98,34 @@ class AuthRepositoryImpl @Inject constructor( // Silent fail } } + + override suspend fun refreshToken(): Result { + val currentRefreshToken = tokenManager.getRefreshToken() + if (currentRefreshToken == null) { + return Result.failure(Exception("Refresh token is null")) + } + + return try { + val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken)) + val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null")) + val newRefreshToken = response.refreshToken + val userId = response.userId ?: "" + + tokenManager.saveToken(newAccessToken, userId, newRefreshToken) + _isAuthenticated.value = true + + Result.success( + AuthResult( + token = newAccessToken, + refreshToken = newRefreshToken, + userId = userId, + userName = response.username ?: "", + displayName = response.displayName ?: "", + avatarUrl = null + ) + ) + } catch (e: Exception) { + Result.failure(e) + } + } } diff --git a/client-mobile/auth/domain/model/AuthModels.kt b/client-mobile/auth/domain/model/AuthModels.kt index 45d28bd..06ac946 100644 --- a/client-mobile/auth/domain/model/AuthModels.kt +++ b/client-mobile/auth/domain/model/AuthModels.kt @@ -2,6 +2,7 @@ package auth.domain.model data class AuthResult( val token: String, + val refreshToken: String?, val userId: String, val userName: String, val displayName: String, diff --git a/client-mobile/auth/domain/repository/AuthRepository.kt b/client-mobile/auth/domain/repository/AuthRepository.kt index 9237d30..781d4a7 100644 --- a/client-mobile/auth/domain/repository/AuthRepository.kt +++ b/client-mobile/auth/domain/repository/AuthRepository.kt @@ -10,4 +10,5 @@ interface AuthRepository { fun isAuthenticated(): Boolean fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow suspend fun updatePushToken(token: String) + suspend fun refreshToken(): Result } diff --git a/client-mobile/auth/presentation/AuthViewModel.kt b/client-mobile/auth/presentation/AuthViewModel.kt index 990e87b..be8ab94 100644 --- a/client-mobile/auth/presentation/AuthViewModel.kt +++ b/client-mobile/auth/presentation/AuthViewModel.kt @@ -69,6 +69,18 @@ class AuthViewModel @Inject constructor( } } + fun refreshToken() { + viewModelScope.launch { + repository.refreshToken() + .onSuccess { + // Token refreshed successfully + } + .onFailure { + // Refresh failed, will trigger logout via AuthInterceptor + } + } + } + private fun updatePushToken() { FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (task.isSuccessful) { diff --git a/client-mobile/core/network/AuthInterceptor.kt b/client-mobile/core/network/AuthInterceptor.kt index 16a9f3c..d0f8502 100644 --- a/client-mobile/core/network/AuthInterceptor.kt +++ b/client-mobile/core/network/AuthInterceptor.kt @@ -5,9 +5,7 @@ import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.runBlocking @Singleton class AuthInterceptor @Inject constructor( @@ -15,6 +13,10 @@ class AuthInterceptor @Inject constructor( private val navigationManager: core.utils.NavigationManager, private val authRepositoryProvider: javax.inject.Provider ) : Interceptor { + + @Volatile + private var isRefreshing = false + override fun intercept(chain: Interceptor.Chain): Response { val token = tokenManager.getToken() val request = chain.request().newBuilder().apply { @@ -23,16 +25,55 @@ class AuthInterceptor @Inject constructor( } }.build() - val response = chain.proceed(request) + var response = chain.proceed(request) if (response.code == 401) { - // Global logout - kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.Main) { - authRepositoryProvider.get().logout() - navigationManager.logout() + // Attempt to refresh token synchronously + val refreshedToken = refreshAuthToken() + + if (refreshedToken != null) { + // Close the old response before retrying + response.close() + + // Retry the original request with the new token + val retryRequest = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $refreshedToken") + .build() + response = chain.proceed(retryRequest) + } else { + // Refresh failed, logout + logoutUser() } } return response } + + private fun refreshAuthToken(): String? { + if (isRefreshing) { + // Already refreshing, wait and return current token + return tokenManager.getToken() + } + + return runBlocking { + isRefreshing = true + try { + val result = authRepositoryProvider.get().refreshToken() + if (result.isSuccess) { + result.getOrNull()?.token + } else { + null + } + } finally { + isRefreshing = false + } + } + } + + private fun logoutUser() { + runBlocking { + authRepositoryProvider.get().logout() + } + navigationManager.logout() + } } diff --git a/client-mobile/core/security/TokenManager.kt b/client-mobile/core/security/TokenManager.kt index abc4c4e..112ab94 100644 --- a/client-mobile/core/security/TokenManager.kt +++ b/client-mobile/core/security/TokenManager.kt @@ -20,11 +20,14 @@ class TokenManager @Inject constructor(context: Context) { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) - fun saveToken(token: String, userId: String? = null) { + fun saveToken(token: String, userId: String? = null, refreshToken: String? = null) { val editor = prefs.edit().putString("jwt_token", token) if (userId != null) { editor.putString("user_id", userId) } + if (refreshToken != null) { + editor.putString("refresh_token", refreshToken) + } editor.apply() } @@ -32,11 +35,15 @@ class TokenManager @Inject constructor(context: Context) { return prefs.getString("jwt_token", null) } + fun getRefreshToken(): String? { + return prefs.getString("refresh_token", null) + } + fun getUserId(): String? { return prefs.getString("user_id", null) } fun deleteToken() { - prefs.edit().remove("jwt_token").remove("user_id").apply() + prefs.edit().remove("jwt_token").remove("user_id").remove("refresh_token").apply() } }