feat(setup): Erstinstallation ueber den Update-Agent, Installationskonto, Downloads
Bisher gab es nur den Update-Weg: eine Anwendung musste bereits installiert und eingerichtet sein, damit sich etwas aktualisieren liess. Die Erstinstallation auf einem neuen System war Handarbeit - Paket kopieren, Konfiguration abtippen, Token besorgen. Setup-API (neu) - POST /api/setup/v1/login tauscht Benutzername und Passwort gegen ein Token mit 30 Minuten Gueltigkeit und ausschliesslich setup:install. Es wird nicht mitgeschrieben und lebt im Installer nur im Speicher. - GET /api/setup/v1/catalog zeigt nur, was zur Laufzeitkennung des anfragenden Systems passt. Ein Projekt mit ausschliesslich Windows-Paket taucht auf einem Linux-Rechner gar nicht erst auf. - POST /api/setup/v1/token stellt das Dauertoken der Anwendung aus. Welche Rechte vergeben werden, entscheidet der Server; die Anfrage kann nur einschraenken. Sonst waere der Umweg ueber ein kurzlebiges Token wirkungslos. Rollentrennung (Migration 012) - dc_users bekommt role, disabled und last_login_at. Die Rolle "installer" darf sich ueber den Setup-Weg anmelden und nicht am WebUI. Die Zugangsdaten werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und Monitoren auf jeden Rechner, auf dem je etwas installiert wurde. - Auth::verifyCredentials() prueft sessionfrei, damit Setup- und WebUI-Login nicht zwei verschiedene Haertungsgrade haben (Drosselung, Timing-Angleichung, Rehash gelten fuer beide). - Konten mit hinterlegtem TOTP-Geheimnis werden am Setup-Weg mit 501 abgewiesen. Eine TOTP-Pruefung gibt es im Deploymentcenter noch nicht; sie stillschweigend zu uebergehen waere ein Rueckschritt. - Benutzerverwaltung im WebUI - es gab bisher gar keine, nur den einen von install_db.php angelegten Admin. Das letzte aktive Administratorkonto laesst sich weder deaktivieren noch loeschen. Installer - update-agent --action install fuehrt durch Anmeldung, Auswahl, Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und Rollback. Ein zweiter Download-Weg waere ein zweiter Ort fuer dieselben Fehler. - --action configure holt die Einrichtung nachtraeglich. - setup.json im Paket beschreibt die benoetigten Werte. Bewusst im Paket und nicht zentral: so ist sie mit der Anwendung versioniert. - Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... -> provision -> fragen. Platzhalter wie changeme oder <dein-wert> gelten dabei nicht als eingerichtet, sonst liefe die Anwendung mit der Vorlage los. - SetupWriter erhaelt vorhandene Inhalte. Eine appsettings.json fuehrt neben den abgefragten Werten meist Logging und anderes; sie neu zu erzeugen waere bequemer und verloere das - bei einer Neuinstallation ohne Backup. int und bool landen als JSON-Typ, nicht als Zeichenkette. Downloads - scripts/build_installer.ps1 baut selbstenthaltende Einzeldateien fuer win-x64, linux-x64 und linux-arm64 (rund 34 MB, .NET-Laufzeit inbegriffen). Ohne NativeAOT und ohne Trimming: Spectre.Console loest ueber Reflexion auf und braeche sonst erst beim Anwender. - scripts/upload_installer.py laedt sie nach /installer/. Getrennt von deploy.py, das client-dotnet bewusst ausklammert. - Bereich "Installer" auf der UpdateService-Seite mit Groessen, Pruefsummen und den wget-Befehlen; die Angaben stammen aus installer.json statt aus fest eingetragenem Text. - install.sh und install.ps1 laden, pruefen die Pruefsumme und legen ab - sie richten bewusst nichts selbst ein. Das Manifest wird BOM-frei geschrieben, sonst scheitert json_decode() daran. Enthaelt ausserdem die bislang nicht committete Arbeit an den RocketChat-Benachrichtigungen (Migrationen 010 und 011) sowie die Loesch- und Editierfunktion des UpdateService; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2388b5abe1
commit
c8f3e78635
+82
-9
@@ -84,20 +84,26 @@ final class Auth
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
|
||||
* Prueft Zugangsdaten, ohne eine Session anzufassen.
|
||||
*
|
||||
* Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht,
|
||||
* aber ein kurzlebiges Token statt eines Session-Cookies ausstellt.
|
||||
* Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie
|
||||
* hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden
|
||||
* zu haben.
|
||||
*
|
||||
* @return array<string,mixed>|null Der Benutzerdatensatz, oder null
|
||||
*/
|
||||
public static function login(PDO $db, string $username, string $password): bool
|
||||
public static function verifyCredentials(PDO $db, string $username, string $password): ?array
|
||||
{
|
||||
self::startSession();
|
||||
|
||||
$ip = Http::clientIp();
|
||||
|
||||
if (self::isLockedOut($db, $ip)) {
|
||||
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1');
|
||||
$stmt = $db->prepare('SELECT * FROM dc_users WHERE username = :u LIMIT 1');
|
||||
$stmt->execute([':u' => $username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
@@ -110,7 +116,18 @@ final class Auth
|
||||
|
||||
if (!$verified || !$found) {
|
||||
self::recordAttempt($db, $ip, $username, false);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter
|
||||
// darauf verweisen koennen - anmelden darf es sich nicht.
|
||||
if ((int)($user['disabled'] ?? 0) === 1) {
|
||||
self::recordAttempt($db, $ip, $username, false);
|
||||
Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [
|
||||
'username' => $username,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
|
||||
@@ -119,19 +136,75 @@ final class Auth
|
||||
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
|
||||
}
|
||||
|
||||
self::recordAttempt($db, $ip, $username, true);
|
||||
self::touchLastLogin($db, (int)$user['id']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolle eines Datensatzes aus dc_users.
|
||||
*
|
||||
* Faellt auf 'admin' zurueck, solange Migration 012 nicht gelaufen ist -
|
||||
* sonst waere nach dem Einspielen des Codes und vor der Migration niemand
|
||||
* mehr anmeldeberechtigt.
|
||||
*/
|
||||
public static function roleOf(?array $user): string
|
||||
{
|
||||
$role = is_array($user) ? (string)($user['role'] ?? 'admin') : 'admin';
|
||||
return $role === 'installer' ? 'installer' : 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
|
||||
*/
|
||||
public static function login(PDO $db, string $username, string $password): bool
|
||||
{
|
||||
self::startSession();
|
||||
|
||||
$user = self::verifyCredentials($db, $username, $password);
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu
|
||||
// suchen. Waere die Anmeldung hier erlaubt, brauchte man das Konto
|
||||
// gar nicht zu trennen: wer es auf einem Zielsystem eingibt, haette
|
||||
// damit auch Zugriff auf Tokens, Lizenzen und Monitore.
|
||||
if (self::roleOf($user) === 'installer') {
|
||||
Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [
|
||||
'username' => $username,
|
||||
'ip' => Http::clientIp(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION['dc_user_id'] = (int)$user['id'];
|
||||
$_SESSION['dc_username'] = (string)$user['username'];
|
||||
$_SESSION['dc_role'] = self::roleOf($user);
|
||||
$_SESSION['dc_login_at'] = time();
|
||||
$_SESSION['dc_last_seen'] = time();
|
||||
|
||||
self::recordAttempt($db, $ip, $username, true);
|
||||
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => $ip]);
|
||||
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function touchLastLogin(PDO $db, int $userId): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id');
|
||||
$stmt->execute([':id' => $userId]);
|
||||
} catch (\Throwable $e) {
|
||||
// Spalte fehlt (Migration 012 noch nicht gelaufen) - kein Grund,
|
||||
// die Anmeldung scheitern zu lassen.
|
||||
Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
self::startSession();
|
||||
|
||||
@@ -40,6 +40,11 @@ final class TokenManager
|
||||
'updateservice:read',
|
||||
'updateservice:publish',
|
||||
'tokens:provision',
|
||||
// Erstinstallation: Katalog lesen und eine Anwendung einrichten.
|
||||
// Diese Rechte traegt ausschliesslich das kurzlebige Token aus
|
||||
// /api/setup/v1/login - sie gehoeren nicht auf ein Dauertoken.
|
||||
'setup:catalog',
|
||||
'setup:install',
|
||||
];
|
||||
|
||||
private PDO $db;
|
||||
@@ -111,6 +116,82 @@ final class TokenManager
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein kurzlebiges Token ohne Elterntoken.
|
||||
*
|
||||
* Gedacht fuer die Erstinstallation: Der Installer meldet sich mit
|
||||
* Benutzername und Passwort an und bekommt dafuer ein Token, das nach
|
||||
* wenigen Minuten verfaellt. Bewusst kein Master-Token - es soll nichts
|
||||
* weitervererben koennen - und bewusst mit Ablauf, weil es auf einem
|
||||
* fremden Zielsystem im Speicher liegt.
|
||||
*
|
||||
* @param list<string> $scopes
|
||||
*/
|
||||
public function createEphemeralToken(
|
||||
string $name,
|
||||
array $scopes,
|
||||
int $ttlSeconds,
|
||||
?string $ownerIdentity = null
|
||||
): array {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.');
|
||||
}
|
||||
|
||||
$effectiveScopes = self::normalizeScopes($scopes, []);
|
||||
if ($effectiveScopes === []) {
|
||||
throw new InvalidArgumentException('Ein kurzlebiges Token ohne Rechte waere wirkungslos.');
|
||||
}
|
||||
|
||||
// Eine Obergrenze verhindert, dass aus einem Setup-Token durch einen
|
||||
// grosszuegigen Aufrufer ein Dauertoken wird.
|
||||
$ttlSeconds = max(60, min($ttlSeconds, 3600));
|
||||
|
||||
$tokenId = 'tok_s_' . bin2hex(random_bytes(8));
|
||||
$rawToken = 'dc_setup_' . bin2hex(random_bytes(24));
|
||||
$expires = gmdate('Y-m-d H:i:s', time() + $ttlSeconds);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name,
|
||||
project_slug, license_key, owner_type, owner_identity,
|
||||
type, scopes, environment, expires_at, created_at
|
||||
) VALUES (
|
||||
:id, NULL, :hash, NULL, :name,
|
||||
NULL, NULL, "host", :identity,
|
||||
"sub", :scopes, "all", :expires, UTC_TIMESTAMP()
|
||||
)
|
||||
');
|
||||
|
||||
// raw_token bleibt hier immer NULL, auch wenn die Konfiguration das
|
||||
// Mitschreiben erlaubt: ein Setup-Token wird einmal ausgeliefert und
|
||||
// muss nirgends nachschlagbar sein.
|
||||
$stmt->execute([
|
||||
':id' => $tokenId,
|
||||
':hash' => hash('sha256', $rawToken),
|
||||
':name' => $name,
|
||||
':identity' => self::nullIfEmpty($ownerIdentity),
|
||||
':scopes' => json_encode($effectiveScopes),
|
||||
':expires' => $expires,
|
||||
]);
|
||||
|
||||
Logger::info('Kurzlebiges Token erstellt', [
|
||||
'token_id' => $tokenId,
|
||||
'name' => $name,
|
||||
'scopes' => $effectiveScopes,
|
||||
'expires_at' => $expires,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token_id' => $tokenId,
|
||||
'raw_token' => $rawToken,
|
||||
'name' => $name,
|
||||
'type' => 'sub',
|
||||
'scopes' => $effectiveScopes,
|
||||
'expires_at' => $expires,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein Sub-Token aus einem gueltigen Master-Token.
|
||||
* Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden.
|
||||
@@ -353,6 +434,10 @@ final class TokenManager
|
||||
'updateservice:publish' => ['updateservice:read'],
|
||||
'watchdog:evaluate' => ['watchdog:read'],
|
||||
'watchdog:ping' => ['watchdog:read'],
|
||||
// Wer einrichten darf, muss den Katalog sehen und Releases lesen
|
||||
// koennen - sonst gibt es nichts zu installieren.
|
||||
'setup:install' => ['setup:catalog', 'updateservice:read'],
|
||||
'setup:catalog' => ['updateservice:read'],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user