diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt index 611befac2..f3635da2e 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt @@ -407,6 +407,8 @@ class DataStoreTest { @Test fun dataStore_getProxySetting_success() { + dataStore.setProxySetting(ProxySetting.NO_PROXY) + val result = dataStore.getProxySetting() assertEquals(ProxySetting.NO_PROXY, result) @@ -576,6 +578,11 @@ class DataStoreTest { @Test fun dataStore_getManualProxySettings_success() { + dataStore.setProxyHost("") + dataStore.setProxyPort(80) + dataStore.setProxyUsername("") + dataStore.setProxyPassword("") + val result = dataStore.getManualProxySettings() assertEquals("", result.host) diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt index 7e3791e8b..8097f5619 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt @@ -180,7 +180,10 @@ class DiagnosticsViewModelTest { proxySetting = ProxySetting.NO_PROXY manualProxy = ManualProxy("", 80, "", "") dataStore.setProxySetting(ProxySetting.NO_PROXY) + dataStore.setProxyHost("") + dataStore.setProxyPort(80) dataStore.setProxyUsername("") + dataStore.setProxyPassword("") } @Test @@ -457,6 +460,8 @@ class DiagnosticsViewModelTest { @Test fun diagnosticsViewModel_isProxyAuthEnabled_returnTrueWhenUsernameSet() { + dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) + dataStore.setProxyHost("proxyHost") dataStore.setProxyUsername("username") val result = viewModel.isProxyAuthEnabled() @@ -466,6 +471,8 @@ class DiagnosticsViewModelTest { @Test fun diagnosticsViewModel_isProxyAuthEnabled_returnFalseWhenUsernameEmpty() { + dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) + dataStore.setProxyHost("proxyHost") dataStore.setProxyUsername("") val result = viewModel.isProxyAuthEnabled() @@ -473,6 +480,61 @@ class DiagnosticsViewModelTest { assertFalse(result) } + @Test + fun diagnosticsViewModel_isProxyAuthEnabled_returnFalseWhenManualProxyHasNoHost() { + dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) + dataStore.setProxyHost("") + dataStore.setProxyUsername("username") + + val result = viewModel.isProxyAuthEnabled() + + assertFalse(result) + } + + @Test + fun diagnosticsViewModel_isProxyAuthEnabled_returnFalseWhenNoProxyChosen() { + dataStore.setProxySetting(ProxySetting.NO_PROXY) + dataStore.setProxyUsername("username") + + val result = viewModel.isProxyAuthEnabled() + + assertFalse(result) + } + + @Test + fun diagnosticsViewModel_isProxyAuthEnabled_returnTrueWhenSystemProxyHasCredentials() { + dataStore.setProxySetting(ProxySetting.SYSTEM_PROXY) + dataStore.setProxyUsername("") + System.setProperty("http.proxyHost", "systemProxyHost") + System.setProperty("http.proxyUser", "systemProxyUser") + + val result = + try { + viewModel.isProxyAuthEnabled() + } finally { + System.clearProperty("http.proxyHost") + System.clearProperty("http.proxyUser") + } + + assertTrue(result) + } + + @Test + fun diagnosticsViewModel_isProxyAuthEnabled_returnFalseWhenSystemProxyHasNoHost() { + dataStore.setProxySetting(ProxySetting.SYSTEM_PROXY) + dataStore.setProxyUsername("") + System.setProperty("http.proxyUser", "systemProxyUser") + + val result = + try { + viewModel.isProxyAuthEnabled() + } finally { + System.clearProperty("http.proxyUser") + } + + assertFalse(result) + } + @Suppress("SameParameterValue") private fun createTempFileWithStringContent( filename: String, diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModelTest.kt index ea9a56124..503351e23 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModelTest.kt @@ -412,6 +412,7 @@ class NFCViewModelTest { viewModel.message.removeObserver(messageObserver) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun nfcViewModel_performNFCSignWorkRequest_success() = runTest { @@ -446,6 +447,7 @@ class NFCViewModelTest { viewModel.message.removeObserver(messageObserver) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun nfcViewModel_performNFCSignWorkRequest_nullContainer() = runTest { diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt index 693e4ec92..bde08cda1 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt @@ -28,6 +28,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.documentfile.provider.DocumentFile import androidx.test.platform.app.InstrumentationRegistry import com.google.gson.Gson +import ee.ria.DigiDoc.R import ee.ria.DigiDoc.common.Constant.DIR_TSA_CERT import ee.ria.DigiDoc.common.Constant.Defaults.DEFAULT_UUID_VALUE import ee.ria.DigiDoc.common.testfiles.asset.AssetFile @@ -47,7 +48,10 @@ import ee.ria.DigiDoc.manager.ActivityManager import ee.ria.DigiDoc.network.proxy.ManualProxy import ee.ria.DigiDoc.network.proxy.ProxySetting import ee.ria.DigiDoc.network.siva.SivaSetting +import ee.ria.libdigidocpp.DigiDocConf import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.apache.commons.io.FileUtils import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -74,6 +78,9 @@ class SharedSettingsViewModelTest { @get:Rule val instantExecutorRule = InstantTaskExecutorRule() + @get:Rule + val mockWebServer = MockWebServer() + @Mock lateinit var contentResolver: ContentResolver @@ -84,6 +91,8 @@ class SharedSettingsViewModelTest { private lateinit var activityManager: ActivityManager companion object { + private const val AWAIT_ERROR_TIMEOUT = 10_000L + private lateinit var configurationLoader: ConfigurationLoader private lateinit var configurationRepository: ConfigurationRepository @@ -127,6 +136,12 @@ class SharedSettingsViewModelTest { dataStore = DataStore(context) LibdigidocLibraryLoader().init(context) initialization = Initialization(configurationRepository) + initialization.overrideProxy("", 80, "", "") + dataStore.setProxySetting(ProxySetting.NO_PROXY) + dataStore.setProxyHost("") + dataStore.setProxyPort(80) + dataStore.setProxyUsername("") + dataStore.setProxyPassword("") viewModel = SharedSettingsViewModel( context = context, @@ -186,7 +201,7 @@ class SharedSettingsViewModelTest { fun sharedSettingsViewModel_saveProxySettings_savesManualProxySettings() { dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) val manualProxySettings = ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass") - viewModel.saveProxySettings(false, manualProxySettings) + viewModel.saveProxySettings(manualProxySettings) assertEquals("proxyHost", dataStore.getProxyHost()) assertEquals(8080, dataStore.getProxyPort()) @@ -195,14 +210,18 @@ class SharedSettingsViewModelTest { } @Test - fun sharedSettingsViewModel_saveProxySettings_savesSystemProxySettingsWithClearSettingsIsFalse() { + fun sharedSettingsViewModel_saveProxySettings_keepsManualProxySettingsWhenSystemProxyIsChosen() { dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) val manualProxySettings = ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass") - viewModel.saveProxySettings(false, manualProxySettings) + viewModel.saveProxySettings(manualProxySettings) - System.setProperty("http.proxyHost", "proxyHost") + System.setProperty("http.proxyHost", "systemProxyHost") dataStore.setProxySetting(ProxySetting.SYSTEM_PROXY) - viewModel.saveProxySettings(false, ManualProxy("", 0, "", "")) + try { + viewModel.saveProxySettings(ManualProxy("", 80, "", "")) + } finally { + System.clearProperty("http.proxyHost") + } assertEquals("proxyHost", dataStore.getProxyHost()) assertEquals(8080, dataStore.getProxyPort()) @@ -211,13 +230,13 @@ class SharedSettingsViewModelTest { } @Test - fun sharedSettingsViewModel_saveProxySettings_savesNoProxySettingsWithClearSettingsIsFalse() { + fun sharedSettingsViewModel_saveProxySettings_keepsManualProxySettingsWhenNoProxyIsChosen() { dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) val manualProxySettings = ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass") - viewModel.saveProxySettings(false, manualProxySettings) + viewModel.saveProxySettings(manualProxySettings) dataStore.setProxySetting(ProxySetting.NO_PROXY) - viewModel.saveProxySettings(false, ManualProxy("", 0, "", "")) + viewModel.saveProxySettings(ManualProxy("", 80, "", "")) assertEquals("proxyHost", dataStore.getProxyHost()) assertEquals(8080, dataStore.getProxyPort()) @@ -226,33 +245,14 @@ class SharedSettingsViewModelTest { } @Test - fun sharedSettingsViewModel_saveProxySettings_savesSystemProxySettingsWithClearSettingsIsTrue() { + fun sharedSettingsViewModel_saveProxySettings_clearsLibdigidocppProxyWhenNoProxyIsChosen() { dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) - val manualProxySettings = ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass") - viewModel.saveProxySettings(false, manualProxySettings) - System.setProperty("http.proxyHost", "") - dataStore.setProxySetting(ProxySetting.SYSTEM_PROXY) - viewModel.saveProxySettings(true, manualProxySettings) - - assertEquals("", dataStore.getProxyHost()) - assertEquals(80, dataStore.getProxyPort()) - assertEquals("", dataStore.getProxyUsername()) - assertEquals("", dataStore.getProxyPassword()) - } - - @Test - fun sharedSettingsViewModel_saveProxySettings_savesNoProxySettingsWithClearSettingsIsTrue() { - dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) - val manualProxySettings = ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass") - viewModel.saveProxySettings(false, manualProxySettings) + viewModel.saveProxySettings(ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass")) dataStore.setProxySetting(ProxySetting.NO_PROXY) - viewModel.saveProxySettings(true, manualProxySettings) + viewModel.saveProxySettings(ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass")) - assertEquals("", dataStore.getProxyHost()) - assertEquals(80, dataStore.getProxyPort()) - assertEquals("", dataStore.getProxyUsername()) - assertEquals("", dataStore.getProxyPassword()) + assertEquals("", DigiDocConf.instance().proxyHost()) } @Test @@ -364,29 +364,71 @@ class SharedSettingsViewModelTest { viewModel.handleTsaFile(uri) } - @Test(expected = Test.None::class) - fun sharedSettingsViewModel_checkConnection_withInvalidManualProxySettings() { + @Test + fun sharedSettingsViewModel_checkConnection_savesManualProxySettings() { + mockWebServer.enqueue(MockResponse().setResponseCode(407)) dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) - val manualProxySettings = ManualProxy("proxyHost", 8080, "proxyUser", "proxyPass") - viewModel.checkConnection(manualProxySettings) - assertEquals("proxyHost", dataStore.getProxyHost()) - assertEquals(8080, dataStore.getProxyPort()) + viewModel.checkConnection(localProxy("proxyUser", "proxyPass")) + + assertEquals("127.0.0.1", dataStore.getProxyHost()) + assertEquals(mockWebServer.port, dataStore.getProxyPort()) assertEquals("proxyUser", dataStore.getProxyUsername()) assertEquals("proxyPass", dataStore.getProxyPassword()) } - @Test(expected = Test.None::class) - fun sharedSettingsViewModel_checkConnection_withValidNoProxySettings() { - dataStore.setProxySetting(ProxySetting.NO_PROXY) - val manualProxySettings = ManualProxy("", 80, "", "") - viewModel.saveProxySettings(true, manualProxySettings) - viewModel.checkConnection(manualProxySettings) + @Test + fun sharedSettingsViewModel_checkConnection_reportsWrongCredentialsWhenProxyDemandsAuthentication() { + mockWebServer.enqueue(MockResponse().setResponseCode(407)) + mockWebServer.enqueue(MockResponse().setResponseCode(407)) + dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) - assertEquals("", dataStore.getProxyHost()) - assertEquals(80, dataStore.getProxyPort()) - assertEquals("", dataStore.getProxyUsername()) - assertEquals("", dataStore.getProxyPassword()) + viewModel.checkConnection(localProxy("proxyUser", "wrongPass")) + + assertEquals( + R.string.main_settings_proxy_check_username_and_password, + awaitErrorMessage(), + ) + } + + @Test + fun sharedSettingsViewModel_checkConnection_reportsWrongCredentialsWhenProxyForbidsConnect() { + mockWebServer.enqueue(MockResponse().setResponseCode(403)) + dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) + + viewModel.checkConnection(localProxy("proxyUser", "proxyPass")) + + assertEquals( + R.string.main_settings_proxy_check_username_and_password, + awaitErrorMessage(), + ) + } + + @Test + fun sharedSettingsViewModel_checkConnection_reportsUnsuccessfulWhenProxyFailsForAnotherReason() { + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) + + viewModel.checkConnection(localProxy("proxyUser", "proxyPass")) + + assertEquals( + R.string.main_settings_proxy_check_connection_unsuccessful, + awaitErrorMessage(), + ) + } + + private fun localProxy( + username: String, + password: String, + ) = ManualProxy("127.0.0.1", mockWebServer.port, username, password) + + private fun awaitErrorMessage(): Int? { + val deadline = System.currentTimeMillis() + AWAIT_ERROR_TIMEOUT + while (System.currentTimeMillis() < deadline) { + viewModel.errorState.value?.let { return it } + Thread.sleep(50) + } + return null } @Test diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt index 49066bb80..f3e1c7190 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt @@ -90,6 +90,7 @@ import ee.ria.DigiDoc.ui.theme.buttonRoundedCornerShape import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.isTalkBackEnabled import ee.ria.DigiDoc.utils.extensions.notAccessible import ee.ria.DigiDoc.utils.snackbar.SnackBarManager.showMessage +import ee.ria.DigiDoc.utils.snackbar.SnackbarType import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel import kotlinx.coroutines.Dispatchers.Main @@ -192,7 +193,13 @@ fun ProxyServicesSettingsScreen( sharedSettingsViewModel.errorState.collect { errorState -> errorState?.let { withContext(Main) { - showMessage(it.text, it.type) + val type = + if (it == R.string.main_settings_proxy_check_connection_success) { + SnackbarType.SUCCESS + } else { + SnackbarType.ERROR + } + showMessage(context, it, type) sharedSettingsViewModel.resetErrorState() } } @@ -254,7 +261,7 @@ fun ProxyServicesSettingsScreen( .clickable { settingsProxyChoice.value = ProxySetting.NO_PROXY.name setProxySetting(ProxySetting.NO_PROXY) - sharedSettingsViewModel.saveProxySettings(true, ManualProxy("", 80, "", "")) + sharedSettingsViewModel.saveProxySettings() }, verticalAlignment = Alignment.CenterVertically, ) { @@ -275,7 +282,7 @@ fun ProxyServicesSettingsScreen( onClick = { settingsProxyChoice.value = ProxySetting.NO_PROXY.name setProxySetting(ProxySetting.NO_PROXY) - sharedSettingsViewModel.saveProxySettings(true, ManualProxy("", 80, "", "")) + sharedSettingsViewModel.saveProxySettings() }, ) } @@ -302,7 +309,7 @@ fun ProxyServicesSettingsScreen( .clickable { settingsProxyChoice.value = ProxySetting.SYSTEM_PROXY.name setProxySetting(ProxySetting.SYSTEM_PROXY) - sharedSettingsViewModel.saveProxySettings(true, ManualProxy("", 80, "", "")) + sharedSettingsViewModel.saveProxySettings() }, verticalAlignment = Alignment.CenterVertically, ) { @@ -323,7 +330,7 @@ fun ProxyServicesSettingsScreen( onClick = { settingsProxyChoice.value = ProxySetting.SYSTEM_PROXY.name setProxySetting(ProxySetting.SYSTEM_PROXY) - sharedSettingsViewModel.saveProxySettings(true, ManualProxy("", 80, "", "")) + sharedSettingsViewModel.saveProxySettings() }, ) } @@ -352,7 +359,6 @@ fun ProxyServicesSettingsScreen( settingsProxyChoice.value = ProxySetting.MANUAL_PROXY.name setProxySetting(ProxySetting.MANUAL_PROXY) sharedSettingsViewModel.saveProxySettings( - false, ManualProxy( host = proxyHost.text, port = proxyPortValue, @@ -383,7 +389,6 @@ fun ProxyServicesSettingsScreen( settingsProxyChoice.value = ProxySetting.MANUAL_PROXY.name setProxySetting(ProxySetting.MANUAL_PROXY) sharedSettingsViewModel.saveProxySettings( - false, ManualProxy( host = proxyHost.text, port = proxyPortValue, diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt index 603a83eaa..d9d62fd80 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt @@ -41,7 +41,9 @@ import ee.ria.DigiDoc.configuration.repository.ConfigurationRepository import ee.ria.DigiDoc.configuration.utils.TSLUtil import ee.ria.DigiDoc.domain.model.settings.CDOCSetting import ee.ria.DigiDoc.domain.preferences.DataStore +import ee.ria.DigiDoc.network.proxy.ManualProxy import ee.ria.DigiDoc.network.proxy.ProxySetting +import ee.ria.DigiDoc.network.utils.ProxyUtil import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.sendAccessibilityEvent import ee.ria.DigiDoc.utilsLib.date.DateUtil import ee.ria.DigiDoc.utilsLib.file.FileUtil @@ -168,7 +170,21 @@ class DiagnosticsViewModel ProxySetting.MANUAL_PROXY -> "MANUAL" } - fun isProxyAuthEnabled(): Boolean = dataStore.getProxyUsername().isNotEmpty() + fun isProxyAuthEnabled(): Boolean { + val proxyValues = + ProxyUtil.getProxyValues( + dataStore.getProxySetting(), + ManualProxy( + dataStore.getProxyHost(), + dataStore.getProxyPort(), + dataStore.getProxyUsername(), + "", + ), + ) + return proxyValues != null && + proxyValues.host.isNotEmpty() && + proxyValues.username.isNotEmpty() + } fun getTslCacheData(context: Context): List { val tslCacheList = ArrayList() diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt index 750fa0309..376f7e246 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt @@ -37,6 +37,7 @@ import ee.ria.DigiDoc.cryptolib.CryptoContainer import ee.ria.DigiDoc.cryptolib.exception.DataFilesEmptyException import ee.ria.DigiDoc.cryptolib.exception.RecipientsEmptyException import ee.ria.DigiDoc.cryptolib.repository.RecipientRepository +import ee.ria.DigiDoc.network.proxy.ProxyAuthenticationException import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.utilsLib.mimetype.MimeTypeResolver @@ -100,6 +101,9 @@ class EncryptRecipientViewModel } catch (nce: NoInternetConnectionException) { errorLog(logTag, "Unable to get LDAP addressees. No Internet connection", nce) _errorState.postValue(R.string.no_internet_connection) + } catch (pae: ProxyAuthenticationException) { + errorLog(logTag, "Unable to get LDAP addressees. Proxy authentication failed", pae) + _errorState.postValue(R.string.main_settings_proxy_check_username_and_password) } catch (e: Exception) { errorLog(logTag, "Unable to get LDAP addressees", e) _errorState.postValue(R.string.error_general_client) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt index 9a1e5c935..8566c0a72 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt @@ -51,8 +51,6 @@ import ee.ria.DigiDoc.network.siva.SivaSetting import ee.ria.DigiDoc.network.utils.NetworkUtil.constructClientBuilder import ee.ria.DigiDoc.network.utils.ProxyUtil import ee.ria.DigiDoc.network.utils.UserAgentUtil -import ee.ria.DigiDoc.utils.snackbar.SnackBarMessage -import ee.ria.DigiDoc.utils.snackbar.SnackbarType import ee.ria.DigiDoc.utilsLib.file.FileUtil import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog @@ -128,8 +126,8 @@ class SharedSettingsViewModel private val _cryptoCertificate = MutableStateFlow(null) val cryptoCertificate: StateFlow = _cryptoCertificate - private val _errorState = MutableStateFlow(null) - val errorState: StateFlow = _errorState + private val _errorState = MutableStateFlow(null) + val errorState: StateFlow = _errorState private val defaultManualProxySettings = ManualProxy("", 80, "", "") @@ -279,27 +277,15 @@ class SharedSettingsViewModel dataStore.setCryptoCertName(null) } - fun saveProxySettings( - clearSettings: Boolean, - manualProxySettings: ManualProxy, - ) { - val currentProxySetting: ProxySetting = dataStore.getProxySetting() - if (currentProxySetting == ProxySetting.MANUAL_PROXY) { - setManualProxySettings(manualProxySettings) - } else if (currentProxySetting == ProxySetting.SYSTEM_PROXY) { - val systemSettings: ProxyConfig = ProxyUtil.getProxy(currentProxySetting, defaultManualProxySettings) - val proxySettings: ManualProxy? = systemSettings.manualProxy() - if (proxySettings != null) { - overrideLibdigidocppProxy(proxySettings) - return - } - if (clearSettings) { - clearProxySettings() - } - } else { - if (clearSettings) { - clearProxySettings() + fun saveProxySettings(manualProxySettings: ManualProxy = defaultManualProxySettings) { + when (dataStore.getProxySetting()) { + ProxySetting.MANUAL_PROXY -> setManualProxySettings(manualProxySettings) + ProxySetting.SYSTEM_PROXY -> { + val systemSettings: ProxyConfig = + ProxyUtil.getProxy(ProxySetting.SYSTEM_PROXY, defaultManualProxySettings) + overrideLibdigidocppProxy(systemSettings.manualProxy() ?: defaultManualProxySettings) } + ProxySetting.NO_PROXY -> overrideLibdigidocppProxy(defaultManualProxySettings) } } @@ -530,7 +516,7 @@ class SharedSettingsViewModel fun checkConnection(manualProxySettings: ManualProxy) { debugLog(logTag, "Checking connection") - saveProxySettings(false, manualProxySettings) + saveProxySettings(manualProxySettings) val request: Request = Request @@ -551,41 +537,36 @@ class SharedSettingsViewModel val call = httpClient.newCall(request) try { val response = call.execute() - if (response.code == 403) { + val isProxyInUse = dataStore.getProxySetting() != ProxySetting.NO_PROXY + if (isProxyInUse && (response.code == 403 || response.code == 407)) { debugLog(logTag, "Forbidden error with proxy configuration") _errorState.value = - SnackBarMessage(context.getString(R.string.main_settings_proxy_check_username_and_password)) + R.string.main_settings_proxy_check_username_and_password } else if (response.code != 200) { debugLog(logTag, "No Internet connection detected") _errorState.value = - SnackBarMessage( - context.getString(R.string.main_settings_proxy_check_connection_unsuccessful), - ) + R.string.main_settings_proxy_check_connection_unsuccessful } else { debugLog(logTag, "Internet connection detected successfully") _errorState.value = - SnackBarMessage( - context.getString(R.string.main_settings_proxy_check_connection_success), - SnackbarType.SUCCESS, - ) + R.string.main_settings_proxy_check_connection_success } } catch (e: IOException) { val message = e.message - if (message != null && - ( - message.contains("CONNECT: 403") || - message.contains("Failed to authenticate with proxy") - ) - ) { - errorLog( - logTag, - "Received HTTP status 403 or failed to authenticate. " + - "Unable to connect with proxy configuration", - ) - } + val isProxyAuthenticationFailure = + message != null && + ( + message.contains("CONNECT: 403") || + message.contains("CONNECT: 407") || + message.contains("Failed to authenticate with proxy") + ) errorLog(logTag, "Unable to check Internet connection", e) _errorState.value = - SnackBarMessage(context.getString(R.string.main_settings_proxy_check_connection_unsuccessful)) + if (isProxyAuthenticationFailure) { + R.string.main_settings_proxy_check_username_and_password + } else { + R.string.main_settings_proxy_check_connection_unsuccessful + } } } } diff --git a/config-lib/src/androidTest/kotlin/ee/ria/DigiDoc/configuration/domain/model/ConfigurationViewModelTest.kt b/config-lib/src/androidTest/kotlin/ee/ria/DigiDoc/configuration/domain/model/ConfigurationViewModelTest.kt index 2ef956dd2..ea73d3587 100644 --- a/config-lib/src/androidTest/kotlin/ee/ria/DigiDoc/configuration/domain/model/ConfigurationViewModelTest.kt +++ b/config-lib/src/androidTest/kotlin/ee/ria/DigiDoc/configuration/domain/model/ConfigurationViewModelTest.kt @@ -114,32 +114,33 @@ class ConfigurationViewModelTest { private fun createMockConfigurationProvider(): ConfigurationProvider = ConfigurationProvider( - ConfigurationProvider.MetaInf("url", "date", 1, 1), - "sivaUrl", - mapOf( - DEFAULT_UUID_VALUE to - ConfigurationProvider.CDOC2Conf( - uuid = UUID.randomUUID(), - name = "RIA", - post = "https://cdoc2.id.ee:8443", - fetch = "https://cdoc2.id.ee:8444", - ), - ), - false, - false, - DEFAULT_UUID_VALUE, - "tslUrl", - emptyList(), - "tsaUrl", - "ldapPersonUrl", + metaInf = ConfigurationProvider.MetaInf("url", "date", 1, 1), + sivaUrl = "sivaUrl", + cdoc2Conf = + mapOf( + DEFAULT_UUID_VALUE to + ConfigurationProvider.CDOC2Conf( + uuid = UUID.randomUUID(), + name = "RIA", + post = "https://cdoc2.id.ee:8443", + fetch = "https://cdoc2.id.ee:8444", + ), + ), + cdoc2Default = false, + cdoc2UseKeyServer = false, + cdoc2DefaultKeyServer = DEFAULT_UUID_VALUE, + tslUrl = "tslUrl", + tslCerts = emptyList(), + tsaUrl = "tsaUrl", + ldapPersonUrl = "ldapPersonUrl", ldapPersonUrls = listOf("ldapPersonUrl"), - "ldapCorpUrl", - "midRestUrl", - "midSkRestUrl", - "sidV2RestUrl", - "sidV2SkRestUrl", - emptyList(), - Date(), - Date(), + ldapCorpUrl = "ldapCorpUrl", + midRestUrl = "midRestUrl", + midSkRestUrl = "midSkRestUrl", + sidV2RestUrl = "sidV2RestUrl", + sidV2SkRestUrl = "sidV2SkRestUrl", + certBundle = emptyList(), + configurationLastUpdateCheckDate = Date(), + configurationUpdateDate = Date(), ) } diff --git a/config-lib/src/main/kotlin/ee/ria/DigiDoc/configuration/provider/ConfigurationProvider.kt b/config-lib/src/main/kotlin/ee/ria/DigiDoc/configuration/provider/ConfigurationProvider.kt index fbd61df61..03a867aca 100644 --- a/config-lib/src/main/kotlin/ee/ria/DigiDoc/configuration/provider/ConfigurationProvider.kt +++ b/config-lib/src/main/kotlin/ee/ria/DigiDoc/configuration/provider/ConfigurationProvider.kt @@ -47,6 +47,7 @@ data class ConfigurationProvider( @SerializedName("SIDV2-PROXY-URL") val sidV2RestUrl: String, @SerializedName("SIDV2-SK-URL") val sidV2SkRestUrl: String, @SerializedName("CERT-BUNDLE") val certBundle: List, + @SerializedName("LDAP-CERTS") val ldapCerts: List = listOf(), var configurationLastUpdateCheckDate: Date?, var configurationUpdateDate: Date?, ) { diff --git a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt index cbe205f23..c46723c6a 100644 --- a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt +++ b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt @@ -25,12 +25,15 @@ import android.content.Context import com.google.common.collect.ImmutableList import com.unboundid.asn1.ASN1OctetString import com.unboundid.ldap.sdk.LDAPConnection +import com.unboundid.ldap.sdk.LDAPConnectionOptions import com.unboundid.ldap.sdk.LDAPException +import com.unboundid.ldap.sdk.NameResolver import com.unboundid.ldap.sdk.ResultCode import com.unboundid.ldap.sdk.SearchRequest import com.unboundid.ldap.sdk.SearchScope import com.unboundid.ldap.sdk.controls.SimplePagedResultsControl import com.unboundid.util.LDAPTestUtils +import com.unboundid.util.ssl.HostNameSSLSocketVerifier import com.unboundid.util.ssl.SSLUtil import com.unboundid.util.ssl.TLSCipherSuiteSelector import ee.ria.DigiDoc.common.Constant.BASE_DN @@ -44,16 +47,28 @@ import ee.ria.DigiDoc.configuration.repository.ConfigurationRepository import ee.ria.DigiDoc.cryptolib.Addressee import ee.ria.DigiDoc.cryptolib.exception.CryptoException import ee.ria.DigiDoc.cryptolib.ldap.LdapFilter +import ee.ria.DigiDoc.network.proxy.ManualProxy +import ee.ria.DigiDoc.network.proxy.ProxyAuthenticationException +import ee.ria.DigiDoc.network.proxy.ProxyTunnelSocketFactory +import ee.ria.DigiDoc.network.utils.NetworkUtil +import ee.ria.DigiDoc.network.utils.ProxyUtil import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import org.bouncycastle.asn1.x509.KeyPurposeId import org.bouncycastle.asn1.x509.KeyUsage import java.io.IOException +import java.net.InetAddress import java.security.GeneralSecurityException +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton +import javax.net.SocketFactory import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory @Singleton class RecipientRepositoryImpl @@ -63,6 +78,7 @@ class RecipientRepositoryImpl private val certificateService: CertificateService, ) : RecipientRepository { private val logTag = "RecipientRepositoryImpl" + private val proxyConnectTimeoutMillis = NetworkUtil.DEFAULT_TIMEOUT * 1000 @Throws(CryptoException::class, NoInternetConnectionException::class) override suspend fun find( @@ -137,11 +153,16 @@ class RecipientRepositoryImpl ldapFilter: LdapFilter, ): Pair, Int> { try { - LDAPConnection(getDefaultKeystoreSslSocketFactory()).use { connection -> + val tunnelProxy = tunnelProxy(context, url) + LDAPConnection( + socketFactory(tunnelProxy, url), + connectionOptions(tunnelProxy != null), + ).use { connection -> connection.connect(url, LDAP_PORT) return executeSearch(connection, ldapFilter, dn) } } catch (e: Exception) { + proxyAuthenticationFailure(e)?.let { throw it } if (e is LDAPException && e.resultCode.equals(ResultCode.CONNECT_ERROR)) { throw NoInternetConnectionException(context) } @@ -149,6 +170,62 @@ class RecipientRepositoryImpl } } + private fun connectionOptions(isTunnelled: Boolean): LDAPConnectionOptions = + LDAPConnectionOptions().apply { + sslSocketVerifier = HostNameSSLSocketVerifier(true) + if (isTunnelled) { + nameResolver = TunnelledNameResolver + connectTimeoutMillis = proxyConnectTimeoutMillis * 3 + } + } + + private fun tunnelProxy( + context: Context, + url: String?, + ): ManualProxy? { + if (url == null) { + return null + } + return ProxyUtil + .getProxyValues( + ProxyUtil.getProxySetting(context), + ProxyUtil.getManualProxySettings(context), + )?.takeIf { it.host.isNotEmpty() } + } + + @Throws(GeneralSecurityException::class) + private fun socketFactory( + tunnelProxy: ManualProxy?, + url: String?, + ): SocketFactory { + val sslSocketFactory = getDefaultKeystoreSslSocketFactory() + if (url == null) { + return sslSocketFactory + } + return ProxyTunnelSocketFactory( + tunnelProxy, + url, + LDAP_PORT, + sslSocketFactory, + proxyConnectTimeoutMillis, + ) + } + + private object TunnelledNameResolver : NameResolver() { + override fun getByName(host: String?): InetAddress = InetAddress.getLoopbackAddress() + + override fun getAllByName(host: String?): Array = arrayOf(InetAddress.getLoopbackAddress()) + + override fun toString(buffer: StringBuilder) { + buffer.append("TunnelledNameResolver()") + } + } + + private fun proxyAuthenticationFailure(throwable: Throwable): ProxyAuthenticationException? = + generateSequence(throwable) { it.cause } + .filterIsInstance() + .firstOrNull() + @Throws(LDAPException::class, IOException::class) private fun executeSearch( connection: LDAPConnection, @@ -210,7 +287,29 @@ class RecipientRepositoryImpl private fun getDefaultKeystoreSslSocketFactory(): SSLSocketFactory { TLSCipherSuiteSelector.setAllowSHA1(true) TLSCipherSuiteSelector.setAllowRSAKeyExchange(true) - return SSLUtil().createSSLSocketFactory() + val ldapCerts = configurationRepository.getConfiguration()?.ldapCerts ?: listOf() + if (ldapCerts.isEmpty()) { + return SSLUtil().createSSLSocketFactory() + } + return SSLUtil(ldapTrustManagers(ldapCerts)).createSSLSocketFactory() + } + + @Throws(GeneralSecurityException::class, IOException::class) + private fun ldapTrustManagers(ldapCerts: List): Array { + val certificateFactory = CertificateFactory.getInstance("X.509") + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null, null) + ldapCerts.forEachIndexed { index, ldapCert -> + val certificate = + certificateFactory.generateCertificate( + Base64.getMimeDecoder().decode(ldapCert).inputStream(), + ) + keyStore.setCertificateEntry("ldap-$index", certificate) + } + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) + return trustManagerFactory.trustManagers } private fun isSuitableKeyAndNotMobileId(certificate: ExtendedCertificate): Boolean = diff --git a/libdigidoc-lib/src/androidTest/kotlin/ee/ria/DigiDoc/libdigidoclib/init/InitializationTest.kt b/libdigidoc-lib/src/androidTest/kotlin/ee/ria/DigiDoc/libdigidoclib/init/InitializationTest.kt index dc557510a..310e26e67 100644 --- a/libdigidoc-lib/src/androidTest/kotlin/ee/ria/DigiDoc/libdigidoclib/init/InitializationTest.kt +++ b/libdigidoc-lib/src/androidTest/kotlin/ee/ria/DigiDoc/libdigidoclib/init/InitializationTest.kt @@ -24,12 +24,17 @@ package ee.ria.DigiDoc.libdigidoclib.init import android.content.Context import android.content.res.Resources import android.content.res.Resources.NotFoundException +import androidx.core.content.edit +import androidx.preference.PreferenceManager import androidx.test.platform.app.InstrumentationRegistry import ee.ria.DigiDoc.common.Constant.Defaults.DEFAULT_UUID_VALUE import ee.ria.DigiDoc.configuration.provider.ConfigurationProvider import ee.ria.DigiDoc.configuration.repository.ConfigurationRepository import ee.ria.DigiDoc.libdigidoclib.exceptions.AlreadyInitializedException +import ee.ria.DigiDoc.network.proxy.ProxySetting +import ee.ria.libdigidocpp.DigiDocConf import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.fail import org.junit.Before @@ -113,6 +118,7 @@ class InitializationTest { LibdigidocLibraryLoader().init(context) initialization = Initialization(configurationRepository) resetInitialization() + setProxyPreferences(ProxySetting.NO_PROXY, "", 80) } @Test @@ -166,4 +172,48 @@ class InitializationTest { } } } + + @Test + fun initialization_init_doesNotApplyManualProxyWhenNoProxyIsChosen() { + setProxyPreferences(ProxySetting.NO_PROXY, "proxyHost", 8080) + + `when`(configurationRepository.getConfiguration()).thenReturn(configurationProvider) + runTest { + initialization.init(context) + } + + assertEquals("", DigiDocConf.instance().proxyHost()) + } + + @Test + fun initialization_init_appliesManualProxyWhenManualProxyIsChosen() { + setProxyPreferences(ProxySetting.MANUAL_PROXY, "proxyHost", 8080) + + `when`(configurationRepository.getConfiguration()).thenReturn(configurationProvider) + runTest { + initialization.init(context) + } + + val proxyHost = DigiDocConf.instance().proxyHost() + val proxyPort = DigiDocConf.instance().proxyPort() + initialization.overrideProxy("", 80, "", "") + + assertEquals("proxyHost", proxyHost) + assertEquals("8080", proxyPort) + } + + private fun setProxyPreferences( + proxySetting: ProxySetting, + host: String, + port: Int, + ) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString( + context.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_setting_key), + proxySetting.name, + ) + putString(context.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_host_key), host) + putInt(context.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_port_key), port) + } + } } diff --git a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt index b2b7ed284..8a3c954c7 100644 --- a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt +++ b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt @@ -43,7 +43,6 @@ import ee.ria.DigiDoc.network.proxy.ManualProxy import ee.ria.DigiDoc.network.proxy.ProxyConfig import ee.ria.DigiDoc.network.proxy.ProxySetting import ee.ria.DigiDoc.network.utils.ProxyUtil -import ee.ria.DigiDoc.network.utils.ProxyUtil.getManualProxySettings import ee.ria.DigiDoc.network.utils.ProxyUtil.getProxySetting import ee.ria.DigiDoc.network.utils.UserAgentUtil import ee.ria.DigiDoc.utilsLib.extensions.removeWhitespaces @@ -156,42 +155,38 @@ class Initialization forcePKCS12Certificate() val proxySetting: ProxySetting? = getProxySetting(context) - val manualProxy: ManualProxy = getManualProxySettings(context) - val proxyConfig: ProxyConfig = ProxyUtil.getProxy(proxySetting, manualProxy) - val proxySettings = proxyConfig.manualProxy() - - val proxyHostPreferenceKey = - context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_host_key) - val proxyPortPreferenceKey = - context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_port_key) - val proxyUsernamePreferenceKey = - context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_username_key) - val proxyPasswordPreferenceKey = - context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_password_key) - - if (proxySetting == ProxySetting.SYSTEM_PROXY) { - if (proxySettings != null) { - overrideProxy( - proxySettings.host, - proxySettings.port, - proxySettings.username, - proxySettings.password, + + when (proxySetting) { + ProxySetting.SYSTEM_PROXY -> { + val proxyConfig: ProxyConfig = ProxyUtil.getProxy(proxySetting, ManualProxy("", 80, "", "")) + val proxySettings = proxyConfig.manualProxy() + if (proxySettings != null) { + overrideProxy( + proxySettings.host, + proxySettings.port, + proxySettings.username, + proxySettings.password, + ) + } else { + overrideProxy("", 80, "", "") + } + } + + ProxySetting.MANUAL_PROXY -> { + initProxy( + context, + context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_host_key), + "", + context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_port_key), + 80, + context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_username_key), + "", + context.resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_password_key), + "", ) - } else { - overrideProxy("", 80, "", "") } - } else { - initProxy( - context, - proxyHostPreferenceKey, - "", - proxyPortPreferenceKey, - 80, - proxyUsernamePreferenceKey, - "", - proxyPasswordPreferenceKey, - "", - ) + + ProxySetting.NO_PROXY, null -> overrideProxy("", 80, "", "") } loadConfiguration(context) diff --git a/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/proxy/ProxyTunnelSocketFactoryTest.kt b/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/proxy/ProxyTunnelSocketFactoryTest.kt new file mode 100644 index 000000000..555572453 --- /dev/null +++ b/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/proxy/ProxyTunnelSocketFactoryTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.network.proxy + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import java.io.IOException +import javax.net.ssl.SSLSocketFactory + +class ProxyTunnelSocketFactoryTest { + @get:Rule + val proxyServer = MockWebServer() + + private val targetHost = "ldap.example.com" + private val targetPort = 636 + + @Test + fun proxyTunnelSocketFactory_createSocket_throwsProxyAuthenticationExceptionWhenProxyDemandsCredentials() { + proxyServer.enqueue(MockResponse().setResponseCode(407)) + + assertThrows(ProxyAuthenticationException::class.java) { + socketFactory("proxyUser", "proxyPass").createSocket(targetHost, targetPort) + } + } + + @Test + fun proxyTunnelSocketFactory_createSocket_throwsProxyAuthenticationExceptionWhenProxyForbidsConnect() { + proxyServer.enqueue(MockResponse().setResponseCode(403)) + + assertThrows(ProxyAuthenticationException::class.java) { + socketFactory("proxyUser", "proxyPass").createSocket(targetHost, targetPort) + } + } + + @Test + fun proxyTunnelSocketFactory_createSocket_throwsPlainIOExceptionWhenProxyRefusesTunnel() { + proxyServer.enqueue(MockResponse().setResponseCode(502)) + + val exception = + assertThrows(IOException::class.java) { + socketFactory("proxyUser", "proxyPass").createSocket(targetHost, targetPort) + } + + assertFalse(exception is ProxyAuthenticationException) + } + + @Test + fun proxyTunnelSocketFactory_createSocket_sendsConnectWithProxyAuthorization() { + proxyServer.enqueue(MockResponse().setResponseCode(407)) + + assertThrows(ProxyAuthenticationException::class.java) { + socketFactory("proxyUser", "proxyPass").createSocket(targetHost, targetPort) + } + + val request = proxyServer.takeRequest() + assertEquals("CONNECT $targetHost:$targetPort HTTP/1.1", request.requestLine) + assertEquals("Basic cHJveHlVc2VyOnByb3h5UGFzcw==", request.getHeader("Proxy-Authorization")) + } + + @Test + fun proxyTunnelSocketFactory_createSocket_omitsProxyAuthorizationWithoutCredentials() { + proxyServer.enqueue(MockResponse().setResponseCode(407)) + + assertThrows(ProxyAuthenticationException::class.java) { + socketFactory("", "").createSocket(targetHost, targetPort) + } + + assertNull(proxyServer.takeRequest().getHeader("Proxy-Authorization")) + } + + @Test + fun proxyTunnelSocketFactory_createSocket_throwsWhenUnconnectedSocketRequested() { + assertThrows(UnsupportedOperationException::class.java) { + socketFactory("proxyUser", "proxyPass").createSocket() + } + } + + private fun socketFactory( + username: String, + password: String, + ) = ProxyTunnelSocketFactory( + ManualProxy("127.0.0.1", proxyServer.port, username, password), + targetHost, + targetPort, + SSLSocketFactory.getDefault() as SSLSocketFactory, + 5000, + ) +} diff --git a/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtilTest.kt b/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtilTest.kt index f8e840b2a..77bc541a2 100644 --- a/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtilTest.kt +++ b/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtilTest.kt @@ -30,9 +30,14 @@ import androidx.test.platform.app.InstrumentationRegistry import ee.ria.DigiDoc.network.R import ee.ria.DigiDoc.network.proxy.ProxySetting import ee.ria.DigiDoc.network.utils.NetworkUtil.constructClientBuilder +import okhttp3.Authenticator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.net.Proxy class NetworkUtilTest { private lateinit var context: Context @@ -44,6 +49,7 @@ class NetworkUtilTest { context = InstrumentationRegistry.getInstrumentation().targetContext preferences = PreferenceManager.getDefaultSharedPreferences(context) resources = context.resources + setProxyPreferences(ProxySetting.NO_PROXY, "") } @Test @@ -87,4 +93,47 @@ class NetworkUtilTest { assertNotNull(result) } + + @Test + fun networkUtil_constructClientBuilder_manualProxyWithoutHostInstallsNoAuthenticator() { + setProxyPreferences(ProxySetting.MANUAL_PROXY, "proxyUser") + preferences.edit { + putString(resources.getString(R.string.main_settings_proxy_host_key), "") + } + + val result = constructClientBuilder(context).build() + + assertEquals(Authenticator.NONE, result.proxyAuthenticator) + } + + @Test + fun networkUtil_constructClientBuilder_noProxyIgnoresStoredProxyCredentials() { + setProxyPreferences(ProxySetting.NO_PROXY, "proxyUser") + + val result = constructClientBuilder(context).build() + + assertEquals(Proxy.NO_PROXY, result.proxy) + assertEquals(Authenticator.NONE, result.proxyAuthenticator) + } + + @Test + fun networkUtil_constructClientBuilder_manualProxyAuthenticatesTheProxyOnly() { + setProxyPreferences(ProxySetting.MANUAL_PROXY, "proxyUser") + + val result = constructClientBuilder(context).build() + + assertNotEquals(Authenticator.NONE, result.proxyAuthenticator) + assertTrue(result.interceptors.isEmpty()) + } + + private fun setProxyPreferences( + proxySetting: ProxySetting, + username: String, + ) { + preferences.edit { + putString(resources.getString(R.string.main_settings_proxy_setting_key), proxySetting.name) + putString(resources.getString(R.string.main_settings_proxy_host_key), "proxyHost") + putString(resources.getString(R.string.main_settings_proxy_username_key), username) + } + } } diff --git a/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtilTest.kt b/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtilTest.kt index 981c6a7d4..e1561bac4 100644 --- a/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtilTest.kt +++ b/networking-lib/src/androidTest/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtilTest.kt @@ -138,7 +138,7 @@ class ProxyUtilTest { assertNull(result.proxy()) assertNull(result.manualProxy()) - assertNotEquals(Authenticator.Companion.NONE, result.authenticator()) + assertEquals(Authenticator.Companion.NONE, result.authenticator()) } @Test diff --git a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/proxy/ProxyAuthenticationException.kt b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/proxy/ProxyAuthenticationException.kt new file mode 100644 index 000000000..d09f936ba --- /dev/null +++ b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/proxy/ProxyAuthenticationException.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.network.proxy + +import java.io.IOException + +class ProxyAuthenticationException( + message: String, +) : IOException(message) diff --git a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/proxy/ProxyTunnelSocketFactory.kt b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/proxy/ProxyTunnelSocketFactory.kt new file mode 100644 index 000000000..979254acb --- /dev/null +++ b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/proxy/ProxyTunnelSocketFactory.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.network.proxy + +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog +import okhttp3.Credentials.basic +import java.io.IOException +import java.io.InputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import javax.net.SocketFactory +import javax.net.ssl.SSLSocketFactory + +class ProxyTunnelSocketFactory( + private val manualProxy: ManualProxy?, + private val targetHost: String, + private val targetPort: Int, + private val sslSocketFactory: SSLSocketFactory, + private val connectTimeoutMillis: Int, +) : SocketFactory() { + companion object { + private const val LOG_TAG = "ProxyTunnelSocketFactory" + private const val HEADER_TERMINATOR = "\r\n\r\n" + private const val MAX_RESPONSE_LENGTH = 8192 + private const val HTTP_OK = 200 + private const val HTTP_FORBIDDEN = 403 + private const val HTTP_PROXY_AUTHENTICATION_REQUIRED = 407 + } + + override fun createSocket(): Socket = + throw UnsupportedOperationException("Unconnected sockets cannot be tunnelled through a proxy") + + override fun createSocket( + host: String, + port: Int, + ): Socket = openTunnel() + + override fun createSocket( + host: String, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = openTunnel() + + override fun createSocket( + address: InetAddress, + port: Int, + ): Socket = openTunnel() + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = openTunnel() + + private fun openTunnel(): Socket { + val socket = Socket() + try { + val endpoint = + if (manualProxy == null) { + InetSocketAddress(targetHost, targetPort) + } else { + InetSocketAddress(manualProxy.host, manualProxy.port) + } + socket.connect(endpoint, connectTimeoutMillis) + socket.soTimeout = connectTimeoutMillis + if (manualProxy != null) { + requestTunnel(socket, System.currentTimeMillis() + connectTimeoutMillis) + } + return sslSocketFactory.createSocket(socket, targetHost, targetPort, true) + } catch (e: Exception) { + closeQuietly(socket) + throw e + } + } + + private fun requestTunnel( + proxySocket: Socket, + deadline: Long, + ) { + val authorization = + if (manualProxy != null && manualProxy.username.isNotEmpty()) { + "Proxy-Authorization: ${basic(manualProxy.username, manualProxy.password)}\r\n" + } else { + "" + } + val request = + "CONNECT $targetHost:$targetPort HTTP/1.1\r\n" + + "Host: $targetHost:$targetPort\r\n" + + authorization + + "\r\n" + proxySocket.getOutputStream().apply { + write(request.toByteArray(Charsets.ISO_8859_1)) + flush() + } + verifyTunnelEstablished(proxySocket.getInputStream(), deadline) + } + + private fun verifyTunnelEstablished( + input: InputStream, + deadline: Long, + ) { + val response = StringBuilder() + while (!response.endsWith(HEADER_TERMINATOR)) { + if (System.currentTimeMillis() > deadline) { + throw IOException("Proxy did not complete the tunnel to $targetHost in time") + } + val next = input.read() + if (next == -1) { + throw IOException("Proxy closed the connection before the tunnel was established") + } + response.append(next.toChar()) + if (response.length > MAX_RESPONSE_LENGTH) { + throw IOException("Proxy sent an oversized response to the tunnel request") + } + } + when (val statusCode = statusCode(response.toString())) { + HTTP_OK -> return + HTTP_FORBIDDEN, HTTP_PROXY_AUTHENTICATION_REQUIRED -> + throw ProxyAuthenticationException("Proxy rejected the credentials for $targetHost") + else -> + throw IOException("Proxy refused a tunnel to $targetHost with status $statusCode") + } + } + + private fun statusCode(response: String): Int = + response + .substringBefore("\r\n") + .split(' ') + .getOrNull(1) + ?.toIntOrNull() + ?: throw IOException("Proxy sent an unparseable response to the tunnel request") + + private fun closeQuietly(proxySocket: Socket) { + try { + proxySocket.close() + } catch (e: IOException) { + debugLog(LOG_TAG, "Unable to close the proxy socket", e) + } + } +} diff --git a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtil.kt b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtil.kt index 58c9a4a4c..97ea57506 100644 --- a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtil.kt +++ b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/NetworkUtil.kt @@ -29,10 +29,7 @@ import ee.ria.DigiDoc.network.utils.ProxyUtil.getManualProxySettings import ee.ria.DigiDoc.network.utils.ProxyUtil.getProxy import ee.ria.DigiDoc.network.utils.ProxyUtil.getProxySetting import okhttp3.Authenticator -import okhttp3.Credentials.basic -import okhttp3.Interceptor import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.internal.tls.OkHostnameVerifier import java.net.Proxy import java.util.concurrent.TimeUnit @@ -60,22 +57,6 @@ object NetworkUtil { .proxyAuthenticator( if (proxySetting === ProxySetting.NO_PROXY) Authenticator.NONE else proxyConfig.authenticator(), ) - - builder.addInterceptor( - Interceptor { chain: Interceptor.Chain -> - val originalRequest = chain.request() - val credential = - basic(manualProxy.username, manualProxy.password) - val requestBuilder: Request.Builder = - originalRequest - .newBuilder() - .addHeader("Proxy-Authorization", credential) - .addHeader("Authorization", credential) - - val newRequest: Request = requestBuilder.build() - chain.proceed(newRequest) - }, - ) } return builder diff --git a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt index 2f1a21581..8fcfd61d7 100644 --- a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt +++ b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt @@ -97,7 +97,6 @@ object ProxyUtil { response.request .newBuilder() .header("Proxy-Authorization", credential) - .header("Authorization", credential) .build() } } @@ -110,32 +109,15 @@ object ProxyUtil { if (hasRetried(response)) { return@Authenticator null } - val credential = - manualProxySettings?.username.let { username -> - manualProxySettings?.password.let { password -> - if (username != null) { - if (password != null) { - Credentials.basic( - username, - password, - ) - } else { - null - } - } else { - null - } - } - } - if (credential != null) { - response.request - .newBuilder() - .header("Proxy-Authorization", credential) - .header("Authorization", credential) - .build() - } else { - null + val username = manualProxySettings?.username + val password = manualProxySettings?.password + if (username.isNullOrEmpty() || password == null) { + return@Authenticator null } + response.request + .newBuilder() + .header("Proxy-Authorization", Credentials.basic(username, password)) + .build() } return getProxyConfig(manualProxySettings, authenticator).join() } @@ -162,7 +144,7 @@ object ProxyUtil { manualProxy, ) } - ProxyConfig(null, authenticator ?: Authenticator.Companion.NONE, null) + ProxyConfig(null, Authenticator.Companion.NONE, null) } }