Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
6.2 KiB
PHP
179 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Auth;
|
|
|
|
use App\Actions\Auth\SyncPireonUser;
|
|
use App\Services\Pireon\AccessToken;
|
|
use App\Services\Pireon\PireonClient;
|
|
use App\Services\Pireon\PireonException;
|
|
use App\Services\Pireon\TokenStore;
|
|
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
|
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
|
use Filament\Auth\Pages\Login;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Contracts\Support\Htmlable;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
use SensitiveParameter;
|
|
|
|
/**
|
|
* The panel's login screen: an ordinary e-mail and password form.
|
|
*
|
|
* The identity server is still the only thing that decides whether those
|
|
* credentials are correct — there are no local passwords, and `users` has no
|
|
* usable password column — but the person signing in never sees that. They
|
|
* type their credentials here and land in the panel.
|
|
*
|
|
* The cost of that convenience is stated plainly because it is real: this
|
|
* application now handles the password rather than never seeing it. It is sent
|
|
* straight to the identity server over the password grant and is never stored,
|
|
* logged, or written anywhere; but a compromise here would expose credentials
|
|
* to the identity server, which the redirect flow made impossible. The
|
|
* Authorization Code + PKCE routes are still in place
|
|
* ([`PireonAuthController`](app/Http/Controllers/Auth/PireonAuthController.php))
|
|
* for anyone who wants that trade back.
|
|
*/
|
|
class PireonLogin extends Login
|
|
{
|
|
public function getTitle(): string|Htmlable
|
|
{
|
|
return __('pireon.sign_in');
|
|
}
|
|
|
|
public function getHeading(): string|Htmlable
|
|
{
|
|
return __('pireon.sign_in');
|
|
}
|
|
|
|
public function getSubheading(): string|Htmlable|null
|
|
{
|
|
return __('pireon.sign_in_intro');
|
|
}
|
|
|
|
/**
|
|
* Filament's own flow checks the credentials against the local user table.
|
|
* There are no local passwords here, so the whole of it is replaced: the
|
|
* identity server is asked instead, and only what it says is trusted.
|
|
*/
|
|
public function authenticate(): ?LoginResponse
|
|
{
|
|
try {
|
|
// Filament's own limiter, keyed by IP: five attempts, then a wait.
|
|
// The identity server has its own; this stops us spending it.
|
|
$this->rateLimit(5);
|
|
} catch (TooManyRequestsException $exception) {
|
|
$this->getRateLimitedNotification($exception)?->send();
|
|
|
|
return null;
|
|
}
|
|
|
|
$data = $this->form->getState();
|
|
|
|
$this->assertUsable();
|
|
|
|
$token = $this->exchange((string) $data['email'], (string) $data['password']);
|
|
|
|
$client = app(PireonClient::class);
|
|
|
|
try {
|
|
$identity = $client->identity($token->accessToken);
|
|
} catch (PireonException $e) {
|
|
$this->fail($e);
|
|
}
|
|
|
|
// Said only once the credentials were right, so this cannot be used to
|
|
// find out which addresses have an account.
|
|
if (! $identity->isUsable()) {
|
|
throw ValidationException::withMessages([
|
|
'data.email' => $identity->isApproved()
|
|
? __('pireon.inactive')
|
|
: __('pireon.pending_approval'),
|
|
]);
|
|
}
|
|
|
|
$user = app(SyncPireonUser::class)->handle($identity);
|
|
|
|
// Being a valid account upstream is not the same as being allowed in
|
|
// here: roles are this application's business.
|
|
if (! $user->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
|
|
throw ValidationException::withMessages([
|
|
'data.email' => __('pireon.no_permission'),
|
|
]);
|
|
}
|
|
|
|
// Rotate the session id so a fixated pre-login session cannot be
|
|
// reused now that it carries an authenticated identity.
|
|
session()->regenerate();
|
|
|
|
Auth::login($user, (bool) ($data['remember'] ?? false));
|
|
|
|
app(TokenStore::class)->put($token);
|
|
|
|
if ($user->locale) {
|
|
session()->put(config('locales.session_key'), $user->locale);
|
|
}
|
|
|
|
return app(LoginResponse::class);
|
|
}
|
|
|
|
/**
|
|
* Trade the credentials for a token upstream.
|
|
*
|
|
* Any refusal answers with the same message. The identity server
|
|
* distinguishes an unknown address from a wrong password; repeating that
|
|
* here would turn the form into a way of finding out who has an account.
|
|
*/
|
|
protected function exchange(string $email, #[SensitiveParameter] string $password): AccessToken
|
|
{
|
|
try {
|
|
return app(PireonClient::class)->requestPasswordToken($email, $password);
|
|
} catch (PireonException $e) {
|
|
$this->fail($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A misconfiguration is an operator's problem, not a visitor's, so it says
|
|
* what is actually wrong instead of "those credentials are incorrect".
|
|
*/
|
|
protected function assertUsable(): void
|
|
{
|
|
if (! app(PireonClient::class)->isConfigured()) {
|
|
throw ValidationException::withMessages([
|
|
'data.email' => __('pireon.not_configured_body', ['server' => (string) config('pireon.server')]),
|
|
]);
|
|
}
|
|
|
|
if (! config('pireon.password_grant_enabled')) {
|
|
throw ValidationException::withMessages([
|
|
'data.email' => __('pireon.password_grant_disabled'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
protected function fail(PireonException $e): never
|
|
{
|
|
// The address is logged, the password never is — it does not leave the
|
|
// request it arrived in.
|
|
Log::warning('Pireon sign-in failed', [
|
|
'message' => $e->getMessage(),
|
|
'status' => $e->status,
|
|
]);
|
|
|
|
// Only say "those credentials are wrong" when that is what happened.
|
|
// A server that answered 500, or did not answer at all, has said
|
|
// nothing about the password, and reporting otherwise sends whoever is
|
|
// signing in to look for a problem that is not theirs.
|
|
if (! $e->isRejection()) {
|
|
throw ValidationException::withMessages([
|
|
'data.email' => __('pireon.server_unavailable'),
|
|
]);
|
|
}
|
|
|
|
$this->throwFailureValidationException();
|
|
}
|
|
}
|