Add reusable two-factor verification for login and money operations (#32)

This commit is contained in:
Stephan D committed 2026-09-12 12:44:34 +02:00
1 parent b7a58b1a87
commit fb40f860d2
41 files changed
+2439 -43

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../generated/i18n/app_localizations.dart';
class FactorRouteRequest {
const FactorRouteRequest(this.provider, this.summary);
final TwoFactorProvider provider;
final Widget? summary;
}
Future<FactorGrant?> verifyFactor(
BuildContext context, {
required FactorChallenge challenge,
required TwoFactorService service,
required JourneyRecorder recorder,
required JourneyKind journey,
Widget? summary,
}) async {
final provider = TwoFactorProvider(
service,
challenge,
recorder: recorder,
journey: journey,
);
FactorGrant? result;
try {
result = await context.push<FactorGrant>(
'/two-factor',
extra: FactorRouteRequest(provider, summary),
);
return result;
} finally {
if (result == null) {
final step = recorder.begin(
journey,
JourneyStep.factorVerification,
simulated: service.isSimulated,
);
recorder.finish(step, cancelled: true, simulated: service.isSimulated);
}
provider.dispose();
}
}
String factorErrorText(AppLocalizations l, FactorError error) =>
switch (error) {
FactorError.invalid => l.factorInvalid,
FactorError.expired => l.factorExpired,
FactorError.locked => l.factorLocked,
FactorError.cooldown => l.factorWait,
FactorError.required => l.factorRequired,
FactorError.unavailable => l.factorUnavailable,
};
class FactorRoute extends StatefulWidget {
const FactorRoute({super.key, required this.request});
final FactorRouteRequest request;
@override
State<FactorRoute> createState() => _FactorRouteState();
}
class _FactorRouteState extends State<FactorRoute> {
FactorRouteRequest get request => widget.request;
@override
void dispose() {
request.provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final purpose = request.provider.challenge.purpose;
final purposeText = switch (purpose) {
FactorPurpose.login => l.factorLogin,
FactorPurpose.enable => l.factorEnableHelp,
FactorPurpose.disable => l.factorDisableHelp,
_ => l.factorMoney,
};
final methodText = request.provider.challenge.method == FactorMethod.email
? l.factorEmail
: l.factorAuthenticator;
return TwoFactorPage(
provider: request.provider,
summary: request.summary,
labels: TwoFactorLabels(
title: l.factorTitle,
description: '$purposeText $methodText',
code: l.factorCode,
confirm: l.factorConfirm,
cancel: l.wClose,
resend: l.factorResend,
expired: request.provider.challenge.method == FactorMethod.email
? l.factorExpired
: l.factorAppExpired,
help: request.provider.challenge.method == FactorMethod.email
? l.factorHelp
: l.factorAppHelp,
errorText: (error) =>
error == FactorError.expired &&
request.provider.challenge.method == FactorMethod.authenticator
? l.factorAppExpired
: factorErrorText(l, error),
cooldownText: (seconds) => l.factorCooldown(seconds),
),
onVerified: (grant) => context.pop(grant),
onCancel: () => context.pop(),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'challenge.dart';
import '../generated/i18n/app_localizations.dart';
class SecuritySettings extends StatefulWidget {
const SecuritySettings({super.key});
@override
State<SecuritySettings> createState() => _SecuritySettingsState();
}
class _SecuritySettingsState extends State<SecuritySettings> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<SecurityProvider>().refresh();
});
}
Future<void> _change(SecurityProvider p, bool enabled) async {
final challenge = await p.prepare(enabled);
if (!mounted || challenge == null) return;
final grant = await verifyFactor(
context,
challenge: challenge,
service: p.service,
recorder: p.recorder,
journey: JourneyKind.security,
);
if (mounted && grant != null) await p.apply(enabled, grant);
}
@override
Widget build(BuildContext context) {
final p = context.watch<SecurityProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.factorSettings, style: t.textTheme.titleLarge),
const SizedBox(height: 12),
Text(l.factorSettingsHelp),
const SizedBox(height: 16),
if (p.busy) const LinearProgressIndicator(),
if (p.data != null) ...[
Semantics(
liveRegion: true,
child: Text(
p.data!.enabled ? l.factorOn : l.factorOff,
key: const ValueKey('two-factor-status'),
style: t.textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Text(
p.data!.method == FactorMethod.email
? l.factorEmailMethod
: l.factorAppMethod,
),
const SizedBox(height: 16),
FilledButton.tonal(
key: const ValueKey('two-factor-setting'),
onPressed: p.busy ? null : () => _change(p, !p.data!.enabled),
child: Text(p.data!.enabled ? l.factorDisable : l.factorEnable),
),
],
if (p.error != null) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
factorErrorText(l, p.error!),
style: TextStyle(color: t.colorScheme.error),
),
),
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.wRefresh),
),
],
],
),
),
);
}
}