Under the e-mail and password form, a button takes the redirect flow (Authorization Code + PKCE), so the password is only ever typed at the identity server. The routes for it were already there; the page had no way to reach them. A Pireon client allows one grant type and the panel's is a password client, so the redirect flow can use its own: PIREON_REDIRECT_CLIENT_ID and PIREON_REDIRECT_CLIENT_SECRET (the main client when they are empty). The button shows up once that client is set, or with PIREON_REDIRECT_LOGIN=true. Each session renews its token with the client that issued it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
7.0 KiB
PHP
202 lines
7.0 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\Actions\Action;
|
|
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.
|
|
*
|
|
* «Entrar con Pireon», under the form, takes the redirect flow instead: the
|
|
* password is then typed at the identity server only.
|
|
*
|
|
* 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');
|
|
}
|
|
|
|
/**
|
|
* Under «Entrar», «Entrar con Pireon»: the redirect flow, for whoever would
|
|
* rather not type their password here (or signs in some other way there).
|
|
*/
|
|
protected function getFormActions(): array
|
|
{
|
|
$actions = parent::getFormActions();
|
|
|
|
if (config('pireon.redirect_login') && PireonClient::forRedirect()->isConfigured()) {
|
|
$actions[] = Action::make('pireon')
|
|
->label(__('pireon.continue_with_pireon'))
|
|
->url(route('auth.redirect'))
|
|
->color('gray')
|
|
->outlined();
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
|
|
/**
|
|
* 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, viaRedirect: false);
|
|
|
|
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();
|
|
}
|
|
}
|