kurzlebiges Token * GET /api/setup/v1/catalog Was ist hier installierbar? * POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung * * 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: Anmelden darf sich hier jedes Konto, aber gedacht ist der * Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI * anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt, * verteilt den Zugang zur gesamten Verwaltung. */ 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\TokenManager; use Deploymentcenter\Modules\License\RateLimiter; use Deploymentcenter\Modules\Setup\SetupCatalog; use Deploymentcenter\Modules\UpdateService\UpdateManager; 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.'); } $lockout = Auth::lockoutSeconds($db, Http::clientIp()); if ($lockout > 0) { Http::fail(429, 'locked_out', sprintf( 'Zu viele fehlgeschlagene Anmeldungen. Bitte in %d Minute(n) erneut versuchen.', (int)ceil($lockout / 60) )); } $user = Auth::verifyCredentials($db, $username, $password); if ($user === null) { // Bewusst dieselbe Meldung fuer falschen Benutzer, falsches // Passwort und deaktiviertes Konto. Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.'); } // TOTP ist in dc_users vorgesehen, wird aber noch von keinem // Anmeldeweg geprueft. Ein Konto mit hinterlegtem Geheimnis hier // durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein // stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt. if (!empty($user['totp_secret'])) { Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [ 'username' => $username, 'ip' => Http::clientIp(), ]); Http::fail( 501, 'totp_not_supported', 'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung ' . 'unterstuetzt ihn noch nicht - bitte ein eigenes Installationskonto verwenden.' ); } $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); default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ 'available' => ['login', 'catalog', 'token'], ]); } /** * 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' => $last, default => 'unknown', }; }