Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions assets/js/token-expiry.js
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 5 additions & 0 deletions boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
5 changes: 5 additions & 0 deletions install.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
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
52 changes: 47 additions & 5 deletions lib/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,28 @@ class Token
private ?int $id = null;
private string $name = '';
private string $token = '';
private ?string $expiresAt = null;
private ?bool $expired = 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 +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'],
);
Comment thread
skerbis marked this conversation as resolved.
if (0 == count($Token)) {
return null;
}
Expand All @@ -80,6 +119,9 @@ public static function getFromBearerToken(): ?self
return self::getByToken($BearerToken);
}

/**
* @return list<string>
*/
public static function getAvailableScopes(): array
{
$Scopes = [];
Expand Down
66 changes: 64 additions & 2 deletions pages/token.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<?php

use FriendsOfRedaxo\Api\RouteCollection;
use FriendsOfRedaxo\Api\Token;

$_csrf_key = 'api_token';
Expand All @@ -14,6 +13,23 @@
$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 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) {
Expand All @@ -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';

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
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