Implement token invalidation
This commit is contained in:
@@ -13,4 +13,6 @@ interface AuthRepo {
|
|||||||
suspend fun requireAuthHeader(): String
|
suspend fun requireAuthHeader(): String
|
||||||
|
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
|
|
||||||
|
fun invalidateAuthHeader(header: String)
|
||||||
}
|
}
|
||||||
@@ -37,12 +37,14 @@ class AuthRepoImpl @Inject constructor(
|
|||||||
}.getOrThrow() // Throw error to show it to user
|
}.getOrThrow() // Throw error to show it to user
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAuthHeader(): String? {
|
override suspend fun getAuthHeader(): String? = runCatchingExceptCancel {
|
||||||
Timber.v("getAuthHeader() called")
|
Timber.v("getAuthHeader() called")
|
||||||
return currentAccount()
|
currentAccount()
|
||||||
?.let { getAuthToken(it) }
|
?.let { getAuthToken(it) }
|
||||||
?.let { AUTH_HEADER_FORMAT.format(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? {
|
private suspend fun getAuthToken(account: Account?): String? {
|
||||||
return account?.let { accountManagerInteractor.getAuthToken(it) }
|
return account?.let { accountManagerInteractor.getAuthToken(it) }
|
||||||
@@ -67,6 +69,16 @@ class AuthRepoImpl @Inject constructor(
|
|||||||
accountManagerInteractor.removeAccount(account)
|
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 {
|
companion object {
|
||||||
private const val AUTH_HEADER_FORMAT = "Bearer %s"
|
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 timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class OkHttpBuilder
|
class OkHttpBuilder @Inject constructor(
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
|
// 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 {
|
fun buildOkHttp(): OkHttpClient {
|
||||||
Timber.v("buildOkHttp() called")
|
Timber.v("buildOkHttp() called")
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder().apply {
|
||||||
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
|
addInterceptor(authenticationInterceptor)
|
||||||
.build()
|
for (interceptor in interceptors) addNetworkInterceptor(interceptor)
|
||||||
|
}.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package gq.kirmanak.mealient.data.recipes.network
|
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.network.ServiceFactory
|
||||||
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
|
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
|
||||||
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
|
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
|
||||||
@@ -10,20 +9,19 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class RecipeDataSourceImpl @Inject constructor(
|
class RecipeDataSourceImpl @Inject constructor(
|
||||||
private val authRepo: AuthRepo,
|
|
||||||
private val recipeServiceFactory: ServiceFactory<RecipeService>,
|
private val recipeServiceFactory: ServiceFactory<RecipeService>,
|
||||||
) : RecipeDataSource {
|
) : RecipeDataSource {
|
||||||
|
|
||||||
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
|
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
|
||||||
Timber.v("requestRecipes() called with: start = $start, limit = $limit")
|
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")
|
Timber.v("requestRecipes() returned: $recipeSummary")
|
||||||
return recipeSummary
|
return recipeSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
|
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
|
||||||
Timber.v("requestRecipeInfo() called with: slug = $slug")
|
Timber.v("requestRecipeInfo() called with: slug = $slug")
|
||||||
val recipeInfo = getRecipeService().getRecipe(slug, getToken())
|
val recipeInfo = getRecipeService().getRecipe(slug)
|
||||||
Timber.v("requestRecipeInfo() returned: $recipeInfo")
|
Timber.v("requestRecipeInfo() returned: $recipeInfo")
|
||||||
return recipeInfo
|
return recipeInfo
|
||||||
}
|
}
|
||||||
@@ -32,6 +30,4 @@ class RecipeDataSourceImpl @Inject constructor(
|
|||||||
Timber.v("getRecipeService() called")
|
Timber.v("getRecipeService() called")
|
||||||
return recipeServiceFactory.provideService()
|
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.GetRecipeResponse
|
||||||
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
|
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Header
|
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
@@ -12,12 +11,10 @@ interface RecipeService {
|
|||||||
suspend fun getRecipeSummary(
|
suspend fun getRecipeSummary(
|
||||||
@Query("start") start: Int,
|
@Query("start") start: Int,
|
||||||
@Query("limit") limit: Int,
|
@Query("limit") limit: Int,
|
||||||
@Header("Authorization") authHeader: String?,
|
|
||||||
): List<GetRecipeSummaryResponse>
|
): List<GetRecipeSummaryResponse>
|
||||||
|
|
||||||
@GET("/api/recipes/{recipe_slug}")
|
@GET("/api/recipes/{recipe_slug}")
|
||||||
suspend fun getRecipe(
|
suspend fun getRecipe(
|
||||||
@Path("recipe_slug") recipeSlug: String,
|
@Path("recipe_slug") recipeSlug: String,
|
||||||
@Header("Authorization") authHeader: String?,
|
|
||||||
): GetRecipeResponse
|
): GetRecipeResponse
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,11 @@ class AccountAuthenticatorImpl @Inject constructor(
|
|||||||
private val accountManager: AccountManager,
|
private val accountManager: AccountManager,
|
||||||
) : AbstractAccountAuthenticator(context) {
|
) : AbstractAccountAuthenticator(context) {
|
||||||
|
|
||||||
|
private val accountType: String
|
||||||
|
get() = accountParameters.accountType
|
||||||
|
private val authTokenType: String
|
||||||
|
get() = accountParameters.authTokenType
|
||||||
|
|
||||||
override fun getAuthToken(
|
override fun getAuthToken(
|
||||||
response: AccountAuthenticatorResponse,
|
response: AccountAuthenticatorResponse,
|
||||||
account: Account,
|
account: Account,
|
||||||
@@ -50,7 +55,7 @@ class AccountAuthenticatorImpl @Inject constructor(
|
|||||||
|
|
||||||
return Bundle().apply {
|
return Bundle().apply {
|
||||||
putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
|
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)
|
putString(AccountManager.KEY_AUTHTOKEN, token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,13 +119,13 @@ class AccountAuthenticatorImpl @Inject constructor(
|
|||||||
// end region
|
// end region
|
||||||
|
|
||||||
private fun checkAccountType(accountType: String) {
|
private fun checkAccountType(accountType: String) {
|
||||||
if (accountType != accountParameters.accountType) {
|
if (accountType != this.accountType) {
|
||||||
throw UnsupportedAccountType(accountType)
|
throw UnsupportedAccountType(accountType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAuthTokenType(authTokenType: String) {
|
private fun checkAuthTokenType(authTokenType: String) {
|
||||||
if (authTokenType != accountParameters.authTokenType) {
|
if (authTokenType != this.authTokenType) {
|
||||||
throw UnsupportedAuthTokenType(authTokenType)
|
throw UnsupportedAuthTokenType(authTokenType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ interface AccountManagerInteractor {
|
|||||||
fun accountUpdatesFlow(): Flow<Array<Account>>
|
fun accountUpdatesFlow(): Flow<Array<Account>>
|
||||||
|
|
||||||
suspend fun removeAccount(account: Account)
|
suspend fun removeAccount(account: Account)
|
||||||
|
|
||||||
|
fun invalidateAuthToken(token: String)
|
||||||
}
|
}
|
||||||
@@ -13,16 +13,21 @@ class AccountManagerInteractorImpl @Inject constructor(
|
|||||||
private val accountParameters: AccountParameters,
|
private val accountParameters: AccountParameters,
|
||||||
) : AccountManagerInteractor {
|
) : AccountManagerInteractor {
|
||||||
|
|
||||||
|
private val accountType: String
|
||||||
|
get() = accountParameters.accountType
|
||||||
|
private val authTokenType: String
|
||||||
|
get() = accountParameters.authTokenType
|
||||||
|
|
||||||
override fun getAccounts(): Array<Account> {
|
override fun getAccounts(): Array<Account> {
|
||||||
Timber.v("getAccounts() called")
|
Timber.v("getAccounts() called")
|
||||||
val accounts = accountManager.getAccountsByType(accountParameters.accountType)
|
val accounts = accountManager.getAccountsByType(accountType)
|
||||||
Timber.v("getAccounts() returned: ${accounts.contentToString()}")
|
Timber.v("getAccounts() returned: ${accounts.contentToString()}")
|
||||||
return accounts
|
return accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addAccount(email: String, password: String): Account {
|
override suspend fun addAccount(email: String, password: String): Account {
|
||||||
Timber.v("addAccount() called with: email = $email, password = $password")
|
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
|
removeAccount(account) // Remove account if it was created earlier
|
||||||
accountManager.addAccountExplicitly(account, password, null)
|
accountManager.addAccountExplicitly(account, password, null)
|
||||||
return account
|
return account
|
||||||
@@ -32,7 +37,7 @@ class AccountManagerInteractorImpl @Inject constructor(
|
|||||||
Timber.v("getAuthToken() called with: account = $account")
|
Timber.v("getAuthToken() called with: account = $account")
|
||||||
val bundle = accountManager.getAuthToken(
|
val bundle = accountManager.getAuthToken(
|
||||||
account,
|
account,
|
||||||
accountParameters.authTokenType,
|
authTokenType,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -49,7 +54,7 @@ class AccountManagerInteractorImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun accountUpdatesFlow(): Flow<Array<Account>> {
|
override fun accountUpdatesFlow(): Flow<Array<Account>> {
|
||||||
Timber.v("accountUpdatesFlow() called")
|
Timber.v("accountUpdatesFlow() called")
|
||||||
return accountManager.accountUpdatesFlow(accountParameters.accountType)
|
return accountManager.accountUpdatesFlow(accountType)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun removeAccount(account: Account) {
|
override suspend fun removeAccount(account: Account) {
|
||||||
@@ -57,4 +62,9 @@ class AccountManagerInteractorImpl @Inject constructor(
|
|||||||
val bundle = accountManager.removeAccount(account, null, null, null).await()
|
val bundle = accountManager.removeAccount(account, null, null, null).await()
|
||||||
Timber.d("removeAccount: result is ${bundle.result()}")
|
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