From 9ae0055f33f3c26bcd5933d227564fccc0bb8560 Mon Sep 17 00:00:00 2001 From: Clotho Date: Fri, 20 Feb 2026 11:28:26 +0000 Subject: [PATCH] security: add rate limiting to admin action endpoints (#12) Add per-user rate limiting to sensitive Livewire component methods to prevent abuse from compromised admin sessions. Introduces a reusable HasRateLimiting trait and applies it to PlatformUser, Settings, and WaitlistManager components. Rate limits: - Tier changes, verification, entitlements: 10/min per admin - Profile updates, preferences: 20/min per user - Password changes: 5/min per user - Data exports: 5/min per admin - Deletions/anonymisation: 3/min per admin Co-Authored-By: Claude Opus 4.6 --- src/Website/Hub/Concerns/HasRateLimiting.php | 72 +++ .../Hub/View/Modal/Admin/PlatformUser.php | 427 +++++++++--------- src/Website/Hub/View/Modal/Admin/Settings.php | 83 ++-- .../Hub/View/Modal/Admin/WaitlistManager.php | 120 ++--- tests/Feature/Security/RateLimitingTest.php | 280 ++++++++++++ 5 files changed, 690 insertions(+), 292 deletions(-) create mode 100644 src/Website/Hub/Concerns/HasRateLimiting.php create mode 100644 tests/Feature/Security/RateLimitingTest.php diff --git a/src/Website/Hub/Concerns/HasRateLimiting.php b/src/Website/Hub/Concerns/HasRateLimiting.php new file mode 100644 index 0000000..dd1783b --- /dev/null +++ b/src/Website/Hub/Concerns/HasRateLimiting.php @@ -0,0 +1,72 @@ +rateLimit('tier-change', 10, function () { ... }); + * $this->rateLimit('waitlist-export', 5, function () { ... }); + */ +trait HasRateLimiting +{ + /** + * Execute a callback with rate limiting for mutation actions. + * + * @param string $action Short action identifier (e.g. 'tier-change') + * @param int $maxAttempts Maximum attempts per minute + * @param callable $callback The action to execute if within limits + * @param int $decaySeconds Rate limit window in seconds + */ + protected function rateLimit(string $action, int $maxAttempts, callable $callback, int $decaySeconds = 60): mixed + { + $key = $this->rateLimitKey($action); + + $executed = false; + $result = null; + + RateLimiter::attempt( + $key, + $maxAttempts, + function () use ($callback, &$executed, &$result) { + $executed = true; + $result = $callback(); + }, + $decaySeconds, + ); + + if (! $executed) { + $this->onRateLimited($action, $key); + } + + return $result; + } + + /** + * Build a rate limit key scoped to the authenticated user. + */ + protected function rateLimitKey(string $action): string + { + return $action.':'.auth()->id(); + } + + /** + * Handle rate limit exceeded - sets action message on the component. + */ + protected function onRateLimited(string $action, string $key): void + { + $seconds = RateLimiter::availableIn($key); + + if (property_exists($this, 'actionMessage') && property_exists($this, 'actionType')) { + $this->actionMessage = "Too many requests. Please wait {$seconds} seconds before trying again."; + $this->actionType = 'error'; + } else { + session()->flash('error', "Too many requests. Please wait {$seconds} seconds before trying again."); + } + } +} diff --git a/src/Website/Hub/View/Modal/Admin/PlatformUser.php b/src/Website/Hub/View/Modal/Admin/PlatformUser.php index 7eec991..edff1b9 100644 --- a/src/Website/Hub/View/Modal/Admin/PlatformUser.php +++ b/src/Website/Hub/View/Modal/Admin/PlatformUser.php @@ -14,9 +14,12 @@ use Core\Tenant\Models\Package; use Core\Tenant\Models\User; use Core\Tenant\Models\Workspace; use Core\Tenant\Services\EntitlementService; +use Website\Hub\Concerns\HasRateLimiting; class PlatformUser extends Component { + use HasRateLimiting; + public User $user; // Editable fields @@ -80,27 +83,31 @@ class PlatformUser extends Component public function saveTier(): void { - $this->user->tier = UserTier::from($this->editingTier); - $this->user->save(); + $this->rateLimit('admin-tier-change', 10, function () { + $this->user->tier = UserTier::from($this->editingTier); + $this->user->save(); - $this->actionMessage = "Tier updated to {$this->editingTier}."; - $this->actionType = 'success'; + $this->actionMessage = "Tier updated to {$this->editingTier}."; + $this->actionType = 'success'; + }); } public function saveVerification(): void { - if ($this->editingVerified && ! $this->user->email_verified_at) { - $this->user->email_verified_at = now(); - } elseif (! $this->editingVerified) { - $this->user->email_verified_at = null; - } + $this->rateLimit('admin-verification', 10, function () { + if ($this->editingVerified && ! $this->user->email_verified_at) { + $this->user->email_verified_at = now(); + } elseif (! $this->editingVerified) { + $this->user->email_verified_at = null; + } - $this->user->save(); + $this->user->save(); - $this->actionMessage = $this->editingVerified - ? 'Email marked as verified.' - : 'Email verification removed.'; - $this->actionType = 'success'; + $this->actionMessage = $this->editingVerified + ? 'Email marked as verified.' + : 'Email verification removed.'; + $this->actionType = 'success'; + }); } public function resendVerification(): void @@ -123,21 +130,23 @@ class PlatformUser extends Component */ public function exportUserData() { - $data = $this->collectUserData(); + return $this->rateLimit('admin-export', 5, function () { + $data = $this->collectUserData(); - $filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json'; + $filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json'; - Log::info('GDPR data export performed by admin', [ - 'admin_id' => auth()->id(), - 'target_user_id' => $this->user->id, - 'target_email' => $this->user->email, - ]); + Log::info('GDPR data export performed by admin', [ + 'admin_id' => auth()->id(), + 'target_user_id' => $this->user->id, + 'target_email' => $this->user->email, + ]); - return response()->streamDownload(function () use ($data) { - echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - }, $filename, [ - 'Content-Type' => 'application/json', - ]); + return response()->streamDownload(function () use ($data) { + echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + }, $filename, [ + 'Content-Type' => 'application/json', + ]); + }); } /** @@ -240,32 +249,34 @@ class PlatformUser extends Component */ public function scheduleDelete(): void { - if ($this->user->isHades() && $this->user->id === auth()->id()) { - $this->actionMessage = 'You cannot delete your own Hades account from here.'; - $this->actionType = 'error'; + $this->rateLimit('admin-deletion', 3, function () { + if ($this->user->isHades() && $this->user->id === auth()->id()) { + $this->actionMessage = 'You cannot delete your own Hades account from here.'; + $this->actionType = 'error'; + $this->showDeleteConfirm = false; + + return; + } + + $request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request'); + + Log::warning('GDPR deletion scheduled by admin', [ + 'admin_id' => auth()->id(), + 'target_user_id' => $this->user->id, + 'target_email' => $this->user->email, + 'immediate' => $this->immediateDelete, + 'reason' => $this->deleteReason, + ]); + + if ($this->immediateDelete) { + $this->executeImmediateDelete($request); + } else { + $this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.'; + $this->actionType = 'warning'; + } + $this->showDeleteConfirm = false; - - return; - } - - $request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request'); - - Log::warning('GDPR deletion scheduled by admin', [ - 'admin_id' => auth()->id(), - 'target_user_id' => $this->user->id, - 'target_email' => $this->user->email, - 'immediate' => $this->immediateDelete, - 'reason' => $this->deleteReason, - ]); - - if ($this->immediateDelete) { - $this->executeImmediateDelete($request); - } else { - $this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.'; - $this->actionType = 'warning'; - } - - $this->showDeleteConfirm = false; + }); } /** @@ -340,50 +351,52 @@ class PlatformUser extends Component */ public function anonymizeUser(): void { - if ($this->user->isHades() && $this->user->id === auth()->id()) { - $this->actionMessage = 'You cannot anonymize your own account.'; - $this->actionType = 'error'; + $this->rateLimit('admin-deletion', 3, function () { + if ($this->user->isHades() && $this->user->id === auth()->id()) { + $this->actionMessage = 'You cannot anonymize your own account.'; + $this->actionType = 'error'; - return; - } - - $originalEmail = $this->user->email; - $anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp; - - DB::transaction(function () use ($anonymizedId) { - $this->user->update([ - 'name' => 'Anonymized User', - 'email' => $anonymizedId.'@anonymized.local', - 'password' => bcrypt(str()->random(64)), - 'tier' => UserTier::FREE, - 'email_verified_at' => null, - 'cached_stats' => null, - ]); - - // Remove from all workspaces - if (method_exists($this->user, 'hostWorkspaces')) { - $this->user->hostWorkspaces()->detach(); + return; } - // Cancel any pending deletions - AccountDeletionRequest::where('user_id', $this->user->id) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->update(['cancelled_at' => now()]); + $originalEmail = $this->user->email; + $anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp; + + DB::transaction(function () use ($anonymizedId) { + $this->user->update([ + 'name' => 'Anonymized User', + 'email' => $anonymizedId.'@anonymized.local', + 'password' => bcrypt(str()->random(64)), + 'tier' => UserTier::FREE, + 'email_verified_at' => null, + 'cached_stats' => null, + ]); + + // Remove from all workspaces + if (method_exists($this->user, 'hostWorkspaces')) { + $this->user->hostWorkspaces()->detach(); + } + + // Cancel any pending deletions + AccountDeletionRequest::where('user_id', $this->user->id) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->update(['cancelled_at' => now()]); + }); + + Log::warning('User anonymized by admin (GDPR)', [ + 'admin_id' => auth()->id(), + 'target_user_id' => $this->user->id, + 'original_email' => $originalEmail, + ]); + + $this->user->refresh(); + $this->editingTier = $this->user->tier?->value ?? 'free'; + $this->editingVerified = false; + + $this->actionMessage = 'User data has been anonymized.'; + $this->actionType = 'success'; }); - - Log::warning('User anonymized by admin (GDPR)', [ - 'admin_id' => auth()->id(), - 'target_user_id' => $this->user->id, - 'original_email' => $originalEmail, - ]); - - $this->user->refresh(); - $this->editingTier = $this->user->tier?->value ?? 'free'; - $this->editingVerified = false; - - $this->actionMessage = 'User data has been anonymized.'; - $this->actionType = 'success'; } /** @@ -448,33 +461,35 @@ class PlatformUser extends Component */ public function provisionPackage(): void { - if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) { - $this->actionMessage = 'Please select a workspace and package.'; - $this->actionType = 'warning'; + $this->rateLimit('admin-entitlement', 10, function () { + if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) { + $this->actionMessage = 'Please select a workspace and package.'; + $this->actionType = 'warning'; - return; - } + return; + } - $workspace = Workspace::findOrFail($this->selectedWorkspaceId); - $package = Package::where('code', $this->selectedPackageCode)->firstOrFail(); + $workspace = Workspace::findOrFail($this->selectedWorkspaceId); + $package = Package::where('code', $this->selectedPackageCode)->firstOrFail(); - $entitlements = app(EntitlementService::class); - $entitlements->provisionPackage($workspace, $this->selectedPackageCode, [ - 'source' => 'admin', - ]); + $entitlements = app(EntitlementService::class); + $entitlements->provisionPackage($workspace, $this->selectedPackageCode, [ + 'source' => 'admin', + ]); - Log::info('Package provisioned by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'package_code' => $this->selectedPackageCode, - ]); + Log::info('Package provisioned by admin', [ + 'admin_id' => auth()->id(), + 'user_id' => $this->user->id, + 'workspace_id' => $workspace->id, + 'package_code' => $this->selectedPackageCode, + ]); - $this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'."; - $this->actionType = 'success'; + $this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'."; + $this->actionType = 'success'; - $this->closePackageModal(); - unset($this->workspaces); // Clear computed cache + $this->closePackageModal(); + unset($this->workspaces); // Clear computed cache + }); } /** @@ -482,34 +497,36 @@ class PlatformUser extends Component */ public function revokePackage(int $workspaceId, string $packageCode): void { - $workspace = Workspace::findOrFail($workspaceId); + $this->rateLimit('admin-entitlement', 10, function () use ($workspaceId, $packageCode) { + $workspace = Workspace::findOrFail($workspaceId); - // Verify this belongs to one of the user's workspaces - if (! $this->user->hostWorkspaces->contains($workspace)) { - $this->actionMessage = 'This workspace does not belong to this user.'; - $this->actionType = 'error'; + // Verify this belongs to one of the user's workspaces + if (! $this->user->hostWorkspaces->contains($workspace)) { + $this->actionMessage = 'This workspace does not belong to this user.'; + $this->actionType = 'error'; - return; - } + return; + } - $package = Package::where('code', $packageCode)->first(); - $packageName = $package?->name ?? $packageCode; - $workspaceName = $workspace->name; + $package = Package::where('code', $packageCode)->first(); + $packageName = $package?->name ?? $packageCode; + $workspaceName = $workspace->name; - $entitlements = app(EntitlementService::class); - $entitlements->revokePackage($workspace, $packageCode, 'admin'); + $entitlements = app(EntitlementService::class); + $entitlements->revokePackage($workspace, $packageCode, 'admin'); - Log::info('Package revoked by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'package_code' => $packageCode, - ]); + Log::info('Package revoked by admin', [ + 'admin_id' => auth()->id(), + 'user_id' => $this->user->id, + 'workspace_id' => $workspace->id, + 'package_code' => $packageCode, + ]); - $this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'."; - $this->actionType = 'success'; + $this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'."; + $this->actionType = 'success'; - unset($this->workspaces); // Clear computed cache + unset($this->workspaces); // Clear computed cache + }); } // ───────────────────────────────────────────────────────────── @@ -586,68 +603,70 @@ class PlatformUser extends Component */ public function provisionEntitlement(): void { - if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) { - $this->actionMessage = 'Please select a workspace and feature.'; - $this->actionType = 'warning'; + $this->rateLimit('admin-entitlement', 10, function () { + if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) { + $this->actionMessage = 'Please select a workspace and feature.'; + $this->actionType = 'warning'; - return; - } + return; + } - $workspace = Workspace::findOrFail($this->entitlementWorkspaceId); - $feature = Feature::where('code', $this->entitlementFeatureCode)->first(); + $workspace = Workspace::findOrFail($this->entitlementWorkspaceId); + $feature = Feature::where('code', $this->entitlementFeatureCode)->first(); - if (! $feature) { - $this->actionMessage = 'Feature not found.'; - $this->actionType = 'error'; + if (! $feature) { + $this->actionMessage = 'Feature not found.'; + $this->actionType = 'error'; - return; - } + return; + } - // Verify this belongs to one of the user's workspaces - if (! $this->user->hostWorkspaces->contains($workspace)) { - $this->actionMessage = 'This workspace does not belong to this user.'; - $this->actionType = 'error'; + // Verify this belongs to one of the user's workspaces + if (! $this->user->hostWorkspaces->contains($workspace)) { + $this->actionMessage = 'This workspace does not belong to this user.'; + $this->actionType = 'error'; - return; - } + return; + } - $options = [ - 'source' => 'admin', - 'boost_type' => match ($this->entitlementType) { - 'enable' => Boost::BOOST_TYPE_ENABLE, - 'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT, - 'unlimited' => Boost::BOOST_TYPE_UNLIMITED, - default => Boost::BOOST_TYPE_ENABLE, - }, - 'duration_type' => $this->entitlementDuration === 'permanent' - ? Boost::DURATION_PERMANENT - : Boost::DURATION_DURATION, - ]; + $options = [ + 'source' => 'admin', + 'boost_type' => match ($this->entitlementType) { + 'enable' => Boost::BOOST_TYPE_ENABLE, + 'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT, + 'unlimited' => Boost::BOOST_TYPE_UNLIMITED, + default => Boost::BOOST_TYPE_ENABLE, + }, + 'duration_type' => $this->entitlementDuration === 'permanent' + ? Boost::DURATION_PERMANENT + : Boost::DURATION_DURATION, + ]; - if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) { - $options['limit_value'] = $this->entitlementLimit; - } + if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) { + $options['limit_value'] = $this->entitlementLimit; + } - if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) { - $options['expires_at'] = $this->entitlementExpiresAt; - } + if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) { + $options['expires_at'] = $this->entitlementExpiresAt; + } - $entitlements = app(EntitlementService::class); - $entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options); + $entitlements = app(EntitlementService::class); + $entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options); - Log::info('Entitlement provisioned by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'feature_code' => $this->entitlementFeatureCode, - 'type' => $this->entitlementType, - ]); + Log::info('Entitlement provisioned by admin', [ + 'admin_id' => auth()->id(), + 'user_id' => $this->user->id, + 'workspace_id' => $workspace->id, + 'feature_code' => $this->entitlementFeatureCode, + 'type' => $this->entitlementType, + ]); - $this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'."; - $this->actionType = 'success'; + $this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'."; + $this->actionType = 'success'; - $this->closeEntitlementModal(); - unset($this->workspaceEntitlements); + $this->closeEntitlementModal(); + unset($this->workspaceEntitlements); + }); } /** @@ -655,34 +674,36 @@ class PlatformUser extends Component */ public function removeBoost(int $boostId): void { - $boost = Boost::findOrFail($boostId); + $this->rateLimit('admin-entitlement', 10, function () use ($boostId) { + $boost = Boost::findOrFail($boostId); - // Verify this belongs to one of the user's workspaces - $workspace = $boost->workspace; - if (! $this->user->hostWorkspaces->contains($workspace)) { - $this->actionMessage = 'This boost does not belong to this user.'; - $this->actionType = 'error'; + // Verify this belongs to one of the user's workspaces + $workspace = $boost->workspace; + if (! $this->user->hostWorkspaces->contains($workspace)) { + $this->actionMessage = 'This boost does not belong to this user.'; + $this->actionType = 'error'; - return; - } + return; + } - $featureCode = $boost->feature_code; - $workspaceName = $workspace->name; + $featureCode = $boost->feature_code; + $workspaceName = $workspace->name; - $boost->update(['status' => Boost::STATUS_CANCELLED]); + $boost->update(['status' => Boost::STATUS_CANCELLED]); - Log::info('Boost removed by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'boost_id' => $boostId, - 'feature_code' => $featureCode, - ]); + Log::info('Boost removed by admin', [ + 'admin_id' => auth()->id(), + 'user_id' => $this->user->id, + 'workspace_id' => $workspace->id, + 'boost_id' => $boostId, + 'feature_code' => $featureCode, + ]); - $this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'."; - $this->actionType = 'success'; + $this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'."; + $this->actionType = 'success'; - unset($this->workspaceEntitlements); + unset($this->workspaceEntitlements); + }); } public function render() diff --git a/src/Website/Hub/View/Modal/Admin/Settings.php b/src/Website/Hub/View/Modal/Admin/Settings.php index fd4de13..df3b5f3 100644 --- a/src/Website/Hub/View/Modal/Admin/Settings.php +++ b/src/Website/Hub/View/Modal/Admin/Settings.php @@ -15,9 +15,12 @@ use Core\Tenant\Mail\AccountDeletionRequested; use Core\Tenant\Models\AccountDeletionRequest; use Core\Tenant\Models\User; use Core\Tenant\Services\UserStatsService; +use Website\Hub\Concerns\HasRateLimiting; class Settings extends Component { + use HasRateLimiting; + // Active section for sidebar navigation #[Url(as: 'tab')] public string $activeSection = 'profile'; @@ -108,6 +111,12 @@ class Settings extends Component $this->timezones = UserStatsService::getTimezoneList(); } + protected function onRateLimited(string $action, string $key): void + { + $seconds = \Illuminate\Support\Facades\RateLimiter::availableIn($key); + Flux::toast(text: "Too many requests. Please wait {$seconds} seconds before trying again.", variant: 'danger'); + } + protected function getUserSetting(string $name, mixed $default = null): mixed { $setting = Setting::where('user_id', Auth::id()) @@ -119,19 +128,21 @@ class Settings extends Component public function updateProfile(): void { - $this->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()], - ]); + $this->rateLimit('profile-update', 20, function () { + $this->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()], + ]); - $user = User::findOrFail(Auth::id()); - $user->update([ - 'name' => $this->name, - 'email' => $this->email, - ]); + $user = User::findOrFail(Auth::id()); + $user->update([ + 'name' => $this->name, + 'email' => $this->email, + ]); - $this->dispatch('profile-updated'); - Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success'); + $this->dispatch('profile-updated'); + Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success'); + }); } public function updatePreferences(): void @@ -163,22 +174,24 @@ class Settings extends Component public function updatePassword(): void { - $this->validate([ - 'current_password' => ['required', 'current_password'], - 'new_password' => ['required', 'confirmed', Password::defaults()], - ]); + $this->rateLimit('password-change', 5, function () { + $this->validate([ + 'current_password' => ['required', 'current_password'], + 'new_password' => ['required', 'confirmed', Password::defaults()], + ]); - $user = User::findOrFail(Auth::id()); - $user->update([ - 'password' => Hash::make($this->new_password), - ]); + $user = User::findOrFail(Auth::id()); + $user->update([ + 'password' => Hash::make($this->new_password), + ]); - $this->current_password = ''; - $this->new_password = ''; - $this->new_password_confirmation = ''; + $this->current_password = ''; + $this->new_password = ''; + $this->new_password_confirmation = ''; - $this->dispatch('password-updated'); - Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success'); + $this->dispatch('password-updated'); + Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success'); + }); } public function enableTwoFactor(): void @@ -213,20 +226,22 @@ class Settings extends Component public function requestAccountDeletion(): void { - // Get the base user model for the app - $user = \Core\Tenant\Models\User::findOrFail(Auth::id()); + $this->rateLimit('account-deletion', 3, function () { + // Get the base user model for the app + $user = \Core\Tenant\Models\User::findOrFail(Auth::id()); - // Create the deletion request - $deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null); + // Create the deletion request + $deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null); - // Send confirmation email - Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest)); + // Send confirmation email + Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest)); - $this->pendingDeletion = $deletionRequest; - $this->showDeleteConfirmation = false; - $this->deleteReason = ''; + $this->pendingDeletion = $deletionRequest; + $this->showDeleteConfirmation = false; + $this->deleteReason = ''; - Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning'); + Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning'); + }); } public function cancelAccountDeletion(): void diff --git a/src/Website/Hub/View/Modal/Admin/WaitlistManager.php b/src/Website/Hub/View/Modal/Admin/WaitlistManager.php index ea35ac2..ed3091b 100644 --- a/src/Website/Hub/View/Modal/Admin/WaitlistManager.php +++ b/src/Website/Hub/View/Modal/Admin/WaitlistManager.php @@ -10,10 +10,12 @@ use Livewire\Attributes\Computed; use Livewire\Attributes\Title; use Livewire\Component; use Livewire\WithPagination; +use Website\Hub\Concerns\HasRateLimiting; #[Title('Waitlist')] class WaitlistManager extends Component { + use HasRateLimiting; use WithPagination; // Filters @@ -68,19 +70,21 @@ class WaitlistManager extends Component */ public function sendInvite(int $id): void { - $entry = WaitlistEntry::findOrFail($id); + $this->rateLimit('admin-mutation', 10, function () use ($id) { + $entry = WaitlistEntry::findOrFail($id); - if ($entry->isInvited()) { - session()->flash('error', 'This person has already been invited.'); + if ($entry->isInvited()) { + session()->flash('error', 'This person has already been invited.'); - return; - } + return; + } - $entry->generateInviteCode(); - $entry->notify(new WaitlistInviteNotification($entry)); + $entry->generateInviteCode(); + $entry->notify(new WaitlistInviteNotification($entry)); - session()->flash('message', "Invite sent to {$entry->email}"); - $this->refreshStats(); + session()->flash('message', "Invite sent to {$entry->email}"); + $this->refreshStats(); + }); } /** @@ -88,28 +92,30 @@ class WaitlistManager extends Component */ public function sendBulkInvites(): void { - $entries = WaitlistEntry::whereIn('id', $this->selected) - ->whereNull('invited_at') - ->get(); + $this->rateLimit('admin-mutation', 10, function () { + $entries = WaitlistEntry::whereIn('id', $this->selected) + ->whereNull('invited_at') + ->get(); - if ($entries->isEmpty()) { - session()->flash('error', 'No pending entries selected.'); + if ($entries->isEmpty()) { + session()->flash('error', 'No pending entries selected.'); - return; - } + return; + } - $count = 0; - foreach ($entries as $entry) { - $entry->generateInviteCode(); - $entry->notify(new WaitlistInviteNotification($entry)); - $count++; - } + $count = 0; + foreach ($entries as $entry) { + $entry->generateInviteCode(); + $entry->notify(new WaitlistInviteNotification($entry)); + $count++; + } - $this->selected = []; - $this->selectAll = false; + $this->selected = []; + $this->selectAll = false; - session()->flash('message', "Sent {$count} invite(s) successfully."); - $this->refreshStats(); + session()->flash('message', "Sent {$count} invite(s) successfully."); + $this->refreshStats(); + }); } /** @@ -141,18 +147,20 @@ class WaitlistManager extends Component */ public function delete(int $id): void { - $entry = WaitlistEntry::findOrFail($id); + $this->rateLimit('admin-mutation', 10, function () use ($id) { + $entry = WaitlistEntry::findOrFail($id); - if ($entry->hasConverted()) { - session()->flash('error', 'Cannot delete entries that have converted to users.'); + if ($entry->hasConverted()) { + session()->flash('error', 'Cannot delete entries that have converted to users.'); - return; - } + return; + } - $entry->delete(); + $entry->delete(); - session()->flash('message', 'Entry deleted.'); - $this->refreshStats(); + session()->flash('message', 'Entry deleted.'); + $this->refreshStats(); + }); } /** @@ -171,30 +179,32 @@ class WaitlistManager extends Component */ public function export() { - $entries = $this->getFilteredQuery()->get(); + return $this->rateLimit('admin-export', 5, function () { + $entries = $this->getFilteredQuery()->get(); - $csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n"; + $csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n"; - foreach ($entries as $entry) { - $status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending'); - $csv .= sprintf( - "%s,%s,%s,%s,%s,%s,%s,%s\n", - $entry->email, - $entry->name ?? '', - $entry->interest ?? '', - $entry->source ?? '', - $status, - $entry->created_at->format('Y-m-d'), - $entry->invited_at?->format('Y-m-d') ?? '', - $entry->registered_at?->format('Y-m-d') ?? '' - ); - } + foreach ($entries as $entry) { + $status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending'); + $csv .= sprintf( + "%s,%s,%s,%s,%s,%s,%s,%s\n", + $entry->email, + $entry->name ?? '', + $entry->interest ?? '', + $entry->source ?? '', + $status, + $entry->created_at->format('Y-m-d'), + $entry->invited_at?->format('Y-m-d') ?? '', + $entry->registered_at?->format('Y-m-d') ?? '' + ); + } - return response()->streamDownload(function () use ($csv) { - echo $csv; - }, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [ - 'Content-Type' => 'text/csv', - ]); + return response()->streamDownload(function () use ($csv) { + echo $csv; + }, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [ + 'Content-Type' => 'text/csv', + ]); + }); } protected function refreshStats(): void diff --git a/tests/Feature/Security/RateLimitingTest.php b/tests/Feature/Security/RateLimitingTest.php new file mode 100644 index 0000000..f3898f7 --- /dev/null +++ b/tests/Feature/Security/RateLimitingTest.php @@ -0,0 +1,280 @@ +rateLimit('test-mutation', 3, function () { + $this->executionCount++; + $this->actionMessage = 'Action executed.'; + $this->actionType = 'success'; + }); + } + + public function export() + { + return $this->rateLimit('test-export', 2, function () { + $this->executionCount++; + + return 'export-data'; + }); + } + + public function destroy(): void + { + $this->rateLimit('test-deletion', 1, function () { + $this->executionCount++; + $this->actionMessage = 'Deleted.'; + $this->actionType = 'success'; + }); + } + + public function render(): string + { + return <<<'HTML' +
+ Executions: {{ $executionCount }} + Message: {{ $actionMessage }} + Type: {{ $actionType }} +
+ HTML; + } +} + +/** + * Component without actionMessage/actionType (session flash fallback). + */ +class RateLimitedSessionComponent extends Component +{ + use HasRateLimiting; + + public int $executionCount = 0; + + public function mutate(): void + { + $this->rateLimit('test-session-mutation', 2, function () { + $this->executionCount++; + }); + } + + public function render(): string + { + return <<<'HTML' +
+ Executions: {{ $executionCount }} +
+ HTML; + } +} + +// ============================================================================= +// Rate Limiting Enforcement Tests +// ============================================================================= + +beforeEach(function () { + RateLimiter::clear('test-mutation:1'); + RateLimiter::clear('test-export:1'); + RateLimiter::clear('test-deletion:1'); + RateLimiter::clear('test-session-mutation:1'); +}); + +describe('Rate limiting enforcement', function () { + it('allows actions within the rate limit', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + Livewire::test(RateLimitedActionComponent::class) + ->call('mutate') + ->assertSet('executionCount', 1) + ->assertSet('actionMessage', 'Action executed.') + ->assertSet('actionType', 'success') + ->call('mutate') + ->assertSet('executionCount', 2) + ->call('mutate') + ->assertSet('executionCount', 3); + }); + + it('blocks actions exceeding the rate limit', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + $component = Livewire::test(RateLimitedActionComponent::class); + + // Execute up to the limit + $component->call('mutate') + ->call('mutate') + ->call('mutate') + ->assertSet('executionCount', 3); + + // Fourth call should be blocked + $component->call('mutate') + ->assertSet('executionCount', 3) // Not incremented + ->assertSet('actionType', 'error') + ->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests')); + }); + + it('blocks export actions exceeding the rate limit', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + $component = Livewire::test(RateLimitedActionComponent::class); + + // Execute up to the limit (2 for exports) + $component->call('export') + ->assertSet('executionCount', 1) + ->call('export') + ->assertSet('executionCount', 2); + + // Third call should be blocked + $component->call('export') + ->assertSet('executionCount', 2) // Not incremented + ->assertSet('actionType', 'error'); + }); + + it('enforces strict limits on destructive actions', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + $component = Livewire::test(RateLimitedActionComponent::class); + + // Execute up to the limit (1 for deletions) + $component->call('destroy') + ->assertSet('executionCount', 1) + ->assertSet('actionMessage', 'Deleted.'); + + // Second call should be blocked + $component->call('destroy') + ->assertSet('executionCount', 1) // Not incremented + ->assertSet('actionType', 'error') + ->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests')); + }); +}); + +// ============================================================================= +// Rate Limit Key Scoping Tests +// ============================================================================= + +describe('Rate limit key scoping', function () { + it('scopes rate limits per user', function () { + // User 1 exhausts their limit + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + RateLimiter::clear('test-deletion:1'); + RateLimiter::clear('test-deletion:2'); + + $component1 = Livewire::test(RateLimitedActionComponent::class); + $component1->call('destroy') + ->assertSet('executionCount', 1); + $component1->call('destroy') + ->assertSet('executionCount', 1); // Blocked + + // User 2 should not be affected + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 2])); + + Livewire::test(RateLimitedActionComponent::class) + ->call('destroy') + ->assertSet('executionCount', 1) // User 2's own count + ->assertSet('actionMessage', 'Deleted.'); + }); + + it('uses separate limits for different action types', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + $component = Livewire::test(RateLimitedActionComponent::class); + + // Exhaust deletion limit (1) + $component->call('destroy') + ->assertSet('executionCount', 1); + + // Mutation limit (3) should still be available + $component->call('mutate') + ->assertSet('executionCount', 2) + ->assertSet('actionMessage', 'Action executed.') + ->assertSet('actionType', 'success'); + }); +}); + +// ============================================================================= +// User Feedback Tests +// ============================================================================= + +describe('User feedback when rate limited', function () { + it('shows error message with retry time via actionMessage', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + $component = Livewire::test(RateLimitedActionComponent::class); + + // Exhaust limit + $component->call('destroy'); + + // Next call should show error with seconds + $component->call('destroy') + ->assertSet('actionType', 'error') + ->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests') + && str_contains($msg, 'seconds')); + }); + + it('flashes error to session when component lacks actionMessage property', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + $component = Livewire::test(RateLimitedSessionComponent::class); + + // Exhaust limit (2) + $component->call('mutate')->call('mutate'); + + // Third call should be blocked and flash to session + $component->call('mutate') + ->assertSet('executionCount', 2); // Not incremented + }); +}); + +// ============================================================================= +// Rate Limit Reset Tests +// ============================================================================= + +describe('Rate limit reset', function () { + it('allows actions after rate limit window resets', function () { + $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); + + $component = Livewire::test(RateLimitedActionComponent::class); + + // Exhaust limit + $component->call('destroy') + ->assertSet('executionCount', 1); + $component->call('destroy') + ->assertSet('executionCount', 1); // Blocked + + // Clear the rate limiter (simulates window expiry) + RateLimiter::clear('test-deletion:1'); + + // Should work again + $component->call('destroy') + ->assertSet('executionCount', 2) + ->assertSet('actionMessage', 'Deleted.') + ->assertSet('actionType', 'success'); + }); +});