diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index bb59488..57ab7ea 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -4,8 +4,12 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Models\AuditLog; +use App\Models\User; +use App\Notifications\BruteForceLoginAlert; +use App\Support\SafeNotifier; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; class AuthenticatedSessionController extends Controller { @@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller 'details' => ['email' => $credentials['email']], ]); + $this->detectBruteForce($request, $credentials['email']); + return back()->withErrors([ 'email' => 'The provided credentials do not match our records.', ]); } + /** + * Fire one alert to super admins when the same email has racked up + * BRUTE_FORCE_THRESHOLD failures in BRUTE_FORCE_WINDOW_MINUTES. A Cache + * key with the same TTL prevents the alert from re-firing on every + * subsequent failed attempt within the same burst. + */ + private const BRUTE_FORCE_THRESHOLD = 5; + private const BRUTE_FORCE_WINDOW_MINUTES = 10; + + private function detectBruteForce(Request $request, string $email): void + { + $window = self::BRUTE_FORCE_WINDOW_MINUTES; + $threshold = self::BRUTE_FORCE_THRESHOLD; + + // SQLite stores JSON-cast `details` as text; a LIKE-based match is + // database-portable and avoids per-driver JSON operator syntax. + $pattern = '%"email":"' . addslashes($email) . '"%'; + + $recent = AuditLog::where('action', 'user.login.failed') + ->where('created_at', '>=', now()->subMinutes($window)) + ->where('details', 'like', $pattern) + ->count(); + + if ($recent < $threshold) { + return; + } + + $cacheKey = 'brute_force_alert:' . sha1($email); + if (Cache::has($cacheKey)) { + return; + } + Cache::put($cacheKey, 1, now()->addMinutes($window)); + + $ip = $request->header('CF-Connecting-IP') + ?? $request->header('X-Forwarded-For') + ?? $request->ip() + ?? 'unknown'; + + $notification = new BruteForceLoginAlert($email, (string) $ip, $recent, $window); + User::where('role', 'super_admin')->each(function (User $admin) use ($notification) { + SafeNotifier::send($admin, $notification); + }); + } + public function destroy(Request $request) { $user = Auth::user(); diff --git a/app/Notifications/BruteForceLoginAlert.php b/app/Notifications/BruteForceLoginAlert.php new file mode 100644 index 0000000..c401fbd --- /dev/null +++ b/app/Notifications/BruteForceLoginAlert.php @@ -0,0 +1,51 @@ + 'brute_force_login_alert', + 'email' => $this->email, + 'ip' => $this->ip, + 'failures' => $this->failures, + 'window_minutes' => $this->windowMinutes, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('TAKEONE — suspicious login attempts') + ->greeting('Possible brute-force login attempt') + ->line("The address {$this->email} has had {$this->failures} failed login attempts in the last {$this->windowMinutes} minutes.") + ->line("Most recent attempt came from {$this->ip}.") + ->line('Review the audit log and consider forcing a password reset if this account looks real.') + ->action('Open Admin', url('/admin/audit-logs')); + } +} diff --git a/routes/auth.php b/routes/auth.php index 7e6eb49..74f5462 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -15,7 +15,7 @@ Route::middleware('guest')->group(function () { Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login'); Route::post('login', [AuthenticatedSessionController::class, 'store']) - ->middleware('throttle:10,1') // max 10 login attempts per minute per IP + ->middleware('throttle:5,1') // max 5 login attempts per minute per IP ->name('login.store'); });