Skip to content

Commit b0f0f7d

Browse files
committed
feat: harden auth flows and server mobile navigation
Add normalized email identity rate limiting for registration and forgot-password requests, and refresh Sentinel status from restart broadcasts. Rework server sidebars and navbar for mobile menus and active status visibility.
1 parent 94079c9 commit b0f0f7d

42 files changed

Lines changed: 1064 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/Actions/Fortify/CreateNewUser.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,26 @@
44

55
use App\Models\Team;
66
use App\Models\User;
7+
use Illuminate\Http\Request;
78
use Illuminate\Support\Facades\Hash;
9+
use Illuminate\Support\Facades\RateLimiter;
810
use Illuminate\Support\Facades\Validator;
911
use Illuminate\Validation\Rule;
1012
use Illuminate\Validation\Rules\Password;
1113
use Laravel\Fortify\Contracts\CreatesNewUsers;
1214

1315
class CreateNewUser implements CreatesNewUsers
1416
{
17+
private const REGISTRATION_IP_MAX_ATTEMPTS = 3;
18+
19+
private const REGISTRATION_IP_DECAY_SECONDS = 600;
20+
21+
private const REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS = 3;
22+
23+
private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600;
24+
25+
public function __construct(private readonly Request $request) {}
26+
1527
/**
1628
* Validate and create a newly registered user.
1729
*
@@ -23,6 +35,9 @@ public function create(array $input): User
2335
if (! $settings->is_registration_enabled) {
2436
abort(403);
2537
}
38+
39+
$this->ensureRegistrationIsNotRateLimited($input);
40+
2641
Validator::make($input, [
2742
'name' => ['required', 'string', 'max:255'],
2843
'email' => [
@@ -72,4 +87,42 @@ public function create(array $input): User
7287

7388
return $user;
7489
}
90+
91+
/**
92+
* @param array<string, string> $input
93+
*/
94+
private function ensureRegistrationIsNotRateLimited(array $input): void
95+
{
96+
$keys = [
97+
[
98+
'key' => 'registration:ip:'.sha1($this->realIp()),
99+
'max' => self::REGISTRATION_IP_MAX_ATTEMPTS,
100+
'decay' => self::REGISTRATION_IP_DECAY_SECONDS,
101+
],
102+
];
103+
104+
$emailIdentity = normalize_email_identity($input['email'] ?? null);
105+
if ($emailIdentity !== null) {
106+
$keys[] = [
107+
'key' => 'registration:email-identity:'.sha1($emailIdentity),
108+
'max' => self::REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS,
109+
'decay' => self::REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS,
110+
];
111+
}
112+
113+
foreach ($keys as $limit) {
114+
if (RateLimiter::tooManyAttempts($limit['key'], $limit['max'])) {
115+
abort(429, 'Too many registration attempts. Please try again later.');
116+
}
117+
}
118+
119+
foreach ($keys as $limit) {
120+
RateLimiter::hit($limit['key'], $limit['decay']);
121+
}
122+
}
123+
124+
private function realIp(): string
125+
{
126+
return $this->request->server('REMOTE_ADDR') ?? $this->request->ip();
127+
}
75128
}

app/Livewire/Server/Navbar.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public function getListeners()
3939
return [
4040
'refreshServerShow' => 'refreshServer',
4141
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
42+
"echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus',
4243
];
4344
}
4445

@@ -203,6 +204,15 @@ public function refreshServer()
203204
$this->server->load('settings');
204205
}
205206

