Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add device authentication to app lock flow #952

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.solanamobile.seedvault.ACCESS_SEED_VAULT" />
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>

<application android:name="${applicationName}"
tools:replace="android:label"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.pleasecrypto.flutter

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity()
class MainActivity : FlutterFragmentActivity()
2 changes: 2 additions & 0 deletions packages/espressocash_app/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,7 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Allow access to use FaceID to unlock the app.</string>
</dict>
</plist>
18 changes: 14 additions & 4 deletions packages/espressocash_app/lib/features/app_lock/module.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nested/nested.dart';
import 'package:provider/provider.dart';

import '../../di.dart';
import '../accounts/services/accounts_bloc.dart';
import 'screens/app_lock_screen.dart';
import 'services/app_lock_bloc.dart';
import 'services/local_auth_repository.dart';

class AppLockModule extends SingleChildStatelessWidget {
const AppLockModule({super.key, super.child});

@override
Widget buildWithChild(BuildContext context, Widget? child) => BlocProvider(
create: (_) => sl<AppLockBloc>()
..add(const AppLockEvent.init())
..add(const AppLockEvent.lock()),
Widget buildWithChild(BuildContext context, Widget? child) => MultiProvider(
providers: [
BlocProvider(
create: (_) => sl<AppLockBloc>()
..add(const AppLockEvent.init())
..add(const AppLockEvent.lock()),
),
ChangeNotifierProvider<LocalAuthRepository>(
create: (context) => sl<LocalAuthRepository>(),
),
],
child: _Content(child: child),
);
}
Expand Down Expand Up @@ -61,6 +70,7 @@ class _ContentState extends State<_Content>
listener: (context, state) {
if (state.account == null) {
context.read<AppLockBloc>().add(const AppLockEvent.logout());
context.read<LocalAuthRepository>().clear();
}
},
child: Stack(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import '../../../l10n/l10n.dart';
import '../../../ui/back_button.dart';
import '../../../ui/decorated_window/decorated_window.dart';
import '../services/app_lock_bloc.dart';
import '../widgets/local_auth_wrapper.dart';
import '../widgets/pin_input_display_widget.dart';
import 'app_lock_setup_flow_screen.dart';

Expand All @@ -25,16 +26,21 @@ class AppLockDisableScreen extends StatelessWidget {
),
hasLogo: true,
backgroundStyle: BackgroundStyle.dark,
child: PinInputDisplayWidget(
message: state.maybeMap(
enabled: (state) => state.disableFailed
? context.l10n.incorrectPasscode
: context.l10n.enterPasscode,
orElse: () => context.l10n.enterPasscode,
child: LocalAuthWrapper(
onLocalAuthComplete: () => context
.read<AppLockBloc>()
.add(const AppLockEvent.disable(AppUnlockMode.biometrics())),
child: PinInputDisplayWidget(
message: state.maybeMap(
enabled: (state) => state.disableFailed
? context.l10n.incorrectPasscode
: context.l10n.enterPasscode,
orElse: () => context.l10n.enterPasscode,
),
onCompleted: (pin) => context
.read<AppLockBloc>()
.add(AppLockEvent.disable(AppUnlockMode.pin(pin))),
),
onCompleted: (pin) => context.read<AppLockBloc>().add(
AppLockEvent.disable(pin),
),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../../../l10n/l10n.dart';
import '../../../ui/back_button.dart';
import '../../../ui/decorated_window/decorated_window.dart';
import '../widgets/local_auth_wrapper.dart';
import '../widgets/pin_input_display_widget.dart';
import 'app_lock_setup_flow_screen.dart';

Expand All @@ -19,15 +20,15 @@ class AppLockEnableScreen extends StatefulWidget {
class _AppLockEnableScreenState extends State<AppLockEnableScreen> {
String? _firstPass;
String? _secondPass;
bool _askForBiometrics = false;

void _onComplete(String value) {
if (_firstPass == null) {
setState(() => _firstPass = value);
} else {
setState(() => _secondPass = value);
if (_firstPass == _secondPass) {
// ignore: avoid-non-null-assertion, cannot be null here
context.read<AppLockSetupRouter>().onEnableFinished(_firstPass!);
setState(() => _askForBiometrics = true);
}
}
}
Expand All @@ -36,16 +37,28 @@ class _AppLockEnableScreenState extends State<AppLockEnableScreen> {
? context.l10n.enterPasscode
: context.l10n.reEnterPasscode;

void _finish() {
final passCode = _firstPass;
if (passCode != null && _secondPass != null && passCode == _secondPass) {
context.read<AppLockSetupRouter>().onEnableFinished(passCode);
}
}

@override
Widget build(BuildContext context) => DecoratedWindow(
backButton: CpBackButton(
onPressed: () => context.read<AppLockSetupRouter>().closeFlow(),
),
hasLogo: true,
backgroundStyle: BackgroundStyle.dark,
child: PinInputDisplayWidget(
message: _instructions,
onCompleted: _onComplete,
child: LocalAuthWrapper(
shouldUseLocalAuth: _askForBiometrics,
onLocalAuthComplete: _finish,
onLocalAuthFailed: _finish,
child: PinInputDisplayWidget(
message: _instructions,
onCompleted: _onComplete,
),
),
);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../../l10n/l10n.dart';
import '../../../ui/decorated_window/decorated_window.dart';
import '../../../../l10n/l10n.dart';
import '../../../../ui/decorated_window/decorated_window.dart';
import '../services/app_lock_bloc.dart';
import '../widgets/local_auth_wrapper.dart';
import '../widgets/pin_input_display_widget.dart';

class AppLockScreen extends StatelessWidget {
Expand All @@ -16,15 +17,21 @@ class AppLockScreen extends StatelessWidget {
builder: (context, state) => DecoratedWindow(
hasLogo: true,
backgroundStyle: BackgroundStyle.dark,
child: PinInputDisplayWidget(
message: state.maybeMap(
locked: (state) => state.isRetrying
? context.l10n.incorrectPasscode
: context.l10n.enterPasscode,
orElse: () => context.l10n.enterPasscode,
child: LocalAuthWrapper(
onLocalAuthComplete: () => context
.read<AppLockBloc>()
.add(const AppLockEvent.unlock(AppUnlockMode.biometrics())),
child: PinInputDisplayWidget(
message: state.maybeMap(
locked: (state) => state.isRetrying
? context.l10n.incorrectPasscode
: context.l10n.enterPasscode,
orElse: () => context.l10n.enterPasscode,
),
onCompleted: (pin) => context
.read<AppLockBloc>()
.add(AppLockEvent.unlock(AppUnlockMode.pin(pin))),
),
onCompleted: (pin) =>
context.read<AppLockBloc>().add(AppLockEvent.unlock(pin)),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../../../routes.gr.dart';
import '../../../../routes.gr.dart';
import '../services/app_lock_bloc.dart';
import '../services/local_auth_repository.dart';

@RoutePage()
class AppLockSetupFlowScreen extends StatefulWidget {
Expand Down Expand Up @@ -33,6 +34,7 @@ class _AppLockSetupFlowScreenState extends State<AppLockSetupFlowScreen>

@override
void onDisableFinished() {
context.read<LocalAuthRepository>().clear();
context.router.pop();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:dfunc/dfunc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';

part 'app_lock_bloc.freezed.dart';

part 'app_lock_event.dart';
part 'app_lock_state.dart';

Expand Down Expand Up @@ -53,8 +53,8 @@ class AppLockBloc extends Bloc<AppLockEvent, AppLockState> {

Future<void> _onDisable(AppLockEventDisable event, _Emitter emit) async {
if (state is! AppLockStateEnabled) return;
final pin = await _secureStorage.read(key: _key);
if (pin == event.pin) {

if (await _validate(event.mode)) {
await _secureStorage.delete(key: _key);
emit(const AppLockState.disabled());
} else {
Expand All @@ -69,13 +69,18 @@ class AppLockBloc extends Bloc<AppLockEvent, AppLockState> {

Future<void> _onUnlock(AppLockEventUnlock event, _Emitter emit) async {
if (state is! AppLockStateLocked) return;
final pin = await _secureStorage.read(key: _key);
if (pin == event.pin) {

if (await _validate(event.mode)) {
emit(const AppLockState.enabled(disableFailed: false));
} else {
emit(const AppLockState.locked(isRetrying: true));
}
}

Future<bool> _validate(AppUnlockMode mode) async => mode.when(
pin: (pin) async => pin == await _secureStorage.read(key: _key),
biometrics: T,
);
}

const _key = 'lock-key';
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@ part of 'app_lock_bloc.dart';

@freezed
class AppLockEvent with _$AppLockEvent {
const factory AppLockEvent.unlock(String pin) = AppLockEventUnlock;
const factory AppLockEvent.unlock(AppUnlockMode mode) = AppLockEventUnlock;

const factory AppLockEvent.lock() = AppLockEventLock;

const factory AppLockEvent.enable(String pin) = AppLockEventEnable;

const factory AppLockEvent.disable(String pin) = AppLockEventDisable;
const factory AppLockEvent.disable(AppUnlockMode mode) = AppLockEventDisable;

const factory AppLockEvent.init() = AppLockEventInit;

const factory AppLockEvent.logout() = AppLockEventLogout;
}

@freezed
class AppUnlockMode with _$AppUnlockMode {
const factory AppUnlockMode.pin(String pin) = AppUnlockPinMode;

const factory AppUnlockMode.biometrics() = AppUnlockBiometricsMode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import 'package:freezed_annotation/freezed_annotation.dart';

part 'local_auth_preference.freezed.dart';

@freezed
class LocalAuthPreference with _$LocalAuthPreference {
const factory LocalAuthPreference.disabled() = _Disabled;
const factory LocalAuthPreference.enabled() = _Enabled;
const factory LocalAuthPreference.neverAsked() = _NeverAsked;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// ignore_for_file: avoid_positional_boolean_parameters

import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'local_auth_preference.dart';

@injectable
class LocalAuthRepository extends ChangeNotifier {
LocalAuthRepository(this._sharedPreferences);

final SharedPreferences _sharedPreferences;

LocalAuthPreference get localAuthPreference {
final value = _sharedPreferences.getBool(_localAuthKey);
if (value == null) return const LocalAuthPreference.neverAsked();

return value
? const LocalAuthPreference.enabled()
: const LocalAuthPreference.disabled();
}

Future<void> savePreference(bool useBiometrics) async {
await _sharedPreferences.setBool(_localAuthKey, useBiometrics);
notifyListeners();
}

Future<void> clear() => _sharedPreferences.remove(_localAuthKey);
}

const _localAuthKey = 'localAuthKey';
Loading