Files
Deploymentcenter/public/api/auth/v1/index.php
T
Deploymentcenter BotandClaude Opus 5 f0ed67ad29 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>
2026-09-20 18:16:51 +02:00

249 lines
9.2 KiB
PHP

<?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'],
]);
}