webauthn_secure_storage 0.2.3
webauthn_secure_storage: ^0.2.3 copied to clipboard
App-facing package for the webauthn_secure_storage federated plugin.
import 'dart:io';
import 'package:webauthn_secure_storage/webauthn_secure_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:logging_appenders/logging_appenders.dart';
final MemoryAppender logMessages = MemoryAppender();
final _logger = Logger('main');
void main() {
Logger.root.level = Level.ALL;
PrintAppender().attachToLogger(Logger.root);
logMessages.attachToLogger(Logger.root);
_logger.fine('Application launched. (v2)');
runApp(const MyApp());
}
class StringBufferWrapper with ChangeNotifier {
final StringBuffer _buffer = StringBuffer();
void writeln(String line) {
_buffer.writeln(line);
notifyListeners();
}
@override
String toString() => _buffer.toString();
}
class ShortFormatter extends LogRecordFormatter {
@override
StringBuffer formatToStringBuffer(LogRecord rec, StringBuffer sb) {
sb.write(
'${rec.time.hour}:${rec.time.minute}:${rec.time.second} ${rec.level.name} '
'${rec.message}',
);
if (rec.error != null) {
sb.write(rec.error);
}
// ignore: avoid_as
final stackTrace =
rec.stackTrace ??
(rec.error is Error ? (rec.error as Error).stackTrace : null);
if (stackTrace != null) {
sb.write(stackTrace);
}
return sb;
}
}
class MemoryAppender extends BaseLogAppender {
MemoryAppender() : super(ShortFormatter());
final StringBufferWrapper log = StringBufferWrapper();
@override
void handle(LogRecord record) {
log.writeln(formatter.format(record));
}
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
MyAppState createState() => MyAppState();
}
class MyAppState extends State<MyApp> {
final String baseName = 'default';
static final _authStorageInitOptions = StorageFileInitOptions();
static final _customPromptInitOptions = StorageFileInitOptions(
androidBiometricOnly: false,
androidAuthenticationValidityDuration: const Duration(seconds: 5),
darwinBiometricOnly: false,
darwinTouchIDAuthenticationForceReuseContextDuration: const Duration(
seconds: 5,
),
androidUseStrongBox: false,
);
BiometricStorageFile? _authStorage;
BiometricStorageFile? _storage;
BiometricStorageFile? _customPrompt;
final TextEditingController _writeController = TextEditingController(
text: 'Lorem Ipsum',
);
@override
void initState() {
super.initState();
logMessages.log.addListener(_logChanged);
// _checkAuthenticate();
}
@override
void dispose() {
logMessages.log.removeListener(_logChanged);
super.dispose();
}
Future<CanAuthenticateResponse> _checkAuthenticate(
StorageFileInitOptions? options,
) async {
final response = await BiometricStorage().canAuthenticate(options: options);
_logger.info('checked if authentication was possible: $response');
return response;
}
Future<void> _logLoginFlowGuidance() async {
final storage = BiometricStorage();
final isSupported = await storage.isSupported(
options: _authStorageInitOptions,
);
if (!isSupported) {
_logger.info(
'Biometric login is not supported on this platform. '
'Skip directly to regular login without error.',
);
return;
}
final response = await storage.canAuthenticate(
options: _authStorageInitOptions,
);
if (response.canAuthenticateWithBiometrics) {
_logger.info(
'Biometric login is available. On app start, attempt to read the '
'stored credential and let the platform prompt automatically.',
);
return;
}
_logger.info(
'Biometric-backed storage is supported, but biometrics are not currently '
'available ($response). Fall back to regular login and optionally offer '
'an opt-in toggle after password success.',
);
}
void _logChanged() => setState(() {});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Plugin example app')),
body: Column(
children: [
const Text('Methods:'),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ElevatedButton(
child: const Text('init'),
onPressed: () async {
_logger.finer('Initializing $baseName');
await _logLoginFlowGuidance();
final storageApi = BiometricStorage();
final authStorageSupported = await storageApi.isSupported(
options: _authStorageInitOptions,
);
final authStorageSupport = await _checkAuthenticate(
_authStorageInitOptions,
);
if (!authStorageSupported) {
_logger.severe(
'Unable to use authenticate. Unable to get storage.',
);
return;
}
final supportsAuthenticated =
authStorageSupport.canAuthenticateWithBiometrics;
if (supportsAuthenticated) {
_authStorage = await storageApi.getStorageIfSupported(
'${baseName}_authenticated',
options: _authStorageInitOptions,
);
}
_storage = await storageApi.getStorageIfSupported(
'${baseName}_unauthenticated',
options: StorageFileInitOptions(
authenticationRequired: false,
),
);
final supportsCustomPrompt = await _checkAuthenticate(
_customPromptInitOptions,
);
if (supportsCustomPrompt.canAuthenticateWithBiometrics) {
_customPrompt = await storageApi.getStorageIfSupported(
'${baseName}_customPrompt',
options: _customPromptInitOptions,
promptInfo: const PromptInfo(
iosPromptInfo: IosPromptInfo(
saveTitle: 'Custom save title',
accessTitle: 'Custom access title.',
),
androidPromptInfo: AndroidPromptInfo(
title: 'Custom title',
subtitle: 'Custom subtitle',
description: 'Custom description',
negativeButton: 'Nope!',
),
),
);
}
setState(() {});
_logger.info('initialized $baseName');
},
),
ElevatedButton(
child: const Text('Delete and dispose all'),
onPressed: () async {
_logger.info('Deleting and disposing all storages');
try {
if (_authStorage != null) {
await _authStorage!.deleteAndDispose();
_authStorage = null;
_logger.finer(
'Deleted and disposed authenticated storage',
);
}
if (_storage != null) {
await _storage!.deleteAndDispose();
_storage = null;
_logger.finer(
'Deleted and disposed unauthenticated storage',
);
}
if (_customPrompt != null) {
await _customPrompt!.deleteAndDispose();
_customPrompt = null;
_logger.finer(
'Deleted and disposed custom prompt storage',
);
}
setState(() {});
_logger.info('All storages deleted and disposed');
} catch (e) {
_logger.severe(
'Error deleting and disposing storages: $e',
);
}
},
),
],
),
...?_appArmorButton(),
...(_authStorage == null
? []
: [
const Text(
'Biometric Authentication',
style: TextStyle(fontWeight: FontWeight.bold),
),
StorageActions(
storageFile: _authStorage!,
writeController: _writeController,
),
const Divider(),
]),
...?(_storage == null
? null
: [
const Text(
'Unauthenticated',
style: TextStyle(fontWeight: FontWeight.bold),
),
StorageActions(
storageFile: _storage!,
writeController: _writeController,
),
const Divider(),
]),
...?(_customPrompt == null
? null
: [
const Text(
'Custom Prompts w/ 5s auth validity',
style: TextStyle(fontWeight: FontWeight.bold),
),
StorageActions(
storageFile: _customPrompt!,
writeController: _writeController,
),
const Divider(),
]),
const Divider(),
TextField(
decoration: const InputDecoration(
labelText: 'Example text to write',
),
controller: _writeController,
),
Expanded(
child: Container(
color: Colors.white,
constraints: const BoxConstraints.expand(),
child: SingleChildScrollView(
reverse: true,
child: Container(
padding: const EdgeInsets.all(16),
child: Text(logMessages.log.toString()),
),
),
),
),
],
),
),
);
}
List<Widget>? _appArmorButton() => kIsWeb || !Platform.isLinux
? null
: [
ElevatedButton(
child: const Text('Check App Armor'),
onPressed: () async {
if (await BiometricStorage().linuxCheckAppArmorError()) {
_logger.info(
'Got an error! User has to authorize us to '
'use secret service.',
);
_logger.info(
'Run: `snap connect webauthn-secure-storage-example:password-manager-service`',
);
} else {
_logger.info('all good.');
}
},
),
];
}
class StorageActions extends StatelessWidget {
const StorageActions({
super.key,
required this.storageFile,
required this.writeController,
});
final BiometricStorageFile storageFile;
final TextEditingController writeController;
@override
Widget build(BuildContext context) {
return Wrap(
children: <Widget>[
ElevatedButton(
child: const Text('read'),
onPressed: () async {
_logger.fine('reading from ${storageFile.name}');
try {
final result = await storageFile.read();
_logger.fine('read: {$result}');
} on AuthException catch (e) {
if (e.code == AuthExceptionCode.userCanceled) {
_logger.info('User canceled.');
return;
}
rethrow;
}
},
),
ElevatedButton(
child: const Text('read with force'),
onPressed: () async {
_logger.fine(
'reading with forceBiometricAuthentication from ${storageFile.name}',
);
try {
final result = await storageFile.read(
forceBiometricAuthentication: true,
);
_logger.fine('read: {$result}');
} on AuthException catch (e) {
if (e.code == AuthExceptionCode.userCanceled) {
_logger.info('User canceled.');
return;
}
rethrow;
}
},
),
ElevatedButton(
child: const Text('write'),
onPressed: () async {
_logger.fine('Going to write...');
try {
await storageFile.write(
' [${DateTime.now()}] ${writeController.text}',
);
_logger.info('Written content.');
} on AuthException catch (e) {
if (e.code == AuthExceptionCode.userCanceled) {
_logger.info('User canceled.');
return;
}
rethrow;
}
},
),
ElevatedButton(
child: const Text('write with forceBiometricAuthentication'),
onPressed: () async {
_logger.fine('Going to write with force...');
try {
await storageFile.write(
' [${DateTime.now()}] ${writeController.text}',
forceBiometricAuthentication: true,
);
_logger.info('Written content.');
} on AuthException catch (e) {
if (e.code == AuthExceptionCode.userCanceled) {
_logger.info('User canceled.');
return;
}
rethrow;
}
},
),
ElevatedButton(
child: const Text('delete'),
onPressed: () async {
_logger.fine('deleting...');
await storageFile.delete();
_logger.info('Deleted.');
},
),
],
);
}
}