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:
2026-07-19 10:15:34 +02:00
parent c10e8f9085
commit 7b122d03da
22 changed files with 1351 additions and 8 deletions

View File

@ -69,11 +69,27 @@ Expected response:
{"status":"ok"} {"status":"ok"}
``` ```
## Authentication
Ticket #48 adds account registration, login, logout and bearer-token request
authentication.
Endpoints:
- `POST /auth/register` with `{ "email": "...", "password": "...", "displayName": "..." }`.
- `POST /auth/login` with `{ "email": "...", "password": "...", "deviceLabel": "..." }`.
- `POST /auth/logout` with `Authorization: Bearer <token>`.
Passwords are stored with PBKDF2-HMAC-SHA256 via `package:cryptography`, using a
per-password random salt. API tokens are opaque random values; only a SHA-256
hash of the token is stored in PostgreSQL.
## Scope ## Scope
Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and
the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema only; auth, the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48
sync endpoints and sharing behavior are still implemented in later tickets. adds authentication only; sync endpoints and sharing behavior are still
implemented in later tickets.
Upcoming tickets will fill the empty adapters and use cases: Upcoming tickets will fill the empty adapters and use cases:

View File

@ -1,12 +1,51 @@
import 'dart:io'; import 'dart:io';
import 'package:gametime_server/api/auth_api.dart';
import 'package:gametime_server/api/router.dart'; import 'package:gametime_server/api/router.dart';
import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/infrastructure/postgres/postgres.dart';
import 'package:gametime_server/infrastructure/security/security.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf/shelf_io.dart' as shelf_io;
Future<void> main(List<String> arguments) async { Future<void> main(List<String> arguments) async {
final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080; final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080;
final connection = await PostgresConnectionFactory(
PostgresConnectionConfig.fromEnvironment(),
).open();
final users = PostgresUserRepository(connection);
final sessions = PostgresAuthSessionRepository(connection);
final passwordHasher = Pbkdf2PasswordHasher();
final tokens = SecureOpaqueTokenService();
const clock = SystemClock();
final ids = UuidV4Generator();
final authenticateRequest = AuthenticateRequestUseCase(
users: users,
sessions: sessions,
tokens: tokens,
clock: clock,
);
final authApi = AuthApi(
registerUser: RegisterUserUseCase(
users: users,
passwordHasher: passwordHasher,
clock: clock,
ids: ids,
),
login: LoginUseCase(
users: users,
sessions: sessions,
passwordHasher: passwordHasher,
tokens: tokens,
clock: clock,
ids: ids,
),
logout: LogoutUseCase(sessions: sessions, tokens: tokens, clock: clock),
authenticateRequest: authenticateRequest,
);
final server = await shelf_io.serve( final server = await shelf_io.serve(
buildApiHandler(), buildApiHandler(authApi: authApi),
InternetAddress.anyIPv4, InternetAddress.anyIPv4,
port, port,
); );

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

View File

@ -3,7 +3,9 @@ import 'dart:convert';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
Handler buildApiHandler() { import 'auth_api.dart';
Handler buildApiHandler({AuthApi? authApi}) {
final router = Router() final router = Router()
..get('/health', (Request request) { ..get('/health', (Request request) {
return Response.ok( 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); return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
} }

View File

@ -2,4 +2,5 @@
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL. Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
Concrete adapters are wired from `bin/server.dart`. Authentication use cases live here and depend only on repository/security
ports. Concrete adapters are wired from `bin/server.dart`.

View File

@ -0,0 +1,2 @@
export 'ports.dart';
export 'use_cases.dart';

View File

@ -0,0 +1,43 @@
import '../domain/domain.dart';
abstract interface class UserRepository {
Future<UserAccount?> findByEmail(String email);
Future<UserAccount?> findById(String id);
Future<void> insert(UserAccount user);
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
});
}
abstract interface class AuthSessionRepository {
Future<void> insert(AuthSession session);
Future<AuthSession?> findByTokenHash(String tokenHash);
Future<void> revoke({required String sessionId, required DateTime revokedAt});
}
abstract interface class PasswordHasher {
Future<String> hash(String password);
Future<bool> verify({required String password, required String passwordHash});
}
abstract interface class OpaqueTokenService {
String generateToken();
String hashToken(String token);
}
abstract interface class Clock {
DateTime now();
}
abstract interface class IdGenerator {
String newId();
}

View File

@ -0,0 +1,190 @@
import '../domain/domain.dart';
import 'ports.dart';
const _minimumPasswordLength = 8;
final _emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
final class RegisterUserUseCase {
const RegisterUserUseCase({
required this.users,
required this.passwordHasher,
required this.clock,
required this.ids,
});
final UserRepository users;
final PasswordHasher passwordHasher;
final Clock clock;
final IdGenerator ids;
Future<UserAccount> execute({
required String email,
required String password,
String? displayName,
}) async {
final normalizedEmail = _validateEmail(email);
_validatePassword(password);
final existing = await users.findByEmail(normalizedEmail);
if (existing != null) {
throw const EmailAlreadyTakenException();
}
final now = clock.now().toUtc();
final user = UserAccount(
id: ids.newId(),
email: normalizedEmail,
passwordHash: await passwordHasher.hash(password),
displayName: _blankToNull(displayName),
createdAt: now,
updatedAt: now,
);
await users.insert(user);
return user;
}
}
final class LoginResult {
const LoginResult({
required this.user,
required this.session,
required this.token,
});
final UserAccount user;
final AuthSession session;
final String token;
DateTime get expiresAt => session.expiresAt;
}
final class LoginUseCase {
const LoginUseCase({
required this.users,
required this.sessions,
required this.passwordHasher,
required this.tokens,
required this.clock,
required this.ids,
this.sessionDuration = const Duration(days: 30),
});
final UserRepository users;
final AuthSessionRepository sessions;
final PasswordHasher passwordHasher;
final OpaqueTokenService tokens;
final Clock clock;
final IdGenerator ids;
final Duration sessionDuration;
Future<LoginResult> execute({
required String email,
required String password,
String? userAgent,
String? deviceLabel,
}) async {
final normalizedEmail = _validateEmail(email);
final user = await users.findByEmail(normalizedEmail);
if (user == null || user.isDisabled) {
throw const InvalidCredentialsException();
}
final verified = await passwordHasher.verify(
password: password,
passwordHash: user.passwordHash,
);
if (!verified) {
throw const InvalidCredentialsException();
}
final now = clock.now().toUtc();
final token = tokens.generateToken();
final session = AuthSession(
id: ids.newId(),
userId: user.id,
tokenHash: tokens.hashToken(token),
issuedAt: now,
expiresAt: now.add(sessionDuration),
userAgent: _blankToNull(userAgent),
deviceLabel: _blankToNull(deviceLabel),
);
await sessions.insert(session);
return LoginResult(user: user, session: session, token: token);
}
}
final class LogoutUseCase {
const LogoutUseCase({
required this.sessions,
required this.tokens,
required this.clock,
});
final AuthSessionRepository sessions;
final OpaqueTokenService tokens;
final Clock clock;
Future<void> execute({required String token}) async {
final session = await sessions.findByTokenHash(tokens.hashToken(token));
if (session == null || !session.isActiveAt(clock.now())) {
throw const UnauthorizedException();
}
await sessions.revoke(
sessionId: session.id,
revokedAt: clock.now().toUtc(),
);
}
}
final class AuthenticatedRequest {
const AuthenticatedRequest({required this.user, required this.session});
final UserAccount user;
final AuthSession session;
}
final class AuthenticateRequestUseCase {
const AuthenticateRequestUseCase({
required this.users,
required this.sessions,
required this.tokens,
required this.clock,
});
final UserRepository users;
final AuthSessionRepository sessions;
final OpaqueTokenService tokens;
final Clock clock;
Future<AuthenticatedRequest> execute({required String token}) async {
final session = await sessions.findByTokenHash(tokens.hashToken(token));
if (session == null || !session.isActiveAt(clock.now())) {
throw const UnauthorizedException();
}
final user = await users.findById(session.userId);
if (user == null || user.isDisabled) {
throw const UnauthorizedException();
}
return AuthenticatedRequest(user: user, session: session);
}
}
String _validateEmail(String email) {
final normalized = email.trim().toLowerCase();
if (!_emailPattern.hasMatch(normalized)) {
throw const ValidationException('Email format is invalid.');
}
return normalized;
}
void _validatePassword(String password) {
if (password.length < _minimumPasswordLength) {
throw const ValidationException(
'Password must contain at least 8 characters.',
);
}
}
String? _blankToNull(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}

View File

@ -4,3 +4,5 @@ Pure server domain entities and invariants.
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
other infrastructure concerns. other infrastructure concerns.
Current entities: `UserAccount` and `AuthSession`.

View File

@ -0,0 +1 @@
export 'entities.dart';

View File

@ -0,0 +1,97 @@
final class DomainException implements Exception {
const DomainException(this.message);
final String message;
@override
String toString() => message;
}
final class ValidationException extends DomainException {
const ValidationException(super.message);
}
final class EmailAlreadyTakenException extends DomainException {
const EmailAlreadyTakenException() : super('Email already taken.');
}
final class InvalidCredentialsException extends DomainException {
const InvalidCredentialsException() : super('Invalid credentials.');
}
final class UnauthorizedException extends DomainException {
const UnauthorizedException() : super('Unauthorized.');
}
final class UserAccount {
UserAccount({
required String id,
required String email,
required String passwordHash,
this.displayName,
required DateTime createdAt,
required DateTime updatedAt,
DateTime? disabledAt,
}) : id = _nonBlank(id, 'User id'),
email = _nonBlank(email, 'Email').toLowerCase(),
passwordHash = _nonBlank(passwordHash, 'Password hash'),
createdAt = createdAt.toUtc(),
updatedAt = updatedAt.toUtc(),
disabledAt = disabledAt?.toUtc();
final String id;
final String email;
final String passwordHash;
final String? displayName;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? disabledAt;
bool get isDisabled => disabledAt != null;
}
final class AuthSession {
AuthSession({
required String id,
required String userId,
required String tokenHash,
required DateTime issuedAt,
required DateTime expiresAt,
DateTime? revokedAt,
this.userAgent,
this.deviceLabel,
}) : id = _nonBlank(id, 'Auth session id'),
userId = _nonBlank(userId, 'User id'),
tokenHash = _nonBlank(tokenHash, 'Token hash'),
issuedAt = issuedAt.toUtc(),
expiresAt = expiresAt.toUtc(),
revokedAt = revokedAt?.toUtc() {
if (!this.expiresAt.isAfter(this.issuedAt)) {
throw const ValidationException(
'Auth session expiration must be after issue time.',
);
}
}
final String id;
final String userId;
final String tokenHash;
final DateTime issuedAt;
final DateTime expiresAt;
final DateTime? revokedAt;
final String? userAgent;
final String? deviceLabel;
bool isActiveAt(DateTime now) {
final instant = now.toUtc();
return revokedAt == null && expiresAt.isAfter(instant);
}
}
String _nonBlank(String value, String label) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ValidationException('$label must not be blank.');
}
return trimmed;
}

View File

@ -0,0 +1,199 @@
import 'package:postgres/postgres.dart';
import '../../application/application.dart';
import '../../domain/domain.dart';
final class PostgresUserRepository implements UserRepository {
const PostgresUserRepository(this.connection);
final Connection connection;
@override
Future<UserAccount?> findByEmail(String email) async {
final result = await connection.execute(
Sql.named('''
SELECT id, email, password_hash, display_name, created_at, updated_at,
disabled_at
FROM users
WHERE email = @email
LIMIT 1
'''),
parameters: {'email': email.trim().toLowerCase()},
);
return result.isEmpty ? null : _userFromRow(result.single);
}
@override
Future<UserAccount?> findById(String id) async {
final result = await connection.execute(
Sql.named('''
SELECT id, email, password_hash, display_name, created_at, updated_at,
disabled_at
FROM users
WHERE id = @id
LIMIT 1
'''),
parameters: {'id': id},
);
return result.isEmpty ? null : _userFromRow(result.single);
}
@override
Future<void> insert(UserAccount user) async {
await connection.execute(
Sql.named('''
INSERT INTO users (
id, email, password_hash, display_name, created_at, updated_at,
disabled_at
)
VALUES (
@id, @email, @password_hash, @display_name, @created_at,
@updated_at, @disabled_at
)
'''),
parameters: {
'id': user.id,
'email': user.email,
'password_hash': user.passwordHash,
'display_name': user.displayName,
'created_at': user.createdAt,
'updated_at': user.updatedAt,
'disabled_at': user.disabledAt,
},
);
}
@override
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
}) async {
await connection.execute(
Sql.named('''
UPDATE users
SET password_hash = @password_hash, updated_at = @updated_at
WHERE id = @user_id
'''),
parameters: {
'user_id': userId,
'password_hash': passwordHash,
'updated_at': updatedAt.toUtc(),
},
);
}
}
final class PostgresAuthSessionRepository implements AuthSessionRepository {
const PostgresAuthSessionRepository(this.connection);
final Connection connection;
@override
Future<void> insert(AuthSession session) async {
await connection.execute(
Sql.named('''
INSERT INTO auth_sessions (
id, user_id, token_hash, issued_at, expires_at, revoked_at,
user_agent, device_label
)
VALUES (
@id, @user_id, @token_hash, @issued_at, @expires_at, @revoked_at,
@user_agent, @device_label
)
'''),
parameters: {
'id': session.id,
'user_id': session.userId,
'token_hash': session.tokenHash,
'issued_at': session.issuedAt,
'expires_at': session.expiresAt,
'revoked_at': session.revokedAt,
'user_agent': session.userAgent,
'device_label': session.deviceLabel,
},
);
}
@override
Future<AuthSession?> findByTokenHash(String tokenHash) async {
final result = await connection.execute(
Sql.named('''
SELECT id, user_id, token_hash, issued_at, expires_at, revoked_at,
user_agent, device_label
FROM auth_sessions
WHERE token_hash = @token_hash
LIMIT 1
'''),
parameters: {'token_hash': tokenHash},
);
return result.isEmpty ? null : _authSessionFromRow(result.single);
}
@override
Future<void> revoke({
required String sessionId,
required DateTime revokedAt,
}) async {
await connection.execute(
Sql.named('''
UPDATE auth_sessions
SET revoked_at = @revoked_at
WHERE id = @id AND revoked_at IS NULL
'''),
parameters: {'id': sessionId, 'revoked_at': revokedAt.toUtc()},
);
}
}
UserAccount _userFromRow(dynamic row) {
final values = row.toColumnMap() as Map<String, Object?>;
return UserAccount(
id: _stringValue(values['id']),
email: _stringValue(values['email']),
passwordHash: _stringValue(values['password_hash']),
displayName: values['display_name'] as String?,
createdAt: _dateTimeValue(values['created_at']),
updatedAt: _dateTimeValue(values['updated_at']),
disabledAt: _nullableDateTimeValue(values['disabled_at']),
);
}
AuthSession _authSessionFromRow(dynamic row) {
final values = row.toColumnMap() as Map<String, Object?>;
return AuthSession(
id: _stringValue(values['id']),
userId: _stringValue(values['user_id']),
tokenHash: _stringValue(values['token_hash']),
issuedAt: _dateTimeValue(values['issued_at']),
expiresAt: _dateTimeValue(values['expires_at']),
revokedAt: _nullableDateTimeValue(values['revoked_at']),
userAgent: values['user_agent'] as String?,
deviceLabel: values['device_label'] as String?,
);
}
String _stringValue(Object? value) {
if (value == null) {
throw const FormatException('Expected non-null string value.');
}
return value.toString();
}
DateTime _dateTimeValue(Object? value) {
final result = _nullableDateTimeValue(value);
if (result == null) {
throw const FormatException('Expected non-null DateTime value.');
}
return result;
}
DateTime? _nullableDateTimeValue(Object? value) {
if (value == null) {
return null;
}
if (value is DateTime) {
return value.toUtc();
}
return DateTime.parse(value.toString()).toUtc();
}

