This commit is contained in:
Dain Nilsson 2022-09-14 16:22:32 +02:00
commit 62a7365439
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
10 changed files with 585 additions and 353 deletions

View File

@ -20,7 +20,12 @@ class MainViewModel : ViewModel() {
private var _appContext = MutableLiveData(OperationContext.Oath)
val appContext: LiveData<OperationContext> = _appContext
fun setAppContext(appContext: OperationContext) = _appContext.postValue(appContext)
fun setAppContext(appContext: OperationContext) {
// Don't reset the context unless it actually changes
if(appContext != _appContext.value) {
_appContext.postValue(appContext)
}
}
private val _connectedYubiKey = MutableLiveData<UsbYubiKeyDevice?>()
val connectedYubiKey: LiveData<UsbYubiKeyDevice?> = _connectedYubiKey

View File

@ -52,6 +52,7 @@ class OathManager(
private var pendingAction: OathAction? = null
private var refreshJob: Job? = null
private var addToAny = false
// provides actions for lifecycle events
private val lifecycleObserver = object : DefaultLifecycleObserver {
@ -61,14 +62,16 @@ class OathManager(
override fun onPause(owner: LifecycleOwner) {
startTimeMs = currentTimeMs
// cancel any pending actions
pendingAction?.let {
Log.d(TAG, "Cancelling pending action/closing nfc dialog.")
it.invoke(Result.failure(CancellationException()))
coroutineScope.launch {
dialogManager.closeDialog()
// cancel any pending actions, except for addToAny
if(!addToAny) {
pendingAction?.let {
Log.d(TAG, "Cancelling pending action/closing nfc dialog.")
it.invoke(Result.failure(CancellationException()))
coroutineScope.launch {
dialogManager.closeDialog()
}
pendingAction = null
}
pendingAction = null
}
super.onPause(owner)
@ -150,6 +153,10 @@ class OathManager(
args["issuer"] as String?
)
"deleteAccount" -> deleteAccount(args["credentialId"] as String)
"addAccountToAny" -> addAccountToAny(
args["uri"] as String,
args["requireTouch"] as Boolean
)
else -> throw NotImplementedError()
}
}
@ -190,13 +197,6 @@ class OathManager(
}
}
} else {
// Awaiting an action for a different device? Fail it and stop processing.
pendingAction?.let { action ->
action.invoke(Result.failure(IllegalStateException("Wrong deviceId")))
pendingAction = null
return@withConnection
}
// Clear in-memory password for any previous device
if (connection.transport == Transport.NFC && previousId != null) {
memoryKeyProvider.removeKey(previousId)
@ -210,6 +210,20 @@ class OathManager(
)
}
// Awaiting an action for a different or no device?
pendingAction?.let { action ->
pendingAction = null
if(addToAny) {
// Special "add to any YubiKey" action, process
addToAny = false
action.invoke(Result.success(oath))
} else {
// Awaiting an action for a different device? Fail it and stop processing.
action.invoke(Result.failure(IllegalStateException("Wrong deviceId")))
return@withConnection
}
}
// Update deviceInfo since the deviceId has changed
if (oath.version.isLessThan(4, 0, 0) && connection.transport == Transport.NFC) {
// NEO over NFC, need a new connection to select another applet
@ -259,6 +273,32 @@ class OathManager(
}
}
private suspend fun addAccountToAny(
uri: String,
requireTouch: Boolean,
): String {
val credentialData: CredentialData =
CredentialData.parseUri(URI.create(uri))
addToAny = true
return useOathSessionNfc("Add account") { session ->
val credential = session.putCredential(credentialData, requireTouch)
val code =
if (credentialData.oathType == OathType.TOTP && !requireTouch) {
// recalculate the code
calculateCode(session, credential)
} else null
val addedCred = oathViewModel.addCredential(
credential.model(session.deviceId),
code?.model()
)
Log.d(TAG, "Added cred $credential")
jsonSerializer.encodeToString(addedCred)
}
}
private suspend fun reset(): String {
useOathSession("Reset YubiKey") {
// note, it is ok to reset locked session

View File

@ -108,6 +108,26 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
}
}
final addCredentialToAnyProvider =
Provider((ref) => (Uri credentialUri, {bool requireTouch = false}) async {
try {
String resultString = await _methods.invokeMethod(
'addAccountToAny', {
'uri': credentialUri.toString(),
'requireTouch': requireTouch
});
var result = jsonDecode(resultString);
return OathCredential.fromJson(result['credential']);
} on PlatformException catch (pe) {
if (CancellationException.isCancellation(pe)) {
throw CancellationException();
}
_log.error('Failed to add account.', pe);
rethrow;
}
});
final androidCredentialListProvider = StateNotifierProvider.autoDispose
.family<OathCredentialListNotifier, List<OathPair>?, DevicePath>(
(ref, devicePath) {

View File

@ -11,6 +11,7 @@ class AppPage extends ConsumerWidget {
final List<Widget> actions;
final List<PopupMenuEntry> keyActions;
final bool centered;
final Widget Function(List<PopupMenuEntry>)? actionButtonBuilder;
AppPage({
super.key,
this.title,
@ -18,6 +19,7 @@ class AppPage extends ConsumerWidget {
this.actions = const [],
this.keyActions = const [],
this.centered = false,
this.actionButtonBuilder,
});
@override
@ -89,7 +91,8 @@ class AppPage extends ConsumerWidget {
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: DeviceButton(actions: keyActions),
child: actionButtonBuilder?.call(keyActions) ??
DeviceButton(actions: keyActions),
),
],
),

View File

@ -2,12 +2,15 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/core/state.dart';
import 'message_page.dart';
import 'device_error_screen.dart';
import '../models.dart';
import '../state.dart';
import '../message.dart';
import '../../fido/views/fido_screen.dart';
import '../../oath/views/add_account_page.dart';
import '../../oath/views/oath_screen.dart';
class MainPage extends ConsumerWidget {
@ -31,13 +34,37 @@ class MainPage extends ConsumerWidget {
'settings',
'about',
'licenses',
'user_interaction_prompt',
'oath_add_account',
].contains(route.settings.name);
});
});
final deviceNode = ref.watch(currentDeviceProvider);
if (deviceNode == null) {
return MessagePage(message: Platform.isAndroid ? 'Insert or tap your YubiKey' : 'Insert your YubiKey');
if (isAndroid) {
return MessagePage(
message: 'Insert or tap your YubiKey',
actionButtonBuilder: (keyActions) => IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Add account',
onPressed: () {
showBlurDialog(
context: context,
routeSettings: const RouteSettings(name: 'oath_add_account'),
builder: (context) => OathAddAccountPage(
null,
null,
openQrScanner: Platform.isAndroid,
credentials: null,
),
);
},
),
);
} else {
return const MessagePage(message: 'Insert your YubiKey');
}
} else {
return ref.watch(currentDeviceDataProvider).when(
data: (data) {
@ -45,12 +72,14 @@ class MainPage extends ConsumerWidget {
if (app.getAvailability(data) == Availability.unsupported) {
return MessagePage(
header: 'Application not supported',
message: 'The used YubiKey does not support \'${app.name}\' application',
message:
'The used YubiKey does not support \'${app.name}\' application',
);
} else if (app.getAvailability(data) != Availability.enabled) {
return MessagePage(
header: 'Application disabled',
message: 'Enable the \'${app.name}\' application on your YubiKey to access',
message:
'Enable the \'${app.name}\' application on your YubiKey to access',
);
}

View File

@ -9,6 +9,7 @@ class MessagePage extends StatelessWidget {
final String? message;
final List<Widget> actions;
final List<PopupMenuEntry> keyActions;
final Widget Function(List<PopupMenuEntry> keyActions)? actionButtonBuilder;
const MessagePage({
super.key,
@ -18,6 +19,7 @@ class MessagePage extends StatelessWidget {
this.message,
this.actions = const [],
this.keyActions = const [],
this.actionButtonBuilder,
});
@override
@ -26,6 +28,7 @@ class MessagePage extends StatelessWidget {
centered: true,
actions: actions,
keyActions: keyActions,
actionButtonBuilder: actionButtonBuilder,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../message.dart';
@ -50,7 +52,7 @@ class _UserInteractionDialog extends StatefulWidget {
class _UserInteractionDialogState extends State<_UserInteractionDialog> {
void _rebuild() {
setState(() {});
Timer.run(() => setState(() {}));
}
@override
@ -126,6 +128,7 @@ UserInteractionController promptUserInteraction(
);
showBlurDialog(
context: context,
routeSettings: const RouteSettings(name: 'user_interaction_prompt'),
builder: (context) {
return WillPopScope(
onWillPop: () async {

View File

@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
@ -7,12 +9,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../android/oath/state.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/user_interaction.dart';
import '../../cancellation_exception.dart';
import '../../desktop/models.dart';
import '../../management/models.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/file_drop_target.dart';
import '../../widgets/responsive_dialog.dart';
@ -20,6 +25,7 @@ import '../../widgets/utf8_utils.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'unlock_form.dart';
import 'utils.dart';
final _log = Logger('oath.view.add_account_page');
@ -30,8 +36,8 @@ final _secretFormatterPattern =
enum _QrScanState { none, scanning, success, failed }
class OathAddAccountPage extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OathState state;
final DevicePath? devicePath;
final OathState? state;
final List<OathCredential>? credentials;
final bool openQrScanner;
const OathAddAccountPage(
@ -52,6 +58,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final _accountController = TextEditingController();
final _secretController = TextEditingController();
final _periodController = TextEditingController(text: '$defaultPeriod');
UserInteractionController? _promptController;
Uri? _otpauthUri;
bool _touch = false;
OathType _oathType = defaultOathType;
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
@ -147,8 +155,91 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}
}
Future<void> _doAddCredential(
{DevicePath? devicePath, required Uri credUri}) async {
try {
if (devicePath == null) {
assert(Platform.isAndroid, 'devicePath is only optional for Android');
await ref
.read(addCredentialToAnyProvider)
.call(credUri, requireTouch: _touch);
} else {
await ref
.read(credentialListProvider(devicePath).notifier)
.addAccount(credUri, requireTouch: _touch);
}
if (!mounted) return;
Navigator.of(context).pop();
showMessage(
context, AppLocalizations.of(context)!.oath_success_add_account);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
showMessage(
context,
'${AppLocalizations.of(context)!.oath_fail_add_account}: $errorMessage',
duration: const Duration(seconds: 4),
);
}
}
@override
Widget build(BuildContext context) {
final deviceNode = ref.watch(currentDeviceProvider);
if (widget.devicePath != null && widget.devicePath != deviceNode?.path) {
// If the dialog was started for a specific device and it was
// changed/removed, close the dialog.
Navigator.of(context).popUntil((route) => route.isFirst);
}
final OathState? oathState;
if (widget.state == null && deviceNode != null) {
oathState = ref
.watch(oathStateProvider(deviceNode.path))
.maybeWhen(data: (data) => data, orElse: () => null);
} else {
oathState = widget.state;
}
final otpauthUri = _otpauthUri;
_promptController?.updateContent(title: 'Insert YubiKey');
if (otpauthUri != null && deviceNode != null) {
final deviceData = ref.watch(currentDeviceDataProvider);
deviceData.when(data: (data) {
if (Capability.oath.value ^
(data.info.config.enabledCapabilities[deviceNode.transport] ??
0) !=
0) {
if (oathState == null) {
_promptController?.updateContent(title: 'Please wait...');
} else if (oathState.locked) {
_promptController?.close();
} else {
_otpauthUri = null;
_promptController?.close();
Timer.run(() => _doAddCredential(
devicePath: deviceNode.path,
credUri: otpauthUri,
));
}
} else {
_promptController?.updateContent(title: 'Unsupported YubiKey');
}
}, error: (error, _) {
_promptController?.updateContent(title: 'Unsupported YubiKey');
}, loading: () {
_promptController?.updateContent(title: 'Please wait...');
});
}
final period = int.tryParse(_periodController.text) ?? -1;
final remaining = getRemainingKeySpace(
oathType: _oathType,
@ -170,7 +261,10 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
.isEmpty ??
true;
final isValid = _accountController.text.trim().isNotEmpty &&
final isLocked = oathState?.locked ?? false;
final isValid = !isLocked &&
_accountController.text.trim().isNotEmpty &&
secret.isNotEmpty &&
isUnique &&
issuerRemaining >= -1 &&
@ -179,6 +273,20 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final qrScanner = ref.watch(qrScannerProvider);
final hashAlgorithms = HashAlgorithm.values
.where((alg) =>
alg != HashAlgorithm.sha512 ||
(oathState?.version.isAtLeast(4, 3, 1) ?? true))
.toList();
if (!hashAlgorithms.contains(_hashAlgorithm)) {
_hashAlgorithm = HashAlgorithm.sha1;
}
if (!(oathState?.version.isAtLeast(4, 2) ?? true)) {
// Touch not supported
_touch = false;
}
void submit() async {
if (secretLengthValid) {
final issuer = _issuerController.text.trim();
@ -193,29 +301,23 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
period: period,
);
try {
await ref
.read(credentialListProvider(widget.devicePath).notifier)
.addAccount(cred.toUri(), requireTouch: _touch);
if (!mounted) return;
Navigator.of(context).pop();
showMessage(
context, AppLocalizations.of(context)!.oath_success_add_account);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
showMessage(
final devicePath = deviceNode?.path;
if (devicePath != null) {
await _doAddCredential(devicePath: devicePath, credUri: cred.toUri());
} else if (Platform.isAndroid) {
// Send the credential to Android to be added to the next YubiKey
await _doAddCredential(devicePath: null, credUri: cred.toUri());
} else {
// Desktop. No YubiKey, prompt and store the cred.
_otpauthUri = cred.toUri();
_promptController = promptUserInteraction(
context,
'${AppLocalizations.of(context)!.oath_fail_add_account}: $errorMessage',
duration: const Duration(seconds: 4),
title: 'Insert YubiKey',
description: 'Add account',
icon: const Icon(Icons.usb),
onCancel: () {
_otpauthUri = null;
},
);
}
} else {
@ -249,198 +351,217 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
key: keys.issuerField,
controller: _issuerController,
autofocus: !widget.openQrScanner,
enabled: issuerRemaining > 0,
maxLength: max(issuerRemaining, 1),
inputFormatters: [limitBytesLength(issuerRemaining)],
buildCounter:
buildByteCounterFor(_issuerController.text.trim()),
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.oath_issuer_optional,
helperText: '', // Prevents dialog resizing when disabled
prefixIcon: const Icon(Icons.business_outlined),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
onSubmitted: (_) {
if (isValid) submit();
},
),
TextField(
key: keys.nameField,
controller: _accountController,
maxLength: max(nameRemaining, 1),
buildCounter:
buildByteCounterFor(_accountController.text.trim()),
inputFormatters: [limitBytesLength(nameRemaining)],
decoration: InputDecoration(
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
labelText: AppLocalizations.of(context)!.oath_account_name,
helperText: '', // Prevents dialog resizing when disabled
errorText: isUnique
? null
: AppLocalizations.of(context)!.oath_duplicate_name,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
onSubmitted: (_) {
if (isValid) submit();
},
),
TextField(
key: keys.secretField,
controller: _secretController,
obscureText: _isObscure,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(_secretFormatterPattern)
],
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color,
child: isLocked
? Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child:
UnlockForm(deviceNode!.path, keystore: oathState!.keystore),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
key: keys.issuerField,
controller: _issuerController,
autofocus: !widget.openQrScanner,
enabled: issuerRemaining > 0,
maxLength: max(issuerRemaining, 1),
inputFormatters: [limitBytesLength(issuerRemaining)],
buildCounter:
buildByteCounterFor(_issuerController.text.trim()),
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText:
AppLocalizations.of(context)!.oath_issuer_optional,
helperText:
'', // Prevents dialog resizing when disabled
prefixIcon: const Icon(Icons.business_outlined),
),
onPressed: () {
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_isObscure = !_isObscure;
// Update maxlengths
});
},
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined),
labelText: AppLocalizations.of(context)!.oath_secret_key,
errorText: _validateSecretLength && !secretLengthValid
? AppLocalizations.of(context)!.oath_invalid_length
: null),
readOnly: _qrState == _QrScanState.success,
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
_validateSecretLength = false;
});
},
onSubmitted: (_) {
if (isValid) submit();
},
),
if (qrScanner != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ActionChip(
avatar: _qrState != _QrScanState.scanning
? (_qrState == _QrScanState.success
? const Icon(Icons.qr_code)
: const Icon(Icons.qr_code_scanner_outlined))
: const CircularProgressIndicator(strokeWidth: 2.0),
label: _qrState == _QrScanState.success
? Text(AppLocalizations.of(context)!.oath_scanned_qr)
: Text(AppLocalizations.of(context)!.oath_scan_qr),
onPressed: () {
_scanQrCode(qrScanner);
}),
),
const Divider(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
if (widget.state.version.isAtLeast(4, 2))
FilterChip(
label: Text(
AppLocalizations.of(context)!.oath_require_touch),
selected: _touch,
onSelected: (value) {
setState(() {
_touch = value;
});
onSubmitted: (_) {
if (isValid) submit();
},
),
ChoiceFilterChip<OathType>(
items: OathType.values,
value: _oathType,
selected: _oathType != defaultOathType,
itemBuilder: (value) => Text(value.displayName),
onChanged: _qrState != _QrScanState.success
? (value) {
setState(() {
_oathType = value;
});
}
: null,
),
ChoiceFilterChip<HashAlgorithm>(
items: HashAlgorithm.values,
value: _hashAlgorithm,
selected: _hashAlgorithm != defaultHashAlgorithm,
itemBuilder: (value) => Text(value.displayName),
onChanged: _qrState != _QrScanState.success
? (value) {
setState(() {
_hashAlgorithm = value;
});
}
: null,
),
if (_oathType == OathType.totp)
ChoiceFilterChip<int>(
items: _periodValues,
value:
int.tryParse(_periodController.text) ?? defaultPeriod,
selected:
int.tryParse(_periodController.text) != defaultPeriod,
itemBuilder: ((value) => Text(
'$value ${AppLocalizations.of(context)!.oath_sec}')),
onChanged: _qrState != _QrScanState.success
? (period) {
TextField(
key: keys.nameField,
controller: _accountController,
maxLength: max(nameRemaining, 1),
buildCounter:
buildByteCounterFor(_accountController.text.trim()),
inputFormatters: [limitBytesLength(nameRemaining)],
decoration: InputDecoration(
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
labelText:
AppLocalizations.of(context)!.oath_account_name,
helperText:
'', // Prevents dialog resizing when disabled
errorText: isUnique
? null
: AppLocalizations.of(context)!.oath_duplicate_name,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
onSubmitted: (_) {
if (isValid) submit();
},
),
TextField(
key: keys.secretField,
controller: _secretController,
obscureText: _isObscure,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
_secretFormatterPattern)
],
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(
_isObscure
? Icons.visibility
: Icons.visibility_off,
color: IconTheme.of(context).color,
),
onPressed: () {
setState(() {
_periodController.text = '$period';
_isObscure = !_isObscure;
});
}
: null,
},
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined),
labelText:
AppLocalizations.of(context)!.oath_secret_key,
errorText: _validateSecretLength && !secretLengthValid
? AppLocalizations.of(context)!
.oath_invalid_length
: null),
readOnly: _qrState == _QrScanState.success,
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
_validateSecretLength = false;
});
},
onSubmitted: (_) {
if (isValid) submit();
},
),
ChoiceFilterChip<int>(
items: _digitsValues,
value: _digits,
selected: _digits != defaultDigits,
itemBuilder: (value) => Text(
'$value ${AppLocalizations.of(context)!.oath_digits}'),
onChanged: _qrState != _QrScanState.success
? (digits) {
setState(() {
_digits = digits;
});
}
: null,
),
],
if (qrScanner != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ActionChip(
avatar: _qrState != _QrScanState.scanning
? (_qrState == _QrScanState.success
? const Icon(Icons.qr_code)
: const Icon(
Icons.qr_code_scanner_outlined))
: const CircularProgressIndicator(
strokeWidth: 2.0),
label: _qrState == _QrScanState.success
? Text(AppLocalizations.of(context)!
.oath_scanned_qr)
: Text(
AppLocalizations.of(context)!.oath_scan_qr),
onPressed: () {
_scanQrCode(qrScanner);
}),
),
const Divider(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
if (oathState?.version.isAtLeast(4, 2) ?? true)
FilterChip(
label: Text(AppLocalizations.of(context)!
.oath_require_touch),
selected: _touch,
onSelected: (value) {
setState(() {
_touch = value;
});
},
),
ChoiceFilterChip<OathType>(
items: OathType.values,
value: _oathType,
selected: _oathType != defaultOathType,
itemBuilder: (value) => Text(value.displayName),
onChanged: _qrState != _QrScanState.success
? (value) {
setState(() {
_oathType = value;
});
}
: null,
),
ChoiceFilterChip<HashAlgorithm>(
items: hashAlgorithms,
value: _hashAlgorithm,
selected: _hashAlgorithm != defaultHashAlgorithm,
itemBuilder: (value) => Text(value.displayName),
onChanged: _qrState != _QrScanState.success
? (value) {
setState(() {
_hashAlgorithm = value;
});
}
: null,
),
if (_oathType == OathType.totp)
ChoiceFilterChip<int>(
items: _periodValues,
value: int.tryParse(_periodController.text) ??
defaultPeriod,
selected: int.tryParse(_periodController.text) !=
defaultPeriod,
itemBuilder: ((value) => Text(
'$value ${AppLocalizations.of(context)!.oath_sec}')),
onChanged: _qrState != _QrScanState.success
? (period) {
setState(() {
_periodController.text = '$period';
});
}
: null,
),
ChoiceFilterChip<int>(
items: _digitsValues,
value: _digits,
selected: _digits != defaultDigits,
itemBuilder: (value) => Text(
'$value ${AppLocalizations.of(context)!.oath_digits}'),
onChanged: _qrState != _QrScanState.success
? (digits) {
setState(() {
_digits = digits;
});
}
: null,
),
],
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
),
);
}

View File

@ -21,6 +21,7 @@ import 'account_list.dart';
import 'add_account_page.dart';
import 'manage_password_dialog.dart';
import 'reset_dialog.dart';
import 'unlock_form.dart';
class OathScreen extends ConsumerWidget {
final DevicePath devicePath;
@ -78,13 +79,12 @@ class _LockedView extends ConsumerWidget {
},
),
],
child: Column(
children: [
_UnlockForm(
devicePath,
keystore: oathState.keystore,
),
],
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: UnlockForm(
devicePath,
keystore: oathState.keystore,
),
),
);
}
@ -244,122 +244,3 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
];
}
}
class _UnlockForm extends ConsumerStatefulWidget {
final DevicePath _devicePath;
final KeystoreState keystore;
const _UnlockForm(this._devicePath, {required this.keystore});
@override
ConsumerState<_UnlockForm> createState() => _UnlockFormState();
}
class _UnlockFormState extends ConsumerState<_UnlockForm> {
final _passwordController = TextEditingController();
bool _remember = false;
bool _passwordIsWrong = false;
bool _isObscure = true;
void _submit() async {
setState(() {
_passwordIsWrong = false;
});
final result = await ref
.read(oathStateProvider(widget._devicePath).notifier)
.unlock(_passwordController.text, remember: _remember);
if (!mounted) return;
if (!result.first) {
setState(() {
_passwordIsWrong = true;
_passwordController.clear();
});
} else if (_remember && !result.second) {
showMessage(
context, AppLocalizations.of(context)!.oath_failed_remember_pw);
}
}
@override
Widget build(BuildContext context) {
final keystoreFailed = widget.keystore == KeystoreState.failed;
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 18.0, right: 18, top: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.oath_enter_oath_pw,
),
const SizedBox(height: 16.0),
TextField(
key: keys.passwordField,
controller: _passwordController,
autofocus: true,
obscureText: _isObscure,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.oath_password,
errorText: _passwordIsWrong
? AppLocalizations.of(context)!.oath_wrong_password
: null,
helperText: '', // Prevents resizing when errorText shown
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color,
),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
),
),
onChanged: (_) => setState(() {
_passwordIsWrong = false;
}), // Update state on change
onSubmitted: (_) => _submit(),
),
],
),
),
const SizedBox(height: 8.0),
keystoreFailed
? ListTile(
leading: const Icon(Icons.warning_amber_rounded),
title: Text(
AppLocalizations.of(context)!.oath_keystore_unavailable),
dense: true,
minLeadingWidth: 0,
)
: CheckboxListTile(
title:
Text(AppLocalizations.of(context)!.oath_remember_password),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _remember,
onChanged: (value) {
setState(() {
_remember = value ?? false;
});
},
),
Padding(
padding: const EdgeInsets.only(top: 12.0, right: 18.0, bottom: 4.0),
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
key: keys.unlockButton,
label: Text(AppLocalizations.of(context)!.oath_unlock),
icon: const Icon(Icons.lock_open),
onPressed: _passwordController.text.isNotEmpty ? _submit : null,
),
),
),
],
);
}
}

