align backend auth patterns

This commit is contained in:
Yisroel Baum 2026-08-02 20:57:25 +03:00
parent b3266b38c8
commit 299efe0839
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
23 changed files with 249 additions and 142 deletions

View file

@ -0,0 +1,16 @@
<?php
namespace App\Auth;
class BcryptPasswordHasher implements PasswordHasher
{
public function hash(string $password): string
{
return password_hash($password, PASSWORD_DEFAULT);
}
public function verify(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Auth;
interface PasswordHasher
{
public function hash(string $password): string;
public function verify(string $password, string $hash): bool;
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Auth;
class RandomTokenGenerator implements TokenGenerator
{
public function generate(): string
{
return bin2hex(random_bytes(32));
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Auth;
interface TokenGenerator
{
public function generate(): string;
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Auth\UseCases\AuthenticateUser;
use App\Auth\PasswordHasher;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use App\User\UserRepository;
class AuthenticateUser
{
public function __construct(
private UserRepository $userRepo,
private PasswordHasher $hasher,
) {}
/**
* @throws BadRequestException
* @throws UnauthorizedException
*/
public function execute(AuthenticateUserRequest $request): User
{
if ($request->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;
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Auth\UseCases\AuthenticateUser;
class AuthenticateUserRequest
{
public function __construct(
public ?string $email,
public ?string $password,
) {}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Auth\UseCases\CreateSession;
use App\Auth\Clock;
use App\Auth\CreateSessionDto;
use App\Auth\Session;
use App\Auth\SessionRepository;
use App\Auth\TokenGenerator;
use App\User\User;
class CreateSession
{
private const SESSION_LIFETIME = '+7 days';
public function __construct(
private SessionRepository $sessionRepo,
private TokenGenerator $tokenGenerator,
private Clock $clock,
) {}
public function execute(User $user): Session
{
$now = $this->clock->now();
$expiresAt = $now->modify(self::SESSION_LIFETIME);
return $this->sessionRepo->create(new CreateSessionDto(
token: $this->tokenGenerator->generate(),
user: $user,
createdAt: $now,
expiresAt: $expiresAt,
));
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use DomainException;
class BadRequestException extends DomainException {}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use DomainException;
class UnauthorizedException extends DomainException {}

View file

@ -2,68 +2,63 @@
namespace App\Http\Controllers;
use App\Auth\Clock;
use App\Auth\CreateSessionDto;
use App\Auth\SessionRepository;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
use App\Auth\UseCases\CreateSession\CreateSession;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\Http\Middleware\AuthMiddleware;
use App\Http\Requests\LoginRequest;
use App\Shared\ValueObject\EmailAddress;
use App\Shared\Http\RequestInput;
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
public function __construct(
private AuthenticateUser $authenticateUser,
private CreateSession $createSession,
) {}
public function login(Request $request): JsonResponse
{
/** @var array{email: string, password: string} $credentials */
$credentials = $request->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,
};
}
}

View file

@ -1,40 +0,0 @@
<?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

@ -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);
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Shared\Http;
use Illuminate\Http\Request;
class RequestInput
{
public function __construct(private Request $request) {}
public function string(string $key): ?string
{
$value = $this->request->input($key);
if (is_string($value)) {
return $value;
}
if (is_int($value) || is_float($value) || is_bool($value)) {
return (string) $value;
}
return null;
}
}

View file

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

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $email
* @property string $password
* @property string $passwordHash
*
* @method static Builder<static>|UserModel newModelQuery()
* @method static Builder<static>|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';

View file

@ -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;
}