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>
135 lines
4.5 KiB
PHP
135 lines
4.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Actions\Auth\SyncPireonUser;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Services\Pireon\PireonClient;
|
|
use App\Services\Pireon\PireonException;
|
|
use App\Services\Pireon\TokenStore;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Authorization Code + PKCE flow against the Pireon identity server.
|
|
*
|
|
* The browser never sees a password belonging to this application, and the
|
|
* authorization code alone is useless without the verifier we keep server-side.
|
|
* It is «Entrar con Pireon» on the login page; it uses the redirect client
|
|
* (`PireonClient::forRedirect()`), its own one when configured.
|
|
*/
|
|
class PireonAuthController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected PireonClient $client,
|
|
protected TokenStore $tokens,
|
|
protected SyncPireonUser $sync,
|
|
) {}
|
|
|
|
/**
|
|
* Step 1 — hand the browser off to the identity server.
|
|
*/
|
|
public function redirect(Request $request): RedirectResponse
|
|
{
|
|
if (Auth::check()) {
|
|
return redirect()->intended(filament()->getUrl());
|
|
}
|
|
|
|
try {
|
|
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = PireonClient::forRedirect()->authorizationUrl();
|
|
} catch (PireonException $e) {
|
|
return $this->fail($e, route('login'));
|
|
}
|
|
|
|
$request->session()->put('pireon.state', $state);
|
|
$request->session()->put('pireon.code_verifier', $verifier);
|
|
|
|
// Remember where the user was headed before the redirect, so the
|
|
// callback can send them back there.
|
|
if ($target = $request->query('intended')) {
|
|
$request->session()->put('url.intended', $target);
|
|
}
|
|
|
|
return redirect()->away($url);
|
|
}
|
|
|
|
/**
|
|
* Step 2 — the identity server sends the browser back with a code.
|
|
*/
|
|
public function callback(Request $request): RedirectResponse
|
|
{
|
|
// The user pressed "deny", or the server refused the request.
|
|
if ($request->filled('error')) {
|
|
return redirect()->route('login')->with('error', __('pireon.denied', [
|
|
'reason' => (string) $request->query('error_description', $request->query('error')),
|
|
]));
|
|
}
|
|
|
|
$state = $request->session()->pull('pireon.state');
|
|
$verifier = $request->session()->pull('pireon.code_verifier');
|
|
|
|
// CSRF protection for the callback: a code arriving with a state we
|
|
// did not issue is not the result of a login we started.
|
|
if (blank($state) || ! hash_equals((string) $state, (string) $request->query('state'))) {
|
|
return $this->fail(PireonException::invalidState(), route('login'));
|
|
}
|
|
|
|
if (blank($verifier) || blank($request->query('code'))) {
|
|
return $this->fail(PireonException::invalidState(), route('login'));
|
|
}
|
|
|
|
try {
|
|
$token = PireonClient::forRedirect()->exchangeCode((string) $request->query('code'), (string) $verifier);
|
|
$identity = $this->client->identity($token->accessToken);
|
|
} catch (PireonException $e) {
|
|
return $this->fail($e, route('login'));
|
|
}
|
|
|
|
if (! $identity->isUsable()) {
|
|
return redirect()->route('login')->with('error', $identity->isApproved()
|
|
? __('pireon.inactive')
|
|
: __('pireon.pending_approval'));
|
|
}
|
|
|
|
$user = $this->sync->handle($identity);
|
|
|
|
// Rotate the session id so a fixated pre-login session cannot be
|
|
// reused now that it carries an authenticated identity.
|
|
$request->session()->regenerate();
|
|
|
|
Auth::login($user, remember: true);
|
|
$this->tokens->put($token, viaRedirect: true);
|
|
|
|
if ($user->locale) {
|
|
$request->session()->put(config('locales.session_key'), $user->locale);
|
|
}
|
|
|
|
return redirect()->intended(filament()->getUrl());
|
|
}
|
|
|
|
public function logout(Request $request): RedirectResponse
|
|
{
|
|
$this->tokens->forget();
|
|
|
|
Auth::logout();
|
|
$request->session()->invalidate();
|
|
$request->session()->regenerateToken();
|
|
|
|
return redirect()->route('login');
|
|
}
|
|
|
|
protected function fail(PireonException $e, string $to): RedirectResponse
|
|
{
|
|
Log::warning('Pireon login failed', [
|
|
'message' => $e->getMessage(),
|
|
'status' => $e->status,
|
|
]);
|
|
|
|
return redirect()->to($to)->with('error', $e->getMessage());
|
|
}
|
|
}
|