From 3dc92049792430c62421111e930f0d0f703a964d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 20:26:08 +0300 Subject: [PATCH] add email signup flow --- .../AuthenticateUser/AuthenticateUser.php | 7 +- .../CreateEmailConfirmationTokenDto.php | 15 +++ ...oquentEmailConfirmationTokenRepository.php | 68 ++++++++++++++ .../EmailConfirmationToken.php | 36 +++++++ .../EmailConfirmationTokenModel.php | 42 +++++++++ .../EmailConfirmationTokenRepository.php | 18 ++++ .../UseCases/CreateEmailConfirmationToken.php | 48 ++++++++++ .../CreateEmailConfirmationTokenRequest.php | 13 +++ backend/app/Email/EmailFactory.php | 8 ++ backend/app/Email/Emailer.php | 14 +++ backend/app/Email/LaravelEmailFactory.php | 25 +++++ backend/app/Email/LaravelEmailer.php | 25 +++++ .../app/Http/Controllers/AuthController.php | 94 +++++++++++++++---- backend/app/Providers/AppServiceProvider.php | 12 +++ backend/app/User/CreateUserDto.php | 2 +- backend/app/User/EloquentUserRepository.php | 17 ++++ .../ConfirmUserEmail/ConfirmUserEmail.php | 61 ++++++++++++ .../ConfirmUserEmailRequest.php | 11 +++ .../User/UseCases/SignupUser/SignupUser.php | 79 ++++++++++++++++ .../UseCases/SignupUser/SignupUserRequest.php | 8 ++ backend/app/User/User.php | 11 ++- backend/app/User/UserModel.php | 2 +- backend/app/User/UserRepository.php | 2 + backend/config/app.php | 2 + .../0001_01_01_000000_create_users_table.php | 2 +- ...create_email_confirmation_tokens_table.php | 29 ++++++ backend/routes/api.php | 2 + 27 files changed, 629 insertions(+), 24 deletions(-) create mode 100644 backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php create mode 100644 backend/app/Email/EmailConfirmationToken/EloquentEmailConfirmationTokenRepository.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php create mode 100644 backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationToken.php create mode 100644 backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php create mode 100644 backend/app/Email/EmailFactory.php create mode 100644 backend/app/Email/Emailer.php create mode 100644 backend/app/Email/LaravelEmailFactory.php create mode 100644 backend/app/Email/LaravelEmailer.php create mode 100644 backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php create mode 100644 backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php create mode 100644 backend/app/User/UseCases/SignupUser/SignupUser.php create mode 100644 backend/app/User/UseCases/SignupUser/SignupUserRequest.php create mode 100644 backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php index 7e8c92c..19a4b9d 100644 --- a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php @@ -36,9 +36,14 @@ class AuthenticateUser throw new UnauthorizedException('invalid credentials'); } + $passwordHash = $user->getPasswordHash(); + if ($passwordHash === null) { + throw new UnauthorizedException('invalid credentials'); + } + $passwordMatches = $this->hasher->verify( $request->password, - $user->getPasswordHash(), + $passwordHash, ); if (! $passwordMatches) { throw new UnauthorizedException('invalid credentials'); diff --git a/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php new file mode 100644 index 0000000..1464bb1 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php @@ -0,0 +1,15 @@ + $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, + ); + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php new file mode 100644 index 0000000..b19e5b1 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php @@ -0,0 +1,36 @@ +id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getAvailableTo(): DateTimeImmutable + { + return $this->availableTo; + } + + public function getToken(): string + { + return $this->token; + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php new file mode 100644 index 0000000..1b4f14f --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php @@ -0,0 +1,42 @@ +|EmailConfirmationTokenModel newModelQuery() + * @method static Builder|EmailConfirmationTokenModel newQuery() + * @method static Builder|EmailConfirmationTokenModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'user_id', + 'token', + 'available_to', +])] +class EmailConfirmationTokenModel extends Model +{ + protected $table = 'email_confirmation_tokens'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'available_to' => 'immutable_datetime', + ]; + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php new file mode 100644 index 0000000..ed71f62 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php @@ -0,0 +1,18 @@ +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(), + ), + ); + } +} diff --git a/backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php b/backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php new file mode 100644 index 0000000..92a67a5 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php @@ -0,0 +1,13 @@ +mailer->raw( + $body, + function (Message $message) use ($recipient, $subject): void { + $message->to($recipient->value())->subject($subject); + }, + ); + } +} diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php index 4fdaea2..623367d 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -10,7 +10,12 @@ use App\Exceptions\BadRequestException; use App\Exceptions\UnauthorizedException; use App\Http\Middleware\AuthMiddleware; 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 DomainException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Cookie; @@ -18,11 +23,62 @@ use Symfony\Component\HttpFoundation\Cookie; class AuthController extends Controller { public function __construct( + private SignupUser $signupUser, + private ConfirmUserEmail $confirmUserEmail, private AuthenticateUser $authenticateUser, private CreateSession $createSession, 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 { $input = new RequestInput($request); @@ -44,23 +100,7 @@ class AuthController extends Controller ); } - $session = $this->createSession->execute($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, - )); + return $this->authenticatedResponse($user); } public function me(Request $request): JsonResponse @@ -105,4 +145,24 @@ class AuthController extends Controller '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, + )); + } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 36ac77d..2255353 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -10,6 +10,12 @@ use App\Auth\RandomTokenGenerator; use App\Auth\SessionRepository; use App\Auth\SystemClock; 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\UserRepository; use Carbon\CarbonImmutable; @@ -33,6 +39,12 @@ class AppServiceProvider extends ServiceProvider SessionRepository::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(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(Clock::class, SystemClock::class); diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index e0267b6..bb8e5d7 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 $passwordHash, + public ?string $passwordHash, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index cee7817..9982439 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 DomainException; class EloquentUserRepository implements UserRepository { @@ -38,6 +39,22 @@ class EloquentUserRepository implements UserRepository 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 { return new User( diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php new file mode 100644 index 0000000..a247a40 --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php @@ -0,0 +1,61 @@ +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; + } +} diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php new file mode 100644 index 0000000..05bf21c --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php @@ -0,0 +1,11 @@ +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; + } +} diff --git a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php new file mode 100644 index 0000000..0788038 --- /dev/null +++ b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php @@ -0,0 +1,8 @@ +email; } - public function getPasswordHash(): string + public function getPasswordHash(): ?string { return $this->passwordHash; } + + public function setPasswordHash(string $passwordHash): void + { + $this->passwordHash = $passwordHash; + } } diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index d2c64e3..bc4f960 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 $passwordHash + * @property string|null $passwordHash * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 4805f3f..995fde0 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -11,4 +11,6 @@ interface UserRepository public function find(int $id): ?User; public function findByEmail(EmailAddress $email): ?User; + + public function update(User $user): User; } diff --git a/backend/config/app.php b/backend/config/app.php index 1f8dd27..5877288 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -54,6 +54,8 @@ return [ 'url' => env('APP_URL', 'http://localhost'), + 'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'), + /* |-------------------------------------------------------------------------- | Application Timezone 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 065daab..fa5685e 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('passwordHash'); + $table->string('passwordHash')->nullable(); }); } diff --git a/backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php b/backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php new file mode 100644 index 0000000..c57c688 --- /dev/null +++ b/backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 7d7d7c1..d471fcf 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -5,6 +5,8 @@ use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; 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::get('/me', [AuthController::class, 'me']);