WebViewLoginController.kt 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. /*
  2. * Nextcloud Talk application
  3. *
  4. * @author Mario Danic
  5. * @author Andy Scherzinger
  6. * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
  7. * Copyright (C) 2017 Mario Danic (mario@lovelyhq.com)
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU General Public License as published by
  11. * the Free Software Foundation, either version 3 of the License, or
  12. * at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU General Public License
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. */
  22. package com.nextcloud.talk.controllers
  23. import android.annotation.SuppressLint
  24. import android.content.pm.ActivityInfo
  25. import android.graphics.Bitmap
  26. import android.net.http.SslError
  27. import android.os.Build
  28. import android.os.Bundle
  29. import android.security.KeyChain
  30. import android.security.KeyChainException
  31. import android.text.TextUtils
  32. import android.util.Log
  33. import android.view.View
  34. import android.webkit.ClientCertRequest
  35. import android.webkit.CookieSyncManager
  36. import android.webkit.SslErrorHandler
  37. import android.webkit.WebResourceRequest
  38. import android.webkit.WebResourceResponse
  39. import android.webkit.WebSettings
  40. import android.webkit.WebView
  41. import android.webkit.WebViewClient
  42. import androidx.appcompat.app.AppCompatActivity
  43. import androidx.core.content.res.ResourcesCompat
  44. import androidx.work.Data
  45. import androidx.work.OneTimeWorkRequest
  46. import androidx.work.WorkManager
  47. import autodagger.AutoInjector
  48. import com.bluelinelabs.conductor.RouterTransaction
  49. import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler
  50. import com.nextcloud.talk.R
  51. import com.nextcloud.talk.application.NextcloudTalkApplication
  52. import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
  53. import com.nextcloud.talk.controllers.base.NewBaseController
  54. import com.nextcloud.talk.controllers.util.viewBinding
  55. import com.nextcloud.talk.databinding.ControllerWebViewLoginBinding
  56. import com.nextcloud.talk.events.CertificateEvent
  57. import com.nextcloud.talk.jobs.PushRegistrationWorker
  58. import com.nextcloud.talk.models.LoginData
  59. import com.nextcloud.talk.users.UserManager
  60. import com.nextcloud.talk.utils.DisplayUtils
  61. import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
  62. import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
  63. import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
  64. import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
  65. import com.nextcloud.talk.utils.singletons.ApplicationWideMessageHolder
  66. import com.nextcloud.talk.utils.ssl.MagicTrustManager
  67. import de.cotech.hw.fido.WebViewFidoBridge
  68. import io.reactivex.disposables.Disposable
  69. import org.greenrobot.eventbus.EventBus
  70. import java.lang.reflect.Field
  71. import java.net.CookieManager
  72. import java.net.URLDecoder
  73. import java.security.PrivateKey
  74. import java.security.cert.CertificateException
  75. import java.security.cert.X509Certificate
  76. import java.util.Locale
  77. import javax.inject.Inject
  78. @AutoInjector(NextcloudTalkApplication::class)
  79. class WebViewLoginController(args: Bundle? = null) : NewBaseController(
  80. R.layout.controller_web_view_login,
  81. args
  82. ) {
  83. private val binding: ControllerWebViewLoginBinding by viewBinding(ControllerWebViewLoginBinding::bind)
  84. @Inject
  85. lateinit var userManager: UserManager
  86. @Inject
  87. lateinit var magicTrustManager: MagicTrustManager
  88. @Inject
  89. lateinit var eventBus: EventBus
  90. @Inject
  91. lateinit var cookieManager: CookieManager
  92. private var assembledPrefix: String? = null
  93. private var userQueryDisposable: Disposable? = null
  94. private var baseUrl: String? = null
  95. private var isPasswordUpdate = false
  96. private var username: String? = null
  97. private var password: String? = null
  98. private var loginStep = 0
  99. private var automatedLoginAttempted = false
  100. private var webViewFidoBridge: WebViewFidoBridge? = null
  101. constructor(baseUrl: String?, isPasswordUpdate: Boolean) : this() {
  102. this.baseUrl = baseUrl
  103. this.isPasswordUpdate = isPasswordUpdate
  104. }
  105. constructor(baseUrl: String?, isPasswordUpdate: Boolean, username: String?, password: String?) : this() {
  106. this.baseUrl = baseUrl
  107. this.isPasswordUpdate = isPasswordUpdate
  108. this.username = username
  109. this.password = password
  110. }
  111. private val webLoginUserAgent: String
  112. get() = (
  113. Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) +
  114. Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) +
  115. " " +
  116. Build.MODEL +
  117. " (" +
  118. resources!!.getString(R.string.nc_app_product_name) +
  119. ")"
  120. )
  121. @SuppressLint("SetJavaScriptEnabled")
  122. override fun onViewBound(view: View) {
  123. super.onViewBound(view)
  124. activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
  125. actionBar?.hide()
  126. assembledPrefix = resources!!.getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/"
  127. binding.webview.settings.allowFileAccess = false
  128. binding.webview.settings.allowFileAccessFromFileURLs = false
  129. binding.webview.settings.javaScriptEnabled = true
  130. binding.webview.settings.javaScriptCanOpenWindowsAutomatically = false
  131. binding.webview.settings.domStorageEnabled = true
  132. binding.webview.settings.setUserAgentString(webLoginUserAgent)
  133. binding.webview.settings.saveFormData = false
  134. binding.webview.settings.savePassword = false
  135. binding.webview.settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
  136. binding.webview.clearCache(true)
  137. binding.webview.clearFormData()
  138. binding.webview.clearHistory()
  139. WebView.clearClientCertPreferences(null)
  140. webViewFidoBridge = WebViewFidoBridge.createInstanceForWebView(activity as AppCompatActivity?, binding.webview)
  141. CookieSyncManager.createInstance(activity)
  142. android.webkit.CookieManager.getInstance().removeAllCookies(null)
  143. val headers: MutableMap<String, String> = HashMap()
  144. headers.put("OCS-APIRequest", "true")
  145. binding.webview.webViewClient = object : WebViewClient() {
  146. private var basePageLoaded = false
  147. override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
  148. webViewFidoBridge?.delegateShouldInterceptRequest(view, request)
  149. return super.shouldInterceptRequest(view, request)
  150. }
  151. override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
  152. super.onPageStarted(view, url, favicon)
  153. webViewFidoBridge?.delegateOnPageStarted(view, url, favicon)
  154. }
  155. override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
  156. if (url.startsWith(assembledPrefix!!)) {
  157. parseAndLoginFromWebView(url)
  158. return true
  159. }
  160. return false
  161. }
  162. @Suppress("Detekt.TooGenericExceptionCaught")
  163. override fun onPageFinished(view: WebView, url: String) {
  164. try {
  165. loginStep++
  166. if (!basePageLoaded) {
  167. binding.progressBar.visibility = View.GONE
  168. binding.webview.visibility = View.VISIBLE
  169. basePageLoaded = true
  170. }
  171. if (!TextUtils.isEmpty(username)) {
  172. if (loginStep == 1) {
  173. binding.webview.loadUrl(
  174. "javascript: {document.getElementsByClassName('login')[0].click(); };"
  175. )
  176. } else if (!automatedLoginAttempted) {
  177. automatedLoginAttempted = true
  178. if (TextUtils.isEmpty(password)) {
  179. binding.webview.loadUrl(
  180. "javascript:var justStore = document.getElementById('user').value = '$username';"
  181. )
  182. } else {
  183. binding.webview.loadUrl(
  184. "javascript: {" +
  185. "document.getElementById('user').value = '" + username + "';" +
  186. "document.getElementById('password').value = '" + password + "';" +
  187. "document.getElementById('submit').click(); };"
  188. )
  189. }
  190. }
  191. }
  192. } catch (npe: NullPointerException) {
  193. // view binding can be null
  194. // since this is called asynchronously and UI might have been destroyed in the meantime
  195. Log.i(TAG, "UI destroyed - view binding already gone")
  196. }
  197. super.onPageFinished(view, url)
  198. }
  199. override fun onReceivedClientCertRequest(view: WebView, request: ClientCertRequest) {
  200. val user = userManager.currentUser.blockingGet()
  201. var alias: String? = null
  202. if (!isPasswordUpdate) {
  203. alias = appPreferences!!.temporaryClientCertAlias
  204. }
  205. if (TextUtils.isEmpty(alias) && user != null) {
  206. alias = user.clientCertificate
  207. }
  208. if (!TextUtils.isEmpty(alias)) {
  209. val finalAlias = alias
  210. Thread {
  211. try {
  212. val privateKey = KeyChain.getPrivateKey(activity!!, finalAlias!!)
  213. val certificates = KeyChain.getCertificateChain(
  214. activity!!, finalAlias
  215. )
  216. if (privateKey != null && certificates != null) {
  217. request.proceed(privateKey, certificates)
  218. } else {
  219. request.cancel()
  220. }
  221. } catch (e: KeyChainException) {
  222. request.cancel()
  223. } catch (e: InterruptedException) {
  224. request.cancel()
  225. }
  226. }.start()
  227. } else {
  228. KeyChain.choosePrivateKeyAlias(
  229. activity!!,
  230. { chosenAlias: String? ->
  231. if (chosenAlias != null) {
  232. appPreferences!!.temporaryClientCertAlias = chosenAlias
  233. Thread {
  234. var privateKey: PrivateKey? = null
  235. try {
  236. privateKey = KeyChain.getPrivateKey(activity!!, chosenAlias)
  237. val certificates = KeyChain.getCertificateChain(
  238. activity!!, chosenAlias
  239. )
  240. if (privateKey != null && certificates != null) {
  241. request.proceed(privateKey, certificates)
  242. } else {
  243. request.cancel()
  244. }
  245. } catch (e: KeyChainException) {
  246. request.cancel()
  247. } catch (e: InterruptedException) {
  248. request.cancel()
  249. }
  250. }.start()
  251. } else {
  252. request.cancel()
  253. }
  254. },
  255. arrayOf("RSA", "EC"),
  256. null,
  257. request.host,
  258. request.port,
  259. null
  260. )
  261. }
  262. }
  263. @Suppress("Detekt.TooGenericExceptionCaught")
  264. override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
  265. try {
  266. val sslCertificate = error.certificate
  267. val f: Field = sslCertificate.javaClass.getDeclaredField("mX509Certificate")
  268. f.isAccessible = true
  269. val cert = f[sslCertificate] as X509Certificate
  270. if (cert == null) {
  271. handler.cancel()
  272. } else {
  273. try {
  274. magicTrustManager.checkServerTrusted(arrayOf(cert), "generic")
  275. handler.proceed()
  276. } catch (exception: CertificateException) {
  277. eventBus.post(CertificateEvent(cert, magicTrustManager, handler))
  278. }
  279. }
  280. } catch (exception: Exception) {
  281. handler.cancel()
  282. }
  283. }
  284. override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
  285. super.onReceivedError(view, errorCode, description, failingUrl)
  286. }
  287. }
  288. binding.webview.loadUrl("$baseUrl/index.php/login/flow", headers)
  289. }
  290. private fun dispose() {
  291. if (userQueryDisposable != null && !userQueryDisposable!!.isDisposed) {
  292. userQueryDisposable!!.dispose()
  293. }
  294. userQueryDisposable = null
  295. }
  296. private fun parseAndLoginFromWebView(dataString: String) {
  297. val loginData = parseLoginData(assembledPrefix, dataString)
  298. if (loginData != null) {
  299. dispose()
  300. val currentUser = userManager.currentUser.blockingGet()
  301. var messageType: ApplicationWideMessageHolder.MessageType? = null
  302. if (!isPasswordUpdate &&
  303. userManager.checkIfUserExists(loginData.username!!, baseUrl!!).blockingGet()
  304. ) {
  305. messageType = ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED
  306. }
  307. if (userManager.checkIfUserIsScheduledForDeletion(loginData.username!!, baseUrl!!).blockingGet()) {
  308. ApplicationWideMessageHolder.getInstance().messageType =
  309. ApplicationWideMessageHolder.MessageType.ACCOUNT_SCHEDULED_FOR_DELETION
  310. if (!isPasswordUpdate) {
  311. router.popToRoot()
  312. } else {
  313. router.popCurrentController()
  314. }
  315. }
  316. val finalMessageType = messageType
  317. cookieManager.cookieStore.removeAll()
  318. if (!isPasswordUpdate && finalMessageType == null) {
  319. val bundle = Bundle()
  320. bundle.putString(KEY_USERNAME, loginData.username)
  321. bundle.putString(KEY_TOKEN, loginData.token)
  322. bundle.putString(KEY_BASE_URL, loginData.serverUrl)
  323. var protocol = ""
  324. if (baseUrl!!.startsWith("http://")) {
  325. protocol = "http://"
  326. } else if (baseUrl!!.startsWith("https://")) {
  327. protocol = "https://"
  328. }
  329. if (!TextUtils.isEmpty(protocol)) {
  330. bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
  331. }
  332. router.pushController(
  333. RouterTransaction.with(AccountVerificationController(bundle))
  334. .pushChangeHandler(HorizontalChangeHandler())
  335. .popChangeHandler(HorizontalChangeHandler())
  336. )
  337. } else {
  338. if (isPasswordUpdate) {
  339. if (currentUser != null) {
  340. currentUser.clientCertificate = appPreferences!!.temporaryClientCertAlias
  341. currentUser.token = loginData.token
  342. val rowsUpdated = userManager.updateOrCreateUser(currentUser).blockingGet()
  343. Log.d(TAG, "User rows updated: $rowsUpdated")
  344. if (finalMessageType != null) {
  345. ApplicationWideMessageHolder.getInstance().messageType = finalMessageType
  346. }
  347. val data = Data.Builder().putString(
  348. PushRegistrationWorker.ORIGIN,
  349. "WebViewLoginController#parseAndLoginFromWebView"
  350. ).build()
  351. val pushRegistrationWork = OneTimeWorkRequest.Builder(
  352. PushRegistrationWorker::class.java
  353. )
  354. .setInputData(data)
  355. .build()
  356. WorkManager.getInstance().enqueue(pushRegistrationWork)
  357. router.popCurrentController()
  358. }
  359. } else {
  360. if (finalMessageType != null) {
  361. // FIXME when the user registers a new account that was setup before (aka
  362. // ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED)
  363. // The token is not updated in the database and therefore the account not visible/usable
  364. ApplicationWideMessageHolder.getInstance().messageType = finalMessageType
  365. }
  366. router.popToRoot()
  367. }
  368. }
  369. }
  370. }
  371. private fun parseLoginData(prefix: String?, dataString: String): LoginData? {
  372. if (dataString.length < prefix!!.length) {
  373. return null
  374. }
  375. val loginData = LoginData()
  376. // format is xxx://login/server:xxx&user:xxx&password:xxx
  377. val data: String = dataString.substring(prefix.length)
  378. val values: Array<String> = data.split("&").toTypedArray()
  379. if (values.size != PARAMETER_COUNT) {
  380. return null
  381. }
  382. for (value in values) {
  383. if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
  384. loginData.username = URLDecoder.decode(
  385. value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
  386. )
  387. } else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
  388. loginData.token = URLDecoder.decode(
  389. value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
  390. )
  391. } else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
  392. loginData.serverUrl = URLDecoder.decode(
  393. value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
  394. )
  395. } else {
  396. return null
  397. }
  398. }
  399. return if (!TextUtils.isEmpty(loginData.serverUrl) && !TextUtils.isEmpty(loginData.username) &&
  400. !TextUtils.isEmpty(loginData.token)
  401. ) {
  402. loginData
  403. } else {
  404. null
  405. }
  406. }
  407. override fun onAttach(view: View) {
  408. super.onAttach(view)
  409. if (activity != null && resources != null) {
  410. DisplayUtils.applyColorToStatusBar(
  411. activity,
  412. ResourcesCompat.getColor(resources!!, R.color.colorPrimary, null)
  413. )
  414. DisplayUtils.applyColorToNavigationBar(
  415. activity!!.window,
  416. ResourcesCompat.getColor(resources!!, R.color.colorPrimary, null)
  417. )
  418. }
  419. }
  420. public override fun onDestroy() {
  421. super.onDestroy()
  422. dispose()
  423. }
  424. override fun onDestroyView(view: View) {
  425. super.onDestroyView(view)
  426. activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR
  427. }
  428. init {
  429. sharedApplication!!.componentApplication.inject(this)
  430. }
  431. override val appBarLayoutType: AppBarLayoutType
  432. get() = AppBarLayoutType.EMPTY
  433. companion object {
  434. const val TAG = "WebViewLoginController"
  435. private const val PROTOCOL_SUFFIX = "://"
  436. private const val LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"
  437. private const val PARAMETER_COUNT = 3
  438. }
  439. }