From 306ea27f79fbafec3f529f0df585e206f1e6a3b2 Mon Sep 17 00:00:00 2001 From: Njogu Amos <29255728+njoguamos@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:31:46 +0300 Subject: [PATCH] fix: password enumatation attack --- README.md | 39 +++++++++++-------- src/Actions/ResolveSocialIdentity.php | 14 ++++--- .../AbstractPasswordResetLinkController.php | 20 +++------- .../PasswordResetLinkControllerTest.php | 33 ++++++++++++++++ tests/Feature/ResolveSocialIdentityTest.php | 24 ++++++++++++ 5 files changed, 95 insertions(+), 35 deletions(-) create mode 100644 tests/Feature/PasswordResetLinkControllerTest.php diff --git a/README.md b/README.md index 98db564..b254dbe 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,13 @@ return [ Remove `passkeys` from `auth-kit.fortify.features` to disable Fortify's passkey routes. Auth Kit only enables and configures Fortify; passkey ceremonies, responses, and persistence remain provided by Fortify and `laravel/passkeys`. +### Security defaults + +- Two-factor authentication is not enabled by default. MFA is still work in progress. +- Social sign-in only provisions or auto-links accounts when Google, LinkedIn, or PayPal supplies a trusted `email_verified` claim. Facebook and X identities can still be linked by an authenticated user, but cannot establish trust through an email-verification claim. +- Before production, configure HTTPS, secure session cookies, a working mail transport, and Turnstile keys when bot protection is enabled. +- PayPal uses sandbox mode by default. Set `AUTH_KIT_PAYPAL_SANDBOX_MODE=false` with production PayPal credentials before enabling it in production. + ## Passkeys Passkey support uses Fortify's native integration with `laravel/passkeys`. It is stateful and requires the consuming application's authenticatable model to implement Fortify's `PasskeyUser` contract and use Auth Kit's morph-aware `PasskeyAuthenticatable` trait: @@ -112,22 +119,22 @@ The application client should use Fortify's `/passkeys/login/options`, `/passkey ## Actions -| Action | Purpose | -|-----------------------------|----------------------------------------------------------------| -| `AttemptEmailPasswordLogin` | Verify email + password against a guard, returns `AuthResult` | -| `LoginUser` | Log user into session + regenerate session | -| `LogoutUser` | Log out + invalidate session | -| `CreateNewUser` | Validate and create user (Fortify `CreatesNewUsers`) | -| `ResetUserPassword` | Validate and reset password (Fortify `ResetsUserPasswords`) | +| Action | Purpose | +|--------------------------------|-----------------------------------------------------------------------| +| `AttemptEmailPasswordLogin` | Verify email + password against a guard, returns `AuthResult` | +| `LoginUser` | Log user into session + regenerate session | +| `LogoutUser` | Log out + invalidate session | +| `CreateNewUser` | Validate and create user (Fortify `CreatesNewUsers`) | +| `ResetUserPassword` | Validate and reset password (Fortify `ResetsUserPasswords`) | | `UpdateUserProfileInformation` | Validate and update profile (Fortify `UpdatesUserProfileInformation`) | -| `UpdateUserPassword` | Validate and update password (Fortify `UpdatesUserPasswords`) | -| `IssueTokenForUser` | Issue Sanctum personal access token, returns `TokenResult` | -| `CheckEmailExists` | Check if email is registered | -| `FindUserByEmail` | Retrieve user by email | -| `ResolveSocialIdentity` | Safe social identity → user resolution (verified-email check) | -| `SocialRedirectAction` | Generate OAuth redirect URL, returns `SocialRedirectResult` | -| `SocialCallbackAction` | Handle OAuth callback via `ResolveSocialIdentity` | -| `CreateSocialAccountAction` | Create social account record via polymorphic relation | +| `UpdateUserPassword` | Validate and update password (Fortify `UpdatesUserPasswords`) | +| `IssueTokenForUser` | Issue Sanctum personal access token, returns `TokenResult` | +| `CheckEmailExists` | Check if email is registered | +| `FindUserByEmail` | Retrieve user by email | +| `ResolveSocialIdentity` | Safe social identity → user resolution (verified-email check) | +| `SocialRedirectAction` | Generate OAuth redirect URL, returns `SocialRedirectResult` | +| `SocialCallbackAction` | Handle OAuth callback via `ResolveSocialIdentity` | +| `CreateSocialAccountAction` | Create social account record via polymorphic relation | ## Result types @@ -164,7 +171,7 @@ Extend these to wire up your own routes. JSON responses are handled automaticall | `AbstractLogoutController` | `loggedOut()` | | `AbstractRegisterController` | `registered()` | | `AbstractSocialRedirectController` | `redirect()` | -| `AbstractSocialCallbackController` | `passed()`, `failed()` | +| `AbstractSocialCallbackController` | `passed()`, `failed()` | ## Social login diff --git a/src/Actions/ResolveSocialIdentity.php b/src/Actions/ResolveSocialIdentity.php index ddb948d..c04d30b 100644 --- a/src/Actions/ResolveSocialIdentity.php +++ b/src/Actions/ResolveSocialIdentity.php @@ -53,7 +53,7 @@ public function execute(SocialProvider $provider, SocialiteUser $socialUser, str return auth()->user(); } - $email = $this->normalizedVerifiedEmail($socialUser); + $email = $this->normalizedVerifiedEmail($provider, $socialUser); if ($email !== null && ($existingUser = $this->findUserByEmail($userModel, $email)) !== null) { $this->createSocialAccount->execute( @@ -80,19 +80,23 @@ public function execute(SocialProvider $provider, SocialiteUser $socialUser, str return $user; } - private function normalizedVerifiedEmail(SocialiteUser $socialUser): ?string + private function normalizedVerifiedEmail(SocialProvider $provider, SocialiteUser $socialUser): ?string { $email = $socialUser->getEmail(); - if ($email === null || ! $this->emailIsVerified($socialUser)) { + if ($email === null || ! $this->emailIsVerified($provider, $socialUser)) { return null; } return Str::lower($email); } - private function emailIsVerified(SocialiteUser $socialUser): bool + private function emailIsVerified(SocialProvider $provider, SocialiteUser $socialUser): bool { + if (! in_array($provider, [SocialProvider::GOOGLE, SocialProvider::LINKEDIN, SocialProvider::PAYPAL], true)) { + return false; + } + $rawUser = $socialUser instanceof \Laravel\Socialite\AbstractUser ? $socialUser->getRaw() : []; @@ -101,7 +105,7 @@ private function emailIsVerified(SocialiteUser $socialUser): bool return false; } - return filter_var($rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false, FILTER_VALIDATE_BOOLEAN); + return filter_var($rawUser['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN); } private function findUserByEmail(string $userModel, ?string $email): ?Authenticatable diff --git a/src/Http/Controllers/AbstractPasswordResetLinkController.php b/src/Http/Controllers/AbstractPasswordResetLinkController.php index c933693..1d27cdb 100644 --- a/src/Http/Controllers/AbstractPasswordResetLinkController.php +++ b/src/Http/Controllers/AbstractPasswordResetLinkController.php @@ -24,17 +24,16 @@ public function store(Request $request): mixed $email = Str::lower($email); } - $status = $this->broker()->sendResetLink(['email' => $email]); + $this->broker()->sendResetLink(['email' => $email]); if ($request->expectsJson()) { - return $status === Password::RESET_LINK_SENT - ? $this->jsonResponse(status: 'passed', data: ['message' => __($status)]) - : $this->jsonResponse(status: 'failed', data: ['message' => __($status)], code: 422); + return $this->jsonResponse( + status: 'passed', + data: ['message' => __(Password::RESET_LINK_SENT)], + ); } - return $status === Password::RESET_LINK_SENT - ? $this->sent(request: $request, status: $status) - : $this->failed(request: $request, status: $status); + return $this->sent(request: $request, status: Password::RESET_LINK_SENT); } protected function sent(Request $request, string $status): mixed @@ -42,13 +41,6 @@ protected function sent(Request $request, string $status): mixed return back()->with('status', __($status)); } - protected function failed(Request $request, string $status): mixed - { - return back() - ->withInput($request->only('email')) - ->withErrors(['email' => __($status)]); - } - protected function broker(): PasswordBroker { return Password::broker(config('fortify.passwords')); diff --git a/tests/Feature/PasswordResetLinkControllerTest.php b/tests/Feature/PasswordResetLinkControllerTest.php new file mode 100644 index 0000000..f6cd233 --- /dev/null +++ b/tests/Feature/PasswordResetLinkControllerTest.php @@ -0,0 +1,33 @@ +create(['email' => 'known@example.com']); + + $controller = new class () extends AbstractPasswordResetLinkController {}; + + $knownResponse = $controller->store(jsonRequest(['email' => 'known@example.com'])); + $unknownResponse = $controller->store(jsonRequest(['email' => 'unknown@example.com'])); + + expect($knownResponse->getStatusCode())->toBe(200) + ->and($unknownResponse->getStatusCode())->toBe(200) + ->and($unknownResponse->getData(true))->toBe($knownResponse->getData(true)); +}); + +function jsonRequest(array $data): Request +{ + return Request::create( + uri: '/forgot-password', + method: 'POST', + parameters: $data, + server: ['HTTP_ACCEPT' => 'application/json'], + ); +} diff --git a/tests/Feature/ResolveSocialIdentityTest.php b/tests/Feature/ResolveSocialIdentityTest.php index 99d6dd7..2ebf321 100644 --- a/tests/Feature/ResolveSocialIdentityTest.php +++ b/tests/Feature/ResolveSocialIdentityTest.php @@ -144,6 +144,30 @@ function socialiteUserWithoutVerification(): SocialiteUser ->and(Social::query()->count())->toBe(0); }); +it('does not trust an email verification claim from Facebook', function (): void { + User::factory()->create(['email' => 'john@example.com']); + + $user = app(ResolveSocialIdentity::class)->execute( + provider: SocialProvider::FACEBOOK, + socialUser: socialiteUser(['email_verified' => true]), + guard: 'web', + ); + + expect($user)->toBeNull() + ->and(Social::query()->count())->toBe(0); +}); + +it('accepts LinkedIns documented email verification claim', function (): void { + $user = app(ResolveSocialIdentity::class)->execute( + provider: SocialProvider::LINKEDIN, + socialUser: socialiteUser(['email_verified' => 'true']), + guard: 'web', + ); + + expect($user)->not->toBeNull() + ->and($user->email)->toBe('john@example.com'); +}); + it('does not auto-link when raw user has no verification field', function (): void { User::factory()->create(['email' => 'john@example.com']);