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
+271 -37
View File
@@ -355,46 +355,243 @@ final class Roadmap
// ==================================================================
/**
* Vergibt die Reihenfolge eines Bereichs neu, in Zehnerschritten.
* Ordnet die Eintraege eines Bereichs untereinander neu.
*
* Die Luecken sind der Zweck: Ein Eintrag laesst sich spaeter
* dazwischenschieben (Rang 15), ohne dass alle folgenden neu geschrieben
* werden muessen. Erst wenn die Luecke aufgebraucht ist, laeuft das hier
* noch einmal.
* WICHTIG - das war einmal anders, siehe #177: roadmap_rank gilt
* PROJEKTWEIT, nicht innerhalb eines Bereichs. nextUp() sortiert danach
* ohne jede Gruppierung nach epic, und die Tafel schneidet ihre Phasen
* daraus. Die frueher hier vergebene Folge 10/20/30 je Bereich hatte
* deshalb eine Wirkung, die niemand angefordert hatte: Eine Geste
* innerhalb eines Bereichs schob dessen Eintraege gegenueber allen
* anderen Bereichen durch die halbe Warteschlange.
*
* Jetzt werden ausschliesslich die Raenge vertauscht, die diese
* Eintraege ohnehin schon halten. Ihre Plaetze in der projektweiten
* Reihenfolge bleiben als Menge unveraendert - nur wer auf welchem
* sitzt, aendert sich. Genau das ist die Geste, die gemeint war.
*
* @param list<int> $orderedIds Item-IDs in der gewuenschten Reihenfolge
*/
public function reorder(string $projectSlug, string $epic, array $orderedIds): int
{
$orderedIds = array_values(array_map('intval', $orderedIds));
if ($orderedIds === []) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($orderedIds), '?'));
$stmt = $this->db->prepare('
UPDATE bugtracker_items
SET roadmap_rank = :rank
WHERE id = :id AND project_slug = :slug AND epic = :epic
SELECT id, roadmap_rank FROM bugtracker_items
WHERE project_slug = ? AND epic = ? AND id IN (' . $placeholders . ')
');
$stmt->execute(array_merge([$projectSlug, $epic], $orderedIds));
$known = [];
$ranks = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$known[(int)$row['id']] = true;
if ($row['roadmap_rank'] !== null) {
$ranks[] = (int)$row['roadmap_rank'];
}
}
// Nur die Eintraege, die es wirklich gibt - und in der gewuenschten
// Reihenfolge, nicht in der der Datenbank.
$targets = array_values(array_filter($orderedIds, static fn ($id) => isset($known[$id])));
if ($targets === []) {
return 0;
}
sort($ranks);
// Ungereihte Eintraege im Bereich brauchen zusaetzliche Plaetze.
// Sie kommen hinter den hoechsten vorhandenen Rang, in Zehnerschritten -
// dort standen sie ohnehin schon, naemlich am Ende.
$next = ($ranks === [] ? 0 : $ranks[count($ranks) - 1]);
while (count($ranks) < count($targets)) {
$next += 10;
$ranks[] = $next;
}
$update = $this->db->prepare('
UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id AND project_slug = :slug
');
$touched = 0;
$rank = 10;
foreach ($orderedIds as $id) {
$stmt->execute([
':rank' => $rank,
':id' => (int)$id,
':slug' => $projectSlug,
':epic' => $epic,
]);
$touched += $stmt->rowCount();
$rank += 10;
foreach ($targets as $i => $id) {
$update->execute([':rank' => $ranks[$i], ':id' => $id, ':slug' => $projectSlug]);
$touched += $update->rowCount();
}
return $touched;
}
/**
* Schiebt einen Eintrag innerhalb seines Bereichs um eine Position.
* Setzt einen Eintrag zwischen zwei Nachbarn - das Ziehen auf der Tafel.
*
* Der Rang ist die Mitte zwischen den Nachbarraengen. Ist dort kein Platz
* mehr, wird das Projekt EINMAL neu durchnummeriert (Zehnerschritte) und
* danach erneut die Mitte genommen. Dass das passiert ist, steht im
* Rueckgabewert - eine stille Neuvergabe aller Raenge waere das, wovor
* #177 warnt.
*
* @param int|null $prevId Nachbar davor (kleinerer Rang), null = ganz nach vorn
* @param int|null $nextId Nachbar dahinter (groesserer Rang), null = ganz nach hinten
* @param int|null $minRank Untere Grenze der Zielspalte, falls sie leer ist
* @param int|null $maxRank Obere Grenze der Zielspalte, falls sie leer ist
* @return array{rank:int,renumbered:bool}
*/
public function rankBetween(
int $itemId,
?int $prevId,
?int $nextId,
?int $minRank = null,
?int $maxRank = null
): array {
$stmt = $this->db->prepare('SELECT id, project_slug FROM bugtracker_items WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $itemId]);
$item = $stmt->fetch();
if (!is_array($item)) {
throw new \RuntimeException(sprintf('Item #%d existiert nicht.', $itemId));
}
$projectSlug = (string)$item['project_slug'];
$bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank);
$rank = self::midpoint($bounds['lower'], $bounds['upper']);
if ($rank !== null) {
$this->setRank($itemId, $rank);
return ['rank' => $rank, 'renumbered' => false];
}
// Kein Platz zwischen den Nachbarn. Einmal Luft schaffen.
$this->renumber($projectSlug);
$bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank);
$rank = self::midpoint($bounds['lower'], $bounds['upper']);
if ($rank === null) {
throw new \RuntimeException('Zwischen den Nachbarn ist auch nach dem Neunummerieren kein Platz.');
}
$this->setRank($itemId, $rank);
return ['rank' => $rank, 'renumbered' => true];
}
/**
* Die Raenge der beiden Nachbarn.
*
* @return array{lower:?int,upper:?int}
*/
private function bounds(
string $projectSlug,
int $itemId,
?int $prevId,
?int $nextId,
?int $minRank,
?int $maxRank
): array {
$rankOf = function (?int $id) use ($projectSlug, $itemId): ?int {
if ($id === null || $id === $itemId) {
return null;
}
$stmt = $this->db->prepare('
SELECT roadmap_rank FROM bugtracker_items
WHERE id = :id AND project_slug = :slug LIMIT 1
');
$stmt->execute([':id' => $id, ':slug' => $projectSlug]);
$rank = $stmt->fetchColumn();
return ($rank === false || $rank === null) ? null : (int)$rank;
};
$lower = $rankOf($prevId);
$upper = $rankOf($nextId);
// Leere Zielspalte: Dann geben die Phasengrenzen den Rahmen vor.
if ($lower === null && $minRank !== null) {
$lower = $minRank - 1;
}
if ($upper === null && $maxRank !== null) {
$upper = $maxRank + 1;
}
return ['lower' => $lower, 'upper' => $upper];
}
/**
* Die Mitte zwischen zwei Raengen, oder null wenn dort kein Platz ist.
*/
private static function midpoint(?int $lower, ?int $upper): ?int
{
if ($lower === null && $upper === null) {
return 10;
}
if ($lower === null) {
// Ganz nach vorn. Unter 1 geht nichts mehr.
return $upper > 1 ? (int)max(1, intdiv($upper, 2)) : null;
}
if ($upper === null) {
return $lower + 10;
}
if ($upper - $lower < 2) {
return null;
}
return intdiv($lower + $upper, 2);
}
/**
* Vergibt alle Raenge eines Projekts neu, in Zehnerschritten.
*
* Die bestehende Reihenfolge bleibt dabei erhalten - es wird nur Luft
* zwischen die Eintraege gelassen. Laeuft ausschliesslich dann, wenn
* zwischen zwei Nachbarn kein Platz mehr ist.
*/
public function renumber(string $projectSlug): int
{
$stmt = $this->db->prepare('
SELECT id FROM bugtracker_items
WHERE project_slug = :slug AND roadmap_rank IS NOT NULL
ORDER BY roadmap_rank ASC, id ASC
');
$stmt->execute([':slug' => $projectSlug]);
$update = $this->db->prepare('UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id');
$rank = 0;
$touched = 0;
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [] as $id) {
$rank += 10;
$update->execute([':rank' => $rank, ':id' => (int)$id]);
$touched++;
}
return $touched;
}
/**
* Schiebt einen Eintrag um eine Position in der Reihenfolge des Projekts.
*
* Tauscht die Raenge mit dem Nachbarn, statt sie durchzuzaehlen: Das ist
* ein UPDATE auf zwei Zeilen und bleibt auch bei einem Bereich mit
* hundert Eintraegen billig.
* ein UPDATE auf zwei Zeilen und bleibt auch bei hundert Eintraegen
* billig.
*
* Der Nachbar wird PROJEKTWEIT gesucht und nicht mehr nur im eigenen
* Bereich (#177). Zuvor tauschte ein Klick die Raenge zweier Eintraege
* desselben Bereichs, die in der projektweiten Reihenfolge weit
* auseinanderlagen - der Eintrag sprang dann in der Warteschlange ueber
* beliebig viele fremde Vorhaben hinweg, obwohl die Geste "eine Position"
* hiess.
*/
public function move(int $itemId, string $direction): bool
{
@@ -415,9 +612,9 @@ final class Roadmap
if ($item['roadmap_rank'] === null) {
$max = $this->db->prepare('
SELECT COALESCE(MAX(roadmap_rank), 0) FROM bugtracker_items
WHERE project_slug = :slug AND epic = :epic
WHERE project_slug = :slug
');
$max->execute([':slug' => $item['project_slug'], ':epic' => $item['epic']]);
$max->execute([':slug' => $item['project_slug']]);
$newRank = ((int)$max->fetchColumn()) + 10;
$this->setRank($itemId, $newRank);
@@ -429,7 +626,7 @@ final class Roadmap
$neighbour = $this->db->prepare('
SELECT id, roadmap_rank FROM bugtracker_items
WHERE project_slug = :slug AND epic = :epic
WHERE project_slug = :slug
AND roadmap_rank IS NOT NULL
AND roadmap_rank ' . $comparison . ' :rank
ORDER BY roadmap_rank ' . $sort . ', id ' . $sort . '
@@ -437,7 +634,6 @@ final class Roadmap
');
$neighbour->execute([
':slug' => $item['project_slug'],
':epic' => $item['epic'],
':rank' => (int)$item['roadmap_rank'],
]);
$other = $neighbour->fetch();
@@ -462,22 +658,60 @@ final class Roadmap
// Bereiche verwalten
// ==================================================================
public function saveEpic(string $projectSlug, string $epic, string $title, ?string $summary, int $sortOrder): void
{
$stmt = $this->db->prepare('
INSERT INTO roadmap_epics (project_slug, epic, title, summary, sort_order)
VALUES (:slug, :epic, :title, :summary, :sort)
ON DUPLICATE KEY UPDATE title = VALUES(title),
summary = VALUES(summary),
sort_order = VALUES(sort_order)
');
$stmt->execute([
/**
* Legt einen Themenbereich an oder aendert ihn.
*
* $color und $highlight steuern die Spur auf der Tafel. Das Kennzeichen
* heisst absichtlich "hervorheben" und nicht "Sicherheit": Welcher
* Bereich ins Auge fallen soll, ist eine Entscheidung des Projekts.
* Bei uns ist es die Sicherheit, anderswo heisst er anders - ein Name im
* Code waere fuer genau ein Projekt richtig.
*
* $color und $highlight auf null zu lassen aendert sie nicht. Sonst
* wuerde jeder Aufruf aus dem bestehenden Formular, das diese Felder
* nicht kennt, die Farbe stillschweigend loeschen.
*/
public function saveEpic(
string $projectSlug,
string $epic,
string $title,
?string $summary,
int $sortOrder,
?string $color = null,
?bool $highlight = null
): void {
$columns = 'project_slug, epic, title, summary, sort_order';
$values = ':slug, :epic, :title, :summary, :sort';
$updates = 'title = VALUES(title), summary = VALUES(summary), sort_order = VALUES(sort_order)';
$params = [
':slug' => $projectSlug,
':epic' => mb_substr(trim($epic), 0, 24),
':title' => mb_substr(trim($title), 0, 160),
':summary' => ($summary !== null && trim($summary) !== '') ? trim($summary) : null,
':sort' => $sortOrder,
]);
];
if ($color !== null) {
$columns .= ', color';
$values .= ', :color';
$updates .= ', color = VALUES(color)';
$params[':color'] = trim($color) !== '' ? mb_substr(trim($color), 0, 16) : null;
}
if ($highlight !== null) {
$columns .= ', highlight';
$values .= ', :highlight';
$updates .= ', highlight = VALUES(highlight)';
$params[':highlight'] = $highlight ? 1 : 0;
}
$stmt = $this->db->prepare('
INSERT INTO roadmap_epics (' . $columns . ')
VALUES (' . $values . ')
ON DUPLICATE KEY UPDATE ' . $updates . '
');
$stmt->execute($params);
}
/**