diff --git a/backend/app/Auth/Clock.php b/backend/app/Auth/Clock.php new file mode 100644 index 0000000..b96ad32 --- /dev/null +++ b/backend/app/Auth/Clock.php @@ -0,0 +1,10 @@ + $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->userRepository->find($model->user_id); + if ($user === null) { + return null; + } + + return new Session( + token: $model->token, + user: $user, + createdAt: $this->toUtc($model->created_at), + expiresAt: $this->toUtc($model->expires_at), + ); + } + + public function deleteByToken(string $token): void + { + SessionModel::where('token', $token)->delete(); + } + + private function toUtc(DateTimeImmutable $dateTime): DateTimeImmutable + { + return DateTimeImmutable::createFromInterface($dateTime) + ->setTimezone(new DateTimeZone('UTC')); + } +} diff --git a/backend/app/Auth/Session.php b/backend/app/Auth/Session.php new file mode 100644 index 0000000..5fd9cb1 --- /dev/null +++ b/backend/app/Auth/Session.php @@ -0,0 +1,41 @@ +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..37b30cb --- /dev/null +++ b/backend/app/Auth/SessionModel.php @@ -0,0 +1,50 @@ +|SessionModel newModelQuery() + * @method static Builder|SessionModel newQuery() + * @method static Builder|SessionModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'token', + 'user_id', + 'created_at', + 'expires_at', +])] +class SessionModel extends Model +{ + protected $table = 'sessions'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + '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 @@ +cookie(self::COOKIE_NAME); + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepository->findByToken($token); + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepository->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/Models/User.php b/backend/app/Models/User.php deleted file mode 100644 index 4c7c078..0000000 --- a/backend/app/Models/User.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index f1525e9..52192d0 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,12 @@ namespace App\Providers; +use App\Auth\Clock; +use App\Auth\EloquentSessionRepository; +use App\Auth\SessionRepository; +use App\Auth\SystemClock; +use App\User\EloquentUserRepository; +use App\User\UserRepository; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -15,7 +21,15 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind( + UserRepository::class, + EloquentUserRepository::class, + ); + $this->app->bind( + SessionRepository::class, + EloquentSessionRepository::class, + ); + $this->app->bind(Clock::class, SystemClock::class); } /** diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php new file mode 100644 index 0000000..de15bd9 --- /dev/null +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -0,0 +1,40 @@ +normalized = $normalizedEmail; + } + + public function value(): string + { + return $this->normalized; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..735320b --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,12 @@ + $dto->email->value(), + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + if ($model === null) { + return null; + } + + return $this->toDomain($model); + } + + private function toDomain(UserModel $model): User + { + return new User( + id: $model->id, + email: new EmailAddress($model->email), + ); + } +} diff --git a/backend/app/User/User.php b/backend/app/User/User.php new file mode 100644 index 0000000..8c9fa9c --- /dev/null +++ b/backend/app/User/User.php @@ -0,0 +1,23 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..74f3211 --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,25 @@ +|UserModel newModelQuery() + * @method static Builder|UserModel newQuery() + * @method static Builder|UserModel query() + * + * @mixin \Eloquent + */ +#[Fillable(['email'])] +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..5fcd6eb --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,10 @@ + [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - -]; diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..dafb6c2 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,21 @@ +id(); + $table->string('email')->unique(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/backend/database/migrations/2026_07_31_000000_create_sessions_table.php b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php new file mode 100644 index 0000000..687d9fb --- /dev/null +++ b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php @@ -0,0 +1,25 @@ +string('token', 64)->primary(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->timestamp('created_at'); + $table->timestamp('expires_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..30374d3 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,18 @@ +currentTime; + } +} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..d64efd5 --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,38 @@ + + */ + private array $sessions = []; + + public function create(CreateSessionDto $dto): Session + { + $session = new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + return $this->sessions[$token] ?? null; + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php new file mode 100644 index 0000000..8d84a1d --- /dev/null +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -0,0 +1,119 @@ +now = $this->utc('2026-07-31T12:00:00'); + $this->app->instance(Clock::class, new FakeClock($this->now)); + + Route::middleware(AuthMiddleware::class)->get( + '/test/authenticated-user', + function (Request $request): JsonResponse { + $user = $request->attributes->get('user'); + if (! $user instanceof User) { + return new JsonResponse(['error' => 'missing user'], 500); + } + + return new JsonResponse([ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]); + }, + ); + } + + public function test_valid_cookie_reaches_the_protected_route(): void + { + $user = $this->createUserAndSession( + token: 'valid-token', + expiresAt: $this->now->modify('+7 days'), + ); + + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/test/authenticated-user'); + + $response->assertOk()->assertExactJson([ + 'id' => $user->getId(), + 'email' => 'user@example.com', + ]); + } + + public function test_missing_cookie_is_rejected(): void + { + $response = $this->getJson('/test/authenticated-user'); + + $response + ->assertStatus(401) + ->assertExactJson(['error' => 'unauthenticated']); + } + + public function test_expired_cookie_is_rejected_and_deleted(): void + { + $this->createUserAndSession( + token: 'expired-token', + expiresAt: $this->now->modify('-1 day'), + ); + + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'expired-token', + )->getJson('/test/authenticated-user'); + + $response->assertStatus(401); + $this->assertNull( + app(SessionRepository::class)->findByToken('expired-token'), + ); + } + + private function createUserAndSession( + string $token, + DateTimeImmutable $expiresAt, + ): User { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + app(SessionRepository::class)->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $this->now, + expiresAt: $expiresAt, + )); + + return $user; + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php new file mode 100644 index 0000000..32d170d --- /dev/null +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -0,0 +1,85 @@ +create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $createdAt = $this->utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $repository = app(SessionRepository::class); + + $session = $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + )); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertDatabaseHas('sessions', [ + 'token' => 'session-token', + 'user_id' => $user->getId(), + ]); + + $foundSession = $repository->findByToken('session-token'); + + $this->assertNotNull($foundSession); + $this->assertSame( + $user->getId(), + $foundSession->getUser()->getId(), + ); + $this->assertEquals($createdAt, $foundSession->getCreatedAt()); + $this->assertEquals($expiresAt, $foundSession->getExpiresAt()); + } + + public function test_it_returns_null_for_an_unknown_token(): void + { + $repository = app(SessionRepository::class); + + $this->assertNull($repository->findByToken('unknown-token')); + } + + public function test_it_deletes_a_session_by_token(): void + { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $repository = app(SessionRepository::class); + $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $this->utc('2026-08-07T12:00:00'), + )); + + $repository->deleteByToken('session-token'); + + $this->assertNull($repository->findByToken('session-token')); + $this->assertDatabaseMissing('sessions', [ + 'token' => 'session-token', + ]); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php new file mode 100644 index 0000000..207523f --- /dev/null +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -0,0 +1,48 @@ +create(new CreateUserDto( + email: new EmailAddress('Founder@EXAMPLE.COM'), + )); + + $this->assertGreaterThan(0, $user->getId()); + $this->assertSame( + 'Founder@example.com', + $user->getEmail()->value(), + ); + $this->assertDatabaseHas('users', [ + 'id' => $user->getId(), + 'email' => 'Founder@example.com', + ]); + + $foundUser = $repository->find($user->getId()); + + $this->assertNotNull($foundUser); + $this->assertSame($user->getId(), $foundUser->getId()); + $this->assertSame( + $user->getEmail()->value(), + $foundUser->getEmail()->value(), + ); + } + + public function test_it_returns_null_for_an_unknown_user(): void + { + $repository = app(UserRepository::class); + + $this->assertNull($repository->find(999)); + } +} diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..65908c7 --- /dev/null +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,159 @@ +now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository = new FakeSessionRepository; + $this->middleware = new AuthMiddleware( + sessionRepository: $this->sessionRepository, + clock: new FakeClock($this->now), + ); + } + + public function test_missing_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(null), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'unauthenticated'], + json_decode($response->getContent(), true), + ); + $this->assertNull($capturedRequest); + } + + public function test_empty_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(''), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_unknown_token_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('unknown-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_expired_session_is_deleted(): void + { + $this->sessionRepository->create(new CreateSessionDto( + token: 'expired-token', + user: $this->user(), + createdAt: $this->now->modify('-8 days'), + expiresAt: $this->now->modify('-1 day'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('expired-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + $this->assertNull( + $this->sessionRepository->findByToken('expired-token'), + ); + } + + public function test_valid_session_attaches_user_and_calls_next(): void + { + $user = $this->user(); + $this->sessionRepository->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('valid-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($capturedRequest); + $this->assertSame( + $user, + $capturedRequest->attributes->get('user'), + ); + } + + private function requestWithToken(?string $token): Request + { + $request = Request::create('/anything', 'GET'); + if ($token !== null) { + $request->cookies->set(AuthMiddleware::COOKIE_NAME, $token); + } + + return $request; + } + + /** + * @param Request|null $capturedRequest + * @return Closure(Request): JsonResponse + */ + private function captureNextRequest( + ?Request &$capturedRequest, + ): Closure { + return function (Request $request) use (&$capturedRequest) { + $capturedRequest = $request; + + return new JsonResponse(['ok' => true]); + }; + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + ); + } +} diff --git a/backend/tests/Unit/Auth/SessionTest.php b/backend/tests/Unit/Auth/SessionTest.php new file mode 100644 index 0000000..7804333 --- /dev/null +++ b/backend/tests/Unit/Auth/SessionTest.php @@ -0,0 +1,61 @@ +utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + ); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertSame($createdAt, $session->getCreatedAt()); + $this->assertSame($expiresAt, $session->getExpiresAt()); + } + + public function test_it_expires_at_the_expiry_time(): void + { + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: new User( + id: 7, + email: new EmailAddress('user@example.com'), + ), + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $expiresAt, + ); + + $this->assertFalse( + $session->isExpired($expiresAt->modify('-1 second')), + ); + $this->assertTrue($session->isExpired($expiresAt)); + $this->assertTrue( + $session->isExpired($expiresAt->modify('+1 second')), + ); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php new file mode 100644 index 0000000..c47b3e4 --- /dev/null +++ b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php @@ -0,0 +1,27 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Invalid email address: invalid-email', + ); + + new EmailAddress('invalid-email'); + } + + public function test_it_trims_and_normalizes_the_domain(): void + { + $email = new EmailAddress(' Founder@EXAMPLE.COM '); + + $this->assertSame('Founder@example.com', $email->value()); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php new file mode 100644 index 0000000..0ac8d53 --- /dev/null +++ b/backend/tests/Unit/User/UserTest.php @@ -0,0 +1,19 @@ +assertSame(42, $user->getId()); + $this->assertSame($email, $user->getEmail()); + } +}