Files
sxchnge/frontend/packages/sxchnge_auth/lib/src/services/backend.dart
T

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;
}
}