207+
public function refreshSentinelStatus($event = null): void
208+
{
209+
if (isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) {
210+
return;
211+
}
212+
213+
$this->refreshServer();
214+
}
215+
206216
/**
207217
* Check if Traefik has any outdated version info (patch or minor upgrade).
208218
* This shows a warning indicator in the navbar.

app/Providers/FortifyServiceProvider.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Actions\Fortify\UpdateUserPassword;
88
use App\Actions\Fortify\UpdateUserProfileInformation;
99
use App\Models\OauthSetting;
10+
use App\Models\TeamInvitation;
1011
use App\Models\User;
1112
use Illuminate\Cache\RateLimiting\Limit;
1213
use Illuminate\Http\Request;
@@ -82,7 +83,7 @@ public function boot(): void
8283
$user->save();
8384

8485
// Check if user has a pending invitation they haven't accepted yet
85-
$invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
86+
$invitation = TeamInvitation::whereEmail($email)->first();
8687
if ($invitation && $invitation->isValid()) {
8788
// User is logging in for the first time after being invited
8889
// Attach them to the invited team if not already attached
@@ -130,7 +131,16 @@ public function boot(): void
130131
// Use real client IP (not spoofable forwarded headers)
131132
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
132133

133-
return Limit::perMinute(5)->by($realIp);
134+
$limits = [
135+
Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)),
136+
];
137+
138+
$emailIdentity = normalize_email_identity($request->input('email'));
139+
if ($emailIdentity !== null) {
140+
$limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity));
141+
}
142+
143+
return $limits;
134144
});
135145

136146
RateLimiter::for('login', function (Request $request) {

bootstrap/helpers/email.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
use Illuminate\Support\Str;
4+
5+
function normalize_email_identity(?string $email): ?string
6+
{
7+
if (blank($email) || ! str_contains($email, '@')) {
8+
return null;
9+
}
10+
11+
[$localPart, $domain] = explode('@', Str::lower($email), 2);
12+
$localPart = Str::before($localPart, '+');
13+
$localPart = str_replace('.', '', $localPart);
14+
15+
if (blank($localPart) || blank($domain)) {
16+
return null;
17+
}
18+
19+
return $localPart.'@'.$domain;
20+
}

resources/views/components/resources/breadcrumbs.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
<nav class="pt-2 pb-4 md:pb-10">
5353
<div class="flex min-w-0 flex-col gap-1 md:hidden">
5454
<div class="flex min-w-0 items-center text-xs text-neutral-400">
55-
<a class="min-w-0 truncate text-neutral-300 hover:text-warning" {{ wireNavigate() }}
55+
<a class="min-w-0 truncate" {{ wireNavigate() }}
5656
href="{{ $isApplication
5757
? route('project.application.configuration', $routeParams)
5858
: ($isService
Lines changed: 138 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,139 @@
1-
<div class="sub-menu-wrapper">
2-
<a class="{{ request()->routeIs('server.proxy') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
3-
href="{{ route('server.proxy', $parameters) }}">
4-
<span class="menu-item-label">Configuration</span>
5-
</a>
6-
@if ($server->proxySet())
7-
<a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
8-
href="{{ route('server.proxy.dynamic-confs', $parameters) }}">
9-
<span class="menu-item-label">Dynamic Configurations</span>
10-
</a>
11-
<a class="{{ request()->routeIs('server.proxy.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}"
12-
href="{{ route('server.proxy.logs', $parameters) }}">
13-
<span class="menu-item-label">Logs</span>
14-
</a>
15-
@endif
1+
@php
2+
$serverPageItems = [
3+
[
4+
'label' => 'Configuration',
5+
'route' => 'server.show',
6+
'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'),
7+
],
8+
[
9+
'label' => 'Proxy',
10+
'route' => 'server.proxy',
11+
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
12+
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
13+
],
14+
[
15+
'label' => 'Sentinel',
16+
'route' => 'server.sentinel',
17+
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
18+
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
19+
],
20+
[
21+
'label' => 'Resources',
22+
'route' => 'server.resources',
23+
'active' => request()->routeIs('server.resources'),
24+
],
25+
[
26+
'label' => 'Terminal',
27+
'route' => 'server.command',
28+
'active' => request()->routeIs('server.command'),
29+
'navigate' => false,
30+
'visible' => auth()->user()?->can('canAccessTerminal'),
31+
],
32+
[
33+
'label' => 'Security',
34+
'route' => 'server.security.patches',
35+
'active' => request()->routeIs('server.security.patches'),
36+
'visible' => auth()->user()?->can('update', $server),
37+
],
38+
];
39+
$proxyMenuItems = [
40+
[
41+
'label' => 'Configuration',
42+
'route' => 'server.proxy',
43+
'active' => request()->routeIs('server.proxy'),
44+
],
45+
[
46+
'label' => 'Dynamic Configurations',
47+
'route' => 'server.proxy.dynamic-confs',
48+
'active' => request()->routeIs('server.proxy.dynamic-confs'),
49+
'visible' => $server->proxySet(),
50+
],
51+
[
52+
'label' => 'Logs',
53+
'route' => 'server.proxy.logs',
54+
'active' => request()->routeIs('server.proxy.logs'),
55+
'visible' => $server->proxySet(),
56+
'navigate' => false,
57+
],
58+
];
59+
60+
$serverPageItems = array_values(array_filter(
61+
$serverPageItems,
62+
fn (array $item): bool => $item['visible'] ?? true,
63+
));
64+
$proxyMenuItems = array_values(array_filter(
65+
$proxyMenuItems,
66+
fn (array $item): bool => $item['visible'] ?? true,
67+
));
68+
$activeProxyMenuItem = collect($proxyMenuItems)->firstWhere('active', true) ?? $proxyMenuItems[0];
69+
$activeProxyMenuValue = (($activeProxyMenuItem['navigate'] ?? true) ? 'navigate' : 'location').'|proxy|'.route($activeProxyMenuItem['route'], $parameters);
70+
@endphp
71+
72+
<div class="w-full md:w-auto">
73+
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
74+
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
75+
<select id="server-mobile-section" class="select w-full" aria-label="Proxy menu"
76+
data-current-value="{{ $activeProxyMenuValue }}"
77+
x-data="{
78+
init() {
79+
this.syncFromLocation();
80+
window.Livewire?.hook?.('morphed', ({ el }) => {
81+
if (el.contains(this.$el)) {
82+
queueMicrotask(() => this.syncFromLocation());
83+
}
84+
});
85+
},
86+
selected: $el.dataset.currentValue,
87+
syncFromLocation() {
88+
const currentUrl = new URL(window.location.href);
89+
const matchingOptions = Array.from(this.$el.options).filter((option) => {
90+
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
91+
92+
return optionUrl.pathname === currentUrl.pathname;
93+
});
94+
const selectedOption = matchingOptions.find((option) => option.value.startsWith('navigate|proxy|') || option.value.startsWith('location|proxy|')) || matchingOptions[0];
95+
96+
if (selectedOption) {
97+
this.selected = selectedOption.value;
98+
}
99+
},
100+
}"
101+
x-on:livewire:navigated.window="syncFromLocation()"
102+
x-model="selected"
103+
x-on:change="
104+
const value = $event.target.value;
105+
const url = value.split('|').slice(2).join('|');
106+
107+
if (value.startsWith('navigate|')) {
108+
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
109+
return;
110+
}
111+
112+
window.location.href = url;
113+
">
114+
<optgroup label="Server">
115+
@foreach ($serverPageItems as $menuItem)
116+
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $parameters) }}">
117+
{{ $menuItem['label'] }}
118+
</option>
119+
@endforeach
120+
</optgroup>
121+
<optgroup label="Proxy">
122+
@foreach ($proxyMenuItems as $menuItem)
123+
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|proxy|{{ route($menuItem['route'], $parameters) }}">
124+
{{ $menuItem['label'] }}
125+
</option>
126+
@endforeach
127+
</optgroup>
128+
</select>
129+
</div>
130+
131+
<div class="sub-menu-wrapper hidden md:flex">
132+
@foreach ($proxyMenuItems as $menuItem)
133+
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
134+
href="{{ route($menuItem['route'], $parameters) }}">
135+
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
136+
</a>
137+
@endforeach
138+
</div>
16139
</div>

0 commit comments

Comments
 (0)