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>
This commit is contained in:
178
app/Filament/Pages/Auth/PireonLogin.php
Normal file
178
app/Filament/Pages/Auth/PireonLogin.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user