Add reusable two-factor verification for login and money operations (#32)
This commit is contained in:
1 parent
b7a58b1a87
commit
fb40f860d2
41 files changed
+2439
-43
No files matched your search
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user