Skip to content

Commit

Permalink
feat: Added otpauth protocol support on Android.
Browse files Browse the repository at this point in the history
  • Loading branch information
Skyost committed Jul 9, 2024
1 parent 48e3a2b commit b0de12d
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 61 deletions.
44 changes: 28 additions & 16 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,38 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />

<application
android:label="Open Authenticator"
android:name="${applicationName}"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="Open Authenticator"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />

<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="login.openauthenticator.app"
android:scheme="https"/>
android:scheme="https" />
</intent-filter>
<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="otpauth" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
Expand Down
58 changes: 41 additions & 17 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_authenticator/app.dart';
import 'package:open_authenticator/firebase_options.dart';
import 'package:open_authenticator/i18n/translations.g.dart';
import 'package:open_authenticator/model/authentication/app_links.dart';
import 'package:open_authenticator/model/app_links.dart';
import 'package:open_authenticator/model/authentication/providers/email_link.dart';
import 'package:open_authenticator/model/authentication/providers/provider.dart';
import 'package:open_authenticator/model/settings/show_intro.dart';
Expand All @@ -29,6 +29,7 @@ import 'package:open_authenticator/utils/result.dart';
import 'package:open_authenticator/widgets/centered_circular_progress_indicator.dart';
import 'package:open_authenticator/widgets/dialog/totp_limit.dart';
import 'package:open_authenticator/widgets/route/unlock_challenge.dart';
import 'package:open_authenticator/widgets/waiting_overlay.dart';
import 'package:rate_my_app/rate_my_app.dart';
import 'package:simple_secure_storage/simple_secure_storage.dart';
import 'package:window_manager/window_manager.dart';
Expand Down Expand Up @@ -201,7 +202,7 @@ class _RouteWidget extends ConsumerStatefulWidget {
/// The route widget.
final Widget child;

/// Listen to dynamic links and [totpLimitExceededProvider].
/// Listen to [appLinksListenerProvider] and [totpLimitExceededProvider].
final bool listen;

/// Whether to provide an [UnlockChallengeRouteWidget].
Expand Down Expand Up @@ -236,30 +237,22 @@ class _RouteWidgetState extends ConsumerState<_RouteWidget> {
if (next.valueOrNull == null) {
return;
}
Uri? link = Uri.tryParse(next.value?.queryParameters['link'] ?? '');
if (link == null) {
Uri uri = next.value!;
if (uri.host == Uri.parse(App.firebaseLoginUrl).host) {
handleLoginLink(uri);
return;
}
String? mode = link.queryParameters['mode'];
switch (mode) {
case 'signIn':
EmailLinkAuthenticationProvider emailAuthenticationProvider = ref.read(emailLinkAuthenticationProvider.notifier);
Result<AuthenticationObject> result = await emailAuthenticationProvider.confirm(context, link.toString());
if (mounted) {
AccountUtils.handleAuthenticationResult(context, ref, result);
}
break;
if (uri.scheme == 'otpauth') {
handleTotpLink(uri);
return;
}
});
}
ref.listenManual(totpLimitExceededProvider, (previous, next) async {
if (next.valueOrNull != true) {
return;
}
bool result = false;
while (!result && mounted) {
result = await MandatoryTotpLimitDialog.show(context);
}
MandatoryTotpLimitDialog.showAndBlock(context);
});
}
if (widget.rateMyApp) {
Expand Down Expand Up @@ -291,4 +284,35 @@ class _RouteWidgetState extends ConsumerState<_RouteWidget> {
rateMyApp!.showRateDialog(context);
}
}

/// Handles a login link.
Future<void> handleLoginLink(Uri loginLink) async {
if (!mounted) {
return;
}
Uri? link = Uri.tryParse(loginLink.queryParameters['link'] ?? '');
if (link == null) {
return;
}
String? mode = link.queryParameters['mode'];
switch (mode) {
case 'signIn':
EmailLinkAuthenticationProvider emailAuthenticationProvider = ref.read(emailLinkAuthenticationProvider.notifier);
Result<AuthenticationObject> result = await emailAuthenticationProvider.confirm(context, link.toString());
if (mounted) {
AccountUtils.handleAuthenticationResult(context, ref, result);
}
break;
}
}

/// Handles a TOTP link.
Future<void> handleTotpLink(Uri totpLink) async {
if (mounted) {
await showWaitingOverlay(
context,
future: TotpPage.openFromUri(context, ref, totpLink),
);
}
}
}
File renamed without changes.
40 changes: 12 additions & 28 deletions lib/pages/scan.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_authenticator/i18n/translations.g.dart';
import 'package:open_authenticator/main.dart';
import 'package:open_authenticator/model/crypto.dart';
import 'package:open_authenticator/model/totp/decrypted.dart';
import 'package:open_authenticator/pages/home.dart';
import 'package:open_authenticator/pages/totp.dart';
import 'package:open_authenticator/widgets/centered_circular_progress_indicator.dart';
import 'package:open_authenticator/widgets/code_scan.dart';
import 'package:open_authenticator/widgets/dialog/confirmation_dialog.dart';
import 'package:open_authenticator/widgets/snackbar_icon.dart';
import 'package:open_authenticator/widgets/waiting_overlay.dart';

