From 2936af82bdf2a0417d4c101c243405fa82ab265e Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 17:45:16 +0200 Subject: [PATCH] feat(auth): guests-only BlockBannedIp middleware + 403 page Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Middleware/BlockBannedIp.php | 29 ++++++++++ bootstrap/app.php | 3 +- lang/de/errors.php | 3 + lang/en/errors.php | 3 + resources/views/errors/blocked.blade.php | 4 ++ tests/Feature/BlockBannedIpMiddlewareTest.php | 58 +++++++++++++++++++ 6 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 app/Http/Middleware/BlockBannedIp.php create mode 100644 resources/views/errors/blocked.blade.php create mode 100644 tests/Feature/BlockBannedIpMiddlewareTest.php diff --git a/app/Http/Middleware/BlockBannedIp.php b/app/Http/Middleware/BlockBannedIp.php new file mode 100644 index 0000000..d6495d0 --- /dev/null +++ b/app/Http/Middleware/BlockBannedIp.php @@ -0,0 +1,29 @@ +ip() is correct via the global trustProxies (prod). + */ +class BlockBannedIp +{ + public function __construct(private BruteforceGuard $guard) {} + + public function handle(Request $request, Closure $next): Response + { + if ($this->guard->enabled() && Auth::guest() && $this->guard->isBanned((string) $request->ip())) { + return response()->view('errors.blocked', [], 403); + } + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index db64185..a15261b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ web( prepend: [PanelScheme::class], - append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class], + append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class], ); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/lang/de/errors.php b/lang/de/errors.php index d378e87..7add85f 100644 --- a/lang/de/errors.php +++ b/lang/de/errors.php @@ -23,4 +23,7 @@ return [ '503_title' => 'Wartungsmodus', '503_body' => 'Clusev wird gerade gewartet. Bitte in Kürze erneut versuchen.', + + 'blocked_title' => 'Zugriff vorübergehend gesperrt', + 'blocked_body' => 'Zu viele fehlgeschlagene Anmeldeversuche aus deinem Netzwerk. Bitte später erneut versuchen.', ]; diff --git a/lang/en/errors.php b/lang/en/errors.php index d16b2da..0d3bea4 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -23,4 +23,7 @@ return [ '503_title' => 'Down for maintenance', '503_body' => 'Clusev is undergoing maintenance. Please check back shortly.', + + 'blocked_title' => 'Access temporarily blocked', + 'blocked_body' => 'Too many failed sign-in attempts from your network. Please try again later.', ]; diff --git a/resources/views/errors/blocked.blade.php b/resources/views/errors/blocked.blade.php new file mode 100644 index 0000000..9d99e55 --- /dev/null +++ b/resources/views/errors/blocked.blade.php @@ -0,0 +1,4 @@ +@extends('errors.layout', ['title' => __('errors.blocked_title')]) +@section('code', '403') +@section('heading', __('errors.blocked_title')) +@section('message', __('errors.blocked_body')) diff --git a/tests/Feature/BlockBannedIpMiddlewareTest.php b/tests/Feature/BlockBannedIpMiddlewareTest.php new file mode 100644 index 0000000..24b4b4e --- /dev/null +++ b/tests/Feature/BlockBannedIpMiddlewareTest.php @@ -0,0 +1,58 @@ + $ip, 'banned_until' => now()->addHour(), 'attempts' => 10]); + } + + public function test_guest_from_banned_ip_is_blocked_with_403(): void + { + $this->banned(); + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.5']) + ->get('/login') + ->assertStatus(403) + ->assertSee(__('errors.blocked_title')); + } + + public function test_guest_from_clean_ip_passes(): void + { + $this->banned(); + $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])->get('/login')->assertStatus(200); + } + + public function test_authenticated_user_from_banned_ip_passes(): void + { + $this->banned(); + $user = User::factory()->create(['must_change_password' => false]); + $this->actingAs($user) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.5']) + ->get('/') + ->assertSuccessful(); + } + + public function test_feature_disabled_lets_banned_ip_through(): void + { + Setting::put('bruteforce_enabled', '0'); + $this->banned(); + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.5'])->get('/login')->assertStatus(200); + } +}