|
|
@@ -0,0 +1,240 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Application\Admin;
|
|
|
+
|
|
|
+use App\Domain\Auth\Role;
|
|
|
+use App\Domain\Auth\TokenHasher;
|
|
|
+use App\Domain\Auth\TokenIssuer;
|
|
|
+use App\Domain\Auth\TokenKind;
|
|
|
+use App\Domain\Time\Clock;
|
|
|
+use App\Infrastructure\Auth\TokenRecord;
|
|
|
+use App\Infrastructure\Auth\TokenRepository;
|
|
|
+use App\Infrastructure\Consumer\ConsumerRepository;
|
|
|
+use App\Infrastructure\Reporter\ReporterRepository;
|
|
|
+use DateTimeImmutable;
|
|
|
+use DateTimeZone;
|
|
|
+use Psr\Http\Message\ResponseInterface;
|
|
|
+use Psr\Http\Message\ServerRequestInterface;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Admin token CRUD. Three creatable kinds — `reporter`, `consumer`,
|
|
|
+ * `admin` — each with its own constraint:
|
|
|
+ *
|
|
|
+ * reporter → reporter_id required, no role / consumer_id
|
|
|
+ * consumer → consumer_id required, no role / reporter_id
|
|
|
+ * admin → role required, no FKs
|
|
|
+ *
|
|
|
+ * `service` is rejected with 400; service tokens come from the bootstrap
|
|
|
+ * path only. The list endpoint filters service-kind out unconditionally.
|
|
|
+ *
|
|
|
+ * The raw token string appears **only** in the create response — we
|
|
|
+ * persist its SHA-256 hash and forget the rest.
|
|
|
+ */
|
|
|
+final class TokensController
|
|
|
+{
|
|
|
+ use AdminControllerSupport;
|
|
|
+
|
|
|
+ public function __construct(
|
|
|
+ private readonly TokenRepository $tokens,
|
|
|
+ private readonly TokenIssuer $issuer,
|
|
|
+ private readonly TokenHasher $hasher,
|
|
|
+ private readonly ReporterRepository $reporters,
|
|
|
+ private readonly ConsumerRepository $consumers,
|
|
|
+ private readonly Clock $clock,
|
|
|
+ ) {
|
|
|
+ }
|
|
|
+
|
|
|
+ public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
|
|
+ {
|
|
|
+ $page = self::pagination($request);
|
|
|
+ $rows = $this->tokens->listNonService($page['limit'], $page['offset']);
|
|
|
+ $total = $this->tokens->countNonService();
|
|
|
+
|
|
|
+ $data = array_map(static function (TokenRecord $r): array {
|
|
|
+ return [
|
|
|
+ 'id' => $r->id,
|
|
|
+ 'kind' => $r->kind->value,
|
|
|
+ 'prefix' => $r->prefix,
|
|
|
+ 'reporter_id' => $r->reporterId,
|
|
|
+ 'consumer_id' => $r->consumerId,
|
|
|
+ 'role' => $r->role?->value,
|
|
|
+ 'expires_at' => $r->expiresAt?->format('Y-m-d\TH:i:s\Z'),
|
|
|
+ 'revoked_at' => $r->revokedAt?->format('Y-m-d\TH:i:s\Z'),
|
|
|
+ 'last_used_at' => $r->lastUsedAt?->format('Y-m-d\TH:i:s\Z'),
|
|
|
+ ];
|
|
|
+ }, $rows);
|
|
|
+
|
|
|
+ return self::json($response, 200, [
|
|
|
+ 'data' => $data,
|
|
|
+ 'page' => $page['page'],
|
|
|
+ 'limit' => $page['limit'],
|
|
|
+ 'total' => $total,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
|
|
+ {
|
|
|
+ $body = self::jsonBody($request);
|
|
|
+ $errors = [];
|
|
|
+
|
|
|
+ $kindValue = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
|
|
|
+ $kind = TokenKind::tryFrom($kindValue);
|
|
|
+ if ($kind === null) {
|
|
|
+ return self::validationFailed($response, ['kind' => 'must be reporter, consumer, or admin']);
|
|
|
+ }
|
|
|
+ if ($kind === TokenKind::Service) {
|
|
|
+ return self::error($response, 400, 'service tokens cannot be created via API');
|
|
|
+ }
|
|
|
+
|
|
|
+ $reporterId = self::optInt($body['reporter_id'] ?? null);
|
|
|
+ $consumerId = self::optInt($body['consumer_id'] ?? null);
|
|
|
+ $roleValue = isset($body['role']) && is_string($body['role']) ? $body['role'] : null;
|
|
|
+ $role = $roleValue !== null ? Role::tryFrom($roleValue) : null;
|
|
|
+
|
|
|
+ $expiresAt = null;
|
|
|
+ if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
|
|
|
+ if (!is_string($body['expires_at'])) {
|
|
|
+ $errors['expires_at'] = 'must be ISO 8601 string';
|
|
|
+ } else {
|
|
|
+ $parsed = self::parseUtc($body['expires_at']);
|
|
|
+ if ($parsed === null) {
|
|
|
+ $errors['expires_at'] = 'must be ISO 8601 string';
|
|
|
+ } elseif ($parsed <= $this->clock->now()) {
|
|
|
+ $errors['expires_at'] = 'must be in the future';
|
|
|
+ } else {
|
|
|
+ $expiresAt = $parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ switch ($kind) {
|
|
|
+ case TokenKind::Reporter:
|
|
|
+ if ($reporterId === null) {
|
|
|
+ $errors['reporter_id'] = 'required for reporter tokens';
|
|
|
+ } elseif ($this->reporters->findById($reporterId) === null) {
|
|
|
+ $errors['reporter_id'] = 'unknown reporter';
|
|
|
+ }
|
|
|
+ if ($consumerId !== null) {
|
|
|
+ $errors['consumer_id'] = 'must be null for reporter tokens';
|
|
|
+ }
|
|
|
+ if ($roleValue !== null) {
|
|
|
+ $errors['role'] = 'must be null for reporter tokens';
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case TokenKind::Consumer:
|
|
|
+ if ($consumerId === null) {
|
|
|
+ $errors['consumer_id'] = 'required for consumer tokens';
|
|
|
+ } elseif ($this->consumers->findById($consumerId) === null) {
|
|
|
+ $errors['consumer_id'] = 'unknown consumer';
|
|
|
+ }
|
|
|
+ if ($reporterId !== null) {
|
|
|
+ $errors['reporter_id'] = 'must be null for consumer tokens';
|
|
|
+ }
|
|
|
+ if ($roleValue !== null) {
|
|
|
+ $errors['role'] = 'must be null for consumer tokens';
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case TokenKind::Admin:
|
|
|
+ if ($role === null) {
|
|
|
+ $errors['role'] = 'required for admin tokens (viewer|operator|admin)';
|
|
|
+ }
|
|
|
+ if ($reporterId !== null) {
|
|
|
+ $errors['reporter_id'] = 'must be null for admin tokens';
|
|
|
+ }
|
|
|
+ if ($consumerId !== null) {
|
|
|
+ $errors['consumer_id'] = 'must be null for admin tokens';
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ // Unreachable; service was already rejected.
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($errors !== []) {
|
|
|
+ return self::validationFailed($response, $errors);
|
|
|
+ }
|
|
|
+
|
|
|
+ $raw = $this->issuer->issue($kind);
|
|
|
+ $hash = $this->hasher->hash($raw);
|
|
|
+ $prefix = substr($raw, 0, 8);
|
|
|
+
|
|
|
+ $this->tokens->create(new TokenRecord(
|
|
|
+ id: null,
|
|
|
+ kind: $kind,
|
|
|
+ hash: $hash,
|
|
|
+ prefix: $prefix,
|
|
|
+ reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
|
|
|
+ consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
|
|
|
+ role: $kind === TokenKind::Admin ? $role : null,
|
|
|
+ expiresAt: $expiresAt,
|
|
|
+ revokedAt: null,
|
|
|
+ lastUsedAt: null,
|
|
|
+ ));
|
|
|
+
|
|
|
+ $created = $this->tokens->findByHashIncludingInvalid($hash);
|
|
|
+ if ($created === null) {
|
|
|
+ return self::error($response, 500, 'create_failed');
|
|
|
+ }
|
|
|
+
|
|
|
+ return self::json($response, 201, [
|
|
|
+ 'id' => $created->id,
|
|
|
+ 'kind' => $created->kind->value,
|
|
|
+ 'prefix' => $created->prefix,
|
|
|
+ 'reporter_id' => $created->reporterId,
|
|
|
+ 'consumer_id' => $created->consumerId,
|
|
|
+ 'role' => $created->role?->value,
|
|
|
+ 'expires_at' => $created->expiresAt?->format('Y-m-d\TH:i:s\Z'),
|
|
|
+ 'raw_token' => $raw,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param array{id: string} $args
|
|
|
+ */
|
|
|
+ public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
|
|
+ {
|
|
|
+ $id = self::parseId($args['id']);
|
|
|
+ if ($id === null) {
|
|
|
+ return self::error($response, 404, 'not_found');
|
|
|
+ }
|
|
|
+ $token = $this->tokens->findById($id);
|
|
|
+ if ($token === null) {
|
|
|
+ return self::error($response, 404, 'not_found');
|
|
|
+ }
|
|
|
+ if ($token->kind === TokenKind::Service) {
|
|
|
+ return self::error($response, 403, 'cannot revoke service tokens via API');
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->tokens->revoke($id, $this->clock->now());
|
|
|
+
|
|
|
+ return $response->withStatus(204);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function optInt(mixed $value): ?int
|
|
|
+ {
|
|
|
+ if (is_int($value) && $value > 0) {
|
|
|
+ return $value;
|
|
|
+ }
|
|
|
+ if (is_string($value) && ctype_digit($value) && $value !== '0') {
|
|
|
+ return (int) $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function parseUtc(string $iso): ?DateTimeImmutable
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
|
|
|
+ } catch (\Exception) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function parseId(string $raw): ?int
|
|
|
+ {
|
|
|
+ return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
|
|
|
+ }
|
|
|
+}
|