View File

@ -0,0 +1,2 @@
export 'auth_repositories.dart';
export 'postgres_database.dart';

View File

@ -1,5 +1,7 @@
# Security infrastructure # Security infrastructure
Placeholder for password hashing, token signing and token verification. Password hashing uses PBKDF2-HMAC-SHA256 via `package:cryptography`, with a
random per-password salt and encoded parameters in the stored hash.
Authentication is planned for ticket #48. API tokens are opaque random base64url strings generated with `Random.secure()`.
Only the SHA-256 hash of a token is persisted.

View File

@ -0,0 +1,88 @@
import 'dart:convert';
import 'dart:math';
import 'package:cryptography/cryptography.dart';
import '../../application/application.dart';
import '../../domain/domain.dart';
final class Pbkdf2PasswordHasher implements PasswordHasher {
Pbkdf2PasswordHasher({
this.iterations = 210000,
this.saltLength = 16,
this.bits = 256,
Random? random,
}) : _random = random ?? Random.secure();
final int iterations;
final int saltLength;
final int bits;
final Random _random;
@override
Future<String> hash(String password) async {
final salt = List<int>.generate(saltLength, (_) => _random.nextInt(256));
final hash = await _derive(password, salt, iterations, bits);
return [
'pbkdf2_sha256',
iterations.toString(),
base64UrlEncode(salt),
base64UrlEncode(hash),
].join(r'$');
}
@override
Future<bool> verify({
required String password,
required String passwordHash,
}) async {
final parts = passwordHash.split(r'$');
if (parts.length != 4 || parts[0] != 'pbkdf2_sha256') {
throw const ValidationException('Unsupported password hash format.');
}
final parsedIterations = int.tryParse(parts[1]);
if (parsedIterations == null || parsedIterations <= 0) {
throw const ValidationException('Invalid password hash iterations.');
}
final salt = base64Url.decode(parts[2]);
final expectedHash = base64Url.decode(parts[3]);
final actualHash = await _derive(
password,
salt,
parsedIterations,
expectedHash.length * 8,
);
return _constantTimeEquals(actualHash, expectedHash);
}
Future<List<int>> _derive(
String password,
List<int> salt,
int iterations,
int bits,
) async {
final algorithm = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: iterations,
bits: bits,
);
final key = await algorithm.deriveKey(
secretKey: SecretKey(utf8.encode(password)),
nonce: salt,
);
return key.extractBytes();
}
}
bool _constantTimeEquals(List<int> left, List<int> right) {
if (left.length != right.length) {
return false;
}
var diff = 0;
for (var index = 0; index < left.length; index++) {
diff |= left[index] ^ right[index];
}
return diff == 0;
}

