diff --git a/.env.example b/.env.example index e156b22..854e003 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d1a6cee..e54b691 100644 --- a/README.md +++ b/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). diff --git a/app/Actions/Auth/HandlePireonWebhook.php b/app/Actions/Auth/HandlePireonWebhook.php new file mode 100644 index 0000000..b4c7914 --- /dev/null +++ b/app/Actions/Auth/HandlePireonWebhook.php @@ -0,0 +1,79 @@ + $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(); + } +} diff --git a/app/Auth/PireonTokenGuard.php b/app/Auth/PireonTokenGuard.php index 7945025..a2c72f4 100644 --- a/app/Auth/PireonTokenGuard.php +++ b/app/Auth/PireonTokenGuard.php @@ -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. diff --git a/app/Http/Controllers/Api/V1/PireonWebhookController.php b/app/Http/Controllers/Api/V1/PireonWebhookController.php new file mode 100644 index 0000000..c980c1d --- /dev/null +++ b/app/Http/Controllers/Api/V1/PireonWebhookController.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/config/pireon.php b/config/pireon.php index 9190b1c..58ab6dd 100644 --- a/config/pireon.php +++ b/config/pireon.php @@ -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), /* diff --git a/public/openapi.yaml b/public/openapi.yaml index 2605010..4d9d076 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -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] diff --git a/routes/api.php b/routes/api.php index d8306ea..2d95aca 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Auth/PireonWebhookTest.php b/tests/Feature/Auth/PireonWebhookTest.php new file mode 100644 index 0000000..2bf77cc --- /dev/null +++ b/tests/Feature/Auth/PireonWebhookTest.php @@ -0,0 +1,110 @@ +seed(RolePermissionSeeder::class); + config(['pireon.webhook_secrets' => ['password-secret', 'redirect-secret']]); + } + + /** + * @param array $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); + } +}