OATH: Add advanced options to add dialog.

This commit is contained in:
Dain Nilsson 2022-01-28 17:05:05 +01:00
parent 7d8a09529e
commit a5f73f7d94
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
3 changed files with 267 additions and 96 deletions

View File

@ -3,6 +3,12 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'models.freezed.dart';
part 'models.g.dart';
const defaultPeriod = 30;
const defaultDigits = 6;
const defaultCounter = 0;
const defaultOathType = OathType.totp;
const defaultHashAlgorithm = HashAlgorithm.sha1;
enum HashAlgorithm {
@JsonValue(0x01)
sha1,
@ -12,10 +18,6 @@ enum HashAlgorithm {
sha512,
}
extension on HashAlgorithm {
String get name => toString().split('.').last.toUpperCase();
}
enum OathType {
@JsonValue(0x10)
hotp,
@ -23,10 +25,6 @@ enum OathType {
totp,
}
extension on OathType {
String get name => toString().split('.').last.toUpperCase();
}
@freezed
class OathCredential with _$OathCredential {
factory OathCredential(
@ -71,11 +69,11 @@ class CredentialData with _$CredentialData {
String? issuer,
required String name,
required String secret,
@Default(OathType.totp) OathType oathType,
@Default(HashAlgorithm.sha1) HashAlgorithm hashAlgorithm,
@Default(6) int digits,
@Default(30) int period,
@Default(0) int counter,
@Default(defaultOathType) OathType oathType,
@Default(defaultHashAlgorithm) HashAlgorithm hashAlgorithm,
@Default(defaultDigits) int digits,
@Default(defaultPeriod) int period,
@Default(defaultCounter) int counter,
}) = _CredentialData;
factory CredentialData.fromJson(Map<String, dynamic> json) =>

View File

@ -1,4 +1,7 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/oath/models.dart';
@ -6,21 +9,251 @@ import '../../app/state.dart';
import '../../app/models.dart';
import '../state.dart';
class OathAddAccountPage extends ConsumerStatefulWidget {
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
final DeviceNode device;
final _secretFormatterPattern =
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
class AddAccountForm extends StatefulWidget {
final Function(CredentialData, bool) onSubmit;
const AddAccountForm({Key? key, required this.onSubmit}) : super(key: key);
@override
OathAddAccountPageState createState() => OathAddAccountPageState();
State<StatefulWidget> createState() => _AddAccountFormState();
}
class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
class _AddAccountFormState extends State<AddAccountForm> {
String _issuer = '';
String _account = '';
String _secret = '';
bool _touch = false;
bool _advanced = false;
OathType _oathType = defaultOathType;
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
int _period = defaultPeriod;
int _digits = defaultDigits;
@override
Widget build(BuildContext context) {
int remaining = 64; // 64 bytes are shared between issuer and name.
if (_oathType == OathType.totp && _period != defaultPeriod) {
// Non-standard periods are stored as part of this data, as a "D/"- prefix.
remaining -= '$_period/'.length;
}
if (_issuer.isNotEmpty) {
// Issuer is separated from name with a ":", if present.
remaining -= 1;
}
final issuerRemaining = remaining - max<int>(_account.length, 1);
final nameRemaining = remaining - _issuer.length;
final secretValid = _secret.length * 5 % 8 < 5;
final isValid =
_account.isNotEmpty && _secret.isNotEmpty && secretValid && _period > 0;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
enabled: issuerRemaining > 0,
maxLength: issuerRemaining > 0 ? issuerRemaining : null,
decoration: const InputDecoration(
labelText: 'Issuer (optional)',
helperText:
'', // Prevents dialog resizing when enabled = false
),
onChanged: (value) {
setState(() {
_issuer = value;
});
},
),
TextFormField(
enabled: nameRemaining > 0,
maxLength: nameRemaining > 0 ? nameRemaining : null,
decoration: const InputDecoration(
labelText: 'Account name',
helperText:
'', // Prevents dialog resizing when enabled = false
),
onChanged: (value) {
setState(() {
_account = value;
});
},
),
TextFormField(
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(_secretFormatterPattern)
],
decoration: InputDecoration(
labelText: 'Secret key',
errorText: secretValid ? null : 'Invalid value'),
onChanged: (value) {
setState(() {
_secret = value.replaceAll(' ', '');
});
},
),
],
),
),
CheckboxListTile(
title: const Text('Require touch'),
controlAffinity: ListTileControlAffinity.leading,
value: _touch,
onChanged: (value) {
setState(() {
_touch = value ?? false;
});
},
),
CheckboxListTile(
title: const Text('Show advanced settings'),
controlAffinity: ListTileControlAffinity.leading,
value: _advanced,
onChanged: (value) {
setState(() {
_advanced = value ?? false;
});
},
),
if (_advanced)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<OathType>(
decoration: const InputDecoration(labelText: 'Type'),
value: _oathType,
items: OathType.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name.toUpperCase()),
))
.toList(),
onChanged: (type) {
setState(() {
_oathType = type ?? OathType.totp;
});
},
),
),
const SizedBox(
width: 8.0,
),
Expanded(
child: DropdownButtonFormField<HashAlgorithm>(
decoration:
const InputDecoration(label: Text('Algorithm')),
value: _hashAlgorithm,
items: HashAlgorithm.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name.toUpperCase()),
))
.toList(),
onChanged: (type) {
setState(() {
_hashAlgorithm = type ?? HashAlgorithm.sha1;
});
},
),
),
],
),
Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_oathType == OathType.totp)
Expanded(
child: TextFormField(
initialValue: _period > 0 ? _period.toString() : '',
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
label: const Text('Period'),
errorText: _period > 0
? null
: 'Must be a positive number',
),
onChanged: (value) {
setState(() {
_period = int.tryParse(value) ?? -1;
});
},
),
),
if (_oathType == OathType.totp)
const SizedBox(
width: 8.0,
),
Expanded(
child: DropdownButtonFormField<int>(
decoration:
const InputDecoration(label: Text('Digits')),
value: _digits,
items: [6, 7, 8]
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.toString()),
))
.toList(),
onChanged: (value) {
setState(() {
_digits = value ?? defaultDigits;
});
},
),
),
],
),
],
),
),
Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.centerRight,
child: ElevatedButton(
onPressed: isValid
? () {
widget.onSubmit(
CredentialData(
issuer: _issuer.isEmpty ? null : _issuer,
name: _account,
secret: _secret,
oathType: _oathType,
hashAlgorithm: _hashAlgorithm,
digits: _digits,
period: _period,
),
_touch,
);
}
: null,
child: const Text('Add account'),
),
),
],
);
}
}
class OathAddAccountPage extends ConsumerWidget {
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
final DeviceNode device;
@override
Widget build(BuildContext context, WidgetRef ref) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
//TODO: This can probably be checked better to make sure it's the main page.
@ -28,77 +261,16 @@ class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
});
return Scaffold(
appBar: AppBar(
title: const Text('Add account'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Issuer',
),
onChanged: (value) {
setState(() {
_issuer = value;
});
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Account name *',
),
onChanged: (value) {
setState(() {
_account = value;
});
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Secret key *',
),
onChanged: (value) {
setState(() {
_secret = value;
});
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: CheckboxListTile(
title: const Text('Require touch'),
checkColor: Colors.white,
value: _touch,
onChanged: (value) {
setState(() {
_touch = value ?? false;
});
},
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: () {
final cred = CredentialData(
issuer: _issuer.isEmpty ? null : _issuer,
name: _account,
secret: _secret);
appBar: AppBar(
title: const Text('Add account'),
),
body: ListView(
children: [
AddAccountForm(
onSubmit: (cred, requireTouch) {
ref
.read(credentialListProvider(widget.device.path).notifier)
.addAccount(cred.toUri(), requireTouch: _touch);
.read(credentialListProvider(device.path).notifier)
.addAccount(cred.toUri(), requireTouch: requireTouch);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -107,11 +279,8 @@ class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
),
);
},
child: const Text('Submit'),
),
),
],
),
);
)
],
));
}
}

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -44,7 +46,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
: credential.name;
int remaining = 64; // 64 bytes are shared between issuer and name.
if (credential.oathType == OathType.totp && credential.period != 30) {
if (credential.oathType == OathType.totp &&
credential.period != defaultPeriod) {
// Non-standard periods are stored as part of this data, as a "D/"- prefix.
remaining -= '${credential.period}/'.length;
}
@ -52,7 +55,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
// Issuer is separated from name with a ":", if present.
remaining -= 1;
}
final issuerRemaining = remaining - _nameController.text.length;
final issuerRemaining =
remaining - max<int>(_nameController.text.length, 1);
final nameRemaining = remaining - _issuerController.text.length;
final isValid = _nameController.text.trim().isNotEmpty;