add email signup flow

This commit is contained in:
Yisroel Baum 2026-08-03 20:26:08 +03:00
parent 9945163a2f
commit 3dc9204979
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
27 changed files with 629 additions and 24 deletions

View file

@ -36,9 +36,14 @@ class AuthenticateUser
throw new UnauthorizedException('invalid credentials'); throw new UnauthorizedException('invalid credentials');
} }
$passwordHash = $user->getPasswordHash();
if ($passwordHash === null) {
throw new UnauthorizedException('invalid credentials');
}
$passwordMatches = $this->hasher->verify( $passwordMatches = $this->hasher->verify(
$request->password, $request->password,
$user->getPasswordHash(), $passwordHash,
); );
if (! $passwordMatches) { if (! $passwordMatches) {
throw new UnauthorizedException('invalid credentials'); throw new UnauthorizedException('invalid credentials');

View file

@ -0,0 +1,15 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
use DateTimeImmutable;
final readonly class CreateEmailConfirmationTokenDto
{
public function __construct(
public User $user,
public DateTimeImmutable $availableTo,
public string $token,
) {}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
use App\User\UserRepository;
use DomainException;
class EloquentEmailConfirmationTokenRepository implements
EmailConfirmationTokenRepository
{
public function __construct(
private UserRepository $userRepository,
) {}
public function create(
CreateEmailConfirmationTokenDto $dto,
): EmailConfirmationToken {
$model = EmailConfirmationTokenModel::create([
'user_id' => $dto->user->getId(),
'token' => $dto->token,
'available_to' => $dto->availableTo,
]);
return $this->toDomain($model);
}
public function findByToken(string $token): ?EmailConfirmationToken
{
$model = EmailConfirmationTokenModel::query()
->where('token', $token)
->first();
return $model === null ? null : $this->toDomain($model);
}
public function findByUser(User $user): ?EmailConfirmationToken
{
$model = EmailConfirmationTokenModel::query()
->where('user_id', $user->getId())
->first();
return $model === null ? null : $this->toDomain($model);
}
public function delete(int $id): void
{
EmailConfirmationTokenModel::where('id', $id)->delete();
}
private function toDomain(
EmailConfirmationTokenModel $model,
): EmailConfirmationToken {
$user = $this->userRepository->find($model->user_id);
if ($user === null) {
throw new DomainException(
"User with id {$model->user_id} not found",
);
}
return new EmailConfirmationToken(
id: $model->id,
user: $user,
availableTo: $model->available_to,
token: $model->token,
);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
use DateTimeImmutable;
final readonly class EmailConfirmationToken
{
public function __construct(
private int $id,
private User $user,
private DateTimeImmutable $availableTo,
private string $token,
) {}
public function getId(): int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
public function getAvailableTo(): DateTimeImmutable
{
return $this->availableTo;
}
public function getToken(): string
{
return $this->token;
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Email\EmailConfirmationToken;
use DateTimeImmutable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $user_id
* @property string $token
* @property DateTimeImmutable $available_to
*
* @method static Builder<static>|EmailConfirmationTokenModel newModelQuery()
* @method static Builder<static>|EmailConfirmationTokenModel newQuery()
* @method static Builder<static>|EmailConfirmationTokenModel query()
*
* @mixin \Eloquent
*/
#[Fillable([
'user_id',
'token',
'available_to',
])]
class EmailConfirmationTokenModel extends Model
{
protected $table = 'email_confirmation_tokens';
public $timestamps = false;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'available_to' => 'immutable_datetime',
];
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
interface EmailConfirmationTokenRepository
{
public function create(
CreateEmailConfirmationTokenDto $dto,
): EmailConfirmationToken;
public function findByToken(string $token): ?EmailConfirmationToken;
public function findByUser(User $user): ?EmailConfirmationToken;
public function delete(int $id): void;
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Email\EmailConfirmationToken\UseCases;
use App\Auth\Clock;
use App\Auth\TokenGenerator;
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
use App\Email\EmailConfirmationToken\EmailConfirmationToken;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\Exceptions\BadRequestException;
class CreateEmailConfirmationToken
{
public function __construct(
private EmailConfirmationTokenRepository $tokenRepository,
private Clock $clock,
private TokenGenerator $tokenGenerator,
) {}
/**
* @throws BadRequestException
*/
public function execute(
CreateEmailConfirmationTokenRequest $request,
): EmailConfirmationToken {
if ($request->user === null) {
throw new BadRequestException('user is required');
}
if ($request->minuteOffset === null) {
throw new BadRequestException('minuteOffset is required');
}
$existingToken = $this->tokenRepository->findByUser($request->user);
if ($existingToken !== null) {
$this->tokenRepository->delete($existingToken->getId());
}
return $this->tokenRepository->create(
new CreateEmailConfirmationTokenDto(
user: $request->user,
availableTo: $this->clock->now()->modify(
"+{$request->minuteOffset} minutes",
),
token: $this->tokenGenerator->generate(),
),
);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Email\EmailConfirmationToken\UseCases;
use App\User\User;
final readonly class CreateEmailConfirmationTokenRequest
{
public function __construct(
public ?User $user,
public ?int $minuteOffset,
) {}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Email;
interface EmailFactory
{
public function makeConfirmationEmail(string $token): string;
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Email;
use App\Shared\ValueObject\EmailAddress;
interface Emailer
{
public function send(
EmailAddress $recipient,
string $subject,
string $body,
): void;
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Email;
use RuntimeException;
class LaravelEmailFactory implements EmailFactory
{
public function makeConfirmationEmail(string $token): string
{
$configuredFrontendUrl = config('app.frontend_url');
if (! is_string($configuredFrontendUrl)) {
throw new RuntimeException('FRONTEND_URL must be configured');
}
$frontendUrl = rtrim($configuredFrontendUrl, '/');
$confirmationUrl = $frontendUrl
.'/confirm-email?token='.urlencode($token);
return "Welcome to Attainly.\n\n"
."Confirm your email and choose a password:\n"
."{$confirmationUrl}\n\n"
.'This link expires in 10 minutes.';
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Email;
use App\Shared\ValueObject\EmailAddress;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Message;
class LaravelEmailer implements Emailer
{
public function __construct(private Mailer $mailer) {}
public function send(
EmailAddress $recipient,
string $subject,
string $body,
): void {
$this->mailer->raw(
$body,
function (Message $message) use ($recipient, $subject): void {
$message->to($recipient->value())->subject($subject);
},
);
}
}

View file

@ -10,7 +10,12 @@ use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException; use App\Exceptions\UnauthorizedException;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use App\Shared\Http\RequestInput; use App\Shared\Http\RequestInput;
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail;
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmailRequest;
use App\User\UseCases\SignupUser\SignupUser;
use App\User\UseCases\SignupUser\SignupUserRequest;
use App\User\User; use App\User\User;
use DomainException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Cookie;
@ -18,11 +23,62 @@ use Symfony\Component\HttpFoundation\Cookie;
class AuthController extends Controller class AuthController extends Controller
{ {
public function __construct( public function __construct(
private SignupUser $signupUser,
private ConfirmUserEmail $confirmUserEmail,
private AuthenticateUser $authenticateUser, private AuthenticateUser $authenticateUser,
private CreateSession $createSession, private CreateSession $createSession,
private Logout $logout, private Logout $logout,
) {} ) {}
public function signup(Request $request): JsonResponse
{
$input = new RequestInput($request);
try {
$this->signupUser->execute(new SignupUserRequest(
email: $input->string('email'),
));
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
400,
);
} catch (DomainException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
409,
);
}
return new JsonResponse(null, 201);
}
public function confirmEmail(Request $request): JsonResponse
{
$input = new RequestInput($request);
try {
$user = $this->confirmUserEmail->execute(
new ConfirmUserEmailRequest(
token: $input->string('token'),
password: $input->string('password'),
),
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
400,
);
} catch (DomainException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
409,
);
}
return $this->authenticatedResponse($user);
}
public function login(Request $request): JsonResponse public function login(Request $request): JsonResponse
{ {
$input = new RequestInput($request); $input = new RequestInput($request);
@ -44,23 +100,7 @@ class AuthController extends Controller
); );
} }
$session = $this->createSession->execute($user); return $this->authenticatedResponse($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 public function me(Request $request): JsonResponse
@ -105,4 +145,24 @@ class AuthController extends Controller
'email' => $user->getEmail()->value(), 'email' => $user->getEmail()->value(),
]; ];
} }
private function authenticatedResponse(User $user): JsonResponse
{
$session = $this->createSession->execute($user);
$response = new JsonResponse([
'user' => $this->userPayload($user),
]);
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,
));
}
} }

View file

@ -10,6 +10,12 @@ use App\Auth\RandomTokenGenerator;
use App\Auth\SessionRepository; use App\Auth\SessionRepository;
use App\Auth\SystemClock; use App\Auth\SystemClock;
use App\Auth\TokenGenerator; use App\Auth\TokenGenerator;
use App\Email\EmailConfirmationToken\EloquentEmailConfirmationTokenRepository;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\Email\Emailer;
use App\Email\EmailFactory;
use App\Email\LaravelEmailer;
use App\Email\LaravelEmailFactory;
use App\User\EloquentUserRepository; use App\User\EloquentUserRepository;
use App\User\UserRepository; use App\User\UserRepository;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
@ -33,6 +39,12 @@ class AppServiceProvider extends ServiceProvider
SessionRepository::class, SessionRepository::class,
EloquentSessionRepository::class, EloquentSessionRepository::class,
); );
$this->app->bind(
EmailConfirmationTokenRepository::class,
EloquentEmailConfirmationTokenRepository::class,
);
$this->app->bind(Emailer::class, LaravelEmailer::class);
$this->app->bind(EmailFactory::class, LaravelEmailFactory::class);
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
$this->app->bind(Clock::class, SystemClock::class); $this->app->bind(Clock::class, SystemClock::class);

