WebViewLoginController.java 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. /*
  2. *
  3. * Nextcloud Talk application
  4. *
  5. * @author Mario Danic
  6. * Copyright (C) 2017 Mario Danic (mario@lovelyhq.com)
  7. *
  8. * This program is free software: you can redistribute it and/or modify
  9. * it under the terms of the GNU General Public License as published by
  10. * the Free Software Foundation, either version 3 of the License, or
  11. * at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU General Public License
  19. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. */
  21. package com.nextcloud.talk.controllers;
  22. import android.annotation.SuppressLint;
  23. import android.content.pm.ActivityInfo;
  24. import android.graphics.Bitmap;
  25. import android.net.http.SslCertificate;
  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.view.LayoutInflater;
  33. import android.view.View;
  34. import android.view.ViewGroup;
  35. import android.webkit.ClientCertRequest;
  36. import android.webkit.CookieSyncManager;
  37. import android.webkit.SslErrorHandler;
  38. import android.webkit.WebResourceRequest;
  39. import android.webkit.WebResourceResponse;
  40. import android.webkit.WebSettings;
  41. import android.webkit.WebView;
  42. import android.webkit.WebViewClient;
  43. import android.widget.ProgressBar;
  44. import com.bluelinelabs.conductor.RouterTransaction;
  45. import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler;
  46. import com.nextcloud.talk.R;
  47. import com.nextcloud.talk.application.NextcloudTalkApplication;
  48. import com.nextcloud.talk.controllers.base.BaseController;
  49. import com.nextcloud.talk.events.CertificateEvent;
  50. import com.nextcloud.talk.jobs.PushRegistrationWorker;
  51. import com.nextcloud.talk.models.LoginData;
  52. import com.nextcloud.talk.models.database.UserEntity;
  53. import com.nextcloud.talk.utils.DisplayUtils;
  54. import com.nextcloud.talk.utils.bundle.BundleKeys;
  55. import com.nextcloud.talk.utils.database.user.UserUtils;
  56. import com.nextcloud.talk.utils.preferences.AppPreferences;
  57. import com.nextcloud.talk.utils.singletons.ApplicationWideMessageHolder;
  58. import com.nextcloud.talk.utils.ssl.MagicTrustManager;
  59. import org.greenrobot.eventbus.EventBus;
  60. import java.lang.reflect.Field;
  61. import java.net.CookieManager;
  62. import java.net.URLDecoder;
  63. import java.security.PrivateKey;
  64. import java.security.cert.CertificateException;
  65. import java.security.cert.X509Certificate;
  66. import java.util.HashMap;
  67. import java.util.Locale;
  68. import java.util.Map;
  69. import javax.inject.Inject;
  70. import androidx.annotation.NonNull;
  71. import androidx.appcompat.app.AppCompatActivity;
  72. import androidx.core.content.res.ResourcesCompat;
  73. import androidx.work.Data;
  74. import androidx.work.OneTimeWorkRequest;
  75. import androidx.work.WorkManager;
  76. import autodagger.AutoInjector;
  77. import butterknife.BindView;
  78. import de.cotech.hw.fido.WebViewFidoBridge;
  79. import io.reactivex.android.schedulers.AndroidSchedulers;
  80. import io.reactivex.disposables.Disposable;
  81. import io.reactivex.schedulers.Schedulers;
  82. import io.requery.Persistable;
  83. import io.requery.reactivex.ReactiveEntityStore;
  84. @AutoInjector(NextcloudTalkApplication.class)
  85. public class WebViewLoginController extends BaseController {
  86. public static final String TAG = "WebViewLoginController";
  87. private final String PROTOCOL_SUFFIX = "://";
  88. private final String LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":";
  89. @Inject
  90. UserUtils userUtils;
  91. @Inject
  92. AppPreferences appPreferences;
  93. @Inject
  94. ReactiveEntityStore<Persistable> dataStore;
  95. @Inject
  96. MagicTrustManager magicTrustManager;
  97. @Inject
  98. EventBus eventBus;
  99. @Inject
  100. CookieManager cookieManager;
  101. @BindView(R.id.webview)
  102. WebView webView;
  103. @BindView(R.id.progress_bar)
  104. ProgressBar progressBar;
  105. private String assembledPrefix;
  106. private Disposable userQueryDisposable;
  107. private String baseUrl;
  108. private boolean isPasswordUpdate;
  109. private String username;
  110. private String password;
  111. private int loginStep = 0;
  112. private boolean automatedLoginAttempted = false;
  113. private WebViewFidoBridge webViewFidoBridge;
  114. public WebViewLoginController(String baseUrl, boolean isPasswordUpdate) {
  115. this.baseUrl = baseUrl;
  116. this.isPasswordUpdate = isPasswordUpdate;
  117. }
  118. public WebViewLoginController(String baseUrl, boolean isPasswordUpdate, String username, String password) {
  119. this.baseUrl = baseUrl;
  120. this.isPasswordUpdate = isPasswordUpdate;
  121. this.username = username;
  122. this.password = password;
  123. }
  124. public WebViewLoginController(Bundle args) {
  125. super(args);
  126. }
  127. private String getWebLoginUserAgent() {
  128. return Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) +
  129. Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) + " " + Build.MODEL + " ("
  130. + getResources().getString(R.string.nc_app_product_name) + ")";
  131. }
  132. @Override
  133. protected View inflateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
  134. return inflater.inflate(R.layout.controller_web_view_login, container, false);
  135. }
  136. @SuppressLint("SetJavaScriptEnabled")
  137. @Override
  138. protected void onViewBound(@NonNull View view) {
  139. super.onViewBound(view);
  140. NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
  141. if (getActivity() != null) {
  142. getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
  143. }
  144. if (getActionBar() != null) {
  145. getActionBar().hide();
  146. }
  147. assembledPrefix = getResources().getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/";
  148. webView.getSettings().setAllowFileAccess(false);
  149. webView.getSettings().setAllowFileAccessFromFileURLs(false);
  150. webView.getSettings().setJavaScriptEnabled(true);
  151. webView.getSettings().setJavaScriptCanOpenWindowsAutomatically(false);
  152. webView.getSettings().setDomStorageEnabled(true);
  153. webView.getSettings().setUserAgentString(getWebLoginUserAgent());
  154. webView.getSettings().setSaveFormData(false);
  155. webView.getSettings().setSavePassword(false);
  156. webView.getSettings().setRenderPriority(WebSettings.RenderPriority.HIGH);
  157. webView.clearCache(true);
  158. webView.clearFormData();
  159. webView.clearHistory();
  160. WebView.clearClientCertPreferences(null);
  161. webViewFidoBridge = WebViewFidoBridge.createInstanceForWebView((AppCompatActivity) getActivity(), webView);
  162. CookieSyncManager.createInstance(getActivity());
  163. android.webkit.CookieManager.getInstance().removeAllCookies(null);
  164. Map<String, String> headers = new HashMap<>();
  165. headers.put("OCS-APIRequest", "true");
  166. webView.setWebViewClient(new WebViewClient() {
  167. private boolean basePageLoaded;
  168. @Override
  169. public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
  170. webViewFidoBridge.delegateShouldInterceptRequest(view, request);
  171. return super.shouldInterceptRequest(view, request);
  172. }
  173. @Override
  174. public void onPageStarted(WebView view, String url, Bitmap favicon) {
  175. super.onPageStarted(view, url, favicon);
  176. webViewFidoBridge.delegateOnPageStarted(view, url, favicon);
  177. }
  178. @Override
  179. public boolean shouldOverrideUrlLoading(WebView view, String url) {
  180. if (url.startsWith(assembledPrefix)) {
  181. parseAndLoginFromWebView(url);
  182. return true;
  183. }
  184. return false;
  185. }
  186. @Override
  187. public void onPageFinished(WebView view, String url) {
  188. loginStep++;
  189. if (!basePageLoaded) {
  190. if (progressBar != null) {
  191. progressBar.setVisibility(View.GONE);
  192. }
  193. if (webView != null) {
  194. webView.setVisibility(View.VISIBLE);
  195. }
  196. basePageLoaded = true;
  197. }
  198. if (!TextUtils.isEmpty(username) && webView != null) {
  199. if (loginStep == 1) {
  200. webView.loadUrl("javascript: {document.getElementsByClassName('login')[0].click(); };");
  201. } else if (!automatedLoginAttempted) {
  202. automatedLoginAttempted = true;
  203. if (TextUtils.isEmpty(password)) {
  204. webView.loadUrl("javascript:var justStore = document.getElementById('user').value = '" + username + "';");
  205. } else {
  206. webView.loadUrl("javascript: {" +
  207. "document.getElementById('user').value = '" + username + "';" +
  208. "document.getElementById('password').value = '" + password + "';" +
  209. "document.getElementById('submit').click(); };");
  210. }
  211. }
  212. }
  213. super.onPageFinished(view, url);
  214. }
  215. @Override
  216. public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
  217. UserEntity userEntity = userUtils.getCurrentUser();
  218. String alias = null;
  219. if (!isPasswordUpdate) {
  220. alias = appPreferences.getTemporaryClientCertAlias();
  221. }
  222. if (TextUtils.isEmpty(alias) && (userEntity != null)) {
  223. alias = userEntity.getClientCertificate();
  224. }
  225. if (!TextUtils.isEmpty(alias)) {
  226. String finalAlias = alias;
  227. new Thread(() -> {
  228. try {
  229. PrivateKey privateKey = KeyChain.getPrivateKey(getActivity(), finalAlias);
  230. X509Certificate[] certificates = KeyChain.getCertificateChain(getActivity(), finalAlias);
  231. if (privateKey != null && certificates != null) {
  232. request.proceed(privateKey, certificates);
  233. } else {
  234. request.cancel();
  235. }
  236. } catch (KeyChainException | InterruptedException e) {
  237. request.cancel();
  238. }
  239. }).start();
  240. } else {
  241. KeyChain.choosePrivateKeyAlias(getActivity(), chosenAlias -> {
  242. if (chosenAlias != null) {
  243. appPreferences.setTemporaryClientCertAlias(chosenAlias);
  244. new Thread(() -> {
  245. PrivateKey privateKey = null;
  246. try {
  247. privateKey = KeyChain.getPrivateKey(getActivity(), chosenAlias);
  248. X509Certificate[] certificates = KeyChain.getCertificateChain(getActivity(), chosenAlias);
  249. if (privateKey != null && certificates != null) {
  250. request.proceed(privateKey, certificates);
  251. } else {
  252. request.cancel();
  253. }
  254. } catch (KeyChainException | InterruptedException e) {
  255. request.cancel();
  256. }
  257. }).start();
  258. } else {
  259. request.cancel();
  260. }
  261. }, new String[]{"RSA", "EC"}, null, request.getHost(), request.getPort(), null);
  262. }
  263. }
  264. @Override
  265. public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
  266. try {
  267. SslCertificate sslCertificate = error.getCertificate();
  268. Field f = sslCertificate.getClass().getDeclaredField("mX509Certificate");
  269. f.setAccessible(true);
  270. X509Certificate cert = (X509Certificate) f.get(sslCertificate);
  271. if (cert == null) {
  272. handler.cancel();
  273. } else {
  274. try {
  275. magicTrustManager.checkServerTrusted(new X509Certificate[]{cert}, "generic");
  276. handler.proceed();
  277. } catch (CertificateException exception) {
  278. eventBus.post(new CertificateEvent(cert, magicTrustManager, handler));
  279. }
  280. }
  281. } catch (Exception exception) {
  282. handler.cancel();
  283. }
  284. }
  285. @Override
  286. public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
  287. super.onReceivedError(view, errorCode, description, failingUrl);
  288. }
  289. });
  290. webView.loadUrl(baseUrl + "/index.php/login/flow", headers);
  291. }
  292. private void dispose() {
  293. if (userQueryDisposable != null && !userQueryDisposable.isDisposed()) {
  294. userQueryDisposable.dispose();
  295. }
  296. userQueryDisposable = null;
  297. }
  298. private void parseAndLoginFromWebView(String dataString) {
  299. LoginData loginData = parseLoginData(assembledPrefix, dataString);
  300. if (loginData != null) {
  301. dispose();
  302. UserEntity currentUser = userUtils.getCurrentUser();
  303. ApplicationWideMessageHolder.MessageType messageType = null;
  304. if (!isPasswordUpdate && userUtils.getIfUserWithUsernameAndServer(loginData.getUsername(), baseUrl)) {
  305. messageType = ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED;
  306. }
  307. if (userUtils.checkIfUserIsScheduledForDeletion(loginData.getUsername(), baseUrl)) {
  308. ApplicationWideMessageHolder.getInstance().setMessageType(
  309. ApplicationWideMessageHolder.MessageType.ACCOUNT_SCHEDULED_FOR_DELETION);
  310. if (!isPasswordUpdate) {
  311. getRouter().popToRoot();
  312. } else {
  313. getRouter().popCurrentController();
  314. }
  315. }
  316. ApplicationWideMessageHolder.MessageType finalMessageType = messageType;
  317. cookieManager.getCookieStore().removeAll();
  318. if (!isPasswordUpdate && finalMessageType == null) {
  319. Bundle bundle = new Bundle();
  320. bundle.putString(BundleKeys.INSTANCE.getKEY_USERNAME(), loginData.getUsername());
  321. bundle.putString(BundleKeys.INSTANCE.getKEY_TOKEN(), loginData.getToken());
  322. bundle.putString(BundleKeys.INSTANCE.getKEY_BASE_URL(), loginData.getServerUrl());
  323. String 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(BundleKeys.INSTANCE.getKEY_ORIGINAL_PROTOCOL(), protocol);
  331. }
  332. getRouter().pushController(RouterTransaction.with(new AccountVerificationController
  333. (bundle)).pushChangeHandler(new HorizontalChangeHandler())
  334. .popChangeHandler(new HorizontalChangeHandler()));
  335. } else {
  336. if (isPasswordUpdate) {
  337. if (currentUser != null) {
  338. userQueryDisposable = userUtils.createOrUpdateUser(null, loginData.getToken(),
  339. null, null, "", Boolean.TRUE,
  340. null, currentUser.getId(), null, appPreferences.getTemporaryClientCertAlias(), null)
  341. .subscribeOn(Schedulers.io())
  342. .observeOn(AndroidSchedulers.mainThread())
  343. .subscribe(userEntity -> {
  344. if (finalMessageType != null) {
  345. ApplicationWideMessageHolder.getInstance().setMessageType(finalMessageType);
  346. }
  347. Data data =
  348. new Data.Builder().putString(PushRegistrationWorker.ORIGIN,
  349. "WebViewLoginController#parseAndLoginFromWebView").build();
  350. OneTimeWorkRequest pushRegistrationWork = new OneTimeWorkRequest.Builder(PushRegistrationWorker.class)
  351. .setInputData(data)
  352. .build();
  353. WorkManager.getInstance().enqueue(pushRegistrationWork);
  354. getRouter().popCurrentController();
  355. }, throwable -> dispose(),
  356. this::dispose);
  357. }
  358. } else {
  359. if (finalMessageType != null) {
  360. // FIXME when the user registers a new account that was setup before (aka
  361. // ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED)
  362. // The token is not updated in the database and therefor the account not visible/usable
  363. ApplicationWideMessageHolder.getInstance().setMessageType(finalMessageType);
  364. }
  365. getRouter().popToRoot();
  366. }
  367. }
  368. }
  369. }
  370. private LoginData parseLoginData(String prefix, String dataString) {
  371. if (dataString.length() < prefix.length()) {
  372. return null;
  373. }
  374. LoginData loginData = new LoginData();
  375. // format is xxx://login/server:xxx&user:xxx&password:xxx
  376. String data = dataString.substring(prefix.length());
  377. String[] values = data.split("&");
  378. if (values.length != 3) {
  379. return null;
  380. }
  381. for (String value : values) {
  382. if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
  383. loginData.setUsername(URLDecoder.decode(
  384. value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())));
  385. } else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
  386. loginData.setToken(URLDecoder.decode(
  387. value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())));
  388. } else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
  389. loginData.setServerUrl(URLDecoder.decode(
  390. value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())));
  391. } else {
  392. return null;
  393. }
  394. }
  395. if (!TextUtils.isEmpty(loginData.getServerUrl()) && !TextUtils.isEmpty(loginData.getUsername()) &&
  396. !TextUtils.isEmpty(loginData.getToken())) {
  397. return loginData;
  398. } else {
  399. return null;
  400. }
  401. }
  402. @Override
  403. protected void onAttach(@NonNull View view) {
  404. super.onAttach(view);
  405. if (getActivity() != null && getResources() != null) {
  406. DisplayUtils.applyColorToStatusBar(getActivity(), ResourcesCompat.getColor(getResources(), R.color.colorPrimary, null));
  407. DisplayUtils.applyColorToNavigationBar(getActivity().getWindow(), ResourcesCompat.getColor(getResources(), R.color.colorPrimary, null));
  408. }
  409. }
  410. @Override
  411. public void onDestroy() {
  412. super.onDestroy();
  413. dispose();
  414. }
  415. @Override
  416. protected void onDestroyView(@NonNull View view) {
  417. super.onDestroyView(view);
  418. if (getActivity() != null) {
  419. getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);
  420. }
  421. }
  422. @Override
  423. public AppBarLayoutType getAppBarLayoutType() {
  424. return AppBarLayoutType.EMPTY;
  425. }
  426. }