Ajoute la couche application (ports, use cases), les entités du domaine, l'endpoint api/auth_api.dart (register/login/logout) et son câblage dans le router, le hashing de mot de passe et le service de token (infrastructure/security), et l'adapter Postgres des repositories d'auth s'appuyant sur la table users du ticket #49. dart pub get OK, dart analyze clean, dart test 10/10 vert (test PostgreSQL réel skip faute de Docker disponible dans ce sandbox). Logique d'auth et middleware testés via repositories fake en mémoire ; les endpoints HTTP register/login/logout n'ont pas pu être exercés de bout en bout faute d'accès à un vrai PostgreSQL local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
4.8 KiB
Dart
162 lines
4.8 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:shelf/shelf.dart';
|
|
|
|
import '../application/application.dart';
|
|
import '../domain/domain.dart';
|
|
|
|
const authenticatedRequestContextKey = 'gametime.authenticatedRequest';
|
|
const bearerTokenContextKey = 'gametime.bearerToken';
|
|
|
|
typedef AuthenticateToken =
|
|
Future<AuthenticatedRequest> Function({required String token});
|
|
|
|
final class AuthApi {
|
|
const AuthApi({
|
|
required this.registerUser,
|
|
required this.login,
|
|
required this.logout,
|
|
required this.authenticateRequest,
|
|
});
|
|
|
|
final RegisterUserUseCase registerUser;
|
|
final LoginUseCase login;
|
|
final LogoutUseCase logout;
|
|
final AuthenticateRequestUseCase authenticateRequest;
|
|
|
|
Future<Response> register(Request request) async {
|
|
try {
|
|
final body = await _readJsonObject(request);
|
|
final user = await registerUser.execute(
|
|
email: _requiredString(body, 'email'),
|
|
password: _requiredString(body, 'password'),
|
|
displayName: _optionalString(body, 'displayName'),
|
|
);
|
|
return _jsonResponse(201, {'userId': user.id, 'email': user.email});
|
|
} on EmailAlreadyTakenException catch (error) {
|
|
return _errorResponse(409, error.message);
|
|
} on ValidationException catch (error) {
|
|
return _errorResponse(400, error.message);
|
|
} on FormatException catch (error) {
|
|
return _errorResponse(400, error.message);
|
|
}
|
|
}
|
|
|
|
Future<Response> loginUser(Request request) async {
|
|
try {
|
|
final body = await _readJsonObject(request);
|
|
final result = await login.execute(
|
|
email: _requiredString(body, 'email'),
|
|
password: _requiredString(body, 'password'),
|
|
userAgent: request.headers['user-agent'],
|
|
deviceLabel: _optionalString(body, 'deviceLabel'),
|
|
);
|
|
return _jsonResponse(200, {
|
|
'token': result.token,
|
|
'expiresAt': result.expiresAt.toIso8601String(),
|
|
});
|
|
} on InvalidCredentialsException catch (error) {
|
|
return _errorResponse(401, error.message);
|
|
} on ValidationException catch (error) {
|
|
return _errorResponse(400, error.message);
|
|
} on FormatException catch (error) {
|
|
return _errorResponse(400, error.message);
|
|
}
|
|
}
|
|
|
|
Future<Response> logoutUser(Request request) async {
|
|
final token = request.context[bearerTokenContextKey] as String?;
|
|
if (token == null) {
|
|
return _errorResponse(401, 'Unauthorized.');
|
|
}
|
|
try {
|
|
await logout.execute(token: token);
|
|
return Response(204);
|
|
} on UnauthorizedException catch (error) {
|
|
return _errorResponse(401, error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
Middleware authenticationMiddleware(AuthenticateToken authenticate) {
|
|
return (innerHandler) {
|
|
return (request) async {
|
|
final token = _bearerToken(request.headers['authorization']);
|
|
if (token == null) {
|
|
return _errorResponse(401, 'Missing bearer token.');
|
|
}
|
|
|
|
try {
|
|
final authenticated = await authenticate(token: token);
|
|
return innerHandler(
|
|
request.change(
|
|
context: {
|
|
authenticatedRequestContextKey: authenticated,
|
|
bearerTokenContextKey: token,
|
|
},
|
|
),
|
|
);
|
|
} on UnauthorizedException catch (error) {
|
|
return _errorResponse(401, error.message);
|
|
}
|
|
};
|
|
};
|
|
}
|
|
|
|
AuthenticatedRequest? authenticatedRequestFrom(Request request) {
|
|
return request.context[authenticatedRequestContextKey]
|
|
as AuthenticatedRequest?;
|
|
}
|
|
|
|
String? _bearerToken(String? authorization) {
|
|
if (authorization == null) {
|
|
return null;
|
|
}
|
|
final parts = authorization.trim().split(RegExp(r'\s+'));
|
|
if (parts.length != 2 || parts.first.toLowerCase() != 'bearer') {
|
|
return null;
|
|
}
|
|
return parts[1].isEmpty ? null : parts[1];
|
|
}
|
|
|
|
Future<Map<String, Object?>> _readJsonObject(Request request) async {
|
|
final raw = await request.readAsString();
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! Map<String, Object?>) {
|
|
throw const FormatException('Request body must be a JSON object.');
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
String _requiredString(Map<String, Object?> body, String key) {
|
|
final value = body[key];
|
|
if (value is! String || value.trim().isEmpty) {
|
|
throw FormatException('$key must be a non-empty string.');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
String? _optionalString(Map<String, Object?> body, String key) {
|
|
final value = body[key];
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
if (value is! String) {
|
|
throw FormatException('$key must be a string.');
|
|
}
|
|
final trimmed = value.trim();
|
|
return trimmed.isEmpty ? null : trimmed;
|
|
}
|
|
|
|
Response _jsonResponse(int statusCode, Map<String, Object?> body) {
|
|
return Response(
|
|
statusCode,
|
|
body: jsonEncode(body),
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
|
|
Response _errorResponse(int statusCode, String message) {
|
|
return _jsonResponse(statusCode, {'error': message});
|
|
}
|