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>
465 lines
20 KiB
PHP
465 lines
20 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Steuerung der Testinstanz.
|
|
*
|
|
* ACHTUNG: Diese Datei liegt NUR auf der Testinstanz. scripts/deploy_dev.py
|
|
* laedt sie eigens nach /testctl.php hoch; scripts/deploy.py schliesst das
|
|
* ganze Verzeichnis tests/ aus. Sie darf nie Teil eines Produktivstands
|
|
* werden - deshalb steht sie hier und nicht unter public/.
|
|
*
|
|
* Warum es sie ueberhaupt gibt: Ein automatisierter Testlauf braucht einen
|
|
* definierten Ausgangszustand. Ohne den prueft der zweite Lauf etwas anderes
|
|
* als der erste, und ein Fehlschlag sagt nichts mehr darueber aus, ob die
|
|
* Anwendung kaputt ist oder nur der Datenbestand gewachsen. Das Zuruecksetzen
|
|
* muss dort passieren, wo die Datenbank steht - also auf dem Server.
|
|
*
|
|
* Drei Schloesser sitzen davor, und jedes einzelne wuerde reichen:
|
|
* 1. Basic-Auth der Testinstanz (die Datei liegt nicht unter /api/)
|
|
* 2. testing.enabled = true UND app.env = 'test' in der config.php.
|
|
* Die Produktivkonfiguration hat beides nicht.
|
|
* 3. Der Shared Key der Instanz, per Header. Kein Session-Zugang: ein
|
|
* Browserfenster, das noch offen ist, soll keine Tabellen loeschen.
|
|
* Fuer die zerstoerenden Aktionen kommt der Name der Datenbank als
|
|
* Bestaetigung dazu - so laesst sich "reset" nicht versehentlich gegen die
|
|
* falsche Instanz absetzen.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\Config;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Core\Migrator;
|
|
use Deploymentcenter\Core\TokenManager;
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS']);
|
|
|
|
// --- Schloss 2: Diese Instanz muss sich selbst als Testinstanz ausweisen ---
|
|
if (Config::get('testing.enabled') !== true || Config::get('app.env') !== 'test') {
|
|
Http::fail(
|
|
403,
|
|
'not_a_test_instance',
|
|
'testctl steht nur auf einer Instanz mit testing.enabled = true und app.env = "test" zur Verfuegung.'
|
|
);
|
|
}
|
|
|
|
// --- Schloss 3: Shared Key, ohne Umweg ueber Sitzung oder Sub-Token ---
|
|
$sharedKey = (string)Config::get('security.shared_key', '');
|
|
$presented = (string)(Http::bearerToken() ?? '');
|
|
if ($sharedKey === '' || !hash_equals($sharedKey, $presented)) {
|
|
Http::fail(401, 'unauthorized', 'Shared Key erforderlich (Header X-Master-Token oder Authorization: Bearer).');
|
|
}
|
|
|
|
$db = Db::init();
|
|
$dbName = (string)Config::get('db.dbname', '');
|
|
$action = strtolower(trim((string)(Http::str('action') ?? 'status')));
|
|
|
|
/** Bestaetigung fuer alles, was Daten vernichtet. */
|
|
$requireConfirmation = static function () use ($dbName): void {
|
|
if ((string)(Http::str('confirm') ?? '') !== $dbName) {
|
|
Http::fail(
|
|
400,
|
|
'confirmation_required',
|
|
sprintf('Zur Bestaetigung muss confirm den Datenbanknamen enthalten ("%s").', $dbName)
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Alle Tabellen der Testdatenbank entfernen. */
|
|
$wipe = static function () use ($db): array {
|
|
$tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
|
if ($tables === []) {
|
|
return [];
|
|
}
|
|
|
|
// Fremdschluessel vorruebergehend aus: sonst haengt die Reihenfolge des
|
|
// Loeschens von der Reihenfolge der Tabellen ab, und die ist nicht stabil.
|
|
$db->exec('SET FOREIGN_KEY_CHECKS = 0');
|
|
try {
|
|
foreach ($tables as $table) {
|
|
$db->exec('DROP TABLE IF EXISTS `' . str_replace('`', '', (string)$table) . '`');
|
|
}
|
|
} finally {
|
|
$db->exec('SET FOREIGN_KEY_CHECKS = 1');
|
|
}
|
|
|
|
return array_map('strval', $tables);
|
|
};
|
|
|
|
/** Zustand der Instanz - der Blick, den ein Testlauf vor und nach sich wirft. */
|
|
$status = static function () use ($db, $dbName): array {
|
|
$tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
|
$counts = [];
|
|
foreach ($tables as $table) {
|
|
$name = (string)$table;
|
|
try {
|
|
$counts[$name] = (int)$db->query('SELECT COUNT(*) FROM `' . str_replace('`', '', $name) . '`')->fetchColumn();
|
|
} catch (Throwable $e) {
|
|
$counts[$name] = null;
|
|
}
|
|
}
|
|
|
|
$migrations = [];
|
|
if (in_array('dc_migrations', array_map('strval', $tables), true)) {
|
|
$migrations = $db->query('SELECT version FROM dc_migrations ORDER BY version')
|
|
->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
|
}
|
|
|
|
return [
|
|
'database' => $dbName,
|
|
'php' => PHP_VERSION,
|
|
'app_version' => Config::get('app.version'),
|
|
'debug' => Config::isDebug(),
|
|
'tables' => count($tables),
|
|
'rows' => $counts,
|
|
'migrations' => array_map('strval', $migrations),
|
|
'log_dir' => is_writable(DC_VAR . '/log'),
|
|
'signing_key' => is_readable((string)Config::get('security.release_private_key', '')),
|
|
];
|
|
};
|
|
|
|
switch ($action) {
|
|
// ------------------------------------------------------------------
|
|
case 'status':
|
|
Http::ok($status());
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
case 'wipe':
|
|
$requireConfirmation();
|
|
Http::ok(['dropped' => $wipe(), 'state' => $status()]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
case 'migrate':
|
|
$result = Migrator::migrate($db);
|
|
if ($result['failed'] !== null) {
|
|
Http::fail(500, 'migration_failed', 'Migration abgebrochen.', null, $result);
|
|
}
|
|
Http::ok(['migration' => $result, 'state' => $status()]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Ausgangszustand herstellen: leere Datenbank, frisches Schema, ein
|
|
// Administrator und ein Master-Token mit allen Rechten. Beides gibt der
|
|
// Aufruf zurueck - der Testlauf soll sich seine Zugaenge selbst holen
|
|
// koennen und nicht darauf warten, dass jemand im WebUI etwas anklickt.
|
|
// ------------------------------------------------------------------
|
|
case 'reset':
|
|
$requireConfirmation();
|
|
|
|
$dropped = $wipe();
|
|
|
|
$result = Migrator::migrate($db);
|
|
if ($result['failed'] !== null) {
|
|
Http::fail(500, 'migration_failed', 'Migration nach dem Zuruecksetzen abgebrochen.', null, $result);
|
|
}
|
|
|
|
$adminUser = (string)(Http::str('admin_user') ?? 'admin');
|
|
$adminPass = (string)(Http::str('admin_pass') ?? 'test02763!');
|
|
|
|
// Registrierte Hardwarekeys des alten Kontos mitnehmen. Ohne das
|
|
// blieben verwaiste Zeilen stehen, und weil credential_id geraeteweit
|
|
// eindeutig ist, liesse sich derselbe Yubikey danach nicht wieder
|
|
// registrieren - er gehoerte noch einer Konto-ID, die es nicht mehr
|
|
// gibt. Ein Zuruecksetzen, nach dem der Hardwarekey verbrannt ist,
|
|
// waere schlimmer als keines.
|
|
try {
|
|
$db->prepare('
|
|
DELETE FROM dc_user_credentials
|
|
WHERE user_id IN (SELECT id FROM dc_users WHERE username = :u)
|
|
')->execute([':u' => $adminUser]);
|
|
} catch (Throwable $e) {
|
|
// Vor Migration 019 gibt es die Tabelle nicht.
|
|
}
|
|
|
|
// Die Hardwarekey-Regel gehoert ebenfalls zurueckgesetzt. Sie steht
|
|
// in dc_settings und ueberlebt das Loeschen der Konten sonst - ein
|
|
// Lauf, der auf "strict" endet, liesse den naechsten an einer Regel
|
|
// scheitern, die er gar nicht prueft. Ausgetragen statt auf "off"
|
|
// gesetzt: Fehlt der Eintrag, liest HardwarePolicy die Vorgabe, und
|
|
// das ist genau derselbe Zustand wie bei einer frischen Anlage.
|
|
$db->prepare("
|
|
DELETE FROM dc_settings
|
|
WHERE skey IN ('webauthn_hardware_policy', 'webauthn_aaguid_allowlist')
|
|
")->execute();
|
|
|
|
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]);
|
|
$db->prepare('
|
|
INSERT INTO dc_users (username, password_hash, created_at)
|
|
VALUES (:u, :h, UTC_TIMESTAMP())
|
|
')->execute([':u' => $adminUser, ':h' => password_hash($adminPass, PASSWORD_DEFAULT)]);
|
|
|
|
// Rolle setzen, falls die Spalte existiert (Migration 012).
|
|
try {
|
|
$db->prepare('UPDATE dc_users SET role = "admin" WHERE username = :u')->execute([':u' => $adminUser]);
|
|
} catch (Throwable $e) {
|
|
// Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun.
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Installationskonto
|
|
// ------------------------------------------------------------------
|
|
// Seit 2.9.3 nimmt /api/setup/v1/login nur noch Konten der Rolle
|
|
// "installer" an. Die Setup-Testfaelle benutzten bis dahin das
|
|
// Administratorkonto - und pruefen damit gerade den Weg, der jetzt
|
|
// verschlossen ist. Ein eigenes Konto hier anzulegen ist nicht bloss
|
|
// Testkosmetik: Es bildet ab, wie der Weg in der Produktion gedacht
|
|
// ist, und die Testfaelle liefen bisher auf einer Abkuerzung.
|
|
$installerUser = $adminUser . '-installer';
|
|
$installerPass = bin2hex(random_bytes(12));
|
|
|
|
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $installerUser]);
|
|
$db->prepare('
|
|
INSERT INTO dc_users (username, password_hash, created_at)
|
|
VALUES (:u, :h, UTC_TIMESTAMP())
|
|
')->execute([':u' => $installerUser, ':h' => password_hash($installerPass, PASSWORD_DEFAULT)]);
|
|
|
|
$installerOk = false;
|
|
try {
|
|
$db->prepare('UPDATE dc_users SET role = "installer" WHERE username = :u')
|
|
->execute([':u' => $installerUser]);
|
|
$installerOk = true;
|
|
} catch (Throwable $e) {
|
|
// Ohne Rollenspalte (vor Migration 012) gibt es keine Installer.
|
|
}
|
|
|
|
$manager = new TokenManager($db);
|
|
$master = $manager->createMasterToken(
|
|
'Testlauf (automatisch)',
|
|
null,
|
|
null,
|
|
'custom',
|
|
'testkit',
|
|
['*'],
|
|
'all'
|
|
);
|
|
|
|
Http::ok([
|
|
'dropped' => $dropped,
|
|
'migration' => $result,
|
|
'admin' => ['username' => $adminUser, 'password' => $adminPass],
|
|
'installer' => $installerOk
|
|
? ['username' => $installerUser, 'password' => $installerPass]
|
|
: null,
|
|
'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']],
|
|
'state' => $status(),
|
|
]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Werkbank-Token (owner_type = dev_agent), an ein Projekt gebunden.
|
|
//
|
|
// Es gibt keine API, so eines anzulegen - in der Produktion geschieht das
|
|
// von Hand in der Token-Verwaltung, und das ist auch richtig so. Fuer den
|
|
// Testlauf fehlte damit aber ausgerechnet der Tokentyp, den der
|
|
// MCP-Adapter und dc-testkit benutzen. Die Folge war ein Fehler, den
|
|
// niemand fand: Beim Lesen laesst projectFilter() ein Werkbank-Token
|
|
// bewusst durch, also war der Projektbezug leer - und
|
|
// roadmap.php?action=render lehnte mit "missing_project" ab, obwohl das
|
|
// Token sehr wohl an ein Projekt gebunden ist.
|
|
//
|
|
// Ein Tokentyp, den kein Testfall benutzt, ist ein Tokentyp, dessen
|
|
// Sonderwege niemand prueft.
|
|
// ------------------------------------------------------------------
|
|
case 'workbench_token': {
|
|
$slug = (string)(Http::str('project_slug') ?? '');
|
|
if ($slug === '') {
|
|
Http::fail(400, 'missing_project', 'project_slug wird gebraucht.');
|
|
}
|
|
|
|
// owner_type und scopes sind seit 2.8.1 waehlbar.
|
|
//
|
|
// Grund: Zwei Befunde lassen sich nur mit einem projektgebundenen
|
|
// Token ueberhaupt ausloesen, und zwar mit VERSCHIEDENEN Sorten davon.
|
|
//
|
|
// * #404 (Monitor ohne Projekt) braucht ein gebundenes Token mit
|
|
// watchdog:ping. Die feste Scope-Liste hier hatte nur
|
|
// watchdog:read - pingen konnte das Fixture also nicht.
|
|
// * #131 (Leseweg verengt still) tritt NUR bei einem gebundenen
|
|
// NICHT-Werkbank-Token auf: Ein dev_agent faellt beim Lesen
|
|
// absichtlich nicht unter die Schranke, wird also nie verengt.
|
|
//
|
|
// Beides war mit dem alten, fest verdrahteten Fixture nicht
|
|
// darstellbar - und beides ist deshalb jahrelang durchgerutscht.
|
|
$ownerType = (string)(Http::str('owner_type') ?? 'dev_agent');
|
|
|
|
$scopes = Http::input('scopes');
|
|
if (!is_array($scopes) || $scopes === []) {
|
|
$scopes = ['bugtracker:manage', 'supervisor:manage', 'watchdog:read', 'license:dev'];
|
|
}
|
|
|
|
$manager = new TokenManager($db);
|
|
$token = $manager->createMasterToken(
|
|
(string)(Http::str('name') ?? 'Werkbank (Testlauf)'),
|
|
$slug,
|
|
null,
|
|
$ownerType,
|
|
'testkit',
|
|
array_values(array_map('strval', $scopes)),
|
|
'all'
|
|
);
|
|
|
|
Http::ok([
|
|
'token_id' => $token['token_id'],
|
|
'raw_token' => $token['raw_token'],
|
|
'project_slug' => $slug,
|
|
'owner_type' => $ownerType,
|
|
'scopes' => $scopes,
|
|
]);
|
|
break;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Master-Token nachtraeglich aendern.
|
|
//
|
|
// Der Weg dorthin ist die Oberflaeche - eine API gibt es dafuer bewusst
|
|
// nicht, denn ein Token soll seine eigenen Rechte nicht erweitern koennen.
|
|
// Damit waere die Aenderung aber ungeprueft geblieben, und gerade hier
|
|
// zaehlt eine Zusage: dass der Tokenwert unveraendert bleibt. Sonst
|
|
// muessten alle Maschinen neu bestueckt werden, und das Versprechen der
|
|
// Funktion waere gebrochen.
|
|
// ------------------------------------------------------------------
|
|
case 'update_master_token': {
|
|
$tokenId = (string)(Http::str('token_id') ?? '');
|
|
if ($tokenId === '') {
|
|
Http::fail(400, 'missing_token_id', 'token_id wird gebraucht.');
|
|
}
|
|
|
|
$felder = Http::body();
|
|
unset($felder['action'], $felder['token_id']);
|
|
|
|
try {
|
|
$ok = (new TokenManager($db))->updateMasterToken($tokenId, $felder);
|
|
} catch (\InvalidArgumentException $e) {
|
|
Http::fail(400, 'invalid_update', $e->getMessage());
|
|
}
|
|
|
|
if (!$ok) {
|
|
Http::fail(404, 'not_found', sprintf('Master-Token "%s" existiert nicht.', $tokenId));
|
|
}
|
|
|
|
$stmt = $db->prepare('SELECT token_id, name, project_slug, owner_type, owner_identity,
|
|
scopes, environment, expires_at, type
|
|
FROM dc_tokens WHERE token_id = :id');
|
|
$stmt->execute([':id' => $tokenId]);
|
|
|
|
Http::ok(['token' => $stmt->fetch() ?: null]);
|
|
break;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Signierschluessel fuer den Updateservice. Er wird auf der Testinstanz
|
|
// erzeugt und nie von der Produktion kopiert - ein Testlauf darf mit dem
|
|
// Schluessel, mit dem echte Releases signiert werden, nichts zu tun haben.
|
|
// ------------------------------------------------------------------
|
|
case 'genkey':
|
|
$target = (string)Config::get('security.release_private_key', DC_ROOT . '/config/release-signing.pem');
|
|
|
|
if (is_readable($target) && (string)(Http::str('force') ?? '') !== '1') {
|
|
Http::ok(['created' => false, 'path' => basename($target), 'message' => 'Schluessel vorhanden.']);
|
|
}
|
|
|
|
$res = openssl_pkey_new([
|
|
'private_key_bits' => 4096,
|
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
]);
|
|
if ($res === false) {
|
|
Http::fail(500, 'keygen_failed', 'Schluesselerzeugung fehlgeschlagen: ' . openssl_error_string());
|
|
}
|
|
|
|
$pem = '';
|
|
openssl_pkey_export($res, $pem);
|
|
if (file_put_contents($target, $pem) === false) {
|
|
Http::fail(500, 'keygen_failed', 'Schluessel konnte nicht geschrieben werden.');
|
|
}
|
|
@chmod($target, 0600);
|
|
|
|
$details = openssl_pkey_get_details($res);
|
|
Http::ok([
|
|
'created' => true,
|
|
'path' => basename($target),
|
|
'bits' => $details['bits'] ?? null,
|
|
'public' => $details['key'] ?? null,
|
|
]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Protokoll lesen. Ohne diese Aktion endet jeder fehlgeschlagene Testlauf
|
|
// bei "500" und dem Ratespiel, was dahinter stand - die Logdatei liegt
|
|
// unter var/log/ und ist per .htaccess zu Recht gesperrt.
|
|
// ------------------------------------------------------------------
|
|
case 'log':
|
|
$lines = max(1, min(500, (int)(Http::str('lines') ?? 80)));
|
|
$dir = DC_VAR . '/log';
|
|
$files = glob($dir . '/*.log') ?: [];
|
|
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
|
|
|
|
$out = [];
|
|
foreach (array_slice($files, 0, 3) as $file) {
|
|
$content = file($file, FILE_IGNORE_NEW_LINES) ?: [];
|
|
$out[basename($file)] = array_slice($content, -$lines);
|
|
}
|
|
Http::ok(['log' => $out]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
case 'clearlog':
|
|
foreach (glob(DC_VAR . '/log/*.log') ?: [] as $file) {
|
|
file_put_contents($file, '');
|
|
}
|
|
Http::ok(['cleared' => true]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
case 'unlock_user':
|
|
$username = (string)(Http::str('username') ?? 'admin');
|
|
$stmt = $db->prepare('UPDATE dc_users SET failed_login_count = 0, locked_until = NULL WHERE username = :u');
|
|
$stmt->execute([':u' => $username]);
|
|
Http::ok(['unlocked' => $username, 'affected' => $stmt->rowCount()]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Hardwarekeys eines Kontos entfernen, ohne die Datenbank zu leeren.
|
|
//
|
|
// Warum eigens und nicht ueber "reset": Ein registrierter zweiter Faktor
|
|
// macht das Konto fuer einen automatisierten Lauf unbenutzbar - Passwort
|
|
// allein fuehrt nur noch in den Zwischenzustand. Ein Testlauf, der einen
|
|
// Schluessel aufnimmt, sperrt damit den naechsten aus. Der ganze
|
|
// Datenbestand muss dafuer aber nicht weichen; "reset" wuerde Roadmap,
|
|
// Meldungen und Lizenzen gleich mit loeschen, und das ist ein hoher Preis
|
|
// fuer das Aufraeumen einer einzigen Tabelle.
|
|
//
|
|
// Nebenbei ist es der Weg zurueck, wenn man sich auf der Testinstanz mit
|
|
// einer schaerferen Regel selbst ausgesperrt hat. Auf der Produktion gibt
|
|
// es diese Datei nicht - dort ist der Notfalltoken der Weg.
|
|
case 'clear_credentials': {
|
|
$username = (string)(Http::str('username') ?? 'admin');
|
|
|
|
try {
|
|
$stmt = $db->prepare('
|
|
DELETE FROM dc_user_credentials
|
|
WHERE user_id IN (SELECT id FROM dc_users WHERE username = :u)
|
|
');
|
|
$stmt->execute([':u' => $username]);
|
|
$entfernt = $stmt->rowCount();
|
|
} catch (Throwable $e) {
|
|
Http::fail(500, 'no_table', 'dc_user_credentials fehlt - Migration 019 steht aus.');
|
|
}
|
|
|
|
Http::ok(['username' => $username, 'removed' => $entfernt]);
|
|
break;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
default:
|
|
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
|
'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token',
|
|
'update_master_token', 'genkey', 'log', 'clearlog', 'unlock_user',
|
|
'clear_credentials'],
|
|
]);
|
|
}
|