Integrate email notifications into signup and login (#38)
This commit is contained in:
1 parent
326d58f705
commit
ad5a061164
55 files changed
+1859
-3464
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class BackendDTO {
|
||||
BackendDTO(this.data);
|
||||
factory BackendDTO.decode(String body) => BackendDTO(
|
||||
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
||||
);
|
||||
final Map<String, dynamic> data;
|
||||
String get error => data['error'] as String? ?? 'unavailable';
|
||||
String get userId =>
|
||||
data['userId'] as String? ??
|
||||
(data['account'] as Map<String, dynamic>?)?['id'] as String? ??
|
||||
'';
|
||||
String get pendingToken =>
|
||||
(data['pendingToken'] as Map<String, dynamic>)['token'] as String;
|
||||
String get target => data['target'] as String;
|
||||
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
|
||||
DateTime get resendAt => DateTime.parse(data['resendAt'] as String);
|
||||
String get accessToken =>
|
||||
(data['accessToken'] as Map<String, dynamic>)['token'] as String;
|
||||
String get refreshToken =>
|
||||
(data['refreshToken'] as Map<String, dynamic>)['token'] as String;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import '../dto/backend.dart';
|
||||
import '../models/auth.dart';
|
||||
import '../models/session.dart';
|
||||
import '../models/two_factor.dart';
|
||||
|
||||
abstract final class BackendMapper {
|
||||
static AuthResult signup(BackendDTO dto) {
|
||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
||||
}
|
||||
|
||||
static FactorChallenge challenge(BackendDTO dto, String id) =>
|
||||
FactorChallenge(
|
||||
id: id,
|
||||
purpose: FactorPurpose.login,
|
||||
operation: id,
|
||||
target: dto.target,
|
||||
expiresAt: dto.expiresAt,
|
||||
resendAt: dto.resendAt,
|
||||
);
|
||||
static AuthSession session(BackendDTO dto) {
|
||||
if (dto.accessToken.isEmpty || dto.userId.isEmpty) {
|
||||
throw const FormatException('Missing session');
|
||||
}
|
||||
return AuthSession(dto.userId, dto.accessToken, dto.refreshToken);
|
||||
}
|
||||
|
||||
static AuthException authFailure(int status, BackendDTO dto) =>
|
||||
AuthException(switch (dto.error) {
|
||||
'email_in_use' => AuthError.emailInUse,
|
||||
'account_not_verified' => AuthError.accountNotVerified,
|
||||
_ when status == 401 => AuthError.invalidCredentials,
|
||||
_ when status == 429 => AuthError.factorCooldown,
|
||||
_ => AuthError.unavailable,
|
||||
});
|
||||
static FactorException factorFailure(int status, BackendDTO dto) =>
|
||||
FactorException(switch (dto.error) {
|
||||
'expired' => FactorError.expired,
|
||||
'locked' => FactorError.locked,
|
||||
'invalid' => FactorError.invalid,
|
||||
_ when status == 429 => FactorError.cooldown,
|
||||
_ when status == 401 => FactorError.expired,
|
||||
_ => FactorError.unavailable,
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class AuthResult {
|
||||
|
||||
enum AuthError {
|
||||
invalidCredentials,
|
||||
accountNotVerified,
|
||||
emailInUse,
|
||||
unavailable,
|
||||
unexpected,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/// In-memory credentials. Never include these in analytics or provider state.
|
||||
class AuthSession {
|
||||
const AuthSession(this.userId, this.accessToken, this.refreshToken);
|
||||
final String userId, accessToken, refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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>{};
|
||||
final _grants = <String, AuthSession>{};
|
||||
final _retryKeys = <String, String>{};
|
||||
String? _queuedSignup;
|
||||
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,
|
||||
}) 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) {
|
||||
throw factor
|
||||
? BackendMapper.factorFailure(res.statusCode, dto)
|
||||
: BackendMapper.authFailure(res.statusCode, dto);
|
||||
}
|
||||
return dto;
|
||||
} on AuthException {
|
||||
rethrow;
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
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);
|
||||
_retryKeys.remove('signup:$email');
|
||||
_queuedSignup = 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] = dto.pendingToken;
|
||||
return AuthResult(userId: '', isSimulated: false, challenge: challenge);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async {
|
||||
final normalized = email.trim().toLowerCase();
|
||||
// Signup already queued this email. The existing confirmation page requests
|
||||
// its initial delivery on mount; consume that receipt once.
|
||||
if (_queuedSignup == normalized) {
|
||||
_queuedSignup = null;
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
}
|
||||
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
||||
try {
|
||||
await _post('verification/send', {
|
||||
'email': normalized,
|
||||
'idempotencyKey': key,
|
||||
}, factor: true);
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
} on FactorException catch (e) {
|
||||
if (e.code == FactorError.expired || e.code == FactorError.invalid) {
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
}
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
} catch (_) {
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) async {
|
||||
try {
|
||||
await _post('verification/confirm', {'token': token}, factor: true);
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
} on FactorException catch (e) {
|
||||
throw VerificationException(switch (e.code) {
|
||||
FactorError.expired => VerificationError.expired,
|
||||
FactorError.invalid => VerificationError.invalid,
|
||||
_ => VerificationError.internal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
'idempotencyKey': key,
|
||||
}, factor: true);
|
||||
_retryKeys.remove('login:$challengeId');
|
||||
return BackendMapper.challenge(dto, challengeId);
|
||||
}
|
||||
|
||||
@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,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:http/browser_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => BrowserClient()..withCredentials = true;
|
||||
@@ -0,0 +1 @@
|
||||
export 'native.dart' if (dart.library.js_interop) 'browser.dart';
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => http.Client();
|
||||
@@ -56,6 +56,7 @@ class InstrumentedAuthServices
|
||||
} catch (error) {
|
||||
final failure = switch (error) {
|
||||
AuthException(code: final code) => switch (code) {
|
||||
AuthError.accountNotVerified => JourneyFailure.invalid,
|
||||
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
||||
AuthError.emailInUse => JourneyFailure.emailInUse,
|
||||
AuthError.unavailable => JourneyFailure.unavailable,
|
||||
|
||||
@@ -20,3 +20,6 @@ export 'src/services/two_factor.dart';
|
||||
export 'src/services/dummy_factor.dart';
|
||||
export 'src/widgets/two_factor.dart';
|
||||
export 'src/services/guarded_login.dart';
|
||||
|
||||
export 'src/services/backend.dart';
|
||||
export 'src/models/session.dart';
|
||||
@@ -6,6 +6,7 @@ environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
http: ^1.6.0
|
||||
sxchnge_analytics:
|
||||
path: ../sxchnge_analytics
|
||||
flutter:
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
void main() {
|
||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||
http.Response(jsonEncode({'data': data}), status);
|
||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
||||
final paths = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
paths.add(request.url.path);
|
||||
if (request.url.path.endsWith('/signup')) {
|
||||
expect(jsonDecode(request.body)['password'], ' password preserved ');
|
||||
return response({'userId': 'user'});
|
||||
}
|
||||
return response({'accepted': true});
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.signup(
|
||||
const SignupRequest(
|
||||
name: 'User',
|
||||
email: 'USER@example.com',
|
||||
password: ' password preserved ',
|
||||
locale: 'en',
|
||||
),
|
||||
);
|
||||
expect(result.isSimulated, isFalse);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths, ['/api/v1/accounts/signup']);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths.last, '/api/v1/accounts/verification/send');
|
||||
});
|
||||
test('login confirms backend session only after factor and consumes local grant once', () async {
|
||||
final now = DateTime.now();
|
||||
var verified = false;
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
final body = jsonDecode(request.body);
|
||||
if (request.url.path.endsWith('/login')) {
|
||||
expect(body['login'], {
|
||||
'login': 'user@example.com',
|
||||
'password': ' password ',
|
||||
});
|
||||
expect(body['clientId'], 'web');
|
||||
return response({
|
||||
'userId': 'user',
|
||||
'pendingToken': {'token': 'signed-pending'},
|
||||
'target': 'u***@example.com',
|
||||
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
|
||||
'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(),
|
||||
});
|
||||
}
|
||||
expect(request.url.path, '/api/v1/accounts/login/verify');
|
||||
expect(body['pendingToken'], 'signed-pending');
|
||||
if (body['code'] != '123456')
|
||||
return response({'error': 'invalid'}, 400);
|
||||
verified = true;
|
||||
return response({
|
||||
'account': {'id': 'user'},
|
||||
'accessToken': {'token': 'access'},
|
||||
'refreshToken': {'token': 'refresh'},
|
||||
}, 200);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.login(
|
||||
const LoginRequest(
|
||||
email: 'user@example.com',
|
||||
password: ' password ',
|
||||
locale: 'en',
|
||||
),
|
||||
);
|
||||
expect(result.challenge, isNotNull);
|
||||
expect(service.session, isNull);
|
||||
final challenge = result.challenge!;
|
||||
expect(challenge.id, isNot('signed-pending'));
|
||||
await expectLater(
|
||||
service.verify(challenge.id, '000000'),
|
||||
throwsA(isA<FactorException>()),
|
||||
);
|
||||
expect(verified, isFalse);
|
||||
expect(service.session, isNull);
|
||||
final grant = await service.verify(challenge.id, '123456');
|
||||
final accepted = await service.completeLogin(challenge, grant);
|
||||
expect(accepted.userId, 'user');
|
||||
expect(accepted.isSimulated, isFalse);
|
||||
expect(service.session!.accessToken, 'access');
|
||||
await expectLater(
|
||||
service.completeLogin(challenge, grant),
|
||||
throwsA(isA<AuthException>()),
|
||||
);
|
||||
await expectLater(
|
||||
service.authorize(FactorPurpose.payout, 'payment', grant.token),
|
||||
throwsA(isA<FactorException>()),
|
||||
);
|
||||
});
|
||||
test(
|
||||
'failed delivery preserves resend idempotency and never claims success',
|
||||
() async {
|
||||
final keys = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
|
||||
return response({'error': 'unavailable'}, 503);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await expectLater(
|
||||
service.send(email: 'user@example.com', locale: 'en'),
|
||||
throwsA(isA<VerificationException>()),
|
||||
);
|
||||
}
|
||||
expect(keys[0], keys[1]);
|
||||
},
|
||||
);
|
||||
test('expired activation maps to recovery state', () async {
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((_) async => response({'error': 'expired'}, 400)),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
await expectLater(
|
||||
service.confirm(token: 'expired'),
|
||||
throwsA(
|
||||
isA<VerificationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
VerificationError.expired,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user