mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update login to check for leaked passwords
This commit is contained in:
@@ -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) {
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user