Files
landing-api/app/Filament/Pages/Auth/PireonLogin.php
Josep Piguillem 3897113313 Initial commit: Pireon Admin — headless admin panel and public API
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>
2026-08-19 17:55:27 +02:00

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();
}
}