diff --git a/assets/js/token-expiry.js b/assets/js/token-expiry.js new file mode 100644 index 0000000..24e00cd --- /dev/null +++ b/assets/js/token-expiry.js @@ -0,0 +1,78 @@ +(function ($) { + function hasDateValue(expiresGroup) { + var textInput = expiresGroup.querySelector('input[type="text"]'); + if (textInput) { + var value = String(textInput.value || '').trim(); + return value !== '' && value !== '0000-00-00 00:00:00'; + } + + var year = expiresGroup.querySelector('select[name*="[year]"]'); + var month = expiresGroup.querySelector('select[name*="[month]"]'); + var day = expiresGroup.querySelector('select[name*="[day]"]'); + + if (!year || !month || !day) { + return false; + } + + return parseInt(year.value, 10) > 0 && parseInt(month.value, 10) > 0 && parseInt(day.value, 10) > 0; + } + + function setGroupEnabled(expiresGroup, enabled) { + var fields = expiresGroup.querySelectorAll('input, select, textarea, button'); + fields.forEach(function (field) { + field.disabled = !enabled; + }); + expiresGroup.classList.toggle('text-muted', !enabled); + } + + 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; + } + + function applyVisibility(isActive) { + expiresGroup.style.display = isActive ? '' : 'none'; + setGroupEnabled(expiresGroup, isActive); + } + + var isFirstInit = checkbox.dataset.apiExpiryInit !== '1'; + checkbox.dataset.apiExpiryInit = '1'; + + if (isFirstInit) { + checkbox.checked = hasDateValue(expiresGroup); + } + + applyVisibility(checkbox.checked); + + if (checkbox._apiExpiryHandler) { + checkbox.removeEventListener('change', checkbox._apiExpiryHandler); + checkbox.removeEventListener('click', checkbox._apiExpiryHandler); + checkboxGroup.removeEventListener('change', checkbox._apiExpiryHandler); + checkboxGroup.removeEventListener('click', checkbox._apiExpiryHandler); + } + + checkbox._apiExpiryHandler = function () { + applyVisibility(checkbox.checked); + }; + + checkbox.addEventListener('change', checkbox._apiExpiryHandler); + checkbox.addEventListener('click', checkbox._apiExpiryHandler); + checkboxGroup.addEventListener('change', checkbox._apiExpiryHandler); + checkboxGroup.addEventListener('click', checkbox._apiExpiryHandler); + } + + $(function () { + init(); + }); + + $(document).on('rex:ready', function () { + init(); + }); +})(jQuery); diff --git a/boot.php b/boot.php index 422d720..65f5e7d 100644 --- a/boot.php +++ b/boot.php @@ -42,4 +42,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..5558685 100644 --- a/install.php +++ b/install.php @@ -6,4 +6,10 @@ ->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(); + +rex_sql::factory()->setQuery( + 'UPDATE ' . rex::getTable('api_token') . ' SET expires_at = NULL WHERE expires_at = ? OR expires_at = ?', + ['0000-00-00 00:00:00', ''], +); diff --git a/lang/de_de.lang b/lang/de_de.lang index 277f47e..68623ef 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 0eba37a..7119321 100644 --- a/lib/Token.php +++ b/lib/Token.php @@ -15,7 +15,11 @@ class Token private ?int $id = null; private string $name = ''; private string $token = ''; + private ?string $expiresAt = null; + /** + * @param array $data + */ public function __construct(array $data) { $this->id = (int) $data['id']; @@ -23,8 +27,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 +58,38 @@ 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; + } + + return strtotime($this->expiresAt) <= time(); } - 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 = ? or expires_at > now())', + [$Token, 1, '', '0000-00-00 00:00:00'], + ); if (0 == count($Token)) { return null; } @@ -80,6 +108,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..e05fb8f 100644 --- a/pages/token.php +++ b/pages/token.php @@ -14,6 +14,27 @@ $content = ''; $show_list = true; +$normalizeExpiresAt = static function (int $tokenId, bool $forceNull = false) use ($table): void { + if ($tokenId < 1) { + return; + } + + $sql = rex_sql::factory(); + if ($forceNull) { + $sql->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 (expires_at = :zeroDate OR expires_at = :emptyValue)', + [ + 'id' => $tokenId, + 'zeroDate' => '0000-00-00 00:00:00', + 'emptyValue' => '', + ], + ); +}; + 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 +47,8 @@ $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))); + $form_data[] = 'checkbox|expires_active|translate:api_token_expire_active|0|no_db'; + $form_data[] = 'datetime|expires_at|translate:api_token_expires_at|||Y-m-d H:i:s|1'; $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 +59,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 +95,31 @@ $content = $yform->executeActions(); if ($yform->objparams['actions_executed']) { + $tokenId = (int) ($yform->objparams['main_id'] ?? 0); + if ($tokenId > 0) { + $isExpiresActive = false; + $expiresAtValue = ''; + + 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(); + } elseif ('expires_at' === $fieldName) { + $expiresAtValue = trim((string) $fieldValue->getValue()); + } + } + + if (!$isExpiresActive) { + $normalizeExpiresAt($tokenId, true); + } else { + $normalizeExpiresAt($tokenId, false); + } + } + switch ($func) { case 'edit': if (2 == $submit_type) { @@ -92,6 +141,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,7 +197,16 @@ $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')); 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'),