Take Pireon's webhooks: a deactivation or a revocation counts at once
Until now, an account switched off or a token revoked at the identity server kept working here: bearer tokens until the introspection cache ran out, panel sessions until they ended, and machine tokens for good, as they only look at the local account. POST /api/v1/webhooks/pireon takes user.deactivated (the account goes inactive, which stops its machine tokens and the panel; its sessions end and its cached tokens are forgotten) and token.revoked (sessions and cached tokens go). Pireon signs each delivery with the secret of the client that registered the webhook, so either PIREON_WEBHOOK_SECRET (password client) or PIREON_REDIRECT_WEBHOOK_SECRET («Entrar con Pireon») verifies it; with neither, the endpoint refuses everything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,12 @@ PIREON_REDIRECT_CLIENT_ID=
|
||||
PIREON_REDIRECT_CLIENT_SECRET=
|
||||
# PIREON_REDIRECT_LOGIN=
|
||||
|
||||
# Webhooks at ${APP_URL}/api/v1/webhooks/pireon (user.deactivated, token.revoked):
|
||||
# register it on each client and paste the secret each one returns. Empty: the
|
||||
# endpoint refuses everything and a revocation waits for the cache below.
|
||||
PIREON_WEBHOOK_SECRET=
|
||||
PIREON_REDIRECT_WEBHOOK_SECRET=
|
||||
|
||||
# Grants. The panel's sign-in form uses the password grant, so turning this off
|
||||
# locks everyone out of the panel as well as out of POST /api/v1/auth/token.
|
||||
PIREON_PASSWORD_GRANT=true
|
||||
|
||||
21
README.md
21
README.md
@@ -198,6 +198,27 @@ curl -X POST http://localhost:8000/api/v1/auth/token \
|
||||
Es el mismo grant que usa el formulario del panel, así que
|
||||
`PIREON_PASSWORD_GRANT=false` desactiva los dos a la vez.
|
||||
|
||||
### Webhooks de Pireon
|
||||
|
||||
Para que desactivar una cuenta o revocar un token en Pireon surta efecto al
|
||||
instante, sin esperar al TTL de la caché:
|
||||
|
||||
1. Registra `https://tu-dominio/api/v1/webhooks/pireon` con los eventos
|
||||
`user.deactivated` y `token.revoked` en el cliente de contraseña
|
||||
(`POST /api/clients/{client}/webhooks` del servidor de identidad) y pon el
|
||||
secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
||||
2. Si usas el cliente de «Entrar con Pireon», registra el mismo webhook en él y
|
||||
pon su secret en `PIREON_REDIRECT_WEBHOOK_SECRET`. Pireon firma cada aviso
|
||||
con el secret del cliente que lo registró, y una revocación solo la avisa el
|
||||
cliente que emitió el token.
|
||||
|
||||
`user.deactivated` desactiva la cuenta aquí (lo que corta también sus tokens de
|
||||
máquina y el panel), cierra sus sesiones del panel (con `SESSION_DRIVER=database`)
|
||||
y olvida sus tokens cacheados; vuelve a estar activa al entrar de nuevo cuando
|
||||
Pireon la reactive. `token.revoked` cierra sus sesiones y olvida sus tokens
|
||||
cacheados. La firma (`X-Webhook-Signature`, HMAC-SHA256 del cuerpo) vale con
|
||||
cualquiera de los dos secrets; sin ninguno, el endpoint lo rechaza todo.
|
||||
|
||||
## Permisos
|
||||
|
||||
El catálogo vive en un único sitio: [`app/Support/Permissions.php`](app/Support/Permissions.php).
|
||||
|
||||
79
app/Actions/Auth/HandlePireonWebhook.php
Normal file
79
app/Actions/Auth/HandlePireonWebhook.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Auth;
|
||||
|
||||
use App\Auth\PireonTokenGuard;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* What a Pireon webhook changes here.
|
||||
*
|
||||
* - `user.deactivated`: the account goes inactive (which also stops its
|
||||
* machine tokens and the panel), its panel sessions end and its cached
|
||||
* bearer tokens are forgotten. Signing in again once Pireon reactivates it
|
||||
* turns it back on, as every sign-in copies the upstream state.
|
||||
* - `token.revoked`: only token hashes are cached, so every cached token and
|
||||
* panel session of that person goes; they sign in again.
|
||||
*
|
||||
* Anything else, or a person who never signed in here, is ignored. Repeating
|
||||
* a delivery (one arrives per registered client) changes nothing.
|
||||
*/
|
||||
class HandlePireonWebhook
|
||||
{
|
||||
public const EVENTS = ['user.deactivated', 'token.revoked'];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function handle(string $event, array $data): void
|
||||
{
|
||||
if (! in_array($event, self::EVENTS, true)) {
|
||||
Log::debug('Unhandled Pireon webhook', ['event' => $event]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$oauthId = $data['user_id'] ?? $data['userId'] ?? ($data['user']['id'] ?? null);
|
||||
|
||||
if ($oauthId === null || $oauthId === '') {
|
||||
Log::warning('Pireon webhook without a user id, ignored', ['event' => $event]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PireonTokenGuard::forgetUser((string) $oauthId);
|
||||
|
||||
$user = User::query()->where('oauth_id', $oauthId)->first();
|
||||
|
||||
if ($user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event === 'user.deactivated' && $user->is_active) {
|
||||
$user->forceFill(['is_active' => false])->save();
|
||||
}
|
||||
|
||||
$this->endSessions($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions kept in the database can be ended one person at a time. With
|
||||
* any other driver they cannot, but an inactive account is still turned
|
||||
* away on its next request (canAccessPanel).
|
||||
*/
|
||||
protected function endSessions(User $user): void
|
||||
{
|
||||
if (config('session.driver') !== 'database') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::connection(config('session.connection'))
|
||||
->table((string) config('session.table', 'sessions'))
|
||||
->where('user_id', $user->getKey())
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,33 @@ class PireonTokenGuard
|
||||
|
||||
Cache::put($key, $identity->toArray(), $ttl);
|
||||
|
||||
// Which cached tokens are whose, so a webhook can drop a person's.
|
||||
$index = self::indexKey($identity->id);
|
||||
Cache::put($index, array_values(array_unique([...(array) Cache::get($index, []), $key])), $ttl);
|
||||
|
||||
return $identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every token of that person checked lately: the next request with
|
||||
* any of them asks the identity server again.
|
||||
*/
|
||||
public static function forgetUser(int|string $oauthId): void
|
||||
{
|
||||
$index = self::indexKey($oauthId);
|
||||
|
||||
foreach ((array) Cache::get($index, []) as $key) {
|
||||
Cache::forget((string) $key);
|
||||
}
|
||||
|
||||
Cache::forget($index);
|
||||
}
|
||||
|
||||
protected static function indexKey(int|string $oauthId): string
|
||||
{
|
||||
return 'pireon:introspect:user:'.$oauthId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the identity locally so policies, ownership checks and foreign
|
||||
* keys have a real row to work with. Local roles are left untouched.
|
||||
|
||||
61
app/Http/Controllers/Api/V1/PireonWebhookController.php
Normal file
61
app/Http/Controllers/Api/V1/PireonWebhookController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Actions\Auth\HandlePireonWebhook;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* Receives `user.deactivated` and `token.revoked` from the Pireon identity
|
||||
* server, called by it and not by apps.
|
||||
*
|
||||
* `X-Webhook-Signature` is the HMAC-SHA256 (hex) of the raw body with the
|
||||
* secret of the Pireon client that registered the webhook. This application
|
||||
* has two clients (password and redirect), so a delivery is good if it
|
||||
* verifies with either secret. With none configured the endpoint is off: an
|
||||
* open one would let anyone switch accounts off.
|
||||
*/
|
||||
class PireonWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, HandlePireonWebhook $handle): Response|JsonResponse
|
||||
{
|
||||
$secrets = (array) config('pireon.webhook_secrets', []);
|
||||
|
||||
if ($secrets === []) {
|
||||
return $this->refuse('Pireon webhooks are disabled (PIREON_WEBHOOK_SECRET is not set).');
|
||||
}
|
||||
|
||||
$signature = preg_replace('/^sha256=/', '', (string) $request->header('X-Webhook-Signature'));
|
||||
$body = $request->getContent();
|
||||
|
||||
$valid = $signature !== '' && collect($secrets)->contains(
|
||||
fn (string $secret): bool => hash_equals(hash_hmac('sha256', $body, $secret), $signature),
|
||||
);
|
||||
|
||||
if (! $valid) {
|
||||
return $this->refuse('Missing or invalid signature.');
|
||||
}
|
||||
|
||||
$event = (string) ($request->input('event') ?? $request->input('type') ?? '');
|
||||
|
||||
if ($event === '') {
|
||||
return response()->json(['message' => 'Missing event name.'], 400);
|
||||
}
|
||||
|
||||
$data = (array) ($request->input('data') ?? $request->input('payload') ?? $request->all());
|
||||
|
||||
$handle->handle($event, $data);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function refuse(string $message): JsonResponse
|
||||
{
|
||||
return response()->json(['message' => $message], 401);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,25 @@ return [
|
||||
|
||||
'introspection_ttl' => (int) env('PIREON_INTROSPECTION_TTL', 60),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Webhooks (POST /api/v1/webhooks/pireon)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `user.deactivated` and `token.revoked` take effect at once instead of
|
||||
| when the cache above runs out. Pireon signs each delivery with the secret
|
||||
| of the client that registered the webhook, and only the client that
|
||||
| issued a token tells when it is revoked: register the webhook on both
|
||||
| clients (password and redirect) and put each secret here. With none, the
|
||||
| endpoint refuses everything.
|
||||
|
|
||||
*/
|
||||
|
||||
'webhook_secrets' => array_values(array_filter([
|
||||
env('PIREON_WEBHOOK_SECRET'),
|
||||
env('PIREON_REDIRECT_WEBHOOK_SECRET'),
|
||||
])),
|
||||
|
||||
'http_timeout' => (int) env('PIREON_HTTP_TIMEOUT', 10),
|
||||
|
||||
/*
|
||||
|
||||
@@ -1054,6 +1054,41 @@ paths:
|
||||
"403": { description: Account inactive or awaiting approval }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/webhooks/pireon:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Pireon events (called by the identity server, not by apps)
|
||||
description: |
|
||||
`user.deactivated` turns the account off here, ends its panel sessions
|
||||
and forgets its cached bearer tokens; `token.revoked` ends its sessions
|
||||
and forgets its cached tokens. Signed in `X-Webhook-Signature` with the
|
||||
HMAC-SHA256 (hex) of the raw body and the webhook secret of the Pireon
|
||||
client that registered it (`PIREON_WEBHOOK_SECRET` or
|
||||
`PIREON_REDIRECT_WEBHOOK_SECRET`). Disabled while neither is set.
|
||||
security: []
|
||||
parameters:
|
||||
- name: X-Webhook-Signature
|
||||
in: header
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [event]
|
||||
properties:
|
||||
event: { type: string, example: user.deactivated }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
user_id: { type: integer, example: 4242 }
|
||||
responses:
|
||||
"204": { description: "Processed (or nothing to do)" }
|
||||
"400": { description: No event name }
|
||||
"401": { description: "Missing or invalid signature, or webhooks disabled" }
|
||||
|
||||
/v1/me:
|
||||
get:
|
||||
tags: [Auth]
|
||||
|
||||
@@ -27,6 +27,7 @@ use App\Http\Controllers\Api\V1\MediaController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\MissingUrlController;
|
||||
use App\Http\Controllers\Api\V1\PageController;
|
||||
use App\Http\Controllers\Api\V1\PireonWebhookController;
|
||||
use App\Http\Controllers\Api\V1\PreviewController;
|
||||
use App\Http\Controllers\Api\V1\ProjectController;
|
||||
use App\Http\Controllers\Api\V1\RedirectController;
|
||||
@@ -185,6 +186,11 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->middleware('throttle:auth-token')
|
||||
->name('auth.token');
|
||||
|
||||
// Called by the identity server, signed with a client's webhook secret.
|
||||
Route::post('webhooks/pireon', PireonWebhookController::class)
|
||||
->middleware('throttle:60,1')
|
||||
->name('webhooks.pireon');
|
||||
|
||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||
Route::get('me', MeController::class)->name('me');
|
||||
|
||||
|
||||
110
tests/Feature/Auth/PireonWebhookTest.php
Normal file
110
tests/Feature/Auth/PireonWebhookTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PireonWebhookTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
config(['pireon.webhook_secrets' => ['password-secret', 'redirect-secret']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function deliver(array $payload, ?string $secret = 'password-secret'): TestResponse
|
||||
{
|
||||
$body = (string) json_encode($payload);
|
||||
$headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'];
|
||||
|
||||
if ($secret !== null) {
|
||||
$headers['HTTP_X_WEBHOOK_SIGNATURE'] = hash_hmac('sha256', $body, $secret);
|
||||
}
|
||||
|
||||
return $this->call('POST', route('api.v1.webhooks.pireon'), [], [], [], $headers, $body);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_everything_without_a_secret(): void
|
||||
{
|
||||
config(['pireon.webhook_secrets' => []]);
|
||||
|
||||
$this->deliver(['event' => 'token.revoked', 'data' => ['user_id' => 1]])->assertUnauthorized();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_a_delivery_that_is_not_signed_with_either_secret(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], 'someone-else')->assertUnauthorized();
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], null)->assertUnauthorized();
|
||||
|
||||
$this->assertTrue($user->fresh()->is_active);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_deactivated_account_goes_inactive_and_loses_its_sessions(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
config(['session.driver' => 'database']);
|
||||
DB::table('sessions')->insert([
|
||||
'id' => 'a-session', 'user_id' => $user->id, 'payload' => '', 'last_activity' => time(),
|
||||
]);
|
||||
|
||||
// Signed by the redirect client: either secret is good.
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], 'redirect-secret')
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertFalse($user->fresh()->is_active);
|
||||
$this->assertDatabaseMissing('sessions', ['id' => 'a-session']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_revoked_token_stops_working_at_once_instead_of_after_the_cache(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
$headers = $this->apiHeadersFor($user);
|
||||
|
||||
$this->getJson(route('api.v1.me'), $headers)->assertOk();
|
||||
|
||||
// Revoked upstream: the cached answer would still let it through…
|
||||
$this->pireonIdentities = array_fill_keys(['nobody'], []);
|
||||
$this->app['auth']->forgetGuards();
|
||||
$this->getJson(route('api.v1.me'), $headers)->assertOk();
|
||||
|
||||
// …until Pireon says so.
|
||||
$this->deliver(['event' => 'token.revoked', 'data' => ['user_id' => 77]])->assertNoContent();
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
$this->getJson(route('api.v1.me'), $headers)->assertUnauthorized();
|
||||
$this->assertTrue($user->fresh()->is_active);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function other_events_and_unknown_people_change_nothing(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
|
||||
$this->deliver(['event' => 'user.updated', 'data' => ['user_id' => 77]])->assertNoContent();
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 999]])->assertNoContent();
|
||||
$this->deliver(['data' => ['user_id' => 77]])->assertStatus(400);
|
||||
|
||||
$this->assertTrue($user->fresh()->is_active);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user