From 42ffaa1ba95ccdbed63ba7fdbf4e4f648bf1ae09 Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Fri, 1 Sep 2023 13:32:49 +0530 Subject: [PATCH] login: Implement browser login Sets up the Flutter deeplink support for android in order to capture the browser redirect. Adds the BrowserLoginWidget which is used to co-ordinate the auth flow, storing the otp temporarily, and finally handling the browser redirect to complete the auth flow. Fixes #36 --- android/app/src/main/AndroidManifest.xml | 8 + lib/widgets/app.dart | 28 +++- lib/widgets/login.dart | 17 ++- lib/widgets/login/browser_login.dart | 181 +++++++++++++++++++++++ pubspec.lock | 10 +- pubspec.yaml | 3 +- 6 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/login/browser_login.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 91d594f30d..3b32727aeb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,14 @@ + + + + + + + + diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 3d054ad0c6..83b381af11 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../model/narrow.dart'; import 'about_zulip.dart'; import 'login.dart'; +import 'login/browser_login.dart'; import 'message_list.dart'; import 'page.dart'; import 'recent_dm_conversations.dart'; @@ -25,10 +26,29 @@ class ZulipApp extends StatelessWidget { // https://m3.material.io/theme-builder#/custom colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor)); return GlobalStoreWidget( - child: MaterialApp( - title: 'Zulip', - theme: theme, - home: const ChooseAccountPage())); + child: BrowserLoginWidget( + child: Builder( + builder: (context) => MaterialApp( + title: 'Zulip', + theme: theme, + home: const ChooseAccountPage(), + navigatorKey: BrowserLoginWidget.of(context).navigatorKey, + // TODO: Migrate to `MaterialApp.router` & `Router`, so that we can receive + // a full Uri instead of just path+query components and also maybe + // remove the InheritedWidget + navigatorKey hack. + // See docs: + // https://api.flutter.dev/flutter/widgets/Router-class.html + onGenerateRoute: (settings) { + if (settings.name == null) return null; + final uri = Uri.parse(settings.name!); + if (uri.queryParameters.containsKey('otp_encrypted_api_key')) { + BrowserLoginWidget.of(context).loginFromExternalRoute(context, uri); + return null; + } + return null; + })), + ), + ); } } diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index a35c7d5d4b..a765a6b327 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -9,11 +9,12 @@ import '../model/store.dart'; import 'app.dart'; import 'dialog.dart'; import 'input.dart'; +import 'login/browser_login.dart'; import 'page.dart'; import 'store.dart'; -class _LoginSequenceRoute extends MaterialWidgetRoute { - _LoginSequenceRoute({ +class LoginSequenceRoute extends MaterialWidgetRoute { + LoginSequenceRoute({ required super.page, }); } @@ -102,7 +103,7 @@ class AddAccountPage extends StatefulWidget { const AddAccountPage({super.key}); static Route buildRoute() { - return _LoginSequenceRoute(page: const AddAccountPage()); + return LoginSequenceRoute(page: const AddAccountPage()); } @override @@ -230,7 +231,7 @@ class AuthMethodsPage extends StatefulWidget { final GetServerSettingsResult serverSettings; static Route buildRoute({required GetServerSettingsResult serverSettings}) { - return _LoginSequenceRoute( + return LoginSequenceRoute( page: AuthMethodsPage(serverSettings: serverSettings)); } @@ -243,10 +244,12 @@ class _AuthMethodsPageState extends State { // or update to add a new method. static const Set _testedAuthMethods = { 'github', + 'gitlab', 'google', }; - Future _openBrowserLogin(ExternalAuthenticationMethod method) async {} + Future _openBrowserLogin(ExternalAuthenticationMethod method) => + BrowserLoginWidget.of(context).openLoginUrl(widget.serverSettings, method.loginUrl); @override Widget build(BuildContext context) { @@ -304,7 +307,7 @@ class PasswordLoginPage extends StatefulWidget { final GetServerSettingsResult serverSettings; static Route buildRoute({required GetServerSettingsResult serverSettings}) { - return _LoginSequenceRoute( + return LoginSequenceRoute( page: PasswordLoginPage(serverSettings: serverSettings)); } @@ -396,7 +399,7 @@ class _PasswordLoginPageState extends State { Navigator.of(context).pushAndRemoveUntil( HomePage.buildRoute(accountId: accountId), - (route) => (route is! _LoginSequenceRoute), + (route) => (route is! LoginSequenceRoute), ); } finally { setState(() { diff --git a/lib/widgets/login/browser_login.dart b/lib/widgets/login/browser_login.dart new file mode 100644 index 0000000000..75eebce4eb --- /dev/null +++ b/lib/widgets/login/browser_login.dart @@ -0,0 +1,181 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter/widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../api/route/realm.dart'; +import '../../log.dart'; +import '../../model/store.dart'; +import '../app.dart'; +import '../login.dart'; +import '../store.dart'; + +/// An InheritedWidget to co-ordinate the browser auth flow +/// +/// The provided [navigatorKey] by this object should be attached to +/// the main app widget so that when the browser redirects to the app +/// using the universal link this widget can use it to access the current +/// navigator instance. +/// +/// This object also stores the temporarily generated OTP required for +/// the completion of the flow. +class BrowserLoginWidget extends InheritedWidget { + BrowserLoginWidget({super.key, required super.child}); + + final GlobalKey navigatorKey = GlobalKey(); + + // TODO: Maybe store these on local DB too, because OS can close the + // app while user is using the browser during the auth flow. + + // Temporary mobile_flow_otp, that was generated while initiating a browser auth flow. + final Map _tempAuthOtp = {}; + // Temporary server settngs, that was stored while initiating a browser auth flow. + final Map _tempServerSettings = {}; + + @override + bool updateShouldNotify(covariant BrowserLoginWidget oldWidget) => + !identical(oldWidget.navigatorKey, navigatorKey) + && !identical(oldWidget._tempAuthOtp, _tempAuthOtp) + && !identical(oldWidget._tempServerSettings, _tempServerSettings); + + static BrowserLoginWidget of(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + assert(widget != null, 'No BrowserLogin ancestor'); + return widget!; + } + + Future openLoginUrl(GetServerSettingsResult serverSettings, String loginUrl) async { + // Generate a temporary otp and store it for later use - for decoding the + // api key returned by server which will be XOR-ed with this otp. + final otp = _generateMobileFlowOtp(); + _tempAuthOtp[serverSettings.realmUri] = otp; + _tempServerSettings[serverSettings.realmUri] = serverSettings; + + // Open the browser + await launchUrl(serverSettings.realmUri.replace( + path: loginUrl, + queryParameters: {'mobile_flow_otp': otp}, + )); + } + + Future loginFromExternalRoute(BuildContext context, Uri uri) async { + final globalStore = GlobalStoreWidget.of(context); + + // Parse the query params from the browser redirect url + final String otpEncryptedApiKey; + final String email; + final int userId; + final Uri realm; + try { + if (uri.queryParameters case { + 'otp_encrypted_api_key': final String otpEncryptedApiKeyStr, + 'email': final String emailStr, + 'user_id': final String userIdStr, + 'realm': final String realmStr, + }) { + if (otpEncryptedApiKeyStr.isEmpty || emailStr.isEmpty || userIdStr.isEmpty || realmStr.isEmpty) { + throw 'Got invalid query params from browser redirect url'; + } + otpEncryptedApiKey = otpEncryptedApiKeyStr; + realm = Uri.parse(realmStr); + userId = int.parse(userIdStr); + email = emailStr; + } else { + throw 'Got invalid query params from browser redirect url'; + } + } catch (e, st) { + // TODO: Log error to Sentry + debugLog('$e\n$st'); + return; + } + + // Get the previously temporarily stored otp & serverSettings. + final GetServerSettingsResult serverSettings; + final String apiKey; + try { + final otp = _tempAuthOtp[realm]; + _tempAuthOtp.clear(); + final settings = _tempServerSettings[realm]; + _tempServerSettings.clear(); + if (otp == null) { + throw 'Failed to find the previously generated mobile_auth_otp'; + } + if (settings == null) { + // TODO: Maybe try refetching instead of error-ing out. + throw 'Failed to find the previously stored serverSettings'; + } + + // Decode the otp XOR-ed api key + apiKey = _decodeApiKey(otp, otpEncryptedApiKey); + serverSettings = settings; + } catch (e, st) { + // TODO: Log error to Sentry + debugLog('$e\n$st'); + return; + } + + // TODO(#108): give feedback to user on SQL exception, like dupe realm+user + final accountId = await globalStore.insertAccount(AccountsCompanion.insert( + realmUrl: serverSettings.realmUri, + email: email, + apiKey: apiKey, + userId: userId, + zulipFeatureLevel: serverSettings.zulipFeatureLevel, + zulipVersion: serverSettings.zulipVersion, + zulipMergeBase: Value(serverSettings.zulipMergeBase), + )); + + if (!context.mounted) { + return; + } + navigatorKey.currentState?.pushAndRemoveUntil( + HomePage.buildRoute(accountId: accountId), + (route) => (route is! LoginSequenceRoute), + ); + } +} + +/// Generates a `mobile_flow_otp` to be used by the server for +/// mobile login flow, server XOR's the api key with the otp hex +/// and returns the resulting value. So, the same otp that was passed +/// to the server can be used again to decode the actual api key. +String _generateMobileFlowOtp() { + final rand = Random.secure(); + return hex.encode(rand.nextBytes(32)); +} + +String _decodeApiKey(String otp, String otpEncryptedApiKey) { + final otpHex = hex.decode(otp); + final otpEncryptedApiKeyHex = hex.decode(otpEncryptedApiKey); + return String.fromCharCodes(otpHex ^ otpEncryptedApiKeyHex); +} + +// TODO: Remove this when upstream issue is fixed +// https://github.com/dart-lang/sdk/issues/53339 +extension _RandomNextBytes on Random { + static const int _pow2_32 = 0x100000000; + Uint8List nextBytes(int length) { + if ((length % 4) != 0) { + throw ArgumentError('\'length\' must be a multiple of 4'); + } + final result = Uint32List(length); + for (int i = 0; i < length; i++) { + result[i] = nextInt(_pow2_32); + } + return result.buffer.asUint8List(0, length); + } +} + +extension _IntListOpXOR on List { + Iterable operator ^(List other) sync* { + if (length != other.length) { + throw ArgumentError('Both lists must have the same length'); + } + for (var i = 0; i < length; i++) { + yield this[i] ^ other[i]; + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 320c58b783..82e78b4985 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -186,7 +186,7 @@ packages: source: hosted version: "1.18.0" convert: - dependency: transitive + dependency: "direct main" description: name: convert sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" @@ -921,18 +921,18 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e" + sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" url: "https://pub.dev" source: hosted - version: "6.1.12" + version: "6.1.14" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "3dd2388cc0c42912eee04434531a26a82512b9cb1827e0214430c9bcbddfe025" + sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 url: "https://pub.dev" source: hosted - version: "6.0.38" + version: "6.1.0" url_launcher_ios: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 57b235bee9..b69a30edb4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,7 +56,8 @@ dependencies: image_picker: ^1.0.0 package_info_plus: ^4.0.1 collection: ^1.17.2 - url_launcher: ^6.1.11 + url_launcher: ^6.1.14 + convert: ^3.1.1 dev_dependencies: flutter_test: