user_service.dart 21 KB

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