|
| 1 | +import 'dart:math'; |
| 2 | + |
| 3 | +import 'package:collection/collection.dart'; |
1 | 4 | import 'package:flutter/material.dart';
|
| 5 | +import 'package:url_launcher/url_launcher.dart'; |
| 6 | +import 'package:convert/convert.dart'; |
2 | 7 |
|
3 | 8 | import '../api/core.dart';
|
4 | 9 | import '../api/exception.dart';
|
@@ -167,9 +172,8 @@ class _AddAccountPageState extends State<AddAccountPage> {
|
167 | 172 | return;
|
168 | 173 | }
|
169 | 174 |
|
170 |
| - // TODO(#36): support login methods beyond username/password |
171 | 175 | Navigator.push(context,
|
172 |
| - PasswordLoginPage.buildRoute(serverSettings: serverSettings)); |
| 176 | + AuthMethodsPage.buildRoute(serverSettings: serverSettings)); |
173 | 177 | } finally {
|
174 | 178 | setState(() {
|
175 | 179 | _inProgress = false;
|
@@ -225,6 +229,176 @@ class _AddAccountPageState extends State<AddAccountPage> {
|
225 | 229 | }
|
226 | 230 | }
|
227 | 231 |
|
| 232 | +class AuthMethodsPage extends StatefulWidget { |
| 233 | + const AuthMethodsPage({super.key, required this.serverSettings}); |
| 234 | + |
| 235 | + final GetServerSettingsResult serverSettings; |
| 236 | + |
| 237 | + static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) { |
| 238 | + return _LoginSequenceRoute( |
| 239 | + page: AuthMethodsPage(serverSettings: serverSettings)); |
| 240 | + } |
| 241 | + |
| 242 | + @override |
| 243 | + State<AuthMethodsPage> createState() => _AuthMethodsPageState(); |
| 244 | +} |
| 245 | + |
| 246 | +class _AuthMethodsPageState extends State<AuthMethodsPage> { |
| 247 | + // TODO: Remove this list when all the methods are tested, |
| 248 | + // or update to add a new one. |
| 249 | + static const Set<String> testedAuthMethods = { |
| 250 | + 'github', |
| 251 | + }; |
| 252 | + |
| 253 | + Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) async { |
| 254 | + final otp = _generateMobileFlowOtp(); |
| 255 | + GlobalStoreWidget.of(context).setAuthOtp(otp); |
| 256 | + await launchUrl( |
| 257 | + widget.serverSettings.realmUri.replace( |
| 258 | + path: method.loginUrl, |
| 259 | + queryParameters: {'mobile_flow_otp': otp}, |
| 260 | + ), |
| 261 | + mode: LaunchMode.externalApplication, |
| 262 | + ); |
| 263 | + } |
| 264 | + |
| 265 | + @override |
| 266 | + Widget build(BuildContext context) { |
| 267 | + Uri? iconUrl = switch (widget.serverSettings.realmIcon) { |
| 268 | + final Uri realmIcon => realmIcon.hasAuthority |
| 269 | + ? realmIcon |
| 270 | + : widget.serverSettings.realmUri.replace(pathSegments: realmIcon.pathSegments, queryParameters: realmIcon.queryParameters), |
| 271 | + null => null, |
| 272 | + }; |
| 273 | + |
| 274 | + return Scaffold( |
| 275 | + appBar: AppBar(title: const Text('Log in')), |
| 276 | + body: SafeArea( |
| 277 | + child: ListView( |
| 278 | + padding: const EdgeInsets.all(8), |
| 279 | + children: [ |
| 280 | + Padding( |
| 281 | + padding: const EdgeInsets.only(bottom: 8), |
| 282 | + child: Row( |
| 283 | + mainAxisAlignment: MainAxisAlignment.center, |
| 284 | + children: [ |
| 285 | + if (iconUrl != null) ...[ |
| 286 | + Image.network( |
| 287 | + iconUrl.toString(), |
| 288 | + width: 48, |
| 289 | + height: 48), |
| 290 | + const SizedBox(width: 8), |
| 291 | + ], |
| 292 | + Text(widget.serverSettings.realmName, style: const TextStyle(fontSize: 20)), |
| 293 | + ]), |
| 294 | + ), |
| 295 | + if (widget.serverSettings.emailAuthEnabled) |
| 296 | + OutlinedButton( |
| 297 | + onPressed: () => Navigator.push(context, AuthMethodsPage.buildRoute(serverSettings: widget.serverSettings)), |
| 298 | + child: const Text('Sign in with password')), |
| 299 | + ...widget.serverSettings.externalAuthenticationMethods.map( |
| 300 | + (authMethod) => switch (authMethod.displayIcon) { |
| 301 | + null || '' => OutlinedButton( |
| 302 | + onPressed: testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null, |
| 303 | + child: Text('Sign in with ${authMethod.displayName}'), |
| 304 | + ), |
| 305 | + final displayIcon => OutlinedButton.icon( |
| 306 | + onPressed: testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null, |
| 307 | + icon: Image.network(displayIcon, width: 24, height: 24), |
| 308 | + label: Text('Sign in with ${authMethod.displayName}'), |
| 309 | + ), |
| 310 | + }).toList(), |
| 311 | + ]))); |
| 312 | + } |
| 313 | +} |
| 314 | + |
| 315 | +/// Generates a `mobile_flow_otp` to be used by the server for |
| 316 | +/// mobile login flow, server XOR's the api key with the otp hex |
| 317 | +/// and returns the resulting value. So, the same otp that was passed |
| 318 | +/// to the server can be used again to decode the actual api key. |
| 319 | +String _generateMobileFlowOtp() { |
| 320 | + final rand = Random.secure(); |
| 321 | + return hex.encode(List.generate(32, (_) => rand.nextInt(256), growable: false)); |
| 322 | +} |
| 323 | + |
| 324 | +extension IntListOpXOR on List<int> { |
| 325 | + Iterable<int> operator ^(List<int> other) { |
| 326 | + if (length != other.length) { |
| 327 | + throw ArgumentError('Both lists must have same length'); |
| 328 | + } |
| 329 | + return mapIndexed((i, x) => x ^ other[i]); |
| 330 | + } |
| 331 | +} |
| 332 | + |
| 333 | +String _decodeApiKey(String otp, String otpEncryptedApiKey) { |
| 334 | + final otpHex = hex.decode(otp); |
| 335 | + final otpEncryptedApiKeyHex = hex.decode(otpEncryptedApiKey); |
| 336 | + return String.fromCharCodes(otpHex ^ otpEncryptedApiKeyHex); |
| 337 | +} |
| 338 | + |
| 339 | +Future<void> loginFromIncomingRoute(BuildContext context, Uri uri) async { |
| 340 | + final globalStore = GlobalStoreWidget.of(context); |
| 341 | + final otp = globalStore.getAuthOtp(); |
| 342 | + if (otp == null) return; |
| 343 | + globalStore.setAuthOtp(null); |
| 344 | + |
| 345 | + final String apiKey; |
| 346 | + final String emailId; |
| 347 | + final int userId; |
| 348 | + final Uri realmUrl; |
| 349 | + if (uri.queryParameters case { |
| 350 | + 'otp_encrypted_api_key' : final String otpEncryptedApiKey, |
| 351 | + 'email' : final String email, |
| 352 | + 'user_id' : final String userIdStr, |
| 353 | + 'realm' : final String realm, |
| 354 | + }) { |
| 355 | + if (otpEncryptedApiKey.isEmpty || email.isEmpty || userIdStr.isEmpty || realm.isEmpty) { |
| 356 | + // TODO: Log error to Sentry |
| 357 | + return; |
| 358 | + } |
| 359 | + realmUrl = Uri.parse(realm); |
| 360 | + userId = int.parse(userIdStr); |
| 361 | + emailId = email; |
| 362 | + apiKey = _decodeApiKey(otp, otpEncryptedApiKey); |
| 363 | + } else { |
| 364 | + // TODO: Log error to Sentry |
| 365 | + return; |
| 366 | + } |
| 367 | + |
| 368 | + final GetServerSettingsResult serverSettings; |
| 369 | + try { |
| 370 | + serverSettings = await getServerSettings(realmUrl: realmUrl, zulipFeatureLevel: null); |
| 371 | + } catch (e) { |
| 372 | + if (!context.mounted) { |
| 373 | + return; |
| 374 | + } |
| 375 | + // TODO(#105) give more helpful feedback; see `fetchServerSettings` |
| 376 | + // in zulip-mobile's src/message/fetchActions.js. |
| 377 | + showErrorDialog(context: context, |
| 378 | + title: 'Could not connect', message: 'Failed to connect to server:\n$realmUrl'); |
| 379 | + return; |
| 380 | + } |
| 381 | + |
| 382 | + // TODO(#108): give feedback to user on SQL exception, like dupe realm+user |
| 383 | + final accountId = await globalStore.insertAccount(AccountsCompanion.insert( |
| 384 | + realmUrl: realmUrl, |
| 385 | + email: emailId, |
| 386 | + apiKey: apiKey, |
| 387 | + userId: userId, |
| 388 | + zulipFeatureLevel: serverSettings.zulipFeatureLevel, |
| 389 | + zulipVersion: serverSettings.zulipVersion, |
| 390 | + zulipMergeBase: Value(serverSettings.zulipMergeBase), |
| 391 | + )); |
| 392 | + |
| 393 | + if (!context.mounted) { |
| 394 | + return; |
| 395 | + } |
| 396 | + navigatorKey.currentState?.pushAndRemoveUntil( |
| 397 | + HomePage.buildRoute(accountId: accountId), |
| 398 | + (route) => (route is! _LoginSequenceRoute), |
| 399 | + ); |
| 400 | +} |
| 401 | + |
228 | 402 | class PasswordLoginPage extends StatefulWidget {
|
229 | 403 | const PasswordLoginPage({super.key, required this.serverSettings});
|
230 | 404 |
|
|
0 commit comments