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>
129 lines
3.5 KiB
PHP
129 lines
3.5 KiB
PHP
<?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());
|
|
}
|
|
}
|