Skip to content
Merged
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
99 changes: 99 additions & 0 deletions lib/services/totp_vault.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'dart:convert';

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

import 'totp_service.dart';

/// One saved authenticator entry: a TOTP secret plus display metadata. The
/// [secretOrUri] is what [TotpService] consumes - a bare base32 secret or a full
/// `otpauth://` URI (as encoded in an authenticator QR code). Provider-agnostic:
/// any service's 2FA can live here. Held only in the device keystore, never
/// synced to a Schuly account.
class TotpEntry {
final String id;
final String secretOrUri;
final String? issuer;
final String? account;

const TotpEntry({required this.id, required this.secretOrUri, this.issuer, this.account});

/// The normalized base32 secret (what a backend `/login` expects), or null if
/// [secretOrUri] can't be parsed.
String? get secret => TotpService.secretOf(secretOrUri);

/// Primary display line - issuer if known, else the account, else a fallback.
String get title {
if (issuer != null && issuer!.isNotEmpty) return issuer!;
if (account != null && account!.isNotEmpty) return account!;
return 'Account';
}

/// Secondary display line - the account when a distinct issuer is shown.
String? get subtitle => (issuer != null && issuer!.isNotEmpty && account != null && account!.isNotEmpty) ? account : null;

Map<String, dynamic> toJson() => {'id': id, 'secretOrUri': secretOrUri, 'issuer': issuer, 'account': account};

factory TotpEntry.fromJson(Map<String, dynamic> json) => TotpEntry(
id: json['id'] as String,
secretOrUri: json['secretOrUri'] as String? ?? '',
issuer: json['issuer'] as String?,
account: json['account'] as String?,
);

/// Builds an entry from a raw scanned/typed [payload] (an `otpauth://` URI or
/// bare secret), pulling issuer/account from the URI when present. Returns null
/// when no usable TOTP secret can be extracted. [id] is caller-supplied so the
/// factory stays deterministic; pass a fresh unique value.
static TotpEntry? fromPayload(String id, String payload, {String? issuer, String? account}) {
final config = TotpConfig.tryParse(payload);
if (config == null) return null;
return TotpEntry(
id: id,
secretOrUri: payload.trim(),
issuer: (issuer != null && issuer.isNotEmpty) ? issuer : config.issuer,
account: (account != null && account.isNotEmpty) ? account : config.account,
);
}
}

/// Persists the list of authenticator entries in the platform keystore
/// (Android EncryptedSharedPreferences / iOS Keychain). Secrets never leave the
/// device.
class TotpVault {
TotpVault._();
static final TotpVault instance = TotpVault._();

static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
static const _key = 'authenticator.entries';

Future<List<TotpEntry>> load() async {
final raw = await _storage.read(key: _key);
if (raw == null) return [];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list.map((e) => TotpEntry.fromJson(e as Map<String, dynamic>)).toList();
} catch (_) {
return [];
}
}

Future<void> _saveAll(List<TotpEntry> entries) =>
_storage.write(key: _key, value: jsonEncode(entries.map((e) => e.toJson()).toList()));

/// Adds [entry], replacing any existing one with the same id, and returns it.
Future<TotpEntry> add(TotpEntry entry) async {
final entries = await load();
entries.removeWhere((e) => e.id == entry.id);
entries.add(entry);
await _saveAll(entries);
return entry;
}

Future<void> remove(String id) async {
final entries = await load();
entries.removeWhere((e) => e.id == id);
await _saveAll(entries);
}
}
142 changes: 142 additions & 0 deletions lib/ui/authenticator/add_totp_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';

import '../../services/totp_service.dart';
import '../../services/totp_vault.dart';
import 'totp_scan_screen.dart';

/// Adds a TOTP authenticator entry - either by scanning an `otpauth://` QR code
/// with the camera or by typing the setup key manually. Saves the entry to the
/// [TotpVault] and pops the created [TotpEntry] (or null if cancelled).
class AddTotpScreen extends StatefulWidget {
const AddTotpScreen({super.key});

@override
State<AddTotpScreen> createState() => _AddTotpScreenState();
}

class _AddTotpScreenState extends State<AddTotpScreen> {
final _issuerCtrl = TextEditingController();
final _accountCtrl = TextEditingController();
final _secretCtrl = TextEditingController();
bool _busy = false;
String? _error;

@override
void dispose() {
_issuerCtrl.dispose();
_accountCtrl.dispose();
_secretCtrl.dispose();
super.dispose();
}

/// Unique-enough id for a new entry (keystore is single-writer per device).
String _newId() => DateTime.now().microsecondsSinceEpoch.toString();

Future<void> _scan() async {
final scanned = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const TotpScanScreen()),
);
if (scanned == null || scanned.isEmpty || !mounted) return;
final entry = TotpEntry.fromPayload(_newId(), scanned);
if (entry == null) {
setState(() => _error = "That QR code doesn't contain a valid TOTP secret.");
return;
}
await _save(entry);
}

Future<void> _saveManual() async {
final secret = _secretCtrl.text.trim();
if (TotpConfig.tryParse(secret) == null) {
setState(() => _error = 'Enter a valid TOTP secret (base32) or otpauth:// URI.');
return;
}
final entry = TotpEntry.fromPayload(
_newId(),
secret,
issuer: _issuerCtrl.text.trim(),
account: _accountCtrl.text.trim(),
);
if (entry == null) {
setState(() => _error = 'Enter a valid TOTP secret (base32) or otpauth:// URI.');
return;
}
await _save(entry);
}

Future<void> _save(TotpEntry entry) async {
setState(() {
_busy = true;
_error = null;
});
try {
await TotpVault.instance.add(entry);
if (mounted) Navigator.of(context).pop(entry);
} catch (e) {
if (mounted) setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}

@override
Widget build(BuildContext context) {
final colors = context.theme.colors;
final typography = context.theme.typography;
return FScaffold(
header: FHeader.nested(
title: const Text('Add authenticator'),
prefixes: [FHeaderAction.back(onPress: () => Navigator.of(context).pop())],
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [
FButton(
onPress: _busy ? null : _scan,
prefix: const Icon(FIcons.scanQrCode),
child: const Text('Scan QR code'),
),
Row(
children: [
const Expanded(child: FDivider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text('or enter manually', style: typography.sm.copyWith(color: colors.mutedForeground)),
),
const Expanded(child: FDivider()),
],
),
FTextField(
control: FTextFieldControl.managed(controller: _issuerCtrl),
label: const Text('Provider (optional)'),
hint: 'e.g. Schulnetz, GitHub',
autocorrect: false,
),
FTextField(
control: FTextFieldControl.managed(controller: _accountCtrl),
label: const Text('Account (optional)'),
hint: 'e.g. your username or email',
autocorrect: false,
),
FTextField(
control: FTextFieldControl.managed(controller: _secretCtrl),
label: const Text('Setup key'),
hint: 'TOTP secret or otpauth:// URI',
autocorrect: false,
),
FButton(
style: FButtonStyle.outline(),
onPress: _busy ? null : _saveManual,
child: Text(_busy ? 'Saving…' : 'Save'),
),
if (_error != null)
SelectableText(_error!, style: TextStyle(color: colors.destructive)),
],
),
),
);
}
}
Loading
Loading