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
+1
View File
@@ -36,6 +36,7 @@ Options -Indexes
RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA]
RewriteRule ^api/tokens/v1/whoami/?$ public/api/tokens/v1/whoami.php [L,QSA]
RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA]
RewriteRule ^api/auth/v1(?:/(.*))?$ public/api/auth/v1/index.php [L,QSA]
RewriteRule ^api/supervisor/v1(?:/(.*))?$ public/api/supervisor/v1/index.php [L,QSA]
# Fallback fuer direkt adressierte Dateien unterhalb von /api/
+10
View File
@@ -179,6 +179,16 @@ Zur Reihenfolge: **Zehnerschritte.** Die Lücken sind Absicht — ein Eintrag
lässt sich später auf Rang 15 dazwischenschieben, ohne dass alle folgenden neu
nummeriert werden müssen.
**Der Rang gilt projektweit, nicht innerhalb des Bereichs.** Hier stand
einmal „Reihenfolge im Bereich“, und das war die Hälfte eines Widerspruchs
(#177): `dc_next_task` und die Roadmap-Tafel lesen den Rang ohne jede
Gruppierung nach `epic`. Wer je Bereich bei 10 anfängt, setzt damit das erste
Vorhaben **jedes** Bereichs gleichauf an die Spitze — und dann entscheidet der
Schweregrad die Reihenfolge, also ausgerechnet die Größe, von der hier steht,
sie sei keine Priorität. Eine durchgehende Liste über mehrere Bereiche hinweg
ist deshalb genau richtig: `A` kann auf 10 stehen, `C` auf 20, `A` wieder auf
30.
Zurückgestelltes braucht den Auslöser, sonst wird der Aufruf abgelehnt:
```
+86
View File
@@ -588,3 +588,89 @@ Rückgabewert 2.
Zum Abschalten die erzeugten `.htaccess`-Dateien in den Produktverzeichnissen
löschen. Sie entstehen beim nächsten Auslöser neu.
---
## 17. Umstellung auf 2.9
Zwei Dinge kommen dazu: ein Hardwarekey als zweite Stufe der WebUI-Anmeldung
und die Roadmap-Tafel. Nur das Erste braucht Aufmerksamkeit — beim Zweiten
kann nichts schiefgehen, was sich nicht durch Neuladen beheben ließe.
### 17.1 Die Reihenfolge, und warum sie zählt
```
1. Deployment
2. Migration 019 ausführen
3. Anmelden, Hardwarekey registrieren
4. Notfalltoken erzeugen und ausdrucken
```
**Die Pflicht zum zweiten Faktor hängt daran, ob ein Schlüssel registriert
ist — nicht an einem Schalter.** Das ist Absicht und der Grund, warum diese
Reihenfolge ungefährlich ist: Solange kein Schlüssel hinterlegt ist, läuft die
Anmeldung genau wie vorher. Ein Schalter, den man vor der Registrierung
umlegt, hätte die entgegengesetzte Eigenschaft.
Nach Schritt 3 ist das Passwort allein nicht mehr genug. Deshalb gehört
Schritt 4 unmittelbar dahinter und nicht auf die Liste für nächste Woche.
### 17.2 Der Schlüssel gilt nur für eine Adresse
Ein auf `dc.mhdf.de` registrierter Yubikey funktioniert auf
`dc-dev.mhdf.de` **nicht**, und umgekehrt. Das ist keine Einschränkung der
Umsetzung, sondern der Kern des Verfahrens: Genau deshalb läuft ein
Anmeldeversuch auf einer nachgebauten Seite ins Leere. Auf beiden Instanzen
also je einmal registrieren.
Registriert wird unter **System → 👤 Benutzer → Meine Hardwarekeys**.
Registrieren Sie zwei, wenn Sie zwei haben — der Notfalltoken ist der
Ersatzweg, nicht der zweite Schlüssel.
### 17.3 Der Notfalltoken
Fünf Gruppen zu vier Zeichen, **einmalig** bei der Erzeugung angezeigt. In der
Datenbank liegt nur ein Hash; wer sie liest, hält damit keinen Zweitschlüssel
in der Hand. Er gilt für **genau eine** Anmeldung und ist danach verbraucht —
die Meldezeile sagt es Ihnen.
Beim Eintippen zählen nur Buchstaben und Ziffern. Bindestriche und
Groß-/Kleinschreibung sind gleichgültig.
### 17.4 Wenn Schlüssel *und* Notfalltoken weg sind
Dann hilft nur die Datenbank. Der Weg zurück ist, die Schlüssel des Kontos zu
entfernen — danach genügt wieder das Passwort allein:
```sql
DELETE FROM dc_user_credentials
WHERE user_id = (SELECT id FROM dc_users WHERE username = '<name>');
```
### 17.5 Konto umbenennen
**System → 👤 Benutzer** kann jetzt auch den Anmeldenamen ändern. Registrierte
Hardwarekeys hängen an der Konto-ID und überstehen das; offene Sperrfenster des
alten Namens werden dabei aufgeräumt.
### 17.6 Was sich an der API ändert
- Sitzungsauthentifizierte **Schreibzugriffe** auf die API verlangen jetzt ein
CSRF-Token (`csrf_token` im Rumpf oder der Kopf `X-CSRF-Token`).
Token-Clients — Agenten, MCP-Adapter, `scripts/dc.py` — sind **nicht**
betroffen: Sie schicken keinen Cookie. Betroffen sind nur eigene Skripte,
die sich auf eine WebUI-Sitzung stützen.
- `roadmap_rank` gilt **projektweit**, nicht je Bereich (#177). `move` sucht
den Nachbarn entsprechend ohne Bereichsfilter, und `reorder` zählt nicht mehr
je Bereich bei 10 neu durch, sondern vertauscht nur die Ränge, die die
genannten Einträge ohnehin halten. Wer bisher je Bereich ab 10 vergeben hat,
sollte die Reihenfolge einmal durchsehen.
### 17.7 Prüfen, ob alles läuft
- [ ] Anmeldung mit Passwort führt auf die zweite Stufe
- [ ] Hardwarekey schließt sie ab
- [ ] Notfalltoken schließt sie ebenfalls ab und ist danach verbraucht
- [ ] Roadmap → 🧭 Tafel zeigt Spalten, Spuren und Kärtchen
- [ ] Ein Klick auf ein Kärtchen öffnet das Panel, Kommentieren funktioniert
- [ ] `python tests/e2e/run.py` gegen die Testinstanz: alles grün
+1 -1
View File
@@ -465,7 +465,7 @@ const TOOLS = [
properties: {
id: { type: 'integer', description: 'Item-ID.' },
epic: { type: 'string', description: 'Themenbereich; leer nimmt es von der Roadmap.' },
roadmap_rank: { type: 'integer', description: 'Reihenfolge im Bereich, Zehnerschritte.' },
roadmap_rank: { type: 'integer', description: 'Reihenfolge im PROJEKT, Zehnerschritte. Nicht im Bereich: dc_next_task und die Roadmap-Tafel lesen den Rang ohne Gruppierung nach epic (siehe #177).' },
target_build: { type: 'string', description: 'Geplante Zielfassung, z. B. "2.7.0".' },
status: { type: 'string', enum: STATUSES },
defer_trigger: {
+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
+299 -5
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)) {
} else {
$result = Auth::startLogin($pdo, $username, $password);
if ($result === Auth::LOGIN_COMPLETE) {
header('Location: /index.php');
exit;
} else {
}
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,18 +418,60 @@ 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; ?>
<?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 id="keyStatus" class="key-status">Warte auf den Hardwarekey …</div>
<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 placeholder="z. B. admin">
<input type="text" id="username" name="username" class="form-control" required autofocus>
</div>
<div class="form-group">
@@ -277,9 +482,98 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<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>
@@ -0,0 +1,101 @@
-- Migration 019: Hardwarekey an der WebUI-Anmeldung, Notfalltoken, Roadmap-Tafel
--
-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091 ("existiert bereits").
-- ---------------------------------------------------------------------------
-- 1. Registrierte Hardwarekeys (WebAuthn/FIDO2)
-- ---------------------------------------------------------------------------
-- Ein Konto darf mehrere Keys haben, und das ist keine Bequemlichkeit: Wer nur
-- einen Schluessel registriert, hat beim Verlust genau einen Weg zurueck - den
-- Notfalltoken. Ein zweiter Key im Schrank ist der billigere Ersatzweg.
--
-- credential_id kommt roh vom Authenticator (bis 1023 Byte erlaubt, in der
-- Praxis 16-128). VARBINARY(255) deckt jeden real vorkommenden Fall ab und
-- bleibt als eindeutiger Schluessel unterhalb der InnoDB-Grenze.
--
-- public_key ist das PEM der oeffentlichen Haelfte. Bewusst PEM und nicht das
-- rohe COSE: Geprueft wird spaeter mit openssl_verify(), und das will ein PEM.
-- Die Wandlung einmal bei der Registrierung zu bezahlen ist billiger, als sie
-- bei jeder Anmeldung zu wiederholen.
--
-- sign_count ist der Zaehler des Authenticators. Faellt er bei einer Anmeldung
-- nicht hoeher aus als der gespeicherte Wert, ist das der Hinweis auf einen
-- geklonten Schluessel. Yubikeys, die den Zaehler gar nicht fuehren, melden
-- konstant 0 - dieser Fall ist erlaubt und darf nicht als Klon gelten.
CREATE TABLE IF NOT EXISTS dc_user_credentials (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
credential_id VARBINARY(255) NOT NULL,
public_key TEXT NOT NULL,
-- Das COSE-Verfahren (-7 = ES256, -257 = RS256). Liesse sich zwar aus dem
-- PEM herleiten, steht aber hier: Das Pruefen soll nicht davon abhaengen,
-- dass das Herleiten richtig geraten hat.
alg INT NOT NULL DEFAULT -7,
sign_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
aaguid VARBINARY(16) NULL,
transports VARCHAR(128) NULL,
label VARCHAR(64) NOT NULL DEFAULT 'Hardwarekey',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
UNIQUE KEY ux_credential_id (credential_id),
KEY ix_credential_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- 2. Notfalltoken je Konto
-- ---------------------------------------------------------------------------
-- Nur der Hash liegt hier. Der Klartext wird bei der Erzeugung ein einziges
-- Mal angezeigt und danach nicht wieder - wer die Datenbank liest, haelt
-- deshalb keinen Zweitschluessel in der Hand.
--
-- recovery_used_at bleibt nach dem Gebrauch stehen, obwohl der Hash dabei
-- geleert wird. Ohne diese Spur stuende in der Oberflaeche nur "kein
-- Notfalltoken hinterlegt" - und das sieht genauso aus wie "es gab nie einen",
-- waehrend es in Wahrheit heisst: jemand hat ihn benutzt.
ALTER TABLE dc_users
ADD COLUMN recovery_hash VARCHAR(255) NULL AFTER totp_secret;
ALTER TABLE dc_users
ADD COLUMN recovery_created_at DATETIME NULL AFTER recovery_hash;
ALTER TABLE dc_users
ADD COLUMN recovery_used_at DATETIME NULL AFTER recovery_created_at;
-- ---------------------------------------------------------------------------
-- 3. Phasen der Roadmap-Tafel
-- ---------------------------------------------------------------------------
-- Die Tafel schneidet die Vorhaben in Spalten entlang von roadmap_rank. Die
-- Grenzen duerfen nicht im Code stehen: Der Rang ist projektweit und frei
-- vergeben, und jedes Projekt benutzt andere Abstaende. Was fuer das
-- Deploymentcenter "Rang 5-40" ist, ist fuer Predictalytics eine einzige
-- Spalte.
--
-- Ist fuer ein Projekt nichts gepflegt, schneidet die Tafel selbst - an den
-- groessten Luecken der Rangfolge - und sagt in der Ansicht dazu, dass die
-- Grenzen geraten sind. Diese Tabelle ist der Weg, das Geratene zu ersetzen.
CREATE TABLE IF NOT EXISTS roadmap_phases (
id INT AUTO_INCREMENT PRIMARY KEY,
project_slug VARCHAR(64) NOT NULL,
sort_order INT NOT NULL DEFAULT 100,
rank_from INT NOT NULL,
rank_to INT NOT NULL,
label VARCHAR(80) NOT NULL,
subtitle VARCHAR(160) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY ux_roadmap_phase (project_slug, sort_order),
KEY ix_roadmap_phase_range (project_slug, rank_from, rank_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- 4. Spuren der Tafel: Farbe und Hervorhebung je Themenbereich
-- ---------------------------------------------------------------------------
-- "Sicherheit faellt ins Auge" war in der Vorlage ein fest verdrahteter
-- Bereichsname. Das traegt genau ein Projekt weit: Andere Projekte nennen
-- ihren wichtigen Bereich anders. Deshalb ein Kennzeichen am Bereich statt
-- eines Namens im Code.
ALTER TABLE roadmap_epics
ADD COLUMN color VARCHAR(16) NULL AFTER summary;
ALTER TABLE roadmap_epics
ADD COLUMN highlight TINYINT(1) NOT NULL DEFAULT 0 AFTER color;
+16
View File
@@ -81,6 +81,22 @@ final class ApiAuth
// 2. WebUI-Session
if ($allowSession && Auth::isLoggedIn()) {
// Ein sitzungsauthentifizierter Schreibzugriff braucht zusaetzlich
// ein CSRF-Token. Der Kommentar in Http::beginJson - diese
// Endpunkte authentifizierten "ausschliesslich per Token-Header,
// niemals per Cookie" - traf auf die API nie zu: Genau hier wird
// der Cookie akzeptiert. Bisher war das nur deshalb harmlos, weil
// SameSite=Lax den Cookie bei fremden POST-Anfragen zurueckhaelt;
// das ist eine Eigenschaft des Browsers und kein Schutz, den der
// Server selbst in der Hand haette.
//
// Token-Clients (Agenten, MCP-Adapter, CLI) sind nicht betroffen:
// Sie kommen nie in diesen Zweig, weil sie keinen Cookie
// mitschicken.
if (!in_array(Http::method(), ['GET', 'HEAD', 'OPTIONS'], true)) {
Csrf::requireValid();
}
return [
'method' => 'session',
'actor' => Auth::username(),
+218 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Deploymentcenter\Core;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
use PDO;
@@ -38,6 +39,20 @@ final class Auth
/** Kontosperrdauer in Sekunden (15 Minuten). */
public const DEFAULT_ACCOUNT_LOCKOUT_SECONDS = 900;
/**
* Wie lange zwischen Passwort und zweitem Faktor liegen darf (5 Minuten).
*
* Der Zwischenzustand ist keine Anmeldung, aber er ist auch nicht nichts:
* Wer ihn erreicht hat, kennt das Passwort. Er soll nicht ueber Nacht
* offen stehen bleiben.
*/
public const PENDING_TIMEOUT = 300;
/** Rueckgabewerte von startLogin(). */
public const LOGIN_FAILED = 'failed';
public const LOGIN_COMPLETE = 'complete';
public const LOGIN_SECOND_FACTOR = 'second_factor';
public static function startSession(): void
{
if (session_status() !== PHP_SESSION_NONE) {
@@ -278,16 +293,25 @@ final class Auth
}
/**
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
* Erste Stufe: Zugangsdaten pruefen.
*
* Liefert LOGIN_COMPLETE, wenn die Anmeldung damit fertig ist (Konto ohne
* registrierten Hardwarekey), LOGIN_SECOND_FACTOR, wenn noch ein
* Schluessel oder der Notfalltoken fehlt, und LOGIN_FAILED sonst.
*
* Bewusst nicht mehr `login(): bool`: 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
* und nicht an einer Zeichenkette, die als truthy durchgeht.
*/
public static function login(PDO $db, string $username, string $password): bool
public static function startLogin(PDO $db, string $username, string $password): string
{
self::startSession();
$user = self::verifyCredentials($db, $username, $password);
if ($user === null) {
return false;
return self::LOGIN_FAILED;
}
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu suchen
@@ -297,9 +321,200 @@ final class Auth
'username' => $username,
'ip' => Http::clientIp(),
]);
return self::LOGIN_FAILED;
}
if (!self::secondFactorRequired($db, (int)$user['id'])) {
self::establishSession($user);
return self::LOGIN_COMPLETE;
}
// Zwischenzustand. Ausdruecklich keine Anmeldung: dc_user_id bleibt
// leer, isLoggedIn() ist falsch, und jede geschuetzte Seite weist
// weiterhin ab.
self::clearPending();
$_SESSION['dc_pending_user_id'] = (int)$user['id'];
$_SESSION['dc_pending_username'] = (string)$user['username'];
$_SESSION['dc_pending_at'] = time();
return self::LOGIN_SECOND_FACTOR;
}
/**
* Verlangt dieses Konto einen zweiten Faktor?
*
* Die Pflicht haengt daran, ob ein Schluessel registriert ist, und 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. So herum kann das nicht passieren -
* wer einen Schluessel registriert hat, hat ihn auch.
*/
public static function secondFactorRequired(PDO $db, int $userId): bool
{
if ($userId <= 0) {
return false;
}
try {
return (new CredentialRepo($db))->countForUser($userId) > 0;
} catch (\Throwable $e) {
// Tabelle fehlt eventuell vor Migration 019. Dann gibt es keine
// Schluessel, also auch keine Pflicht - und die Anmeldung laeuft
// wie zuvor, statt an einer fehlenden Tabelle zu scheitern.
Logger::warning('Pruefung auf Hardwarekey nicht moeglich', ['error' => $e->getMessage()]);
return false;
}
}
/** Das Konto, das gerade zwischen erster und zweiter Stufe steht (0 = keines). */
public static function pendingUserId(): int
{
self::startSession();
$userId = (int)($_SESSION['dc_pending_user_id'] ?? 0);
if ($userId <= 0) {
return 0;
}
$since = (int)($_SESSION['dc_pending_at'] ?? 0);
if ($since <= 0 || (time() - $since) > self::PENDING_TIMEOUT) {
self::clearPending();
return 0;
}
return $userId;
}
public static function pendingUsername(): string
{
self::startSession();
return self::pendingUserId() > 0
? (string)($_SESSION['dc_pending_username'] ?? '')
: '';
}
/**
* Zweite Stufe bestanden: jetzt wird angemeldet.
*
* @param string $method 'hardware_key' oder 'recovery_token' - nur fuers Protokoll
*/
public static function completeSecondFactor(PDO $db, string $method): bool
{
$userId = self::pendingUserId();
if ($userId <= 0) {
return false;
}
$stmt = $db->prepare('SELECT * FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$user = $stmt->fetch();
if (!is_array($user) || (int)($user['disabled'] ?? 0) === 1) {
self::clearPending();
return false;
}
self::clearPending();
self::establishSession($user);
Logger::info('Zweite Stufe bestanden', [
'username' => $user['username'],
'method' => $method,
'ip' => Http::clientIp(),
]);
return true;
}
/**
* Fehlversuch auf der zweiten Stufe.
*
* Muss in dieselbe Sperrlogik laufen wie ein falsches Passwort. Sonst
* waere die zweite Stufe die Stelle, an der beliebig oft geraten werden
* darf - und damit die schwaechste, obwohl sie die staerkere sein soll.
*/
public static function recordSecondFactorFailure(PDO $db, string $reason): void
{
self::startSession();
$userId = (int)($_SESSION['dc_pending_user_id'] ?? 0);
$username = (string)($_SESSION['dc_pending_username'] ?? '');
$ip = Http::clientIp();
if ($username === '') {
return;
}
self::recordAttempt($db, $ip, $username, false);
SecurityLogger::authFail($ip, $username, 'second_factor_' . $reason);
try {
$stmt = $db->prepare('SELECT * FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$user = $stmt->fetch();
if (is_array($user)) {
self::handleAccountFailedAttempt($db, $user, $ip, $username);
}
} catch (\Throwable $e) {
Logger::warning('Fehlversuch der zweiten Stufe nicht gezaehlt', ['error' => $e->getMessage()]);
}
}
public static function clearPending(): void
{
self::startSession();
unset(
$_SESSION['dc_pending_user_id'],
$_SESSION['dc_pending_username'],
$_SESSION['dc_pending_at'],
$_SESSION['dc_webauthn_challenge'],
$_SESSION['dc_webauthn_challenge_at']
);
}
/**
* Legt die Herausforderung ab, die der Browser gleich unterschreiben soll.
*
* In der Sitzung und nicht in der Datenbank: Sie lebt zwei Minuten, gilt
* fuer genau diesen Browser und waere als Tabellenzeile nur Ballast, den
* jemand wieder aufraeumen muesste.
*/
public static function storeChallenge(string $challenge): void
{
self::startSession();
$_SESSION['dc_webauthn_challenge'] = $challenge;
$_SESSION['dc_webauthn_challenge_at'] = time();
}
/**
* Holt die Herausforderung und verbraucht sie dabei.
*
* Verbrauchen ist der Punkt: Eine Herausforderung, die liegen bleibt,
* laesst sich ein zweites Mal beantworten.
*/
public static function takeChallenge(): string
{
self::startSession();
$challenge = (string)($_SESSION['dc_webauthn_challenge'] ?? '');
$at = (int)($_SESSION['dc_webauthn_challenge_at'] ?? 0);
unset($_SESSION['dc_webauthn_challenge'], $_SESSION['dc_webauthn_challenge_at']);
if ($challenge === '' || $at <= 0 || (time() - $at) > Webauthn\WebauthnServer::CHALLENGE_TTL) {
return '';
}
return $challenge;
}
/** Session-Variablen einer erfolgreichen Anmeldung setzen. */
private static function establishSession(array $user): void
{
session_regenerate_id(true);
$_SESSION['dc_user_id'] = (int)$user['id'];
@@ -309,8 +524,6 @@ final class Auth
$_SESSION['dc_last_seen'] = time();
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]);
return true;
}
private static function handleAccountFailedAttempt(PDO $db, array $user, string $ip, string $username): void
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn;
/**
* Ein CBOR-Leser, gerade gross genug fuer WebAuthn.
*
* Warum selbst geschrieben: Das Projekt hat kein Composer und kein vendor/.
* Das ist eine bewusste Entscheidung und keine Nachlaessigkeit - ein Server,
* der sich ueber FTP aktualisieren laesst, soll nicht von einem
* Abhaengigkeitsbaum abhaengen, den niemand mehr liest. Also steht hier, was
* gebraucht wird, und nicht mehr.
*
* Nicht unterstuetzt und bewusst nicht nachgeruestet:
* - Gleitkommazahlen (Major 7, Zusatz 25/26/27). In einem
* Attestationsobjekt oder einem COSE-Schluessel kommen keine vor.
* - Unbestimmte Laengen (Zusatz 31). Der WebAuthn-Standard verlangt fuer
* COSE die kanonische Form, und die kennt sie nicht.
* Beides fuehrt zu einer Ausnahme statt zu einem stillschweigend falschen
* Ergebnis - bei Material, das von aussen kommt, ist Abbrechen die richtige
* Antwort.
*/
final class Cbor
{
private const MAJOR_UINT = 0;
private const MAJOR_NINT = 1;
private const MAJOR_BYTES = 2;
private const MAJOR_TEXT = 3;
private const MAJOR_ARRAY = 4;
private const MAJOR_MAP = 5;
private const MAJOR_TAG = 6;
private const MAJOR_SIMPLE = 7;
/**
* Liest genau einen Wert ab $offset und schiebt $offset dahinter.
*
* Dass der Versatz durchgereicht wird, ist kein Zierrat: Das
* Attestationsobjekt enthaelt hinter dem COSE-Schluessel noch weitere
* Bytes, und ohne die Endposition wuesste der Aufrufer nicht, wo der
* Schluessel aufhoert.
*
* @return mixed
*/
public static function decode(string $data, int &$offset = 0)
{
$first = self::byteAt($data, $offset);
$offset++;
$major = $first >> 5;
$minor = $first & 0x1F;
if ($minor === 31) {
throw new \RuntimeException('CBOR: unbestimmte Laenge wird nicht unterstuetzt.');
}
// Major 7 traegt im Zusatzfeld keine Laenge, sondern den Wert selbst.
if ($major === self::MAJOR_SIMPLE) {
return self::simpleValue($minor);
}
$value = self::readLength($data, $offset, $minor);
switch ($major) {
case self::MAJOR_UINT:
return $value;
case self::MAJOR_NINT:
return -1 - $value;
case self::MAJOR_BYTES:
case self::MAJOR_TEXT:
return self::readChunk($data, $offset, $value);
case self::MAJOR_ARRAY:
$out = [];
for ($i = 0; $i < $value; $i++) {
$out[] = self::decode($data, $offset);
}
return $out;
case self::MAJOR_MAP:
$out = [];
for ($i = 0; $i < $value; $i++) {
$key = self::decode($data, $offset);
if (!is_int($key) && !is_string($key)) {
throw new \RuntimeException('CBOR: Schluessel ist weder Zahl noch Zeichenkette.');
}
// Ganzzahlige Schluessel bleiben ganzzahlig: In einem
// COSE-Schluessel heisst -2 die x-Koordinate und "-2" gar
// nichts. PHP wuerde negative Zahlen als Array-Schluessel
// zwar behalten, der Unterschied zu "-2" ginge aber beim
// Nachschlagen verloren.
$out[$key] = self::decode($data, $offset);
}
return $out;
case self::MAJOR_TAG:
// Der Inhalt zaehlt, die Etikettierung nicht. WebAuthn
// benutzt keine Tags, aber ein uebersprungenes Tag ist
// harmloser als ein Abbruch.
return self::decode($data, $offset);
}
throw new \RuntimeException(sprintf('CBOR: unbekannter Haupttyp %d.', $major));
}
/**
* Wie decode(), verlangt aber, dass nichts uebrig bleibt.
*
* Fuer clientDataJSON-nahe Faelle, in denen angehaengte Bytes ein Zeichen
* dafuer waeren, dass jemand etwas untergeschoben hat.
*
* @return mixed
*/
public static function decodeComplete(string $data)
{
$offset = 0;
$value = self::decode($data, $offset);
if ($offset !== strlen($data)) {
throw new \RuntimeException(sprintf(
'CBOR: %d Byte hinter dem Wert uebrig.',
strlen($data) - $offset
));
}
return $value;
}
/** Wert aus dem Zusatzfeld von Haupttyp 7. */
private static function simpleValue(int $minor)
{
switch ($minor) {
case 20: return false;
case 21: return true;
case 22: return null;
case 23: return null; // undefined - fuer uns dasselbe wie null
}
throw new \RuntimeException(sprintf('CBOR: Zusatzwert %d wird nicht unterstuetzt.', $minor));
}
/**
* Die Laenge bzw. den Zahlwert aus dem Zusatzfeld lesen.
*/
private static function readLength(string $data, int &$offset, int $minor): int
{
if ($minor < 24) {
return $minor;
}
switch ($minor) {
case 24: $bytes = 1; break;
case 25: $bytes = 2; break;
case 26: $bytes = 4; break;
case 27: $bytes = 8; break;
default:
throw new \RuntimeException(sprintf('CBOR: ungueltiges Zusatzfeld %d.', $minor));
}
$chunk = self::readChunk($data, $offset, $bytes);
$value = 0;
for ($i = 0; $i < $bytes; $i++) {
$value = ($value << 8) | ord($chunk[$i]);
}
// Auf einer 64-Bit-Maschine kippt erst das oberste Bit ins Negative.
// Eine Laengenangabe, die das tut, ist keine Laenge.
if ($value < 0) {
throw new \RuntimeException('CBOR: Laengenangabe ausserhalb des darstellbaren Bereichs.');
}
return $value;
}
private static function readChunk(string $data, int &$offset, int $length): string
{
if ($length < 0 || $offset + $length > strlen($data)) {
throw new \RuntimeException('CBOR: Daten enden vorzeitig.');
}
$chunk = substr($data, $offset, $length);
$offset += $length;
return $chunk;
}
private static function byteAt(string $data, int $offset): int
{
if ($offset < 0 || $offset >= strlen($data)) {
throw new \RuntimeException('CBOR: Daten enden vorzeitig.');
}
return ord($data[$offset]);
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn;
/**
* Wandelt den oeffentlichen Schluessel eines Authenticators aus der
* COSE-Darstellung in ein PEM.
*
* Der Authenticator liefert seinen Schluessel als CBOR-Abbildung mit
* ganzzahligen Schluesseln (COSE_Key, RFC 8152). openssl_verify() will ein
* PEM. Dazwischen liegt eine DER-Struktur, die hier von Hand gebaut wird -
* es sind zwei Dutzend Bytes, und die Alternative waere eine Abhaengigkeit.
*
* Gewandelt wird einmal bei der Registrierung, nicht bei jeder Anmeldung.
*
* Unterstuetzt werden ES256 (P-256) und RS256. Das ist keine Sparsamkeit,
* sondern deckt, was Hardwarekeys anbieten - ein Yubikey erzeugt ES256.
* EdDSA bliebe moeglich, braucht aber die sodium-Erweiterung zum Pruefen und
* wird deshalb erst gebaut, wenn ein Schluessel danach verlangt; bis dahin
* wird es bei der Registrierung gar nicht erst angeboten (siehe
* WebauthnServer::ALGORITHMS).
*/
final class CoseKey
{
/** ECDSA mit SHA-256 ueber P-256. */
public const ES256 = -7;
/** RSASSA-PKCS1-v1_5 mit SHA-256. */
public const RS256 = -257;
/** EdDSA - erkannt, um eine verstaendliche Absage geben zu koennen. */
public const EDDSA = -8;
// Schluessel innerhalb der COSE-Abbildung
private const LABEL_KTY = 1;
private const LABEL_ALG = 3;
private const LABEL_CRV = -1; // bei EC2
private const LABEL_X = -2; // bei EC2
private const LABEL_Y = -3; // bei EC2
private const LABEL_N = -1; // bei RSA
private const LABEL_E = -2; // bei RSA
private const KTY_OKP = 1;
private const KTY_EC2 = 2;
private const KTY_RSA = 3;
private const CRV_P256 = 1;
/**
* @param array<int|string,mixed> $cose Die entschluesselte COSE-Abbildung
* @return array{pem:string,alg:int}
*/
public static function toPem(array $cose): array
{
$kty = self::intAt($cose, self::LABEL_KTY, 'kty');
$alg = self::intAt($cose, self::LABEL_ALG, 'alg');
if ($alg === self::EDDSA || $kty === self::KTY_OKP) {
throw new \RuntimeException(
'Dieser Schluessel bietet EdDSA an. Das Deploymentcenter prueft derzeit ES256 und RS256.'
);
}
if ($kty === self::KTY_EC2) {
if ($alg !== self::ES256) {
throw new \RuntimeException(sprintf('Unerwartetes EC-Verfahren (alg=%d), erwartet ES256.', $alg));
}
return ['pem' => self::ecPem($cose), 'alg' => $alg];
}
if ($kty === self::KTY_RSA) {
if ($alg !== self::RS256) {
throw new \RuntimeException(sprintf('Unerwartetes RSA-Verfahren (alg=%d), erwartet RS256.', $alg));
}
return ['pem' => self::rsaPem($cose), 'alg' => $alg];
}
throw new \RuntimeException(sprintf('Unbekannter Schluesseltyp (kty=%d).', $kty));
}
/** Das Hashverfahren, mit dem zu diesem alg geprueft wird. */
public static function digestFor(int $alg): int
{
if ($alg === self::ES256 || $alg === self::RS256) {
return OPENSSL_ALGO_SHA256;
}
throw new \RuntimeException(sprintf('Kein Hashverfahren fuer alg=%d hinterlegt.', $alg));
}
// -----------------------------------------------------------------
// EC2 / P-256
// -----------------------------------------------------------------
/**
* SubjectPublicKeyInfo fuer einen P-256-Schluessel.
*
* SEQUENCE {
* SEQUENCE { OID id-ecPublicKey, OID prime256v1 }
* BIT STRING { 0x04 || X || Y }
* }
*
* Das fuehrende 0x04 kennzeichnet den unkomprimierten Punkt.
*/
private static function ecPem(array $cose): string
{
$crv = self::intAt($cose, self::LABEL_CRV, 'crv');
if ($crv !== self::CRV_P256) {
throw new \RuntimeException(sprintf('Nur die Kurve P-256 wird unterstuetzt (crv=%d).', $crv));
}
$x = self::bytesAt($cose, self::LABEL_X, 'x');
$y = self::bytesAt($cose, self::LABEL_Y, 'y');
// Beide Koordinaten sind bei P-256 genau 32 Byte. Ein Authenticator,
// der fuehrende Nullen wegkuerzt, wuerde sonst einen Punkt erzeugen,
// der sich nie verifizieren laesst - und der Fehler faende sich erst
// bei der ersten Anmeldung, nicht bei der Registrierung.
$x = self::padLeft($x, 32, 'x');
$y = self::padLeft($y, 32, 'y');
$algorithm = self::sequence(
self::oid("\x2A\x86\x48\xCE\x3D\x02\x01") // 1.2.840.10045.2.1
. self::oid("\x2A\x86\x48\xCE\x3D\x03\x01\x07") // 1.2.840.10045.3.1.7
);
$point = self::bitString("\x04" . $x . $y);
return self::pem(self::sequence($algorithm . $point));
}
// -----------------------------------------------------------------
// RSA
// -----------------------------------------------------------------
/**
* SubjectPublicKeyInfo fuer einen RSA-Schluessel.
*
* SEQUENCE {
* SEQUENCE { OID rsaEncryption, NULL }
* BIT STRING { SEQUENCE { INTEGER n, INTEGER e } }
* }
*/
private static function rsaPem(array $cose): string
{
$n = self::bytesAt($cose, self::LABEL_N, 'n');
$e = self::bytesAt($cose, self::LABEL_E, 'e');
$algorithm = self::sequence(
self::oid("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") // 1.2.840.113549.1.1.1
. "\x05\x00" // NULL
);
$key = self::bitString(self::sequence(self::integer($n) . self::integer($e)));
return self::pem(self::sequence($algorithm . $key));
}
// -----------------------------------------------------------------
// DER-Bausteine
// -----------------------------------------------------------------
private static function sequence(string $contents): string
{
return "\x30" . self::length($contents) . $contents;
}
private static function oid(string $raw): string
{
return "\x06" . self::length($raw) . $raw;
}
private static function bitString(string $raw): string
{
// Das fuehrende 0x00 sagt: keine ungenutzten Bits im letzten Byte.
$body = "\x00" . $raw;
return "\x03" . self::length($body) . $body;
}
/**
* DER-INTEGER aus einer vorzeichenlosen Zahl.
*
* DER liest Ganzzahlen vorzeichenbehaftet. Ein Modulus, dessen oberstes
* Bit gesetzt ist - und das ist bei einem RSA-Modulus immer der Fall -
* waere ohne die vorangestellte Null eine negative Zahl.
*/
private static function integer(string $raw): string
{
$raw = ltrim($raw, "\x00");
if ($raw === '') {
$raw = "\x00";
} elseif ((ord($raw[0]) & 0x80) !== 0) {
$raw = "\x00" . $raw;
}
return "\x02" . self::length($raw) . $raw;
}
private static function length(string $contents): string
{
$length = strlen($contents);
if ($length < 0x80) {
return chr($length);
}
$bytes = '';
while ($length > 0) {
$bytes = chr($length & 0xFF) . $bytes;
$length >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
private static function pem(string $der): string
{
return "-----BEGIN PUBLIC KEY-----\n"
. chunk_split(base64_encode($der), 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
// -----------------------------------------------------------------
// Zugriff auf die COSE-Abbildung
// -----------------------------------------------------------------
private static function intAt(array $cose, int $label, string $name): int
{
if (!array_key_exists($label, $cose) || !is_int($cose[$label])) {
throw new \RuntimeException(sprintf('COSE-Schluessel: Feld "%s" fehlt oder ist keine Zahl.', $name));
}
return $cose[$label];
}
private static function bytesAt(array $cose, int $label, string $name): string
{
if (!array_key_exists($label, $cose) || !is_string($cose[$label]) || $cose[$label] === '') {
throw new \RuntimeException(sprintf('COSE-Schluessel: Feld "%s" fehlt oder ist leer.', $name));
}
return $cose[$label];
}
private static function padLeft(string $raw, int $size, string $name): string
{
if (strlen($raw) > $size) {
throw new \RuntimeException(sprintf('COSE-Schluessel: Feld "%s" ist laenger als %d Byte.', $name, $size));
}
return str_pad($raw, $size, "\x00", STR_PAD_LEFT);
}
}
+303
View File
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Registrierte Hardwarekeys und der Notfalltoken eines Kontos.
*
* Die beiden gehoeren zusammen, weil sie dieselbe Frage beantworten: Wer
* ausser dem Passwort noch etwas vorzeigen muss, braucht einen Weg zurueck,
* wenn das Vorzuzeigende im Zug liegen geblieben ist.
*/
final class CredentialRepo
{
/** Zeichen des Notfalltokens - ohne 0/O und 1/I, die beim Abschreiben kippen. */
private const TOKEN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
/** Gruppen zu je vier Zeichen, damit man ihn vorlesen kann. */
private const TOKEN_GROUPS = 5;
public function __construct(private PDO $db)
{
}
// -----------------------------------------------------------------
// Hardwarekeys
// -----------------------------------------------------------------
/**
* Alle Schluessel eines Kontos, aufbereitet fuer die Anzeige.
*
* @return list<array<string,mixed>>
*/
public function forUser(int $userId): array
{
$stmt = $this->db->prepare('
SELECT id, credential_id, label, sign_count, transports, created_at, last_used_at
FROM dc_user_credentials
WHERE user_id = :u
ORDER BY created_at ASC
');
$stmt->execute([':u' => $userId]);
$out = [];
foreach ($stmt->fetchAll() as $row) {
$row['credential_b64'] = WebauthnServer::b64uEncode((string)$row['credential_id']);
// Die rohe Kennung hat in der Oberflaeche nichts zu suchen - sie
// ist binaer und wuerde jede HTML-Ausgabe verunstalten.
unset($row['credential_id']);
$out[] = $row;
}
return $out;
}
/**
* Die rohen Kennungen eines Kontos, fuer allowCredentials.
*
* @return list<string>
*/
public function rawIdsForUser(int $userId): array
{
$stmt = $this->db->prepare('SELECT credential_id FROM dc_user_credentials WHERE user_id = :u');
$stmt->execute([':u' => $userId]);
return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}
public function countForUser(int $userId): int
{
$stmt = $this->db->prepare('SELECT COUNT(*) FROM dc_user_credentials WHERE user_id = :u');
$stmt->execute([':u' => $userId]);
return (int)$stmt->fetchColumn();
}
/**
* Sucht einen Schluessel - und zwar am Konto, nicht nur an der Kennung.
*
* Die Einschraenkung auf das Konto ist der Grund, warum diese Methode
* nicht bloss die Kennung nimmt: Sonst koennte der Schluessel eines
* beliebigen anderen Kontos eine Anmeldung abschliessen, solange er nur
* gueltig unterschreibt.
*
* @return array<string,mixed>|null
*/
public function find(int $userId, string $rawCredentialId): ?array
{
$stmt = $this->db->prepare('
SELECT * FROM dc_user_credentials
WHERE user_id = :u AND credential_id = :c
LIMIT 1
');
$stmt->bindValue(':u', $userId, PDO::PARAM_INT);
$stmt->bindValue(':c', $rawCredentialId, PDO::PARAM_LOB);
$stmt->execute();
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
public function add(
int $userId,
string $credentialId,
string $publicKeyPem,
int $alg,
int $signCount,
?string $aaguid,
string $label,
?string $transports
): void {
$stmt = $this->db->prepare('
INSERT INTO dc_user_credentials
(user_id, credential_id, public_key, alg, sign_count, aaguid, transports, label, created_at)
VALUES
(:u, :c, :pk, :alg, :sc, :aaguid, :transports, :label, UTC_TIMESTAMP())
');
$stmt->bindValue(':u', $userId, PDO::PARAM_INT);
$stmt->bindValue(':c', $credentialId, PDO::PARAM_LOB);
$stmt->bindValue(':pk', $publicKeyPem);
$stmt->bindValue(':alg', $alg, PDO::PARAM_INT);
$stmt->bindValue(':sc', $signCount, PDO::PARAM_INT);
$stmt->bindValue(':aaguid', $aaguid, $aaguid === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':transports', $transports, $transports === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->bindValue(':label', $label);
$stmt->execute();
Logger::info('Hardwarekey registriert', ['user_id' => $userId, 'label' => $label]);
}
public function touch(int $credentialRowId, int $signCount): void
{
$stmt = $this->db->prepare('
UPDATE dc_user_credentials
SET sign_count = :sc, last_used_at = UTC_TIMESTAMP()
WHERE id = :id
');
$stmt->execute([':sc' => $signCount, ':id' => $credentialRowId]);
}
/** Entfernt einen Schluessel. Das Konto begrenzt, wessen Schluessel das sein darf. */
public function delete(int $userId, int $credentialRowId): bool
{
$stmt = $this->db->prepare('DELETE FROM dc_user_credentials WHERE id = :id AND user_id = :u');
$stmt->execute([':id' => $credentialRowId, ':u' => $userId]);
$removed = $stmt->rowCount() > 0;
if ($removed) {
Logger::info('Hardwarekey entfernt', ['user_id' => $userId, 'credential' => $credentialRowId]);
}
return $removed;
}
public function rename(int $userId, int $credentialRowId, string $label): bool
{
$stmt = $this->db->prepare('UPDATE dc_user_credentials SET label = :l WHERE id = :id AND user_id = :u');
$stmt->execute([':l' => $label, ':id' => $credentialRowId, ':u' => $userId]);
return $stmt->rowCount() > 0;
}
// -----------------------------------------------------------------
// Notfalltoken
// -----------------------------------------------------------------
/**
* Erzeugt einen neuen Notfalltoken und liefert ihn im Klartext zurueck.
*
* Der Rueckgabewert ist die einzige Gelegenheit, ihn zu sehen. In der
* Datenbank landet nur der Hash - wer sie liest, haelt damit keinen
* Zweitschluessel in der Hand. Ein vorhandener Token wird dabei
* ueberschrieben; zwei gueltige Notfallwege waeren einer zu viel.
*/
public function issueRecoveryToken(int $userId): string
{
$token = self::generateToken();
$stmt = $this->db->prepare('
UPDATE dc_users
SET recovery_hash = :h, recovery_created_at = UTC_TIMESTAMP(), recovery_used_at = NULL
WHERE id = :id
');
$stmt->execute([':h' => password_hash($token, PASSWORD_DEFAULT), ':id' => $userId]);
Logger::info('Notfalltoken erzeugt', ['user_id' => $userId]);
return $token;
}
/**
* Prueft einen Notfalltoken und verbraucht ihn bei Erfolg.
*
* Einmalgebrauch heisst hier woertlich: Nach dem erfolgreichen Vorzeigen
* ist der Hash weg. Wer sich damit angemeldet hat, muss sich in der
* Oberflaeche einen neuen erzeugen - und merkt dadurch, dass er es tun
* muss, statt es zu vergessen.
*/
public function consumeRecoveryToken(int $userId, string $presented): bool
{
$stmt = $this->db->prepare('SELECT recovery_hash FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$hash = $stmt->fetchColumn();
if (!is_string($hash) || $hash === '') {
// Trotzdem einmal rechnen: Ob ein Notfalltoken hinterlegt ist,
// soll sich nicht an der Antwortzeit ablesen lassen.
password_verify($presented, self::dummyHash());
return false;
}
if (!password_verify(self::normalizeToken($presented), $hash)) {
return false;
}
$clear = $this->db->prepare('
UPDATE dc_users
SET recovery_hash = NULL, recovery_used_at = UTC_TIMESTAMP()
WHERE id = :id
');
$clear->execute([':id' => $userId]);
Logger::warning('Anmeldung mit Notfalltoken', ['user_id' => $userId]);
return true;
}
/**
* Zustand des Notfalltokens fuer die Anzeige.
*
* @return array{present:bool,created_at:?string,used_at:?string}
*/
public function recoveryState(int $userId): array
{
$stmt = $this->db->prepare('
SELECT recovery_hash, recovery_created_at, recovery_used_at
FROM dc_users WHERE id = :id LIMIT 1
');
$stmt->execute([':id' => $userId]);
$row = $stmt->fetch();
if (!is_array($row)) {
return ['present' => false, 'created_at' => null, 'used_at' => null];
}
return [
'present' => is_string($row['recovery_hash'] ?? null) && $row['recovery_hash'] !== '',
'created_at' => $row['recovery_created_at'] ?? null,
'used_at' => $row['recovery_used_at'] ?? null,
];
}
/**
* Der Klartext, den der Betreiber notiert: fuenf Gruppen zu vier Zeichen.
*/
private static function generateToken(): string
{
$alphabet = self::TOKEN_ALPHABET;
$max = strlen($alphabet) - 1;
$groups = [];
for ($g = 0; $g < self::TOKEN_GROUPS; $g++) {
$group = '';
for ($i = 0; $i < 4; $i++) {
$group .= $alphabet[random_int(0, $max)];
}
$groups[] = $group;
}
return implode('-', $groups);
}
/**
* Macht aus dem Abgetippten das, was gehasht wurde.
*
* Bindestriche und Leerzeichen fallen weg, Kleinbuchstaben werden gross:
* Der Token wird von einem Zettel abgeschrieben, und ein vergessener
* Bindestrich ist kein Grund, jemanden auszusperren.
*/
private static function normalizeToken(string $presented): string
{
$stripped = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $presented) ?? '');
$groups = str_split($stripped, 4);
return implode('-', $groups);
}
private static function dummyHash(): string
{
static $hash = null;
if ($hash === null) {
$hash = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT);
}
return $hash;
}
}
+369
View File
@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Http;
/**
* Die beiden WebAuthn-Zeremonien: Registrieren und Anmelden.
*
* Die Klasse haelt keinen Zustand. Die Herausforderung (challenge) erzeugt
* sie, aufbewahren muss sie der Aufrufer - bei uns in der Sitzung. Das ist
* Absicht: Eine Herausforderung, die hier liegen bliebe, waere entweder
* global (und damit fuer parallele Anmeldungen falsch) oder sie braeuchte
* eine eigene Tabelle fuer etwas, das ohnehin nur zwei Minuten lebt.
*
* Zur Attestation: Geprueft wird sie nicht ("none"). Attestation beantwortet
* die Frage "stammt dieser Schluessel wirklich von Hersteller X" - das ist
* die Frage eines Unternehmens, das Geraete vorschreibt. Hier registriert der
* Betreiber seinen eigenen Schluessel an seinem eigenen Server; die Frage
* stellt sich nicht, und ihre Beantwortung braeuchte eine gepflegte Liste von
* Herstellerzertifikaten. Was zaehlt - dass spaetere Anmeldungen mit
* demselben privaten Schluessel unterschrieben sind - haengt an der
* Attestation nicht.
*/
final class WebauthnServer
{
/** Was bei der Registrierung angeboten wird, in dieser Reihenfolge. */
public const ALGORITHMS = [CoseKey::ES256, CoseKey::RS256];
/** Lebensdauer einer Herausforderung in Sekunden. */
public const CHALLENGE_TTL = 300;
// Bits im flags-Byte der Authenticator-Daten
private const FLAG_USER_PRESENT = 0x01;
private const FLAG_USER_VERIFIED = 0x04;
private const FLAG_ATTESTED_DATA = 0x40;
/**
* Die Kennung, auf die ein Schluessel festgelegt wird.
*
* Ein registrierter Schluessel gilt nur fuer genau diese Kennung. Damit
* ist ein Schluessel von dc.mhdf.de auf dc-dev.mhdf.de wertlos und
* umgekehrt - auf beiden Instanzen muss einmal registriert werden. Das
* ist kein Mangel der Umsetzung, sondern der Kern des Verfahrens: genau
* deshalb laeuft ein Anmeldeversuch auf einer nachgebauten Seite ins
* Leere.
*/
public static function rpId(): string
{
$configured = trim((string)Config::get('security.webauthn_rp_id', ''));
if ($configured !== '') {
return $configured;
}
$host = parse_url(Http::baseUrl(), PHP_URL_HOST);
return is_string($host) && $host !== '' ? $host : 'localhost';
}
/** Der Ursprung, den der Browser in clientDataJSON eintragen muss. */
public static function expectedOrigin(): string
{
return rtrim(Http::baseUrl(), '/');
}
public static function challenge(): string
{
return random_bytes(32);
}
// -----------------------------------------------------------------
// Registrieren
// -----------------------------------------------------------------
/**
* Die Vorgaben, mit denen der Browser navigator.credentials.create() ruft.
*
* @param list<string> $existingCredentialIds Roh, nicht kodiert
* @return array<string,mixed>
*/
public static function registrationOptions(
string $challenge,
int $userId,
string $username,
array $existingCredentialIds
): array {
$exclude = [];
foreach ($existingCredentialIds as $id) {
$exclude[] = ['type' => 'public-key', 'id' => self::b64uEncode($id)];
}
$params = [];
foreach (self::ALGORITHMS as $alg) {
$params[] = ['type' => 'public-key', 'alg' => $alg];
}
return [
'challenge' => self::b64uEncode($challenge),
'rp' => [
'id' => self::rpId(),
'name' => (string)Config::get('app.name', 'Deploymentcenter'),
],
'user' => [
// Die Nutzerkennung ist die interne ID, nicht der Name. Wird
// das Konto umbenannt, bleiben registrierte Schluessel damit
// gueltig - haette hier der Name gestanden, waere jede
// Umbenennung ein stiller Verlust aller Schluessel.
'id' => self::b64uEncode((string)$userId),
'name' => $username,
'displayName' => $username,
],
'pubKeyCredParams' => $params,
'timeout' => self::CHALLENGE_TTL * 1000,
'attestation' => 'none',
'excludeCredentials' => $exclude,
'authenticatorSelection' => [
// "cross-platform": gemeint ist ein Stueck Hardware zum
// Anstecken, nicht der Fingerabdrucksensor des Rechners, an
// dem man gerade sitzt. Wer Windows Hello registriert, hat
// einen zweiten Faktor, der mit dem ersten im selben Geraet
// sitzt.
'authenticatorAttachment' => 'cross-platform',
'userVerification' => 'discouraged',
'residentKey' => 'discouraged',
],
];
}
/**
* Prueft die Antwort des Browsers auf create() und liefert, was gespeichert wird.
*
* @return array{credential_id:string,public_key:string,sign_count:int,aaguid:?string,alg:int}
*/
public static function verifyRegistration(
string $clientDataJson,
string $attestationObject,
string $expectedChallenge
): array {
self::checkClientData($clientDataJson, 'webauthn.create', $expectedChallenge);
$attestation = Cbor::decodeComplete($attestationObject);
if (!is_array($attestation) || !isset($attestation['authData']) || !is_string($attestation['authData'])) {
throw new \RuntimeException('Das Attestationsobjekt enthaelt keine Authenticator-Daten.');
}
$authData = self::parseAuthData($attestation['authData'], true);
if (($authData['flags'] & self::FLAG_ATTESTED_DATA) === 0) {
throw new \RuntimeException('Der Authenticator hat keinen Schluessel mitgeschickt.');
}
$key = CoseKey::toPem($authData['cose']);
return [
'credential_id' => $authData['credential_id'],
'public_key' => $key['pem'],
'sign_count' => $authData['sign_count'],
'aaguid' => $authData['aaguid'],
'alg' => $key['alg'],
];
}
// -----------------------------------------------------------------
// Anmelden
// -----------------------------------------------------------------
/**
* Die Vorgaben, mit denen der Browser navigator.credentials.get() ruft.
*
* @param list<string> $credentialIds Roh, nicht kodiert
* @return array<string,mixed>
*/
public static function assertionOptions(string $challenge, array $credentialIds): array
{
$allow = [];
foreach ($credentialIds as $id) {
$allow[] = ['type' => 'public-key', 'id' => self::b64uEncode($id)];
}
return [
'challenge' => self::b64uEncode($challenge),
'rpId' => self::rpId(),
'timeout' => self::CHALLENGE_TTL * 1000,
'userVerification' => 'discouraged',
'allowCredentials' => $allow,
];
}
/**
* Prueft die Antwort des Browsers auf get().
*
* @return array{sign_count:int}
*/
public static function verifyAssertion(
string $clientDataJson,
string $authenticatorData,
string $signature,
string $expectedChallenge,
string $publicKeyPem,
int $alg,
int $storedSignCount
): array {
self::checkClientData($clientDataJson, 'webauthn.get', $expectedChallenge);
$authData = self::parseAuthData($authenticatorData, false);
// Unterschrieben wird ueber die Authenticator-Daten und den Hash der
// Clientdaten - in dieser Reihenfolge, unveraendert.
$signed = $authenticatorData . hash('sha256', $clientDataJson, true);
$key = openssl_pkey_get_public($publicKeyPem);
if ($key === false) {
throw new \RuntimeException('Der hinterlegte oeffentliche Schluessel ist unlesbar.');
}
$ok = openssl_verify($signed, $signature, $key, CoseKey::digestFor($alg));
if ($ok !== 1) {
throw new \RuntimeException('Die Signatur des Hardwarekeys stimmt nicht.');
}
// Klonerkennung. Ein Authenticator zaehlt bei jeder Benutzung hoch;
// sieht der Server denselben oder einen kleineren Wert wieder, gibt
// es den Schluessel zweimal. Ausgenommen ist der Fall, dass beide
// Werte 0 sind: Ein Teil der Hardware fuehrt den Zaehler gar nicht,
// und "zaehlt nicht" darf nicht wie "geklont" aussehen.
if (!($storedSignCount === 0 && $authData['sign_count'] === 0)
&& $authData['sign_count'] <= $storedSignCount) {
throw new \RuntimeException(sprintf(
'Der Zaehler des Hardwarekeys ist nicht gestiegen (%d nach %d). Moeglicherweise ein Duplikat.',
$authData['sign_count'],
$storedSignCount
));
}
return ['sign_count' => $authData['sign_count']];
}
// -----------------------------------------------------------------
// Gemeinsame Pruefungen
// -----------------------------------------------------------------
private static function checkClientData(string $json, string $expectedType, string $expectedChallenge): void
{
$data = json_decode($json, true);
if (!is_array($data)) {
throw new \RuntimeException('Die Clientdaten sind kein lesbares JSON.');
}
if (($data['type'] ?? null) !== $expectedType) {
throw new \RuntimeException(sprintf(
'Unerwartete Zeremonie: "%s" statt "%s".',
is_string($data['type'] ?? null) ? $data['type'] : '?',
$expectedType
));
}
$presented = is_string($data['challenge'] ?? null) ? self::b64uDecode($data['challenge']) : '';
if ($presented === '' || !hash_equals($expectedChallenge, $presented)) {
throw new \RuntimeException('Die Herausforderung stimmt nicht. Bitte erneut versuchen.');
}
$origin = is_string($data['origin'] ?? null) ? $data['origin'] : '';
if (!hash_equals(self::expectedOrigin(), $origin)) {
throw new \RuntimeException(sprintf(
'Die Anmeldung kam von "%s", erwartet wird "%s".',
$origin,
self::expectedOrigin()
));
}
}
/**
* Zerlegt die Authenticator-Daten.
*
* 32 Byte rpIdHash | 1 Byte flags | 4 Byte signCount
* danach bei einer Registrierung:
* 16 Byte aaguid | 2 Byte Laenge | credentialId | COSE-Schluessel
*
* @return array{flags:int,sign_count:int,credential_id:string,aaguid:?string,cose:array}
*/
private static function parseAuthData(string $authData, bool $expectCredential): array
{
if (strlen($authData) < 37) {
throw new \RuntimeException('Die Authenticator-Daten sind zu kurz.');
}
$rpIdHash = substr($authData, 0, 32);
if (!hash_equals(hash('sha256', self::rpId(), true), $rpIdHash)) {
throw new \RuntimeException(sprintf(
'Der Schluessel gehoert zu einer anderen Adresse als "%s".',
self::rpId()
));
}
$flags = ord($authData[32]);
if (($flags & self::FLAG_USER_PRESENT) === 0) {
throw new \RuntimeException('Der Hardwarekey wurde nicht beruehrt.');
}
$unpacked = unpack('Ncount', substr($authData, 33, 4));
$signCount = is_array($unpacked) ? (int)$unpacked['count'] : 0;
$result = [
'flags' => $flags,
'sign_count' => $signCount,
'credential_id' => '',
'aaguid' => null,
'cose' => [],
];
if (!$expectCredential) {
return $result;
}
if (strlen($authData) < 55) {
throw new \RuntimeException('Die Authenticator-Daten enthalten keine Schluesseldaten.');
}
$aaguid = substr($authData, 37, 16);
$lengthBytes = unpack('nlen', substr($authData, 53, 2));
$credentialLength = is_array($lengthBytes) ? (int)$lengthBytes['len'] : 0;
if ($credentialLength < 1 || $credentialLength > 1023 || strlen($authData) < 55 + $credentialLength) {
throw new \RuntimeException('Die Laengenangabe der Schluesselkennung ist unbrauchbar.');
}
$result['credential_id'] = substr($authData, 55, $credentialLength);
$result['aaguid'] = $aaguid === str_repeat("\x00", 16) ? null : $aaguid;
// Der COSE-Schluessel schliesst sich unmittelbar an. Der Versatz
// interessiert danach nicht mehr: Hinter dem Schluessel stehen nur
// noch Erweiterungen, die wir nicht auswerten.
$offset = 55 + $credentialLength;
$cose = Cbor::decode($authData, $offset);
if (!is_array($cose)) {
throw new \RuntimeException('Der mitgelieferte Schluessel ist keine COSE-Abbildung.');
}
$result['cose'] = $cose;
return $result;
}
// -----------------------------------------------------------------
// base64url - ohne Auffuellzeichen, mit - und _ statt + und /
// -----------------------------------------------------------------
public static function b64uEncode(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
public static function b64uDecode(string $encoded): string
{
$normalized = strtr($encoded, '-_', '+/');
$padded = str_pad($normalized, (int)(ceil(strlen($normalized) / 4) * 4), '=', STR_PAD_RIGHT);
$decoded = base64_decode($padded, true);
return $decoded === false ? '' : $decoded;
}
}
+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);
}
/**
+563
View File
@@ -0,0 +1,563 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Bugtracker;
use PDO;
/**
* Die Roadmap als Tafel: Spalten entlang der Prioritaet, Spuren je Bereich.
*
* Der Server liefert die fertige Gliederung, die Darstellung rendert nur.
* Das ist keine Geschmacksfrage: Sobald das Frontend selbst schneidet,
* gruppiert und einfaerbt, erzaehlen WebUI, CLI und ein spaeterer Export
* drei Geschichten aus denselben Daten - und der erzeugte Markdown-Block in
* docs/ROADMAP.md eine vierte.
*
* Keine eigene Datenhaltung ausser den Phasengrenzen. Ein Kaertchen ist ein
* bugtracker_item; alles, was daran steht, steckt in Feldern, die es schon
* gibt.
*/
final class RoadmapBoard
{
/**
* Wie viele erledigte Vorhaben die Fundamentspalte zeigt.
*
* Bei uns sind es rund sechzig, bei Predictalytics mehr. Ungekappt
* erschlaegt die Spalte die Tafel und beantwortet dabei die Frage nicht,
* fuer die man eine Roadmap aufschlaegt.
*/
public const DONE_LIMIT = 12;
/** Juenger als das gilt als neu. */
public const NEW_DAYS = 14;
/** Hoechstzahl automatisch geschnittener Phasen. */
private const MAX_AUTO_PHASES = 5;
/** So viele Eintraege muss eine automatisch geschnittene Phase mindestens haben. */
private const MIN_AUTO_PHASE_SIZE = 5;
/** Vorschlaege fuer die Beschriftung automatisch geschnittener Phasen. */
private const AUTO_LABELS = [
['Jetzt', 'Was gerade ansteht'],
['Als Naechstes', 'Direkt danach'],
['Danach', 'Mittlere Sicht'],
['Spaeter', 'Noch ohne Termin'],
['Irgendwann', 'Am Ende der Liste'],
];
/** Status, die auf der Tafel gar nicht erscheinen. */
private const HIDDEN_STATUSES = ['rejected', 'ignored'];
public function __construct(private PDO $db)
{
}
/**
* Die ganze Tafel eines Projekts.
*
* @param array{done_limit?:int,include_done?:bool} $options
* @return array<string,mixed>
*/
public function build(string $projectSlug, array $options = []): array
{
$doneLimit = (int)($options['done_limit'] ?? self::DONE_LIMIT);
$rows = $this->load($projectSlug);
$done = [];
$thinking = [];
$ranked = [];
foreach ($rows as $row) {
$status = (string)$row['status'];
if (in_array($status, self::HIDDEN_STATUSES, true)) {
continue;
}
if (in_array($status, ['resolved', 'closed'], true)) {
$done[] = $row;
continue;
}
// "Wird ueberlegt": Zurueckgestelltes, Ideen und alles, was noch
// keinen Rang hat. Letzteres ist der wichtigste Fall - ein
// Vorhaben ohne Rang ist nicht eingeordnet, und das soll man
// sehen, statt es ans Ende einer Spalte zu sortieren, wo es wie
// eingeordnet aussieht.
if ($status === 'deferred'
|| $row['roadmap_rank'] === null
|| (string)$row['severity'] === 'idea') {
$thinking[] = $row;
continue;
}
$ranked[] = $row;
}
$doneTotal = count($done);
usort($done, static fn ($a, $b) => strcmp((string)$b['updated_at'], (string)$a['updated_at']));
$doneShown = $doneLimit > 0 ? array_slice($done, 0, $doneLimit) : $done;
$phases = $this->phases($projectSlug, $ranked);
// --- Kaertchen den Spalten zuordnen -----------------------------
$items = [];
foreach ($doneShown as $row) {
$items[] = $this->card($row, 'fundament');
}
foreach ($ranked as $row) {
$items[] = $this->card($row, $this->phaseFor($phases, (int)$row['roadmap_rank']));
}
foreach ($thinking as $row) {
$items[] = $this->card($row, 'ueberlegt');
}
return [
'project' => $projectSlug,
'phases' => $this->phaseList($phases, $doneTotal, count($doneShown), count($thinking)),
'lanes' => $this->lanes($projectSlug, $items),
'items' => $items,
'stats' => [
'done' => $doneTotal,
'planned' => count($ranked),
'thinking' => count($thinking),
'highlight' => $this->countHighlighted($projectSlug, $items),
],
'meta' => [
'generated_at' => gmdate('Y-m-d H:i:s'),
'phases_guessed' => $phases['guessed'],
'done_shown' => count($doneShown),
'done_total' => $doneTotal,
'done_limit' => $doneLimit,
],
];
}
// ==================================================================
// Laden
// ==================================================================
/** @return list<array<string,mixed>> */
private function load(string $projectSlug): array
{
$stmt = $this->db->prepare('
SELECT id, project_slug, type, title, status, severity, epic, roadmap_rank,
target_build, defer_trigger, doc_anchor, claimed_by, lease_until,
occurrence_count, created_at, updated_at
FROM bugtracker_items
WHERE project_slug = :slug
ORDER BY roadmap_rank IS NULL ASC, roadmap_rank ASC, id ASC
');
$stmt->execute([':slug' => $projectSlug]);
return $stmt->fetchAll() ?: [];
}
// ==================================================================
// Phasen
// ==================================================================
/**
* Die Rangphasen: gepflegt, oder aus den groessten Luecken geschnitten.
*
* @param list<array<string,mixed>> $ranked
* @return array{list:list<array<string,mixed>>,guessed:bool}
*/
private function phases(string $projectSlug, array $ranked): array
{
$configured = $this->configuredPhases($projectSlug);
if ($configured !== []) {
return ['list' => $configured, 'guessed' => false];
}
return ['list' => $this->autoPhases($ranked), 'guessed' => true];
}
/** @return list<array<string,mixed>> */
private function configuredPhases(string $projectSlug): array
{
try {
$stmt = $this->db->prepare('
SELECT sort_order, rank_from, rank_to, label, subtitle
FROM roadmap_phases
WHERE project_slug = :slug
ORDER BY sort_order ASC, rank_from ASC
');
$stmt->execute([':slug' => $projectSlug]);
$rows = $stmt->fetchAll() ?: [];
} catch (\Throwable $e) {
// Vor Migration 019 gibt es die Tabelle nicht. Dann wird
// geschnitten - das ist der Normalfall, nicht der Fehlerfall.
return [];
}
$out = [];
foreach ($rows as $i => $row) {
$out[] = [
'key' => 'p' . (int)$row['sort_order'],
'label' => (string)$row['label'],
'subtitle' => $row['subtitle'] !== null ? (string)$row['subtitle'] : null,
'rank_from' => (int)$row['rank_from'],
'rank_to' => (int)$row['rank_to'],
'kind' => $i === 0 ? 'now' : 'plan',
];
}
return $out;
}
/**
* Schneidet an den groessten Luecken der Rangfolge.
*
* Die Grenzen sind geraten, und die Tafel sagt das auch - "phases_guessed".
* Geraten ist hier trotzdem besser als fest verdrahtet: roadmap_rank wird
* je Projekt frei vergeben, und wer 5/10/15 benutzt, hat andere Abstaende
* als jemand, der in Hundertern zaehlt. Eine Grenze im Code waere fuer das
* eine Projekt richtig und fuer jedes andere falsch.
*
* @param list<array<string,mixed>> $ranked
* @return list<array<string,mixed>>
*/
private function autoPhases(array $ranked): array
{
if ($ranked === []) {
return [];
}
$ranks = [];
foreach ($ranked as $row) {
$ranks[] = (int)$row['roadmap_rank'];
}
sort($ranks);
$count = count($ranks);
// Zu wenige Eintraege fuer eine Aufteilung: eine Spalte, fertig.
if ($count < self::MIN_AUTO_PHASE_SIZE * 2) {
return [[
'key' => 'p0',
'label' => self::AUTO_LABELS[0][0],
'subtitle' => self::AUTO_LABELS[0][1],
'rank_from' => $ranks[0],
'rank_to' => $ranks[$count - 1],
'kind' => 'now',
]];
}
// Luecken sammeln: Position i heisst "zwischen Index i und i+1".
$gaps = [];
for ($i = 0; $i < $count - 1; $i++) {
$gaps[] = ['at' => $i, 'size' => $ranks[$i + 1] - $ranks[$i]];
}
usort($gaps, static fn ($a, $b) => $b['size'] <=> $a['size']);
// Groesste Luecken der Reihe nach annehmen, solange auf beiden Seiten
// genug uebrig bleibt.
$cuts = [];
foreach ($gaps as $gap) {
if (count($cuts) >= self::MAX_AUTO_PHASES - 1) {
break;
}
if ($gap['size'] <= 0) {
continue;
}
$candidate = $cuts;
$candidate[] = $gap['at'];
sort($candidate);
if ($this->segmentsLargeEnough($candidate, $count)) {
$cuts = $candidate;
}
}
sort($cuts);
$phases = [];
$start = 0;
foreach (array_merge($cuts, [$count - 1]) as $index => $cutAt) {
$labels = self::AUTO_LABELS[min($index, count(self::AUTO_LABELS) - 1)];
$phases[] = [
'key' => 'p' . $index,
'label' => $labels[0],
'subtitle' => $labels[1],
'rank_from' => $ranks[$start],
'rank_to' => $ranks[$cutAt],
'kind' => $index === 0 ? 'now' : 'plan',
];
$start = $cutAt + 1;
}
return $phases;
}
/**
* Haben alle Abschnitte die Mindestgroesse?
*
* @param list<int> $cuts Aufsteigend sortierte Schnittpositionen
*/
private function segmentsLargeEnough(array $cuts, int $count): bool
{
$start = 0;
foreach (array_merge($cuts, [$count - 1]) as $cutAt) {
if (($cutAt - $start + 1) < self::MIN_AUTO_PHASE_SIZE) {
return false;
}
$start = $cutAt + 1;
}
return true;
}
/**
* In welche Spalte gehoert dieser Rang?
*
* @param array{list:list<array<string,mixed>>,guessed:bool} $phases
*/
private function phaseFor(array $phases, int $rank): string
{
$last = 'p0';
foreach ($phases['list'] as $phase) {
if ($rank >= $phase['rank_from'] && $rank <= $phase['rank_to']) {
return (string)$phase['key'];
}
if ($rank > $phase['rank_to']) {
$last = (string)$phase['key'];
}
}
// Ein Rang ausserhalb aller gepflegten Bereiche - er faellt in die
// letzte Spalte, die noch unter ihm liegt, sonst in die erste. Ein
// Kaertchen darf nicht verschwinden, nur weil die Grenzen nicht
// nachgezogen wurden.
return $phases['list'] === [] ? 'p0' : $last;
}
/**
* Die Spaltenliste fuer die Darstellung, mit den beiden festen Raendern.
*
* @param array{list:list<array<string,mixed>>,guessed:bool} $phases
* @return list<array<string,mixed>>
*/
private function phaseList(array $phases, int $doneTotal, int $doneShown, int $thinking): array
{
$out = [[
'key' => 'fundament',
'label' => 'Fundament',
'subtitle' => $doneTotal > $doneShown
? sprintf('Traegt heute (%d von %d gezeigt)', $doneShown, $doneTotal)
: 'Traegt heute',
'kind' => 'done',
'rank_from' => null,
'rank_to' => null,
]];
foreach ($phases['list'] as $phase) {
$phase['subtitle'] = $phase['subtitle'] ?? null;
$phase['hint'] = sprintf('Rang %d-%d', $phase['rank_from'], $phase['rank_to']);
$out[] = $phase;
}
$out[] = [
'key' => 'ueberlegt',
'label' => 'Wird ueberlegt',
'subtitle' => 'Zurueckgestelltes, Ideen, noch nicht Eingeordnetes',
'kind' => 'think',
'rank_from' => null,
'rank_to' => null,
'hint' => sprintf('%d ohne festen Platz', $thinking),
];
return $out;
}
// ==================================================================
// Spuren und Kaertchen
// ==================================================================
/**
* Die Spuren: Themenbereiche in ihrer gepflegten Reihenfolge.
*
* @param list<array<string,mixed>> $items
* @return list<array<string,mixed>>
*/
private function lanes(string $projectSlug, array $items): array
{
$used = [];
foreach ($items as $item) {
$used[(string)$item['lane']] = true;
}
$lanes = [];
try {
$stmt = $this->db->prepare('
SELECT epic, title, summary, color, highlight, sort_order
FROM roadmap_epics
WHERE project_slug = :slug
ORDER BY sort_order ASC, epic ASC
');
$stmt->execute([':slug' => $projectSlug]);
$rows = $stmt->fetchAll() ?: [];
} catch (\Throwable $e) {
// Vor Migration 019 fehlen color und highlight.
$stmt = $this->db->prepare('
SELECT epic, title, summary, sort_order
FROM roadmap_epics
WHERE project_slug = :slug
ORDER BY sort_order ASC, epic ASC
');
$stmt->execute([':slug' => $projectSlug]);
$rows = $stmt->fetchAll() ?: [];
}
foreach ($rows as $row) {
$epic = (string)$row['epic'];
if (!isset($used[$epic])) {
continue; // leere Spuren nehmen nur Platz weg
}
$lanes[] = [
'epic' => $epic,
'title' => (string)$row['title'],
'summary' => $row['summary'] !== null ? (string)$row['summary'] : null,
'color' => isset($row['color']) && $row['color'] !== null ? (string)$row['color'] : null,
'highlight' => (int)($row['highlight'] ?? 0) === 1,
];
unset($used[$epic]);
}
// Was uebrig bleibt, hat keinen gepflegten Bereich - und genau das
// soll man sehen. Eine eigene Spur am Ende, statt es unter einen
// fremden Titel zu mischen.
if (isset($used[''])) {
$lanes[] = [
'epic' => '',
'title' => 'Ohne Themenbereich',
'summary' => 'Noch keinem Bereich zugeordnet.',
'color' => null,
'highlight' => false,
];
unset($used['']);
}
foreach (array_keys($used) as $epic) {
$lanes[] = [
'epic' => (string)$epic,
'title' => (string)$epic,
'summary' => 'Bereich ist nicht angelegt.',
'color' => null,
'highlight' => false,
];
}
return $lanes;
}
/**
* Ein Kaertchen. Alle Zustaende kommen aus vorhandenen Feldern.
*
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function card(array $row, string $phase): array
{
$status = (string)$row['status'];
$claimed = is_string($row['claimed_by'] ?? null) && $row['claimed_by'] !== '';
$leaseUntil = $row['lease_until'] ?? null;
$leaseExpired = false;
if ($claimed && is_string($leaseUntil) && $leaseUntil !== '') {
$leaseExpired = strtotime($leaseUntil . ' UTC') < time();
}
// Reihenfolge der Pruefungen ist die Aussage: Nachpruefung steht vor
// "in Arbeit", weil ein Item, dessen Fix nicht trug, keine laufende
// Arbeit ist, sondern eine offene Frage.
if (in_array($status, ['resolved', 'closed'], true)) {
$state = 'done';
} elseif (in_array($status, ['awaiting_verification', 'in_verification', 'fix_insufficient'], true)) {
$state = 'verification';
} elseif ($status === 'deferred') {
$state = 'deferred';
} elseif ((string)$row['severity'] === 'idea') {
$state = 'idea';
} elseif ($status === 'in_progress' || ($claimed && !$leaseExpired)) {
$state = 'working';
} else {
$state = 'open';
}
$created = strtotime((string)$row['created_at'] . ' UTC');
$isNew = $created !== false && (time() - $created) < (self::NEW_DAYS * 86400);
return [
'id' => (int)$row['id'],
'phase' => $phase,
'lane' => (string)($row['epic'] ?? ''),
// Fremdtext von fremden Maschinen. Er geht hier unveraendert
// durch und wird erst beim Rendern escaped - nicht davor, sonst
// stuende die Maskierung im JSON und ein zweiter Verbraucher
// bekaeme sie doppelt.
'title' => (string)$row['title'],
'state' => $state,
'status' => $status,
'status_label' => Roadmap::STATUS_LABELS[$status] ?? $status,
'severity' => (string)$row['severity'],
'type' => (string)$row['type'],
'rank' => $row['roadmap_rank'] !== null ? (int)$row['roadmap_rank'] : null,
'target_build' => $row['target_build'] !== null ? (string)$row['target_build'] : null,
'defer_trigger' => $row['defer_trigger'] !== null ? (string)$row['defer_trigger'] : null,
'doc_anchor' => $row['doc_anchor'] !== null ? (string)$row['doc_anchor'] : null,
'claimed_by' => $claimed ? (string)$row['claimed_by'] : null,
'lease_until' => $claimed && is_string($leaseUntil) ? $leaseUntil : null,
'lease_expired' => $leaseExpired,
'is_new' => $isNew,
'updated_at' => (string)$row['updated_at'],
];
}
/**
* @param list<array<string,mixed>> $items
*/
private function countHighlighted(string $projectSlug, array $items): int
{
$highlighted = [];
try {
$stmt = $this->db->prepare('
SELECT epic FROM roadmap_epics
WHERE project_slug = :slug AND highlight = 1
');
$stmt->execute([':slug' => $projectSlug]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [] as $epic) {
$highlighted[(string)$epic] = true;
}
} catch (\Throwable $e) {
return 0;
}
$count = 0;
foreach ($items as $item) {
if ($item['state'] !== 'done' && isset($highlighted[(string)$item['lane']])) {
$count++;
}
}
return $count;
}
}
+1
View File
@@ -44,6 +44,7 @@ import suite_sperre # noqa: E402,F401
import suite_kern # noqa: E402,F401
import suite_bugtracker # noqa: E402,F401
import suite_dienste # noqa: E402,F401
import suite_tafel # noqa: E402,F401
def main():
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""
Roadmap-Tafel und zweite Stufe der Anmeldung.
Die Tafel wird ueber ihre Struktur geprueft und nicht ueber ihr Aussehen:
Der Server liefert Spalten, Spuren und Zustaende fertig aus, und genau das
ist der Vertrag, auf den sich WebUI, CLI und ein spaeterer Export stuetzen.
Die WebAuthn-Zeremonie selbst laeuft hier nicht mit. Sie braucht einen
Authenticator, der wirklich unterschreibt - dafuer gibt es ein eigenes
Werkzeug ausserhalb der Suite, weil es eine Krypto-Bibliothek verlangt und
diese Suite mit der Standardbibliothek auskommen soll. Was hier geprueft
wird, ist der Rahmen: dass die Endpunkte ohne Zwischenzustand nichts
herausgeben und dass ein sitzungsloser Aufruf abgewiesen wird.
"""
import time
from harness import (test, erwarte, erwarte_status, erwarte_erfolg,
erwarte_fehler, ueberspringen)
PROJEKT = 'myapp' # existiert nach dem Zuruecksetzen, siehe sql/schema.sql
def _item_id(resp):
for name in ('item_id', 'id'):
wert = resp.feld(name)
if isinstance(wert, int) and wert > 0:
return wert
return resp.feld('item', 'id')
def _melden(ctx, titel, ref):
return ctx.client.post('/api/bugtracker/v1/report', body={
'project_slug': PROJEKT,
'title': titel,
'description': 'Angelegt von tests/e2e (Tafel). Darf jederzeit geloescht werden.',
'severity': 'medium',
'environment': 'testing',
'client_ref': ref,
})
# ---------------------------------------------------------------------------
# Aufbau der Tafel
# ---------------------------------------------------------------------------
@test('tafel', 'Tafel liefert Spalten, Spuren und Kaertchen')
def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
erwarte_erfolg(r)
for feld in ('phases', 'lanes', 'items', 'stats', 'meta'):
erwarte(r.feld(feld) is not None, "Feld '%s' fehlt in der Antwort" % feld)
ctx['tafel'] = r.json
@test('tafel', 'Die beiden festen Randspalten sind immer da')
def _(ctx):
"""
"Fundament" links und "Wird ueberlegt" rechts haengen am Status und nicht
am Rang. Sie muessen auch dann stehen, wenn dazwischen gar nichts ist -
sonst haette ein frisches Projekt keine Tafel, sondern eine leere Flaeche.
"""
tafel = ctx.get('tafel')
if tafel is None:
ueberspringen('tafel')
schluessel = [p['key'] for p in tafel['phases']]
erwarte(schluessel[0] == 'fundament', "Erste Spalte ist '%s'" % schluessel[0])
erwarte(schluessel[-1] == 'ueberlegt', "Letzte Spalte ist '%s'" % schluessel[-1])
@test('tafel', 'Geratene Spaltengrenzen sind als solche gekennzeichnet')
def _(ctx):
"""
Ohne gepflegte roadmap_phases schneidet die Tafel selbst. Das darf sie -
aber sie muss es sagen. Eine geratene Grenze, die wie eine Entscheidung
aussieht, ist schlimmer als gar keine.
"""
tafel = ctx.get('tafel')
if tafel is None:
ueberspringen('tafel')
erwarte('phases_guessed' in tafel['meta'],
"meta.phases_guessed fehlt - die Tafel sagt nicht, woher ihre Grenzen kommen")
@test('tafel', 'Erledigte Vorhaben werden gekappt')
def _(ctx):
"""
Bei ueber hundert erledigten Eintraegen erschlaegt die Fundamentspalte
die ganze Tafel. done_limit muss greifen, und die Antwort muss die
Gesamtzahl trotzdem nennen - sonst sieht gekappt wie vollstaendig aus.
"""
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=%s&done_limit=1' % PROJEKT)
erwarte_erfolg(r)
meta = r.feld('meta', default={})
erwarte('done_total' in meta and 'done_shown' in meta,
"meta nennt done_shown/done_total nicht")
erwarte(meta['done_shown'] <= 1,
"done_limit=1 zeigt %s erledigte Eintraege" % meta['done_shown'])
@test('tafel', 'Kaertchen tragen ihren Zustand und ihre Spalte')
def _(ctx):
tafel = ctx.get('tafel')
if tafel is None:
ueberspringen('tafel')
spalten = {p['key'] for p in tafel['phases']}
erlaubt = {'done', 'verification', 'working', 'deferred', 'idea', 'open'}
for karte in tafel['items']:
erwarte(karte['state'] in erlaubt,
"Unbekannter Zustand '%s' an #%s" % (karte['state'], karte['id']))
erwarte(karte['phase'] in spalten,
"Karte #%s zeigt auf die Spalte '%s', die es nicht gibt"
% (karte['id'], karte['phase']))
@test('tafel', 'Nachpruefung wird nicht als erledigt gezeichnet')
def _(ctx):
"""
Der Fall, den die Bastelloesung im Projekt clawddotnet von Hand als
"Status pruefen" danebenschreiben musste: Ein Fix, der nicht getragen
hat, ist keine Erledigung.
"""
r = _melden(ctx, 'Tafel-Testfall: wartet auf Nachpruefung', 'e2e-tafel-verify-%d' % time.time())
erwarte_status(r, 200, 201)
item = _item_id(r)
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
body={'id': item, 'epic': 'T', 'roadmap_rank': 10})
ctx.client.post('/api/bugtracker/v1/manage/?action=status',
body={'id': item, 'status': 'awaiting_verification'})
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
erwarte_erfolg(r)
treffer = [k for k in r.feld('items', default=[]) if k['id'] == item]
erwarte(treffer, "Item #%d taucht auf der Tafel nicht auf" % item)
erwarte(treffer[0]['state'] == 'verification',
"Zustand ist '%s' statt 'verification'" % treffer[0]['state'])
ctx['tafel_item'] = item
@test('tafel', 'Vorhaben ohne Rang landen in "Wird ueberlegt"')
def _(ctx):
"""
Ein Vorhaben ohne Rang ist nicht eingeordnet. Es ans Ende einer Spalte zu
sortieren liesse es eingeordnet aussehen.
"""
r = _melden(ctx, 'Tafel-Testfall: noch nicht eingeordnet', 'e2e-tafel-ohne-%d' % time.time())
erwarte_status(r, 200, 201)
item = _item_id(r)
ctx.client.post('/api/bugtracker/v1/roadmap?action=set', body={'id': item, 'epic': 'T'})
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
treffer = [k for k in r.feld('items', default=[]) if k['id'] == item]
erwarte(treffer, "Item #%d taucht auf der Tafel nicht auf" % item)
erwarte(treffer[0]['phase'] == 'ueberlegt',
"Ungereihtes Item steht in Spalte '%s'" % treffer[0]['phase'])
@test('tafel', 'Tafel verlangt ein einzelnes Projekt')
def _(ctx):
"""
Die Phasen werden aus roadmap_rank geschnitten, und der Rang gilt je
Projekt. Zwei Projekte uebereinanderzulegen ergaebe Spalten, die in
keinem von beiden stimmen.
"""
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=all')
erwarte_fehler(r, http=400)
# ---------------------------------------------------------------------------
# Einsortieren - der Befund aus #177
# ---------------------------------------------------------------------------
@test('tafel', 'rank_between setzt den Eintrag zwischen seine Nachbarn')
def _(ctx):
stempel = int(time.time())
ids = []
for i, rang in enumerate((100, 200, 300)):
r = _melden(ctx, 'Tafel-Testfall: Rangnachbar %d' % i, 'e2e-tafel-rang-%d-%d' % (stempel, i))
erwarte_status(r, 200, 201)
item = _item_id(r)
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
body={'id': item, 'epic': 'T', 'roadmap_rank': rang})
ids.append(item)
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=rank_between',
body={'id': ids[2], 'prev_id': ids[0], 'next_id': ids[1]})
erwarte_erfolg(r)
rang = r.feld('rank')
erwarte(100 < rang < 200, "Rang %s liegt nicht zwischen 100 und 200" % rang)
erwarte(r.feld('renumbered') is False,
"Ohne Not wurde neu nummeriert - das ist genau das, wovor #177 warnt")
ctx['tafel_ids'] = ids
@test('tafel', 'Kein Platz: einmal neu nummerieren, und es steht in der Antwort')
def _(ctx):
"""
Zwischen zwei direkt benachbarten Raengen ist kein Platz. Dann darf neu
nummeriert werden - aber nicht stillschweigend.
"""
ids = ctx.get('tafel_ids')
if not ids:
ueberspringen('tafel_ids')
# Zwei Eintraege direkt nebeneinander setzen
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
body={'id': ids[0], 'roadmap_rank': 500})
ctx.client.post('/api/bugtracker/v1/roadmap?action=set',
body={'id': ids[1], 'roadmap_rank': 501})
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=rank_between',
body={'id': ids[2], 'prev_id': ids[0], 'next_id': ids[1]})
erwarte_erfolg(r)
erwarte(r.feld('renumbered') is True,
"Zwischen 500 und 501 war Platz? renumbered=%s" % r.feld('renumbered'))
# Und danach liegt der Eintrag wirklich dazwischen.
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
raenge = {k['id']: k['rank'] for k in r.feld('items', default=[])}
erwarte(raenge.get(ids[0]) < raenge.get(ids[2]) < raenge.get(ids[1]),
"Nach dem Neunummerieren: %s / %s / %s"
% (raenge.get(ids[0]), raenge.get(ids[2]), raenge.get(ids[1])))
@test('tafel', 'move tauscht mit dem projektweiten Nachbarn (#177)')
def _(ctx):
"""
Der Nachbar wird projektweit gesucht, nicht im eigenen Bereich. Zuvor
sprang ein Eintrag bei einem Klick ueber beliebig viele fremde Vorhaben
hinweg, obwohl die Geste "eine Position" hiess.
"""
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
erwarte_erfolg(r)
gereiht = sorted(
[k for k in r.feld('items', default=[]) if k['rank'] is not None and k['state'] != 'done'],
key=lambda k: (k['rank'], k['id'])
)
if len(gereiht) < 3:
ueberspringen('zu wenige gereihte Eintraege')
mitte, davor = gereiht[1], gereiht[0]
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=move',
body={'id': mitte['id'], 'direction': 'up'})
erwarte_erfolg(r)
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
raenge = {k['id']: k['rank'] for k in r.feld('items', default=[])}
erwarte(raenge.get(mitte['id']) == davor['rank'] and raenge.get(davor['id']) == mitte['rank'],
"Raenge nicht getauscht: #%s %s->%s, #%s %s->%s"
% (mitte['id'], mitte['rank'], raenge.get(mitte['id']),
davor['id'], davor['rank'], raenge.get(davor['id'])))
@test('tafel', 'Themenbereich traegt Farbe und Hervorhebung')
def _(ctx):
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=epic', body={
'project_slug': PROJEKT, 'epic': 'T', 'title': 'Tafel-Testbereich',
'summary': 'Von tests/e2e angelegt.', 'sort_order': 900,
'color': '#F6465D', 'highlight': 1,
})
erwarte_erfolg(r)
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
spuren = {l['epic']: l for l in r.feld('lanes', default=[])}
erwarte('T' in spuren, "Spur 'T' fehlt auf der Tafel")
erwarte(spuren['T']['highlight'] is True, "Hervorhebung nicht uebernommen")
erwarte(spuren['T']['color'] == '#F6465D', "Farbe ist '%s'" % spuren['T']['color'])
@test('tafel', 'Ein Aufruf ohne diese Felder loescht Farbe und Hervorhebung nicht')
def _(ctx):
"""
Das bestehende Formular im WebUI kennt color und highlight nicht. Wuerde
ein Aufruf ohne sie die Werte zuruecksetzen, waere jede Titelkorrektur
ein stiller Verlust.
"""
r = ctx.client.post('/api/bugtracker/v1/roadmap?action=epic', body={
'project_slug': PROJEKT, 'epic': 'T', 'title': 'Tafel-Testbereich (umbenannt)',
'sort_order': 900,
})
erwarte_erfolg(r)
r = ctx.client.get('/api/bugtracker/v1/roadmap?action=board&project=' + PROJEKT)
spuren = {l['epic']: l for l in r.feld('lanes', default=[])}
erwarte(spuren.get('T', {}).get('highlight') is True,
"Hervorhebung ging bei einem Aufruf ohne das Feld verloren")
erwarte(spuren.get('T', {}).get('color') == '#F6465D',
"Farbe ging bei einem Aufruf ohne das Feld verloren")
# ---------------------------------------------------------------------------
# Zweite Stufe der Anmeldung
# ---------------------------------------------------------------------------
@test('tafel', 'WebAuthn-Endpunkt gibt ohne offene Anmeldung nichts heraus')
def _(ctx):
"""
login_options laeuft im Zwischenzustand zwischen Passwort und zweitem
Faktor. Ohne diesen Zustand darf der Endpunkt nicht verraten, ob es ein
Konto gibt oder welche Schluessel dazu hinterlegt sind.
"""
r = ctx.client.post('/api/auth/v1/?action=login_options', body={}, token=None)
erwarte_status(r, 401)
erwarte(r.feld('error', 'code') == 'no_pending_login',
"Fehlercode ist '%s'" % r.feld('error', 'code'))
@test('tafel', 'Registrieren verlangt eine Anmeldung')
def _(ctx):
r = ctx.client.post('/api/auth/v1/?action=register_options', body={}, token=None)
erwarte_status(r, 401)
@test('tafel', 'Unbekannte Zeremonie wird abgewiesen')
def _(ctx):
r = ctx.client.post('/api/auth/v1/?action=rundgang', body={}, token=None)
erwarte_status(r, 404)
erwarte(isinstance(r.feld('error', 'available'), list),
"Die Absage nennt die moeglichen Aktionen nicht")
+15
View File
@@ -161,6 +161,21 @@ switch ($action) {
$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.
}
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]);
$db->prepare('
INSERT INTO dc_users (username, password_hash, created_at)