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:
2026-10-01 09:21:37 +02:00
parent 0119004f2c
commit f93775e549
18 changed files with 748 additions and 17 deletions

View File

@@ -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);

View 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;
}
}

View 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(),
];
}
}

View File

@@ -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
View 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());
}
}

View File

@@ -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.
*

View File

@@ -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;
});
}
}