diff --git a/backend/app/Auth/BcryptPasswordHasher.php b/backend/app/Auth/BcryptPasswordHasher.php new file mode 100644 index 0000000..0bc4a46 --- /dev/null +++ b/backend/app/Auth/BcryptPasswordHasher.php @@ -0,0 +1,16 @@ + $dto->token, + 'user_id' => $dto->user->getId(), + 'created_at' => $dto->createdAt, + 'expires_at' => $dto->expiresAt, + ]); + + return new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + } + + public function findByToken(string $token): ?Session + { + $model = SessionModel::find($token); + if ($model === null) { + return null; + } + $user = $this->userRepo->find($model->user_id); + if ($user === null) { + return null; + } + $utc = new DateTimeZone('UTC'); + + return new Session( + token: $model->token, + user: $user, + createdAt: new DateTimeImmutable( + $model->created_at->toDateTimeString(), + $utc + ), + expiresAt: new DateTimeImmutable( + $model->expires_at->toDateTimeString(), + $utc + ), + ); + } + + public function deleteByToken(string $token): void + { + SessionModel::where('token', $token)->delete(); + } +} diff --git a/backend/app/Auth/PasswordHasher.php b/backend/app/Auth/PasswordHasher.php new file mode 100644 index 0000000..ab57a41 --- /dev/null +++ b/backend/app/Auth/PasswordHasher.php @@ -0,0 +1,10 @@ +token; + } + + public function getUser(): User + { + return $this->user; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php new file mode 100644 index 0000000..574a142 --- /dev/null +++ b/backend/app/Auth/SessionModel.php @@ -0,0 +1,44 @@ +|SessionModel newModelQuery() + * @method static Builder|SessionModel newQuery() + * @method static Builder|SessionModel query() + * + * @mixin \Eloquent + */ +class SessionModel extends Model +{ + protected $table = 'sessions'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + protected $fillable = [ + 'token', + 'user_id', + 'created_at', + 'expires_at', + ]; + + protected $casts = [ + 'created_at' => 'datetime', + 'expires_at' => 'datetime', + ]; +} diff --git a/backend/app/Auth/SessionRepository.php b/backend/app/Auth/SessionRepository.php new file mode 100644 index 0000000..cabae60 --- /dev/null +++ b/backend/app/Auth/SessionRepository.php @@ -0,0 +1,12 @@ +email === null || $request->email === '') { + throw new BadRequestException('email is required'); + } + if ($request->password === null || $request->password === '') { + throw new BadRequestException('password is required'); + } + + $user = $this->userRepo->findByEmail( + new EmailAddress($request->email) + ); + if ($user === null) { + throw new UnauthorizedException('invalid credentials'); + } + + $passwordMatches = $this->hasher->verify( + $request->password, + $user->getPasswordHash(), + ); + if (! $passwordMatches) { + throw new UnauthorizedException('invalid credentials'); + } + + return $user; + } +} diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php new file mode 100644 index 0000000..aa8b1df --- /dev/null +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php @@ -0,0 +1,11 @@ +clock->now(); + $expiresAt = $now->modify(self::SESSION_LIFETIME); + + return $this->sessionRepo->create(new CreateSessionDto( + token: $this->tokenGenerator->generate(), + user: $user, + createdAt: $now, + expiresAt: $expiresAt, + )); + } +} diff --git a/backend/app/Auth/UseCases/Logout/Logout.php b/backend/app/Auth/UseCases/Logout/Logout.php new file mode 100644 index 0000000..31b16de --- /dev/null +++ b/backend/app/Auth/UseCases/Logout/Logout.php @@ -0,0 +1,17 @@ +sessionRepo->deleteByToken($token); + } +} diff --git a/backend/app/Exceptions/BadRequestException.php b/backend/app/Exceptions/BadRequestException.php new file mode 100644 index 0000000..b900f47 --- /dev/null +++ b/backend/app/Exceptions/BadRequestException.php @@ -0,0 +1,7 @@ +cookie(self::COOKIE_NAME); + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepo->findByToken($token); + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepo->deleteByToken($token); + + return $this->unauthorized(); + } + + $request->attributes->set('user', $session->getUser()); + + return $next($request); + } + + private function unauthorized(): JsonResponse + { + return new JsonResponse(['error' => 'unauthenticated'], 401); + } +} diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php new file mode 100644 index 0000000..a744918 --- /dev/null +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -0,0 +1,54 @@ +domain = mb_strtolower($domain); + $normalized = $local.'@'.$this->domain; + + if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) { + throw new InvalidArgumentException(self::ERROR_MESSAGE." $email"); + } + + $this->normalized = $normalized; + } + + public function value(): string + { + return $this->normalized; + } + + public function equals(self $other): bool + { + return $this->normalized === $other->normalized; + } + + public function getDomain(): string + { + return $this->domain; + } + + public function __toString(): string + { + return $this->normalized; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..d10b373 --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,13 @@ + $dto->email->value(), + 'password_hash' => $dto->passwordHash, + ]); + + return $this->toDomain($model); + } + + public function findByEmail(EmailAddress $email): ?User + { + $model = UserModel::where('email', $email->value())->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function findByEmailDomain(string $domain): array + { + $models = UserModel::where('email', 'like', '%@'.$domain)->get(); + $users = []; + foreach ($models as $model) { + $users[] = $this->toDomain($model); + } + + return $users; + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + private function toDomain(UserModel $model): User + { + return new User( + id: $model->id, + email: new EmailAddress($model->email), + passwordHash: $model->password_hash, + ); + } +} diff --git a/backend/app/User/User.php b/backend/app/User/User.php new file mode 100644 index 0000000..3d8ed63 --- /dev/null +++ b/backend/app/User/User.php @@ -0,0 +1,29 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..7d8c09d --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,27 @@ +|UserModel newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserModel newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserModel query() + * @method static \Illuminate\Database\Eloquent\Builder|UserModel whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserModel whereId($value) + * + * @mixin \Eloquent + */ +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; + + protected $fillable = ['email', 'password_hash']; +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..3a1f083 --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,19 @@ +withRouting( - web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..f32d5a4 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,15 @@ +token, + $dto->user, + $dto->createdAt, + $dto->expiresAt + ); + $this->sessionsByToken[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + if (! isset($this->sessionsByToken[$token])) { + return null; + } + + $stored = $this->sessionsByToken[$token]; + + return new Session( + $stored->getToken(), + $stored->getUser(), + $stored->getCreatedAt(), + $stored->getExpiresAt() + ); + } + + public function deleteByToken(string $token): void + { + unset($this->sessionsByToken[$token]); + } +} diff --git a/backend/tests/Fakes/FakeTokenGenerator.php b/backend/tests/Fakes/FakeTokenGenerator.php new file mode 100644 index 0000000..dcb8819 --- /dev/null +++ b/backend/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,13 @@ +usersById) + 1; + $user = new User($id, $dto->email, $dto->passwordHash); + $this->usersById[$id] = $user; + $this->usersByEmail[$dto->email->value()] = $user; + + return $user; + } + + public function findByEmail(EmailAddress $email): ?User + { + if (! isset($this->usersByEmail[$email->value()])) { + return null; + } + + $stored = $this->usersByEmail[$email->value()]; + + return new User( + $stored->getId(), + $stored->getEmail(), + $stored->getPasswordHash() + ); + } + + public function findByEmailDomain(string $domain): array + { + $result = []; + foreach ($this->usersByEmail as $email => $stored) { + if (str_ends_with($email, '@' . $domain)) { + $result[] = new User( + $stored->getId(), + $stored->getEmail(), + $stored->getPasswordHash() + ); + } + } + + return $result; + } + + public function find(int $id): ?User + { + if (! isset($this->usersById[$id])) { + return null; + } + + $stored = $this->usersById[$id]; + + return new User( + $stored->getId(), + $stored->getEmail(), + $stored->getPasswordHash() + ); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..8a96f41 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -0,0 +1,99 @@ +userRepo = new FakeUserRepository(); + $this->hasher = new FakeHasher(); + + $this->authenticateUser = new AuthenticateUser($this->userRepo, $this->hasher); + } + + public function testAuthenticatesValidUser(): void + { + $email = new EmailAddress('user@example.com'); + $this->userRepo->create(new CreateUserDto($email, 'hashed-secret')); + + $request = new AuthenticateUserRequest('user@example.com', 'secret'); + $user = $this->authenticateUser->execute($request); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame('user@example.com', $user->getEmail()->value()); + } + + public function testThrowsWhenEmailMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $request = new AuthenticateUserRequest(null, 'secret'); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenPasswordMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $request = new AuthenticateUserRequest('user@example.com', null); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenEmailEmpty(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $request = new AuthenticateUserRequest('', 'secret'); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenPasswordEmpty(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $request = new AuthenticateUserRequest('user@example.com', ''); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenUserNotFound(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $request = new AuthenticateUserRequest('missing@example.com', 'secret'); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenPasswordIncorrect(): void + { + $email = new EmailAddress('user@example.com'); + $this->userRepo->create(new CreateUserDto($email, 'hashed-secret')); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $request = new AuthenticateUserRequest('user@example.com', 'wrong'); + $this->authenticateUser->execute($request); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..d6368f2 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,47 @@ +sessionRepo = new FakeSessionRepository(); + $this->tokenGenerator = new FakeTokenGenerator(); + $this->clock = new FakeClock(); + + $this->createSession = new CreateSession($this->sessionRepo, $this->tokenGenerator, $this->clock); + } + + public function testCreatesSessionForUser(): void + { + $email = new EmailAddress('user@example.com'); + $user = new User(1, $email, 'hashed-password'); + + $session = $this->createSession->execute($user); + + $this->assertSame('fake-token-123', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertFalse($session->isExpired($this->clock->now())); + $this->assertSame('2026-05-18 12:00:00', $session->getCreatedAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-05-25 12:00:00', $session->getExpiresAt()->format('Y-m-d H:i:s')); + + $stored = $this->sessionRepo->findByToken($session->getToken()); + $this->assertNotNull($stored); + $this->assertSame('fake-token-123', $stored->getToken()); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php new file mode 100644 index 0000000..2b28630 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -0,0 +1,46 @@ +sessionRepo = new FakeSessionRepository(); + $this->logout = new Logout($this->sessionRepo); + } + + public function testDeletesSessionByToken(): void + { + $email = new EmailAddress('user@example.com'); + $user = new User(1, $email, 'hashed-password'); + + $session = $this->sessionRepo->create(new CreateSessionDto( + 'session-token', + $user, + new \DateTimeImmutable(), + new \DateTimeImmutable('+1 hour') + )); + + $this->logout->execute('session-token'); + + $this->assertNull($this->sessionRepo->findByToken('session-token')); + } + + public function testDeletesMissingTokenIsIdempotent(): void + { + $this->logout->execute('nonexistent-token'); + + $this->assertNull($this->sessionRepo->findByToken('nonexistent-token')); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php new file mode 100644 index 0000000..89734e9 --- /dev/null +++ b/backend/tests/Unit/User/UserTest.php @@ -0,0 +1,20 @@ +assertSame(1, $user->getId()); + $this->assertSame($email, $user->getEmail()); + $this->assertSame('hashed-password', $user->getPasswordHash()); + } +} diff --git a/process-compose.yml b/process-compose.yml index 7f71fe0..4fcfb4e 100644 --- a/process-compose.yml +++ b/process-compose.yml @@ -12,8 +12,8 @@ processes: period_seconds: 2 backend: - command: bash backend/bin/serve - working_dir: . + command: php artisan serve --host=127.0.0.1 --port=8000 + working_dir: backend depends_on: postgres: condition: process_healthy