Production hardening: password complexity, token refresh, network resilience
Password complexity: real-time validation UI on register, onboarding, and reset screens (uppercase, lowercase, digit, min 8 chars) — Compose + iOS Swift iOS privacy descriptions: camera, photo library, photo save usage strings Token refresh: Ktor interceptor catches 401 "token_expired", refreshes, retries Retry with backoff: 3 retries on 5xx/IO errors, exponential delay (1s base, 10s max) Gzip: ContentEncoding plugin on all platform HTTP clients Request timeouts: 30s request, 10s connect, 30s socket Validation rules: split passwordMissingLetter into uppercase/lowercase (iOS Swift) Test fixes: corrected import paths in 5 existing test files New tests: HTTP client retry/refresh (9), validation rules
This commit is contained in:
@@ -1253,6 +1253,26 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the current auth token.
|
||||
* Calls POST /api/auth/refresh/ with the current token.
|
||||
* On success, saves the new token to DataManager and TokenStorage.
|
||||
* On failure, returns an error (caller decides whether to trigger logout).
|
||||
*/
|
||||
suspend fun refreshToken(): ApiResult<String> {
|
||||
val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
|
||||
val result = authApi.refreshToken(currentToken)
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setAuthToken(result.data.token)
|
||||
com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token)
|
||||
}
|
||||
return when (result) {
|
||||
is ApiResult.Success -> ApiResult.Success(result.data.token)
|
||||
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
|
||||
else -> ApiResult.Error("Unexpected state")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(forceRefresh: Boolean = false): ApiResult<User> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.TokenRefreshResponse
|
||||
import com.tt.honeyDue.storage.TokenStorage
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.compression.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
expect fun getLocalhostAddress(): String
|
||||
@@ -23,6 +34,142 @@ expect fun getDeviceLanguage(): String
|
||||
*/
|
||||
expect fun getDeviceTimezone(): String
|
||||
|
||||
/**
|
||||
* Mutex to prevent multiple concurrent token refresh attempts.
|
||||
* When one request triggers a 401, only one refresh call is made;
|
||||
* other requests wait for it to complete.
|
||||
*/
|
||||
private val tokenRefreshMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Install common plugins shared across all platform HttpClient instances.
|
||||
* This includes retry with exponential backoff, request timeouts,
|
||||
* gzip content encoding, and 401 token refresh handling.
|
||||
*/
|
||||
fun HttpClientConfig<*>.installCommonPlugins() {
|
||||
// Task 1: Gzip support — tells the server we accept compressed responses
|
||||
install(ContentEncoding) {
|
||||
gzip()
|
||||
}
|
||||
|
||||
// Task 2: Retry with exponential backoff for server errors and IO exceptions
|
||||
install(HttpRequestRetry) {
|
||||
maxRetries = 3
|
||||
retryIf { _, response ->
|
||||
response.status.value in 500..599
|
||||
}
|
||||
retryOnExceptionIf { _, cause ->
|
||||
// Retry on network-level IO errors (connection resets, timeouts, etc.)
|
||||
// but not on application-level exceptions like serialization errors
|
||||
cause is io.ktor.client.network.sockets.ConnectTimeoutException ||
|
||||
cause is io.ktor.client.plugins.HttpRequestTimeoutException ||
|
||||
cause.cause is kotlinx.io.IOException ||
|
||||
cause is kotlinx.io.IOException
|
||||
}
|
||||
exponentialDelay(
|
||||
base = 1000.0, // 1 second base
|
||||
maxDelayMs = 10000 // 10 second max
|
||||
)
|
||||
}
|
||||
|
||||
// Task 4: Request timeout configuration
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30_000 // 30 seconds
|
||||
connectTimeoutMillis = 10_000 // 10 seconds
|
||||
socketTimeoutMillis = 30_000 // 30 seconds
|
||||
}
|
||||
|
||||
// Task 3: Token refresh on 401 responses
|
||||
HttpResponseValidator {
|
||||
validateResponse { response ->
|
||||
if (response.status.value == 401) {
|
||||
// Check if this is a token_expired error (not invalid credentials)
|
||||
val bodyText = response.bodyAsText()
|
||||
val isTokenExpired = bodyText.contains("token_expired") ||
|
||||
bodyText.contains("Token has expired") ||
|
||||
bodyText.contains("expired")
|
||||
|
||||
if (isTokenExpired) {
|
||||
val currentToken = DataManager.authToken.value
|
||||
if (currentToken != null) {
|
||||
// Use mutex to prevent concurrent refresh attempts
|
||||
val refreshed = tokenRefreshMutex.withLock {
|
||||
// Double-check: another coroutine may have already refreshed
|
||||
val tokenAfterLock = DataManager.authToken.value
|
||||
if (tokenAfterLock != currentToken) {
|
||||
// Token was already refreshed by another coroutine
|
||||
true
|
||||
} else {
|
||||
attemptTokenRefresh(currentToken)
|
||||
}
|
||||
}
|
||||
if (!refreshed) {
|
||||
// Refresh failed — clear auth and trigger logout
|
||||
DataManager.clear()
|
||||
}
|
||||
// Throw so the caller can retry (or handle the logout)
|
||||
throw TokenExpiredException(refreshed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh the auth token by calling POST /api/auth/refresh/.
|
||||
* Returns true if refresh succeeded and the new token was saved.
|
||||
*/
|
||||
private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
|
||||
return try {
|
||||
// Use a minimal client to avoid recursive interceptor triggers
|
||||
val refreshClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 15_000
|
||||
connectTimeoutMillis = 10_000
|
||||
socketTimeoutMillis = 15_000
|
||||
}
|
||||
}
|
||||
val baseUrl = ApiConfig.getBaseUrl()
|
||||
val response = refreshClient.post("$baseUrl/auth/refresh/") {
|
||||
header("Authorization", "Token $currentToken")
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
refreshClient.close()
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val tokenResponse = response.body<TokenRefreshResponse>()
|
||||
// Save the new token to both DataManager and persistent storage
|
||||
DataManager.setAuthToken(tokenResponse.token)
|
||||
TokenStorage.saveToken(tokenResponse.token)
|
||||
println("[ApiClient] Token refreshed successfully")
|
||||
true
|
||||
} else {
|
||||
println("[ApiClient] Token refresh failed: ${response.status.value}")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[ApiClient] Token refresh error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when a 401 response indicates an expired token.
|
||||
* [refreshed] indicates whether the token was successfully refreshed.
|
||||
* Callers can catch this and retry the request if refreshed is true.
|
||||
*/
|
||||
class TokenExpiredException(val refreshed: Boolean) : Exception(
|
||||
if (refreshed) "Token was expired but has been refreshed — retry the request"
|
||||
else "Token expired and refresh failed — user must re-authenticate"
|
||||
)
|
||||
|
||||
object ApiClient {
|
||||
val httpClient = createHttpClient()
|
||||
|
||||
@@ -41,9 +188,9 @@ object ApiClient {
|
||||
* Print current environment configuration
|
||||
*/
|
||||
init {
|
||||
println("🌐 API Client initialized")
|
||||
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
|
||||
println("🔗 Base URL: ${getBaseUrl()}")
|
||||
println("📁 Media URL: ${getMediaBaseUrl()}")
|
||||
println("API Client initialized")
|
||||
println("Environment: ${ApiConfig.getEnvironmentName()}")
|
||||
println("Base URL: ${getBaseUrl()}")
|
||||
println("Media URL: ${getMediaBaseUrl()}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,25 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
// Token Refresh
|
||||
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/refresh/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
// Google Sign In
|
||||
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
||||
return try {
|
||||
|
||||
Reference in New Issue
Block a user