add password login

This commit is contained in:
Yisroel Baum 2026-07-31 11:38:05 +03:00
parent 93f5f022e4
commit ebfe147ca4
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
12 changed files with 192 additions and 6 deletions

View file

@ -87,3 +87,10 @@ Future versions of Attainly may include:
Attainly is built around a simple idea: Attainly is built around a simple idea:
Large goals become attainable when they are broken into clear, scheduled steps and completed consistently over time. 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`

View file

@ -33,6 +33,7 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local

View file

@ -2,22 +2,118 @@
namespace App\Http\Controllers; 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\User;
use App\User\UserRepository;
use DateInterval;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie;
class AuthController extends Controller 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 public function me(Request $request): JsonResponse
{ {
/** @var User $user */ /** @var User $user */
$user = $request->attributes->get('user'); $user = $request->attributes->get('user');
return new JsonResponse([ return new JsonResponse([
'user' => [ 'user' => $this->userPayload($user),
'id' => $user->getId(),
'email' => $user->getEmail()->value(),
],
]); ]);
} }
/**
* @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,
};
}
} }

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
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)]);
}
}
}

View file

@ -8,5 +8,6 @@ final readonly class CreateUserDto
{ {
public function __construct( public function __construct(
public EmailAddress $email, public EmailAddress $email,
public string $password,
) {} ) {}
} }

View file

@ -3,6 +3,7 @@
namespace App\User; namespace App\User;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use Illuminate\Support\Facades\Hash;
class EloquentUserRepository implements UserRepository class EloquentUserRepository implements UserRepository
{ {
@ -10,6 +11,7 @@ class EloquentUserRepository implements UserRepository
{ {
$model = UserModel::create([ $model = UserModel::create([
'email' => $dto->email->value(), 'email' => $dto->email->value(),
'password' => Hash::make($dto->password),
]); ]);
return $this->toDomain($model); return $this->toDomain($model);
@ -25,6 +27,24 @@ class EloquentUserRepository implements UserRepository
return $this->toDomain($model); 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 private function toDomain(UserModel $model): User
{ {
return new User( return new User(

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property int $id * @property int $id
* @property string $email * @property string $email
* @property string $password
* *
* @method static Builder<static>|UserModel newModelQuery() * @method static Builder<static>|UserModel newModelQuery()
* @method static Builder<static>|UserModel newQuery() * @method static Builder<static>|UserModel newQuery()
@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\Model;
* *
* @mixin \Eloquent * @mixin \Eloquent
*/ */
#[Fillable(['email'])] #[Fillable(['email', 'password'])]
class UserModel extends Model class UserModel extends Model
{ {
protected $table = 'users'; protected $table = 'users';

View file

@ -2,9 +2,16 @@
namespace App\User; namespace App\User;
use App\Shared\ValueObject\EmailAddress;
interface UserRepository interface UserRepository
{ {
public function create(CreateUserDto $dto): User; public function create(CreateUserDto $dto): User;
public function find(int $id): ?User; public function find(int $id): ?User;
public function findByCredentials(
EmailAddress $email,
string $password,
): ?User;
} }

View file

@ -11,6 +11,7 @@ return new class extends Migration
Schema::create('users', function (Blueprint $table): void { Schema::create('users', function (Blueprint $table): void {
$table->id(); $table->id();
$table->string('email')->unique(); $table->string('email')->unique();
$table->string('password');
}); });
} }

View file

@ -4,13 +4,20 @@ namespace Database\Seeders;
use App\User\UserModel; use App\User\UserModel;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class UserSeeder extends Seeder class UserSeeder extends Seeder
{ {
public const string EMAIL = 'user@example.com';
public const string PASSWORD = 'password';
public function run(): void public function run(): void
{ {
UserModel::firstOrCreate([ UserModel::firstOrCreate([
'email' => 'user@example.com', 'email' => self::EMAIL,
], [
'password' => Hash::make(self::PASSWORD),
]); ]);
} }
} }

View file

@ -4,6 +4,9 @@ use App\Http\Controllers\AuthController;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:5,1');
Route::middleware(AuthMiddleware::class)->group(function (): void { Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/me', [AuthController::class, 'me']); Route::get('/me', [AuthController::class, 'me']);
}); });

View file

@ -45,6 +45,7 @@ DEV_DB_PORT="5432"
DEV_DB_DATABASE="$PGDATABASE" DEV_DB_DATABASE="$PGDATABASE"
DEV_DB_USERNAME="$PGUSER" DEV_DB_USERNAME="$PGUSER"
DEV_DB_PASSWORD="" DEV_DB_PASSWORD=""
DEV_SESSION_SECURE_COOKIE="true"
DEV_MAIL_MAILER="smtp" DEV_MAIL_MAILER="smtp"
DEV_MAIL_HOST="127.0.0.1" DEV_MAIL_HOST="127.0.0.1"
DEV_MAIL_PORT="$MAILPIT_SMTP_PORT" 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_DATABASE "$DEV_DB_DATABASE"
set_env_value DB_USERNAME "$DEV_DB_USERNAME" set_env_value DB_USERNAME "$DEV_DB_USERNAME"
set_env_value DB_PASSWORD "$DEV_DB_PASSWORD" 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_MAILER "$DEV_MAIL_MAILER"
set_env_value MAIL_HOST "$DEV_MAIL_HOST" set_env_value MAIL_HOST "$DEV_MAIL_HOST"
set_env_value MAIL_PORT "$DEV_MAIL_PORT" set_env_value MAIL_PORT "$DEV_MAIL_PORT"