From ec54579921c35ee289e432af0dd0b2d52a1b4f78 Mon Sep 17 00:00:00 2001 From: Arseni Date: Tue, 23 Dec 2025 14:56:47 +0300 Subject: [PATCH] Implemented cooldown before User is able to resend confirmation code for 2fa --- .../lib/api/responses/confirmation.dart | 26 ++++++ .../lib/models/auth/pending_login.dart | 30 +++++++ frontend/pshared/lib/provider/account.dart | 26 +++++- .../pshared/lib/service/verification.dart | 11 ++- frontend/pweb/lib/pages/2fa/resend.dart | 28 ++++++- frontend/pweb/lib/providers/two_factor.dart | 84 ++++++++++++++++++- 6 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 frontend/pshared/lib/api/responses/confirmation.dart diff --git a/frontend/pshared/lib/api/responses/confirmation.dart b/frontend/pshared/lib/api/responses/confirmation.dart new file mode 100644 index 0000000..ff5640e --- /dev/null +++ b/frontend/pshared/lib/api/responses/confirmation.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'confirmation.g.dart'; + +@JsonSerializable() +class ConfirmationResponse { + @JsonKey(name: 'ttl_seconds', defaultValue: 0) + final int ttlSeconds; + @JsonKey(name: 'cooldown_seconds', defaultValue: 0) + final int cooldownSeconds; + @JsonKey(defaultValue: '') + final String destination; + + const ConfirmationResponse({ + required this.ttlSeconds, + required this.cooldownSeconds, + required this.destination, + }); + + Duration get cooldownDuration => Duration(seconds: cooldownSeconds); + Duration get ttlDuration => Duration(seconds: ttlSeconds); + + factory ConfirmationResponse.fromJson(Map json) => _$ConfirmationResponseFromJson(json); + + Map toJson() => _$ConfirmationResponseToJson(this); +} diff --git a/frontend/pshared/lib/models/auth/pending_login.dart b/frontend/pshared/lib/models/auth/pending_login.dart index 3585bcc..0526cda 100644 --- a/frontend/pshared/lib/models/auth/pending_login.dart +++ b/frontend/pshared/lib/models/auth/pending_login.dart @@ -11,6 +11,8 @@ class PendingLogin { final String destination; final int ttlSeconds; final SessionIdentifier session; + final int? cooldownSeconds; + final DateTime? cooldownUntil; const PendingLogin({ required this.account, @@ -18,6 +20,8 @@ class PendingLogin { required this.destination, required this.ttlSeconds, required this.session, + this.cooldownSeconds, + this.cooldownUntil, }); factory PendingLogin.fromResponse( @@ -30,4 +34,30 @@ class PendingLogin { ttlSeconds: response.ttlSeconds, session: session, ); + + PendingLogin copyWith({ + Account? account, + TokenData? pendingToken, + String? destination, + int? ttlSeconds, + SessionIdentifier? session, + int? cooldownSeconds, + DateTime? cooldownUntil, + bool clearCooldown = false, + }) { + return PendingLogin( + account: account ?? this.account, + pendingToken: pendingToken ?? this.pendingToken, + destination: destination ?? this.destination, + ttlSeconds: ttlSeconds ?? this.ttlSeconds, + session: session ?? this.session, + cooldownSeconds: clearCooldown ? null : cooldownSeconds ?? this.cooldownSeconds, + cooldownUntil: clearCooldown ? null : cooldownUntil ?? this.cooldownUntil, + ); + } + + int get cooldownRemainingSeconds { + final remaining = cooldownUntil?.difference(DateTime.now()).inSeconds ?? 0; + return remaining < 0 ? 0 : remaining; + } } diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index 4e17dc7..7376394 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -10,6 +10,7 @@ import 'package:pshared/api/requests/signup.dart'; import 'package:pshared/api/requests/login_data.dart'; import 'package:pshared/config/constants.dart'; import 'package:pshared/models/account/account.dart'; +import 'package:pshared/api/responses/confirmation.dart'; import 'package:pshared/models/auth/login_outcome.dart'; import 'package:pshared/models/auth/pending_login.dart'; import 'package:pshared/models/describable.dart'; @@ -101,8 +102,8 @@ class AccountProvider extends ChangeNotifier { if (pending == null) { throw Exception('Pending login data is missing'); } - await VerificationService.requestLoginCode(pending); - _pendingLogin = pending; + final confirmation = await VerificationService.requestLoginCode(pending); + _pendingLogin = _applyConfirmationMeta(pending, confirmation); _authState = AuthState.idle; _setResource(_resource.copyWith(isLoading: false)); } @@ -114,6 +115,27 @@ class AccountProvider extends ChangeNotifier { } } + PendingLogin _applyConfirmationMeta(PendingLogin pending, ConfirmationResponse confirmation) { + final ttlSeconds = confirmation.ttlSeconds != 0 ? confirmation.ttlSeconds : pending.ttlSeconds; + final destination = confirmation.destination.isNotEmpty ? confirmation.destination : pending.destination; + final cooldownSeconds = confirmation.cooldownSeconds; + + return pending.copyWith( + ttlSeconds: ttlSeconds, + destination: destination, + cooldownSeconds: cooldownSeconds > 0 ? cooldownSeconds : null, + cooldownUntil: cooldownSeconds > 0 ? DateTime.now().add(confirmation.cooldownDuration) : null, + clearCooldown: cooldownSeconds <= 0, + ); + } + + void updatePendingLogin(ConfirmationResponse confirmation) { + final pending = _pendingLogin; + if (pending == null) return; + _pendingLogin = _applyConfirmationMeta(pending, confirmation); + notifyListeners(); + } + void completePendingLogin(Account account) { _pendingLogin = null; _authState = AuthState.ready; diff --git a/frontend/pshared/lib/service/verification.dart b/frontend/pshared/lib/service/verification.dart index 6f4b6d4..ca318c8 100644 --- a/frontend/pshared/lib/service/verification.dart +++ b/frontend/pshared/lib/service/verification.dart @@ -5,6 +5,7 @@ import 'package:pshared/api/responses/login.dart'; import 'package:pshared/data/mapper/session_identifier.dart'; import 'package:pshared/models/account/account.dart'; import 'package:pshared/data/mapper/account/account.dart'; +import 'package:pshared/api/responses/confirmation.dart'; import 'package:pshared/models/auth/pending_login.dart'; import 'package:pshared/service/authorization/storage.dart'; import 'package:pshared/service/services.dart'; @@ -15,24 +16,26 @@ class VerificationService { static final _logger = Logger('service.verification'); static const String _objectType = Services.confirmations; - static Future requestLoginCode(PendingLogin pending, {String? destination}) async { + static Future requestLoginCode(PendingLogin pending, {String? destination}) async { _logger.fine('Requesting login confirmation code'); - await getPOSTResponse( + final response = await getPOSTResponse( _objectType, '', LoginConfirmationRequest(destination: destination).toJson(), authToken: pending.pendingToken.token, ); + return ConfirmationResponse.fromJson(response); } - static Future resendLoginCode(PendingLogin pending, {String? destination}) async { + static Future resendLoginCode(PendingLogin pending, {String? destination}) async { _logger.fine('Resending login confirmation code'); - await getPOSTResponse( + final response = await getPOSTResponse( _objectType, '/resend', LoginConfirmationRequest(destination: destination).toJson(), authToken: pending.pendingToken.token, ); + return ConfirmationResponse.fromJson(response); } static Future confirmLoginCode({ diff --git a/frontend/pweb/lib/pages/2fa/resend.dart b/frontend/pweb/lib/pages/2fa/resend.dart index 13eb971..63d4b44 100644 --- a/frontend/pweb/lib/pages/2fa/resend.dart +++ b/frontend/pweb/lib/pages/2fa/resend.dart @@ -13,9 +13,15 @@ class ResendCodeButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final localizations = AppLocalizations.of(context)!; + final provider = context.watch(); + final isDisabled = provider.isCooldownActive || provider.isResending; + + final label = provider.isCooldownActive + ? '${localizations.twoFactorResend} (${_formatCooldown(provider.cooldownRemainingSeconds)})' + : localizations.twoFactorResend; return TextButton( - onPressed: () => context.read().resendCode(), + onPressed: isDisabled ? null : () => provider.resendCode(), style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: const Size(0, 0), @@ -26,7 +32,25 @@ class ResendCodeButton extends StatelessWidget { decoration: TextDecoration.underline, ), ), - child: Text(localizations.twoFactorResend), + child: provider.isResending + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + : Text(label), ); } + + String _formatCooldown(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + if (minutes > 0) { + return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}'; + } + return remainingSeconds.toString(); + } } diff --git a/frontend/pweb/lib/providers/two_factor.dart b/frontend/pweb/lib/providers/two_factor.dart index de821fe..33f8612 100644 --- a/frontend/pweb/lib/providers/two_factor.dart +++ b/frontend/pweb/lib/providers/two_factor.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -14,15 +16,21 @@ class TwoFactorProvider extends ChangeNotifier { TwoFactorProvider(); bool _isSubmitting = false; + bool _isResending = false; bool _hasError = false; bool _verificationSuccess = false; String? _errorMessage; String? _currentPendingToken; + Timer? _cooldownTimer; + int _cooldownRemainingSeconds = 0; bool get isSubmitting => _isSubmitting; + bool get isResending => _isResending; bool get hasError => _hasError; bool get verificationSuccess => _verificationSuccess; String? get errorMessage => _errorMessage; + int get cooldownRemainingSeconds => _cooldownRemainingSeconds; + bool get isCooldownActive => _cooldownRemainingSeconds > 0; PendingLogin? get pendingLogin => _accountProvider.pendingLogin; void update(AccountProvider accountProvider) { @@ -33,6 +41,7 @@ class TwoFactorProvider extends ChangeNotifier { _resetState(); _currentPendingToken = token; } + _syncCooldown(pending); } Future submitCode(String code) async { @@ -70,12 +79,23 @@ class TwoFactorProvider extends ChangeNotifier { _logger.warning('No pending login to resend code for'); return; } + if (_isResending || isCooldownActive) return; + + _isResending = true; + _hasError = false; + _errorMessage = null; + notifyListeners(); + try { - await VerificationService.resendLoginCode(pending); + final confirmation = await VerificationService.resendLoginCode(pending); + _accountProvider.updatePendingLogin(confirmation); + _startCooldown(confirmation.cooldownSeconds); } catch (e) { _logger.warning('Failed to resend login code', e); _hasError = true; _errorMessage = e.toString(); + } finally { + _isResending = false; notifyListeners(); } } @@ -87,9 +107,71 @@ class TwoFactorProvider extends ChangeNotifier { void _resetState() { _isSubmitting = false; + _isResending = false; _hasError = false; _errorMessage = null; _verificationSuccess = false; + _stopCooldown(); notifyListeners(); } + + void _syncCooldown(PendingLogin? pending) { + if (pending == null) { + _stopCooldown(notify: _cooldownRemainingSeconds != 0); + return; + } + + final remaining = pending.cooldownRemainingSeconds; + if (remaining <= 0) { + _stopCooldown(notify: _cooldownRemainingSeconds != 0); + return; + } + + if (_cooldownRemainingSeconds != remaining) { + _startCooldown(remaining); + } + } + + void _startCooldown(int seconds) { + _cooldownTimer?.cancel(); + _cooldownRemainingSeconds = seconds; + + if (_cooldownRemainingSeconds <= 0) { + _cooldownTimer = null; + notifyListeners(); + return; + } + + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_cooldownRemainingSeconds <= 1) { + _cooldownRemainingSeconds = 0; + _cooldownTimer?.cancel(); + _cooldownTimer = null; + notifyListeners(); + return; + } + + _cooldownRemainingSeconds -= 1; + notifyListeners(); + }); + + notifyListeners(); + } + + void _stopCooldown({bool notify = false}) { + _cooldownTimer?.cancel(); + _cooldownTimer = null; + final hadCooldown = _cooldownRemainingSeconds != 0; + _cooldownRemainingSeconds = 0; + + if (notify && hadCooldown) { + notifyListeners(); + } + } + + @override + void dispose() { + _stopCooldown(); + super.dispose(); + } } -- 2.49.1