Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions assets/js/token-expiry.js
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 5 additions & 0 deletions boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
6 changes: 6 additions & 0 deletions install.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', ''],
);
Comment thread
skerbis marked this conversation as resolved.
4 changes: 4 additions & 0 deletions lang/de_de.lang
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 36 additions & 5 deletions lib/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,27 @@ class Token
private ?int $id = null;
private string $name = '';
private string $token = '';
private ?string $expiresAt = null;

/**
* @param array<string, mixed> $data
*/
public function __construct(array $data)
{
$this->id = (int) $data['id'];
$this->name = (string) $data['name'];
$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<string>
*/
public function getScopes(): array
{
return ('' === $this->scopes) ? [] : explode(',', $this->scopes);
Expand All @@ -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'],
);
Comment thread
skerbis marked this conversation as resolved.
if (0 == count($Token)) {
return null;
}
Expand All @@ -80,6 +108,9 @@ public static function getFromBearerToken(): ?self
return self::getByToken($BearerToken);
}

/**
* @return list<string>
*/
public static function getAvailableScopes(): array
{
$Scopes = [];
Expand Down
61 changes: 60 additions & 1 deletion pages/token.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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';

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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'));
Expand Down
4 changes: 4 additions & 0 deletions tests/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/AuthApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions tests/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down