Implement the simplest account manager authentication

This commit is contained in:
Kirill Kamakin
2022-04-05 16:51:53 +05:00
parent 096b5389bd
commit 57f4ec4e22
13 changed files with 124 additions and 233 deletions

View File

@@ -74,6 +74,7 @@ class MainActivity : AppCompatActivity() {
R.id.logout, R.id.login -> {
// When user clicks logout they don't want to be authorized
authViewModel.authRequested = item.itemId == R.id.login
authViewModel.logout()
true
}
else -> super.onOptionsItemSelected(item)

View File

@@ -1,14 +0,0 @@
package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage {
val authHeaderFlow: Flow<String?>
suspend fun storeAuthData(authHeader: String)
suspend fun getAuthHeader(): String?
suspend fun clearAuthData()
}

View File

@@ -1,8 +1,9 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import android.accounts.Account
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
@@ -11,28 +12,59 @@ import javax.inject.Singleton
@Singleton
class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource,
private val storage: AuthStorage,
private val accountManagerInteractor: AccountManagerInteractor,
) : AuthRepo {
override val isAuthorizedFlow: Flow<Boolean>
get() = storage.authHeaderFlow.map { it != null }
get() = accountManagerInteractor.accountUpdatesFlow()
.map { it.firstOrNull() }
.map { account ->
runCatchingExceptCancel { getAuthToken(account) }
.onFailure { Timber.e(it, "authHeaderObservable: can't get token") }
.getOrNull()
}.map { it != null }
override suspend fun authenticate(username: String, password: String) {
Timber.v("authenticate() called with: username = $username, password = $password")
val accessToken = dataSource.authenticate(username, password)
Timber.d("authenticate result is \"$accessToken\"")
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken))
val account = accountManagerInteractor.addAccount(username, password)
runCatchingExceptCancel {
getAuthToken(account) // Try to get token to check if password is correct
}.onFailure {
Timber.e(it, "authenticate: can't authorize")
removeAccount(account) // Remove account with incorrect password
}.onSuccess {
Timber.d("authenticate: successfully authorized")
}.getOrThrow() // Throw error to show it to user
}
override suspend fun getAuthHeader(): String? = storage.getAuthHeader()
override suspend fun getAuthHeader(): String? {
Timber.v("getAuthHeader() called")
return currentAccount()
?.let { getAuthToken(it) }
?.let { AUTH_HEADER_FORMAT.format(it) }
}
private suspend fun getAuthToken(account: Account?): String? {
return account?.let { accountManagerInteractor.getAuthToken(it) }
}
private fun currentAccount(): Account? {
val account = accountManagerInteractor.getAccounts().firstOrNull()
Timber.v("currentAccount() returned: $account")
return account
}
override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override suspend fun logout() {
Timber.v("logout() called")
storage.clearAuthData()
currentAccount()?.let { removeAccount(it) }
}
private suspend fun removeAccount(account: Account) {
Timber.v("removeAccount() called with: account = $account")
accountManagerInteractor.removeAccount(account)
}
companion object {

View File

@@ -1,52 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import androidx.datastore.preferences.core.Preferences
import dagger.hilt.android.scopes.ActivityScoped
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
@ActivityScoped
class AuthStorageImpl @Inject constructor(
private val accountManagerInteractorImpl: AccountManagerInteractor,
private val preferencesStorage: PreferencesStorage,
) : AuthStorage {
private val authHeaderKey: Preferences.Key<String>
get() = preferencesStorage.authHeaderKey
override val authHeaderFlow: Flow<String?>
get() = preferencesStorage.valueUpdates(authHeaderKey)
override suspend fun storeAuthData(authHeader: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader")
preferencesStorage.storeValues(Pair(authHeaderKey, authHeader))
}
override suspend fun getAuthHeader(): String? {
Timber.v("getAuthHeader() called")
val token = preferencesStorage.getValue(authHeaderKey)
Timber.d("getAuthHeader: header is \"$token\"")
return token
}
override fun authHeaderObservable(): Flow<String?> {
Timber.v("authHeaderObservable() called")
return accountManagerInteractorImpl.accountUpdatesFlow()
.map { it.firstOrNull() }
.map { account ->
account ?: return@map null
runCatching { accountManagerInteractorImpl.getAuthToken(account) }
.onFailure { Timber.e(it, "authHeaderObservable: can't get token") }
.getOrNull()
}
}
override suspend fun clearAuthData() {
Timber.v("clearAuthData() called")
preferencesStorage.removeValues(authHeaderKey)
}
}

View File

@@ -1,22 +1,25 @@
package gq.kirmanak.mealient.di
import android.accounts.AccountManager
import android.content.Context
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import gq.kirmanak.mealient.service.auth.AccountManagerInteractorImpl
import gq.kirmanak.mealient.service.auth.AccountParameters
import javax.inject.Singleton
@@ -26,8 +29,6 @@ interface AuthModule {
companion object {
const val ACCOUNT_TYPE = "Mealient"
@Provides
@Singleton
fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder,
@@ -54,9 +55,11 @@ interface AuthModule {
@Binds
@Singleton
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
@Binds
@Singleton
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
fun bindAccountManagerInteractor(
accountManagerInteractorImpl: AccountManagerInteractorImpl
): AccountManagerInteractor
}

View File

@@ -2,12 +2,12 @@ package gq.kirmanak.mealient.service.auth
import android.accounts.*
import android.content.Context
import android.content.Intent
import android.os.Bundle
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError
import gq.kirmanak.mealient.ui.auth.AuthenticatorActivity
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.service.auth.AuthenticatorException.*
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
@@ -21,55 +21,30 @@ class AccountAuthenticatorImpl @Inject constructor(
private val accountManager: AccountManager,
) : AbstractAccountAuthenticator(context) {
override fun addAccount(
response: AccountAuthenticatorResponse,
accountType: String,
authTokenType: String,
requiredFeatures: Array<out String>?,
options: Bundle,
): Bundle {
Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options")
try {
checkAccountType(accountType)
checkAuthTokenType(authTokenType)
} catch (e: AuthenticatorException) {
Timber.e(e, "addAccount: validation failure")
return e.bundle
}
val intent = Intent(context, AuthenticatorActivity::class.java)
return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
}
override fun getAuthToken(
response: AccountAuthenticatorResponse,
account: Account,
authTokenType: String,
options: Bundle
options: Bundle?
): Bundle {
Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
val password: String?
val baseUrl: String?
try {
val password = try {
checkAccountType(account.type)
checkAuthTokenType(authTokenType)
password = accountManager.getPassword(account)
?: throw AuthenticatorException.AccountNotFound(account)
baseUrl = options.getString(KEY_BASE_URL) ?: throw AuthenticatorException.NoBaseUrl
accountManager.getPassword(account) ?: throw AccountNotFound(account)
} catch (e: AuthenticatorException) {
Timber.e(e, "getAuthToken: validation failure")
return e.bundle
}
val token = try {
runBlocking { authDataSource.authenticate(account.name, password, baseUrl) }
} catch (e: RuntimeException) {
return when (e) {
is AuthenticationError.NotMealie -> AuthenticatorException.NotMealie.bundle
is AuthenticationError.Unauthorized -> AuthenticatorException.Unauthorized.bundle
else -> throw NetworkErrorException(e)
val token = runCatchingExceptCancel {
runBlocking { authDataSource.authenticate(account.name, password) }
}.getOrElse {
return when (it) {
is NetworkError.NotMealie -> NotMealie.bundle
is NetworkError.Unauthorized -> Unauthorized.bundle
else -> throw NetworkErrorException(it)
}
}
@@ -87,7 +62,18 @@ class AccountAuthenticatorImpl @Inject constructor(
options: Bundle?
): Bundle {
Timber.v("confirmCredentials() called with: response = $response, account = $account, options = $options")
return AuthenticatorException.UnsupportedOperation("confirmCredentials").bundle
return UnsupportedOperation("confirmCredentials").bundle
}
override fun addAccount(
response: AccountAuthenticatorResponse,
accountType: String,
authTokenType: String,
requiredFeatures: Array<out String>?,
options: Bundle?,
): Bundle {
Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options")
return UnsupportedOperation("addAccount").bundle
}
override fun editProperties(
@@ -114,7 +100,7 @@ class AccountAuthenticatorImpl @Inject constructor(
options: Bundle?
): Bundle {
Timber.v("updateCredentials() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
return AuthenticatorException.UnsupportedOperation("updateCredentials").bundle
return UnsupportedOperation("updateCredentials").bundle
}
override fun hasFeatures(
@@ -129,13 +115,13 @@ class AccountAuthenticatorImpl @Inject constructor(
private fun checkAccountType(accountType: String) {
if (accountType != accountParameters.accountType) {
throw AuthenticatorException.UnsupportedAccountType(accountType)
throw UnsupportedAccountType(accountType)
}
}
private fun checkAuthTokenType(authTokenType: String) {
if (authTokenType != accountParameters.accountType) {
throw AuthenticatorException.UnsupportedAccountType(authTokenType)
if (authTokenType != accountParameters.authTokenType) {
throw UnsupportedAuthTokenType(authTokenType)
}
}
}

View File

@@ -7,9 +7,11 @@ interface AccountManagerInteractor {
fun getAccounts(): Array<Account>
suspend fun addAccount(): Account
suspend fun addAccount(email: String, password: String): Account
suspend fun getAuthToken(account: Account): String
fun accountUpdatesFlow(): Flow<Array<Account>>
suspend fun removeAccount(account: Account)
}

View File

@@ -2,38 +2,30 @@ package gq.kirmanak.mealient.service.auth
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Activity
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ActivityScoped
@Singleton
class AccountManagerInteractorImpl @Inject constructor(
private val accountManager: AccountManager,
private val accountParameters: AccountParameters,
private val activity: Activity
) : AccountManagerInteractor {
override fun getAccounts(): Array<Account> {
Timber.v("getAccounts() called")
val accounts = accountManager.getAccountsByType(accountParameters.accountType)
Timber.v("getAccounts() returned: $accounts")
Timber.v("getAccounts() returned: ${accounts.contentToString()}")
return accounts
}
override suspend fun addAccount(): Account {
Timber.v("addAccount() called")
val bundle = accountManager.addAccount(
accountParameters.accountType,
accountParameters.authTokenType,
null,
null,
activity,
null,
null
).await()
return bundle.toAccount()
override suspend fun addAccount(email: String, password: String): Account {
Timber.v("addAccount() called with: email = $email, password = $password")
val account = Account(email, accountParameters.accountType)
removeAccount(account) // Remove account if it was created earlier
accountManager.addAccountExplicitly(account, password, null)
return account
}
override suspend fun getAuthToken(account: Account): String {
@@ -42,18 +34,27 @@ class AccountManagerInteractorImpl @Inject constructor(
account,
accountParameters.authTokenType,
null,
activity,
null,
null,
null
).await()
val receivedAccount = bundle.toAccount()
check(account == receivedAccount) { "Received account ($receivedAccount) differs from requested ($account)" }
check(account == receivedAccount) {
"Received account ($receivedAccount) differs from requested ($account)"
}
val token = bundle.authToken()
Timber.v("getAuthToken() returned: $token")
return token
}
override fun accountUpdatesFlow(): Flow<Array<Account>> {
Timber.v("accountUpdatesFlow() called")
return accountManager.accountUpdatesFlow(accountParameters.accountType)
}
override suspend fun removeAccount(account: Account) {
Timber.v("removeAccount() called with: account = $account")
val bundle = accountManager.removeAccount(account, null, null, null).await()
Timber.d("removeAccount: result is ${bundle.result()}")
}
}

View File

@@ -7,19 +7,16 @@ import android.accounts.AccountManagerFuture
import android.accounts.OnAccountsUpdateListener
import android.os.Build
import android.os.Bundle
import gq.kirmanak.mealient.extensions.logErrors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.onSuccess
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import timber.log.Timber
internal const val KEY_BASE_URL = "mealientBaseUrl"
internal suspend fun <T> AccountManagerFuture<T>.await(): T = withContext(Dispatchers.IO) { result }
internal fun Bundle.toAccount(): Account = Account(accountName(), accountType())
@@ -30,6 +27,8 @@ internal fun Bundle.accountName(): String = string(KEY_ACCOUNT_NAME) { "Account
internal fun Bundle.authToken(): String = string(KEY_AUTHTOKEN) { "Auth token is null" }
internal fun Bundle.result(): Boolean = getBoolean(KEY_BOOLEAN_RESULT)
private fun Bundle.string(key: String, error: () -> String) = checkNotNull(getString(key), error)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -37,12 +36,10 @@ internal fun AccountManager.accountUpdatesFlow(vararg types: String): Flow<Array
callbackFlow {
Timber.v("accountUpdatesFlow() called")
val listener = OnAccountsUpdateListener { accounts ->
Timber.d("accountUpdatesFlow: updated accounts = $accounts")
Timber.d("accountUpdatesFlow: updated accounts = ${accounts.contentToString()}")
val filtered = accounts.filter { types.contains(it.type) }.toTypedArray()
Timber.d("accountUpdatesFlow: filtered accounts = $filtered")
trySendBlocking(filtered)
.onSuccess { Timber.d("accountUpdatesFlow: sent accounts update") }
.onFailure { Timber.e(it, "accountUpdatesFlow: failed to send update") }
Timber.d("accountUpdatesFlow: filtered accounts = ${filtered.contentToString()}")
trySendBlocking(filtered).logErrors("accountUpdatesFlow")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
addOnAccountsUpdatedListener(listener, null, true, types)

View File

@@ -35,18 +35,15 @@ sealed class AuthenticatorException(
errorMessage = "$account not found"
)
object NoBaseUrl : AuthenticatorException(
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
errorMessage = "Base URL was not provided"
)
object Unauthorized : AuthenticatorException(
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
errorCode = ErrorCode.Unauthorized.ordinal,
errorMessage = "E-mail or password weren't correct"
)
object NotMealie : AuthenticatorException(
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
errorCode = ErrorCode.NotMealie.ordinal,
errorMessage = "Base URL must be pointing at a non Mealie server"
)
enum class ErrorCode { NotMealie, Unauthorized; }
}

View File

@@ -8,7 +8,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -32,18 +31,14 @@ class AuthenticationViewModel @Inject constructor(
var authRequested: Boolean by authRequestsFlow::value
var showLoginButton: Boolean by showLoginButtonFlow::value
init {
viewModelScope.launch {
authRequestsFlow.collect { isRequested ->
// Clear auth token on logout request
if (!isRequested) authRepo.logout()
}
}
}
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
authRepo.authenticate(username, password)
}.onFailure {
Timber.e(it, "authenticate: can't authenticate")
}
fun logout() {
Timber.v("logout() called")
viewModelScope.launch { authRepo.logout() }
}
}

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" />

View File

@@ -1,78 +1,22 @@
package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.RobolectricTest
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AuthRepoImplTest : RobolectricTest() {
class AuthRepoImplTest {
@MockK
lateinit var dataSource: AuthDataSource
@MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage
lateinit var accountManagerInteractor: AccountManagerInteractor
lateinit var subject: AuthRepoImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthRepoImpl(dataSource, storage)
subject = AuthRepoImpl(accountManagerInteractor)
}
@Test
fun `when not authenticated then first auth status is false`() = runTest {
coEvery { storage.authHeaderFlow } returns flowOf(null)
assertThat(subject.isAuthorizedFlow.first()).isFalse()
}
@Test
fun `when authenticated then first auth status is true`() = runTest {
coEvery { storage.authHeaderFlow } returns flowOf(TEST_AUTH_HEADER)
assertThat(subject.isAuthorizedFlow.first()).isTrue()
}
@Test(expected = Unauthorized::class)
fun `when authentication fails then authenticate throws`() = runTest {
coEvery {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
} throws Unauthorized(RuntimeException())
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
}
@Test
fun `when authenticated then getToken returns token`() = runTest {
coEvery { storage.getAuthHeader() } returns TEST_AUTH_HEADER
assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER)
}
@Test
fun `when authenticated successfully then stores token and url`() = runTest {
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerify { storage.storeAuthData(TEST_AUTH_HEADER) }
}
@Test
fun `when logout then clearAuthData is called`() = runTest {
subject.logout()
coVerify { storage.clearAuthData() }
}
// TODO write the actual tests
}