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>
This commit is contained in:
161
server/lib/api/auth_api.dart
Normal file
161
server/lib/api/auth_api.dart
Normal file
@ -0,0 +1,161 @@
|
||||
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});
|
||||
}
|
||||
@ -3,7 +3,9 @@ import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
Handler buildApiHandler() {
|
||||
import 'auth_api.dart';
|
||||
|
||||
Handler buildApiHandler({AuthApi? authApi}) {
|
||||
final router = Router()
|
||||
..get('/health', (Request request) {
|
||||
return Response.ok(
|
||||
@ -12,5 +14,17 @@ Handler buildApiHandler() {
|
||||
);
|
||||
});
|
||||
|
||||
if (authApi != null) {
|
||||
router
|
||||
..post('/auth/register', authApi.register)
|
||||
..post('/auth/login', authApi.loginUser)
|
||||
..post(
|
||||
'/auth/logout',
|
||||
authenticationMiddleware(authApi.authenticateRequest.execute)(
|
||||
authApi.logoutUser,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user