Issue machine tokens with limited permissions and an expiry
A token acts as the person who made it, within the permissions it was given and those they still hold, checked on every request. Permission checks now go through one gate rule, since spatie's answered first and never asked the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
29
README.md
29
README.md
@@ -1348,6 +1348,31 @@ cliente en vez de comprobar nombres de rol.
|
||||
|
||||
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
|
||||
|
||||
### Tokens para máquinas
|
||||
|
||||
```
|
||||
GET /api/v1/tokens mis tokens (all=1 para un super_admin)
|
||||
POST /api/v1/tokens {name, abilities, expires_at?} → el token, una vez
|
||||
DELETE /api/v1/tokens/{id} revocar
|
||||
```
|
||||
|
||||
Para scripts, CI o integraciones que no deberían usar la contraseña de nadie.
|
||||
Una persona lo crea con los permisos que le da (`pages.update`, o `pages.*`
|
||||
para todo lo de páginas) y nunca más de los que ella tiene; caduca a los 90
|
||||
días si no se dice otra cosa, y como mucho al año. Se guarda solo su hash, y el
|
||||
token en claro (`pmt_…`) sale una única vez, en la respuesta que lo crea.
|
||||
|
||||
Actúa como esa persona **dentro** de sus permisos, y se comprueba en cada
|
||||
petición, así que quitarle un rol recorta sus tokens al momento; desactivarla
|
||||
los apaga. El guard lo reconoce por el prefijo y **no lo manda nunca al
|
||||
servidor de identidad**. Un token no puede crear ni revocar tokens: si no, uno
|
||||
filtrado sería una forma de no caducar nunca.
|
||||
|
||||
Para que eso funcione, la comprobación de permisos es ahora una sola regla en
|
||||
`AuthServiceProvider` y la de spatie está apagada
|
||||
(`register_permission_check_method => false`): la de spatie respondía antes y
|
||||
concedía el permiso sin preguntar al token.
|
||||
|
||||
### Lotes e importación
|
||||
|
||||
```
|
||||
@@ -1555,7 +1580,9 @@ variables, porque quien lo lanza es quien inicia sesión:
|
||||
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
|
||||
LANDING_USERNAME credenciales de Pireon para la API de administración;
|
||||
LANDING_PASSWORD el token se pide y se refresca solo
|
||||
LANDING_TOKEN o un bearer token fijo en vez de credenciales
|
||||
LANDING_TOKEN o un bearer token fijo en vez de credenciales —
|
||||
lo natural es un token de máquina (pmt_…) con
|
||||
solo los permisos que el MCP necesita
|
||||
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
|
||||
LANDING_OPENAPI opcional: ruta o URL de la especificación
|
||||
```
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Actions\Auth\SyncPireonUser;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\User;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\PireonException;
|
||||
@@ -35,7 +36,16 @@ class PireonTokenGuard
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if (blank($token) || ! $this->client->isConfigured()) {
|
||||
if (blank($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ours, not the identity server's: checked here and never sent there.
|
||||
if (str_starts_with($token, ApiToken::PREFIX)) {
|
||||
return $this->machine($token);
|
||||
}
|
||||
|
||||
if (! $this->client->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -52,6 +62,21 @@ class PireonTokenGuard
|
||||
return $user->is_active ? $user : null;
|
||||
}
|
||||
|
||||
protected function machine(string $plain): ?User
|
||||
{
|
||||
$token = ApiToken::findUsable($plain);
|
||||
$user = $token?->user;
|
||||
|
||||
// A person switched off here takes their tokens with them.
|
||||
if ($user === null || ! $user->is_active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token->touchLastUsed();
|
||||
|
||||
return $user->withAccessToken($token);
|
||||
}
|
||||
|
||||
protected function resolveIdentity(string $token): ?PireonIdentity
|
||||
{
|
||||
$key = 'pireon:introspect:'.hash('sha256', $token);
|
||||
|
||||
104
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
104
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ApiTokenResource;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\User;
|
||||
use App\Support\Permissions;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Machine tokens: made by a person, for something that is not one.
|
||||
*
|
||||
* Only a person signed in through the identity server manages them — a
|
||||
* machine token cannot make another, or a leaked one would be a way of never
|
||||
* expiring.
|
||||
*/
|
||||
class ApiTokenController extends Controller
|
||||
{
|
||||
/**
|
||||
* The caller's own tokens, newest first. A super admin may ask for
|
||||
* everyone's with `all`.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$user = $this->person($request);
|
||||
|
||||
$tokens = ApiToken::query()
|
||||
->with('user')
|
||||
->unless($request->boolean('all') && $user->hasRole('super_admin'), fn ($q) => $q->whereBelongsTo($user))
|
||||
->latest('id')
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString();
|
||||
|
||||
return ApiTokenResource::collection($tokens);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->person($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'abilities' => ['required', 'array', 'min:1'],
|
||||
'abilities.*' => [
|
||||
'string',
|
||||
'distinct',
|
||||
// Only what the person could do themselves: a token narrows,
|
||||
// it never widens.
|
||||
function (string $attribute, mixed $value, Closure $fail) use ($user): void {
|
||||
$matches = array_filter(Permissions::all(), fn (string $permission) => $permission === $value || Str::is((string) $value, $permission));
|
||||
|
||||
if ($matches === [] || ! collect($matches)->contains(fn (string $permission) => $user->can($permission))) {
|
||||
$fail(__('rules.tokens.ability', ['ability' => $value]));
|
||||
}
|
||||
},
|
||||
],
|
||||
'expires_at' => ['sometimes', 'date', 'after:now', 'before_or_equal:'.now()->addDays(ApiToken::MAX_DAYS)->toIso8601String()],
|
||||
]);
|
||||
|
||||
[$token, $plain] = ApiToken::issue(
|
||||
$user,
|
||||
$data['name'],
|
||||
$data['abilities'],
|
||||
isset($data['expires_at']) ? CarbonImmutable::parse($data['expires_at']) : now()->addDays(90),
|
||||
);
|
||||
|
||||
return ApiTokenResource::make($token->load('user'))
|
||||
->additional(['token' => $plain])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke. Kept in the list afterwards, so it is clear what stopped working.
|
||||
*/
|
||||
public function destroy(Request $request, ApiToken $token): JsonResponse
|
||||
{
|
||||
$user = $this->person($request);
|
||||
|
||||
abort_unless($token->user_id === $user->id || $user->hasRole('super_admin'), 403);
|
||||
|
||||
$token->revoke();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
protected function person(Request $request): User
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_if($user->currentAccessToken() !== null, 403, __('rules.tokens.machine'));
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
40
app/Http/Resources/ApiTokenResource.php
Normal file
40
app/Http/Resources/ApiTokenResource.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Never the token itself, which only exists in the answer that made it.
|
||||
*
|
||||
* @mixin ApiToken
|
||||
*/
|
||||
class ApiTokenResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'prefix' => $this->prefix,
|
||||
'abilities' => $this->abilities ?? [],
|
||||
'owner' => ['id' => $this->user_id, 'name' => $this->user?->name],
|
||||
'status' => match (true) {
|
||||
$this->revoked_at !== null => 'revoked',
|
||||
$this->expires_at->isPast() => 'expired',
|
||||
default => 'active',
|
||||
},
|
||||
'expires_at' => $this->expires_at->toIso8601String(),
|
||||
'last_used_at' => $this->last_used_at?->toIso8601String(),
|
||||
'revoked_at' => $this->revoked_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Permissions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -35,7 +36,10 @@ class UserResource extends JsonResource
|
||||
'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()),
|
||||
'permissions' => $this->when(
|
||||
$request->routeIs('api.v1.me'),
|
||||
fn () => $this->getAllPermissions()->pluck('name')->values(),
|
||||
// Through a machine token, only what the token may do.
|
||||
fn () => $this->resource->currentAccessToken()
|
||||
? collect(Permissions::all())->filter(fn (string $permission) => $this->resource->can($permission))->values()
|
||||
: $this->getAllPermissions()->pluck('name')->values(),
|
||||
),
|
||||
|
||||
// Roles reported by the identity server. Informational only: they
|
||||
|
||||
128
app/Models/ApiToken.php
Normal file
128
app/Models/ApiToken.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* An access token for a machine.
|
||||
*
|
||||
* It acts as the person who made it, and never as more: what it may do is the
|
||||
* intersection of its `abilities` and that person's own permissions, checked
|
||||
* on every request — so taking a role away from them narrows their tokens at
|
||||
* once. It always expires, and only its hash is stored.
|
||||
*/
|
||||
#[Fillable(['name', 'abilities', 'expires_at'])]
|
||||
class ApiToken extends Model
|
||||
{
|
||||
/**
|
||||
* What tells this application's tokens from the identity server's, so a
|
||||
* machine token is never sent upstream to be checked.
|
||||
*/
|
||||
public const PREFIX = 'pmt_';
|
||||
|
||||
public const MAX_DAYS = 365;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'abilities' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one, and return the plain token alongside: the only time it exists
|
||||
* anywhere but in the caller's hands.
|
||||
*
|
||||
* @param array<int, string> $abilities
|
||||
* @return array{0: self, 1: string}
|
||||
*/
|
||||
public static function issue(User $owner, string $name, array $abilities, CarbonInterface $expiresAt): array
|
||||
{
|
||||
$plain = self::PREFIX.Str::random(40);
|
||||
|
||||
$token = new self(['name' => $name, 'abilities' => array_values($abilities), 'expires_at' => $expiresAt]);
|
||||
$token->user()->associate($owner);
|
||||
$token->forceFill([
|
||||
'token_hash' => self::hash($plain),
|
||||
'prefix' => substr($plain, 0, 12),
|
||||
])->save();
|
||||
|
||||
return [$token, $plain];
|
||||
}
|
||||
|
||||
public static function hash(string $plain): string
|
||||
{
|
||||
return hash('sha256', $plain);
|
||||
}
|
||||
|
||||
public static function findUsable(string $plain): ?self
|
||||
{
|
||||
$token = self::query()->with('user')->where('token_hash', self::hash($plain))->first();
|
||||
|
||||
return $token?->isUsable() ? $token : null;
|
||||
}
|
||||
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return $this->revoked_at === null && $this->expires_at->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the token was given this permission. `pages.*` covers every
|
||||
* permission on pages.
|
||||
*/
|
||||
public function allows(string $permission): bool
|
||||
{
|
||||
foreach ($this->abilities ?? [] as $ability) {
|
||||
if ($ability === $permission || Str::is($ability, $permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded at most once a minute: a busy script would otherwise turn
|
||||
* every request into a write.
|
||||
*/
|
||||
public function touchLastUsed(): void
|
||||
{
|
||||
if ($this->last_used_at === null || $this->last_used_at->lt(now()->subMinute())) {
|
||||
$this->forceFill(['last_used_at' => now()])->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
public function revoke(): void
|
||||
{
|
||||
$this->forceFill(['revoked_at' => now()])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeUsable(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('revoked_at')->where('expires_at', '>', now());
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,32 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName
|
||||
*/
|
||||
protected string $guard_name = 'web';
|
||||
|
||||
/**
|
||||
* The machine token this request came in with, when it did. Not stored:
|
||||
* it describes the request, not the person.
|
||||
*/
|
||||
protected ?ApiToken $accessToken = null;
|
||||
|
||||
public function withAccessToken(?ApiToken $token): static
|
||||
{
|
||||
$this->accessToken = $token;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function currentAccessToken(): ?ApiToken
|
||||
{
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ApiToken, $this>
|
||||
*/
|
||||
public function apiTokens(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApiToken::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@ use App\Policies\TalkPolicy;
|
||||
use App\Policies\TechnologyPolicy;
|
||||
use App\Policies\TestimonialPolicy;
|
||||
use App\Policies\UserPolicy;
|
||||
use App\Support\Permissions;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Spatie\Permission\Models\Role;
|
||||
@@ -63,12 +64,35 @@ class AuthServiceProvider extends ServiceProvider
|
||||
Gate::policy(User::class, UserPolicy::class);
|
||||
Gate::policy(Role::class, RolePolicy::class);
|
||||
|
||||
// Super admins bypass every permission check. Granting the role this
|
||||
// way rather than by attaching every permission means the role can
|
||||
// never fall behind as the catalogue grows.
|
||||
// The one place a permission is granted — spatie's own gate check is
|
||||
// switched off (config/permission.php) so nothing answers before this.
|
||||
//
|
||||
// Returning null (not false) when the role is absent is important: it
|
||||
// lets the normal policy chain run instead of short-circuiting it.
|
||||
Gate::before(fn (User $user, string $ability) => $user->hasRole('super_admin') ? true : null);
|
||||
// Super admins bypass every check. Granting the role this way rather
|
||||
// than by attaching every permission means the role can never fall
|
||||
// behind as the catalogue grows. Returning null (not false) when
|
||||
// nothing applies lets the normal policy chain run.
|
||||
//
|
||||
// A machine token narrows all of that: a permission must be one the
|
||||
// token was given *and* one its owner holds. A policy is not a
|
||||
// permission, so it runs as usual and asks for permissions, which come
|
||||
// back here.
|
||||
Gate::before(function (User $user, string $ability): ?bool {
|
||||
$token = $user->currentAccessToken();
|
||||
|
||||
if ($token !== null) {
|
||||
if (! in_array($ability, Permissions::all(), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $token->allows($ability)
|
||||
&& ($user->hasRole('super_admin') || $user->checkPermissionTo($ability));
|
||||
}
|
||||
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->checkPermissionTo($ability) ?: null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,10 @@ return [
|
||||
* Set this to false if you want to implement custom logic for checking permissions.
|
||||
*/
|
||||
|
||||
'register_permission_check_method' => true,
|
||||
// Off: App\Providers\AuthServiceProvider registers the one check, so a
|
||||
// machine token's narrower abilities are asked before any permission is
|
||||
// granted rather than after.
|
||||
'register_permission_check_method' => false,
|
||||
|
||||
/*
|
||||
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Access tokens for machines: scripts, CI, an integration — anything that
|
||||
* should reach the admin API without a person's password and with less than
|
||||
* that person may do.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_tokens', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
|
||||
// Whose permissions it borrows from. Deleting the person takes
|
||||
// their tokens with them.
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
|
||||
// Only the hash is kept: the token is shown once, when it is made.
|
||||
$table->string('token_hash', 64)->unique();
|
||||
|
||||
// The first characters, to tell tokens apart in a list.
|
||||
$table->string('prefix', 12);
|
||||
|
||||
$table->jsonb('abilities');
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_tokens');
|
||||
}
|
||||
};
|
||||
@@ -44,6 +44,11 @@ return [
|
||||
'too_many' => 'Com a molt :max files per importació.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.',
|
||||
'machine' => 'Els tokens els gestiona una persona, no un altre token.',
|
||||
],
|
||||
|
||||
'settings' => [
|
||||
'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.',
|
||||
],
|
||||
|
||||
@@ -44,6 +44,11 @@ return [
|
||||
'too_many' => 'At most :max rows per import.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.',
|
||||
'machine' => 'Tokens are managed by a person, not by another token.',
|
||||
],
|
||||
|
||||
'settings' => [
|
||||
'read_only' => 'This setting is written by the application and cannot be edited by hand.',
|
||||
],
|
||||
|
||||
@@ -44,6 +44,11 @@ return [
|
||||
'too_many' => 'Como mucho :max filas por importación.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.',
|
||||
'machine' => 'Los tokens los gestiona una persona, no otro token.',
|
||||
],
|
||||
|
||||
'settings' => [
|
||||
'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.',
|
||||
],
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
*
|
||||
* LANDING_USERNAME Pireon credentials for the admin API (/v1). The
|
||||
* LANDING_PASSWORD token is obtained and refreshed automatically.
|
||||
* LANDING_TOKEN Or a fixed bearer token instead.
|
||||
* LANDING_TOKEN Or a fixed bearer token instead — a machine token
|
||||
* (pmt_…) with only the permissions needed.
|
||||
*
|
||||
* HTTP only — every request brings its own credentials (see src/http.mjs):
|
||||
*
|
||||
|
||||
@@ -16,9 +16,8 @@ info:
|
||||
## Authentication
|
||||
|
||||
Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued
|
||||
by the **Pireon identity server** (`https://oauth.pireon.dev`). This
|
||||
application does not issue tokens of its own; it validates the ones the
|
||||
identity server issued.
|
||||
by the **Pireon identity server** (`https://oauth.pireon.dev`), which this
|
||||
application validates — or a **machine token** of its own.
|
||||
|
||||
Obtain a token through `POST /v1/auth/token`, which proxies the identity
|
||||
server's password grant. The panel's own sign-in form uses the same grant,
|
||||
@@ -26,6 +25,13 @@ info:
|
||||
against `https://oauth.pireon.dev/oauth/authorize` remains available for
|
||||
clients that prefer never to handle a password.
|
||||
|
||||
Machine tokens (`pmt_…`) are for scripts and integrations: a person makes
|
||||
one with `POST /v1/tokens`, naming the permissions it gets — never more
|
||||
than their own — and when it expires. It acts as that person within
|
||||
those permissions, checked on every request, so taking a role away from
|
||||
them narrows their tokens at once. It is checked here and never sent to
|
||||
the identity server.
|
||||
|
||||
## Authorisation
|
||||
|
||||
The identity server establishes *who* the caller is. What they may *do* is
|
||||
@@ -1029,6 +1035,71 @@ paths:
|
||||
data: { $ref: "#/components/schemas/User" }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/v1/tokens:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: List my machine tokens
|
||||
description: Newest first, revoked and expired ones included. A super admin may ask for everyone's with `all`. Not available to a machine token.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: all
|
||||
in: query
|
||||
description: "Everyone's tokens. Super admins only."
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated tokens
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ApiTokenCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Make a machine token
|
||||
description: |
|
||||
`abilities` are permission names — `pages.update` — or a whole
|
||||
resource — `pages.*` — and only ones the caller holds. It expires
|
||||
after 90 days unless `expires_at` says otherwise, a year at most. The
|
||||
token itself is in the answer's `token`, and nowhere else ever again.
|
||||
Not available to a machine token.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, abilities]
|
||||
properties:
|
||||
name: { type: string, maxLength: 120, example: Despliegue continuo }
|
||||
abilities: { type: array, items: { type: string }, example: ["technologies.*", "media.view_any"] }
|
||||
expires_at: { type: string, format: date-time }
|
||||
responses:
|
||||
"201":
|
||||
description: Token made
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/ApiToken" }
|
||||
token: { type: string, description: The token. Shown once. }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/tokens/{token}:
|
||||
delete:
|
||||
tags: [Auth]
|
||||
summary: Revoke a machine token
|
||||
description: It stops working at once and stays listed as revoked. Your own, or anyone's for a super admin.
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"204": { description: Revoked }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/pages:
|
||||
get:
|
||||
tags: [Pages]
|
||||
@@ -4046,9 +4117,9 @@ components:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: |
|
||||
An access token issued by https://oauth.pireon.dev. Validated on each
|
||||
request against the identity server's `GET /api/user`, with the result
|
||||
cached briefly.
|
||||
An access token issued by https://oauth.pireon.dev, validated on each
|
||||
request against the identity server's `GET /api/user` with the result
|
||||
cached briefly — or a machine token (`pmt_…`) from `POST /v1/tokens`.
|
||||
|
||||
ingestToken:
|
||||
type: apiKey
|
||||
@@ -5542,6 +5613,33 @@ components:
|
||||
failed: { type: integer }
|
||||
dry_run: { type: boolean, nullable: true }
|
||||
|
||||
ApiToken:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
prefix: { type: string, description: "The first characters, to tell tokens apart." }
|
||||
abilities: { type: array, items: { type: string } }
|
||||
owner:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string, nullable: true }
|
||||
status: { type: string, enum: [active, expired, revoked] }
|
||||
expires_at: { type: string, format: date-time }
|
||||
last_used_at: { type: string, format: date-time, nullable: true }
|
||||
revoked_at: { type: string, format: date-time, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
ApiTokenCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/ApiToken" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
MediaFor:
|
||||
type: string
|
||||
enum: [pages, technologies, projects, members, clients, testimonials]
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Http\Controllers\Api\PublicApi\V1\SearchController;
|
||||
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
|
||||
use App\Http\Controllers\Api\PublicApi\V1\SiteController;
|
||||
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
|
||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
|
||||
use App\Http\Controllers\Api\V1\BatchController;
|
||||
use App\Http\Controllers\Api\V1\CallController;
|
||||
@@ -181,6 +182,11 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||
Route::get('me', MeController::class)->name('me');
|
||||
|
||||
// Machine tokens, managed by the person they act for.
|
||||
Route::get('tokens', [ApiTokenController::class, 'index'])->name('tokens.index');
|
||||
Route::post('tokens', [ApiTokenController::class, 'store'])->name('tokens.store');
|
||||
Route::delete('tokens/{token}', [ApiTokenController::class, 'destroy'])->name('tokens.destroy');
|
||||
|
||||
Route::apiResource('pages', PageController::class);
|
||||
Route::post('pages/{page}/restore', [PageController::class, 'restore'])
|
||||
->withTrashed()
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use App\Models\Category;
|
||||
@@ -92,6 +93,7 @@ class AdminApiContractTest extends TestCase
|
||||
'missing-urls' => ['path' => '/v1/missing-urls'],
|
||||
'repositories' => ['path' => '/v1/repositories'],
|
||||
'media' => ['path' => '/v1/media'],
|
||||
'tokens' => ['path' => '/v1/tokens'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -115,6 +117,7 @@ class AdminApiContractTest extends TestCase
|
||||
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
||||
$media = Media::factory()->create();
|
||||
$page->comment('Primera nota', $this->caller);
|
||||
ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay());
|
||||
|
||||
// One request, one documented path — the URL a real caller hits and
|
||||
// the path key that describes it, side by side rather than derived
|
||||
@@ -155,6 +158,7 @@ class AdminApiContractTest extends TestCase
|
||||
'media' => ['/v1/media', '/v1/media'],
|
||||
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
|
||||
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
|
||||
'tokens' => ['/v1/tokens', '/v1/tokens'],
|
||||
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
|
||||
'users' => ['/v1/users', '/v1/users'],
|
||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||
|
||||
182
tests/Feature/Api/MachineTokenTest.php
Normal file
182
tests/Feature/Api/MachineTokenTest.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\Page;
|
||||
use App\Models\Technology;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Machine tokens: made by a person, acting as them, never as more.
|
||||
*/
|
||||
class MachineTokenTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
protected function person(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles([$role]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $abilities
|
||||
*/
|
||||
protected function tokenFor(User $user, array $abilities, ?\DateTimeInterface $expires = null): string
|
||||
{
|
||||
[, $plain] = ApiToken::issue($user, 'CI', $abilities, $expires ?? now()->addDay());
|
||||
|
||||
$this->app['auth']->forgetGuards();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_person_makes_a_token_and_sees_it_only_once(): void
|
||||
{
|
||||
$this->withHeaders($this->apiHeadersFor($this->person('editor')));
|
||||
|
||||
$response = $this->postJson('/api/v1/tokens', [
|
||||
'name' => 'Despliegue',
|
||||
'abilities' => ['technologies.*', 'pages.view_any'],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', 'active')
|
||||
->assertJsonPath('data.abilities', ['technologies.*', 'pages.view_any']);
|
||||
|
||||
$plain = $response->json('token');
|
||||
|
||||
$this->assertStringStartsWith(ApiToken::PREFIX, $plain);
|
||||
$this->assertSame(ApiToken::hash($plain), ApiToken::query()->sole()->token_hash);
|
||||
|
||||
$this->getJson('/api/v1/tokens')
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonMissingPath('data.0.token');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_token_cannot_be_given_what_its_maker_lacks(): void
|
||||
{
|
||||
$this->withHeaders($this->apiHeadersFor($this->person('writer')));
|
||||
|
||||
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['users.update']])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('abilities.0');
|
||||
|
||||
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['nothing.*']])
|
||||
->assertUnprocessable();
|
||||
|
||||
$this->postJson('/api/v1/tokens', [
|
||||
'name' => 'X',
|
||||
'abilities' => ['pages.view_any'],
|
||||
'expires_at' => now()->addYears(2)->toIso8601String(),
|
||||
])->assertUnprocessable()->assertJsonValidationErrors('expires_at');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_token_acts_as_its_maker_within_its_abilities(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$editor = $this->person('editor');
|
||||
$this->tokenFor($editor, ['technologies.*']);
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertCreated();
|
||||
$this->getJson('/api/v1/pages')->assertForbidden();
|
||||
|
||||
$permissions = $this->getJson('/api/v1/me')->assertOk()->json('data.permissions');
|
||||
|
||||
$this->assertContains('technologies.create', $permissions);
|
||||
$this->assertNotContains('pages.view_any', $permissions);
|
||||
|
||||
// Never sent to the identity server to be checked.
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_super_admin_token_is_still_limited(): void
|
||||
{
|
||||
$this->tokenFor($this->person('super_admin'), ['pages.view_any', 'pages.view']);
|
||||
|
||||
$page = Page::factory()->create();
|
||||
|
||||
$this->getJson('/api/v1/pages')->assertOk();
|
||||
$this->getJson("/api/v1/pages/{$page->id}")->assertOk();
|
||||
$this->deleteJson("/api/v1/pages/{$page->id}")->assertForbidden();
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function losing_a_role_narrows_the_token_at_once(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
$this->tokenFor($editor, ['technologies.*']);
|
||||
|
||||
$editor->syncRoles(['viewer']);
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
|
||||
$this->assertSame(0, Technology::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function expired_revoked_or_deactivated_tokens_do_not_work(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
|
||||
$this->tokenFor($editor, ['pages.*'], now()->subMinute());
|
||||
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
||||
|
||||
$plain = $this->tokenFor($editor, ['pages.*']);
|
||||
ApiToken::query()->where('token_hash', ApiToken::hash($plain))->sole()->revoke();
|
||||
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
||||
|
||||
$this->tokenFor($editor, ['pages.*']);
|
||||
$editor->update(['is_active' => false]);
|
||||
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_token_cannot_make_or_revoke_tokens(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
$this->tokenFor($editor, ['pages.*']);
|
||||
|
||||
$this->postJson('/api/v1/tokens', ['name' => 'Forever', 'abilities' => ['pages.*']])->assertForbidden();
|
||||
$this->getJson('/api/v1/tokens')->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_revoked_token_stays_listed(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
[$token] = ApiToken::issue($editor, 'Old', ['pages.*'], now()->addDay());
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($editor));
|
||||
|
||||
$this->deleteJson("/api/v1/tokens/{$token->id}")->assertNoContent();
|
||||
$this->getJson('/api/v1/tokens')->assertJsonPath('data.0.status', 'revoked');
|
||||
|
||||
// Someone else's is not theirs to revoke.
|
||||
[$other] = ApiToken::issue($this->person('editor'), 'Theirs', ['pages.*'], now()->addDay());
|
||||
$this->deleteJson("/api/v1/tokens/{$other->id}")->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user