Update login to check for leaked passwords

This commit is contained in:
DariusIII
2026-01-20 13:26:54 +01:00
parent c0b3ffbfd6
commit 941c63bcff
4 changed files with 178 additions and 3 deletions
+32 -2
View File
@@ -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;
}
}
@@ -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) {
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class PasswordBreachService
{
/**
* Minimum number of times a password must appear in breaches to be considered compromised.
*/
protected int $threshold;
public function __construct(int $threshold = 1)
{
$this->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;
}
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Services\PasswordBreachService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Feature tests for PasswordBreachService - tests password breach detection via Have I Been Pwned API.
*/
class PasswordBreachServiceTest extends TestCase
{
/**
* Test that a known breached password is detected.
* "password" is one of the most common breached passwords.
*/
public function test_detects_breached_password(): void
{
// The SHA-1 hash of "password" is 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
// So the prefix is 5BAA6 and suffix is 1E4C9B93F3F0682250B6CF8331B7EE68FD8
$mockResponse = "1D72CD07550416C216D8AD296BF5C0AE8E0:10\n".
"1E4C9B93F3F0682250B6CF8331B7EE68FD8:9659365\n".
'1E4D5B6920BCBB55B13D2E2E2FAC59AB68E:2';
Http::fake([
'api.pwnedpasswords.com/range/5BAA6' => 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'));
}
}