Merge pull request #6082 from k9mail/oauth_setup_flow
Add support for OAuth 2.0 (Gmail)
This commit is contained in:
commit
5065afef88
32 changed files with 651 additions and 95 deletions
|
@ -104,7 +104,14 @@ class ProvidersXmlDiscovery(
|
|||
uri.port
|
||||
}
|
||||
|
||||
return DiscoveredServerSettings(Protocols.IMAP, host, port, security, AuthType.PLAIN, username)
|
||||
// TODO: Remove this hack
|
||||
val authType = if (host == "imap.gmail.com" || host == "imap.googlemail.com") {
|
||||
AuthType.XOAUTH2
|
||||
} else {
|
||||
AuthType.PLAIN
|
||||
}
|
||||
|
||||
return DiscoveredServerSettings(Protocols.IMAP, host, port, security, authType, username)
|
||||
}
|
||||
|
||||
private fun Provider.toOutgoingServerSettings(email: String): DiscoveredServerSettings? {
|
||||
|
@ -127,7 +134,14 @@ class ProvidersXmlDiscovery(
|
|||
uri.port
|
||||
}
|
||||
|
||||
return DiscoveredServerSettings(Protocols.SMTP, host, port, security, AuthType.PLAIN, username)
|
||||
// TODO: Remove this hack
|
||||
val authType = if (host == "smtp.gmail.com" || host == "smtp.googlemail.com") {
|
||||
AuthType.XOAUTH2
|
||||
} else {
|
||||
AuthType.PLAIN
|
||||
}
|
||||
|
||||
return DiscoveredServerSettings(Protocols.SMTP, host, port, security, authType, username)
|
||||
}
|
||||
|
||||
private fun String.fillInUsernameTemplate(email: String, user: String, domain: String): String {
|
||||
|
|
|
@ -20,13 +20,13 @@ class ProvidersXmlDiscoveryTest : RobolectricTest() {
|
|||
with(connectionSettings!!.incoming.first()) {
|
||||
assertThat(host).isEqualTo("imap.gmail.com")
|
||||
assertThat(security).isEqualTo(ConnectionSecurity.SSL_TLS_REQUIRED)
|
||||
assertThat(authType).isEqualTo(AuthType.PLAIN)
|
||||
assertThat(authType).isEqualTo(AuthType.XOAUTH2)
|
||||
assertThat(username).isEqualTo("user@gmail.com")
|
||||
}
|
||||
with(connectionSettings.outgoing.first()) {
|
||||
assertThat(host).isEqualTo("smtp.gmail.com")
|
||||
assertThat(security).isEqualTo(ConnectionSecurity.SSL_TLS_REQUIRED)
|
||||
assertThat(authType).isEqualTo(AuthType.PLAIN)
|
||||
assertThat(authType).isEqualTo(AuthType.XOAUTH2)
|
||||
assertThat(username).isEqualTo("user@gmail.com")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,10 @@ class Account(override val uuid: String) : BaseAccount {
|
|||
internalOutgoingServerSettings = value
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var oAuthState: String? = null
|
||||
|
||||
/**
|
||||
* Storage provider ID, used to locate and manage the underlying DB/file storage.
|
||||
*/
|
||||
|
|
|
@ -36,6 +36,7 @@ class AccountPreferenceSerializer(
|
|||
outgoingServerSettings = serverSettingsSerializer.deserialize(
|
||||
storage.getString("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY", "")
|
||||
)
|
||||
oAuthState = storage.getString("$accountUuid.oAuthState", null)
|
||||
localStorageProviderId = storage.getString("$accountUuid.localStorageProvider", storageManager.defaultProviderId)
|
||||
name = storage.getString("$accountUuid.description", null)
|
||||
alwaysBcc = storage.getString("$accountUuid.alwaysBcc", alwaysBcc)
|
||||
|
@ -240,6 +241,7 @@ class AccountPreferenceSerializer(
|
|||
with(account) {
|
||||
editor.putString("$accountUuid.$INCOMING_SERVER_SETTINGS_KEY", serverSettingsSerializer.serialize(incomingServerSettings))
|
||||
editor.putString("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY", serverSettingsSerializer.serialize(outgoingServerSettings))
|
||||
editor.putString("$accountUuid.oAuthState", oAuthState)
|
||||
editor.putString("$accountUuid.localStorageProvider", localStorageProviderId)
|
||||
editor.putString("$accountUuid.description", name)
|
||||
editor.putString("$accountUuid.alwaysBcc", alwaysBcc)
|
||||
|
@ -359,6 +361,7 @@ class AccountPreferenceSerializer(
|
|||
|
||||
editor.remove("$accountUuid.$INCOMING_SERVER_SETTINGS_KEY")
|
||||
editor.remove("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY")
|
||||
editor.remove("$accountUuid.oAuthState")
|
||||
editor.remove("$accountUuid.description")
|
||||
editor.remove("$accountUuid.name")
|
||||
editor.remove("$accountUuid.email")
|
||||
|
|
|
@ -201,7 +201,7 @@ class Preferences internal constructor(
|
|||
val defaultAccount: Account?
|
||||
get() = accounts.firstOrNull()
|
||||
|
||||
fun saveAccount(account: Account) {
|
||||
override fun saveAccount(account: Account) {
|
||||
ensureAssignedAccountNumber(account)
|
||||
processChangedValues(account)
|
||||
|
||||
|
|
|
@ -13,4 +13,5 @@ interface AccountManager {
|
|||
fun moveAccount(account: Account, newPosition: Int)
|
||||
fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener)
|
||||
fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener)
|
||||
fun saveAccount(account: Account)
|
||||
}
|
||||
|
|
|
@ -65,6 +65,12 @@ android {
|
|||
|
||||
signingConfigs {
|
||||
release
|
||||
debug {
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
storeFile = file("$rootProject.projectDir/debug.keystore")
|
||||
storePassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
@ -76,16 +82,22 @@ android {
|
|||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
|
||||
buildConfigField "boolean", "DEVELOPER_MODE", "false"
|
||||
buildConfigField "String", "OAUTH_GMAIL_CLIENT_ID", "\"262622259280-hhmh92rhklkg2k1tjil69epo0o9a12jm.apps.googleusercontent.com\""
|
||||
|
||||
manifestPlaceholders = ['appAuthRedirectScheme': 'com.fsck.k9']
|
||||
}
|
||||
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
testCoverageEnabled rootProject.testCoverage
|
||||
|
||||
signingConfig signingConfigs.debug
|
||||
|
||||
minifyEnabled false
|
||||
|
||||
buildConfigField "boolean", "DEVELOPER_MODE", "true"
|
||||
buildConfigField "String", "OAUTH_GMAIL_CLIENT_ID", "\"262622259280-5qb3vtj68d5dtudmaif4g9vd3cpar8r3.apps.googleusercontent.com\""
|
||||
|
||||
manifestPlaceholders = ['appAuthRedirectScheme': 'com.fsck.k9.debug']
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.activity.setup.OAuthCredentials
|
||||
import com.fsck.k9.auth.K9OAuthCredentials
|
||||
import com.fsck.k9.backends.backendsModule
|
||||
import com.fsck.k9.controller.ControllerExtension
|
||||
import com.fsck.k9.crypto.EncryptionExtractor
|
||||
|
@ -29,6 +31,7 @@ private val mainAppModule = module {
|
|||
single(named("controllerExtensions")) { emptyList<ControllerExtension>() }
|
||||
single<EncryptionExtractor> { OpenPgpEncryptionExtractor.newInstance() }
|
||||
single<StoragePersister> { K9StoragePersister(get()) }
|
||||
factory<OAuthCredentials> { K9OAuthCredentials() }
|
||||
}
|
||||
|
||||
val appModules = listOf(
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package com.fsck.k9.auth
|
||||
|
||||
import com.fsck.k9.BuildConfig
|
||||
import com.fsck.k9.activity.setup.OAuthCredentials
|
||||
|
||||
class K9OAuthCredentials : OAuthCredentials {
|
||||
override val gmailClientId: String
|
||||
get() = BuildConfig.OAUTH_GMAIL_CLIENT_ID
|
||||
}
|
|
@ -1,11 +1,12 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.backend.BackendFactory
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.imap.ImapBackend
|
||||
import com.fsck.k9.backend.imap.ImapPushConfigProvider
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.power.PowerManager
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshManager
|
||||
|
@ -23,7 +24,8 @@ class ImapBackendFactory(
|
|||
private val powerManager: PowerManager,
|
||||
private val idleRefreshManager: IdleRefreshManager,
|
||||
private val backendStorageFactory: K9BackendStorageFactory,
|
||||
private val trustedSocketFactory: TrustedSocketFactory
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
private val context: Context
|
||||
) : BackendFactory {
|
||||
override fun createBackend(account: Account): Backend {
|
||||
val accountName = account.displayName
|
||||
|
@ -44,7 +46,12 @@ class ImapBackendFactory(
|
|||
}
|
||||
|
||||
private fun createImapStore(account: Account): ImapStore {
|
||||
val oAuth2TokenProvider: OAuth2TokenProvider? = null
|
||||
val oAuth2TokenProvider = if (account.incomingServerSettings.authenticationType == AuthType.XOAUTH2) {
|
||||
RealOAuth2TokenProvider(context, accountManager, account)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val config = createImapStoreConfig(account)
|
||||
return ImapStore.create(
|
||||
account.incomingServerSettings,
|
||||
|
@ -67,7 +74,12 @@ class ImapBackendFactory(
|
|||
|
||||
private fun createSmtpTransport(account: Account): SmtpTransport {
|
||||
val serverSettings = account.outgoingServerSettings
|
||||
val oauth2TokenProvider: OAuth2TokenProvider? = null
|
||||
val oauth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) {
|
||||
RealOAuth2TokenProvider(context, accountManager, account)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,8 @@ val backendsModule = module {
|
|||
powerManager = get(),
|
||||
idleRefreshManager = get(),
|
||||
backendStorageFactory = get(),
|
||||
trustedSocketFactory = get()
|
||||
trustedSocketFactory = get(),
|
||||
context = get()
|
||||
)
|
||||
}
|
||||
single<SystemAlarmManager> { AndroidAlarmManager(context = get(), alarmManager = get()) }
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.preferences.AccountManager
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationService
|
||||
|
||||
class RealOAuth2TokenProvider(
|
||||
context: Context,
|
||||
private val accountManager: AccountManager,
|
||||
private val account: Account
|
||||
) : OAuth2TokenProvider {
|
||||
private val authService = AuthorizationService(context)
|
||||
private var requestFreshToken = false
|
||||
|
||||
override fun getToken(timeoutMillis: Long): String {
|
||||
val latch = CountDownLatch(1)
|
||||
var token: String? = null
|
||||
var exception: AuthorizationException? = null
|
||||
|
||||
val authState = account.oAuthState?.let { AuthState.jsonDeserialize(it) }
|
||||
?: throw AuthenticationFailedException("Login required")
|
||||
|
||||
if (requestFreshToken) {
|
||||
authState.needsTokenRefresh = true
|
||||
}
|
||||
|
||||
val oldAccessToken = authState.accessToken
|
||||
|
||||
authState.performActionWithFreshTokens(authService) { accessToken: String?, _, authException: AuthorizationException? ->
|
||||
token = accessToken
|
||||
exception = authException
|
||||
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
latch.await(timeoutMillis, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (exception != null || token != oldAccessToken) {
|
||||
requestFreshToken = false
|
||||
account.oAuthState = authState.jsonSerializeString()
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
|
||||
exception?.let { authException ->
|
||||
throw AuthenticationFailedException(
|
||||
message = "Failed to fetch an access token",
|
||||
throwable = authException,
|
||||
messageFromServer = authException.error
|
||||
)
|
||||
}
|
||||
|
||||
return token ?: throw AuthenticationFailedException("Failed to fetch an access token")
|
||||
}
|
||||
|
||||
override fun invalidateToken() {
|
||||
requestFreshToken = true
|
||||
}
|
||||
}
|
|
@ -40,6 +40,7 @@ dependencies {
|
|||
implementation "com.mikepenz:fastadapter-extensions-drag:${versions.fastAdapter}"
|
||||
implementation "com.mikepenz:fastadapter-extensions-utils:${versions.fastAdapter}"
|
||||
implementation 'de.hdodenhof:circleimageview:3.1.0'
|
||||
api 'net.openid:appauth:0.11.1'
|
||||
|
||||
implementation "commons-io:commons-io:${versions.commonsIo}"
|
||||
implementation "androidx.core:core-ktx:${versions.androidxCore}"
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
package com.fsck.k9.activity
|
||||
|
||||
import com.fsck.k9.activity.setup.AuthViewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val activityModule = module {
|
||||
single { MessageLoaderHelperFactory(get(), get()) }
|
||||
single { MessageLoaderHelperFactory(messageViewInfoExtractorFactory = get(), htmlSettingsProvider = get()) }
|
||||
viewModel { AuthViewModel(application = get(), accountManager = get(), oauthCredentials = get()) }
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
|
@ -25,6 +26,8 @@ import com.fsck.k9.mailstore.SpecialLocalFoldersCreator
|
|||
import com.fsck.k9.ui.ConnectionSettings
|
||||
import com.fsck.k9.ui.R
|
||||
import com.fsck.k9.ui.base.K9Activity
|
||||
import com.fsck.k9.ui.getEnum
|
||||
import com.fsck.k9.ui.putEnum
|
||||
import com.fsck.k9.ui.settings.ExtraAccountDiscovery
|
||||
import com.fsck.k9.view.ClientCertificateSpinner
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
@ -46,12 +49,15 @@ class AccountSetupBasics : K9Activity() {
|
|||
|
||||
private lateinit var emailView: TextInputEditText
|
||||
private lateinit var passwordView: TextInputEditText
|
||||
private lateinit var passwordLayout: View
|
||||
private lateinit var clientCertificateCheckBox: CheckBox
|
||||
private lateinit var clientCertificateSpinner: ClientCertificateSpinner
|
||||
private lateinit var advancedOptionsContainer: View
|
||||
private lateinit var nextButton: Button
|
||||
private lateinit var manualSetupButton: Button
|
||||
private lateinit var allowClientCertificateView: ViewGroup
|
||||
|
||||
private var uiState = UiState.EMAIL_ADDRESS_ONLY
|
||||
private var account: Account? = null
|
||||
private var checkedIncoming = false
|
||||
|
||||
|
@ -62,14 +68,15 @@ class AccountSetupBasics : K9Activity() {
|
|||
|
||||
emailView = findViewById(R.id.account_email)
|
||||
passwordView = findViewById(R.id.account_password)
|
||||
passwordLayout = findViewById(R.id.account_password_layout)
|
||||
clientCertificateCheckBox = findViewById(R.id.account_client_certificate)
|
||||
clientCertificateSpinner = findViewById(R.id.account_client_certificate_spinner)
|
||||
allowClientCertificateView = findViewById(R.id.account_allow_client_certificate)
|
||||
advancedOptionsContainer = findViewById(R.id.foldable_advanced_options)
|
||||
nextButton = findViewById(R.id.next)
|
||||
manualSetupButton = findViewById(R.id.manual_setup)
|
||||
|
||||
manualSetupButton.setOnClickListener { onManualSetup() }
|
||||
nextButton.setOnClickListener { onNext() }
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -82,12 +89,15 @@ class AccountSetupBasics : K9Activity() {
|
|||
*/
|
||||
initializeViewListeners()
|
||||
validateFields()
|
||||
|
||||
updateUi()
|
||||
}
|
||||
|
||||
private fun initializeViewListeners() {
|
||||
val textWatcher = object : SimpleTextWatcher() {
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
validateFields()
|
||||
val checkPassword = uiState == UiState.PASSWORD_FLOW
|
||||
validateFields(checkPassword)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,9 +119,25 @@ class AccountSetupBasics : K9Activity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateUi() {
|
||||
when (uiState) {
|
||||
UiState.EMAIL_ADDRESS_ONLY -> {
|
||||
passwordLayout.isVisible = false
|
||||
advancedOptionsContainer.isVisible = false
|
||||
nextButton.setOnClickListener { attemptAutoSetupUsingOnlyEmailAddress() }
|
||||
}
|
||||
UiState.PASSWORD_FLOW -> {
|
||||
passwordLayout.isVisible = true
|
||||
advancedOptionsContainer.isVisible = true
|
||||
nextButton.setOnClickListener { attemptAutoSetup() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
outState.putEnum(STATE_KEY_UI_STATE, uiState)
|
||||
outState.putString(EXTRA_ACCOUNT, account?.uuid)
|
||||
outState.putBoolean(STATE_KEY_CHECKED_INCOMING, checkedIncoming)
|
||||
}
|
||||
|
@ -119,6 +145,8 @@ class AccountSetupBasics : K9Activity() {
|
|||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
|
||||
uiState = savedInstanceState.getEnum(STATE_KEY_UI_STATE, UiState.EMAIL_ADDRESS_ONLY)
|
||||
|
||||
val accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT)
|
||||
if (accountUuid != null) {
|
||||
account = preferences.getAccount(accountUuid)
|
||||
|
@ -132,9 +160,10 @@ class AccountSetupBasics : K9Activity() {
|
|||
allowClientCertificateView.isVisible = usingCertificates
|
||||
}
|
||||
|
||||
private fun validateFields() {
|
||||
private fun validateFields(checkPassword: Boolean = true) {
|
||||
val email = emailView.text?.toString().orEmpty()
|
||||
val valid = requiredFieldValid(emailView) && emailValidator.isValidAddressOnly(email) && isPasswordFieldValid()
|
||||
val valid = requiredFieldValid(emailView) && emailValidator.isValidAddressOnly(email) &&
|
||||
(!checkPassword || isPasswordFieldValid())
|
||||
|
||||
nextButton.isEnabled = valid
|
||||
nextButton.isFocusable = valid
|
||||
|
@ -149,7 +178,37 @@ class AccountSetupBasics : K9Activity() {
|
|||
clientCertificateChecked && clientCertificateAlias != null
|
||||
}
|
||||
|
||||
private fun onNext() {
|
||||
private fun attemptAutoSetupUsingOnlyEmailAddress() {
|
||||
val email = emailView.text?.toString() ?: error("Email missing")
|
||||
|
||||
val extraConnectionSettings = ExtraAccountDiscovery.discover(email)
|
||||
if (extraConnectionSettings != null) {
|
||||
finishAutoSetup(extraConnectionSettings)
|
||||
return
|
||||
}
|
||||
|
||||
val connectionSettings = providersXmlDiscoveryDiscover(email)
|
||||
|
||||
if (connectionSettings != null &&
|
||||
connectionSettings.incoming.authenticationType == AuthType.XOAUTH2 &&
|
||||
connectionSettings.outgoing.authenticationType == AuthType.XOAUTH2
|
||||
) {
|
||||
finishAutoSetup(connectionSettings)
|
||||
} else {
|
||||
startPasswordFlow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPasswordFlow() {
|
||||
uiState = UiState.PASSWORD_FLOW
|
||||
|
||||
updateUi()
|
||||
validateFields()
|
||||
|
||||
passwordView.requestFocus()
|
||||
}
|
||||
|
||||
private fun attemptAutoSetup() {
|
||||
if (clientCertificateCheckBox.isChecked) {
|
||||
// Auto-setup doesn't support client certificates.
|
||||
onManualSetup()
|
||||
|
@ -272,8 +331,14 @@ class AccountSetupBasics : K9Activity() {
|
|||
}
|
||||
}
|
||||
|
||||
private enum class UiState {
|
||||
EMAIL_ADDRESS_ONLY,
|
||||
PASSWORD_FLOW
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ACCOUNT = "com.fsck.k9.AccountSetupBasics.account"
|
||||
private const val STATE_KEY_UI_STATE = "com.fsck.k9.AccountSetupBasics.uiState"
|
||||
private const val STATE_KEY_CHECKED_INCOMING = "com.fsck.k9.AccountSetupBasics.checkedIncoming"
|
||||
|
||||
@JvmStatic
|
||||
|
|
|
@ -18,6 +18,7 @@ import com.fsck.k9.Preferences
|
|||
import com.fsck.k9.controller.MessagingController
|
||||
import com.fsck.k9.fragment.ConfirmationDialogFragment
|
||||
import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.CertificateValidationException
|
||||
import com.fsck.k9.mail.MailServerDirection
|
||||
|
@ -25,13 +26,16 @@ import com.fsck.k9.mail.filter.Hex
|
|||
import com.fsck.k9.preferences.Protocols
|
||||
import com.fsck.k9.ui.R
|
||||
import com.fsck.k9.ui.base.K9Activity
|
||||
import com.fsck.k9.ui.observe
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.cert.CertificateEncodingException
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
@ -41,6 +45,8 @@ import timber.log.Timber
|
|||
* while its thread is running.
|
||||
*/
|
||||
class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListener {
|
||||
private val authViewModel: AuthViewModel by viewModel()
|
||||
|
||||
private val messagingController: MessagingController by inject()
|
||||
private val preferences: Preferences by inject()
|
||||
private val localKeyStoreManager: LocalKeyStoreManager by inject()
|
||||
|
@ -63,6 +69,33 @@ class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListen
|
|||
super.onCreate(savedInstanceState)
|
||||
setLayout(R.layout.account_setup_check_settings)
|
||||
|
||||
authViewModel.init(activityResultRegistry, lifecycle)
|
||||
|
||||
authViewModel.uiState.observe(this) { state ->
|
||||
when (state) {
|
||||
AuthFlowState.Idle -> {
|
||||
return@observe
|
||||
}
|
||||
AuthFlowState.Success -> {
|
||||
startCheckServerSettings()
|
||||
}
|
||||
AuthFlowState.Canceled -> {
|
||||
showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_canceled)
|
||||
}
|
||||
is AuthFlowState.Failed -> {
|
||||
showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_failed, state)
|
||||
}
|
||||
AuthFlowState.NotSupported -> {
|
||||
showErrorDialog(R.string.account_setup_failed_dlg_oauth_not_supported)
|
||||
}
|
||||
AuthFlowState.BrowserNotFound -> {
|
||||
showErrorDialog(R.string.account_setup_failed_dlg_browser_not_found)
|
||||
}
|
||||
}
|
||||
|
||||
authViewModel.authResultConsumed()
|
||||
}
|
||||
|
||||
messageView = findViewById(R.id.message)
|
||||
progressBar = findViewById(R.id.progress)
|
||||
findViewById<View>(R.id.cancel).setOnClickListener { onCancel() }
|
||||
|
@ -75,7 +108,26 @@ class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListen
|
|||
direction = intent.getSerializableExtra(EXTRA_CHECK_DIRECTION) as CheckDirection?
|
||||
?: error("Missing CheckDirection")
|
||||
|
||||
CheckAccountTask(account).execute(direction)
|
||||
if (savedInstanceState == null) {
|
||||
if (needsAuthorization()) {
|
||||
setMessage(R.string.account_setup_check_settings_authenticate)
|
||||
authViewModel.login(account)
|
||||
} else {
|
||||
startCheckServerSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun needsAuthorization(): Boolean {
|
||||
return (
|
||||
account.incomingServerSettings.authenticationType == AuthType.XOAUTH2 ||
|
||||
account.outgoingServerSettings.authenticationType == AuthType.XOAUTH2
|
||||
) &&
|
||||
!authViewModel.isAuthorized(account)
|
||||
}
|
||||
|
||||
private fun startCheckServerSettings() {
|
||||
CheckAccountTask(account).executeOnExecutor(Executors.newSingleThreadExecutor(), direction)
|
||||
}
|
||||
|
||||
private fun handleCertificateValidationException(exception: CertificateValidationException) {
|
||||
|
|
|
@ -152,9 +152,6 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
|
|||
}
|
||||
});
|
||||
|
||||
mAuthTypeAdapter = AuthTypeAdapter.get(this);
|
||||
mAuthTypeView.setAdapter(mAuthTypeAdapter);
|
||||
|
||||
/*
|
||||
* Only allow digits in the port field.
|
||||
*/
|
||||
|
@ -173,6 +170,10 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
|
|||
mAccount = Preferences.getPreferences(this).getAccount(accountUuid);
|
||||
}
|
||||
|
||||
boolean oAuthSupported = mAccount.getIncomingServerSettings().type.equals(Protocols.IMAP);
|
||||
mAuthTypeAdapter = AuthTypeAdapter.get(this, oAuthSupported);
|
||||
mAuthTypeView.setAdapter(mAuthTypeAdapter);
|
||||
|
||||
boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction());
|
||||
if (editSettings) {
|
||||
TextInputLayoutHelper.configureAuthenticatedPasswordToggle(
|
||||
|
@ -412,17 +413,14 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
|
|||
* Shows/hides password field and client certificate spinner
|
||||
*/
|
||||
private void updateViewFromAuthType() {
|
||||
AuthType authType = getSelectedAuthType();
|
||||
boolean isAuthTypeExternal = (AuthType.EXTERNAL == authType);
|
||||
|
||||
if (isAuthTypeExternal) {
|
||||
|
||||
// hide password fields, show client certificate fields
|
||||
mPasswordLayoutView.setVisibility(View.GONE);
|
||||
} else {
|
||||
|
||||
// show password fields, hide client certificate fields
|
||||
mPasswordLayoutView.setVisibility(View.VISIBLE);
|
||||
switch (getSelectedAuthType()) {
|
||||
case EXTERNAL:
|
||||
case XOAUTH2:
|
||||
mPasswordLayoutView.setVisibility(View.GONE);
|
||||
break;
|
||||
default:
|
||||
mPasswordLayoutView.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -433,8 +431,9 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
|
|||
private void updateViewFromSecurity() {
|
||||
ConnectionSecurity security = getSelectedSecurity();
|
||||
boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security));
|
||||
boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2;
|
||||
|
||||
if (isUsingTLS) {
|
||||
if (isUsingTLS && !isUsingOAuth) {
|
||||
mAllowClientCertificateView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
mAllowClientCertificateView.setVisibility(View.GONE);
|
||||
|
@ -505,9 +504,13 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
|
|||
&& hasConnectionSecurity
|
||||
&& hasValidCertificateAlias;
|
||||
|
||||
boolean hasValidOAuthSettings = hasValidUserName
|
||||
&& hasConnectionSecurity
|
||||
&& authType == AuthType.XOAUTH2;
|
||||
|
||||
mNextButton.setEnabled(Utility.domainFieldValid(mServerView)
|
||||
&& Utility.requiredFieldValid(mPortView)
|
||||
&& (hasValidPasswordSettings || hasValidExternalAuthSettings));
|
||||
&& (hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings));
|
||||
Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
|
||||
}
|
||||
|
||||
|
|
|
@ -128,7 +128,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
|
||||
mSecurityTypeView.setAdapter(ConnectionSecurityAdapter.get(this));
|
||||
|
||||
mAuthTypeAdapter = AuthTypeAdapter.get(this);
|
||||
mAuthTypeAdapter = AuthTypeAdapter.get(this, true);
|
||||
mAuthTypeView.setAdapter(mAuthTypeAdapter);
|
||||
|
||||
/*
|
||||
|
@ -167,8 +167,6 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
try {
|
||||
ServerSettings settings = mAccount.getOutgoingServerSettings();
|
||||
|
||||
updateAuthPlainTextFromSecurityType(settings.connectionSecurity);
|
||||
updateViewFromSecurity(settings.connectionSecurity);
|
||||
if (savedInstanceState == null) {
|
||||
// The first item is selected if settings.authenticationType is null or is not in mAuthTypeAdapter
|
||||
mCurrentAuthTypeViewPosition = mAuthTypeAdapter.getAuthPosition(settings.authenticationType);
|
||||
|
@ -195,6 +193,9 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
}
|
||||
mSecurityTypeView.setSelection(mCurrentSecurityTypeViewPosition, false);
|
||||
|
||||
updateAuthPlainTextFromSecurityType(getSelectedSecurity());
|
||||
updateViewFromSecurity();
|
||||
|
||||
if (!settings.username.isEmpty()) {
|
||||
mUsernameView.setText(settings.username);
|
||||
mRequireLoginView.setChecked(true);
|
||||
|
@ -256,7 +257,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
*/
|
||||
if (mCurrentSecurityTypeViewPosition != position) {
|
||||
updatePortFromSecurityType();
|
||||
updateViewFromSecurity(getSelectedSecurity());
|
||||
updateViewFromSecurity();
|
||||
boolean isInsecure = (ConnectionSecurity.NONE == getSelectedSecurity());
|
||||
boolean isAuthExternal = (AuthType.EXTERNAL == getSelectedAuthType());
|
||||
boolean loginNotRequired = !mRequireLoginView.isChecked();
|
||||
|
@ -297,6 +298,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
}
|
||||
|
||||
updateViewFromAuthType();
|
||||
updateViewFromSecurity();
|
||||
validateFields();
|
||||
AuthType selection = getSelectedAuthType();
|
||||
|
||||
|
@ -367,25 +369,26 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
* Shows/hides password field
|
||||
*/
|
||||
private void updateViewFromAuthType() {
|
||||
AuthType authType = getSelectedAuthType();
|
||||
boolean isAuthTypeExternal = (AuthType.EXTERNAL == authType);
|
||||
|
||||
if (isAuthTypeExternal) {
|
||||
// hide password fields
|
||||
mPasswordLayoutView.setVisibility(View.GONE);
|
||||
} else {
|
||||
// show password fields
|
||||
mPasswordLayoutView.setVisibility(View.VISIBLE);
|
||||
switch (getSelectedAuthType()) {
|
||||
case EXTERNAL:
|
||||
case XOAUTH2:
|
||||
mPasswordLayoutView.setVisibility(View.GONE);
|
||||
break;
|
||||
default:
|
||||
mPasswordLayoutView.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows/hides client certificate spinner
|
||||
*/
|
||||
private void updateViewFromSecurity(ConnectionSecurity security) {
|
||||
private void updateViewFromSecurity() {
|
||||
ConnectionSecurity security = getSelectedSecurity();
|
||||
boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security));
|
||||
boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2;
|
||||
|
||||
if (isUsingTLS) {
|
||||
if (isUsingTLS && !isUsingOAuth) {
|
||||
mAllowClientCertificateView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
mAllowClientCertificateView.setVisibility(View.GONE);
|
||||
|
@ -421,7 +424,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false);
|
||||
mAuthTypeView.setOnItemSelectedListener(onItemSelectedListener);
|
||||
updateViewFromAuthType();
|
||||
updateViewFromSecurity(getSelectedSecurity());
|
||||
updateViewFromSecurity();
|
||||
|
||||
onItemSelectedListener = mSecurityTypeView.getOnItemSelectedListener();
|
||||
mSecurityTypeView.setOnItemSelectedListener(null);
|
||||
|
@ -456,11 +459,15 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
|
|||
&& hasConnectionSecurity
|
||||
&& hasValidCertificateAlias;
|
||||
|
||||
boolean hasValidOAuthSettings = hasValidUserName
|
||||
&& hasConnectionSecurity
|
||||
&& authType == AuthType.XOAUTH2;
|
||||
|
||||
mNextButton
|
||||
.setEnabled(Utility.domainFieldValid(mServerView)
|
||||
&& Utility.requiredFieldValid(mPortView)
|
||||
&& (!mRequireLoginView.isChecked()
|
||||
|| hasValidPasswordSettings || hasValidExternalAuthSettings));
|
||||
|| hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings));
|
||||
Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,8 +11,14 @@ class AuthTypeAdapter extends ArrayAdapter<AuthTypeHolder> {
|
|||
super(context, resource, holders);
|
||||
}
|
||||
|
||||
public static AuthTypeAdapter get(Context context) {
|
||||
AuthType[] authTypes = new AuthType[]{AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL};
|
||||
public static AuthTypeAdapter get(Context context, boolean oAuthSupported) {
|
||||
AuthType[] authTypes;
|
||||
if (oAuthSupported) {
|
||||
authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL, AuthType.XOAUTH2 };
|
||||
} else {
|
||||
authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL };
|
||||
}
|
||||
|
||||
AuthTypeHolder[] holders = new AuthTypeHolder[authTypes.length];
|
||||
for (int i = 0; i < authTypes.length; i++) {
|
||||
holders[i] = new AuthTypeHolder(authTypes[i], context.getResources());
|
||||
|
|
|
@ -41,7 +41,8 @@ class AuthTypeHolder {
|
|||
return R.string.account_setup_auth_type_encrypted_password;
|
||||
case EXTERNAL:
|
||||
return R.string.account_setup_auth_type_tls_client_certificate;
|
||||
|
||||
case XOAUTH2:
|
||||
return R.string.account_setup_auth_type_oauth2;
|
||||
case AUTOMATIC:
|
||||
case LOGIN:
|
||||
default:
|
||||
|
|
|
@ -0,0 +1,269 @@
|
|||
package com.fsck.k9.activity.setup
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.ActivityResultRegistry
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.preferences.AccountManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationRequest
|
||||
import net.openid.appauth.AuthorizationResponse
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.openid.appauth.AuthorizationServiceConfiguration
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
import timber.log.Timber
|
||||
|
||||
private const val KEY_AUTHORIZATION = "app.k9mail_auth"
|
||||
|
||||
class AuthViewModel(
|
||||
application: Application,
|
||||
private val accountManager: AccountManager,
|
||||
private val oauthCredentials: OAuthCredentials
|
||||
) : AndroidViewModel(application) {
|
||||
private var authService: AuthorizationService? = null
|
||||
private val authState = AuthState()
|
||||
|
||||
private var account: Account? = null
|
||||
|
||||
private lateinit var resultObserver: AppAuthResultObserver
|
||||
|
||||
private val _uiState = MutableStateFlow<AuthFlowState>(AuthFlowState.Idle)
|
||||
val uiState: StateFlow<AuthFlowState> = _uiState.asStateFlow()
|
||||
|
||||
@Synchronized
|
||||
private fun getAuthService(): AuthorizationService {
|
||||
return authService ?: AuthorizationService(getApplication<Application>()).also { authService = it }
|
||||
}
|
||||
|
||||
fun init(activityResultRegistry: ActivityResultRegistry, lifecycle: Lifecycle) {
|
||||
resultObserver = AppAuthResultObserver(activityResultRegistry)
|
||||
lifecycle.addObserver(resultObserver)
|
||||
}
|
||||
|
||||
fun authResultConsumed() {
|
||||
_uiState.update { AuthFlowState.Idle }
|
||||
}
|
||||
|
||||
fun isAuthorized(account: Account): Boolean {
|
||||
val authState = getOrCreateAuthState(account)
|
||||
return authState.isAuthorized
|
||||
}
|
||||
|
||||
private fun getOrCreateAuthState(account: Account): AuthState {
|
||||
return try {
|
||||
account.oAuthState?.let { AuthState.jsonDeserialize(it) } ?: AuthState()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error deserializing AuthState")
|
||||
AuthState()
|
||||
}
|
||||
}
|
||||
|
||||
fun login(account: Account) {
|
||||
this.account = account
|
||||
|
||||
viewModelScope.launch {
|
||||
val config = findOAuthConfiguration(account)
|
||||
if (config == null) {
|
||||
_uiState.update { AuthFlowState.NotSupported }
|
||||
return@launch
|
||||
}
|
||||
|
||||
try {
|
||||
startLogin(account, config)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
_uiState.update { AuthFlowState.BrowserNotFound }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startLogin(account: Account, config: OAuthConfiguration) {
|
||||
val authRequestIntent = withContext(Dispatchers.IO) {
|
||||
createAuthorizationRequestIntent(account.email, config)
|
||||
}
|
||||
|
||||
resultObserver.login(authRequestIntent)
|
||||
}
|
||||
|
||||
private fun createAuthorizationRequestIntent(email: String, config: OAuthConfiguration): Intent {
|
||||
val serviceConfig = AuthorizationServiceConfiguration(
|
||||
config.authorizationEndpoint.toUri(),
|
||||
config.tokenEndpoint.toUri()
|
||||
)
|
||||
|
||||
val applicationId = getApplication<Application>().packageName
|
||||
val redirectUri = Uri.parse("$applicationId:/oauth2redirect")
|
||||
|
||||
val authRequestBuilder = AuthorizationRequest.Builder(
|
||||
serviceConfig,
|
||||
config.clientId,
|
||||
ResponseTypeValues.CODE,
|
||||
redirectUri
|
||||
)
|
||||
|
||||
val scopeString = config.scopes.joinToString(separator = " ")
|
||||
val authRequest = authRequestBuilder
|
||||
.setScope(scopeString)
|
||||
.setLoginHint(email)
|
||||
.build()
|
||||
|
||||
val authService = getAuthService()
|
||||
|
||||
return authService.getAuthorizationRequestIntent(authRequest)
|
||||
}
|
||||
|
||||
private fun findOAuthConfiguration(account: Account): OAuthConfiguration? {
|
||||
return when (account.incomingServerSettings.host) {
|
||||
"imap.gmail.com", "imap.googlemail.com" -> {
|
||||
OAuthConfiguration(
|
||||
clientId = oauthCredentials.gmailClientId,
|
||||
scopes = listOf("https://mail.google.com/"),
|
||||
authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLoginResult(authorizationResult: AuthorizationResult?) {
|
||||
if (authorizationResult == null) {
|
||||
_uiState.update { AuthFlowState.Canceled }
|
||||
return
|
||||
}
|
||||
|
||||
authorizationResult.response?.let { response ->
|
||||
authState.update(authorizationResult.response, authorizationResult.exception)
|
||||
exchangeToken(response)
|
||||
}
|
||||
|
||||
authorizationResult.exception?.let { authorizationException ->
|
||||
_uiState.update {
|
||||
AuthFlowState.Failed(
|
||||
errorCode = authorizationException.error,
|
||||
errorMessage = authorizationException.errorDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exchangeToken(response: AuthorizationResponse) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val authService = getAuthService()
|
||||
|
||||
val tokenRequest = response.createTokenExchangeRequest()
|
||||
authService.performTokenRequest(tokenRequest) { tokenResponse, authorizationException ->
|
||||
authState.update(tokenResponse, authorizationException)
|
||||
|
||||
val account = account!!
|
||||
account.oAuthState = authState.jsonSerializeString()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
|
||||
if (authorizationException != null) {
|
||||
_uiState.update {
|
||||
AuthFlowState.Failed(
|
||||
errorCode = authorizationException.error,
|
||||
errorMessage = authorizationException.errorDescription
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { AuthFlowState.Success }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onCleared() {
|
||||
authService?.dispose()
|
||||
authService = null
|
||||
}
|
||||
|
||||
inner class AppAuthResultObserver(private val registry: ActivityResultRegistry) : DefaultLifecycleObserver {
|
||||
private var authorizationLauncher: ActivityResultLauncher<Intent>? = null
|
||||
private var authRequestIntent: Intent? = null
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
authorizationLauncher = registry.register(KEY_AUTHORIZATION, AuthorizationContract(), ::onLoginResult)
|
||||
authRequestIntent?.let { intent ->
|
||||
authRequestIntent = null
|
||||
login(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
authorizationLauncher = null
|
||||
}
|
||||
|
||||
fun login(authRequestIntent: Intent) {
|
||||
val launcher = authorizationLauncher
|
||||
if (launcher != null) {
|
||||
launcher.launch(authRequestIntent)
|
||||
} else {
|
||||
this.authRequestIntent = authRequestIntent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AuthorizationContract : ActivityResultContract<Intent, AuthorizationResult?>() {
|
||||
override fun createIntent(context: Context, input: Intent): Intent {
|
||||
return input
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResult? {
|
||||
return if (resultCode == Activity.RESULT_OK && intent != null) {
|
||||
AuthorizationResult(
|
||||
response = AuthorizationResponse.fromIntent(intent),
|
||||
exception = AuthorizationException.fromIntent(intent)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AuthorizationResult(
|
||||
val response: AuthorizationResponse?,
|
||||
val exception: AuthorizationException?
|
||||
)
|
||||
|
||||
sealed interface AuthFlowState {
|
||||
object Idle : AuthFlowState
|
||||
|
||||
object Success : AuthFlowState
|
||||
|
||||
object NotSupported : AuthFlowState
|
||||
|
||||
object BrowserNotFound : AuthFlowState
|
||||
|
||||
object Canceled : AuthFlowState
|
||||
|
||||
data class Failed(val errorCode: String?, val errorMessage: String?) : AuthFlowState {
|
||||
override fun toString(): String {
|
||||
return listOfNotNull(errorCode, errorMessage).joinToString(separator = " - ")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.activity.setup
|
||||
|
||||
data class OAuthConfiguration(
|
||||
val clientId: String,
|
||||
val scopes: List<String>,
|
||||
val authorizationEndpoint: String,
|
||||
val tokenEndpoint: String
|
||||
)
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.activity.setup
|
||||
|
||||
interface OAuthCredentials {
|
||||
val gmailClientId: String
|
||||
}
|
|
@ -37,6 +37,7 @@
|
|||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/account_password_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:passwordToggleEnabled="true">
|
||||
|
|
|
@ -356,6 +356,7 @@ Please submit bug reports, contribute new features and ask questions at
|
|||
<string name="account_setup_auth_type_insecure_password">Password, transmitted insecurely</string>
|
||||
<string name="account_setup_auth_type_encrypted_password">Encrypted password</string>
|
||||
<string name="account_setup_auth_type_tls_client_certificate">Client certificate</string>
|
||||
<string name="account_setup_auth_type_oauth2">OAuth 2.0</string>
|
||||
|
||||
<string name="account_setup_incoming_title">Incoming server settings</string>
|
||||
<string name="account_setup_incoming_username_label">Username</string>
|
||||
|
@ -457,6 +458,10 @@ Please submit bug reports, contribute new features and ask questions at
|
|||
<string name="account_setup_failed_dlg_auth_message_fmt">Username or password incorrect.\n(<xliff:g id="error">%s</xliff:g>)</string>
|
||||
<string name="account_setup_failed_dlg_certificate_message_fmt">The server presented an invalid SSL certificate. Sometimes, this is because of a server misconfiguration. Sometimes it is because someone is trying to attack you or your mail server. If you\'re not sure what\'s up, click Reject and contact the folks who manage your mail server.\n\n(<xliff:g id="error">%s</xliff:g>)</string>
|
||||
<string name="account_setup_failed_dlg_server_message_fmt">Cannot connect to server.\n(<xliff:g id="error">%s</xliff:g>)</string>
|
||||
<string name="account_setup_failed_dlg_oauth_flow_canceled">Authorization canceled</string>
|
||||
<string name="account_setup_failed_dlg_oauth_flow_failed">Authorization failed with the following error: <xliff:g id="error">%s</xliff:g></string>
|
||||
<string name="account_setup_failed_dlg_oauth_not_supported">OAuth 2.0 is currently not supported with this provider.</string>
|
||||
<string name="account_setup_failed_dlg_browser_not_found">The app couldn\'t find a browser to use for granting access to your account.</string>
|
||||
<string name="account_setup_failed_dlg_edit_details_action">Edit details</string>
|
||||
<string name="account_setup_failed_dlg_continue_action">Continue</string>
|
||||
|
||||
|
|
21
app/ui/legacy/src/test/AndroidManifest.xml
Normal file
21
app/ui/legacy/src/test/AndroidManifest.xml
Normal file
|
@ -0,0 +1,21 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
<!-- We're replacing this activity so we can run tests without the build tools complaining about a
|
||||
missing manifest placeholder -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
tools:node="replace">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="dummy" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
BIN
debug.keystore
Normal file
BIN
debug.keystore
Normal file
Binary file not shown.
|
@ -1,8 +1,6 @@
|
|||
package com.fsck.k9.mail.oauth;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fsck.k9.mail.AuthenticationFailedException;
|
||||
|
||||
|
||||
|
@ -12,16 +10,11 @@ public interface OAuth2TokenProvider {
|
|||
*/
|
||||
int OAUTH2_TIMEOUT = 30000;
|
||||
|
||||
|
||||
/**
|
||||
* @return Accounts suitable for OAuth 2.0 token provision.
|
||||
*/
|
||||
List<String> getAccounts();
|
||||
|
||||
/**
|
||||
* Fetch a token. No guarantees are provided for validity.
|
||||
*/
|
||||
String getToken(String username, long timeoutMillis) throws AuthenticationFailedException;
|
||||
String getToken(long timeoutMillis) throws AuthenticationFailedException;
|
||||
|
||||
/**
|
||||
* Invalidate the token for this username.
|
||||
|
@ -32,5 +25,5 @@ public interface OAuth2TokenProvider {
|
|||
* <p>
|
||||
* Invalidating a token and then failure with a new token should be treated as a permanent failure.
|
||||
*/
|
||||
void invalidateToken(String username);
|
||||
void invalidateToken();
|
||||
}
|
||||
|
|
|
@ -399,7 +399,7 @@ class RealImapConnection implements ImapConnection {
|
|||
return attemptXOAuth2();
|
||||
} catch (NegativeImapResponseException e) {
|
||||
//TODO: Check response code so we don't needlessly invalidate the token.
|
||||
oauthTokenProvider.invalidateToken(settings.getUsername());
|
||||
oauthTokenProvider.invalidateToken();
|
||||
|
||||
if (!retryXoauth2WithNewToken) {
|
||||
throw handlePermanentXoauth2Failure(e);
|
||||
|
@ -427,13 +427,13 @@ class RealImapConnection implements ImapConnection {
|
|||
//Okay, we failed on a new token.
|
||||
//Invalidate the token anyway but assume it's permanent.
|
||||
Timber.v(e, "Authentication exception for new token, permanent error assumed");
|
||||
oauthTokenProvider.invalidateToken(settings.getUsername());
|
||||
oauthTokenProvider.invalidateToken();
|
||||
throw handlePermanentXoauth2Failure(e2);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ImapResponse> attemptXOAuth2() throws MessagingException, IOException {
|
||||
String token = oauthTokenProvider.getToken(settings.getUsername(), OAuth2TokenProvider.OAUTH2_TIMEOUT);
|
||||
String token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT);
|
||||
String authString = Authentication.computeXoauth(settings.getUsername(), token);
|
||||
String tag = sendSaslIrCommand(Commands.AUTHENTICATE_XOAUTH2, authString, true);
|
||||
|
||||
|
|
|
@ -1045,8 +1045,7 @@ class RealImapConnectionTest {
|
|||
class TestTokenProvider : OAuth2TokenProvider {
|
||||
private var invalidationCount = 0
|
||||
|
||||
override fun getToken(username: String, timeoutMillis: Long): String {
|
||||
assertThat(username).isEqualTo(USERNAME)
|
||||
override fun getToken(timeoutMillis: Long): String {
|
||||
assertThat(timeoutMillis).isEqualTo(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
|
||||
|
||||
return when (invalidationCount) {
|
||||
|
@ -1060,14 +1059,9 @@ class TestTokenProvider : OAuth2TokenProvider {
|
|||
}
|
||||
}
|
||||
|
||||
override fun invalidateToken(username: String) {
|
||||
assertThat(username).isEqualTo(USERNAME)
|
||||
override fun invalidateToken() {
|
||||
invalidationCount++
|
||||
}
|
||||
|
||||
override fun getAccounts(): List<String> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.base64() = this.encodeUtf8().base64()
|
||||
|
|
|
@ -559,7 +559,7 @@ class SmtpTransport(
|
|||
throw negativeResponse
|
||||
}
|
||||
|
||||
oauthTokenProvider!!.invalidateToken(username)
|
||||
oauthTokenProvider!!.invalidateToken()
|
||||
|
||||
if (!retryXoauthWithNewToken) {
|
||||
handlePermanentFailure(negativeResponse)
|
||||
|
@ -588,13 +588,13 @@ class SmtpTransport(
|
|||
// Okay, we failed on a new token. Invalidate the token anyway but assume it's permanent.
|
||||
Timber.v(negativeResponseFromNewToken, "Authentication exception for new token, permanent error assumed")
|
||||
|
||||
oauthTokenProvider!!.invalidateToken(username)
|
||||
oauthTokenProvider!!.invalidateToken()
|
||||
handlePermanentFailure(negativeResponseFromNewToken)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptXoauth2(username: String) {
|
||||
val token = oauthTokenProvider!!.getToken(username, OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
|
||||
val token = oauthTokenProvider!!.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
|
||||
val authString = Authentication.computeXoauth(username, token)
|
||||
|
||||
val response = executeSensitiveCommand("AUTH XOAUTH2 %s", authString)
|
||||
|
|
|
@ -18,10 +18,8 @@ import com.google.common.truth.Truth.assertThat
|
|||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.inOrder
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stubbing
|
||||
|
@ -218,8 +216,8 @@ class SmtpTransportTest {
|
|||
}
|
||||
|
||||
inOrder(oAuth2TokenProvider) {
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken()
|
||||
}
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
|
@ -245,9 +243,9 @@ class SmtpTransportTest {
|
|||
transport.open()
|
||||
|
||||
inOrder(oAuth2TokenProvider) {
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken()
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
}
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
|
@ -273,9 +271,9 @@ class SmtpTransportTest {
|
|||
transport.open()
|
||||
|
||||
inOrder(oAuth2TokenProvider) {
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken()
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
}
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
|
@ -301,9 +299,9 @@ class SmtpTransportTest {
|
|||
transport.open()
|
||||
|
||||
inOrder(oAuth2TokenProvider) {
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken()
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
}
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
|
@ -357,7 +355,7 @@ class SmtpTransportTest {
|
|||
output("221 BYE")
|
||||
}
|
||||
stubbing(oAuth2TokenProvider) {
|
||||
on { getToken(anyString(), anyLong()) } doThrow AuthenticationFailedException("Failed to fetch token")
|
||||
on { getToken(anyLong()) } doThrow AuthenticationFailedException("Failed to fetch token")
|
||||
}
|
||||
val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2)
|
||||
|
||||
|
@ -533,8 +531,8 @@ class SmtpTransportTest {
|
|||
}
|
||||
|
||||
inOrder(oAuth2TokenProvider) {
|
||||
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
|
||||
verify(oAuth2TokenProvider).getToken(anyLong())
|
||||
verify(oAuth2TokenProvider).invalidateToken()
|
||||
}
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
|
@ -950,7 +948,7 @@ class SmtpTransportTest {
|
|||
|
||||
private fun createMockOAuth2TokenProvider(): OAuth2TokenProvider {
|
||||
return mock {
|
||||
on { getToken(eq(USERNAME), anyLong()) } doReturn "oldToken" doReturn "newToken"
|
||||
on { getToken(anyLong()) } doReturn "oldToken" doReturn "newToken"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue