Files
GameTime/server/lib/api/auth_api.dart
Blomios 7b122d03da feat(server): auth comptes utilisateurs et tokens API (ticket #48)
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>
2026-07-19 10:15:34 +02:00

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