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

@ -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';
}
}