View File

@ -0,0 +1,32 @@
import 'dart:math';
import '../../application/application.dart';
final class SystemClock implements Clock {
const SystemClock();
@override
DateTime now() => DateTime.now().toUtc();
}
final class UuidV4Generator implements IdGenerator {
UuidV4Generator({Random? random}) : _random = random ?? Random.secure();
final Random _random;
@override
String newId() {
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0'));
final value = hex.join();
return [
value.substring(0, 8),
value.substring(8, 12),
value.substring(12, 16),
value.substring(16, 20),
value.substring(20),
].join('-');
}
}

View File

@ -0,0 +1,3 @@
export 'password_hasher.dart';
export 'runtime_services.dart';
export 'token_service.dart';

View File

@ -0,0 +1,25 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import '../../application/application.dart';
final class SecureOpaqueTokenService implements OpaqueTokenService {
SecureOpaqueTokenService({this.byteLength = 32, Random? random})
: _random = random ?? Random.secure();
final int byteLength;
final Random _random;
@override
String generateToken() {
final bytes = List<int>.generate(byteLength, (_) => _random.nextInt(256));
return base64UrlEncode(bytes).replaceAll('=', '');
}
@override
String hashToken(String token) {
return sha256.convert(utf8.encode(token)).toString();
}
}

View File

