POST /api/v1/auth/token was limited to 6 requests a minute per IP. The deployed MCP server signs every user in from a single container, so under a per-IP budget one person's mistyped password, or a stranger's guesses, would lock every other user out of it. The named `auth-token` limiter keys on the lower-cased username (or the refresh token) plus the address, still 6 a minute, with a 30-a-minute ceiling per address to bound how many accounts one client can try. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
5.4 KiB
PHP
124 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Providers;
|
|
|
|
use App\Auth\PireonTokenGuard;
|
|
use App\Services\Git\GitManager;
|
|
use App\Services\Pireon\PireonClient;
|
|
use App\Services\Pireon\TokenStore;
|
|
use Illuminate\Auth\Events\Logout;
|
|
use Illuminate\Cache\RateLimiting\Limit;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\ServiceProvider;
|
|
use Illuminate\Support\Str;
|
|
|
|
class AppServiceProvider extends ServiceProvider
|
|
{
|
|
public function register(): void
|
|
{
|
|
$this->app->singleton(PireonClient::class, fn () => PireonClient::fromConfig());
|
|
|
|
$this->app->scoped(TokenStore::class, fn ($app) => new TokenStore(
|
|
$app['session.store'],
|
|
$app->make(PireonClient::class),
|
|
));
|
|
|
|
// Resolves git hosting drivers; see config/git.php.
|
|
$this->app->singleton(GitManager::class);
|
|
}
|
|
|
|
public function boot(): void
|
|
{
|
|
// Backs the "api" guard declared in config/auth.php.
|
|
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
|
->make(PireonTokenGuard::class)($request));
|
|
|
|
// Drop the OAuth token set whenever a session ends, no matter which
|
|
// route did it — ours, or Filament's own logout button.
|
|
Event::listen(Logout::class, function (): void {
|
|
if ($this->app->bound('session') && $this->app['session']->isStarted()) {
|
|
$this->app->make(TokenStore::class)->forget();
|
|
}
|
|
});
|
|
|
|
// Backs `throttle:api` on the REST routes. Authenticated callers are
|
|
// budgeted per user so one noisy client cannot exhaust the allowance
|
|
// of everyone sharing an IP.
|
|
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
|
|
->by($request->user()?->id ?: $request->ip()));
|
|
|
|
// Backs POST /api/v1/auth/token. Budgeted per account and address
|
|
// rather than per address alone, because the deployed MCP server
|
|
// (mcp/) signs everyone in from one container: per IP, one person's
|
|
// wrong password — or a stranger's guesses — would lock every other
|
|
// user out of it. The per-address ceiling still bounds how many
|
|
// accounts a single client can try.
|
|
RateLimiter::for('auth-token', fn (Request $request) => [
|
|
Limit::perMinute(6)->by('auth-token:'.sha1(
|
|
Str::lower((string) ($request->input('username') ?: $request->input('refresh_token')))
|
|
.'|'.$request->ip()
|
|
)),
|
|
|
|
Limit::perMinute(30)->by('auth-token-ip:'.$request->ip()),
|
|
]);
|
|
|
|
// The public API is a different problem. The public frontend renders
|
|
// server-side, so every visitor's page view arrives from the same IP:
|
|
// a per-IP budget sized for one person would throttle the entire
|
|
// website as though it were a single abusive client. Hence a generous
|
|
// limit, and an explicit bypass for the frontend's own address.
|
|
RateLimiter::for('public-api', function (Request $request) {
|
|
if (in_array($request->ip(), (array) config('site.trusted_ips'), true)) {
|
|
return Limit::none();
|
|
}
|
|
|
|
return Limit::perMinute((int) config('site.rate_limit', 600))->by($request->ip());
|
|
});
|
|
|
|
// Submissions are the opposite problem again. Reads are throttled per
|
|
// IP; writes cannot be, because SSR means every submission on the site
|
|
// arrives from the frontend's single address — which is in
|
|
// `trusted_ips` and would bypass the limit anyway.
|
|
//
|
|
// So the budget is spent per e-mail address, with a site-wide hourly
|
|
// ceiling behind it as the actual protection against a flood.
|
|
RateLimiter::for('public-write', fn (Request $request) => [
|
|
Limit::perMinute((int) config('site.submissions.rate_limit', 3))
|
|
->by('submission:'.sha1(Str::lower((string) $request->input('email')))),
|
|
|
|
Limit::perHour((int) config('site.submissions.hourly_limit', 60))
|
|
->by('submissions'),
|
|
]);
|
|
|
|
// Telemetry is a third case again. It is one server reporting to
|
|
// another, authenticated by a shared secret, so the address it comes
|
|
// from is stable and a per-IP budget means what it says — unlike the
|
|
// submissions, where the same address stands for every visitor.
|
|
//
|
|
// Trusted IPs deliberately do *not* bypass this one: the frontend is
|
|
// the only caller there will ever be, so an exemption would leave the
|
|
// endpoint with no ceiling at all.
|
|
RateLimiter::for('public-telemetry', fn (Request $request) => Limit::perMinute(
|
|
(int) config('site.telemetry.rate_limit', 60)
|
|
)->by($request->ip()));
|
|
|
|
// Fail loudly in development when a relation is used without being
|
|
// loaded, or a non-fillable attribute is assigned.
|
|
Model::shouldBeStrict($this->app->isLocal());
|
|
Model::unguard(false);
|
|
|
|
// The OAuth redirect URI is registered as https on the identity
|
|
// server, so generated URLs must match it in deployed environments.
|
|
if ($this->app->environment('production') || str_starts_with((string) config('app.url'), 'https://')) {
|
|
URL::forceScheme('https');
|
|
}
|
|
}
|
|
}
|