246 lines
7.7 KiB
Dart
246 lines
7.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../dto/backend.dart';
|
|
import '../dto/results.dart';
|
|
import '../mappers/backend.dart';
|
|
import '../mappers/results.dart';
|
|
import '../models/auth.dart';
|
|
import '../models/session.dart';
|
|
import '../models/two_factor.dart';
|
|
import '../models/verification.dart';
|
|
import 'contracts.dart';
|
|
import 'guarded_login.dart';
|
|
import 'http/client.dart';
|
|
import 'two_factor.dart';
|
|
import 'verification.dart';
|
|
|
|
/// Real authentication; wallet/settings operations remain separate adapters.
|
|
class BackendAuthServices
|
|
implements
|
|
PendingLoginService,
|
|
SignupService,
|
|
VerificationService,
|
|
TwoFactorService,
|
|
SimulationInfo {
|
|
BackendAuthServices({
|
|
required this.baseUri,
|
|
required this.clientId,
|
|
http.Client? client,
|
|
}) : _client = client ?? createClient() {
|
|
if (clientId.isEmpty ||
|
|
!baseUri.hasAuthority ||
|
|
(baseUri.scheme != 'https' &&
|
|
!(baseUri.scheme == 'http' &&
|
|
['localhost', '127.0.0.1'].contains(baseUri.host))) ||
|
|
baseUri.hasQuery ||
|
|
baseUri.hasFragment ||
|
|
baseUri.userInfo.isNotEmpty) {
|
|
throw ArgumentError('Valid API URL and client ID required');
|
|
}
|
|
}
|
|
final Uri baseUri;
|
|
final String clientId;
|
|
final http.Client _client;
|
|
final String _deviceId = _key();
|
|
final _pending = <String, ({String token, String challengeId})>{};
|
|
final _grants = <String, AuthSession>{};
|
|
final _retryKeys = <String, String>{};
|
|
AuthSession? _session;
|
|
AuthSession? get session => _session;
|
|
@override
|
|
bool get isSimulated => false;
|
|
static String _key() => base64UrlEncode(
|
|
List<int>.generate(24, (_) => Random.secure().nextInt(256)),
|
|
);
|
|
Future<BackendDTO> _post(
|
|
String path,
|
|
Map<String, Object?> body, {
|
|
bool factor = false,
|
|
bool verification = false,
|
|
}) async {
|
|
try {
|
|
final uri = baseUri.replace(
|
|
path: '${baseUri.path.replaceFirst(RegExp(r'/$'), '')}/accounts/$path',
|
|
);
|
|
final res = await _client
|
|
.post(
|
|
uri,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
if (kIsWeb) 'X-Auth-Transport': 'cookie',
|
|
if (kIsWeb) 'X-CSRF-Protection': '1',
|
|
},
|
|
body: jsonEncode(body),
|
|
)
|
|
.timeout(const Duration(seconds: 15));
|
|
final dto = BackendDTO.decode(res.body);
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
if (verification) throw BackendMapper.verificationFailure(dto);
|
|
throw factor
|
|
? BackendMapper.factorFailure(res.statusCode, dto)
|
|
: BackendMapper.authFailure(res.statusCode, dto);
|
|
}
|
|
return dto;
|
|
} on AuthException {
|
|
rethrow;
|
|
} on FactorException {
|
|
rethrow;
|
|
} on VerificationException {
|
|
rethrow;
|
|
} catch (_) {
|
|
if (verification)
|
|
throw const VerificationException(VerificationError.internal);
|
|
if (factor) throw const FactorException(FactorError.unavailable);
|
|
throw const AuthException(AuthError.unavailable);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<AuthResult> signup(SignupRequest r) async {
|
|
final email = r.email.trim().toLowerCase();
|
|
final key = _retryKeys.putIfAbsent('signup:$email', _key);
|
|
final dto = await _post('signup', {
|
|
'name': r.name,
|
|
'email': email,
|
|
'password': r.password,
|
|
'locale': r.locale,
|
|
'idempotencyKey': key,
|
|
});
|
|
final result = BackendMapper.signup(dto, email: email);
|
|
_retryKeys.remove('signup:$email');
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Future<AuthResult> login(LoginRequest r) async {
|
|
_pending.clear();
|
|
_grants.clear();
|
|
_session = null;
|
|
final dto = await _post('login', {
|
|
'login': {'login': r.email, 'password': r.password},
|
|
'clientId': clientId,
|
|
'deviceId': _deviceId,
|
|
});
|
|
final id = _key();
|
|
final challenge = BackendMapper.challenge(dto, id);
|
|
_pending[id] = (token: dto.pendingToken, challengeId: dto.challengeId);
|
|
return AuthResult(userId: '', isSimulated: false, challenge: challenge);
|
|
}
|
|
|
|
@override
|
|
Future<VerificationResult> send({
|
|
required String email,
|
|
required String locale,
|
|
}) async {
|
|
final normalized = email.trim().toLowerCase();
|
|
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
|
try {
|
|
await _post('verification/send', {
|
|
'email': normalized,
|
|
'idempotencyKey': key,
|
|
}, verification: true);
|
|
_retryKeys.remove('activation:$normalized');
|
|
return VerificationMapper.fromDTO(
|
|
const VerificationDTO(simulated: false),
|
|
);
|
|
} on VerificationException catch (e) {
|
|
if (e.code == VerificationError.expired ||
|
|
e.code == VerificationError.invalid) {
|
|
_retryKeys.remove('activation:$normalized');
|
|
}
|
|
rethrow;
|
|
} catch (_) {
|
|
throw const VerificationException(VerificationError.internal);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<VerificationResult> confirm({required String token}) async {
|
|
final dto = await _post('verification/confirm', {
|
|
'token': token,
|
|
}, verification: true);
|
|
return BackendMapper.activation(dto);
|
|
}
|
|
|
|
@override
|
|
Future<FactorChallenge> resend(String challengeId) async {
|
|
final pending = _pending[challengeId];
|
|
if (pending == null) throw const FactorException(FactorError.expired);
|
|
final key = _retryKeys.putIfAbsent('login:$challengeId', _key);
|
|
final dto = await _post('login/resend', {
|
|
'pendingToken': pending.token,
|
|
'challengeId': pending.challengeId,
|
|
'idempotencyKey': key,
|
|
}, factor: true);
|
|
final challenge = BackendMapper.challenge(dto, challengeId);
|
|
if (dto.challengeId != pending.challengeId) {
|
|
throw const FactorException(FactorError.unavailable);
|
|
}
|
|
_retryKeys.remove('login:$challengeId');
|
|
return challenge;
|
|
}
|
|
|
|
@override
|
|
Future<FactorGrant> verify(String challengeId, String code) async {
|
|
final pending = _pending[challengeId];
|
|
if (pending == null) throw const FactorException(FactorError.expired);
|
|
final dto = await _post('login/verify', {
|
|
'pendingToken': pending.token,
|
|
'challengeId': pending.challengeId,
|
|
'code': code,
|
|
}, factor: true);
|
|
final session = BackendMapper.session(dto);
|
|
_pending.remove(challengeId);
|
|
_grants[challengeId] = session;
|
|
return FactorGrant(challengeId);
|
|
}
|
|
|
|
@override
|
|
Future<AuthResult> completeLogin(
|
|
FactorChallenge challenge,
|
|
FactorGrant grant,
|
|
) async {
|
|
if (challenge.id != grant.token ||
|
|
challenge.purpose != FactorPurpose.login) {
|
|
throw const AuthException(AuthError.factorRequired);
|
|
}
|
|
final accepted = _grants.remove(grant.token);
|
|
if (accepted == null) throw const AuthException(AuthError.factorRequired);
|
|
_session = accepted;
|
|
return AuthResult(userId: accepted.userId, isSimulated: false);
|
|
}
|
|
|
|
// Unsupported settings and money operations fail closed until their own
|
|
// backend adapters exist. They cannot borrow an authentication grant.
|
|
@override
|
|
Future<FactorSettings> fetch() async =>
|
|
throw const FactorException(FactorError.unavailable);
|
|
@override
|
|
Future<FactorChallenge?> begin(
|
|
FactorPurpose purpose,
|
|
String operation,
|
|
) async => throw const FactorException(FactorError.unavailable);
|
|
@override
|
|
Future<void> authorize(
|
|
FactorPurpose purpose,
|
|
String operation,
|
|
String? grant,
|
|
) async => throw const FactorException(FactorError.required);
|
|
@override
|
|
Future<FactorSettings> setEnabled(bool enabled, String grant) async =>
|
|
throw const FactorException(FactorError.unavailable);
|
|
void close() {
|
|
_client.close();
|
|
_pending.clear();
|
|
_grants.clear();
|
|
_retryKeys.clear();
|
|
_session = null;
|
|
}
|
|
}
|