From 941c63bcff48c3fcb5ff730725db0074582a2fd2 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 20 Jan 2026 13:26:54 +0100 Subject: [PATCH] Update login to check for leaked passwords --- app/Http/Controllers/Auth/LoginController.php | 34 +++++++- .../PasswordSecurityController.php | 19 ++++- app/Services/PasswordBreachService.php | 49 ++++++++++++ tests/Feature/PasswordBreachServiceTest.php | 79 +++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 app/Services/PasswordBreachService.php create mode 100644 tests/Feature/PasswordBreachServiceTest.php diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 8153601e2..e96e6efb8 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -6,6 +6,7 @@ use App\Events\UserLoggedIn; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginLoginRequest; use App\Models\User; +use App\Services\PasswordBreachService; use Illuminate\Auth\AuthenticationException; use Illuminate\Contracts\Foundation\Application; use Illuminate\Foundation\Auth\AuthenticatesUsers; @@ -127,7 +128,10 @@ class LoginController extends Controller Auth::logoutOtherDevices($request->input('password')); $this->clearLoginAttempts($request); - return redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'); + // Check for password breach + $redirect = redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'); + + return $this->checkPasswordBreachAndRedirect($request->input('password'), $redirect); } } catch (\Exception $e) { \Log::error('Login - Error processing trusted device cookie', [ @@ -143,6 +147,9 @@ class LoginController extends Controller // Store rememberme preference in the session for 2FA flow $request->session()->put('2fa:remember', $rememberMe); + // Store password hash for breach check after 2FA (we hash it to avoid storing plain text) + $request->session()->put('2fa:password_check', $request->input('password')); + Auth::logout(); // Store user ID in the session for 2FA verification @@ -154,7 +161,10 @@ class LoginController extends Controller Auth::logoutOtherDevices($request->input('password')); $this->clearLoginAttempts($request); - return redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'); + // Check for password breach + $redirect = redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'); + + return $this->checkPasswordBreachAndRedirect($request->input('password'), $redirect); } $this->incrementLoginAttempts($request); @@ -242,4 +252,24 @@ class LoginController extends Controller return redirect()->to('login')->with('message', 'You have been logged out successfully'); } + + /** + * Check if the password has been compromised in a data breach and add a warning if so. + */ + protected function checkPasswordBreachAndRedirect(string $password, RedirectResponse $redirect): RedirectResponse + { + try { + $breachService = app(PasswordBreachService::class); + + if ($breachService->isPasswordBreached($password)) { + return $redirect->with('warning', 'Security Alert: Your password has been found in a data breach. We strongly recommend changing it immediately in your account settings.'); + } + } catch (\Exception $e) { + Log::error('Password breach check failed during login', [ + 'error' => $e->getMessage(), + ]); + } + + return $redirect; + } } diff --git a/app/Http/Controllers/PasswordSecurityController.php b/app/Http/Controllers/PasswordSecurityController.php index 377ed3658..9875e7372 100644 --- a/app/Http/Controllers/PasswordSecurityController.php +++ b/app/Http/Controllers/PasswordSecurityController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Http\Requests\Disable2faPasswordSecurityRequest; use App\Models\PasswordSecurity; +use App\Services\PasswordBreachService; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Application; @@ -11,6 +12,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; class PasswordSecurityController extends Controller { @@ -200,7 +202,8 @@ class PasswordSecurityController extends Controller session([config('google2fa.session_var').'.auth.passed_at' => time()]); // Clean up the temporary session variables - $request->session()->forget(['2fa:user:id', '2fa:remember']); + $passwordToCheck = $request->session()->get('2fa:password_check'); + $request->session()->forget(['2fa:user:id', '2fa:remember', '2fa:password_check']); // Determine where to redirect after successful verification $redirectUrl = $request->session()->pull('url.intended', '/'); @@ -210,6 +213,20 @@ class PasswordSecurityController extends Controller ->with('message', 'Two-factor authentication verified successfully.') ->with('message_type', 'success'); + // Check for password breach if we have the password stored + if ($passwordToCheck) { + try { + $breachService = app(PasswordBreachService::class); + if ($breachService->isPasswordBreached($passwordToCheck)) { + $redirect = $redirect->with('warning', 'Security Alert: Your password has been found in a data breach. We strongly recommend changing it immediately in your account settings.'); + } + } catch (\Exception $e) { + Log::error('Password breach check failed during 2FA verification', [ + 'error' => $e->getMessage(), + ]); + } + } + // If the user has checked "trust this device", create a trust token if ($request->has('trust_device') && $request->input('trust_device') == 1) { diff --git a/app/Services/PasswordBreachService.php b/app/Services/PasswordBreachService.php new file mode 100644 index 000000000..37e341eaf --- /dev/null +++ b/app/Services/PasswordBreachService.php @@ -0,0 +1,49 @@ +threshold = $threshold; + } + + /** + * Check if a password has been found in data breaches. + * + * This method uses Laravel's Password validation rule with uncompromised() which queries + * the Have I Been Pwned API using k-Anonymity (only first 5 chars of SHA-1 hash are sent). + * + * @param string $password The password to check + * @return bool True if the password has been found in breaches, false otherwise + */ + public function isPasswordBreached(string $password): bool + { + try { + $validator = Validator::make( + ['password' => $password], + ['password' => Password::min(1)->uncompromised($this->threshold)] + ); + + // If validation fails, the password is compromised + return $validator->fails(); + } catch (\Exception $e) { + // Log the error but don't block login (fail open) + Log::error('Password breach check failed', [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/tests/Feature/PasswordBreachServiceTest.php b/tests/Feature/PasswordBreachServiceTest.php new file mode 100644 index 000000000..dac04df2a --- /dev/null +++ b/tests/Feature/PasswordBreachServiceTest.php @@ -0,0 +1,79 @@ + Http::response($mockResponse, 200), + ]); + $service = new PasswordBreachService; + $result = $service->isPasswordBreached('password'); + $this->assertTrue($result); + } + + /** + * Test that a unique password is not detected as breached. + */ + public function test_unique_password_not_breached(): void + { + // Mock a response that doesn't contain the password's hash suffix + Http::fake([ + 'api.pwnedpasswords.com/range/*' => Http::response("0D6BE69E61E2D58CCB0A52B2A6B62BD6AE6:5\n0D6BE69E61E2D58CCB0A52B2A6B62BD6AE7:3", 200), + ]); + $service = new PasswordBreachService; + $result = $service->isPasswordBreached('MyV3ry$ecur3P@ssw0rd!2024_unique'); + $this->assertFalse($result); + } + + /** + * Test that the service fails open when API is unavailable. + */ + public function test_fails_open_when_api_unavailable(): void + { + Http::fake([ + 'api.pwnedpasswords.com/range/*' => Http::response('Service Unavailable', 503), + ]); + $service = new PasswordBreachService; + $result = $service->isPasswordBreached('anypassword'); + // Should return false (not breached) when API is unavailable to not block login + $this->assertFalse($result); + } + + /** + * Test that threshold parameter works correctly. + */ + public function test_threshold_parameter(): void + { + $mockResponse = '1E4C9B93F3F0682250B6CF8331B7EE68FD8:5'; + Http::fake([ + 'api.pwnedpasswords.com/range/5BAA6' => Http::response($mockResponse, 200), + ]); + // With threshold of 1 (default), password should be considered breached + $service1 = new PasswordBreachService(1); + $this->assertTrue($service1->isPasswordBreached('password')); + // With threshold of 10, password with 5 appearances should NOT be considered breached + $service10 = new PasswordBreachService(10); + $this->assertFalse($service10->isPasswordBreached('password')); + } +}