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.
This commit is contained in:
Andras Bacsai committed 2026-07-08 09:42:52 +02:00
1 parent 94079c90f2
commit b0f0f7d8d0
42 files changed
+1064 -128

No files matched your search

+53
View File
@@ -4,7 +4,9 @@ namespace App\Actions\Fortify;
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 @@ use Laravel\Fortify\Contracts\CreatesNewUsers;
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 @@ class CreateNewUser implements CreatesNewUsers
if (! $settings->is_registration_enabled) {
abort(403);
}
$this->ensureRegistrationIsNotRateLimited($input);
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
@@ -72,4 +87,42 @@ class CreateNewUser implements CreatesNewUsers
return $user;
}
/**
* @param array<string, string> $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();
}
}
+10
View File
@@ -39,6 +39,7 @@ class Navbar extends Component
return [
'refreshServerShow' => 'refreshServer',
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
"echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus',
];
}
@@ -203,6 +204,15 @@ class Navbar extends Component
$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.
+12 -2
View File
@@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword;
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 @@ class FortifyServiceProvider extends ServiceProvider
$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 @@ class FortifyServiceProvider extends ServiceProvider
// 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) {