From ebfe147ca4a20aeb787ab4e45e2da32198f158a6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 11:38:05 +0300 Subject: [PATCH] add password login --- README.md | 7 ++ backend/.env.example | 1 + .../app/Http/Controllers/AuthController.php | 104 +++++++++++++++++- backend/app/Http/Requests/LoginRequest.php | 40 +++++++ backend/app/User/CreateUserDto.php | 1 + backend/app/User/EloquentUserRepository.php | 20 ++++ backend/app/User/UserModel.php | 3 +- backend/app/User/UserRepository.php | 7 ++ .../0001_01_01_000000_create_users_table.php | 1 + backend/database/seeders/UserSeeder.php | 9 +- backend/routes/api.php | 3 + nix/shell-hook.sh | 2 + 12 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 backend/app/Http/Requests/LoginRequest.php 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/.env.example b/backend/.env.example index 027ae21..f94c680 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,6 +33,7 @@ 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/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php index de82e1a..60edf14 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -2,22 +2,118 @@ namespace App\Http\Controllers; +use App\Auth\Clock; +use App\Auth\CreateSessionDto; +use App\Auth\SessionRepository; +use App\Http\Middleware\AuthMiddleware; +use App\Http\Requests\LoginRequest; +use App\Shared\ValueObject\EmailAddress; use App\User\User; +use App\User\UserRepository; +use DateInterval; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Cookie; class AuthController extends Controller { + public function login( + LoginRequest $request, + UserRepository $userRepository, + SessionRepository $sessionRepository, + Clock $clock, + ): JsonResponse + { + /** @var array{email: string, password: string} $credentials */ + $credentials = $request->validated(); + $user = $userRepository->findByCredentials( + new EmailAddress($credentials['email']), + $credentials['password'], + ); + if ($user === null) { + return new JsonResponse( + ['error' => 'invalid_credentials'], + 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, + )); + + $response = new JsonResponse([ + 'user' => $this->userPayload($user), + ]); + $response->headers->setCookie(new Cookie( + name: AuthMiddleware::COOKIE_NAME, + value: $session->getToken(), + expire: $session->getExpiresAt(), + path: $this->cookiePath(), + domain: $this->cookieDomain(), + secure: (bool) config('session.secure', false), + httpOnly: true, + sameSite: $this->cookieSameSite(), + )); + + return $response; + } + 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(), + ]; + } + + 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 new file mode 100644 index 0000000..99fc8da --- /dev/null +++ b/backend/app/Http/Requests/LoginRequest.php @@ -0,0 +1,40 @@ +> + */ + 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/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index 735320b..f035859 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 $password, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index 5996528..14a8530 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -3,6 +3,7 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use Illuminate\Support\Facades\Hash; class EloquentUserRepository implements UserRepository { @@ -10,6 +11,7 @@ class EloquentUserRepository implements UserRepository { $model = UserModel::create([ 'email' => $dto->email->value(), + 'password' => Hash::make($dto->password), ]); return $this->toDomain($model); @@ -25,6 +27,24 @@ class EloquentUserRepository implements UserRepository return $this->toDomain($model); } + public function findByCredentials( + EmailAddress $email, + string $password, + ): ?User + { + $model = UserModel::query() + ->where('email', $email->value()) + ->first(); + if ( + $model === null + || ! Hash::check($password, $model->password) + ) { + return null; + } + + return $this->toDomain($model); + } + private function toDomain(UserModel $model): User { return new User( diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index 74f3211..83cd7e2 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 $password * * @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', 'password'])] class UserModel extends Model { protected $table = 'users'; diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 5fcd6eb..ca5d783 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -2,9 +2,16 @@ namespace App\User; +use App\Shared\ValueObject\EmailAddress; + interface UserRepository { public function create(CreateUserDto $dto): User; public function find(int $id): ?User; + + public function findByCredentials( + EmailAddress $email, + string $password, + ): ?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..f488882 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('password'); }); } diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php index 886e4c9..bc21285 100644 --- a/backend/database/seeders/UserSeeder.php +++ b/backend/database/seeders/UserSeeder.php @@ -4,13 +4,20 @@ namespace Database\Seeders; use App\User\UserModel; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; 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', + 'email' => self::EMAIL, + ], [ + 'password' => Hash::make(self::PASSWORD), ]); } } diff --git a/backend/routes/api.php b/backend/routes/api.php index d8fba04..e0bfb0e 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -4,6 +4,9 @@ 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::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 73d1874..cbaedba 100644 --- a/nix/shell-hook.sh +++ b/nix/shell-hook.sh @@ -45,6 +45,7 @@ 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" @@ -107,6 +108,7 @@ 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"