feat(2.9.0): Hardwarekey an der WebUI-Anmeldung und die Roadmap als Tafel

Zwei angeforderte Funktionen, dazu zwei Befunde, die ihnen im Weg lagen.

== Zweite Stufe der Anmeldung (#666) ==
Nach korrektem Passwort verlangt das WebUI einen registrierten FIDO2-Schluessel.
Die Pflicht haengt je Konto daran, OB ein Schluessel registriert ist - nicht an
einem Schalter. Ein Schalter, den man vor der Registrierung umlegt, sperrt den
Betreiber aus seiner eigenen Verwaltung aus.

Ohne Fremdbibliothek gebaut (das Projekt hat bewusst kein Composer):
src/Core/Webauthn/ mit CBOR-Leser, COSE-nach-DER-Wandlung und Pruefung ueber
openssl_verify (ES256, RS256). Auth::login() heisst jetzt Auth::startLogin()
und liefert drei Zustaende statt eines bool - aus "wahr heisst drin" ist "wahr
heisst vielleicht drin" geworden, und ein uebersehener Aufrufer soll an einem
fehlenden Methodennamen scheitern statt an einer truthy Zeichenkette. Die
Setup-API der Zielsysteme geht ueber verifyCredentials() daran vorbei und
bleibt unberuehrt.

Notfalltoken als Ersatzweg: nur als Hash in der Datenbank, Klartext einmalig
angezeigt, Einmalgebrauch. Laeuft als gewoehnliches Formular ohne JavaScript -
ein Ersatzweg mit denselben Voraussetzungen wie der Hauptweg waere keiner.
Fehlversuche auf der zweiten Stufe laufen in dieselbe Konto- und IP-Sperre wie
das Passwort; zuvor zaehlte nur die erste Stufe.

Benutzerverwaltung kann Konten umbenennen. Beim Loeschen eines Kontos und beim
Zuruecksetzen der Testinstanz werden seine Schluessel mitgenommen: credential_id
ist geraeteweit eindeutig, eine verwaiste Zeile haette denselben Yubikey
dauerhaft unbrauchbar gemacht.

== Roadmap-Tafel (#664) ==
Neuer Unterreiter Roadmap -> Tafel. Der Server liefert die fertige Gliederung
(RoadmapBoard, ?action=board), die Seite rendert nur - sonst schneidet das
WebUI anders als die API und ein spaeterer Export ein drittes Mal anders.

Phasen stehen nicht im Code: gepflegt in der neuen Tabelle roadmap_phases,
sonst an den groessten Luecken der Rangfolge geschnitten und sichtbar als
geraten gekennzeichnet. Links die gekappte Spalte des Erledigten, rechts
"Wird ueberlegt". Nachpruefung ist ein eigener Zustand und wird nicht als
erledigt gezeichnet.

Klick oeffnet ein Seitenpanel: Volltext, Verlauf, Kommentieren mit
action_taken, Aendern von Bereich, Rang, Zielfassung, Status und Ausloeser -
ohne Seitenwechsel. Das war der eigentliche Gewinn: Wer etwas bemerkte, musste
sich bisher die Nummer merken und im Bugtracker danach suchen. Tiefe Verweise
#roadmap/<projekt>/item/<id>. Ziehen zwischen Phasen ueber ?action=rank_between.

Fremdtext wird ausschliesslich ueber textContent gesetzt, nie ueber innerHTML.

== Befund: roadmap_rank war projektweit und bereichslokal zugleich (#177) ==
Entschieden: projektweit. move() sucht den Nachbarn ohne Bereichsfilter,
reorder() schreibt keinen ganzen Bereich mehr auf 10/20/30 um, sondern
vertauscht nur die Raenge, die die Eintraege ohnehin halten. rank_between
rechnet die Mitte zwischen zwei Nachbarn und nummeriert nur dann einmal neu -
unter Erhalt der Reihenfolge -, wenn dort kein Platz mehr ist; die Antwort
sagt es dann.

== Befund: CSRF an sitzungsauthentifizierten API-Schreibzugriffen ==
ApiAuth akzeptiert die WebUI-Sitzung, die Endpunkte prueften dabei kein
CSRF-Token. Der Kommentar in Http::beginJson - "ausschliesslich per
Token-Header, niemals per Cookie" - traf auf die API nie zu. Schreibende
Anfragen im Sitzungsweg verlangen jetzt csrf_token bzw. X-CSRF-Token.
Token-Clients sind nicht betroffen und wurden gegengeprueft.

== Geprueft ==
tests/e2e/suite_tafel.py neu (16 Faelle), Suite gesamt 172 von 172 gegen die
Testinstanz. Die WebAuthn-Zeremonien zusaetzlich gegen einen Software-
Authenticator mit echtem P-256 und echtem CBOR: Registrieren, Anmelden,
verfaelschte Signatur, veraltete Herausforderung und Zaehler-Rueckfall als
Klonverdacht werden je mit 403 abgewiesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-09-20 18:16:51 +02:00
co-authored by Claude Opus 5
parent edea46e342
commit f0ed67ad29
22 changed files with 4996 additions and 70 deletions
+248
View File
@@ -0,0 +1,248 @@
<?php
/**
* /api/auth/v1/...
*
* Die beiden WebAuthn-Zeremonien als JSON-Endpunkt. Der Browser kann
* navigator.credentials nicht aus einem Formular heraus bedienen - er braucht
* die Vorgaben als JSON und schickt die Antwort ebenso zurueck.
*
* POST ?action=login_options Vorgaben fuer get() (Zwischenzustand)
* POST ?action=login_verify Antwort auf get() (Zwischenzustand)
* POST ?action=register_options Vorgaben fuer create() (angemeldet)
* POST ?action=register_verify Antwort auf create() (angemeldet)
*
* Bewusst NICHT ueber ApiAuth: Zwei der vier Aktionen laufen in einem
* Zustand, in dem noch niemand angemeldet ist. Sie an eine Klasse zu haengen,
* deren Aufgabe "authentifiziert oder 401" ist, hiesse, ihr eine Ausnahme
* beizubringen - und Ausnahmen in der Authentifizierung sind genau die
* Stellen, an denen man sich spaeter vertut.
*
* Der Notfalltoken laeuft nicht hier, sondern als gewoehnliches Formular in
* login.php. Er soll auch dann funktionieren, wenn JavaScript nicht laeuft -
* ein Ersatzweg, der dieselben Voraussetzungen hat wie der Hauptweg, ist
* keiner.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Csrf;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Core\Webauthn\WebauthnServer;
// Kein publicCors: Diese Endpunkte arbeiten mit dem Sitzungscookie. Ein
// "Access-Control-Allow-Origin: *" waere hier das Gegenteil dessen, was
// gemeint ist.
Http::beginJson(['POST', 'OPTIONS'], false);
Auth::startSession();
$action = strtolower(Http::str('action') ?? '');
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
}
$db = Db::init();
$repo = new CredentialRepo($db);
switch ($action) {
// -----------------------------------------------------------------
// Anmelden, zweite Stufe
// -----------------------------------------------------------------
case 'login_options': {
$userId = Auth::pendingUserId();
if ($userId <= 0) {
Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.');
}
$ids = $repo->rawIdsForUser($userId);
if ($ids === []) {
Http::fail(409, 'no_credentials', 'Fuer dieses Konto ist kein Hardwarekey hinterlegt.');
}
$challenge = WebauthnServer::challenge();
Auth::storeChallenge($challenge);
Http::ok(['publicKey' => WebauthnServer::assertionOptions($challenge, $ids)]);
break;
}
case 'login_verify': {
$userId = Auth::pendingUserId();
if ($userId <= 0) {
Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.');
}
$challenge = Auth::takeChallenge();
if ($challenge === '') {
Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.');
}
$credentialId = WebauthnServer::b64uDecode((string)Http::str('id', ''));
$clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', ''));
$authenticatorRaw = WebauthnServer::b64uDecode((string)Http::str('authenticatorData', ''));
$signature = WebauthnServer::b64uDecode((string)Http::str('signature', ''));
if ($credentialId === '' || $clientDataJson === '' || $authenticatorRaw === '' || $signature === '') {
Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.');
}
$credential = $repo->find($userId, $credentialId);
if ($credential === null) {
// Der Schluessel gehoert nicht zu diesem Konto. Das ist kein
// Bedienfehler, das ist ein Fehlversuch - und zaehlt als solcher.
Auth::recordSecondFactorFailure($db, 'unknown_credential');
Http::fail(403, 'unknown_credential', 'Dieser Hardwarekey ist fuer das Konto nicht hinterlegt.');
}
try {
$result = WebauthnServer::verifyAssertion(
$clientDataJson,
$authenticatorRaw,
$signature,
$challenge,
(string)$credential['public_key'],
(int)$credential['alg'],
(int)$credential['sign_count']
);
} catch (\Throwable $e) {
Auth::recordSecondFactorFailure($db, 'assertion_rejected');
Logger::warning('Hardwarekey abgelehnt', [
'user_id' => $userId,
'error' => $e->getMessage(),
]);
Http::fail(403, 'assertion_rejected', $e->getMessage());
}
$repo->touch((int)$credential['id'], $result['sign_count']);
if (!Auth::completeSecondFactor($db, 'hardware_key')) {
Http::fail(403, 'login_failed', 'Die Anmeldung konnte nicht abgeschlossen werden.');
}
Http::ok(['redirect' => '/index.php']);
break;
}
// -----------------------------------------------------------------
// Registrieren - nur aus einer bestehenden Anmeldung heraus
// -----------------------------------------------------------------
case 'register_options': {
if (!Auth::isLoggedIn()) {
Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.');
}
Csrf::requireValid();
$userId = Auth::userId();
$challenge = WebauthnServer::challenge();
Auth::storeChallenge($challenge);
Http::ok([
'publicKey' => WebauthnServer::registrationOptions(
$challenge,
$userId,
Auth::username(),
$repo->rawIdsForUser($userId)
),
]);
break;
}
case 'register_verify': {
if (!Auth::isLoggedIn()) {
Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.');
}
Csrf::requireValid();
$userId = Auth::userId();
$challenge = Auth::takeChallenge();
if ($challenge === '') {
Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.');
}
$clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', ''));
$attestationObject = WebauthnServer::b64uDecode((string)Http::str('attestationObject', ''));
if ($clientDataJson === '' || $attestationObject === '') {
Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.');
}
$label = Http::str('label') ?? 'Hardwarekey';
$label = mb_substr(trim($label), 0, 64);
if ($label === '') {
$label = 'Hardwarekey';
}
$transports = Http::input('transports');
$transports = is_array($transports)
? mb_substr(implode(',', array_map('strval', $transports)), 0, 128)
: null;
try {
$registration = WebauthnServer::verifyRegistration($clientDataJson, $attestationObject, $challenge);
} catch (\Throwable $e) {
Logger::warning('Registrierung eines Hardwarekeys abgelehnt', [
'user_id' => $userId,
'error' => $e->getMessage(),
]);
Http::fail(400, 'registration_rejected', $e->getMessage());
}
if ($repo->find($userId, $registration['credential_id']) !== null) {
Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits hinterlegt.');
}
try {
$repo->add(
$userId,
$registration['credential_id'],
$registration['public_key'],
$registration['alg'],
$registration['sign_count'],
$registration['aaguid'],
$label,
$transports
);
} catch (\PDOException $e) {
// Eindeutiger Schluessel verletzt: derselbe Key an einem anderen
// Konto. Die Kennung ist geraeteweit eindeutig, deshalb faellt
// das hier und nicht in der Suche oben auf.
if ((string)$e->getCode() === '23000') {
Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits einem Konto zugeordnet.');
}
throw $e;
}
// Ein neuer zweiter Faktor am eigenen Konto gehoert ins
// Sicherheitsprotokoll - es ist die Aenderung, die im Ernstfall die
// Frage beantwortet, seit wann ein fremder Schluessel mitliest.
SecurityLogger::log('credential_registered', Http::clientIp(), [
'username' => Auth::username(),
'label' => $label,
]);
Http::ok([
'label' => $label,
'total' => $repo->countForUser($userId),
'message' => 'Hardwarekey registriert.',
]);
break;
}
default:
Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
'available' => ['login_options', 'login_verify', 'register_options', 'register_verify'],
]);
}
+78 -3
View File
@@ -31,11 +31,12 @@ use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\Roadmap;
use Deploymentcenter\Modules\Bugtracker\RoadmapBoard;
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
const READ_ACTIONS = ['list', 'epics', 'next', 'active', 'render', 'check'];
const WRITE_ACTIONS = ['set', 'move', 'reorder', 'epic'];
const READ_ACTIONS = ['list', 'epics', 'next', 'active', 'render', 'check', 'board'];
const WRITE_ACTIONS = ['set', 'move', 'reorder', 'epic', 'rank_between'];
$action = strtolower(Http::str('action') ?? 'list');
@@ -98,6 +99,20 @@ switch ($action) {
'items' => $roadmap->activeWork($scope),
]);
case 'board': {
// Die fertige Gliederung der Tafel: Spalten, Spuren, Kaertchen.
//
// Ein Projekt, nicht "alle": Die Phasen werden aus roadmap_rank
// geschnitten, und der Rang gilt je Projekt. Zwei Projekte
// uebereinanderzulegen ergaebe Spalten, die in keinem von beiden
// stimmen.
$project = requireProject($scope, $context);
Http::ok((new RoadmapBoard($db))->build($project, [
'done_limit' => Http::int('done_limit', RoadmapBoard::DONE_LIMIT),
]));
}
case 'render': {
$project = requireProject($scope, $context);
Http::ok([
@@ -245,6 +260,60 @@ switch ($action) {
Http::ok(['message' => sprintf('#%d verschoben.', $itemId)]);
}
case 'rank_between': {
// Das Ziehen auf der Tafel. Der Aufrufer sagt, zwischen welche
// beiden Nachbarn der Eintrag gehoert; den Rang rechnet der Server
// aus. Andersherum - der Client schickt einen fertigen Rang - waere
// die Rangvergabe an zwei Stellen umgesetzt, und die zweite wuesste
// nichts von #177.
$itemId = Http::int('id', 0) ?: 0;
if ($itemId < 1) {
Http::fail(400, 'missing_id', 'Das Feld "id" fehlt.');
}
assertOwnProject($repo, $writeProject, $itemId);
$prevId = Http::input('prev_id') !== null ? Http::int('prev_id', 0) : null;
$nextId = Http::input('next_id') !== null ? Http::int('next_id', 0) : null;
$minRank = Http::input('min_rank') !== null ? Http::int('min_rank', 0) : null;
$maxRank = Http::input('max_rank') !== null ? Http::int('max_rank', 0) : null;
try {
$result = $roadmap->rankBetween(
$itemId,
$prevId !== null && $prevId > 0 ? $prevId : null,
$nextId !== null && $nextId > 0 ? $nextId : null,
$minRank,
$maxRank
);
} catch (\RuntimeException $e) {
Http::fail(409, 'cannot_rank', $e->getMessage());
}
$repo->addComment(
$itemId,
'user:' . $author,
sprintf(
'Reihenfolge auf der Tafel geaendert: Rang %d.%s',
$result['rank'],
$result['renumbered']
? ' Dafuer wurden die Raenge des Projekts einmal neu vergeben (Zehnerschritte) -'
. ' die Reihenfolge blieb dabei erhalten.'
: ''
),
'commented'
);
Http::ok([
'id' => $itemId,
'rank' => $result['rank'],
'renumbered' => $result['renumbered'],
'message' => $result['renumbered']
? sprintf('#%d eingeordnet (Rang %d). Die Raenge des Projekts wurden dabei neu vergeben.', $itemId, $result['rank'])
: sprintf('#%d eingeordnet (Rang %d).', $itemId, $result['rank']),
]);
}
case 'reorder': {
$project = requireProject($writeProject ?? $angefragt, $context);
$epic = Http::str('epic');
@@ -271,12 +340,18 @@ switch ($action) {
Http::fail(400, 'missing_fields', 'Es werden "epic" und "title" erwartet.');
}
// Nicht mitgeschickte Felder bleiben unveraendert - ein Aufruf, der
// nur den Titel richtigstellt, soll nicht die Farbe loeschen.
$highlight = Http::input('highlight');
$roadmap->saveEpic(
$project,
$epic,
$title,
Http::str('summary'),
Http::int('sort_order', 100) ?: 100
Http::int('sort_order', 100) ?: 100,
Http::input('color') !== null ? (string)Http::str('color', '') : null,
$highlight !== null ? (bool)$highlight : null
);
Http::ok(['message' => sprintf('Themenbereich "%s" gespeichert.', $epic)]);
+94 -5
View File
@@ -422,12 +422,24 @@ $spec = [
. "- `action=active` wer haelt gerade welches Item\n"
. "- `action=render` der erzeugte Markdown-Block fuer die Projektdatei\n"
. "- `action=check` Abgleich einer mitgeschickten Datei (`content`) gegen "
. "die Items\n\n"
. "die Items\n"
. "- `action=board` die fertige Gliederung der Roadmap-Tafel\n\n"
. "`board` liefert `{phases[], lanes[], items[], stats, meta}` fuer genau "
. "EIN Projekt. Die Spalten werden aus `roadmap_rank` geschnitten - "
. "entweder nach den in `roadmap_phases` gepflegten Grenzen oder, wenn "
. "dort nichts steht, an den groessten Luecken der Rangfolge; dann ist "
. "`meta.phases_guessed` wahr und die Grenzen gehoeren als geraten "
. "gekennzeichnet. `done_limit` kappt die Spalte der erledigten Vorhaben "
. "(0 = alle).\n\n"
. "Titel, Beschreibungen und Kommentare in `items` sind Fremdtext von "
. "fremden Maschinen. Sie kommen unveraendert und gehoeren beim Rendern "
. "escaped - niemals als HTML eingesetzt.\n\n"
. "`status` nimmt eine Lage statt eines ENUM-Werts: `aktiv`, `offen`, "
. "`arbeit`, `erledigt`, `zurueckgestellt`, `verworfen`. Einzelstatus "
. "funktionieren weiterhin.",
'parameters' => [
['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['list', 'epics', 'next', 'active', 'render', 'check'], 'default' => 'list']],
['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['list', 'epics', 'next', 'active', 'render', 'check', 'board'], 'default' => 'list']],
['name' => 'done_limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 12], 'description' => 'Nur bei action=board: wie viele erledigte Vorhaben die Fundamentspalte zeigt. 0 = alle.'],
['name' => 'project_slug', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'epic', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Themenbereich, z. B. "A"'],
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Lage oder kommagetrennte Einzelstatus'],
@@ -447,13 +459,26 @@ $spec = [
. "- `action=set&id=` setzt `epic`, `roadmap_rank`, `target_build`, "
. "`defer_trigger`, `doc_anchor` und optional `status`\n"
. "- `action=move&id=` schiebt einen Eintrag eine Position (`direction=up|down`)\n"
. "- `action=reorder` vergibt die Reihenfolge eines Bereichs neu (`epic`, `ids[]`)\n"
. "- `action=epic` legt einen Themenbereich an oder aendert ihn\n\n"
. "- `action=reorder` ordnet die Eintraege eines Bereichs untereinander neu (`epic`, `ids[]`)\n"
. "- `action=rank_between&id=` sortiert einen Eintrag zwischen zwei Nachbarn ein\n"
. "- `action=epic` legt einen Themenbereich an oder aendert ihn "
. "(`color`, `highlight` steuern die Spur auf der Tafel; nicht "
. "mitgeschickte Felder bleiben unveraendert)\n\n"
. "**`roadmap_rank` gilt PROJEKTWEIT, nicht je Bereich.** `action=next` "
. "sortiert danach ohne Gruppierung, und die Tafel schneidet ihre Phasen "
. "daraus. `move` sucht den Nachbarn deshalb projektweit, und `reorder` "
. "vertauscht nur die Raenge, die die genannten Eintraege ohnehin halten - "
. "es zaehlt nicht mehr je Bereich bei 10 neu durch.\n\n"
. "`rank_between` nimmt `prev_id` und `next_id` (beide optional) und "
. "setzt den Eintrag auf die Mitte dazwischen; fuer eine leere Zielspalte "
. "tun es `min_rank`/`max_rank`. Ist zwischen den Nachbarn kein Platz "
. "mehr, wird das Projekt EINMAL neu durchnummeriert - die Reihenfolge "
. "bleibt dabei erhalten, und die Antwort sagt es mit `renumbered: true`.\n\n"
. "**Zurueckstellen verlangt einen Ausloeser.** `status=deferred` ohne "
. "`defer_trigger` wird mit 400 abgelehnt: Ohne benannten Anlass ist ein "
. "Vorhaben nicht zurueckgestellt, sondern vergessen.",
'parameters' => [
['name' => 'action', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['set', 'move', 'reorder', 'epic']]],
['name' => 'action', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['set', 'move', 'reorder', 'rank_between', 'epic']]],
['name' => 'id', 'in' => 'query', 'schema' => ['type' => 'integer']],
],
'responses' => [
@@ -784,6 +809,70 @@ $spec = [
],
],
'/api/auth/v1/' => [
'post' => [
'tags' => ['Setup'],
'summary' => 'WebAuthn-Zeremonien der WebUI-Anmeldung',
'description' =>
"Die zweite Stufe der Anmeldung am WebUI: ein Hardwarekey (FIDO2/WebAuthn).\n\n"
. "**Dieser Endpunkt ist nichts fuer Agenten und Clients.** Er arbeitet mit dem "
. "Sitzungscookie des Browsers, nicht mit einem Token, und ist nur deshalb hier "
. "beschrieben, weil `navigator.credentials` sich nicht aus einem Formular "
. "heraus bedienen laesst.\n\n"
. "Vier Aktionen ueber `?action=`:\n"
. "- `login_options` / `login_verify` - laufen im Zwischenzustand zwischen "
. "Passwort und zweitem Faktor. Noch ist niemand angemeldet.\n"
. "- `register_options` / `register_verify` - setzen eine bestehende Anmeldung "
. "voraus und verlangen zusaetzlich ein CSRF-Token im Kopf `X-CSRF-Token`.\n\n"
. "Ein registrierter Schluessel gilt nur fuer die Adresse, unter der er "
. "registriert wurde. Ein Schluessel von `dc.mhdf.de` ist auf der Testinstanz "
. "wertlos und umgekehrt - das ist der Kern des Verfahrens und kein Mangel.\n\n"
. "Der Ersatzweg fuer einen fehlenden Schluessel ist der Notfalltoken; der "
. "laeuft als gewoehnliches Formular ueber `/login.php` und braucht kein "
. "JavaScript.",
'security' => [],
'parameters' => [
['name' => 'action', 'in' => 'query', 'required' => true,
'schema' => ['type' => 'string', 'enum' => [
'login_options', 'login_verify', 'register_options', 'register_verify',
]]],
],
'requestBody' => [
'required' => false,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'description' => 'Je nach Aktion. Alle Bytefelder sind base64url ohne Auffuellzeichen.',
'properties' => [
'id' => ['type' => 'string', 'description' => 'login_verify: Kennung des benutzten Schluessels.'],
'clientDataJSON' => ['type' => 'string'],
'authenticatorData' => ['type' => 'string', 'description' => 'nur login_verify'],
'signature' => ['type' => 'string', 'description' => 'nur login_verify'],
'attestationObject' => ['type' => 'string', 'description' => 'nur register_verify'],
'label' => ['type' => 'string', 'description' => 'register_verify: Bezeichnung des Schluessels.'],
'transports' => ['type' => 'array', 'items' => ['type' => 'string']],
],
]]],
],
'responses' => [
'200' => [
'description' => 'Vorgaben fuer den Browser, oder die bestaetigte Zeremonie',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'publicKey' => ['type' => 'object', 'description' => 'Bei den *_options-Aktionen: direkt an navigator.credentials weiterzureichen.'],
'redirect' => ['type' => 'string', 'description' => 'Bei login_verify: wohin der Browser danach gehoert.'],
],
]]],
],
'400' => $errorResponse,
'401' => $errorResponse,
'403' => $errorResponse,
'409' => $errorResponse,
'419' => $errorResponse,
],
],
],
'/api/setup/v1/catalog' => [
'get' => [
'tags' => ['Setup'],
+72 -1
View File
@@ -1,8 +1,79 @@
{
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
"schema": 1,
"current": "2.8.2",
"current": "2.9.0",
"versions": [
{
"version": "2.9.0",
"date": "2026-09-20",
"summary": "Hardwarekey (WebAuthn) als zweite Stufe der WebUI-Anmeldung mit Notfalltoken als Ersatzweg, Konten lassen sich umbenennen, und die Roadmap gibt es jetzt als Tafel: Zeitstrahl nach Prioritaet mit Seitenpanel, Kommentieren und Ziehen.",
"actionRequired": true,
"changes": [
{
"area": "auth",
"breaking": false,
"title": "Hardwarekey als zweite Stufe der WebUI-Anmeldung",
"text": "Nach korrektem Passwort verlangt das WebUI einen registrierten FIDO2-Schluessel (Yubikey und Gleichartiges). Die Pflicht haengt je Konto daran, OB ein Schluessel registriert ist - nicht an einem Schalter. Der Unterschied zeigt sich beim Einspielen: Ein Schalter, der vor der Registrierung umgelegt wird, sperrt den Betreiber aus seiner eigenen Verwaltung aus. Umgesetzt ohne Fremdbibliothek (CBOR-Leser, COSE-nach-PEM, Pruefung ueber openssl_verify, ES256 und RS256). Die Setup-API fuer Zielsysteme ist nicht betroffen - sie laeuft an Auth::login() vorbei. Registriert wird unter System -> Benutzer; ein Schluessel gilt nur fuer die Adresse, unter der er registriert wurde, auf der Testinstanz also eigens.",
"action": "Migration 019 ausfuehren, dann unter System -> Benutzer mindestens einen Hardwarekey registrieren - moeglichst zwei, oder einen Notfalltoken dazu."
},
{
"area": "auth",
"breaking": false,
"title": "Notfalltoken als Ersatzweg",
"text": "Fuenf Gruppen zu vier Zeichen, einmalig bei der Erzeugung angezeigt; in der Datenbank liegt nur ein Hash. Einmalgebrauch: nach der Anmeldung damit ist er verbraucht, und die Meldezeile sagt es. Beim Eintippen zaehlen nur Buchstaben und Ziffern - Bindestriche und Gross-/Kleinschreibung sind gleichgueltig. Laeuft als gewoehnliches Formular ohne JavaScript; ein Ersatzweg mit denselben Voraussetzungen wie der Hauptweg waere keiner.",
"action": null
},
{
"area": "auth",
"breaking": true,
"title": "Auth::login() heisst jetzt Auth::startLogin()",
"text": "Die Methode liefert keinen bool mehr, sondern einen von drei Zustaenden (LOGIN_FAILED, LOGIN_COMPLETE, LOGIN_SECOND_FACTOR). Die Umbenennung ist Absicht: Aus \"wahr heisst drin\" ist \"wahr heisst vielleicht drin\" geworden, und ein Aufrufer, der die Umstellung nicht mitbekommen hat, soll an einem fehlenden Methodennamen scheitern statt an einer Zeichenkette, die als truthy durchgeht. Im Projekt gab es genau einen Aufrufer (public/login.php). Fehlversuche auf der zweiten Stufe laufen in dieselbe Konto- und IP-Sperre wie ein falsches Passwort.",
"action": null
},
{
"area": "webui",
"breaking": false,
"title": "Benutzerkonten lassen sich umbenennen",
"text": "System -> Benutzer kann jetzt auch den Anmeldenamen aendern, mit Kollisionspruefung und Protokolleintrag. Registrierte Hardwarekeys haengen an der Konto-ID und ueberstehen die Umbenennung; offene Sperrfenster des alten Namens werden aufgeraeumt, damit sie nicht spaeter einen gleichnamigen Versuch treffen. Beim Loeschen eines Kontos werden seine Schluessel mitgenommen - eine verwaiste Zeile haette den betroffenen Yubikey dauerhaft unbrauchbar gemacht, weil die Schluesselkennung geraeteweit eindeutig ist.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "CSRF-Pruefung fuer sitzungsauthentifizierte API-Schreibzugriffe",
"text": "ApiAuth akzeptiert die WebUI-Sitzung als Authentifizierung; bisher prueften die API-Endpunkte dabei kein CSRF-Token. Der Kommentar in Http::beginJson - diese Endpunkte authentifizierten \"ausschliesslich per Token-Header, niemals per Cookie\" - traf auf die API nie zu. Schreibende Anfragen im Sitzungsweg verlangen jetzt csrf_token bzw. den Kopf X-CSRF-Token. Token-Clients (Agenten, MCP-Adapter, CLI) sind nicht betroffen: Sie schicken keinen Cookie und kommen nie in diesen Zweig.",
"action": "Eigene Skripte, die sich auf die WebUI-Sitzung statt auf ein Token stuetzen, muessen das CSRF-Token mitschicken."
},
{
"area": "roadmap",
"breaking": false,
"title": "Roadmap-Tafel: Zeitstrahl nach Prioritaet",
"text": "Neue Unteransicht Roadmap -> Tafel. Spalten sind Phasen entlang von roadmap_rank, Zeilen sind Themenbereiche, Eintraege sind Kaertchen. Die Phasengrenzen stehen nicht im Code: gepflegt werden sie in der neuen Tabelle roadmap_phases, und ist dort nichts hinterlegt, schneidet die Tafel an den groessten Luecken der Rangfolge und weist die Grenzen sichtbar als geraten aus. Links die Spalte der erledigten Vorhaben (gekappt), rechts \"Wird ueberlegt\" mit Zurueckgestelltem, Ideen und allem ohne Rang. Nachpruefung (awaiting_verification, in_verification, fix_insufficient) ist ein eigener Zustand und wird nicht als erledigt gezeichnet.",
"action": null
},
{
"area": "roadmap",
"breaking": false,
"title": "Seitenpanel: bemerken und anmerken ohne Umweg",
"text": "Ein Klick auf ein Kaertchen oeffnet ein Panel mit Volltext, Verlauf, betroffenen Projekten und der Einordnung - ohne Seitenwechsel. Von dort laesst sich direkt kommentieren (mit action_taken) und Bereich, Rang, Zielfassung, Status und Ausloeser aendern. Das war der eigentliche Wunsch: Wer in der Uebersicht etwas bemerkte, musste sich bisher die Nummer merken und im Bugtracker-Tab danach suchen. Tiefe Verweise der Form #roadmap/<projekt>/item/<nummer> zeigen auf ein einzelnes Kaertchen, sodass ein Agent in einem Kommentar auf die Stelle zeigen kann.",
"action": null
},
{
"area": "roadmap",
"breaking": true,
"title": "roadmap_rank gilt projektweit - move und reorder ziehen nach (#177)",
"text": "Der Rang wurde an drei Stellen widerspruechlich behandelt: nextUp() sortierte projektweit, reorder() zaehlte je Bereich bei 10 neu durch, und die Werkzeugbeschreibung sagte \"Reihenfolge im Bereich\". Folge war, dass eine Geste innerhalb eines Bereichs die Position seiner Eintraege gegenueber allen anderen Bereichen still verschob. Jetzt gilt projektweit: move() sucht den Nachbarn ohne Bereichsfilter, und reorder() vertauscht nur noch die Raenge, die die genannten Eintraege ohnehin halten. Neu ist action=rank_between fuer das Ziehen auf der Tafel - es setzt den bewegten Eintrag auf die Mitte zwischen seinen neuen Nachbarn und nummeriert das Projekt nur dann einmal neu (unter Erhalt der Reihenfolge), wenn dort kein Platz mehr ist; die Antwort sagt es dann mit renumbered: true.",
"action": "Wer roadmap_rank bisher je Bereich ab 10 vergeben hat, sollte die Reihenfolge einmal durchsehen - sie war projektweit gelesen schon vorher mehrdeutig."
},
{
"area": "api",
"breaking": false,
"title": "Neue Endpunkte und Felder",
"text": "GET /api/bugtracker/v1/roadmap?action=board liefert die fertige Gliederung der Tafel ({phases[], lanes[], items[], stats, meta}) - damit koennen WebUI, CLI und ein spaeterer Export dieselbe Quelle benutzen, statt jeder fuer sich zu schneiden. POST ?action=rank_between sortiert ein Vorhaben zwischen zwei Nachbarn ein. ?action=epic nimmt zusaetzlich color und highlight; das Kennzeichen heisst \"hervorheben\" und nicht \"Sicherheit\", weil andere Projekte ihren wichtigen Bereich anders nennen. Nicht mitgeschickte Felder bleiben dabei unveraendert. POST /api/auth/v1/ traegt die vier WebAuthn-Zeremonien des Browsers.",
"action": null
}
]
},
{
"version": "2.8.2",
"date": "2026-09-08",
+1448 -2
View File
File diff suppressed because it is too large Load Diff
+310 -16
View File
@@ -9,6 +9,7 @@ use Deploymentcenter\Core\Csrf;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
Auth::startSession();
@@ -18,8 +19,61 @@ if (Auth::isLoggedIn()) {
}
$error = null;
$notice = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// -----------------------------------------------------------------------
// Zweite Stufe: Notfalltoken und Abbruch
//
// Beides laeuft als gewoehnliches Formular und nicht ueber die JSON-API.
// Der Hardwarekey braucht JavaScript, weil navigator.credentials sich
// anders nicht bedienen laesst - der Ersatzweg soll diese Voraussetzung
// gerade nicht teilen.
// -----------------------------------------------------------------------
$step = (string)($_POST['step'] ?? '');
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'cancel') {
Auth::clearPending();
header('Location: /login.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'recovery') {
if (!Csrf::isValid(is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : null)) {
$error = 'Sitzung abgelaufen. Bitte erneut versuchen.';
} elseif (Auth::pendingUserId() <= 0) {
$error = 'Die Anmeldung ist abgelaufen. Bitte von vorn beginnen.';
} else {
try {
$pdo = Db::init();
$repo = new CredentialRepo($pdo);
if ($repo->consumeRecoveryToken(Auth::pendingUserId(), (string)($_POST['recovery_token'] ?? ''))) {
Auth::completeSecondFactor($pdo, 'recovery_token');
// Der Token ist jetzt verbraucht. Das muss der Betreiber
// erfahren, und zwar dort, wo er nach dem Anmelden landet -
// also ueber die allgemeine Meldezeile und nicht in einem
// Unterreiter, den er vielleicht wochenlang nicht aufschlaegt.
$_SESSION['dc_flash'] = [
'type' => 'danger',
'message' => 'Sie haben sich mit dem Notfalltoken angemeldet. '
. 'Er ist damit verbraucht. Unter System → Benutzer einen neuen erzeugen, '
. 'sonst bleibt nur noch der Hardwarekey.',
];
header('Location: /index.php');
exit;
}
Auth::recordSecondFactorFailure($pdo, 'recovery_token_invalid');
$error = 'Der Notfalltoken stimmt nicht.';
} catch (\Throwable $e) {
Logger::error('Notfalltoken-Pruefung fehlgeschlagen', ['error' => $e->getMessage()]);
$error = 'Anmeldung derzeit nicht moeglich. Bitte spaeter erneut versuchen.';
}
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
@@ -44,10 +98,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($accLockout / 60)
);
} elseif (Auth::login($pdo, $username, $password)) {
header('Location: /index.php');
exit;
} else {
$result = Auth::startLogin($pdo, $username, $password);
if ($result === Auth::LOGIN_COMPLETE) {
header('Location: /index.php');
exit;
}
if ($result === Auth::LOGIN_SECOND_FACTOR) {
// Nicht weiterleiten: Die zweite Stufe wird unten
// gerendert. Ein Redirect wuerde nur eine Runde kosten
// und der Adresszeile ansehen lassen, wie weit jemand
// gekommen ist.
$error = null;
}
}
if ($error === null && Auth::pendingUserId() <= 0 && !Auth::isLoggedIn()) {
// Pruefen, ob das Konto oder die IP durch diesen Fehlversuch soeben gesperrt wurde
$newAccLockout = Auth::accountLockoutSeconds($pdo, $username);
$newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
@@ -74,6 +142,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
}
// Erst hier abfragen, nicht weiter oben: Der Zwischenzustand entsteht unter
// Umstaenden erst durch die Auswertung darueber.
$pendingUsername = Auth::pendingUsername();
?>
<!DOCTYPE html>
<html lang="de">
@@ -244,6 +316,97 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
color: var(--text-muted);
margin-top: 2rem;
}
/* --- Zweite Stufe: Hardwarekey --- */
.step-note {
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 1.25rem;
}
.step-note strong { color: var(--text-main); }
.key-status {
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 0.875rem 1rem;
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1rem;
min-height: 3rem;
display: flex;
align-items: center;
}
.key-status.is-error {
background: var(--danger-bg);
border-color: var(--danger-border);
color: var(--danger-text);
}
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.5rem 0 1rem;
color: var(--text-muted);
font-size: 0.75rem;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--card-border);
}
.recovery summary {
cursor: pointer;
font-size: 0.875rem;
color: var(--text-muted);
padding: 0.5rem 0;
}
.recovery summary:hover { color: var(--text-main); }
.recovery[open] summary { margin-bottom: 0.75rem; }
.hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.4rem;
line-height: 1.45;
}
.btn-quiet {
background: rgba(255, 255, 255, 0.06);
box-shadow: none;
font-weight: 500;
}
.btn-quiet:hover {
background: rgba(255, 255, 255, 0.1);
box-shadow: none;
}
.cancel-form { text-align: center; margin-top: 1.25rem; }
.link-button {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.link-button:hover { color: var(--text-main); }
</style>
</head>
<body>
@@ -255,31 +418,162 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
</svg>
</div>
<h1 class="brand-title">Deploymentcenter</h1>
<p class="brand-subtitle">Bitte melden Sie sich an, um fortzufahren</p>
<p class="brand-subtitle">
<?= $pendingUsername !== '' ? 'Zweiter Schritt: Hardwarekey' : 'Bitte melden Sie sich an, um fortzufahren' ?>
</p>
</div>
<?php if ($error): ?>
<div class="alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" action="login.php">
<?= Csrf::field() ?>
<div class="form-group">
<label for="username" class="form-label">Benutzername</label>
<input type="text" id="username" name="username" class="form-control" required autofocus placeholder="z. B. admin">
<?php if ($pendingUsername !== ''): ?>
<div class="step-note">
Passwort erkannt für <strong><?= htmlspecialchars($pendingUsername) ?></strong>.
Bitte jetzt den Hardwarekey anstecken und berühren.
</div>
<div class="form-group">
<label for="password" class="form-label">Passwort</label>
<input type="password" id="password" name="password" class="form-control" required placeholder="••••••••">
</div>
<div id="keyStatus" class="key-status">Warte auf den Hardwarekey …</div>
<button type="submit" class="btn-submit">Anmelden</button>
</form>
<button type="button" class="btn-submit" id="btnKey">Hardwarekey verwenden</button>
<div class="divider"><span>oder</span></div>
<details class="recovery" <?= $error !== null ? 'open' : '' ?>>
<summary>Hardwarekey nicht zur Hand?</summary>
<form method="POST" action="login.php">
<?= Csrf::field() ?>
<input type="hidden" name="step" value="recovery">
<div class="form-group">
<label for="recovery_token" class="form-label">Notfalltoken</label>
<input type="text" id="recovery_token" name="recovery_token" class="form-control"
required autocomplete="off" spellcheck="false"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX">
<p class="hint">
Wird dabei verbraucht. Nach der Anmeldung unter
<em>System → Benutzer</em> einen neuen erzeugen.
</p>
</div>
<button type="submit" class="btn-submit btn-quiet">Mit Notfalltoken anmelden</button>
</form>
</details>
<form method="POST" action="login.php" class="cancel-form">
<?= Csrf::field() ?>
<input type="hidden" name="step" value="cancel">
<button type="submit" class="link-button">Abbrechen und von vorn beginnen</button>
</form>
<?php else: ?>
<form method="POST" action="login.php">
<?= Csrf::field() ?>
<div class="form-group">
<label for="username" class="form-label">Benutzername</label>
<input type="text" id="username" name="username" class="form-control" required autofocus>
</div>
<div class="form-group">
<label for="password" class="form-label">Passwort</label>
<input type="password" id="password" name="password" class="form-control" required placeholder="••••••••">
</div>
<button type="submit" class="btn-submit">Anmelden</button>
</form>
<?php endif; ?>
<div class="footer-note">
Deploymentcenter &copy; <?= date('Y') ?> &bull; Unified License & Monitoring Platform
</div>
</div>
<?php if ($pendingUsername !== ''): ?>
<script>
(function () {
const status = document.getElementById('keyStatus');
const button = document.getElementById('btnKey');
// base64url, nicht base64: Der Standard schreibt es so vor, und die
// Umkehrfunktion muss zum Server passen.
const fromB64u = value => {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
return bytes;
};
const toB64u = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const setStatus = (text, isError) => {
status.textContent = text;
status.classList.toggle('is-error', !!isError);
};
if (!window.PublicKeyCredential) {
setStatus('Dieser Browser kennt keine Hardwarekeys. Bitte den Notfalltoken benutzen.', true);
button.disabled = true;
return;
}
const post = async (action, payload) => {
const response = await fetch('/api/auth/v1/?action=' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload || {})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Unerwartete Antwort des Servers.');
}
return data;
};
const run = async () => {
button.disabled = true;
try {
setStatus('Anfrage wird vorbereitet …', false);
const options = (await post('login_options')).publicKey;
options.challenge = fromB64u(options.challenge);
options.allowCredentials = (options.allowCredentials || []).map(c => ({
...c, id: fromB64u(c.id)
}));
setStatus('Bitte den Hardwarekey jetzt berühren.', false);
const assertion = await navigator.credentials.get({ publicKey: options });
setStatus('Antwort wird geprüft …', false);
const result = await post('login_verify', {
id: assertion.id,
clientDataJSON: toB64u(assertion.response.clientDataJSON),
authenticatorData: toB64u(assertion.response.authenticatorData),
signature: toB64u(assertion.response.signature)
});
window.location.href = result.redirect || '/index.php';
} catch (err) {
// NotAllowedError heisst in der Praxis: abgebrochen oder zu lange
// gewartet. Die Rohmeldung des Browsers hilft dabei niemandem.
const message = err && err.name === 'NotAllowedError'
? 'Abgebrochen oder zu lange gewartet. Bitte erneut versuchen.'
: (err && err.message ? err.message : 'Der Hardwarekey konnte nicht gelesen werden.');
setStatus(message, true);
button.disabled = false;
}
};
button.addEventListener('click', run);
// Von selbst loslaufen: Der Normalfall ist, dass der Key schon steckt.
// Der Knopf bleibt fuer den zweiten Anlauf.
run();
})();
</script>
<?php endif; ?>
</body>
</html>