diff --git a/backend/.env.example b/backend/.env.example index f94c680..027ae21 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,7 +33,6 @@ SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null -SESSION_SECURE_COOKIE=true BROADCAST_CONNECTION=log FILESYSTEM_DISK=local 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 @@ +validated(); - $user = $userRepository->findByCredentials( - new EmailAddress($credentials['email']), - $credentials['password'], - ); - if ($user === null) { + $input = new RequestInput($request); + + try { + $user = $this->authenticateUser->execute( + new AuthenticateUserRequest( + email: $input->string('email'), + password: $input->string('password'), + ) + ); + } catch (BadRequestException $exception) { return new JsonResponse( - ['error' => 'invalid_credentials'], - 401, + ['error' => $exception->getMessage()], 400 + ); + } catch (UnauthorizedException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 401 ); } - $sessionLifetime = (int) config('session.lifetime', 120); - $createdAt = $clock->now(); - $expiresAt = $createdAt->add( - new DateInterval("PT{$sessionLifetime}M"), - ); - $session = $sessionRepository->create(new CreateSessionDto( - token: bin2hex(random_bytes(32)), - user: $user, - createdAt: $createdAt, - expiresAt: $expiresAt, - )); + $session = $this->createSession->execute($user); $response = new JsonResponse([ 'user' => $this->userPayload($user), - ]); - $response->headers->setCookie(new Cookie( + ], 200); + + return $response->withCookie(Cookie::create( name: AuthMiddleware::COOKIE_NAME, value: $session->getToken(), - expire: $session->getExpiresAt(), - path: $this->cookiePath(), - domain: $this->cookieDomain(), - secure: (bool) config('session.secure', false), + expire: $session->getExpiresAt()->getTimestamp(), + path: '/', + domain: null, + secure: false, httpOnly: true, - sameSite: $this->cookieSameSite(), + raw: false, + sameSite: Cookie::SAMESITE_LAX, )); - - return $response; } public function me(Request $request): JsonResponse @@ -86,34 +81,4 @@ class AuthController extends Controller 'email' => $user->getEmail()->value(), ]; } - - private function cookiePath(): string - { - $path = config('session.path', '/'); - - return is_string($path) ? $path : '/'; - } - - private function cookieDomain(): ?string - { - $domain = config('session.domain'); - - return is_string($domain) ? $domain : null; - } - - /** - * @return ''|'lax'|'none'|'strict'|null - */ - private function cookieSameSite(): ?string - { - $sameSite = config('session.same_site', 'lax'); - - return match ($sameSite) { - '' => '', - Cookie::SAMESITE_LAX => Cookie::SAMESITE_LAX, - Cookie::SAMESITE_NONE => Cookie::SAMESITE_NONE, - Cookie::SAMESITE_STRICT => Cookie::SAMESITE_STRICT, - default => null, - }; - } } diff --git a/backend/app/Http/Requests/LoginRequest.php b/backend/app/Http/Requests/LoginRequest.php deleted file mode 100644 index 99fc8da..0000000 --- a/backend/app/Http/Requests/LoginRequest.php +++ /dev/null @@ -1,40 +0,0 @@ -> - */ - public function rules(): array - { - return [ - 'email' => [ - 'required', - 'string', - 'email', - 'max:255', - ], - 'password' => [ - 'required', - 'string', - ], - ]; - } - - protected function prepareForValidation(): void - { - $email = $this->input('email'); - if (is_string($email)) { - $this->merge(['email' => trim($email)]); - } - } -} 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 f035859..e0267b6 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -8,6 +8,6 @@ final readonly class CreateUserDto { public function __construct( public EmailAddress $email, - public string $password, + public string $passwordHash, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index 14a8530..cee7817 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -3,7 +3,6 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; -use Illuminate\Support\Facades\Hash; class EloquentUserRepository implements UserRepository { @@ -11,7 +10,7 @@ class EloquentUserRepository implements UserRepository { $model = UserModel::create([ 'email' => $dto->email->value(), - 'password' => Hash::make($dto->password), + 'passwordHash' => $dto->passwordHash, ]); return $this->toDomain($model); @@ -27,18 +26,12 @@ class EloquentUserRepository implements UserRepository return $this->toDomain($model); } - public function findByCredentials( - EmailAddress $email, - string $password, - ): ?User + public function findByEmail(EmailAddress $email): ?User { $model = UserModel::query() ->where('email', $email->value()) ->first(); - if ( - $model === null - || ! Hash::check($password, $model->password) - ) { + if ($model === null) { return null; } @@ -50,6 +43,7 @@ class EloquentUserRepository implements UserRepository 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 83cd7e2..d2c64e3 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $email - * @property string $password + * @property string $passwordHash * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() @@ -17,7 +17,7 @@ use Illuminate\Database\Eloquent\Model; * * @mixin \Eloquent */ -#[Fillable(['email', 'password'])] +#[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 ca5d783..4805f3f 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -10,8 +10,5 @@ interface UserRepository public function find(int $id): ?User; - public function findByCredentials( - EmailAddress $email, - string $password, - ): ?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 f488882..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,7 +11,7 @@ return new class extends Migration Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('email')->unique(); - $table->string('password'); + $table->string('passwordHash'); }); } diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php index bc21285..ef163e1 100644 --- a/backend/database/seeders/UserSeeder.php +++ b/backend/database/seeders/UserSeeder.php @@ -2,9 +2,11 @@ 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; -use Illuminate\Support\Facades\Hash; class UserSeeder extends Seeder { @@ -14,10 +16,15 @@ class UserSeeder extends Seeder public function run(): void { - UserModel::firstOrCreate([ - 'email' => self::EMAIL, - ], [ - 'password' => Hash::make(self::PASSWORD), - ]); + $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 e0bfb0e..d2e63d8 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -4,8 +4,7 @@ use App\Http\Controllers\AuthController; use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; -Route::post('/login', [AuthController::class, 'login']) - ->middleware('throttle:5,1'); +Route::post('/login', [AuthController::class, 'login']); Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); diff --git a/nix/shell-hook.sh b/nix/shell-hook.sh index cbaedba..73d1874 100644 --- a/nix/shell-hook.sh +++ b/nix/shell-hook.sh @@ -45,7 +45,6 @@ DEV_DB_PORT="5432" DEV_DB_DATABASE="$PGDATABASE" DEV_DB_USERNAME="$PGUSER" DEV_DB_PASSWORD="" -DEV_SESSION_SECURE_COOKIE="true" DEV_MAIL_MAILER="smtp" DEV_MAIL_HOST="127.0.0.1" DEV_MAIL_PORT="$MAILPIT_SMTP_PORT" @@ -108,7 +107,6 @@ set_env_value DB_PORT "$DEV_DB_PORT" set_env_value DB_DATABASE "$DEV_DB_DATABASE" set_env_value DB_USERNAME "$DEV_DB_USERNAME" set_env_value DB_PASSWORD "$DEV_DB_PASSWORD" -set_env_value SESSION_SECURE_COOKIE "$DEV_SESSION_SECURE_COOKIE" set_env_value MAIL_MAILER "$DEV_MAIL_MAILER" set_env_value MAIL_HOST "$DEV_MAIL_HOST" set_env_value MAIL_PORT "$DEV_MAIL_PORT"