diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php new file mode 100644 index 0000000..f7cac97 --- /dev/null +++ b/backend/app/Controllers/AuthController.php @@ -0,0 +1,159 @@ +signupUser->execute(new SignupUserRequest( + email: $request->input('email'), + displayName: $request->input('displayName'), + )); + } 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 + { + try { + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: $request->input('token'), + password: $request->input('password'), + )); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 400, + ); + } catch (DomainException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 409, + ); + } + + return new JsonResponse(null, 200); + } + + public function login(Request $request): JsonResponse + { + try { + $user = $this->authenticateUser->execute( + new AuthenticateUserRequest( + email: $request->input('email'), + password: $request->input('password'), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 400, + ); + } catch (UnauthorizedException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 401, + ); + } + + $session = $this->createSession->execute($user); + + $response = new JsonResponse([ + 'user' => $this->buildUserPayload($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 + { + /** @var User $user */ + $user = $request->attributes->get('user'); + + return new JsonResponse([ + 'user' => $this->buildUserPayload($user), + ], 200); + } + + public function logout(Request $request): JsonResponse + { + $token = $request->cookie(AuthMiddleware::COOKIE_NAME); + if (is_string($token) && $token !== '') { + $this->logoutUseCase->execute($token); + } + + $response = new JsonResponse(null, 204); + + return $response->withCookie(Cookie::create( + name: AuthMiddleware::COOKIE_NAME, + value: '', + expire: 1, + path: '/', + domain: null, + secure: false, + httpOnly: true, + raw: false, + sameSite: Cookie::SAMESITE_LAX, + )); + } + + /** + * @return array{ + * id: int, + * email: string, + * displayName: string, + * isAdmin: bool + * } + */ + private function buildUserPayload(User $user): array + { + return [ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + 'displayName' => $user->getDisplayName(), + 'isAdmin' => $user->isAdmin(), + ]; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 5b73916..d17a4c9 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -8,6 +8,14 @@ use App\Auth\PasswordHasher; use App\Auth\RandomTokenGenerator; use App\Auth\SystemClock; use App\Auth\TokenGenerator; +use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository; +use App\Email\Emailer; +use App\Email\EmailFactory; +use App\Email\LaravelEmailFactory; +use App\Email\LaravelMailer; +use App\User\UseCases\SignupUser\SignupUser; +use App\User\UserRepository; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -17,6 +25,31 @@ class AppServiceProvider extends ServiceProvider $this->app->bind(Clock::class, SystemClock::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); + $this->app->bind(Emailer::class, LaravelMailer::class); + $this->app->bind( + EmailFactory::class, + function () { + return new LaravelEmailFactory( + confirmationUrlPrefix: config('app.frontend_url') + .'/confirm-email?token=', + ); + }, + ); + $this->app->bind( + SignupUser::class, + function (Application $app) { + return new SignupUser( + userRepo: $app->make(UserRepository::class), + tokenRepo: $app->make( + EmailConfirmationTokenRepository::class, + ), + emailer: $app->make(Emailer::class), + emailFactory: $app->make(EmailFactory::class), + clock: $app->make(Clock::class), + fromAddress: config('mail.from.address'), + ); + }, + ); } public function boot(): void diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 0671b59..1532e12 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -4,6 +4,10 @@ namespace App\Providers; use App\Auth\EloquentSessionRepository; use App\Auth\SessionRepository; +use App\Email\EmailConfirmationToken\EloquentEmailConfirmationTokenRepository; +use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository; +use App\Post\EloquentPostRepository; +use App\Post\PostRepository; use App\User\EloquentUserRepository; use App\User\UserRepository; use Illuminate\Support\ServiceProvider; @@ -20,5 +24,13 @@ class RepositoryServiceProvider extends ServiceProvider SessionRepository::class, EloquentSessionRepository::class, ); + $this->app->bind( + EmailConfirmationTokenRepository::class, + EloquentEmailConfirmationTokenRepository::class, + ); + $this->app->bind( + PostRepository::class, + EloquentPostRepository::class, + ); } } diff --git a/backend/routes/api.php b/backend/routes/api.php index f4f638e..45699fb 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,17 @@ middleware(AuthMiddleware::class); +Route::get('/me', [AuthController::class, 'me']) + ->middleware(AuthMiddleware::class); diff --git a/backend/tests/Feature/Auth/AuthFlowTest.php b/backend/tests/Feature/Auth/AuthFlowTest.php new file mode 100644 index 0000000..ceab2f3 --- /dev/null +++ b/backend/tests/Feature/Auth/AuthFlowTest.php @@ -0,0 +1,161 @@ +postJson('/api/signup', [ + 'email' => 'alice@example.com', + 'displayName' => 'alice', + ]); + + $response->assertStatus(201); + + $userRepo = $this->app->make(UserRepository::class); + $user = $userRepo->findByEmail( + new EmailAddress('alice@example.com'), + ); + $this->assertNotNull($user); + $this->assertFalse($user->isEmailConfirmed()); + } + + public function test_signup_validation_returns_400(): void + { + $response = $this->postJson('/api/signup', [ + 'email' => '', + 'displayName' => 'alice', + ]); + $response->assertStatus(400); + } + + public function test_duplicate_signup_returns_409(): void + { + $this->postJson('/api/signup', [ + 'email' => 'alice@example.com', + 'displayName' => 'alice', + ])->assertStatus(201); + + $this->postJson('/api/signup', [ + 'email' => 'alice@example.com', + 'displayName' => 'aliceb', + ])->assertStatus(409); + } + + public function test_confirm_email_then_login_returns_user_and_cookie(): void + { + $this->postJson('/api/signup', [ + 'email' => 'alice@example.com', + 'displayName' => 'alice', + ])->assertStatus(201); + + $userRepo = $this->app->make(UserRepository::class); + $user = $userRepo->findByEmail( + new EmailAddress('alice@example.com'), + ); + $tokenRepo = $this->app->make( + EmailConfirmationTokenRepository::class, + ); + $token = $tokenRepo->findByUser($user); + $this->assertNotNull($token); + + $this->postJson('/api/confirm-email', [ + 'token' => $token->getToken(), + 'password' => 'longenoughpassword', + ])->assertStatus(200); + + $loginResponse = $this->postJson('/api/login', [ + 'email' => 'alice@example.com', + 'password' => 'longenoughpassword', + ]); + $loginResponse->assertStatus(200); + $loginResponse->assertJsonPath('user.email', 'alice@example.com'); + $loginResponse->assertJsonPath('user.displayName', 'alice'); + $loginResponse->assertJsonPath('user.isAdmin', false); + $loginResponse->assertCookie('auth_token'); + } + + public function test_login_with_unconfirmed_account_returns_401(): void + { + $this->postJson('/api/signup', [ + 'email' => 'alice@example.com', + 'displayName' => 'alice', + ])->assertStatus(201); + + $this->postJson('/api/login', [ + 'email' => 'alice@example.com', + 'password' => 'longenoughpassword', + ])->assertStatus(401); + } + + public function test_login_with_wrong_password_returns_401(): void + { + $this->postJson('/api/signup', [ + 'email' => 'alice@example.com', + 'displayName' => 'alice', + ])->assertStatus(201); + + $userRepo = $this->app->make(UserRepository::class); + $user = $userRepo->findByEmail( + new EmailAddress('alice@example.com'), + ); + $tokenRepo = $this->app->make( + EmailConfirmationTokenRepository::class, + ); + $token = $tokenRepo->findByUser($user); + + $this->postJson('/api/confirm-email', [ + 'token' => $token->getToken(), + 'password' => 'longenoughpassword', + ])->assertStatus(200); + + $this->postJson('/api/login', [ + 'email' => 'alice@example.com', + 'password' => 'wrongpassword', + ])->assertStatus(401); + } + + public function test_me_requires_auth_cookie(): void + { + $this->getJson('/api/me')->assertStatus(401); + } + + public function test_login_sets_auth_cookie_on_response(): void + { + $this->postJson('/api/signup', [ + 'email' => 'alice@example.com', + 'displayName' => 'alice', + ])->assertStatus(201); + + $userRepo = $this->app->make(UserRepository::class); + $user = $userRepo->findByEmail( + new EmailAddress('alice@example.com'), + ); + $tokenRepo = $this->app->make( + EmailConfirmationTokenRepository::class, + ); + $token = $tokenRepo->findByUser($user); + $this->postJson('/api/confirm-email', [ + 'token' => $token->getToken(), + 'password' => 'longenoughpassword', + ])->assertStatus(200); + + $loginResponse = $this->postJson('/api/login', [ + 'email' => 'alice@example.com', + 'password' => 'longenoughpassword', + ]); + $loginResponse->assertStatus(200); + $authCookie = $loginResponse->getCookie('auth_token', false); + $this->assertNotNull($authCookie); + $this->assertNotEmpty($authCookie->getValue()); + } +}