user_service.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. import 'dart:typed_data';
  2. import 'package:dio/dio.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter/widgets.dart';
  5. import 'package:flutter_gen/gen_l10n/app_localizations.dart';
  6. import 'package:flutter_sodium/flutter_sodium.dart';
  7. import 'package:logging/logging.dart';
  8. import 'package:photos/core/configuration.dart';
  9. import 'package:photos/core/event_bus.dart';
  10. import 'package:photos/core/network.dart';
  11. import 'package:photos/db/public_keys_db.dart';
  12. import 'package:photos/events/two_factor_status_change_event.dart';
  13. import 'package:photos/events/user_details_changed_event.dart';
  14. import 'package:photos/models/key_attributes.dart';
  15. import 'package:photos/models/key_gen_result.dart';
  16. import 'package:photos/models/public_key.dart';
  17. import 'package:photos/models/set_keys_request.dart';
  18. import 'package:photos/models/set_recovery_key_request.dart';
  19. import 'package:photos/models/user_details.dart';
  20. import 'package:photos/ui/login_page.dart';
  21. import 'package:photos/ui/ott_verification_page.dart';
  22. import 'package:photos/ui/password_entry_page.dart';
  23. import 'package:photos/ui/password_reentry_page.dart';
  24. import 'package:photos/ui/two_factor_authentication_page.dart';
  25. import 'package:photos/ui/two_factor_recovery_page.dart';
  26. import 'package:photos/ui/two_factor_setup_page.dart';
  27. import 'package:photos/utils/crypto_util.dart';
  28. import 'package:photos/utils/dialog_util.dart';
  29. import 'package:photos/utils/navigation_util.dart';
  30. import 'package:photos/utils/toast_util.dart';
  31. class UserService {
  32. final _dio = Network.instance.getDio();
  33. final _logger = Logger("UserAuthenticator");
  34. final _config = Configuration.instance;
  35. UserService._privateConstructor();
  36. static final UserService instance = UserService._privateConstructor();
  37. Future<void> getOtt(
  38. BuildContext context,
  39. String email, {
  40. bool isChangeEmail = false,
  41. }) async {
  42. final dialog = createProgressDialog(context, "please wait...");
  43. await dialog.show();
  44. try {
  45. final response = await _dio.get(
  46. _config.getHttpEndpoint() + "/users/ott",
  47. queryParameters: {
  48. "email": email,
  49. "purpose": isChangeEmail ? "change" : ""
  50. },
  51. );
  52. await dialog.hide();
  53. if (response != null && response.statusCode == 200) {
  54. Navigator.of(context).push(
  55. MaterialPageRoute(
  56. builder: (BuildContext context) {
  57. return OTTVerificationPage(
  58. email,
  59. isChangeEmail: isChangeEmail,
  60. );
  61. },
  62. ),
  63. );
  64. return;
  65. }
  66. showGenericErrorDialog(context);
  67. } on DioError catch (e) {
  68. await dialog.hide();
  69. if (e.response != null && e.response.statusCode == 403) {
  70. showErrorDialog(context, AppLocalizations.of(context).oops,
  71. AppLocalizations.of(context).email_already_claimed);
  72. } else {
  73. showGenericErrorDialog(context);
  74. }
  75. } catch (e) {
  76. await dialog.hide();
  77. _logger.severe(e);
  78. showGenericErrorDialog(context);
  79. }
  80. }
  81. Future<String> getPublicKey(String email) async {
  82. try {
  83. final response = await _dio.get(
  84. _config.getHttpEndpoint() + "/users/public-key",
  85. queryParameters: {"email": email},
  86. options: Options(
  87. headers: {
  88. "X-Auth-Token": _config.getToken(),
  89. },
  90. ),
  91. );
  92. final publicKey = response.data["publicKey"];
  93. await PublicKeysDB.instance.setKey(PublicKey(email, publicKey));
  94. return publicKey;
  95. } on DioError catch (e) {
  96. _logger.info(e);
  97. return null;
  98. }
  99. }
  100. Future<UserDetails> getUserDetails() async {
  101. try {
  102. final response = await _dio.get(
  103. _config.getHttpEndpoint() + "/users/details",
  104. options: Options(
  105. headers: {
  106. "X-Auth-Token": _config.getToken(),
  107. },
  108. ),
  109. );
  110. return UserDetails.fromMap(response.data["details"]);
  111. } on DioError catch (e) {
  112. _logger.info(e);
  113. rethrow;
  114. }
  115. }
  116. Future<void> logout(BuildContext context) async {
  117. final dialog = createProgressDialog(context, "logging out...");
  118. await dialog.show();
  119. try {
  120. final response =
  121. await _dio.post(_config.getHttpEndpoint() + "/users/logout",
  122. options: Options(
  123. headers: {
  124. "X-Auth-Token": _config.getToken(),
  125. },
  126. ));
  127. if (response != null && response.statusCode == 200) {
  128. await Configuration.instance.logout();
  129. await dialog.hide();
  130. Navigator.of(context).popUntil((route) => route.isFirst);
  131. } else {
  132. throw Exception("log out action failed");
  133. }
  134. } catch (e) {
  135. _logger.severe(e);
  136. await dialog.hide();
  137. showGenericErrorDialog(context);
  138. }
  139. }
  140. Future<void> verifyEmail(BuildContext context, String ott) async {
  141. final dialog = createProgressDialog(context, "please wait...");
  142. await dialog.show();
  143. try {
  144. final response = await _dio.post(
  145. _config.getHttpEndpoint() + "/users/verify-email",
  146. data: {
  147. "email": _config.getEmail(),
  148. "ott": ott,
  149. },
  150. );
  151. await dialog.hide();
  152. if (response != null && response.statusCode == 200) {
  153. showToast("email verification successful!");
  154. Widget page;
  155. final String twoFASessionID = response.data["twoFactorSessionID"];
  156. if (twoFASessionID != null && twoFASessionID.isNotEmpty) {
  157. page = TwoFactorAuthenticationPage(twoFASessionID);
  158. } else {
  159. await _saveConfiguration(response);
  160. if (Configuration.instance.getEncryptedToken() != null) {
  161. page = PasswordReentryPage();
  162. } else {
  163. page = PasswordEntryPage();
  164. }
  165. }
  166. Navigator.of(context).pushAndRemoveUntil(
  167. MaterialPageRoute(
  168. builder: (BuildContext context) {
  169. return page;
  170. },
  171. ),
  172. (route) => route.isFirst,
  173. );
  174. } else {
  175. // should never reach here
  176. throw Exception("unexpected response during email verification");
  177. }
  178. } on DioError catch (e) {
  179. await dialog.hide();
  180. if (e.response != null && e.response.statusCode == 410) {
  181. await showErrorDialog(context, AppLocalizations.of(context).oops,
  182. AppLocalizations.of(context).log_in_code_expired);
  183. Navigator.of(context).pop();
  184. } else {
  185. showErrorDialog(
  186. context,
  187. AppLocalizations.of(context).incorrect_code_title,
  188. AppLocalizations.of(context).incorrect_code_msg);
  189. }
  190. } catch (e) {
  191. await dialog.hide();
  192. _logger.severe(e);
  193. showErrorDialog(context, AppLocalizations.of(context).oops,
  194. "verification failed, please try again");
  195. }
  196. }
  197. Future<void> changeEmail(
  198. BuildContext context,
  199. String email,
  200. String ott,
  201. ) async {
  202. final dialog = createProgressDialog(context, "please wait...");
  203. await dialog.show();
  204. try {
  205. final response = await _dio.post(
  206. _config.getHttpEndpoint() + "/users/change-email",
  207. data: {
  208. "email": email,
  209. "ott": ott,
  210. },
  211. options: Options(
  212. headers: {
  213. "X-Auth-Token": _config.getToken(),
  214. },
  215. ),
  216. );
  217. await dialog.hide();
  218. if (response != null && response.statusCode == 200) {
  219. showToast("email changed to " + email);
  220. _config.setEmail(email);
  221. Navigator.of(context).popUntil((route) => route.isFirst);
  222. Bus.instance.fire(UserDetailsChangedEvent());
  223. return;
  224. }
  225. showErrorDialog(context, AppLocalizations.of(context).oops,
  226. "verification failed, please try again");
  227. } on DioError catch (e) {
  228. await dialog.hide();
  229. if (e.response != null && e.response.statusCode == 403) {
  230. showErrorDialog(context, AppLocalizations.of(context).oops,
  231. AppLocalizations.of(context).email_already_claimed);
  232. } else {
  233. showErrorDialog(
  234. context,
  235. AppLocalizations.of(context).incorrect_code_title,
  236. AppLocalizations.of(context).incorrect_code_msg);
  237. }
  238. } catch (e) {
  239. await dialog.hide();
  240. _logger.severe(e);
  241. showErrorDialog(context, AppLocalizations.of(context).oops,
  242. "verification failed, please try again");
  243. }
  244. }
  245. Future<void> setAttributes(KeyGenResult result) async {
  246. try {
  247. final name = _config.getName();
  248. await _dio.put(
  249. _config.getHttpEndpoint() + "/users/attributes",
  250. data: {
  251. "name": name,
  252. "keyAttributes": result.keyAttributes.toMap(),
  253. },
  254. options: Options(
  255. headers: {
  256. "X-Auth-Token": _config.getToken(),
  257. },
  258. ),
  259. );
  260. await _config.setKey(result.privateKeyAttributes.key);
  261. await _config.setSecretKey(result.privateKeyAttributes.secretKey);
  262. await _config.setKeyAttributes(result.keyAttributes);
  263. } catch (e) {
  264. _logger.severe(e);
  265. rethrow;
  266. }
  267. }
  268. Future<void> updateKeyAttributes(KeyAttributes keyAttributes) async {
  269. try {
  270. final setKeyRequest = SetKeysRequest(
  271. kekSalt: keyAttributes.kekSalt,
  272. encryptedKey: keyAttributes.encryptedKey,
  273. keyDecryptionNonce: keyAttributes.keyDecryptionNonce,
  274. memLimit: keyAttributes.memLimit,
  275. opsLimit: keyAttributes.opsLimit,
  276. );
  277. await _dio.put(
  278. _config.getHttpEndpoint() + "/users/keys",
  279. data: setKeyRequest.toMap(),
  280. options: Options(
  281. headers: {
  282. "X-Auth-Token": _config.getToken(),
  283. },
  284. ),
  285. );
  286. await _config.setKeyAttributes(keyAttributes);
  287. } catch (e) {
  288. _logger.severe(e);
  289. rethrow;
  290. }
  291. }
  292. Future<void> setRecoveryKey(KeyAttributes keyAttributes) async {
  293. try {
  294. final setRecoveryKeyRequest = SetRecoveryKeyRequest(
  295. keyAttributes.masterKeyEncryptedWithRecoveryKey,
  296. keyAttributes.masterKeyDecryptionNonce,
  297. keyAttributes.recoveryKeyEncryptedWithMasterKey,
  298. keyAttributes.recoveryKeyDecryptionNonce,
  299. );
  300. await _dio.put(
  301. _config.getHttpEndpoint() + "/users/recovery-key",
  302. data: setRecoveryKeyRequest.toMap(),
  303. options: Options(
  304. headers: {
  305. "X-Auth-Token": _config.getToken(),
  306. },
  307. ),
  308. );
  309. await _config.setKeyAttributes(keyAttributes);
  310. } catch (e) {
  311. _logger.severe(e);
  312. rethrow;
  313. }
  314. }
  315. Future<void> verifyTwoFactor(
  316. BuildContext context, String sessionID, String code) async {
  317. final dialog = createProgressDialog(context, "authenticating...");
  318. await dialog.show();
  319. try {
  320. final response = await _dio.post(
  321. _config.getHttpEndpoint() + "/users/two-factor/verify",
  322. data: {
  323. "sessionID": sessionID,
  324. "code": code,
  325. },
  326. );
  327. await dialog.hide();
  328. if (response != null && response.statusCode == 200) {
  329. showToast("authentication successful!");
  330. await _saveConfiguration(response);
  331. Navigator.of(context).pushAndRemoveUntil(
  332. MaterialPageRoute(
  333. builder: (BuildContext context) {
  334. return PasswordReentryPage();
  335. },
  336. ),
  337. (route) => route.isFirst,
  338. );
  339. }
  340. } on DioError catch (e) {
  341. await dialog.hide();
  342. _logger.severe(e);
  343. if (e.response != null && e.response.statusCode == 404) {
  344. showToast("session expired");
  345. Navigator.of(context).pushAndRemoveUntil(
  346. MaterialPageRoute(
  347. builder: (BuildContext context) {
  348. return LoginPage();
  349. },
  350. ),
  351. (route) => route.isFirst,
  352. );
  353. } else {
  354. showErrorDialog(context, "incorrect code",
  355. "authentication failed, please try again");
  356. }
  357. } catch (e) {
  358. await dialog.hide();
  359. _logger.severe(e);
  360. showErrorDialog(context, AppLocalizations.of(context).oops,
  361. "authentication failed, please try again");
  362. }
  363. }
  364. Future<void> recoverTwoFactor(BuildContext context, String sessionID) async {
  365. final dialog = createProgressDialog(context, "please wait...");
  366. await dialog.show();
  367. try {
  368. final response = await _dio.get(
  369. _config.getHttpEndpoint() + "/users/two-factor/recover",
  370. queryParameters: {
  371. "sessionID": sessionID,
  372. },
  373. );
  374. if (response != null && response.statusCode == 200) {
  375. Navigator.of(context).pushAndRemoveUntil(
  376. MaterialPageRoute(
  377. builder: (BuildContext context) {
  378. return TwoFactorRecoveryPage(
  379. sessionID,
  380. response.data["encryptedSecret"],
  381. response.data["secretDecryptionNonce"]);
  382. },
  383. ),
  384. (route) => route.isFirst,
  385. );
  386. }
  387. } on DioError catch (e) {
  388. _logger.severe(e);
  389. if (e.response != null && e.response.statusCode == 404) {
  390. showToast("session expired");
  391. Navigator.of(context).pushAndRemoveUntil(
  392. MaterialPageRoute(
  393. builder: (BuildContext context) {
  394. return LoginPage();
  395. },
  396. ),
  397. (route) => route.isFirst,
  398. );
  399. } else {
  400. showErrorDialog(context, AppLocalizations.of(context).oops,
  401. "something went wrong, please try again");
  402. }
  403. } catch (e) {
  404. _logger.severe(e);
  405. showErrorDialog(context, AppLocalizations.of(context).oops,
  406. "something went wrong, please try again");
  407. } finally {
  408. await dialog.hide();
  409. }
  410. }
  411. Future<void> removeTwoFactor(
  412. BuildContext context,
  413. String sessionID,
  414. String recoveryKey,
  415. String encryptedSecret,
  416. String secretDecryptionNonce,
  417. ) async {
  418. final dialog = createProgressDialog(context, "please wait...");
  419. await dialog.show();
  420. String secret;
  421. try {
  422. secret = Sodium.bin2base64(await CryptoUtil.decrypt(
  423. Sodium.base642bin(encryptedSecret),
  424. Sodium.hex2bin(recoveryKey.trim()),
  425. Sodium.base642bin(secretDecryptionNonce)));
  426. } catch (e) {
  427. await dialog.hide();
  428. showErrorDialog(context, "incorrect recovery key",
  429. "the recovery key you entered is incorrect");
  430. return;
  431. }
  432. try {
  433. final response = await _dio.post(
  434. _config.getHttpEndpoint() + "/users/two-factor/remove",
  435. data: {
  436. "sessionID": sessionID,
  437. "secret": secret,
  438. },
  439. );
  440. if (response != null && response.statusCode == 200) {
  441. showToast("two-factor authentication successfully reset");
  442. await _saveConfiguration(response);
  443. Navigator.of(context).pushAndRemoveUntil(
  444. MaterialPageRoute(
  445. builder: (BuildContext context) {
  446. return PasswordReentryPage();
  447. },
  448. ),
  449. (route) => route.isFirst,
  450. );
  451. }
  452. } on DioError catch (e) {
  453. _logger.severe(e);
  454. if (e.response != null && e.response.statusCode == 404) {
  455. showToast("session expired");
  456. Navigator.of(context).pushAndRemoveUntil(
  457. MaterialPageRoute(
  458. builder: (BuildContext context) {
  459. return LoginPage();
  460. },
  461. ),
  462. (route) => route.isFirst,
  463. );
  464. } else {
  465. showErrorDialog(context, AppLocalizations.of(context).oops,
  466. "something went wrong, please try again");
  467. }
  468. } catch (e) {
  469. _logger.severe(e);
  470. showErrorDialog(context, AppLocalizations.of(context).oops,
  471. "something went wrong, please try again");
  472. } finally {
  473. await dialog.hide();
  474. }
  475. }
  476. Future<void> setupTwoFactor(BuildContext context) async {
  477. final dialog = createProgressDialog(context, "please wait...");
  478. await dialog.show();
  479. try {
  480. final response = await _dio.post(
  481. _config.getHttpEndpoint() + "/users/two-factor/setup",
  482. options: Options(
  483. headers: {
  484. "X-Auth-Token": _config.getToken(),
  485. },
  486. ),
  487. );
  488. await dialog.hide();
  489. routeToPage(
  490. context,
  491. TwoFactorSetupPage(
  492. response.data["secretCode"], response.data["qrCode"]));
  493. } catch (e, s) {
  494. await dialog.hide();
  495. _logger.severe(e, s);
  496. rethrow;
  497. }
  498. }
  499. Future<bool> enableTwoFactor(
  500. BuildContext context, String secret, String code) async {
  501. Uint8List recoveryKey;
  502. try {
  503. recoveryKey = await getOrCreateRecoveryKey(context);
  504. } catch (e) {
  505. showGenericErrorDialog(context);
  506. return false;
  507. }
  508. final dialog = createProgressDialog(context, "verifying...");
  509. await dialog.show();
  510. final encryptionResult =
  511. CryptoUtil.encryptSync(Sodium.base642bin(secret), recoveryKey);
  512. try {
  513. await _dio.post(
  514. _config.getHttpEndpoint() + "/users/two-factor/enable",
  515. data: {
  516. "code": code,
  517. "encryptedTwoFactorSecret":
  518. Sodium.bin2base64(encryptionResult.encryptedData),
  519. "twoFactorSecretDecryptionNonce":
  520. Sodium.bin2base64(encryptionResult.nonce),
  521. },
  522. options: Options(
  523. headers: {
  524. "X-Auth-Token": _config.getToken(),
  525. },
  526. ),
  527. );
  528. await dialog.hide();
  529. Navigator.pop(context);
  530. Bus.instance.fire(TwoFactorStatusChangeEvent(true));
  531. return true;
  532. } catch (e, s) {
  533. await dialog.hide();
  534. _logger.severe(e, s);
  535. if (e is DioError) {
  536. if (e.response != null && e.response.statusCode == 401) {
  537. showErrorDialog(context, "incorrect code",
  538. "please verify the code you have entered");
  539. return false;
  540. }
  541. }
  542. showErrorDialog(context, "something went wrong",
  543. "please contact support if the problem persists");
  544. }
  545. return false;
  546. }
  547. Future<void> disableTwoFactor(BuildContext context) async {
  548. final dialog =
  549. createProgressDialog(context, "disabling two-factor authentication...");
  550. await dialog.show();
  551. try {
  552. await _dio.post(
  553. _config.getHttpEndpoint() + "/users/two-factor/disable",
  554. options: Options(
  555. headers: {
  556. "X-Auth-Token": _config.getToken(),
  557. },
  558. ),
  559. );
  560. Bus.instance.fire(TwoFactorStatusChangeEvent(false));
  561. await dialog.hide();
  562. showToast("two-factor authentication has been disabled");
  563. } catch (e, s) {
  564. await dialog.hide();
  565. _logger.severe(e, s);
  566. showErrorDialog(context, "something went wrong",
  567. "please contact support if the problem persists");
  568. }
  569. }
  570. Future<bool> fetchTwoFactorStatus() async {
  571. try {
  572. final response = await _dio.get(
  573. _config.getHttpEndpoint() + "/users/two-factor/status",
  574. options: Options(
  575. headers: {
  576. "X-Auth-Token": _config.getToken(),
  577. },
  578. ),
  579. );
  580. return response.data["status"];
  581. } catch (e, s) {
  582. _logger.severe(e, s);
  583. rethrow;
  584. }
  585. }
  586. Future<Uint8List> getOrCreateRecoveryKey(BuildContext context) async {
  587. final encryptedRecoveryKey =
  588. _config.getKeyAttributes().recoveryKeyEncryptedWithMasterKey;
  589. if (encryptedRecoveryKey == null || encryptedRecoveryKey.isEmpty) {
  590. final dialog = createProgressDialog(context, "please wait...");
  591. await dialog.show();
  592. try {
  593. final keyAttributes = await _config.createNewRecoveryKey();
  594. await setRecoveryKey(keyAttributes);
  595. await dialog.hide();
  596. } catch (e, s) {
  597. await dialog.hide();
  598. _logger.severe(e, s);
  599. rethrow;
  600. }
  601. }
  602. final recoveryKey = _config.getRecoveryKey();
  603. return recoveryKey;
  604. }
  605. Future<String> getPaymentToken() async {
  606. try {
  607. var response = await _dio.get(
  608. _config.getHttpEndpoint() + "/users/payment-token",
  609. options: Options(
  610. headers: {
  611. "X-Auth-Token": _config.getToken(),
  612. },
  613. ),
  614. );
  615. if (response != null && response.statusCode == 200) {
  616. return response.data["paymentToken"];
  617. } else {
  618. throw Exception("non 200 ok response");
  619. }
  620. } catch (e, s) {
  621. _logger.severe(e, s);
  622. return null;
  623. }
  624. }
  625. Future<void> _saveConfiguration(Response response) async {
  626. await Configuration.instance.setUserID(response.data["id"]);
  627. if (response.data["encryptedToken"] != null) {
  628. await Configuration.instance
  629. .setEncryptedToken(response.data["encryptedToken"]);
  630. await Configuration.instance.setKeyAttributes(
  631. KeyAttributes.fromMap(response.data["keyAttributes"]));
  632. } else {
  633. await Configuration.instance.setToken(response.data["token"]);
  634. }
  635. }
  636. }