Implement token invalidation
This commit is contained in:
@@ -13,4 +13,6 @@ interface AuthRepo {
|
||||
suspend fun requireAuthHeader(): String
|
||||
|
||||
suspend fun logout()
|
||||
|
||||
fun invalidateAuthHeader(header: String)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RecipeService>,
|
||||
) : RecipeDataSource {
|
||||
|
||||
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<GetRecipeSummaryResponse>
|
||||
|
||||
@GET("/api/recipes/{recipe_slug}")
|
||||
suspend fun getRecipe(
|
||||
@Path("recipe_slug") recipeSlug: String,
|
||||
@Header("Authorization") authHeader: String?,
|
||||
): GetRecipeResponse
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,6 @@ interface AccountManagerInteractor {
|
||||
fun accountUpdatesFlow(): Flow<Array<Account>>
|
||||
|
||||
suspend fun removeAccount(account: Account)
|
||||
|
||||
fun invalidateAuthToken(token: String)
|
||||
}
|
||||
@@ -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<Account> {
|
||||
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<Array<Account>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user