/// Allows to scan QR codes.
class ScanPage extends ConsumerWidget {
Expand All @@ -27,31 +24,18 @@ class ScanPage extends ConsumerWidget {
formats: const [BarcodeFormat.qrCode],
loading: const CenteredCircularProgressIndicator(),
onScan: (code, details, listener) async {
if (code != null && context.mounted) {
Uri? uri = Uri.tryParse(code);
if (uri == null) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.scan.noUri);
return;
}
CryptoStore? cryptoStore = await ref.read(cryptoStoreProvider.future);
DecryptedTotp? totp = await DecryptedTotp.fromUri(uri, cryptoStore);
if (!context.mounted) {
return;
}
if (totp == null) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.withException(exception: Exception('Failed to decrypt TOTP.')));
return;
}
Navigator.pushNamedAndRemoveUntil(
context,
TotpPage.name,
(route) => route.settings.name == HomePage.name,
arguments: {
OpenAuthenticatorApp.kRouteParameterTotp: totp,
OpenAuthenticatorApp.kRouteParameterAddTotp: true,
},
);
if (code == null || !context.mounted) {
return;
}
Uri? uri = Uri.tryParse(code);
if (uri == null) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.scan.noUri);
return;
}
await showWaitingOverlay(
context,
future: TotpPage.openFromUri(context, ref, uri),
);
},
onAccessDenied: (exception, listener) => ConfirmationDialog.ask(
context,
Expand Down
25 changes: 25 additions & 0 deletions lib/pages/totp.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_authenticator/app.dart';
import 'package:open_authenticator/i18n/translations.g.dart';
import 'package:open_authenticator/main.dart';
import 'package:open_authenticator/model/crypto.dart';
import 'package:open_authenticator/model/totp/algorithm.dart';
import 'package:open_authenticator/model/totp/decrypted.dart';
import 'package:open_authenticator/model/totp/repository.dart';
import 'package:open_authenticator/model/totp/totp.dart';
import 'package:open_authenticator/pages/home.dart';
import 'package:open_authenticator/utils/brightness_listener.dart';
import 'package:open_authenticator/utils/form_label.dart';
import 'package:open_authenticator/utils/result.dart';
Expand All @@ -17,6 +19,7 @@ import 'package:open_authenticator/widgets/dialog/totp_limit.dart';
import 'package:open_authenticator/widgets/form/password_form_field.dart';
import 'package:open_authenticator/widgets/list/expand_list_tile.dart';
import 'package:open_authenticator/widgets/list/list_tile_padding.dart';
import 'package:open_authenticator/widgets/snackbar_icon.dart';
import 'package:open_authenticator/widgets/totp/image.dart';
import 'package:open_authenticator/widgets/waiting_overlay.dart';
import 'package:qr_flutter/qr_flutter.dart';
Expand Down Expand Up @@ -45,6 +48,28 @@ class TotpPage extends ConsumerStatefulWidget {

@override
ConsumerState<ConsumerStatefulWidget> createState() => _TotpPageState();

/// Opens this page from a scanned [uri].
static Future<void> openFromUri(BuildContext context, WidgetRef ref, Uri uri) async {
CryptoStore? cryptoStore = await ref.read(cryptoStoreProvider.future);
DecryptedTotp? totp = await DecryptedTotp.fromUri(uri, cryptoStore);
if (!context.mounted) {
return;
}
if (totp == null) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.withException(exception: Exception('Failed to decrypt TOTP.')));
return;
}
Navigator.pushNamedAndRemoveUntil(
context,
TotpPage.name,
(route) => route.settings.name == HomePage.name,
arguments: {
OpenAuthenticatorApp.kRouteParameterTotp: totp,
OpenAuthenticatorApp.kRouteParameterAddTotp: true,
},
);
}
}

/// The TOTP edit page state.
Expand Down
8 changes: 8 additions & 0 deletions lib/widgets/dialog/totp_limit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ class MandatoryTotpLimitDialog extends ConsumerWidget {
],
);

/// Shows the dialog until it returns `true`.
static Future<void> showAndBlock(BuildContext context) async {
bool result = false;
while (!result && context.mounted) {
result = await MandatoryTotpLimitDialog.show(context);
}
}

/// Shows a mandatory totp limit dialog.
static Future<bool> show(
BuildContext context, {
Expand Down

0 comments on commit b0de12d

Please sign in to comment.