Skip to content

Commit

Permalink
draft
Browse files Browse the repository at this point in the history
  • Loading branch information
rajveermalviya committed Aug 21, 2023
1 parent 1b44a6f commit 135559a
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 12 deletions.
8 changes: 8 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>

<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zulip" android:host="login" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
Expand Down
27 changes: 25 additions & 2 deletions lib/api/route/realm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,32 @@ Future<GetServerSettingsResult> getServerSettings({
}
}

@JsonSerializable(fieldRename: FieldRename.snake)
class ExternalAuthenticationMethod {
final String name;
final String displayName;
final String? displayIcon;
final String loginUrl;
final String signupUrl;

ExternalAuthenticationMethod({
required this.name,
required this.displayName,
this.displayIcon,
required this.loginUrl,
required this.signupUrl,
});

factory ExternalAuthenticationMethod.fromJson(Map<String, dynamic> json) =>
_$ExternalAuthenticationMethodFromJson(json);

Map<String, dynamic> toJson() => _$ExternalAuthenticationMethodToJson(this);
}

@JsonSerializable(fieldRename: FieldRename.snake)
class GetServerSettingsResult {
final Map<String, bool> authenticationMethods;
// final List<ExternalAuthenticationMethod> external_authentication_methods; // TODO handle
final List<ExternalAuthenticationMethod> externalAuthenticationMethods;

final int zulipFeatureLevel;
final String zulipVersion;
Expand All @@ -44,12 +66,13 @@ class GetServerSettingsResult {
final bool requireEmailFormatUsernames;
final Uri realmUri;
final String realmName;
final String realmIcon;
final Uri? realmIcon;
final String realmDescription;
final bool? realmWebPublicAccessEnabled; // TODO(server-5)

GetServerSettingsResult({
required this.authenticationMethods,
required this.externalAuthenticationMethods,
required this.zulipFeatureLevel,
required this.zulipVersion,
this.zulipMergeBase,
Expand Down
32 changes: 30 additions & 2 deletions lib/api/route/realm.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,18 @@ export 'database.dart' show Account, AccountsCompanion;
/// we use outside of tests.
abstract class GlobalStore extends ChangeNotifier {
GlobalStore({required Iterable<Account> accounts})
: _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a)));
: _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a))),
_authOtp = null;

/// A cache of the [Accounts] table in the underlying data store.
final Map<int, Account> _accounts;

// TODO settings (those that are per-device rather than per-account)
// TODO push token, and other data corresponding to GlobalSessionState

/// Temporary `mobile_flow_otp`, that was generated while initiating an auth flow.
String? _authOtp;

final Map<int, PerAccountStore> _perAccountStores = {};
final Map<int, Future<PerAccountStore>> _perAccountStoresLoading = {};

Expand Down Expand Up @@ -128,6 +132,9 @@ abstract class GlobalStore extends ChangeNotifier {
/// Add an account to the underlying data store.
Future<Account> doInsertAccount(AccountsCompanion data);

String? getAuthOtp() => _authOtp;
void setAuthOtp(String? otp) => _authOtp = otp;

// More mutators as needed:
// Future<void> updateAccount...
}
Expand Down
27 changes: 23 additions & 4 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import 'page.dart';
import 'recent_dm_conversations.dart';
import 'store.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

class ZulipApp extends StatelessWidget {
const ZulipApp({super.key});

Expand All @@ -25,10 +27,27 @@ 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: Builder(
builder: (context) {
return MaterialApp(
title: 'Zulip',
theme: theme,
home: const ChooseAccountPage(),
navigatorKey: navigatorKey,
// TODO: migrate to `MaterialApp.router`, so that we can receive
// a complete Uri instead of just path+query components.
// See docs:
// https://api.flutter.dev/flutter/widgets/Router-class.html
onGenerateRoute: (settings) {
if (settings.name == null) return null;
if (settings.name!.startsWith('/?otp_encrypted_api_key=')) {
loginFromIncomingRoute(context, Uri.parse(settings.name!));
return null;
}
return null;
});
}
));
}
}

Expand Down
178 changes: 176 additions & 2 deletions lib/widgets/login.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import 'dart:math';

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:convert/convert.dart';

import '../api/core.dart';
import '../api/exception.dart';
Expand Down Expand Up @@ -167,9 +172,8 @@ class _AddAccountPageState extends State<AddAccountPage> {
return;
}

