Skip to content

Commit

Permalink
login: Support web-based auth methods
Browse files Browse the repository at this point in the history
Fixes: #36
  • Loading branch information
chrisbobbe authored and gnprice committed Apr 2, 2024
1 parent 60c5245 commit bd2fc3d
Show file tree
Hide file tree
Showing 8 changed files with 473 additions and 5 deletions.
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
<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
19 changes: 19 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@
"@actionSheetOptionUnstarMessage": {
"description": "Label for unstar button on action sheet."
},
"errorWebAuthOperationalErrorTitle": "Something went wrong",
"@errorWebAuthOperationalErrorTitle": {
"description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)."
},
"errorWebAuthOperationalError": "An unexpected error occurred.",
"@errorWebAuthOperationalError": {
"description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)."
},
"errorAccountLoggedInTitle": "Account already logged in",
"@errorAccountLoggedInTitle": {
"description": "Error title on attempting to log into an account that's already logged in."
Expand Down Expand Up @@ -281,6 +289,17 @@
"@loginFormSubmitLabel": {
"description": "Button text to submit login credentials."
},
"loginMethodDivider": "OR",
"@loginMethodDivider": {
"description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)."
},
"signInWithFoo": "Sign in with {method}",
"@signInWithFoo": {
"description": "Button to use {method} to sign in to the app.",
"placeholders": {
"method": {"type": "String", "example": "Google"}
}
},
"loginAddAnAccountPageTitle": "Add an account",
"@loginAddAnAccountPageTitle": {
"description": "Page title for screen to add a Zulip account."
Expand Down
13 changes: 13 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.zulip.flutter</string>
<key>CFBundleURLSchemes</key>
<array>
<string>zulip</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
Expand Down
87 changes: 87 additions & 0 deletions lib/api/model/web_auth.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import 'dart:math';

import 'package:convert/convert.dart';
import 'package:flutter/foundation.dart';

/// The authentication information contained in the zulip:// redirect URL.
class WebAuthPayload {
final Uri realm;
final String email;
final int? userId; // TODO(server-5) new in FL 108
final String otpEncryptedApiKey;

WebAuthPayload._({
required this.realm,
required this.email,
required this.userId,
required this.otpEncryptedApiKey,
});

factory WebAuthPayload.parse(Uri url) {
if (
url case Uri(
scheme: 'zulip',
host: 'login',
queryParameters: {
'realm': String realmStr,
'email': String email,
// 'user_id' handled below
'otp_encrypted_api_key': String otpEncryptedApiKey,
},
)
) {
final Uri? realm = Uri.tryParse(realmStr);
if (realm == null) throw const FormatException();

// TODO(server-5) require in queryParameters (new in FL 108)
final userIdStr = url.queryParameters['user_id'];
int? userId;
if (userIdStr != null) {
userId = int.tryParse(userIdStr, radix: 10);
if (userId == null) throw const FormatException();
}

if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(otpEncryptedApiKey)) {
throw const FormatException();
}

return WebAuthPayload._(
otpEncryptedApiKey: otpEncryptedApiKey,
email: email,
userId: userId,
realm: realm,
);
} else {
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
throw const FormatException();
}
}

String decodeApiKey(String otp) {
final otpBytes = hex.decode(otp);
final otpEncryptedApiKeyBytes = hex.decode(otpEncryptedApiKey);
if (otpBytes.length != otpEncryptedApiKeyBytes.length) {
throw const FormatException();
}
return String.fromCharCodes(Iterable.generate(otpBytes.length,
(i) => otpBytes[i] ^ otpEncryptedApiKeyBytes[i]));
}
}

String generateOtp() {
final rand = Random.secure();
final Uint8List bytes = Uint8List.fromList(
List.generate(32, (_) => rand.nextInt(256)));
return hex.encode(bytes);
}

/// For tests, create an OTP-encrypted API key.
@visibleForTesting
String debugEncodeApiKey(String apiKey, String otp) {
final apiKeyBytes = apiKey.codeUnits;
assert(apiKeyBytes.every((byte) => byte <= 0xff));
final otpBytes = hex.decode(otp);
assert(apiKeyBytes.length == otpBytes.length);
return hex.encode(List.generate(otpBytes.length,
(i) => apiKeyBytes[i] ^ otpBytes[i]));
}
25 changes: 24 additions & 1 deletion lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,30 @@ class ZulipApp extends StatefulWidget {
State<ZulipApp> createState() => _ZulipAppState();
}

class _ZulipAppState extends State<ZulipApp> {
class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
@override
Future<bool> didPushRouteInformation(routeInformation) async {
if (routeInformation case RouteInformation(
uri: Uri(scheme: 'zulip', host: 'login') && var url)
) {
await LoginPage.handleWebAuthUrl(url);
return true;
}
return super.didPushRouteInformation(routeInformation);
}

@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

@override
Widget build(BuildContext context) {
final theme = ThemeData(
Expand Down
150 changes: 147 additions & 3 deletions lib/widgets/login.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
import 'package:url_launcher/url_launcher.dart';

import '../api/exception.dart';
import '../api/model/web_auth.dart';
import '../api/route/account.dart';
import '../api/route/realm.dart';
import '../api/route/users.dart';
import '../log.dart';
import '../model/binding.dart';
import '../model/store.dart';
import 'app.dart';
import 'dialog.dart';
import 'input.dart';
import 'page.dart';
import 'store.dart';
import 'text.dart';

class _LoginSequenceRoute extends MaterialWidgetRoute<void> {
_LoginSequenceRoute({
Expand Down Expand Up @@ -176,7 +183,6 @@ class _AddAccountPageState extends State<AddAccountPage> {
return;
}

// TODO(#36): support login methods beyond username/password
Navigator.push(context,
LoginPage.buildRoute(serverSettings: serverSettings));
} finally {
Expand Down Expand Up @@ -240,18 +246,108 @@ class LoginPage extends StatefulWidget {

static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
return _LoginSequenceRoute(
page: LoginPage(serverSettings: serverSettings));
page: LoginPage(serverSettings: serverSettings, key: _lastBuiltKey));
}

final GetServerSettingsResult serverSettings;

/// Log in using the payload of a web-auth URL like zulip://login?…
static Future<void> handleWebAuthUrl(Uri url) async {
return _lastBuiltKey.currentState?.handleWebAuthUrl(url);
}

/// A key for the page from the last [buildRoute] call.
static final _lastBuiltKey = GlobalKey<_LoginPageState>();

/// The OTP to use, instead of an app-generated one, for testing.
@visibleForTesting
static String? debugOtpOverride;

@override
State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
bool _inProgress = false;

String? get _otp {
String? result;
assert(() {
result = LoginPage.debugOtpOverride;
return true;
}());
return result ?? __otp;
}
String? __otp;

Future<void> handleWebAuthUrl(Uri url) async {
setState(() {
_inProgress = true;
});
try {
await ZulipBinding.instance.closeInAppWebView();

if (_otp == null) throw Error();
final payload = WebAuthPayload.parse(url);
if (payload.realm.origin != widget.serverSettings.realmUrl.origin) throw Error();
final apiKey = payload.decodeApiKey(_otp!);
await _tryInsertAccountAndNavigate(
// TODO(server-5): Rely on userId from payload.
userId: payload.userId ?? await _getUserId(payload.email, apiKey),
email: payload.email,
apiKey: apiKey,
);
} catch (e) {
assert(debugLog(e.toString()));
if (!mounted) return;
final zulipLocalizations = ZulipLocalizations.of(context);
// Could show different error messages for different failure modes.
await showErrorDialog(context: context,
title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
message: zulipLocalizations.errorWebAuthOperationalError);
} finally {
setState(() {
_inProgress = false;
__otp = null;
});
}
}

Future<void> _beginWebAuth(ExternalAuthenticationMethod method) async {
__otp = generateOtp();
try {
final url = widget.serverSettings.realmUrl.resolve(method.loginUrl)
.replace(queryParameters: {'mobile_flow_otp': _otp!});

// Could set [_inProgress]… but we'd need to unset it if the web-auth
// attempt is aborted (by the user closing the browser, for example),
// and I don't think we can reliably know when that happens.
await ZulipBinding.instance.launchUrl(url, mode: LaunchMode.inAppBrowserView);
} catch (e) {
assert(debugLog(e.toString()));

if (e is PlatformException
&& defaultTargetPlatform == TargetPlatform.iOS
&& e.message != null && e.message!.startsWith('Error while launching')) {
// Ignore; I've seen this on my iPhone even when auth succeeds.
// Specifically, Apple web auth…which on iOS should be replaced by
// Apple native auth; that's #462.
// Possibly related:
// https://github.com/flutter/flutter/issues/91660
// but in that issue, people report authentication not succeeding.
// TODO(#462) remove this?
return;
}

if (!mounted) return;
final zulipLocalizations = ZulipLocalizations.of(context);
// Could show different error messages for different failure modes.
await showErrorDialog(context: context,
title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
message: zulipLocalizations.errorWebAuthOperationalError);
}
}

Future<void> _tryInsertAccountAndNavigate({
required String email,
required String apiKey,
Expand Down Expand Up @@ -312,6 +408,26 @@ class _LoginPageState extends State<LoginPage> {
assert(!PerAccountStoreWidget.debugExistsOf(context));
final zulipLocalizations = ZulipLocalizations.of(context);

final externalAuthenticationMethods = widget.serverSettings.externalAuthenticationMethods;

final loginForm = Column(mainAxisAlignment: MainAxisAlignment.center, children: [
_UsernamePasswordForm(loginPageState: this),
if (externalAuthenticationMethods.isNotEmpty) ...[
const OrDivider(),
...externalAuthenticationMethods.map((method) {
final icon = method.displayIcon;
return OutlinedButton.icon(
icon: icon != null
? Image.network(icon, width: 24, height: 24)
: null,
onPressed: !_inProgress
? () => _beginWebAuth(method)
: null,
label: Text(zulipLocalizations.signInWithFoo(method.displayName)));
}),
],
]);

return Scaffold(
appBar: AppBar(title: Text(zulipLocalizations.loginPageTitle),
bottom: _inProgress
Expand All @@ -330,7 +446,7 @@ class _LoginPageState extends State<LoginPage> {
// left or the right of this box
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: _UsernamePasswordForm(loginPageState: this)))))));
child: loginForm))))));
}
}

Expand Down Expand Up @@ -495,3 +611,31 @@ class _UsernamePasswordFormState extends State<_UsernamePasswordForm> {
])));
}
}

// Loosely based on the corresponding element in the web app.
class OrDivider extends StatelessWidget {
const OrDivider({super.key});

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);

const divider = Expanded(
child: Divider(color: Color(0xffdedede), thickness: 2));

return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
divider,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: Text(zulipLocalizations.loginMethodDivider,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xff575757),
height: 1.5,
).merge(weightVariableTextStyle(context, wght: 600)))),
divider,
]));
}
}

0 comments on commit bd2fc3d

Please sign in to comment.