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:
co-authored by
Claude Opus 5
parent
edea46e342
commit
f0ed67ad29
@@ -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
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user