Ergebnis einer beauftragten Sicherheitsbegutachtung. Die WebAuthn- Zeremonie selbst ist nicht angefasst worden - sie war in Ordnung. Alle Befunde lagen DANEBEN: Ein zweiter Faktor ist nur so stark wie der schwaechste Weg zum Konto, und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht. KRITISCH /api/setup/v1/login prueste auf totp_secret - eine Spalte, die kein Anmeldeweg auswertet - und nicht auf dc_user_credentials. Ein Administratorpasswort genuegte dort fuer ein setup:install-Token ohne Hardwarekey. Neu: Konten mit zweitem Faktor abgewiesen, und die Rollentrennung gilt symmetrisch. Der Docblock behauptete sie seit jeher; erzwungen hat sie nichts. HOCH secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds() fingen jedes Throwable und fielen auf "kein Faktor" bzw. "nicht gesperrt" zurueck. Gefangen wurden damit auch Verbindungsabbrueche und Lock-Timeouts - jeder davon schaltete still fuer alle Konten auf Passwort-allein. Toleriert werden jetzt nur noch 42S02 und 42S22. HOCH Fuenf Aktionen aenderten die Anmeldemittel ohne erneute Bestaetigung. Eine uebernommene Sitzung konnte sich damit dauerhaft einnisten. Neu: Authentifizierung aus den letzten zehn Minuten noetig, geprueft an derselben Stelle, an der auch der Leerlauf sperrt. WEITER - Leerlauf 30 statt 120 Minuten, und GESPERRT statt beendet: AAL2 laesst zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten den Hardwarekey vorzeigen muss, laesst ihn stecken. - Migration 022: session_epoch. Ein Passwortwechsel beendet die uebrigen Sitzungen des Kontos, die eigene bleibt. - Kontosperre wirkt auf der zweiten Stufe; Zaehler gegen unbegrenztes Raten im Zwischenzustand. - Drosselung auf login.php (10/60s) und /api/auth/v1/ (30/60s). - Anmeldefehler ununterscheidbar; die IP-Sperre darf benannt bleiben, sie sagt nichts ueber ein Konto. - HSTS, TLS-Zwang VOR allen Routen, CSP. Die CSP traegt wegen 93 Inline-Handlern nicht gegen XSS - das steht so im Kommentar. - session.use_strict_mode, CSRF-Token bei der Anmeldung verworfen, Secure-Flag aus app.url statt aus $_SERVER['HTTPS']. - rpId/expectedOrigin nicht mehr aus dem Host-Header. /api/health meldet unter checks.webauthn_anchor, ob der Anker steht. - Notfalltoken laeuft nach 180 Tagen ab, geprueft nach dem Hashvergleich. - config.example.php ohne echtes Passwort, verify_ssl auf true. - Serverdaten.txt.bak mitgesperrt, dc_login_attempts raeumt auf. BELEGT 188 von 188 Testfaellen gruen, darunter drei neue in der Gruppe "haertung". Die Blockade einer veralteten Sitzung liess sich nicht in der Suite pruefen - sie verlangt, das Zehn-Minuten-Fenster wirklich abzuwarten. Dafuer gibt es ein Werkzeug ausserhalb; es belegt, dass der Formularweg auf den Sperrbildschirm fuehrt, die Regel dabei unveraendert bleibt, der JSON-Weg reauth_required meldet und das Passwort allein wieder oeffnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
451 lines
18 KiB
PHP
451 lines
18 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Setup API - Erstinstallation von Anwendungen
|
|
*
|
|
* POST /api/setup/v1/login Benutzername + Passwort -> kurzlebiges Token
|
|
* GET /api/setup/v1/catalog Was ist hier installierbar?
|
|
* POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung
|
|
* POST /api/setup/v1/agent Watchdog-Agent fuer die Maschine selbst
|
|
*
|
|
* Warum ein eigener Anmeldeweg statt eines Dauertokens: Ein Token muesste
|
|
* vorher jemand erzeugen und auf das Zielsystem bringen - genau die Huerde,
|
|
* die eine Erstinstallation umstaendlich macht. Zugangsdaten hat der
|
|
* Einrichtende ohnehin.
|
|
*
|
|
* Das ausgestellte Token laeuft nach 30 Minuten ab und traegt nur
|
|
* setup:install. Es liegt auf einem fremden Rechner; alles Langlebige waere
|
|
* dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das
|
|
* Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht.
|
|
*
|
|
* ROLLENTRENNUNG: Seit 2.9.3 duerfen sich hier NUR Konten der Rolle
|
|
* "installer" anmelden - die sich ihrerseits nicht am WebUI anmelden koennen.
|
|
* Bis dahin stand an dieser Stelle "gedacht ist der Weg fuer Konten der Rolle
|
|
* installer", und erzwungen war nichts; der Satz beschrieb eine Absicht, die
|
|
* der Code nicht teilte. Wer auf jedem Zielsystem sein Admin-Passwort
|
|
* eingibt, verteilt den Zugang zur gesamten Verwaltung.
|
|
*
|
|
* ZWEITER FAKTOR: Konten mit hinterlegtem Hardwarekey werden hier ebenfalls
|
|
* abgewiesen. Dieser Weg kennt die WebAuthn-Zeremonie nicht, und ein
|
|
* Anmeldeweg, der den zweiten Faktor einfach uebergeht, macht ihn ueberall
|
|
* wertlos.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\ApiAuth;
|
|
use Deploymentcenter\Core\Auth;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Core\Logger;
|
|
use Deploymentcenter\Core\SecurityLogger;
|
|
use Deploymentcenter\Core\TokenManager;
|
|
use Deploymentcenter\Modules\License\RateLimiter;
|
|
use Deploymentcenter\Modules\Setup\SetupCatalog;
|
|
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
|
use Deploymentcenter\Modules\Watchdog\AgentScript;
|
|
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
|
|
|
$db = Db::init();
|
|
|
|
/** Gueltigkeit des ausgestellten Tokens in Sekunden. */
|
|
const SETUP_TOKEN_TTL = 1800;
|
|
|
|
switch (resolveSetupAction()) {
|
|
|
|
case 'login':
|
|
if (Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', 'Die Anmeldung erwartet POST.');
|
|
}
|
|
|
|
// Deutlich enger als die uebrigen Endpunkte: hier werden Passwoerter
|
|
// geprueft. Die Sperre aus Auth greift zusaetzlich pro IP, sie zaehlt
|
|
// aber erst ab dem zehnten Fehlversuch.
|
|
$limiter = new RateLimiter($db, 10, 60, 'setup_login');
|
|
if (!$limiter->check(Http::clientIp())) {
|
|
Http::fail(429, 'rate_limited', 'Zu viele Anmeldeversuche. Bitte kurz warten.');
|
|
}
|
|
|
|
$username = Http::str('username');
|
|
$password = Http::str('password');
|
|
|
|
if ($username === null || $password === null || $username === '' || $password === '') {
|
|
Http::fail(400, 'missing_credentials', 'Benutzername und Passwort werden benoetigt.');
|
|
}
|
|
|
|
$ipLockout = Auth::lockoutSeconds($db, Http::clientIp());
|
|
if ($ipLockout > 0) {
|
|
Http::fail(429, 'locked_out', sprintf(
|
|
'Zu viele fehlgeschlagene Anmeldungen von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
|
|
(int)ceil($ipLockout / 60)
|
|
), null, ['retry_after_seconds' => $ipLockout]);
|
|
}
|
|
|
|
// Die Kontosperre wird geprueft, aber NICHT als solche gemeldet.
|
|
// Eine eigene Antwort "account_locked" verriete, dass es den Namen
|
|
// gibt - gesperrt wird nur, was existiert. Die sorgfaeltige
|
|
// Laufzeitangleichung ueber dummyHash() in verifyCredentials() waere
|
|
// damit wieder aufgehoben: Fuenf Versuche pro Name genuegten, um ihn
|
|
// zu bestaetigen.
|
|
$accLockout = Auth::accountLockoutSeconds($db, $username);
|
|
|
|
$user = $accLockout > 0 ? null : Auth::verifyCredentials($db, $username, $password);
|
|
|
|
if ($user === null) {
|
|
// Eine Antwort fuer alle Faelle: falscher Name, falsches Passwort,
|
|
// deaktiviertes Konto, gesperrtes Konto. Die Wartezeit darf mit -
|
|
// sie ist ein Hinweis fuer den Berechtigten und sagt einem
|
|
// Ratenden nichts, was er nicht ohnehin ausprobieren wuerde.
|
|
$warten = max($accLockout, Auth::accountLockoutSeconds($db, $username));
|
|
|
|
Http::fail(
|
|
401,
|
|
'invalid_credentials',
|
|
$warten > 0
|
|
? sprintf(
|
|
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
|
|
(int)ceil($warten / 60)
|
|
)
|
|
: 'Anmeldung nicht moeglich.',
|
|
null,
|
|
$warten > 0 ? ['retry_after_seconds' => $warten] : []
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Zweiter Faktor
|
|
// ------------------------------------------------------------------
|
|
// Bis 2.9.2 stand hier nur die Pruefung auf totp_secret - eine Spalte,
|
|
// die von KEINEM Anmeldeweg ausgewertet wird. Der tatsaechlich
|
|
// umgesetzte zweite Faktor liegt in dc_user_credentials, und der
|
|
// wurde hier nicht geprueft.
|
|
//
|
|
// Die Folge war eine offene Tuer neben der verschlossenen: Wer ein
|
|
// Administratorpasswort hatte, bekam hier ohne Hardwarekey ein
|
|
// gueltiges setup:install-Token und konnte damit dauerhafte Tokens
|
|
// fuer jedes Projekt ausstellen. Die Aussage "dieses Konto ist durch
|
|
// einen zweiten Faktor geschuetzt" stimmte damit nicht mehr - und
|
|
// eine Aussage ueber Sicherheit, die nur fuer einen von zwei Wegen
|
|
// gilt, ist keine.
|
|
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
|
|
Logger::warning('Setup-Anmeldung eines Kontos mit zweitem Faktor abgelehnt', [
|
|
'username' => $username,
|
|
'ip' => Http::clientIp(),
|
|
]);
|
|
SecurityLogger::log('setup_login_second_factor', Http::clientIp(), [
|
|
'username' => $username,
|
|
]);
|
|
Http::fail(
|
|
403,
|
|
'second_factor_required',
|
|
'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung '
|
|
. 'unterstuetzt ihn nicht - bitte ein eigenes Installationskonto verwenden.'
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Rollentrennung, und zwar symmetrisch
|
|
// ------------------------------------------------------------------
|
|
// Der Docblock dieses Endpunkts sagte seit jeher, gedacht sei er fuer
|
|
// Konten der Rolle "installer" - erzwungen hat das nichts: "Anmelden
|
|
// darf sich hier jedes Konto."
|
|
//
|
|
// startLogin() haelt Installer umgekehrt schon immer vom WebUI fern.
|
|
// Die Trennung galt also nur in eine Richtung, und die
|
|
// unbeaufsichtigte Richtung war die gefaehrlichere: Ein Zielsystem,
|
|
// auf dem Installationsdaten eingegeben werden, ist der Ort, an dem
|
|
// sie am ehesten abhandenkommen.
|
|
if (Auth::roleOf($user) !== 'installer') {
|
|
Logger::warning('Setup-Anmeldung eines Nicht-Installer-Kontos abgelehnt', [
|
|
'username' => $username,
|
|
'role' => Auth::roleOf($user),
|
|
'ip' => Http::clientIp(),
|
|
]);
|
|
SecurityLogger::log('setup_login_wrong_role', Http::clientIp(), [
|
|
'username' => $username,
|
|
'role' => Auth::roleOf($user),
|
|
]);
|
|
Http::fail(
|
|
403,
|
|
'installer_account_required',
|
|
'Die Setup-Anmeldung steht nur Konten der Rolle "installer" offen. '
|
|
. 'Ein solches Konto legen Sie im WebUI unter System -> Benutzerkonten an.'
|
|
);
|
|
}
|
|
|
|
$manager = new TokenManager($db);
|
|
$hostname = Http::str('hostname') ?? Http::str('instance_id');
|
|
|
|
$token = $manager->createEphemeralToken(
|
|
sprintf('Setup %s%s', $username, $hostname !== null ? ' @ ' . $hostname : ''),
|
|
['setup:install'],
|
|
SETUP_TOKEN_TTL,
|
|
$hostname
|
|
);
|
|
|
|
Logger::info('Setup-Anmeldung erfolgreich', [
|
|
'username' => $username,
|
|
'role' => Auth::roleOf($user),
|
|
'hostname' => $hostname,
|
|
'ip' => Http::clientIp(),
|
|
'token_id' => $token['token_id'],
|
|
]);
|
|
|
|
Http::ok([
|
|
'setup_token' => $token['raw_token'],
|
|
'token_id' => $token['token_id'],
|
|
'expires_at' => $token['expires_at'],
|
|
'expires_in' => SETUP_TOKEN_TTL,
|
|
'scopes' => $token['scopes'],
|
|
'role' => Auth::roleOf($user),
|
|
'message' => 'Angemeldet. Das Token laeuft nach 30 Minuten ab und wird nicht gespeichert.',
|
|
], 201);
|
|
|
|
case 'catalog':
|
|
// allowSession bewusst aus: dieser Weg ist fuer den Installer, nicht
|
|
// fuer einen angemeldeten Browser.
|
|
$context = ApiAuth::requireScope($db, 'setup:catalog', null, false);
|
|
|
|
$platform = Http::str('platform') ?? Http::str('rid');
|
|
$catalog = (new SetupCatalog($db))->forPlatform(
|
|
$platform,
|
|
ApiAuth::projectFilter($context)
|
|
);
|
|
|
|
Http::ok([
|
|
'platform' => UpdateManager::normalizePlatform($platform),
|
|
'count' => count($catalog),
|
|
'projects' => $catalog,
|
|
'message' => $catalog === []
|
|
? 'Fuer diese Plattform ist nichts hinterlegt. Releases ohne Plattformangabe gelten als "any".'
|
|
: sprintf('%d Anwendung(en) installierbar.', count($catalog)),
|
|
]);
|
|
|
|
case 'token':
|
|
if (Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
|
|
}
|
|
|
|
$context = ApiAuth::requireScope($db, 'setup:install', null, false);
|
|
|
|
$project = Http::str('project') ?? Http::str('product_slug');
|
|
if ($project === null || $project === '') {
|
|
Http::fail(400, 'missing_project', 'Der Parameter "project" wird benoetigt.');
|
|
}
|
|
|
|
ApiAuth::enforceProject($context, $project);
|
|
|
|
$exists = $db->prepare('SELECT slug FROM dc_projects WHERE slug = :slug LIMIT 1');
|
|
$exists->execute([':slug' => $project]);
|
|
if ($exists->fetch() === false) {
|
|
Http::fail(404, 'unknown_project', sprintf('Ein Projekt "%s" ist nicht angelegt.', $project));
|
|
}
|
|
|
|
$requested = Http::input('scopes', []);
|
|
if (is_string($requested)) {
|
|
$requested = array_map('trim', explode(',', $requested));
|
|
}
|
|
if (!is_array($requested) || $requested === []) {
|
|
$requested = ['watchdog:ping'];
|
|
}
|
|
|
|
// Was ein Installer ausstellen darf, ist fest umrissen und nicht
|
|
// verhandelbar. Wuerde die Anfrage die Rechte bestimmen, waere der
|
|
// Umweg ueber ein kurzlebiges Setup-Token wirkungslos: wer einmal
|
|
// installieren darf, koennte sich sonst ein Token mit allen Rechten
|
|
// ausstellen und behalten.
|
|
$allowed = ['watchdog:ping', 'watchdog:read', 'bugtracker:report', 'updateservice:read'];
|
|
|
|
$granted = array_values(array_intersect(
|
|
array_map(static fn($s): string => strtolower(trim((string)$s)), $requested),
|
|
$allowed
|
|
));
|
|
|
|
if ($granted === []) {
|
|
Http::fail(403, 'scopes_not_allowed', 'Keines der angeforderten Rechte darf ueber den Setup-Weg vergeben werden.', null, [
|
|
'allowed' => $allowed,
|
|
]);
|
|
}
|
|
|
|
$hostname = Http::str('hostname') ?? Http::str('instance_id');
|
|
|
|
$manager = new TokenManager($db);
|
|
$appToken = $manager->createMasterToken(
|
|
sprintf('%s @ %s', $project, $hostname ?? 'unbekannt'),
|
|
$project,
|
|
null,
|
|
'host',
|
|
$hostname,
|
|
$granted,
|
|
Http::str('environment') ?? 'all'
|
|
);
|
|
|
|
Logger::info('Anwendungstoken ueber den Setup-Weg ausgestellt', [
|
|
'project' => $project,
|
|
'hostname' => $hostname,
|
|
'scopes' => $granted,
|
|
'actor' => $context['actor'],
|
|
'token_id' => $appToken['token_id'],
|
|
]);
|
|
|
|
Http::ok([
|
|
'token' => $appToken['raw_token'],
|
|
'token_id' => $appToken['token_id'],
|
|
'project' => $project,
|
|
'scopes' => $granted,
|
|
'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.',
|
|
], 201);
|
|
|
|
case 'agent':
|
|
// Der Watchdog-Agent fuer die Maschine, auf der gerade installiert
|
|
// wird.
|
|
//
|
|
// Bisher gab es diesen Weg nur ueber die Oberflaeche: Monitor von Hand
|
|
// anlegen, Token erzeugen, Skript herunterladen, auf den Host kopieren,
|
|
// Cron-Eintrag schreiben. Fuenf Schritte an einem anderen Rechner, fuer
|
|
// etwas, das genau dann ansteht, wenn man ohnehin auf dem Zielsystem
|
|
// sitzt - entsprechend oft unterblieb es, und die frisch installierte
|
|
// Anwendung lief auf einem unbeobachteten Host.
|
|
//
|
|
// Ausgestellt wird dasselbe wie bei 'token': ein Token mit genau
|
|
// watchdog:ping. Es liegt anschliessend im Klartext im Skript auf dem
|
|
// Zielsystem - mehr Rechte duerfen daran nicht haengen.
|
|
if (Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
|
|
}
|
|
|
|
$context = ApiAuth::requireScope($db, 'setup:install', null, false);
|
|
|
|
$project = Http::str('project') ?? Http::str('product_slug');
|
|
if ($project === null || $project === '') {
|
|
Http::fail(400, 'missing_project', 'Der Parameter "project" wird benoetigt.');
|
|
}
|
|
|
|
ApiAuth::enforceProject($context, $project);
|
|
|
|
$exists = $db->prepare('SELECT slug FROM dc_projects WHERE slug = :slug LIMIT 1');
|
|
$exists->execute([':slug' => $project]);
|
|
if ($exists->fetch() === false) {
|
|
Http::fail(404, 'unknown_project', sprintf('Ein Projekt "%s" ist nicht angelegt.', $project));
|
|
}
|
|
|
|
// Der Name ist das, was spaeter im Dashboard steht. Gesaeubert wird in
|
|
// AgentScript - hier wird nur geprueft, ob ueberhaupt etwas uebrig
|
|
// bleibt, damit nicht stillschweigend ein Monitor "host" entsteht.
|
|
$rawSource = trim((string)(Http::str('source') ?? ''));
|
|
if ($rawSource === '') {
|
|
Http::fail(400, 'missing_source', 'Der Parameter "source" (Name des Monitors) wird benoetigt.');
|
|
}
|
|
|
|
$source = AgentScript::sanitizeSource($rawSource);
|
|
|
|
$monitorRepo = new MonitorRepo($db);
|
|
$existing = $monitorRepo->getMonitor($source);
|
|
|
|
// Ein Name, den es schon gibt, ist nicht zwangslaeufig ein Fehler -
|
|
// eine Neuinstallation derselben Maschine trifft ihren eigenen Monitor
|
|
// wieder. Nur entscheiden muss das der Aufrufer, nicht dieser
|
|
// Endpunkt: sonst uebernimmt eine zweite Maschine unbemerkt die
|
|
// Historie der ersten.
|
|
$overwrite = filter_var(Http::input('overwrite', false), FILTER_VALIDATE_BOOLEAN);
|
|
|
|
if ($existing !== null && !$overwrite) {
|
|
Http::fail(409, 'monitor_exists', sprintf(
|
|
'Ein Monitor "%s" ist bereits angelegt (Zustand: %s). Mit overwrite=true uebernehmen.',
|
|
$source,
|
|
(string)($existing['state'] ?? 'unbekannt')
|
|
), null, [
|
|
'source' => $source,
|
|
'state' => $existing['state'] ?? null,
|
|
'last_seen' => $existing['last_seen_utc'] ?? null,
|
|
]);
|
|
}
|
|
|
|
$os = AgentScript::normalizeOs(Http::str('os'));
|
|
$interval = Http::int('interval', 60);
|
|
|
|
$manager = new TokenManager($db);
|
|
$agentToken = $manager->createMasterToken(
|
|
sprintf('Watchdog %s', $source),
|
|
$project,
|
|
null,
|
|
'host',
|
|
$source,
|
|
['watchdog:ping'],
|
|
Http::str('environment') ?? 'all'
|
|
);
|
|
|
|
$monitorRepo->createMonitor($source, [
|
|
'type' => 'host',
|
|
'os' => $os,
|
|
'expected_interval_sec' => $interval,
|
|
'group_key' => Http::str('group'),
|
|
'parent_source' => Http::str('parent'),
|
|
]);
|
|
|
|
$agent = AgentScript::build(
|
|
$os,
|
|
$source,
|
|
$agentToken['raw_token'],
|
|
Http::baseUrl() . '/api/watchdog/v1/ping',
|
|
$interval
|
|
);
|
|
|
|
Logger::info('Watchdog-Agent ueber den Setup-Weg ausgestellt', [
|
|
'project' => $project,
|
|
'source' => $source,
|
|
'os' => $os,
|
|
'existing' => $existing !== null,
|
|
'actor' => $context['actor'],
|
|
'token_id' => $agentToken['token_id'],
|
|
]);
|
|
|
|
Http::ok([
|
|
'source' => $source,
|
|
'os' => $agent['os'],
|
|
'filename' => $agent['filename'],
|
|
'script' => $agent['script'],
|
|
'schedule' => $agent['schedule'],
|
|
'interval' => $agent['interval'],
|
|
'token_id' => $agentToken['token_id'],
|
|
'existing' => $existing !== null,
|
|
'message' => sprintf(
|
|
'Monitor "%s" eingerichtet. Das Skript enthaelt das Token und wird nur einmal ausgeliefert.',
|
|
$source
|
|
),
|
|
], 201);
|
|
|
|
default:
|
|
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
|
'available' => ['login', 'catalog', 'token', 'agent'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Bestimmt die Aktion aus dem Pfadsegment oder ?action=.
|
|
* Feste Liste statt Teilstring-Suche.
|
|
*/
|
|
function resolveSetupAction(): string
|
|
{
|
|
$explicit = Http::str('action');
|
|
if ($explicit !== null) {
|
|
return strtolower($explicit);
|
|
}
|
|
|
|
$segments = array_values(array_filter(
|
|
explode('/', trim(Http::path(), '/')),
|
|
static fn(string $s): bool => $s !== ''
|
|
));
|
|
|
|
$last = strtolower((string)end($segments));
|
|
|
|
return match ($last) {
|
|
'login', 'catalog', 'token', 'agent' => $last,
|
|
default => 'unknown',
|
|
};
|
|
}
|