diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt index b0589b2..b1d898d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt @@ -13,4 +13,6 @@ interface AuthRepo { suspend fun requireAuthHeader(): String suspend fun logout() + + fun invalidateAuthHeader(header: String) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt index 790ee4e..2d15301 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt @@ -37,12 +37,14 @@ class AuthRepoImpl @Inject constructor( }.getOrThrow() // Throw error to show it to user } - override suspend fun getAuthHeader(): String? { + override suspend fun getAuthHeader(): String? = runCatchingExceptCancel { Timber.v("getAuthHeader() called") - return currentAccount() + currentAccount() ?.let { getAuthToken(it) } ?.let { AUTH_HEADER_FORMAT.format(it) } - } + }.onFailure { + Timber.e(it, "getAuthHeader: can't request auth header") + }.getOrNull() private suspend fun getAuthToken(account: Account?): String? { return account?.let { accountManagerInteractor.getAuthToken(it) } @@ -67,6 +69,16 @@ class AuthRepoImpl @Inject constructor( accountManagerInteractor.removeAccount(account) } + override fun invalidateAuthHeader(header: String) { + Timber.v("invalidateAuthHeader() called with: header = $header") + val token = header.substringAfter("Bearer ") + if (token == header) { + Timber.w("invalidateAuthHeader: can't find token in $header") + } else { + accountManagerInteractor.invalidateAuthToken(token) + } + } + companion object { private const val AUTH_HEADER_FORMAT = "Bearer %s" } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt new file mode 100644 index 0000000..147f29b --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/AuthenticationInterceptor.kt @@ -0,0 +1,41 @@ +package gq.kirmanak.mealient.data.network + +import gq.kirmanak.mealient.data.auth.AuthRepo +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthenticationInterceptor @Inject constructor( + private val authRepo: AuthRepo, +) : Interceptor { + + private val authHeader: String? + get() = runBlocking { authRepo.getAuthHeader() } + + override fun intercept(chain: Interceptor.Chain): Response { + val currentHeader = authHeader ?: return chain.proceed(chain.request()) + val response = proceedWithAuthHeader(chain, currentHeader) + if (listOf(401, 403).contains(response.code)) { + authRepo.invalidateAuthHeader(currentHeader) + } + val newHeader = authHeader ?: return response + return proceedWithAuthHeader(chain, newHeader) + } + + private fun proceedWithAuthHeader( + chain: Interceptor.Chain, + authHeader: String, + ) = chain.proceed( + chain.request() + .newBuilder() + .header(HEADER_NAME, authHeader) + .build() + ) + + companion object { + private const val HEADER_NAME = "Authorization" + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt index feae5e9..325f96f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/OkHttpBuilder.kt @@ -5,17 +5,17 @@ import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject -class OkHttpBuilder -@Inject -constructor( +class OkHttpBuilder @Inject constructor( // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) - private val interceptors: Set<@JvmSuppressWildcards Interceptor> + private val authenticationInterceptor: AuthenticationInterceptor, + private val interceptors: Set<@JvmSuppressWildcards Interceptor>, ) { fun buildOkHttp(): OkHttpClient { Timber.v("buildOkHttp() called") - return OkHttpClient.Builder() - .apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) } - .build() + return OkHttpClient.Builder().apply { + addInterceptor(authenticationInterceptor) + for (interceptor in interceptors) addNetworkInterceptor(interceptor) + }.build() } } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt index e6b0f0a..4c2c2c2 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt @@ -1,6 +1,5 @@ package gq.kirmanak.mealient.data.recipes.network -import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse @@ -10,20 +9,19 @@ import javax.inject.Singleton @Singleton class RecipeDataSourceImpl @Inject constructor( - private val authRepo: AuthRepo, private val recipeServiceFactory: ServiceFactory, ) : RecipeDataSource { override suspend fun requestRecipes(start: Int, limit: Int): List { Timber.v("requestRecipes() called with: start = $start, limit = $limit") - val recipeSummary = getRecipeService().getRecipeSummary(start, limit, getToken()) + val recipeSummary = getRecipeService().getRecipeSummary(start, limit) Timber.v("requestRecipes() returned: $recipeSummary") return recipeSummary } override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse { Timber.v("requestRecipeInfo() called with: slug = $slug") - val recipeInfo = getRecipeService().getRecipe(slug, getToken()) + val recipeInfo = getRecipeService().getRecipe(slug) Timber.v("requestRecipeInfo() returned: $recipeInfo") return recipeInfo } @@ -32,6 +30,4 @@ class RecipeDataSourceImpl @Inject constructor( Timber.v("getRecipeService() called") return recipeServiceFactory.provideService() } - - private suspend fun getToken(): String? = authRepo.getAuthHeader() } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt index 19a64b9..d21516a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt @@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.recipes.network import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import retrofit2.http.GET -import retrofit2.http.Header import retrofit2.http.Path import retrofit2.http.Query @@ -12,12 +11,10 @@ interface RecipeService { suspend fun getRecipeSummary( @Query("start") start: Int, @Query("limit") limit: Int, - @Header("Authorization") authHeader: String?, ): List @GET("/api/recipes/{recipe_slug}") suspend fun getRecipe( @Path("recipe_slug") recipeSlug: String, - @Header("Authorization") authHeader: String?, ): GetRecipeResponse } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt index 74920ca..515bb2a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountAuthenticatorImpl.kt @@ -21,6 +21,11 @@ class AccountAuthenticatorImpl @Inject constructor( private val accountManager: AccountManager, ) : AbstractAccountAuthenticator(context) { + private val accountType: String + get() = accountParameters.accountType + private val authTokenType: String + get() = accountParameters.authTokenType + override fun getAuthToken( response: AccountAuthenticatorResponse, account: Account, @@ -50,7 +55,7 @@ class AccountAuthenticatorImpl @Inject constructor( return Bundle().apply { putString(AccountManager.KEY_ACCOUNT_NAME, account.name) - putString(AccountManager.KEY_ACCOUNT_TYPE, accountParameters.accountType) + putString(AccountManager.KEY_ACCOUNT_TYPE, accountType) putString(AccountManager.KEY_AUTHTOKEN, token) } } @@ -114,13 +119,13 @@ class AccountAuthenticatorImpl @Inject constructor( // end region private fun checkAccountType(accountType: String) { - if (accountType != accountParameters.accountType) { + if (accountType != this.accountType) { throw UnsupportedAccountType(accountType) } } private fun checkAuthTokenType(authTokenType: String) { - if (authTokenType != accountParameters.authTokenType) { + if (authTokenType != this.authTokenType) { throw UnsupportedAuthTokenType(authTokenType) } } diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt index 4cd2eea..3fff004 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractor.kt @@ -14,4 +14,6 @@ interface AccountManagerInteractor { fun accountUpdatesFlow(): Flow> suspend fun removeAccount(account: Account) + + fun invalidateAuthToken(token: String) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt index 3782e73..5e8a5bf 100644 --- a/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/service/auth/AccountManagerInteractorImpl.kt @@ -13,16 +13,21 @@ class AccountManagerInteractorImpl @Inject constructor( private val accountParameters: AccountParameters, ) : AccountManagerInteractor { + private val accountType: String + get() = accountParameters.accountType + private val authTokenType: String + get() = accountParameters.authTokenType + override fun getAccounts(): Array { Timber.v("getAccounts() called") - val accounts = accountManager.getAccountsByType(accountParameters.accountType) + val accounts = accountManager.getAccountsByType(accountType) Timber.v("getAccounts() returned: ${accounts.contentToString()}") return accounts } override suspend fun addAccount(email: String, password: String): Account { Timber.v("addAccount() called with: email = $email, password = $password") - val account = Account(email, accountParameters.accountType) + val account = Account(email, accountType) removeAccount(account) // Remove account if it was created earlier accountManager.addAccountExplicitly(account, password, null) return account @@ -32,7 +37,7 @@ class AccountManagerInteractorImpl @Inject constructor( Timber.v("getAuthToken() called with: account = $account") val bundle = accountManager.getAuthToken( account, - accountParameters.authTokenType, + authTokenType, null, null, null, @@ -49,7 +54,7 @@ class AccountManagerInteractorImpl @Inject constructor( override fun accountUpdatesFlow(): Flow> { Timber.v("accountUpdatesFlow() called") - return accountManager.accountUpdatesFlow(accountParameters.accountType) + return accountManager.accountUpdatesFlow(accountType) } override suspend fun removeAccount(account: Account) { @@ -57,4 +62,9 @@ class AccountManagerInteractorImpl @Inject constructor( val bundle = accountManager.removeAccount(account, null, null, null).await() Timber.d("removeAccount: result is ${bundle.result()}") } + + override fun invalidateAuthToken(token: String) { + Timber.v("resetAuthToken() called with: token = $token") + accountManager.invalidateAuthToken(accountType, token) + } } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt deleted file mode 100644 index 77e2ea6..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package gq.kirmanak.mealient.data.auth.impl - -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER -import gq.kirmanak.mealient.test.HiltRobolectricTest -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.junit.Test -import javax.inject.Inject - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltAndroidTest -class AuthStorageImplTest : HiltRobolectricTest() { - - @Inject - lateinit var subject: AuthStorageImpl - - @Test - fun `when storing auth data then doesn't throw`() = runTest { - subject.storeAuthData(TEST_AUTH_HEADER) - } - - @Test - fun `when reading token after storing data then returns token`() = runTest { - subject.storeAuthData(TEST_AUTH_HEADER) - assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER) - } - - @Test - fun `when reading token without storing data then returns null`() = runTest { - assertThat(subject.getAuthHeader()).isNull() - } - - @Test - fun `when didn't store auth data then first token is null`() = runTest { - assertThat(subject.authHeaderFlow.first()).isNull() - } - - @Test - fun `when stored auth data then first token is correct`() = runTest { - subject.storeAuthData(TEST_AUTH_HEADER) - assertThat(subject.authHeaderFlow.first()).isEqualTo(TEST_AUTH_HEADER) - } - - @Test - fun `when clearAuthData then first token is null`() = runTest { - subject.storeAuthData(TEST_AUTH_HEADER) - subject.clearAuthData() - assertThat(subject.authHeaderFlow.first()).isNull() - } - - @Test - fun `when clearAuthData then getToken returns null`() = runTest { - subject.storeAuthData(TEST_AUTH_HEADER) - subject.clearAuthData() - assertThat(subject.getAuthHeader()).isNull() - } -} \ No newline at end of file