New code verification service
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed

This commit is contained in:
Stephan D
2025-11-21 16:41:41 +01:00
parent ef5b3dc1a7
commit e1e4c580e8
72 changed files with 1660 additions and 454 deletions

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/token.dart';
part 'login_pending.g.dart';
@JsonSerializable(explicitToJson: true)
class PendingLoginResponse {
final AccountResponse account;
final TokenData pendingToken;
final String destination;
final int ttlSeconds;
const PendingLoginResponse({
required this.account,
required this.pendingToken,
required this.destination,
required this.ttlSeconds,
});
factory PendingLoginResponse.fromJson(Map<String, dynamic> json) => _$PendingLoginResponseFromJson(json);
Map<String, dynamic> toJson() => _$PendingLoginResponseToJson(this);
}

View File

@@ -0,0 +1,17 @@
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/auth/pending_login.dart';
class LoginOutcome {
final Account? account;
final PendingLogin? pending;
const LoginOutcome._({this.account, this.pending});
factory LoginOutcome.completed(Account account) => LoginOutcome._(account: account);
factory LoginOutcome.pending(PendingLogin pending) => LoginOutcome._(pending: pending);
bool get isPending => pending != null;
bool get isCompleted => account != null;
}

View File

@@ -0,0 +1,33 @@
import 'package:pshared/api/responses/login_pending.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/session_identifier.dart';
class PendingLogin {
final Account account;
final TokenData pendingToken;
final String destination;
final int ttlSeconds;
final SessionIdentifier session;
const PendingLogin({
required this.account,
required this.pendingToken,
required this.destination,
required this.ttlSeconds,
required this.session,
});
factory PendingLogin.fromResponse(
PendingLoginResponse response, {
required SessionIdentifier session,
}) => PendingLogin(
account: response.account.account.toDomain(),
pendingToken: response.pendingToken,
destination: response.destination,
ttlSeconds: response.ttlSeconds,
session: session,
);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'session_identifier.g.dart';
@JsonSerializable()
class SessionIdentifier {
final String clientId;
final String deviceId;
const SessionIdentifier({
required this.clientId,
required this.deviceId,
});
factory SessionIdentifier.fromJson(Map<String, dynamic> json) => _$SessionIdentifierFromJson(json);
Map<String, dynamic> toJson() => _$SessionIdentifierToJson(this);
}

View File

@@ -7,6 +7,8 @@ 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/models/auth/login_outcome.dart';
import 'package:pshared/models/auth/pending_login.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/storable.dart';
import 'package:pshared/provider/locale.dart';
@@ -23,8 +25,10 @@ class AccountProvider extends ChangeNotifier {
Resource<Account?> _resource = Resource(data: null);
Resource<Account?> get resource => _resource;
late LocaleProvider _localeProvider;
PendingLogin? _pendingLogin;
Account? get account => _resource.data;
PendingLogin? get pendingLogin => _pendingLogin;
bool get isLoggedIn => account != null;
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
@@ -57,27 +61,38 @@ class AccountProvider extends ChangeNotifier {
void _pickupLocale(String locale) => _localeProvider.setLocale(Locale(locale));
Future<Account> login({
Future<LoginOutcome> login({
required String email,
required String password,
required String locale,
}) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final acc = await AccountService.login(LoginData.build(
final outcome = await AccountService.login(LoginData.build(
login: email,
password: password,
locale: locale,
));
_setResource(Resource(data: acc, isLoading: false));
_pickupLocale(acc.locale);
return acc;
if (outcome.account != null) {
_setResource(Resource(data: outcome.account, isLoading: false));
_pickupLocale(outcome.account!.locale);
} else {
_pendingLogin = outcome.pending;
_setResource(_resource.copyWith(isLoading: false));
}
return outcome;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
void completePendingLogin(Account account) {
_pendingLogin = null;
_setResource(Resource(data: account, isLoading: false, error: null));
_pickupLocale(account.locale);
}
Future<bool> isAuthorizationStored() async => AuthorizationService.isAuthorizationStored();
Future<Account?> restore() async {

View File

@@ -8,9 +8,13 @@ import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/api/requests/password/change.dart';
import 'package:pshared/api/requests/password/forgot.dart';
import 'package:pshared/api/requests/password/reset.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/auth/login_outcome.dart';
import 'package:pshared/models/auth/pending_login.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/authorization/storage.dart';
import 'package:pshared/service/files.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/utils/http/requests.dart';
@@ -20,11 +24,46 @@ class AccountService {
static final _logger = Logger('service.account');
static const String _objectType = Services.account;
static Future<Account> login(LoginData login) async {
static Future<LoginOutcome> login(LoginData login) async {
_logger.fine('Logging in');
return AuthorizationService.login(_objectType, login);
}
static Future<void> resendLoginCode(PendingLogin pending, {String? destination}) async {
await getPOSTResponse(
_objectType,
'confirmations/resend',
{
'target': 'login',
if (destination != null) 'destination': destination,
},
authToken: pending.pendingToken.token,
);
}
static Future<Account> confirmLoginCode({
required PendingLogin pending,
required String code,
String? destination,
}) async {
final response = await getPOSTResponse(
_objectType,
'confirmations/verify',
{
'target': 'login',
'code': code,
if (destination != null) 'destination': destination,
'sessionIdentifier': pending.session.toJson(),
},
authToken: pending.pendingToken.token,
);
final loginResponse = LoginResponse.fromJson(response);
await AuthorizationStorage.updateToken(loginResponse.accessToken);
await AuthorizationStorage.updateRefreshToken(loginResponse.refreshToken);
return loginResponse.account.toDomain();
}
static Future<Account> restore() async {
return AuthorizationService.restore();
}

View File

@@ -5,9 +5,13 @@ import 'package:pshared/api/requests/login.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/api/responses/login_pending.dart';
import 'package:pshared/config/web.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/auth/login_outcome.dart';
import 'package:pshared/models/auth/pending_login.dart';
import 'package:pshared/models/session_identifier.dart';
import 'package:pshared/service/authorization/circuit_breaker.dart';
import 'package:pshared/service/authorization/retry_helper.dart';
import 'package:pshared/service/authorization/storage.dart';
@@ -22,7 +26,7 @@ import 'package:pshared/utils/http/requests.dart' as httpr;
class AuthorizationService {
static final _logger = Logger('service.authorization.auth_service');
static Future<Account> login(String service, LoginData login) async {
static Future<LoginOutcome> login(String service, LoginData login) async {
_logger.fine('Logging in ${login.login} with ${login.locale} locale');
final deviceId = await DeviceIdManager.getDeviceId();
final response = await httpr.getPOSTResponse(
@@ -31,7 +35,17 @@ class AuthorizationService {
LoginRequest(login: login, deviceId: deviceId, clientId: Constants.clientId).toJson(),
);
return (await _completeLogin(response)).account.toDomain();
if (response.containsKey('refreshToken')) {
return LoginOutcome.completed((await completeLogin(response)).account.toDomain());
}
if (response.containsKey('pendingToken')) {
final pending = PendingLogin.fromResponse(
PendingLoginResponse.fromJson(response),
session: SessionIdentifier(clientId: Constants.clientId, deviceId: deviceId),
);
return LoginOutcome.pending(pending);
}
throw AuthenticationFailedException('Unexpected login response', Exception(response.toString()));
}
static Future<void> _updateAccessToken(AccountResponse response) async {
@@ -49,6 +63,8 @@ class AuthorizationService {
return lr;
}
static Future<LoginResponse> completeLogin(Map<String, dynamic> response) => _completeLogin(response);
static Future<Account> restore() async {
return (await TokenService.refreshAccessToken()).account.toDomain();
}

View File

@@ -24,7 +24,6 @@ import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/services/amplitude.dart';
import 'package:pweb/services/auth.dart';
import 'package:pweb/services/balance.dart';
import 'package:pweb/services/payments/payment_methods.dart';
import 'package:pweb/services/payments/upload_history.dart';
@@ -53,17 +52,16 @@ void main() async {
runApp(
MultiProvider(
providers: [
Provider<AuthenticationService>(
create: (_) => AuthenticationService(),
),
ChangeNotifierProxyProvider<AuthenticationService, TwoFactorProvider>(
create: (context) => TwoFactorProvider(
context.read<AuthenticationService>(),
),
update: (context, authService, previous) => TwoFactorProvider(authService),
),
ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
ChangeNotifierProvider(create: (_) => AccountProvider()),
ChangeNotifierProxyProvider<AccountProvider, TwoFactorProvider>(
create: (context) => TwoFactorProvider(
accountProvider: context.read<AccountProvider>(),
),
update: (context, accountProvider, previous) => TwoFactorProvider(
accountProvider: accountProvider,
),
),
ChangeNotifierProvider(create: (_) => OrganizationsProvider()),
ChangeNotifierProvider(create: (_) => AccountProvider()),
ChangeNotifierProvider(create: (_) => CarouselIndexProvider()),

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/providers/two_factor.dart';
class ResendCodeButton extends StatelessWidget {
@@ -12,9 +15,7 @@ class ResendCodeButton extends StatelessWidget {
final localizations = AppLocalizations.of(context)!;
return TextButton(
onPressed: () {
// TODO: Add resend logic
},
onPressed: () => context.read<TwoFactorProvider>().resendCode(),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
@@ -28,4 +29,4 @@ class ResendCodeButton extends StatelessWidget {
child: Text(localizations.twoFactorResend),
);
}
}
}

View File

@@ -38,13 +38,16 @@ class _LoginFormState extends State<LoginForm> {
final provider = Provider.of<AccountProvider>(context, listen: false);
try {
//final account =
await provider.login(
final outcome = await provider.login(
email: _usernameController.text,
password: _passwordController.text,
locale: context.read<LocaleProvider>().locale.languageCode,
);
onLogin();
if (outcome.isPending) {
navigateAndReplace(context, Pages.sfactor);
} else {
onLogin();
}
return 'ok';
} catch (e) {
onError(provider.error ?? e);
@@ -92,7 +95,7 @@ class _LoginFormState extends State<LoginForm> {
onSignUp: () => navigate(context, Pages.signup),
login: () => _login(
context,
() => navigateAndReplace(context, Pages.sfactor),
() => navigateAndReplace(context, Pages.dashboard),
(e) => postNotifyUserOfErrorX(
context: context,
errorSituation: AppLocalizations.of(context)!.errorLogin,

View File

@@ -1,38 +1,69 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:pweb/services/auth.dart';
import 'package:pshared/models/auth/pending_login.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/service/account.dart';
class TwoFactorProvider extends ChangeNotifier {
final AuthenticationService _authService;
static final _logger = Logger('provider.two_factor');
final AccountProvider _accountProvider;
TwoFactorProvider(this._authService);
TwoFactorProvider({required AccountProvider accountProvider}) : _accountProvider = accountProvider;
bool _isSubmitting = false;
bool _hasError = false;
bool _verificationSuccess = false;
String? _errorMessage;
bool get isSubmitting => _isSubmitting;
bool get hasError => _hasError;
bool get verificationSuccess => _verificationSuccess;
String? get errorMessage => _errorMessage;
PendingLogin? get pendingLogin => _accountProvider.pendingLogin;
Future<void> submitCode(String code) async {
_isSubmitting = true;
_hasError = false;
_errorMessage = null;
_verificationSuccess = false;
notifyListeners();
try {
final success = await _authService.verifyTwoFactorCode(code);
if (success) {
_verificationSuccess = true;
final pending = _accountProvider.pendingLogin;
if (pending == null) {
throw Exception('No pending login available');
}
final account = await AccountService.confirmLoginCode(
pending: pending,
code: code,
);
_accountProvider.completePendingLogin(account);
_verificationSuccess = true;
} catch (e) {
_hasError = true;
_errorMessage = e.toString();
_logger.warning('Failed to verify code', e);
} finally {
_isSubmitting = false;
notifyListeners();
}
}
}
Future<void> resendCode() async {
final pending = _accountProvider.pendingLogin;
if (pending == null) {
_logger.warning('No pending login to resend code for');
return;
}
try {
await AccountService.resendLoginCode(pending);
} catch (e) {
_logger.warning('Failed to resend login code', e);
_hasError = true;
_errorMessage = e.toString();
notifyListeners();
}
}
}

View File

@@ -1,12 +0,0 @@
class AuthenticationService {
Future<bool> verifyTwoFactorCode(String code) async {
await Future.delayed(const Duration(seconds: 2));
if (code == '000000') {
return true;
} else {
throw Exception('Wrong Code'); //TODO Localize
}
}
}

View File

@@ -8,7 +8,6 @@ import Foundation
import amplitude_flutter
import file_selector_macos
import flutter_timezone
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
@@ -18,7 +17,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AmplitudeFlutterPlugin.register(with: registry.registrar(forPlugin: "AmplitudeFlutterPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))