From 1ff50c3430588a12aa5f8eef25b47018bb36f098 Mon Sep 17 00:00:00 2001 From: Thomas Skerbis Date: Fri, 5 Jun 2026 00:07:53 +0200 Subject: [PATCH 1/4] =?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/4] 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/4] 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) { From 6670c0e2760a7cac1aa1ffad5c429fa44f9a69e7 Mon Sep 17 00:00:00 2001 From: Jan Kristinus Date: Mon, 24 Aug 2026 12:01:50 +0200 Subject: [PATCH 4/4] Token-Ablauf: Default beim Anlegen aus, Jahresbereich in die Zukunft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behebt drei Probleme, die den optionalen Ablauf praktisch unbrauchbar machten: 1. Ein im Backend angelegter Token war sofort abgelaufen. Das JS hakte "Ablauf aktiv" anhand der Datumsfelder an, und YForms datetime-Feld ist mit current_date=1 nie leer -- gespeichert wurde damit der Zeitpunkt des Formularaufrufs, der beim Absenden schon in der Vergangenheit lag. Der Default entscheidet jetzt serverseitig: beim Anlegen aus, beim Bearbeiten aktiv, wenn ein Datum gespeichert ist. Ohne aktive Checkbox schreibt die Seite NULL -- auch wenn das JS nicht lädt. 2. Der Jahresbereich des Feldes lief von 2006 bis zum laufenden Jahr, ein Ablaufdatum in der Zukunft war also nicht wählbar. Jetzt laufendes Jahr bis +10, vorbelegt mit "in einem Jahr" (modify_default). 3. Ein Datum ohne Jahr (z.B. 0000-03-15, wenn im Select kein Jahr gewählt wurde) ergab einen still unbrauchbaren Token. Normalisierung und Default-Ermittlung prüfen daher auf YEAR() < 1 statt nur auf 0000-00-00 00:00:00; install.php räumt solche Altbestände mit auf. Weitere Korrekturen: - Der Vergleich expires_at = '' entfaellt in getByToken(): er erzeugte pro Auth-Request eine MySQL-Warning 1292 und kann in einer nullable DATETIME-Spalte nicht vorkommen. - isExpired() vergleicht über die Datenbankzeit statt über time(). Bei abweichender PHP- und MySQL-Zeitzone urteilte die Methode anders als der Filter in getByToken(), der mit now() arbeitet. - Die Listenspalte trägt jetzt das Label "Ablaufdatum" statt des technischen Spaltennamens. - Toter Code in pages/token.php entfernt, dazu ein unbenutzter Import (php-cs-fixer). - README: Abschnitt zum Ablaufdatum. --- README.md | 6 +++++ assets/js/token-expiry.js | 55 ++++++--------------------------------- install.php | 7 +++-- lib/Token.php | 17 +++++++++--- pages/token.php | 27 ++++++++++--------- 5 files changed, 46 insertions(+), 66 deletions(-) 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 index 24e00cd..eadc6b1 100644 --- a/assets/js/token-expiry.js +++ b/assets/js/token-expiry.js @@ -1,30 +1,4 @@ (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'); @@ -37,35 +11,22 @@ 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); + // 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(checkbox.checked); + applyVisibility(); 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._apiExpiryHandler = applyVisibility; checkbox.addEventListener('change', checkbox._apiExpiryHandler); - checkbox.addEventListener('click', checkbox._apiExpiryHandler); - checkboxGroup.addEventListener('change', checkbox._apiExpiryHandler); - checkboxGroup.addEventListener('click', checkbox._apiExpiryHandler); } $(function () { diff --git a/install.php b/install.php index 5558685..93509f6 100644 --- a/install.php +++ b/install.php @@ -9,7 +9,6 @@ ->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', ''], -); +// 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/lib/Token.php b/lib/Token.php index 86fa2d4..05174c7 100644 --- a/lib/Token.php +++ b/lib/Token.php @@ -16,6 +16,7 @@ class Token private string $name = ''; private string $token = ''; private ?string $expiresAt = null; + private ?bool $expired = null; /** * @param array $data @@ -72,7 +73,17 @@ public function isExpired(): bool return false; } - return strtotime($this->expiresAt) <= time(); + // 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): ?self @@ -87,8 +98,8 @@ public static function get(int $Id): ?self public static function getByToken(string $Token): ?self { $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'], + '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; diff --git a/pages/token.php b/pages/token.php index e05fb8f..e982762 100644 --- a/pages/token.php +++ b/pages/token.php @@ -1,6 +1,5 @@ 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' => '', - ], + 'UPDATE ' . $table . ' SET expires_at = NULL WHERE id = :id AND YEAR(expires_at) < 1', + ['id' => $tokenId], ); }; @@ -47,8 +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))); - $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'; + $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'; @@ -98,7 +102,6 @@ $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')) { @@ -108,8 +111,7 @@ $fieldName = (string) $fieldValue->getName(); if ('expires_active' === $fieldName) { $isExpiresActive = '1' === (string) $fieldValue->getValue(); - } elseif ('expires_at' === $fieldName) { - $expiresAtValue = trim((string) $fieldValue->getValue()); + break; } } @@ -210,6 +212,7 @@ }); $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) {