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>
105 lines
3.4 KiB
PHP
105 lines
3.4 KiB
PHP
<?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;
|
|
}
|
|
}
|