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
43 changes: 19 additions & 24 deletions components/ILIAS/Authentication/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,31 +32,23 @@ public function init(
array | \ArrayAccess &$pull,
array | \ArrayAccess &$internal,
): void {
// currently this is will be a session storage because we cannot store
// data on the client, see https://mantis.ilias.de/view.php?id=38503.
// @todo: this should be implemented by some proper key-value storage (or service).
$implement[KeyValueStorage\TransientStoragePort::class] = static fn() =>
$internal[Authentication\KeyValueStorage\SessionStoragePort::class];

$implement[UI\Storage::class] = static fn() =>
new class () implements UI\Storage {
public function offsetExists(mixed $offset): bool
{
return \ilSession::has($offset);
}
public function offsetGet(mixed $offset): mixed
{
return \ilSession::get($offset);
}
public function offsetSet(mixed $offset, mixed $value): void
{
if (!is_string($offset)) {
throw new \InvalidArgumentException('Offset needs to be of type string.');
}
\ilSession::set($offset, $value);
}
public function offsetUnset(mixed $offset): void
{
\ilSession::clear($offset);
}
};
new Authentication\KeyValueStorage\UiStorageAdapter(
$use[KeyValueStorage\Factory::class]->transient()->requestCached(
new KeyValueStorage\StorageNamespace('ui.storage')
)
);

$contribute[KeyValueStorage\StorageProvider::class] = static fn() =>
new KeyValueStorage\Implementation\StorageProviderBridge(
KeyValueStorage\StorageBackend::TRANSIENT,
$use[KeyValueStorage\TransientStoragePort::class],
$pull[KeyValueStorage\Implementation\NamespacedStorageFactory::class],
$pull[KeyValueStorage\Implementation\RequestScopeCache::class],
);

$contribute[\ILIAS\Setup\Agent::class] = static fn() =>
new \ilAuthenticationSetupAgent(
Expand All @@ -69,5 +61,8 @@ public function offsetUnset(mixed $offset): void
new Component\Resource\ComponentJS($this, 'js/dist/SessionReminder.min.js');
$contribute[User\Settings\UserSettings::class] = fn() =>
new Authentication\UserSettings\Settings();

$internal[Authentication\KeyValueStorage\SessionStoragePort::class] = static fn() =>
new Authentication\KeyValueStorage\SessionStoragePort();
}
}
60 changes: 60 additions & 0 deletions components/ILIAS/Authentication/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,63 @@ state of a client, identified by its session ID.
(regardless of user type).
- Note that `isAuthenticated()` returns `true` for both logged-in and "Anonymous"
users.

## KeyValueStorage Contribution

Authentication contributes the **transient** backend for
[`ILIAS\KeyValueStorage`](../KeyValueStorage/README.md):

| Role | Class |
|---|---|
| `TransientStoragePort` | `Authentication\KeyValueStorage\SessionStoragePort` |
| `StorageProvider` | `StorageProviderBridge` with `StorageBackend::TRANSIENT` |
| `UI\Storage` | `Authentication\KeyValueStorage\UiStorageAdapter` (session-backed, request-cached) |