@ -90,13 +90,29 @@ packages:
source: hosted source: hosted
version: "1.15.1" version: "1.15.1"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
cryptography:
dependency: "direct main"
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file: file:
dependency: transitive dependency: transitive
description: description:

View File

@ -6,6 +6,8 @@ environment:
sdk: ^3.10.0 sdk: ^3.10.0
dependencies: dependencies:
crypto: ^3.0.6
cryptography: ^2.7.0
postgres: ^3.5.12 postgres: ^3.5.12
shelf: ^1.4.2 shelf: ^1.4.2
shelf_router: ^1.1.4 shelf_router: ^1.1.4

View File

@ -0,0 +1,68 @@
import 'package:gametime_server/api/auth_api.dart';
import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:shelf/shelf.dart';
import 'package:test/test.dart';
void main() {
test('authentication middleware injects the authenticated request', () async {
final user = UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hash',
createdAt: DateTime.utc(2026, 7, 19, 12),
updatedAt: DateTime.utc(2026, 7, 19, 12),
);
final session = AuthSession(
id: 'session-1',
userId: user.id,
tokenHash: 'token-hash',
issuedAt: DateTime.utc(2026, 7, 19, 12),
expiresAt: DateTime.utc(2026, 7, 20, 12),
);
final tokens = <String>[];
final handler =
authenticationMiddleware(({required token}) async {
tokens.add(token);
return AuthenticatedRequest(user: user, session: session);
})((request) {
final authenticated = authenticatedRequestFrom(request);
return Response.ok(authenticated?.user.id ?? 'missing');
});
final response = await handler(
Request(
'GET',
Uri.parse('http://localhost/protected'),
headers: {'authorization': 'Bearer clear-token'},
),
);
expect(response.statusCode, 200);
expect(await response.readAsString(), 'user-1');
expect(tokens, ['clear-token']);
});
test(
'authentication middleware rejects missing or invalid bearer tokens',
() async {
final handler = authenticationMiddleware(({required token}) async {
throw const UnauthorizedException();
})((request) => Response.ok('secret'));
final missing = await handler(
Request('GET', Uri.parse('http://localhost/protected')),
);
final invalid = await handler(
Request(
'GET',
Uri.parse('http://localhost/protected'),
headers: {'authorization': 'Bearer invalid-token'},
),
);
expect(missing.statusCode, 401);
expect(invalid.statusCode, 401);
},
);
}

