diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index cddf66389..44a03c17d 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,7 +4,9 @@ use App\Models\Team; use App\Models\User; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; @@ -12,6 +14,16 @@ class CreateNewUser implements CreatesNewUsers { + private const REGISTRATION_IP_MAX_ATTEMPTS = 3; + + private const REGISTRATION_IP_DECAY_SECONDS = 600; + + private const REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS = 3; + + private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600; + + public function __construct(private readonly Request $request) {} + /** * Validate and create a newly registered user. * @@ -23,6 +35,9 @@ public function create(array $input): User if (! $settings->is_registration_enabled) { abort(403); } + + $this->ensureRegistrationIsNotRateLimited($input); + Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'email' => [ @@ -72,4 +87,42 @@ public function create(array $input): User return $user; } + + /** + * @param array $input + */ + private function ensureRegistrationIsNotRateLimited(array $input): void + { + $keys = [ + [ + 'key' => 'registration:ip:'.sha1($this->realIp()), + 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, + 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, + ], + ]; + + $emailIdentity = normalize_email_identity($input['email'] ?? null); + if ($emailIdentity !== null) { + $keys[] = [ + 'key' => 'registration:email-identity:'.sha1($emailIdentity), + 'max' => self::REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS, + 'decay' => self::REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS, + ]; + } + + foreach ($keys as $limit) { + if (RateLimiter::tooManyAttempts($limit['key'], $limit['max'])) { + abort(429, 'Too many registration attempts. Please try again later.'); + } + } + + foreach ($keys as $limit) { + RateLimiter::hit($limit['key'], $limit['decay']); + } + } + + private function realIp(): string + { + return $this->request->server('REMOTE_ADDR') ?? $this->request->ip(); + } } diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index cd9cfcba6..73c256cbe 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -39,6 +39,7 @@ public function getListeners() return [ 'refreshServerShow' => 'refreshServer', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', + "echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus', ]; } @@ -203,6 +204,15 @@ public function refreshServer() $this->server->load('settings'); } + public function refreshSentinelStatus($event = null): void + { + if (isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) { + return; + } + + $this->refreshServer(); + } + /** * Check if Traefik has any outdated version info (patch or minor upgrade). * This shows a warning indicator in the navbar. diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 85f38b967..1b201fb3f 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserProfileInformation; use App\Models\OauthSetting; +use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -82,7 +83,7 @@ public function boot(): void $user->save(); // Check if user has a pending invitation they haven't accepted yet - $invitation = \App\Models\TeamInvitation::whereEmail($email)->first(); + $invitation = TeamInvitation::whereEmail($email)->first(); if ($invitation && $invitation->isValid()) { // User is logging in for the first time after being invited // Attach them to the invited team if not already attached @@ -130,7 +131,16 @@ public function boot(): void // Use real client IP (not spoofable forwarded headers) $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - return Limit::perMinute(5)->by($realIp); + $limits = [ + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)), + ]; + + $emailIdentity = normalize_email_identity($request->input('email')); + if ($emailIdentity !== null) { + $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); + } + + return $limits; }); RateLimiter::for('login', function (Request $request) { diff --git a/bootstrap/helpers/email.php b/bootstrap/helpers/email.php new file mode 100644 index 000000000..a0b8ba67f --- /dev/null +++ b/bootstrap/helpers/email.php @@ -0,0 +1,20 @@ +
- - - Configuration - - @if ($server->proxySet()) - - Dynamic Configurations - - - Logs - - @endif +@php + $serverPageItems = [ + [ + 'label' => 'Configuration', + 'route' => 'server.show', + 'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'), + ], + [ + 'label' => 'Proxy', + 'route' => 'server.proxy', + 'active' => request()->routeIs('server.proxy', 'server.proxy.*'), + 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, + ], + [ + 'label' => 'Sentinel', + 'route' => 'server.sentinel', + 'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'), + 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server), + ], + [ + 'label' => 'Resources', + 'route' => 'server.resources', + 'active' => request()->routeIs('server.resources'), + ], + [ + 'label' => 'Terminal', + 'route' => 'server.command', + 'active' => request()->routeIs('server.command'), + 'navigate' => false, + 'visible' => auth()->user()?->can('canAccessTerminal'), + ], + [ + 'label' => 'Security', + 'route' => 'server.security.patches', + 'active' => request()->routeIs('server.security.patches'), + 'visible' => auth()->user()?->can('update', $server), + ], + ]; + $proxyMenuItems = [ + [ + 'label' => 'Configuration', + 'route' => 'server.proxy', + 'active' => request()->routeIs('server.proxy'), + ], + [ + 'label' => 'Dynamic Configurations', + 'route' => 'server.proxy.dynamic-confs', + 'active' => request()->routeIs('server.proxy.dynamic-confs'), + 'visible' => $server->proxySet(), + ], + [ + 'label' => 'Logs', + 'route' => 'server.proxy.logs', + 'active' => request()->routeIs('server.proxy.logs'), + 'visible' => $server->proxySet(), + 'navigate' => false, + ], + ]; + + $serverPageItems = array_values(array_filter( + $serverPageItems, + fn (array $item): bool => $item['visible'] ?? true, + )); + $proxyMenuItems = array_values(array_filter( + $proxyMenuItems, + fn (array $item): bool => $item['visible'] ?? true, + )); + $activeProxyMenuItem = collect($proxyMenuItems)->firstWhere('active', true) ?? $proxyMenuItems[0]; + $activeProxyMenuValue = (($activeProxyMenuItem['navigate'] ?? true) ? 'navigate' : 'location').'|proxy|'.route($activeProxyMenuItem['route'], $parameters); +@endphp + +
+
+ + +
+ +
diff --git a/resources/views/components/server/sidebar-security.blade.php b/resources/views/components/server/sidebar-security.blade.php index e61d23c89..9e1071854 100644 --- a/resources/views/components/server/sidebar-security.blade.php +++ b/resources/views/components/server/sidebar-security.blade.php @@ -1,10 +1,127 @@ -