Session data is the natural persistence mechanism for state that MUST NOT survive
logout. See [Design Decisions](#design-decisions) for how namespaces are laid out in
the session.

## Design Decisions

Significant architecture decisions for this component are recorded as lightweight
[Architecture Decision Records](https://github.com/joelparkerhenderson/architecture-decision-record)
(Michael Nygard's *Context / Decision / Consequences* format). Records are
append-only: supersede rather than rewrite.

### ADR 0001 — Flat Session Keys for Transient KeyValueStorage

**Status:** Accepted.

**Context.** `SessionStoragePort` implements `TransientStoragePort` on top of
`ilSession`. The port MUST support namespace-scoped `clear()` (via
`clearNamespace()`), but `ilSession` only exposes single-key `get` / `set` / `has` /
`clear` — there is no API to list or clear keys by prefix.

An alternative nested layout was considered — storing all entries under one session
root as `$_SESSION['__ilias_kv_storage__'][namespace][key]`. That would make
`clearNamespace()` an O(1) subtree drop without scanning the session, but every
`write` / `remove` would read-modify-write the **entire** root bucket. With ILIAS
session handling (all key/value pairs loaded at request start and written back at
request end), concurrent requests — e.g. parallel AJAX calls from one page — can
overwrite each other's in-flight changes to the same root array. Per-key updates via
`ilSession::set()` avoid that class of lost-update races.

**Decision.** Use flat, per-entry session keys:

```php
$_SESSION['__ilias_kv_storage__.{namespace}.{key}'] = encoded_value
```

`has`, `read`, `write`, and `remove` each call `ilSession` for exactly one key.
`clearNamespace()` is the sole exception: it iterates `$_SESSION` for keys matching
the namespace prefix and clears each match through `ilSession::clear()`.

**Consequences.**

- **+** Per-key writes are atomic at the session layer and less prone to concurrent lost-update races than a shared nested bucket.
- **+** `write` / `read` / `remove` use `ilSession` consistently.
- **−** `clearNamespace()` must scan session keys and accesses `$_SESSION` directly, because `ilSession` offers no prefix-based clearing.
- **−** Namespace clearing is O(n) in the number of session variables (typically acceptable — `clear()` is infrequent).

**Revisit when** `ilSession` gains prefix-based clearing, or a session API appears that
supports safe partial updates of a structured bucket under concurrent requests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/**
* This file is part of ILIAS, a powerful learning management system
* published by ILIAS open source e-Learning e.V.
*
* ILIAS is licensed with the GPL-3.0,
* see https://www.gnu.org/licenses/gpl-3.0.en.html
* You should have received a copy of said license along with the
* source code, too.
*
* If this is not the case or you just want to try ILIAS, you'll find
* us at:
* https://www.ilias.de
* https://github.com/ILIAS-eLearning
*
*********************************************************************/

declare(strict_types=1);

namespace ILIAS\Authentication\KeyValueStorage;

use ILIAS\KeyValueStorage\StorageNamespace;
use ILIAS\KeyValueStorage\TransientStoragePort;

/**
* Session-backed implementation of the transient storage port.
*
* Each entry is stored as a separate top-level session variable keyed by
* {@see SESSION_ROOT}, namespace, and storage key. See Authentication ADR 0001.
*/
final readonly class SessionStoragePort implements TransientStoragePort
{
private const string SESSION_ROOT = '__ilias_kv_storage__';

public function has(StorageNamespace $namespace, string $key): bool
{
return \ilSession::has($this->buildSessionKey($namespace, $key));
}

public function read(StorageNamespace $namespace, string $key): ?string
{
$value = \ilSession::get($this->buildSessionKey($namespace, $key));

return \is_string($value) ? $value : null;
}

public function write(StorageNamespace $namespace, string $key, string $value): void
{
\ilSession::set($this->buildSessionKey($namespace, $key), $value);
}

public function remove(StorageNamespace $namespace, string $key): void
{
\ilSession::clear($this->buildSessionKey($namespace, $key));
}

public function clearNamespace(StorageNamespace $namespace): void
{
$prefix = self::SESSION_ROOT . '.' . $namespace->value() . '.';
$session = $_SESSION ?? [];

foreach (\array_keys($session) as $session_key) {
if (!\is_string($session_key) || !\str_starts_with($session_key, $prefix)) {
continue;
}

\ilSession::clear($session_key);
}
}

private function buildSessionKey(StorageNamespace $namespace, string $key): string
{
return self::SESSION_ROOT . '.' . $namespace->value() . '.' . $key;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

/**
* This file is part of ILIAS, a powerful learning management system
* published by ILIAS open source e-Learning e.V.
*
* ILIAS is licensed with the GPL-3.0,
* see https://www.gnu.org/licenses/gpl-3.0.en.html
* You should have received a copy of said license along with the
* source code, too.
*
* If this is not the case or you just want to try ILIAS, you'll find
* us at:
* https://www.ilias.de
* https://github.com/ILIAS-eLearning
*
*********************************************************************/

declare(strict_types=1);

namespace ILIAS\Authentication\KeyValueStorage;

use ILIAS\KeyValueStorage\Storage;
use ILIAS\UI\Storage as UiStorage;

/**
* Adapts transient key-value storage to the UI ArrayAccess contract.
*/
final readonly class UiStorageAdapter implements UiStorage
{
public function __construct(private Storage $storage)
{
}

public function offsetExists(mixed $offset): bool
{
return $this->storage->has($this->assertStringOffset($offset));
}

public function offsetGet(mixed $offset): mixed
{
return $this->storage->get($this->assertStringOffset($offset));
}

public function offsetSet(mixed $offset, mixed $value): void
{
$this->storage->set($this->assertStringOffset($offset), $value);
}

public function offsetUnset(mixed $offset): void
{
$this->storage->delete($this->assertStringOffset($offset));
}

private function assertStringOffset(mixed $offset): string
{
if (!\is_string($offset)) {
throw new \InvalidArgumentException('Offset needs to be of type string.');
}

return $offset;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

/**
* This file is part of ILIAS, a powerful learning management system
* published by ILIAS open source e-Learning e.V.
*
* ILIAS is licensed with the GPL-3.0,
* see https://www.gnu.org/licenses/gpl-3.0.en.html
* You should have received a copy of said license along with the
* source code, too.
*
* If this is not the case or you just want to try ILIAS, you'll find
* us at:
* https://www.ilias.de
* https://github.com/ILIAS-eLearning
*
*********************************************************************/

declare(strict_types=1);

namespace ILIAS\Tests\Authentication\KeyValueStorage;

use ILIAS\Authentication\KeyValueStorage\SessionStoragePort;
use ILIAS\KeyValueStorage\StorageNamespace;
use PHPUnit\Framework\TestCase;

class SessionStoragePortTest extends TestCase
{
private SessionStoragePort $port;

protected function setUp(): void
{
$_SESSION = [];
$this->port = new SessionStoragePort();
}

protected function tearDown(): void
{
$_SESSION = [];
}

public function testWriteUsesExpectedSessionKey(): void
{
$namespace = new StorageNamespace('ui.table');

$this->port->write($namespace, 'sort_column', 'encoded');

self::assertSame(
'encoded',
$_SESSION['__ilias_kv_storage__.ui.table.sort_column'] ?? null
);
}

public function testWriteAndReadValue(): void
{
$namespace = new StorageNamespace('ui.table');

$this->port->write($namespace, 'sort_column', 'encoded');

self::assertTrue($this->port->has($namespace, 'sort_column'));
self::assertSame('encoded', $this->port->read($namespace, 'sort_column'));
}

public function testReadReturnsNullForMissingKey(): void
{
self::assertNull(
$this->port->read(new StorageNamespace('ui.table'), 'missing')
);
}

public function testRemoveDeletesValue(): void
{
$namespace = new StorageNamespace('ui.table');
$this->port->write($namespace, 'sort_column', 'encoded');

$this->port->remove($namespace, 'sort_column');

self::assertFalse($this->port->has($namespace, 'sort_column'));
self::assertArrayNotHasKey('__ilias_kv_storage__.ui.table.sort_column', $_SESSION);
}

public function testClearNamespaceRemovesOnlyMatchingKeys(): void
{
$namespace = new StorageNamespace('ui.table');
$other_namespace = new StorageNamespace('other.namespace');

$this->port->write($namespace, 'sort_column', 'encoded');
$this->port->write($other_namespace, 'key', 'value');

$this->port->clearNamespace($namespace);

self::assertFalse($this->port->has($namespace, 'sort_column'));
self::assertTrue($this->port->has($other_namespace, 'key'));
self::assertSame('value', $this->port->read($other_namespace, 'key'));
}
}
Loading
Loading