diff --git a/README.md b/README.md index 221d046..da2dfff 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] Die meisten APIs haben Authentifizierung. Das heisst, es muss ein API-Token im Backend angelegt werden, um die Endpunkte nutzen zu können, wie auch der entsprechende Scope gesetzt werden. Andere APIs haben eine Backend-Authentifizierung, die dann über den Backend-User läuft, d.h. es kann der Session Cookie verwendet werden, um die Endpunkte zu nutzen. +### Ablaufdatum für Tokens + +Ein Token kann optional ablaufen. Auf der Token-Seite schaltet die Checkbox **Ablauf aktiv** das Feld **Ablaufdatum** frei; ohne sie bleibt das Token unbegrenzt gültig — so verhalten sich auch alle Tokens, die vor dem Update angelegt wurden. + +Ist der Ablauf gesetzt und erreicht, wird das Token nicht mehr autorisiert: Anfragen bekommen `401` mit `{"error": "Authorization failed"}`, genau wie bei einem unbekannten Token. Der Vergleich läuft über die Datenbankzeit (`now()`), also über dieselbe Zeit, in der das Datum im Backend eingegeben wurde. + ## Selbstauskunft: /api/me `GET /api/me` beantwortet für den *aufrufenden* Zugang die Frage, was er darf. Gedacht für Clients und Agenten, die die API ohne externe Doku bedienen sollen: diff --git a/assets/js/token-expiry.js b/assets/js/token-expiry.js new file mode 100644 index 0000000..eadc6b1 --- /dev/null +++ b/assets/js/token-expiry.js @@ -0,0 +1,39 @@ +(function ($) { + function init() { + var checkboxGroup = document.getElementById('yform-api-token-form-expires_active'); + var expiresGroup = document.getElementById('yform-api-token-form-expires_at'); + if (!checkboxGroup || !expiresGroup) { + return; + } + + var checkbox = checkboxGroup.querySelector('input[type="checkbox"]'); + if (!checkbox) { + return; + } + + // Der Zustand kommt aus dem Formular selbst: pages/token.php setzt den + // Default der Checkbox (beim Bearbeiten aktiv, wenn ein Ablaufdatum + // gespeichert ist). Das JS blendet nur ein und aus -- ohne es bleibt die + // Seite bedienbar und speichert korrekt. + function applyVisibility() { + expiresGroup.style.display = checkbox.checked ? '' : 'none'; + } + + applyVisibility(); + + if (checkbox._apiExpiryHandler) { + checkbox.removeEventListener('change', checkbox._apiExpiryHandler); + } + + checkbox._apiExpiryHandler = applyVisibility; + checkbox.addEventListener('change', checkbox._apiExpiryHandler); + } + + $(function () { + init(); + }); + + $(document).on('rex:ready', function () { + init(); + }); +})(jQuery); diff --git a/boot.php b/boot.php index 2999acb..8f2861b 100644 --- a/boot.php +++ b/boot.php @@ -46,4 +46,9 @@ rex_view::addCssFile($addon->getAssetsUrl('css/swagger-ui-redaxo-theme.css')); rex_view::addJsFile($addon->getAssetsUrl('vendor/swagger-ui/js/swagger-ui-bundle.js')); } + + if (rex::isBackend() && 'api/token' === rex_be_controller::getCurrentPage()) { + $addon = rex_addon::get('api'); + rex_view::addJsFile($addon->getAssetsUrl('js/token-expiry.js')); + } } \ No newline at end of file diff --git a/install.php b/install.php index 1f10fa1..93509f6 100644 --- a/install.php +++ b/install.php @@ -6,4 +6,9 @@ ->ensureColumn(new rex_sql_column('token', 'varchar(191)')) ->ensureColumn(new rex_sql_column('status', 'tinyint(1)', false, '1')) ->ensureColumn(new rex_sql_column('scopes', 'text')) +->ensureColumn(new rex_sql_column('expires_at', 'datetime', true)) ->ensure(); + +// Altbestände aus früheren Versionen normalisieren: die Spalte wird nullable +// angelegt, ein bestehendes NOT-NULL-Feld kann aber 0000-00-00 00:00:00 enthalten. +rex_sql::factory()->setQuery('UPDATE ' . rex::getTable('api_token') . ' SET expires_at = NULL WHERE YEAR(expires_at) < 1'); diff --git a/lang/de_de.lang b/lang/de_de.lang index b2e2f95..5dbd75d 100644 --- a/lang/de_de.lang +++ b/lang/de_de.lang @@ -17,9 +17,13 @@ api_token_name = Name/Beschreibung api_token_token = Token api_token_not_found = Keine Tokens bisher vorhanden api_token_status = aktiv +api_active = aktiv +api_inactive = inaktiv api_token_create = API-Token erstellen api_token_token_notice = Wie wäre es mit diesem Token {0} api_token_token_scopes = Scopes +api_token_expire_active = Ablauf aktiv +api_token_expires_at = Ablaufdatum api_token_update = API-Token bearbeiten api_token_updated = API-Token Eintrag wurde aktualisiert api_openapi = OpenAPI diff --git a/lib/Token.php b/lib/Token.php index 4c61ee3..05174c7 100644 --- a/lib/Token.php +++ b/lib/Token.php @@ -15,7 +15,12 @@ class Token private ?int $id = null; private string $name = ''; private string $token = ''; + private ?string $expiresAt = null; + private ?bool $expired = null; + /** + * @param array $data + */ public function __construct(array $data) { $this->id = (int) $data['id']; @@ -23,8 +28,15 @@ public function __construct(array $data) $this->status = (1 == $data['status']) ? true : false; $this->scopes = $data['scopes']; $this->token = $data['token']; + $expiresAt = (string) ($data['expires_at'] ?? ''); + if ('' !== $expiresAt && '0000-00-00 00:00:00' !== $expiresAt) { + $this->expiresAt = $expiresAt; + } } + /** + * @return list + */ public function getScopes(): array { return ('' === $this->scopes) ? [] : explode(',', $this->scopes); @@ -47,21 +59,48 @@ public function getToken(): string public function isActive(): bool { - return $this->status; + return $this->status && !$this->isExpired(); + } + + public function getExpiresAt(): ?string + { + return $this->expiresAt; + } + + public function isExpired(): bool + { + if (null === $this->expiresAt) { + return false; + } + + // Der Vergleich läuft bewusst über die Datenbank und nicht über time(): + // getByToken() filtert mit now(), und das Ablaufdatum wird im Backend in + // DB-Zeit eingegeben. Weichen PHP- und MySQL-Zeitzone voneinander ab, + // würden beide Wege sonst unterschiedlich urteilen. + if (null === $this->expired) { + $sql = rex_sql::factory(); + $sql->setQuery('select ? <= now() as expired', [$this->expiresAt]); + $this->expired = (bool) $sql->getValue('expired'); + } + + return $this->expired; } - public static function get(int $Id) + public static function get(int $Id): ?self { - $Token = rex_sql::factory()->getArray('select * from ' . rex::getTable('api_token') . ' where id = ? and status = ?,', [$Id, 1]); + $Token = rex_sql::factory()->getArray('select * from ' . rex::getTable('api_token') . ' where id = ? and status = ?', [$Id, 1]); if (0 == count($Token)) { return null; } return new self($Token[0]); } - public static function getByToken(string $Token) + public static function getByToken(string $Token): ?self { - $Token = rex_sql::factory()->getArray('select * from ' . rex::getTable('api_token') . ' where token = ? and status = ?', [$Token, 1]); + $Token = rex_sql::factory()->getArray( + 'select * from ' . rex::getTable('api_token') . ' where token = ? and status = ? and (expires_at is null or expires_at = ? or expires_at > now())', + [$Token, 1, '0000-00-00 00:00:00'], + ); if (0 == count($Token)) { return null; } @@ -80,6 +119,9 @@ public static function getFromBearerToken(): ?self return self::getByToken($BearerToken); } + /** + * @return list + */ public static function getAvailableScopes(): array { $Scopes = []; diff --git a/pages/token.php b/pages/token.php index f81f2c4..e982762 100644 --- a/pages/token.php +++ b/pages/token.php @@ -1,6 +1,5 @@ setQuery('UPDATE ' . $table . ' SET expires_at = NULL WHERE id = :id', ['id' => $tokenId]); + return; + } + + $sql->setQuery( + 'UPDATE ' . $table . ' SET expires_at = NULL WHERE id = :id AND YEAR(expires_at) < 1', + ['id' => $tokenId], + ); +}; + if ('delete' == $func && !rex_csrf_token::factory($_csrf_key)->isValid()) { echo rex_view::error(rex_i18n::msg('csrf_token_invalid')); } elseif ('delete' == $func) { @@ -26,6 +42,17 @@ $form_data[] = 'text|name|translate:api_token_name'; $form_data[] = 'validate|empty|name|translate:api_token_name_validate'; $form_data[] = 'text|token|translate:api_token_token|#notice:' . rex_i18n::msg('api_token_token_notice', bin2hex(random_bytes((32 - (32 % 2)) / 2))); + $expires_active_default = 0; + if ('edit' == $func && $data_id > 0) { + $expires_current = rex_sql::factory()->getArray('select expires_at from ' . $table . ' where id = :id', ['id' => $data_id]); + $expires_current_value = (string) ($expires_current[0]['expires_at'] ?? ''); + if ('' !== $expires_current_value && 0 < (int) substr($expires_current_value, 0, 4)) { + $expires_active_default = 1; + } + } + + $form_data[] = 'checkbox|expires_active|translate:api_token_expire_active|' . $expires_active_default . '|no_db'; + $form_data[] = 'datetime|expires_at|translate:api_token_expires_at|' . date('Y') . '|+10|Y-m-d H:i:s|1||select|||+1 year'; $form_data[] = 'validate|empty|token|translate:api_token_token_validate'; $form_data[] = 'choice|scopes|translate:api_token_token_scopes|' . implode(',', Token::getAvailableScopes()) . '||1'; @@ -36,6 +63,7 @@ $yform->setFormData(implode("\n", $form_data)); $yform->setObjectparams('form_showformafterupdate', 1); + /** @var rex_yform $yform_clone */ $yform_clone = clone $yform; if ('edit' == $func) { @@ -71,6 +99,29 @@ $content = $yform->executeActions(); if ($yform->objparams['actions_executed']) { + $tokenId = (int) ($yform->objparams['main_id'] ?? 0); + if ($tokenId > 0) { + $isExpiresActive = false; + + foreach ($yform->objparams['values'] as $fieldValue) { + if (!is_object($fieldValue) || !method_exists($fieldValue, 'getName') || !method_exists($fieldValue, 'getValue')) { + continue; + } + + $fieldName = (string) $fieldValue->getName(); + if ('expires_active' === $fieldName) { + $isExpiresActive = '1' === (string) $fieldValue->getValue(); + break; + } + } + + if (!$isExpiresActive) { + $normalizeExpiresAt($tokenId, true); + } else { + $normalizeExpiresAt($tokenId, false); + } + } + switch ($func) { case 'edit': if (2 == $submit_type) { @@ -92,6 +143,7 @@ $data_id = $yform->objparams['main_id']; $func = 'edit'; + /** @var rex_yform $yform */ $yform = $yform_clone; $yform->setHiddenField('func', $func); $yform->setHiddenField('data_id', $data_id); @@ -147,10 +199,20 @@ $list->removeColumn('token'); $list->setColumnFormat('status', 'custom', static function ($params) { - return (1 == $params['subject']) ? rex_i18n::msg('active') : rex_i18n::msg('inactive'); + return (1 == $params['subject']) ? rex_i18n::msg('api_active') : rex_i18n::msg('api_inactive'); + }); + + $list->setColumnFormat('expires_at', 'custom', static function ($params) { + $expiresAt = (string) $params['subject']; + if ('' === $expiresAt || '0000-00-00 00:00:00' === $expiresAt) { + return '-'; + } + + return $expiresAt; }); $list->setColumnLabel('name', rex_i18n::msg('api_token_name')); + $list->setColumnLabel('expires_at', rex_i18n::msg('api_token_expires_at')); $list->setColumnParams('name', ['page' => $page, 'func' => 'edit', 'data_id' => '###id###']); $list->setColumnFormat('scopes', 'custom', static function ($params) { diff --git a/tests/.env.example b/tests/.env.example index fc05f43..89b029a 100644 --- a/tests/.env.example +++ b/tests/.env.example @@ -11,6 +11,10 @@ API_TEST_DEBUG=1 # grant it ALL scopes the test suite hits. API_TEST_TOKEN= +# Optional: abgelaufener Token für Expiry-Regressionstest in AuthApiTest. +# Leave empty to skip this test. +API_TEST_EXPIRED_TOKEN= + # Optional: a second token with a *limited* set of scopes — used by AuthApiTest # to verify scope enforcement. Must have the scopes listed under # API_TEST_RESTRICTED_TOKEN_ALLOWED_SCOPES below, and explicitly NOT have the diff --git a/tests/AuthApiTest.php b/tests/AuthApiTest.php index d45f206..44ef315 100644 --- a/tests/AuthApiTest.php +++ b/tests/AuthApiTest.php @@ -61,6 +61,18 @@ public function testWrongBearerToken(): void $this->assertSame(401, $response['status']); } + public function testExpiredTokenReturns401(): void + { + $expiredToken = (string) (self::$config['expired_token'] ?? ''); + if ('' === $expiredToken) { + self::markTestSkipped('Kein Expired-Token in tests/.env (API_TEST_EXPIRED_TOKEN).'); + } + + $response = $this->doRequest(['Authorization: Bearer ' . $expiredToken]); + $this->assertSame(401, $response['status']); + $this->assertSame('Authorization failed', $response['data']['error'] ?? null); + } + public function testMalformedAuthorizationHeader(): void { // Kein "Bearer "-Prefix — Token::getFromBearerToken() kann nichts extrahieren. diff --git a/tests/config.php b/tests/config.php index 058b9b8..5afc823 100644 --- a/tests/config.php +++ b/tests/config.php @@ -32,6 +32,7 @@ 'base_url' => $env('API_TEST_BASE_URL', 'https://redaxo.localhost'), 'api_prefix' => $env('API_TEST_API_PREFIX', '/api'), 'api_token' => $env('API_TEST_TOKEN'), + 'expired_token' => $env('API_TEST_EXPIRED_TOKEN'), 'restricted_token' => $env('API_TEST_RESTRICTED_TOKEN'), 'restricted_token_allowed_path' => $env('API_TEST_RESTRICTED_TOKEN_ALLOWED_PATH', 'structure/articles'), 'restricted_token_denied_path' => $env('API_TEST_RESTRICTED_TOKEN_DENIED_PATH', 'users'),