From 1ff50c3430588a12aa5f8eef25b47018bb36f098 Mon Sep 17 00:00:00 2001 From: Thomas Skerbis Date: Fri, 5 Jun 2026 00:07:53 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(api):=20optionalen=20token-ablauf=20mi?= =?UTF-8?q?t=20toggle=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/token-expiry.js | 85 +++++++++++++++++++++++++++++++++++++++ boot.php | 5 +++ install.php | 6 +++ lang/de_de.lang | 2 + lib/Token.php | 39 ++++++++++++++++-- pages/token.php | 75 ++++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 assets/js/token-expiry.js diff --git a/assets/js/token-expiry.js b/assets/js/token-expiry.js new file mode 100644 index 0000000..ac8b416 --- /dev/null +++ b/assets/js/token-expiry.js @@ -0,0 +1,85 @@ +(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'; + + var funcInput = document.querySelector('input[type="hidden"][name*="[func]"]'); + var isAdd = !!(funcInput && funcInput.value === 'add'); + + if (isFirstInit) { + if (isAdd) { + checkbox.checked = true; + } else { + 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..b0cdcd8 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..ca6ce19 100644 --- a/lang/de_de.lang +++ b/lang/de_de.lang @@ -20,6 +20,8 @@ api_token_status = aktiv 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..dcbc56a 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,10 +58,24 @@ 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]); if (0 == count($Token)) { @@ -59,9 +84,12 @@ public static function get(int $Id) 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..ef243d6 100644 --- a/pages/token.php +++ b/pages/token.php @@ -14,6 +14,41 @@ $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' => '', + ], + ); +}; + +$setExpiresAt = static function (int $tokenId, string $expiresAt) use ($table): void { + if ($tokenId < 1) { + return; + } + + rex_sql::factory()->setQuery( + 'UPDATE ' . $table . ' SET expires_at = :expiresAt WHERE id = :id', + [ + 'id' => $tokenId, + 'expiresAt' => $expiresAt, + ], + ); +}; + 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 +61,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 +73,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 +109,33 @@ $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); + } elseif ('' === $expiresAtValue || '0000-00-00 00:00:00' === $expiresAtValue) { + $setExpiresAt($tokenId, date('Y-m-d H:i:s', strtotime('+2 hours'))); + } else { + $normalizeExpiresAt($tokenId, false); + } + } + switch ($func) { case 'edit': if (2 == $submit_type) { @@ -92,6 +157,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); @@ -150,6 +216,15 @@ return (1 == $params['subject']) ? rex_i18n::msg('active') : rex_i18n::msg('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->setColumnParams('name', ['page' => $page, 'func' => 'edit', 'data_id' => '###id###']); From 28098113d4f16057fc0654909331267470d92175 Mon Sep 17 00:00:00 2001 From: Thomas Skerbis Date: Fri, 5 Jun 2026 00:17:22 +0200 Subject: [PATCH 2/3] fix(api): review-kommentare zu token-ablauf umsetzen --- assets/js/token-expiry.js | 9 +-------- install.php | 4 ++-- lib/Token.php | 2 +- pages/token.php | 16 ---------------- tests/.env.example | 4 ++++ tests/AuthApiTest.php | 12 ++++++++++++ tests/config.php | 1 + 7 files changed, 21 insertions(+), 27 deletions(-) diff --git a/assets/js/token-expiry.js b/assets/js/token-expiry.js index ac8b416..24e00cd 100644 --- a/assets/js/token-expiry.js +++ b/assets/js/token-expiry.js @@ -45,15 +45,8 @@ var isFirstInit = checkbox.dataset.apiExpiryInit !== '1'; checkbox.dataset.apiExpiryInit = '1'; - var funcInput = document.querySelector('input[type="hidden"][name*="[func]"]'); - var isAdd = !!(funcInput && funcInput.value === 'add'); - if (isFirstInit) { - if (isAdd) { - checkbox.checked = true; - } else { - checkbox.checked = hasDateValue(expiresGroup); - } + checkbox.checked = hasDateValue(expiresGroup); } applyVisibility(checkbox.checked); diff --git a/install.php b/install.php index b0cdcd8..5558685 100644 --- a/install.php +++ b/install.php @@ -10,6 +10,6 @@ ->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', ''], + 'UPDATE ' . rex::getTable('api_token') . ' SET expires_at = NULL WHERE expires_at = ? OR expires_at = ?', + ['0000-00-00 00:00:00', ''], ); diff --git a/lib/Token.php b/lib/Token.php index dcbc56a..7119321 100644 --- a/lib/Token.php +++ b/lib/Token.php @@ -77,7 +77,7 @@ public function isExpired(): bool 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; } diff --git a/pages/token.php b/pages/token.php index ef243d6..c328990 100644 --- a/pages/token.php +++ b/pages/token.php @@ -35,20 +35,6 @@ ); }; -$setExpiresAt = static function (int $tokenId, string $expiresAt) use ($table): void { - if ($tokenId < 1) { - return; - } - - rex_sql::factory()->setQuery( - 'UPDATE ' . $table . ' SET expires_at = :expiresAt WHERE id = :id', - [ - 'id' => $tokenId, - 'expiresAt' => $expiresAt, - ], - ); -}; - if ('delete' == $func && !rex_csrf_token::factory($_csrf_key)->isValid()) { echo rex_view::error(rex_i18n::msg('csrf_token_invalid')); } elseif ('delete' == $func) { @@ -129,8 +115,6 @@ if (!$isExpiresActive) { $normalizeExpiresAt($tokenId, true); - } elseif ('' === $expiresAtValue || '0000-00-00 00:00:00' === $expiresAtValue) { - $setExpiresAt($tokenId, date('Y-m-d H:i:s', strtotime('+2 hours'))); } else { $normalizeExpiresAt($tokenId, false); } 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'), From 93da6971e8dce3823053b376b892cbc190ee113c Mon Sep 17 00:00:00 2001 From: Thomas Skerbis Date: Fri, 5 Jun 2026 00:20:06 +0200 Subject: [PATCH 3/3] fix(api): status-uebersetzung in token-liste korrigieren --- lang/de_de.lang | 2 ++ pages/token.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lang/de_de.lang b/lang/de_de.lang index ca6ce19..68623ef 100644 --- a/lang/de_de.lang +++ b/lang/de_de.lang @@ -17,6 +17,8 @@ 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 diff --git a/pages/token.php b/pages/token.php index c328990..e05fb8f 100644 --- a/pages/token.php +++ b/pages/token.php @@ -197,7 +197,7 @@ $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) {