mirror of
https://github.com/nextcloud/talk-android
synced 2025-07-13 07:44:11 +01:00
especially for branded clients the primary color might not look good. Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
460 lines
19 KiB
Kotlin
460 lines
19 KiB
Kotlin
/*
|
|
* Nextcloud Talk - Android Client
|
|
*
|
|
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
|
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
|
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
package com.nextcloud.talk.account
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.Intent
|
|
import android.content.pm.ActivityInfo
|
|
import android.graphics.Bitmap
|
|
import android.net.http.SslError
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.security.KeyChain
|
|
import android.security.KeyChainException
|
|
import android.text.TextUtils
|
|
import android.util.Log
|
|
import android.view.View
|
|
import android.webkit.ClientCertRequest
|
|
import android.webkit.CookieSyncManager
|
|
import android.webkit.SslErrorHandler
|
|
import android.webkit.WebResourceRequest
|
|
import android.webkit.WebResourceResponse
|
|
import android.webkit.WebSettings
|
|
import android.webkit.WebView
|
|
import android.webkit.WebViewClient
|
|
import androidx.activity.OnBackPressedCallback
|
|
import androidx.work.OneTimeWorkRequest
|
|
import androidx.work.WorkInfo
|
|
import androidx.work.WorkManager
|
|
import autodagger.AutoInjector
|
|
import com.google.android.material.snackbar.Snackbar
|
|
import com.nextcloud.talk.R
|
|
import com.nextcloud.talk.activities.BaseActivity
|
|
import com.nextcloud.talk.activities.MainActivity
|
|
import com.nextcloud.talk.application.NextcloudTalkApplication
|
|
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
|
import com.nextcloud.talk.databinding.ActivityWebViewLoginBinding
|
|
import com.nextcloud.talk.events.CertificateEvent
|
|
import com.nextcloud.talk.jobs.AccountRemovalWorker
|
|
import com.nextcloud.talk.models.LoginData
|
|
import com.nextcloud.talk.users.UserManager
|
|
import com.nextcloud.talk.utils.bundle.BundleKeys
|
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
|
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
|
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
|
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
|
|
import com.nextcloud.talk.utils.ssl.TrustManager
|
|
import de.cotech.hw.fido.WebViewFidoBridge
|
|
import de.cotech.hw.fido2.WebViewWebauthnBridge
|
|
import de.cotech.hw.fido2.ui.WebauthnDialogOptions
|
|
import io.reactivex.disposables.Disposable
|
|
import java.lang.reflect.Field
|
|
import java.net.CookieManager
|
|
import java.net.URLDecoder
|
|
import java.security.PrivateKey
|
|
import java.security.cert.CertificateException
|
|
import java.security.cert.X509Certificate
|
|
import java.util.Locale
|
|
import javax.inject.Inject
|
|
|
|
@AutoInjector(NextcloudTalkApplication::class)
|
|
class WebViewLoginActivity : BaseActivity() {
|
|
|
|
private lateinit var binding: ActivityWebViewLoginBinding
|
|
|
|
@Inject
|
|
lateinit var userManager: UserManager
|
|
|
|
@Inject
|
|
lateinit var trustManager: TrustManager
|
|
|
|
@Inject
|
|
lateinit var cookieManager: CookieManager
|
|
|
|
private var assembledPrefix: String? = null
|
|
private var userQueryDisposable: Disposable? = null
|
|
private var baseUrl: String? = null
|
|
private var reauthorizeAccount = false
|
|
private var username: String? = null
|
|
private var password: String? = null
|
|
private var loginStep = 0
|
|
private var automatedLoginAttempted = false
|
|
private var webViewFidoBridge: WebViewFidoBridge? = null
|
|
private var webViewWebauthnBridge: WebViewWebauthnBridge? = null
|
|
|
|
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
|
|
override fun handleOnBackPressed() {
|
|
val intent = Intent(context, MainActivity::class.java)
|
|
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
startActivity(intent)
|
|
}
|
|
}
|
|
private val webLoginUserAgent: String
|
|
get() = (
|
|
Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) +
|
|
Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) +
|
|
" " +
|
|
Build.MODEL +
|
|
" (" +
|
|
resources!!.getString(R.string.nc_app_product_name) +
|
|
")"
|
|
)
|
|
|
|
@SuppressLint("SourceLockedOrientationActivity")
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
sharedApplication!!.componentApplication.inject(this)
|
|
binding = ActivityWebViewLoginBinding.inflate(layoutInflater)
|
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
setContentView(binding.root)
|
|
actionBar?.hide()
|
|
setupSystemColors()
|
|
|
|
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
|
handleIntent()
|
|
setupWebView()
|
|
}
|
|
|
|
private fun handleIntent() {
|
|
val extras = intent.extras!!
|
|
baseUrl = extras.getString(KEY_BASE_URL)
|
|
username = extras.getString(KEY_USERNAME)
|
|
|
|
if (extras.containsKey(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)) {
|
|
reauthorizeAccount = extras.getBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)
|
|
}
|
|
|
|
if (extras.containsKey(BundleKeys.KEY_PASSWORD)) {
|
|
password = extras.getString(BundleKeys.KEY_PASSWORD)
|
|
}
|
|
}
|
|
|
|
@SuppressLint("SetJavaScriptEnabled")
|
|
private fun setupWebView() {
|
|
assembledPrefix = resources!!.getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/"
|
|
binding.webview.settings.allowFileAccess = false
|
|
binding.webview.settings.allowFileAccessFromFileURLs = false
|
|
binding.webview.settings.javaScriptEnabled = true
|
|
binding.webview.settings.javaScriptCanOpenWindowsAutomatically = false
|
|
binding.webview.settings.domStorageEnabled = true
|
|
binding.webview.settings.userAgentString = webLoginUserAgent
|
|
binding.webview.settings.saveFormData = false
|
|
binding.webview.settings.savePassword = false
|
|
binding.webview.settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
|
|
binding.webview.clearCache(true)
|
|
binding.webview.clearFormData()
|
|
binding.webview.clearHistory()
|
|
WebView.clearClientCertPreferences(null)
|
|
webViewFidoBridge = WebViewFidoBridge.createInstanceForWebView(this, binding.webview)
|
|
|
|
val webauthnOptionsBuilder = WebauthnDialogOptions.builder().setShowSdkLogo(true).setAllowSkipPin(true)
|
|
webViewWebauthnBridge = WebViewWebauthnBridge.createInstanceForWebView(
|
|
this, binding.webview, webauthnOptionsBuilder
|
|
)
|
|
|
|
CookieSyncManager.createInstance(this)
|
|
android.webkit.CookieManager.getInstance().removeAllCookies(null)
|
|
val headers: MutableMap<String, String> = HashMap()
|
|
headers["OCS-APIRequest"] = "true"
|
|
binding.webview.webViewClient = object : WebViewClient() {
|
|
private var basePageLoaded = false
|
|
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
|
webViewFidoBridge?.delegateShouldInterceptRequest(view, request)
|
|
webViewWebauthnBridge?.delegateShouldInterceptRequest(view, request)
|
|
return super.shouldInterceptRequest(view, request)
|
|
}
|
|
|
|
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
|
super.onPageStarted(view, url, favicon)
|
|
webViewFidoBridge?.delegateOnPageStarted(view, url, favicon)
|
|
webViewWebauthnBridge?.delegateOnPageStarted(view, url, favicon)
|
|
}
|
|
|
|
@Deprecated("Use shouldOverrideUrlLoading(WebView view, WebResourceRequest request)")
|
|
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
|
if (url.startsWith(assembledPrefix!!)) {
|
|
parseAndLoginFromWebView(url)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
@Suppress("Detekt.TooGenericExceptionCaught")
|
|
override fun onPageFinished(view: WebView, url: String) {
|
|
loginStep++
|
|
if (!basePageLoaded) {
|
|
binding.progressBar.visibility = View.GONE
|
|
binding.webview.visibility = View.VISIBLE
|
|
|
|
basePageLoaded = true
|
|
}
|
|
if (!TextUtils.isEmpty(username)) {
|
|
if (loginStep == 1) {
|
|
binding.webview.loadUrl(
|
|
"javascript: {document.getElementsByClassName('login')[0].click(); };"
|
|
)
|
|
} else if (!automatedLoginAttempted) {
|
|
automatedLoginAttempted = true
|
|
if (TextUtils.isEmpty(password)) {
|
|
binding.webview.loadUrl(
|
|
"javascript:var justStore = document.getElementById('user').value = '$username';"
|
|
)
|
|
} else {
|
|
binding.webview.loadUrl(
|
|
"javascript: {" +
|
|
"document.getElementById('user').value = '" + username + "';" +
|
|
"document.getElementById('password').value = '" + password + "';" +
|
|
"document.getElementById('submit').click(); };"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
super.onPageFinished(view, url)
|
|
}
|
|
|
|
override fun onReceivedClientCertRequest(view: WebView, request: ClientCertRequest) {
|
|
val user = userManager.currentUser.blockingGet()
|
|
var alias: String? = null
|
|
if (!reauthorizeAccount) {
|
|
alias = appPreferences.temporaryClientCertAlias
|
|
}
|
|
if (TextUtils.isEmpty(alias) && user != null) {
|
|
alias = user.clientCertificate
|
|
}
|
|
if (!TextUtils.isEmpty(alias)) {
|
|
val finalAlias = alias
|
|
Thread {
|
|
try {
|
|
val privateKey = KeyChain.getPrivateKey(applicationContext, finalAlias!!)
|
|
val certificates = KeyChain.getCertificateChain(
|
|
applicationContext,
|
|
finalAlias
|
|
)
|
|
if (privateKey != null && certificates != null) {
|
|
request.proceed(privateKey, certificates)
|
|
} else {
|
|
request.cancel()
|
|
}
|
|
} catch (e: KeyChainException) {
|
|
request.cancel()
|
|
} catch (e: InterruptedException) {
|
|
request.cancel()
|
|
}
|
|
}.start()
|
|
} else {
|
|
KeyChain.choosePrivateKeyAlias(
|
|
this@WebViewLoginActivity,
|
|
{ chosenAlias: String? ->
|
|
if (chosenAlias != null) {
|
|
appPreferences!!.temporaryClientCertAlias = chosenAlias
|
|
Thread {
|
|
var privateKey: PrivateKey? = null
|
|
try {
|
|
privateKey = KeyChain.getPrivateKey(applicationContext, chosenAlias)
|
|
val certificates = KeyChain.getCertificateChain(
|
|
applicationContext,
|
|
chosenAlias
|
|
)
|
|
if (privateKey != null && certificates != null) {
|
|
request.proceed(privateKey, certificates)
|
|
} else {
|
|
request.cancel()
|
|
}
|
|
} catch (e: KeyChainException) {
|
|
request.cancel()
|
|
} catch (e: InterruptedException) {
|
|
request.cancel()
|
|
}
|
|
}.start()
|
|
} else {
|
|
request.cancel()
|
|
}
|
|
},
|
|
arrayOf("RSA", "EC"),
|
|
null,
|
|
request.host,
|
|
request.port,
|
|
null
|
|
)
|
|
}
|
|
}
|
|
|
|
@Suppress("Detekt.TooGenericExceptionCaught")
|
|
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
|
|
try {
|
|
val sslCertificate = error.certificate
|
|
val f: Field = sslCertificate.javaClass.getDeclaredField("mX509Certificate")
|
|
f.isAccessible = true
|
|
val cert = f[sslCertificate] as X509Certificate
|
|
if (cert == null) {
|
|
handler.cancel()
|
|
} else {
|
|
try {
|
|
trustManager.checkServerTrusted(arrayOf(cert), "generic")
|
|
handler.proceed()
|
|
} catch (exception: CertificateException) {
|
|
eventBus.post(CertificateEvent(cert, trustManager, handler))
|
|
}
|
|
}
|
|
} catch (exception: Exception) {
|
|
handler.cancel()
|
|
}
|
|
}
|
|
|
|
@Deprecated("Deprecated in super implementation")
|
|
override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
|
|
super.onReceivedError(view, errorCode, description, failingUrl)
|
|
}
|
|
}
|
|
binding.webview.loadUrl("$baseUrl/index.php/login/flow", headers)
|
|
}
|
|
|
|
private fun dispose() {
|
|
if (userQueryDisposable != null && !userQueryDisposable!!.isDisposed) {
|
|
userQueryDisposable!!.dispose()
|
|
}
|
|
userQueryDisposable = null
|
|
}
|
|
|
|
private fun parseAndLoginFromWebView(dataString: String) {
|
|
val loginData = parseLoginData(assembledPrefix, dataString)
|
|
if (loginData != null) {
|
|
dispose()
|
|
cookieManager.cookieStore.removeAll()
|
|
|
|
if (userManager.checkIfUserIsScheduledForDeletion(loginData.username!!, baseUrl!!).blockingGet()) {
|
|
Log.e(TAG, "Tried to add already existing user who is scheduled for deletion.")
|
|
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
|
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
|
|
startAccountRemovalWorkerAndRestartApp()
|
|
} else if (userManager.checkIfUserExists(loginData.username!!, baseUrl!!).blockingGet()) {
|
|
if (reauthorizeAccount) {
|
|
updateUserAndRestartApp(loginData)
|
|
} else {
|
|
Log.w(TAG, "It was tried to add an account that account already exists. Skipped user creation.")
|
|
restartApp()
|
|
}
|
|
} else {
|
|
startAccountVerification(loginData)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun startAccountVerification(loginData: LoginData) {
|
|
val bundle = Bundle()
|
|
bundle.putString(KEY_USERNAME, loginData.username)
|
|
bundle.putString(KEY_TOKEN, loginData.token)
|
|
bundle.putString(KEY_BASE_URL, loginData.serverUrl)
|
|
var protocol = ""
|
|
if (baseUrl!!.startsWith("http://")) {
|
|
protocol = "http://"
|
|
} else if (baseUrl!!.startsWith("https://")) {
|
|
protocol = "https://"
|
|
}
|
|
if (!TextUtils.isEmpty(protocol)) {
|
|
bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
|
|
}
|
|
val intent = Intent(context, AccountVerificationActivity::class.java)
|
|
intent.putExtras(bundle)
|
|
startActivity(intent)
|
|
}
|
|
|
|
private fun restartApp() {
|
|
val intent = Intent(context, MainActivity::class.java)
|
|
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
startActivity(intent)
|
|
}
|
|
|
|
private fun updateUserAndRestartApp(loginData: LoginData) {
|
|
val currentUser = userManager.currentUser.blockingGet()
|
|
if (currentUser != null) {
|
|
currentUser.clientCertificate = appPreferences.temporaryClientCertAlias
|
|
currentUser.token = loginData.token
|
|
val rowsUpdated = userManager.updateOrCreateUser(currentUser).blockingGet()
|
|
Log.d(TAG, "User rows updated: $rowsUpdated")
|
|
restartApp()
|
|
}
|
|
}
|
|
|
|
private fun startAccountRemovalWorkerAndRestartApp() {
|
|
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
|
|
WorkManager.getInstance(applicationContext).enqueue(accountRemovalWork)
|
|
|
|
WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
|
|
.observeForever { workInfo: WorkInfo ->
|
|
|
|
when (workInfo.state) {
|
|
WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
|
|
restartApp()
|
|
}
|
|
|
|
else -> {}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun parseLoginData(prefix: String?, dataString: String): LoginData? {
|
|
if (dataString.length < prefix!!.length) {
|
|
return null
|
|
}
|
|
val loginData = LoginData()
|
|
|
|
// format is xxx://login/server:xxx&user:xxx&password:xxx
|
|
val data: String = dataString.substring(prefix.length)
|
|
val values: Array<String> = data.split("&").toTypedArray()
|
|
if (values.size != PARAMETER_COUNT) {
|
|
return null
|
|
}
|
|
for (value in values) {
|
|
if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
|
|
loginData.username = URLDecoder.decode(
|
|
value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
|
|
)
|
|
} else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
|
|
loginData.token = URLDecoder.decode(
|
|
value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
|
|
)
|
|
} else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
|
|
loginData.serverUrl = URLDecoder.decode(
|
|
value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
|
|
)
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
return if (!TextUtils.isEmpty(loginData.serverUrl) && !TextUtils.isEmpty(loginData.username) &&
|
|
!TextUtils.isEmpty(loginData.token)
|
|
) {
|
|
loginData
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
|
|
public override fun onDestroy() {
|
|
super.onDestroy()
|
|
dispose()
|
|
}
|
|
|
|
init {
|
|
sharedApplication!!.componentApplication.inject(this)
|
|
}
|
|
|
|
override val appBarLayoutType: AppBarLayoutType
|
|
get() = AppBarLayoutType.EMPTY
|
|
|
|
companion object {
|
|
private val TAG = WebViewLoginActivity::class.java.simpleName
|
|
private const val PROTOCOL_SUFFIX = "://"
|
|
private const val LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"
|
|
private const val PARAMETER_COUNT = 3
|
|
}
|
|
}
|