diff --git a/README.md b/README.md index 739c8cf..e800d97 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,10 @@ Future versions of Attainly may include: Attainly is built around a simple idea: Large goals become attainable when they are broken into clear, scheduled steps and completed consistently over time. + +## Development Login + +The seeded local development account uses these credentials: + +- Email: `user@example.com` +- Password: `password` 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 @@ +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/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 @@ +authenticateUser->execute( + new AuthenticateUserRequest( + email: $input->string('email'), + password: $input->string('password'), + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 400 + ); + } catch (UnauthorizedException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 401 + ); + } + + $session = $this->createSession->execute($user); + + $response = new JsonResponse([ + 'user' => $this->userPayload($user), + ], 200); + + return $response->withCookie(Cookie::create( + name: AuthMiddleware::COOKIE_NAME, + value: $session->getToken(), + expire: $session->getExpiresAt()->getTimestamp(), + path: '/', + domain: null, + secure: false, + httpOnly: true, + raw: false, + sameSite: Cookie::SAMESITE_LAX, + )); + } + public function me(Request $request): JsonResponse { /** @var User $user */ $user = $request->attributes->get('user'); return new JsonResponse([ - 'user' => [ - 'id' => $user->getId(), - 'email' => $user->getEmail()->value(), - ], + 'user' => $this->userPayload($user), ]); } + + /** + * @return array{id: int, email: string} + */ + private function userPayload(User $user): array + { + return [ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]; + } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 52192d0..36ac77d 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,10 +2,14 @@ namespace App\Providers; +use App\Auth\BcryptPasswordHasher; use App\Auth\Clock; use App\Auth\EloquentSessionRepository; +use App\Auth\PasswordHasher; +use App\Auth\RandomTokenGenerator; use App\Auth\SessionRepository; use App\Auth\SystemClock; +use App\Auth\TokenGenerator; use App\User\EloquentUserRepository; use App\User\UserRepository; use Carbon\CarbonImmutable; @@ -29,6 +33,8 @@ class AppServiceProvider extends ServiceProvider SessionRepository::class, EloquentSessionRepository::class, ); + $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); + $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(Clock::class, SystemClock::class); } diff --git a/backend/app/Shared/Http/RequestInput.php b/backend/app/Shared/Http/RequestInput.php new file mode 100644 index 0000000..6dffb92 --- /dev/null +++ b/backend/app/Shared/Http/RequestInput.php @@ -0,0 +1,23 @@ +request->input($key); + if (is_string($value)) { + return $value; + } + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + + return null; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index 735320b..e0267b6 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -8,5 +8,6 @@ final readonly class CreateUserDto { public function __construct( public EmailAddress $email, + public string $passwordHash, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index 5996528..cee7817 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -10,6 +10,7 @@ class EloquentUserRepository implements UserRepository { $model = UserModel::create([ 'email' => $dto->email->value(), + 'passwordHash' => $dto->passwordHash, ]); return $this->toDomain($model); @@ -25,11 +26,24 @@ class EloquentUserRepository implements UserRepository return $this->toDomain($model); } + public function findByEmail(EmailAddress $email): ?User + { + $model = UserModel::query() + ->where('email', $email->value()) + ->first(); + 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), + passwordHash: $model->passwordHash, ); } } diff --git a/backend/app/User/User.php b/backend/app/User/User.php index 8c9fa9c..3501bee 100644 --- a/backend/app/User/User.php +++ b/backend/app/User/User.php @@ -9,6 +9,7 @@ final readonly class User public function __construct( private int $id, private EmailAddress $email, + private string $passwordHash, ) {} public function getId(): int @@ -20,4 +21,9 @@ final readonly class User { return $this->email; } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } } diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index 74f3211..d2c64e3 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $email + * @property string $passwordHash * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() @@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\Model; * * @mixin \Eloquent */ -#[Fillable(['email'])] +#[Fillable(['email', 'passwordHash'])] class UserModel extends Model { protected $table = 'users'; diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 5fcd6eb..4805f3f 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -2,9 +2,13 @@ namespace App\User; +use App\Shared\ValueObject\EmailAddress; + interface UserRepository { public function create(CreateUserDto $dto): User; public function find(int $id): ?User; + + public function findByEmail(EmailAddress $email): ?User; } 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 index dafb6c2..065daab 100644 --- a/backend/database/migrations/0001_01_01_000000_create_users_table.php +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -11,6 +11,7 @@ return new class extends Migration Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('email')->unique(); + $table->string('passwordHash'); }); } diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php index 886e4c9..ef163e1 100644 --- a/backend/database/seeders/UserSeeder.php +++ b/backend/database/seeders/UserSeeder.php @@ -2,15 +2,29 @@ namespace Database\Seeders; -use App\User\UserModel; +use App\Auth\PasswordHasher; +use App\Shared\ValueObject\EmailAddress; +use App\User\CreateUserDto; +use App\User\UserRepository; use Illuminate\Database\Seeder; class UserSeeder extends Seeder { + public const string EMAIL = 'user@example.com'; + + public const string PASSWORD = 'password'; + public function run(): void { - UserModel::firstOrCreate([ - 'email' => 'user@example.com', - ]); + $userRepository = app(UserRepository::class); + $email = new EmailAddress(self::EMAIL); + if ($userRepository->findByEmail($email) !== null) { + return; + } + + $userRepository->create(new CreateUserDto( + email: $email, + passwordHash: app(PasswordHasher::class)->hash(self::PASSWORD), + )); } } diff --git a/backend/routes/api.php b/backend/routes/api.php index d8fba04..d2e63d8 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -4,6 +4,8 @@ use App\Http\Controllers\AuthController; use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; +Route::post('/login', [AuthController::class, 'login']); + Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); }); diff --git a/backend/tests/Fakes/FakePasswordHasher.php b/backend/tests/Fakes/FakePasswordHasher.php new file mode 100644 index 0000000..9e93325 --- /dev/null +++ b/backend/tests/Fakes/FakePasswordHasher.php @@ -0,0 +1,18 @@ +hash($password) === $hash; + } +} diff --git a/backend/tests/Fakes/FakeTokenGenerator.php b/backend/tests/Fakes/FakeTokenGenerator.php new file mode 100644 index 0000000..54926f3 --- /dev/null +++ b/backend/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,28 @@ +callCount >= count($this->tokens)) { + throw new RuntimeException('FakeTokenGenerator exhausted'); + } + + $token = $this->tokens[$this->callCount]; + $this->callCount++; + + return $token; + } +} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php new file mode 100644 index 0000000..3672f87 --- /dev/null +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -0,0 +1,56 @@ + + */ + private array $users = []; + + public function create(CreateUserDto $dto): User + { + $id = count($this->users) + 1; + $user = new User( + id: $id, + email: $dto->email, + passwordHash: $dto->passwordHash, + ); + $this->users[$id] = $user; + + return $this->copy($user); + } + + public function find(int $id): ?User + { + $user = $this->users[$id] ?? null; + + return $user === null ? null : $this->copy($user); + } + + public function findByEmail(EmailAddress $email): ?User + { + foreach ($this->users as $user) { + if ($user->getEmail()->value() === $email->value()) { + return $this->copy($user); + } + } + + return null; + } + + private function copy(User $user): User + { + return new User( + id: $user->getId(), + email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), + ); + } +} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index 8d84a1d..91d7be3 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -101,6 +101,7 @@ class AuthMiddlewareTest extends TestCase ): User { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', )); app(SessionRepository::class)->create(new CreateSessionDto( token: $token, diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php index 32d170d..25f9fe5 100644 --- a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -20,6 +20,7 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', )); $createdAt = $this->utc('2026-07-31T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00'); @@ -61,6 +62,7 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', )); $repository = app(SessionRepository::class); $repository->create(new CreateSessionDto( diff --git a/backend/tests/Feature/Auth/LoginEndpointTest.php b/backend/tests/Feature/Auth/LoginEndpointTest.php new file mode 100644 index 0000000..bab28b8 --- /dev/null +++ b/backend/tests/Feature/Auth/LoginEndpointTest.php @@ -0,0 +1,47 @@ +hash($password); + app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: $passwordHash, + )); + + $response = $this->postJson('/api/login', [ + 'email' => $email, + 'password' => $password, + ]); + + $response->assertOk(); + $response->assertJsonPath('user.email', $email); + + $cookie = $response->getCookie( + AuthMiddleware::COOKIE_NAME, + false, + ); + $this->assertNotNull($cookie); + $this->assertNotNull( + app(SessionRepository::class)->findByToken( + $cookie->getValue(), + ), + ); + } +} diff --git a/backend/tests/Feature/Auth/MeEndpointTest.php b/backend/tests/Feature/Auth/MeEndpointTest.php index ec19fc3..3cabaf1 100644 --- a/backend/tests/Feature/Auth/MeEndpointTest.php +++ b/backend/tests/Feature/Auth/MeEndpointTest.php @@ -25,6 +25,7 @@ class MeEndpointTest extends TestCase ); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', )); app(SessionRepository::class)->create(new CreateSessionDto( token: 'valid-token', diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php index 26b6b06..ca1ec91 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -2,6 +2,9 @@ namespace Tests\Feature\Database; +use App\Auth\PasswordHasher; +use App\Shared\ValueObject\EmailAddress; +use App\User\UserRepository; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -17,6 +20,20 @@ class DatabaseSeederTest extends TestCase $this->assertDatabaseHas('users', [ 'email' => 'user@example.com', ]); + $this->assertDatabaseMissing('users', [ + 'email' => 'user@example.com', + 'passwordHash' => 'password', + ]); $this->assertDatabaseCount('users', 1); + + $user = app(UserRepository::class)->findByEmail( + new EmailAddress('user@example.com'), + ); + + $this->assertNotNull($user); + $this->assertTrue(app(PasswordHasher::class)->verify( + 'password', + $user->getPasswordHash(), + )); } } diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php index 207523f..5741cb5 100644 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -17,6 +17,7 @@ class EloquentUserRepositoryTest extends TestCase $repository = app(UserRepository::class); $user = $repository->create(new CreateUserDto( email: new EmailAddress('Founder@EXAMPLE.COM'), + passwordHash: 'hashed-password', )); $this->assertGreaterThan(0, $user->getId()); @@ -28,6 +29,10 @@ class EloquentUserRepositoryTest extends TestCase 'id' => $user->getId(), 'email' => 'Founder@example.com', ]); + $this->assertDatabaseHas('users', [ + 'id' => $user->getId(), + 'passwordHash' => 'hashed-password', + ]); $foundUser = $repository->find($user->getId()); @@ -37,6 +42,10 @@ class EloquentUserRepositoryTest extends TestCase $user->getEmail()->value(), $foundUser->getEmail()->value(), ); + $this->assertSame( + $user->getPasswordHash(), + $foundUser->getPasswordHash(), + ); } public function test_it_returns_null_for_an_unknown_user(): void @@ -45,4 +54,28 @@ class EloquentUserRepositoryTest extends TestCase $this->assertNull($repository->find(999)); } + + public function test_it_finds_a_user_by_email(): void + { + $repository = app(UserRepository::class); + $createdUser = $repository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', + )); + + $foundUser = $repository->findByEmail( + new EmailAddress('user@EXAMPLE.COM'), + ); + + $this->assertNotNull($foundUser); + $this->assertSame($createdUser->getId(), $foundUser->getId()); + } + + public function test_it_returns_null_for_an_unknown_email(): void + { + $repository = app(UserRepository::class); + $this->assertNull($repository->findByEmail( + new EmailAddress('unknown@example.com'), + )); + } } diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index 65908c7..d6a72a1 100644 --- a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -154,6 +154,7 @@ class AuthMiddlewareTest extends TestCase return new User( id: 7, email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', ); } } diff --git a/backend/tests/Unit/Auth/SessionTest.php b/backend/tests/Unit/Auth/SessionTest.php index 7804333..264033c 100644 --- a/backend/tests/Unit/Auth/SessionTest.php +++ b/backend/tests/Unit/Auth/SessionTest.php @@ -16,6 +16,7 @@ class SessionTest extends TestCase $user = new User( id: 7, email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', ); $createdAt = $this->utc('2026-07-31T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00'); @@ -40,6 +41,7 @@ class SessionTest extends TestCase user: new User( id: 7, email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', ), createdAt: $this->utc('2026-07-31T12:00:00'), expiresAt: $expiresAt, diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..585af22 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -0,0 +1,104 @@ +userRepository = new FakeUserRepository; + $this->passwordHasher = new FakePasswordHasher; + $this->useCase = new AuthenticateUser( + $this->userRepository, + $this->passwordHasher, + ); + } + + public function test_null_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: null, + password: 'correct-password', + )); + } + + public function test_null_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: null, + )); + } + + public function test_unknown_email_throws_unauthorized(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'unknown@example.com', + password: 'correct-password', + )); + } + + public function test_wrong_password_throws_unauthorized(): void + { + $this->createUser('correct-password'); + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'wrong-password', + )); + } + + public function test_valid_credentials_return_user(): void + { + $user = $this->createUser('correct-password'); + + $authenticatedUser = $this->useCase->execute( + new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correct-password', + ), + ); + + $this->assertSame($user->getId(), $authenticatedUser->getId()); + $this->assertSame( + $user->getEmail()->value(), + $authenticatedUser->getEmail()->value(), + ); + } + + private function createUser(string $password): User + { + return $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->passwordHasher->hash($password), + )); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..4d85166 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,70 @@ +now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository = new FakeSessionRepository; + $this->useCase = new CreateSession( + $this->sessionRepository, + new FakeTokenGenerator(['session-token']), + new FakeClock($this->now), + ); + } + + public function test_creates_a_seven_day_session_with_generated_token(): void + { + $user = $this->user(); + + $session = $this->useCase->execute($user); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertSame($this->now, $session->getCreatedAt()); + $this->assertEquals( + $this->now->modify('+7 days'), + $session->getExpiresAt(), + ); + } + + public function test_created_session_is_findable_by_token(): void + { + $this->useCase->execute($this->user()); + + $session = $this->sessionRepository->findByToken('session-token'); + + $this->assertNotNull($session); + $this->assertSame(7, $session->getUser()->getId()); + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:correct-password', + ); + } +} diff --git a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..cded75c --- /dev/null +++ b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php @@ -0,0 +1,127 @@ +userRepository = new FakeUserRepository; + $this->passwordHasher = new FakePasswordHasher; + $this->sessionRepository = new FakeSessionRepository; + $authenticateUser = new AuthenticateUser( + $this->userRepository, + $this->passwordHasher, + ); + $createSession = new CreateSession( + $this->sessionRepository, + new FakeTokenGenerator(['session-token']), + new FakeClock(new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + )), + ); + $this->controller = new AuthController( + $authenticateUser, + $createSession, + ); + } + + public function test_login_returns_user_and_cookie(): void + { + $this->createUser('correct-password'); + + $response = $this->controller->login(new Request([ + 'email' => 'user@example.com', + 'password' => 'correct-password', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame( + 'user@example.com', + json_decode($response->getContent(), true)['user']['email'], + ); + $cookie = $response->headers->getCookies()[0]; + $this->assertSame(AuthMiddleware::COOKIE_NAME, $cookie->getName()); + $this->assertSame('session-token', $cookie->getValue()); + $this->assertTrue($cookie->isHttpOnly()); + $this->assertSame('lax', $cookie->getSameSite()); + $this->assertNotNull( + $this->sessionRepository->findByToken('session-token'), + ); + } + + public function test_login_returns_bad_request_for_missing_email(): void + { + $response = $this->controller->login(new Request([ + 'password' => 'correct-password', + ])); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame( + ['error' => 'email is required'], + json_decode($response->getContent(), true), + ); + } + + public function test_login_returns_bad_request_for_missing_password(): void + { + $response = $this->controller->login(new Request([ + 'email' => 'user@example.com', + ])); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame( + ['error' => 'password is required'], + json_decode($response->getContent(), true), + ); + } + + public function test_login_returns_unauthorized_for_invalid_credentials(): void + { + $this->createUser('correct-password'); + + $response = $this->controller->login(new Request([ + 'email' => 'user@example.com', + 'password' => 'wrong-password', + ])); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'invalid credentials'], + json_decode($response->getContent(), true), + ); + } + + private function createUser(string $password): void + { + $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->passwordHasher->hash($password), + )); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php index 0ac8d53..b3c6873 100644 --- a/backend/tests/Unit/User/UserTest.php +++ b/backend/tests/Unit/User/UserTest.php @@ -8,12 +8,17 @@ use PHPUnit\Framework\TestCase; class UserTest extends TestCase { - public function test_it_exposes_its_identity_and_email(): void + public function test_it_exposes_its_identity_email_and_password_hash(): void { $email = new EmailAddress('user@example.com'); - $user = new User(id: 42, email: $email); + $user = new User( + id: 42, + email: $email, + passwordHash: 'hashed-password', + ); $this->assertSame(42, $user->getId()); $this->assertSame($email, $user->getEmail()); + $this->assertSame('hashed-password', $user->getPasswordHash()); } } diff --git a/frontend/website/cypress/e2e/login.cy.ts b/frontend/website/cypress/e2e/login.cy.ts new file mode 100644 index 0000000..155d227 --- /dev/null +++ b/frontend/website/cypress/e2e/login.cy.ts @@ -0,0 +1,151 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +function fillLoginForm(): void { + cy.get('#login-email').type('user@example.com') + cy.get('#login-password').type('correct-password') +} + +describe('password login', () => { + beforeEach(() => { + cy.intercept('GET', '**/api/me', { + statusCode: 401, + body: { error: 'unauthenticated' }, + }).as('me') + }) + + it('logs in and redirects to the dashboard', () => { + cy.intercept('POST', '**/api/login', (request) => { + expect(request.body).to.deep.equal({ + email: 'user@example.com', + password: 'correct-password', + }) + request.reply({ + statusCode: 200, + body: { user: authenticatedUser }, + }) + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.location('pathname').should('equal', '/dashboard') + }) + + it('returns to a safe internal redirect after login', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('login') + + cy.visit('/login?redirect=%2Fdashboard%3Ffocus%3Dtoday') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.location('pathname').should('equal', '/dashboard') + cy.location('search').should('equal', '?focus=today') + }) + + it('ignores an unsafe redirect after login', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('login') + + cy.visit('/login?redirect=https%3A%2F%2Fexample.com') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.location('pathname').should('equal', '/dashboard') + }) + + it('validates required login fields before submitting', () => { + cy.visit('/login') + cy.get('form').submit() + + cy.get('#login-email-error') + .should('have.text', 'Enter a valid email address.') + .and('be.visible') + cy.get('#login-email').should('have.attr', 'aria-invalid', 'true') + cy.get('#login-password-error') + .should('have.text', 'Enter your password.') + .and('be.visible') + cy.get('#login-password').should('have.attr', 'aria-invalid', 'true') + cy.location('pathname').should('equal', '/login') + }) + + it('shows the backend invalid-credentials error', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 401, + body: { error: 'invalid credentials' }, + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.get('[role="alert"]') + .should('have.text', 'invalid credentials') + .and('be.visible') + cy.location('pathname').should('equal', '/login') + }) + + it('shows backend request errors', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 400, + body: { error: 'email is required' }, + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.get('[role="alert"]') + .should('have.text', 'email is required') + .and('be.visible') + cy.get('#login-email-error').should('not.exist') + }) + + it('shows a malformed-response error', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 200, + body: { user: { id: 7 } }, + }).as('malformedLogin') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@malformedLogin') + cy.get('[role="alert"]').should( + 'have.text', + 'Unable to log in. Please try again.', + ) + }) + + it('disables the form while login is pending', () => { + cy.intercept('POST', '**/api/login', { + delay: 500, + statusCode: 200, + body: { user: authenticatedUser }, + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + + cy.get('button[type="submit"]') + .should('be.disabled') + .and('have.text', 'Logging in...') + cy.get('#login-email').should('be.disabled') + cy.get('#login-password').should('be.disabled') + cy.wait('@login') + }) +}) diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue index 709091f..1296d51 100644 --- a/frontend/website/src/components/AuthForm.vue +++ b/frontend/website/src/components/AuthForm.vue @@ -1,16 +1,34 @@ @@ -60,6 +78,22 @@ button:focus-visible { outline-offset: 0.2rem; } +button:disabled { + border-color: #708079; + background: #708079; + box-shadow: none; + cursor: wait; + transform: none; +} + +.auth-form__error { + margin: -0.65rem 0; + color: #a33f37; + font-size: 0.8rem; + font-weight: 650; + line-height: 1.5; +} + @media (prefers-reduced-motion: reduce) { button { transition: none; diff --git a/frontend/website/src/components/AuthTextField.vue b/frontend/website/src/components/AuthTextField.vue index e9ec98c..3e5b087 100644 --- a/frontend/website/src/components/AuthTextField.vue +++ b/frontend/website/src/components/AuthTextField.vue @@ -5,7 +5,20 @@ defineProps<{ type: 'email' | 'password' | 'text' autocomplete: string placeholder: string + modelValue?: string + error?: string + disabled?: boolean }>() + +const emit = defineEmits<{ + 'update:modelValue': [value: string] +}>() + +function updateValue(event: Event): void { + if (event.target instanceof HTMLInputElement) { + emit('update:modelValue', event.target.value) + } +} @@ -62,6 +83,23 @@ input:focus { box-shadow: 0 0 0 3px rgb(77 125 109 / 16%); } +input[aria-invalid='true'] { + border-color: #a54e46; +} + +input:disabled { + color: #67736e; + background: #f5f5f2; + cursor: not-allowed; +} + +.text-field__error { + margin: 0; + color: #a33f37; + font-size: 0.76rem; + line-height: 1.4; +} + @media (prefers-reduced-motion: reduce) { input { transition: none; diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts index 399a301..d720f18 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -13,7 +13,12 @@ const meResponseSchema = z.object({ user: authUserSchema, }) +const loginErrorResponseSchema = z.object({ + error: z.string(), +}) + export type AuthUser = z.infer +export type LoginFieldErrors = Partial> export const useAuthStore = defineStore('auth', () => { const user = ref(null) @@ -57,11 +62,51 @@ export const useAuthStore = defineStore('auth', () => { } } + async function login(email: string, password: string): Promise { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/api/login`, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }) + const responseBody: unknown = await response.json() + + if (response.status === 200) { + user.value = meResponseSchema.parse(responseBody).user + + return true + } + + user.value = null + const errorResponse = loginErrorResponseSchema.safeParse(responseBody) + error.value = errorResponse.success + ? errorResponse.data.error + : 'Unable to log in. Please try again.' + + return false + } catch { + user.value = null + error.value = 'Unable to log in. Please try again.' + + return false + } finally { + loading.value = false + } + } + return { user, loading, error, isAuthenticated, fetchMe, + login, } }) diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue index e4215a4..3088dec 100644 --- a/frontend/website/src/views/LoginView.vue +++ b/frontend/website/src/views/LoginView.vue @@ -1,7 +1,58 @@