View File

@ -0,0 +1,340 @@
import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:test/test.dart';
void main() {
late _FakeUserRepository users;
late _FakeAuthSessionRepository sessions;
late _FakePasswordHasher passwordHasher;
late _FakeTokenService tokens;
late _FakeClock clock;
late _FakeIds ids;
setUp(() {
users = _FakeUserRepository();
sessions = _FakeAuthSessionRepository();
passwordHasher = _FakePasswordHasher();
tokens = _FakeTokenService();
clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
ids = _FakeIds();
});
test('register creates a user with a hashed password', () async {
final useCase = RegisterUserUseCase(
users: users,
passwordHasher: passwordHasher,
clock: clock,
ids: ids,
);
final user = await useCase.execute(
email: ' USER@Example.COM ',
password: 'password123',
displayName: ' Anthony ',
);
expect(user.id, 'id-1');
expect(user.email, 'user@example.com');
expect(user.passwordHash, 'hashed:password123');
expect(user.displayName, 'Anthony');
expect(users.byId[user.id], user);
});
test('register rejects an already used email', () async {
users.add(
UserAccount(
id: 'existing-user',
email: 'user@example.com',
passwordHash: 'hash',
createdAt: clock.now(),
updatedAt: clock.now(),
),
);
final useCase = RegisterUserUseCase(
users: users,
passwordHasher: passwordHasher,
clock: clock,
ids: ids,
);
await expectLater(
useCase.execute(email: 'USER@example.com', password: 'password123'),
throwsA(isA<EmailAlreadyTakenException>()),
);
});
test('login returns a clear token once and stores only its hash', () async {
users.add(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: clock.now(),
updatedAt: clock.now(),
),
);
tokens.nextToken = 'clear-token';
final useCase = LoginUseCase(
users: users,
sessions: sessions,
passwordHasher: passwordHasher,
tokens: tokens,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
email: 'user@example.com',
password: 'password123',
userAgent: 'test-agent',
deviceLabel: 'phone',
);
expect(result.token, 'clear-token');
expect(result.expiresAt, DateTime.utc(2026, 8, 18, 12));
expect(sessions.byTokenHash.keys.single, 'token-hash:clear-token');
expect(sessions.byTokenHash.values.single.tokenHash, isNot('clear-token'));
});
test('login rejects a bad password', () async {
users.add(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: clock.now(),
updatedAt: clock.now(),
),
);
final useCase = LoginUseCase(
users: users,
sessions: sessions,
passwordHasher: passwordHasher,
tokens: tokens,
clock: clock,
ids: ids,
);
await expectLater(
useCase.execute(email: 'user@example.com', password: 'wrong-password'),
throwsA(isA<InvalidCredentialsException>()),
);
});
test('authenticate returns the user for a valid token', () async {
final user = _user(clock);
users.add(user);
sessions.add(
AuthSession(
id: 'session-1',
userId: user.id,
tokenHash: 'token-hash:valid-token',
issuedAt: clock.now(),
expiresAt: clock.now().add(const Duration(days: 1)),
),
);
final useCase = _authenticateUseCase(users, sessions, tokens, clock);
final authenticated = await useCase.execute(token: 'valid-token');
expect(authenticated.user.id, user.id);
expect(authenticated.session.id, 'session-1');
});
test('authenticate rejects expired, revoked and unknown tokens', () async {
final user = _user(clock);
users.add(user);
sessions
..add(
AuthSession(
id: 'expired',
userId: user.id,
tokenHash: 'token-hash:expired-token',
issuedAt: clock.now().subtract(const Duration(days: 2)),
expiresAt: clock.now().subtract(const Duration(days: 1)),
),
)
..add(
AuthSession(
id: 'revoked',
userId: user.id,
tokenHash: 'token-hash:revoked-token',
issuedAt: clock.now(),
expiresAt: clock.now().add(const Duration(days: 1)),
revokedAt: clock.now(),
),
);
final useCase = _authenticateUseCase(users, sessions, tokens, clock);
await expectLater(
useCase.execute(token: 'expired-token'),
throwsA(isA<UnauthorizedException>()),
);
await expectLater(
useCase.execute(token: 'revoked-token'),
throwsA(isA<UnauthorizedException>()),
);
await expectLater(
useCase.execute(token: 'unknown-token'),
throwsA(isA<UnauthorizedException>()),
);
});
test('logout revokes an active token', () async {
final user = _user(clock);
users.add(user);
sessions.add(
AuthSession(
id: 'session-1',
userId: user.id,
tokenHash: 'token-hash:valid-token',
issuedAt: clock.now(),
expiresAt: clock.now().add(const Duration(days: 1)),
),
);
final useCase = LogoutUseCase(
sessions: sessions,
tokens: tokens,
clock: clock,
);
await useCase.execute(token: 'valid-token');
expect(sessions.revokedSessionIds, ['session-1']);
});
}
AuthenticateRequestUseCase _authenticateUseCase(
_FakeUserRepository users,
_FakeAuthSessionRepository sessions,
_FakeTokenService tokens,
_FakeClock clock,
) {
return AuthenticateRequestUseCase(
users: users,
sessions: sessions,
tokens: tokens,
clock: clock,
);
}
UserAccount _user(_FakeClock clock) {
return UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: clock.now(),
updatedAt: clock.now(),
);
}
final class _FakeUserRepository implements UserRepository {
final byId = <String, UserAccount>{};
final byEmail = <String, UserAccount>{};
void add(UserAccount user) {
byId[user.id] = user;
byEmail[user.email] = user;
}
@override
Future<UserAccount?> findByEmail(String email) async {
return byEmail[email.trim().toLowerCase()];
}
@override
Future<UserAccount?> findById(String id) async => byId[id];
@override
Future<void> insert(UserAccount user) async => add(user);
@override
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
}) async {
final user = byId[userId];
if (user == null) {
return;
}
add(
UserAccount(
id: user.id,
email: user.email,
passwordHash: passwordHash,
displayName: user.displayName,
createdAt: user.createdAt,
updatedAt: updatedAt,
disabledAt: user.disabledAt,
),
);
}
}
final class _FakeAuthSessionRepository implements AuthSessionRepository {
final byTokenHash = <String, AuthSession>{};
final revokedSessionIds = <String>[];
void add(AuthSession session) {
byTokenHash[session.tokenHash] = session;
}
@override
Future<AuthSession?> findByTokenHash(String tokenHash) async {
return byTokenHash[tokenHash];
}
@override
Future<void> insert(AuthSession session) async => add(session);
@override
Future<void> revoke({
required String sessionId,
required DateTime revokedAt,
}) async {
revokedSessionIds.add(sessionId);
}
}
final class _FakePasswordHasher implements PasswordHasher {
@override
Future<String> hash(String password) async => 'hashed:$password';
@override
Future<bool> verify({
required String password,
required String passwordHash,
}) async {
return passwordHash == 'hashed:$password';
}
}
final class _FakeTokenService implements OpaqueTokenService {
String nextToken = 'token';
@override
String generateToken() => nextToken;
@override
String hashToken(String token) => 'token-hash:$token';
}
final class _FakeClock implements Clock {
_FakeClock(this.value);
DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}