// TODO(#36): support login methods beyond username/password
Navigator.push(context,
PasswordLoginPage.buildRoute(serverSettings: serverSettings));
AuthMethodsPage.buildRoute(serverSettings: serverSettings));
} finally {
setState(() {
_inProgress = false;
Expand Down Expand Up @@ -225,6 +229,176 @@ class _AddAccountPageState extends State<AddAccountPage> {
}
}

class AuthMethodsPage extends StatefulWidget {
const AuthMethodsPage({super.key, required this.serverSettings});

final GetServerSettingsResult serverSettings;

static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
return _LoginSequenceRoute(
page: AuthMethodsPage(serverSettings: serverSettings));
}

@override
State<AuthMethodsPage> createState() => _AuthMethodsPageState();
}

class _AuthMethodsPageState extends State<AuthMethodsPage> {
// TODO: Remove this list when all the methods are tested,
// or update to add a new one.
static const Set<String> testedAuthMethods = {
'github',
};

Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) async {
final otp = _generateMobileFlowOtp();
GlobalStoreWidget.of(context).setAuthOtp(otp);
await launchUrl(
widget.serverSettings.realmUri.replace(
path: method.loginUrl,
queryParameters: {'mobile_flow_otp': otp},
),
mode: LaunchMode.externalApplication,
);
}

@override
Widget build(BuildContext context) {
Uri? iconUrl = switch (widget.serverSettings.realmIcon) {
final Uri realmIcon => realmIcon.hasAuthority
? realmIcon
: widget.serverSettings.realmUri.replace(pathSegments: realmIcon.pathSegments, queryParameters: realmIcon.queryParameters),
null => null,
};

return Scaffold(
appBar: AppBar(title: const Text('Log in')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(8),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (iconUrl != null) ...[
Image.network(
iconUrl.toString(),
width: 48,
height: 48),
const SizedBox(width: 8),
],
Text(widget.serverSettings.realmName, style: const TextStyle(fontSize: 20)),
]),
),
if (widget.serverSettings.emailAuthEnabled)
OutlinedButton(
onPressed: () => Navigator.push(context, AuthMethodsPage.buildRoute(serverSettings: widget.serverSettings)),
child: const Text('Sign in with password')),
...widget.serverSettings.externalAuthenticationMethods.map(
(authMethod) => switch (authMethod.displayIcon) {
null || '' => OutlinedButton(
onPressed: testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
child: Text('Sign in with ${authMethod.displayName}'),
),
final displayIcon => OutlinedButton.icon(
onPressed: testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
icon: Image.network(displayIcon, width: 24, height: 24),
label: Text('Sign in with ${authMethod.displayName}'),
),
}).toList(),
])));
}
}

/// 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(List.generate(32, (_) => rand.nextInt(256), growable: false));
}

extension IntListOpXOR on List<int> {
Iterable<int> operator ^(List<int> other) {
if (length != other.length) {
throw ArgumentError('Both lists must have same length');
}
return mapIndexed((i, x) => x ^ other[i]);
}
}

String _decodeApiKey(String otp, String otpEncryptedApiKey) {
final otpHex = hex.decode(otp);
final otpEncryptedApiKeyHex = hex.decode(otpEncryptedApiKey);
return String.fromCharCodes(otpHex ^ otpEncryptedApiKeyHex);
}

Future<void> loginFromIncomingRoute(BuildContext context, Uri uri) async {
final globalStore = GlobalStoreWidget.of(context);
final otp = globalStore.getAuthOtp();
if (otp == null) return;
globalStore.setAuthOtp(null);

final String apiKey;
final String emailId;
final int userId;
final Uri realmUrl;
if (uri.queryParameters case {
'otp_encrypted_api_key' : final String otpEncryptedApiKey,
'email' : final String email,
'user_id' : final String userIdStr,
'realm' : final String realm,
}) {
if (otpEncryptedApiKey.isEmpty || email.isEmpty || userIdStr.isEmpty || realm.isEmpty) {
// TODO: Log error to Sentry
return;
}
realmUrl = Uri.parse(realm);
userId = int.parse(userIdStr);
emailId = email;
apiKey = _decodeApiKey(otp, otpEncryptedApiKey);
} else {
// TODO: Log error to Sentry
return;
}

final GetServerSettingsResult serverSettings;
try {
serverSettings = await getServerSettings(realmUrl: realmUrl, zulipFeatureLevel: null);
} catch (e) {
if (!context.mounted) {
return;
}
// TODO(#105) give more helpful feedback; see `fetchServerSettings`
// in zulip-mobile's src/message/fetchActions.js.
showErrorDialog(context: context,
title: 'Could not connect', message: 'Failed to connect to server:\n$realmUrl');
return;
}

// TODO(#108): give feedback to user on SQL exception, like dupe realm+user
final accountId = await globalStore.insertAccount(AccountsCompanion.insert(
realmUrl: realmUrl,
email: emailId,
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),
);
}

class PasswordLoginPage extends StatefulWidget {
const PasswordLoginPage({super.key, required this.serverSettings});

Expand Down
Loading

0 comments on commit 135559a

Please sign in to comment.