View file

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

View file

@ -3,6 +3,7 @@
namespace App\User; namespace App\User;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use DomainException;
class EloquentUserRepository implements UserRepository class EloquentUserRepository implements UserRepository
{ {
@ -38,6 +39,22 @@ class EloquentUserRepository implements UserRepository
return $this->toDomain($model); return $this->toDomain($model);
} }
public function update(User $user): User
{
$model = UserModel::find($user->getId());
if ($model === null) {
throw new DomainException(
"User with id {$user->getId()} not found",
);
}
$model->email = $user->getEmail()->value();
$model->passwordHash = $user->getPasswordHash();
$model->save();
return $this->toDomain($model);
}
private function toDomain(UserModel $model): User private function toDomain(UserModel $model): User
{ {
return new User( return new User(

View file

@ -0,0 +1,61 @@
<?php
namespace App\User\UseCases\ConfirmUserEmail;
use App\Auth\Clock;
use App\Auth\PasswordHasher;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\Exceptions\BadRequestException;
use App\User\User;
use App\User\UserRepository;
use DomainException;
class ConfirmUserEmail
{
public function __construct(
private EmailConfirmationTokenRepository $tokenRepository,
private UserRepository $userRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
) {}
/**
* @throws BadRequestException
* @throws DomainException
*/
public function execute(ConfirmUserEmailRequest $request): User
{
if ($request->token === null || $request->token === '') {
throw new BadRequestException('token is required');
}
if ($request->password === null || $request->password === '') {
throw new BadRequestException('password is required');
}
if (strlen($request->password) < 8) {
throw new BadRequestException(
'password must be at least 8 characters',
);
}
$token = $this->tokenRepository->findByToken($request->token);
if ($token === null) {
throw new DomainException('token not found');
}
if ($token->getAvailableTo() < $this->clock->now()) {
throw new DomainException('token expired');
}
$user = $token->getUser();
if ($user->getPasswordHash() !== null) {
throw new DomainException('account already confirmed');
}
$user->setPasswordHash(
$this->passwordHasher->hash($request->password),
);
$confirmedUser = $this->userRepository->update($user);
$this->tokenRepository->delete($token->getId());
return $confirmedUser;
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\User\UseCases\ConfirmUserEmail;
final readonly class ConfirmUserEmailRequest
{
public function __construct(
public ?string $token,
public ?string $password,
) {}
}

View file

@ -0,0 +1,79 @@
<?php
namespace App\User\UseCases\SignupUser;
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationToken;
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationTokenRequest;
use App\Email\Emailer;
use App\Email\EmailFactory;
use App\Exceptions\BadRequestException;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\User;
use App\User\UserRepository;
use DomainException;
use InvalidArgumentException;
class SignupUser
{
public function __construct(
private UserRepository $userRepository,
private CreateEmailConfirmationToken $createToken,
private Emailer $emailer,
private EmailFactory $emailFactory,
) {}
/**
* @throws BadRequestException
* @throws DomainException
*/
public function execute(SignupUserRequest $request): void
{
if ($request->email === null || trim($request->email) === '') {
throw new BadRequestException('email is required');
}
try {
$email = new EmailAddress($request->email);
} catch (InvalidArgumentException) {
throw new BadRequestException('email must be valid');
}
$user = $this->findOrCreatePendingUser($email);
$token = $this->createToken->execute(
new CreateEmailConfirmationTokenRequest(
user: $user,
minuteOffset: 10,
),
);
$body = $this->emailFactory->makeConfirmationEmail(
$token->getToken(),
);
$this->emailer->send(
$user->getEmail(),
'Confirm your Attainly email',
$body,
);
}
/**
* @throws DomainException
*/
private function findOrCreatePendingUser(EmailAddress $email): User
{
$user = $this->userRepository->findByEmail($email);
if ($user === null) {
return $this->userRepository->create(new CreateUserDto(
email: $email,
passwordHash: null,
));
}
if ($user->getPasswordHash() !== null) {
throw new DomainException(
"{$email->value()} already has an account",
);
}
return $user;
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\User\UseCases\SignupUser;
final readonly class SignupUserRequest
{
public function __construct(public ?string $email) {}
}

View file

@ -4,12 +4,12 @@ namespace App\User;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
final readonly class User final class User
{ {
public function __construct( public function __construct(
private int $id, private int $id,
private EmailAddress $email, private EmailAddress $email,
private string $passwordHash, private ?string $passwordHash,
) {} ) {}
public function getId(): int public function getId(): int
@ -22,8 +22,13 @@ final readonly class User
return $this->email; return $this->email;
} }
public function getPasswordHash(): string public function getPasswordHash(): ?string
{ {
return $this->passwordHash; return $this->passwordHash;
} }
public function setPasswordHash(string $passwordHash): void
{
$this->passwordHash = $passwordHash;
}
} }

View file

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

View file

@ -11,4 +11,6 @@ interface UserRepository
public function find(int $id): ?User; public function find(int $id): ?User;
public function findByEmail(EmailAddress $email): ?User; public function findByEmail(EmailAddress $email): ?User;
public function update(User $user): User;
} }

View file

@ -54,6 +54,8 @@ return [
'url' => env('APP_URL', 'http://localhost'), 'url' => env('APP_URL', 'http://localhost'),
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Timezone | Application Timezone

View file

@ -11,7 +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('passwordHash'); $table->string('passwordHash')->nullable();
}); });
} }

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create(
'email_confirmation_tokens',
function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')
->unique()
->constrained('users')
->cascadeOnDelete();
$table->string('token', 64)->unique();
$table->timestamp('available_to');
},
);
}
public function down(): void
{
Schema::dropIfExists('email_confirmation_tokens');
}
};

View file

@ -5,6 +5,8 @@ use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::post('/signup', [AuthController::class, 'signup']);
Route::post('/confirm-email', [AuthController::class, 'confirmEmail']);
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']);