127
lib/oath/views/unlock_form.dart Executable file
View File

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../models.dart';
import '../keys.dart' as keys;
import '../state.dart';
class UnlockForm extends ConsumerStatefulWidget {
final DevicePath _devicePath;
final KeystoreState keystore;
const UnlockForm(this._devicePath, {required this.keystore, super.key});
@override
ConsumerState<UnlockForm> createState() => _UnlockFormState();
}
class _UnlockFormState extends ConsumerState<UnlockForm> {
final _passwordController = TextEditingController();
bool _remember = false;
bool _passwordIsWrong = false;
bool _isObscure = true;
void _submit() async {
setState(() {
_passwordIsWrong = false;
});
final result = await ref
.read(oathStateProvider(widget._devicePath).notifier)
.unlock(_passwordController.text, remember: _remember);
if (!mounted) return;
if (!result.first) {
setState(() {
_passwordIsWrong = true;
_passwordController.clear();
});
} else if (_remember && !result.second) {
showMessage(
context, AppLocalizations.of(context)!.oath_failed_remember_pw);
}
}
@override
Widget build(BuildContext context) {
final keystoreFailed = widget.keystore == KeystoreState.failed;
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.oath_enter_oath_pw,
),
const SizedBox(height: 16.0),
TextField(
key: keys.passwordField,
controller: _passwordController,
autofocus: true,
obscureText: _isObscure,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.oath_password,
errorText: _passwordIsWrong
? AppLocalizations.of(context)!.oath_wrong_password
: null,
helperText: '', // Prevents resizing when errorText shown
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color,
),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
),
),
onChanged: (_) => setState(() {
_passwordIsWrong = false;
}), // Update state on change
onSubmitted: (_) => _submit(),
),
],
),
),
keystoreFailed
? ListTile(
leading: const Icon(Icons.warning_amber_rounded),
title: Text(
AppLocalizations.of(context)!.oath_keystore_unavailable),
dense: true,
minLeadingWidth: 0,
)
: CheckboxListTile(
title:
Text(AppLocalizations.of(context)!.oath_remember_password),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _remember,
onChanged: (value) {
setState(() {
_remember = value ?? false;
});
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
key: keys.unlockButton,
label: Text(AppLocalizations.of(context)!.oath_unlock),
icon: const Icon(Icons.lock_open),
onPressed: _passwordController.text.isNotEmpty ? _submit : null,
),
),
),
],
);
}
}