Ergebnis einer beauftragten Sicherheitsbegutachtung. Die WebAuthn- Zeremonie selbst ist nicht angefasst worden - sie war in Ordnung. Alle Befunde lagen DANEBEN: Ein zweiter Faktor ist nur so stark wie der schwaechste Weg zum Konto, und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht. KRITISCH /api/setup/v1/login prueste auf totp_secret - eine Spalte, die kein Anmeldeweg auswertet - und nicht auf dc_user_credentials. Ein Administratorpasswort genuegte dort fuer ein setup:install-Token ohne Hardwarekey. Neu: Konten mit zweitem Faktor abgewiesen, und die Rollentrennung gilt symmetrisch. Der Docblock behauptete sie seit jeher; erzwungen hat sie nichts. HOCH secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds() fingen jedes Throwable und fielen auf "kein Faktor" bzw. "nicht gesperrt" zurueck. Gefangen wurden damit auch Verbindungsabbrueche und Lock-Timeouts - jeder davon schaltete still fuer alle Konten auf Passwort-allein. Toleriert werden jetzt nur noch 42S02 und 42S22. HOCH Fuenf Aktionen aenderten die Anmeldemittel ohne erneute Bestaetigung. Eine uebernommene Sitzung konnte sich damit dauerhaft einnisten. Neu: Authentifizierung aus den letzten zehn Minuten noetig, geprueft an derselben Stelle, an der auch der Leerlauf sperrt. WEITER - Leerlauf 30 statt 120 Minuten, und GESPERRT statt beendet: AAL2 laesst zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten den Hardwarekey vorzeigen muss, laesst ihn stecken. - Migration 022: session_epoch. Ein Passwortwechsel beendet die uebrigen Sitzungen des Kontos, die eigene bleibt. - Kontosperre wirkt auf der zweiten Stufe; Zaehler gegen unbegrenztes Raten im Zwischenzustand. - Drosselung auf login.php (10/60s) und /api/auth/v1/ (30/60s). - Anmeldefehler ununterscheidbar; die IP-Sperre darf benannt bleiben, sie sagt nichts ueber ein Konto. - HSTS, TLS-Zwang VOR allen Routen, CSP. Die CSP traegt wegen 93 Inline-Handlern nicht gegen XSS - das steht so im Kommentar. - session.use_strict_mode, CSRF-Token bei der Anmeldung verworfen, Secure-Flag aus app.url statt aus $_SERVER['HTTPS']. - rpId/expectedOrigin nicht mehr aus dem Host-Header. /api/health meldet unter checks.webauthn_anchor, ob der Anker steht. - Notfalltoken laeuft nach 180 Tagen ab, geprueft nach dem Hashvergleich. - config.example.php ohne echtes Passwort, verify_ssl auf true. - Serverdaten.txt.bak mitgesperrt, dc_login_attempts raeumt auf. BELEGT 188 von 188 Testfaellen gruen, darunter drei neue in der Gruppe "haertung". Die Blockade einer veralteten Sitzung liess sich nicht in der Suite pruefen - sie verlangt, das Zehn-Minuten-Fenster wirklich abzuwarten. Dafuer gibt es ein Werkzeug ausserhalb; es belegt, dass der Formularweg auf den Sperrbildschirm fuehrt, die Regel dabei unveraendert bleibt, der JSON-Weg reauth_required meldet und das Passwort allein wieder oeffnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
370 lines
15 KiB
PHP
370 lines
15 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\Attestation;
|
|
use Deploymentcenter\Core\Webauthn\CredentialRepo;
|
|
use Deploymentcenter\Core\Webauthn\HardwarePolicy;
|
|
use Deploymentcenter\Core\Webauthn\WebauthnServer;
|
|
use Deploymentcenter\Modules\License\RateLimiter;
|
|
|
|
// 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);
|
|
$policy = new HardwarePolicy($db);
|
|
|
|
// Drosselung. login_options liess sich bisher unbegrenzt aufrufen; der
|
|
// einzige Schutz war die Kontosperre, und die greift erst ab dem fuenften
|
|
// Fehlversuch. Das Fenster ist weiter gefasst als am Anmeldeformular (30
|
|
// statt 10), weil eine einzelne Anmeldung hier mehrere Aufrufe braucht -
|
|
// Vorgaben holen, Antwort pruefen, bei einem Abbruch von vorn.
|
|
$drossel = new RateLimiter($db, 30, 60, 'webauthn');
|
|
if (!$drossel->check(Http::clientIp())) {
|
|
Http::fail(429, 'rate_limited', 'Zu viele Anfragen. Bitte kurz warten.');
|
|
}
|
|
|
|
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.');
|
|
}
|
|
|
|
// Bei "strict" kommen nur geprueft in Hardware entstandene Schluessel
|
|
// in die Auswahl. Ein danach leeres Ergebnis ist kein Fehlerfall,
|
|
// sondern der erwartete Zustand direkt nach dem Umschalten - und die
|
|
// Meldung muss deshalb sagen, was zu tun ist, statt bloss "geht
|
|
// nicht".
|
|
$strict = $policy->appliesToLogin();
|
|
$ids = $repo->rawIdsForUser($userId, $strict);
|
|
|
|
if ($ids === []) {
|
|
if ($strict && $repo->countForUser($userId) > 0) {
|
|
Http::fail(
|
|
409,
|
|
'no_attested_credentials',
|
|
'Fuer dieses Konto ist zwar ein Schluessel hinterlegt, aber keiner davon hat '
|
|
. 'sich als Hardware ausgewiesen. Die Anmeldung laeuft ueber den Notfalltoken; '
|
|
. 'danach den Hardwarekey neu aufnehmen.'
|
|
);
|
|
}
|
|
|
|
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.');
|
|
}
|
|
|
|
// allowCredentials ist eine Vorgabe an den Browser, keine Zusage des
|
|
// Clients. Wer die Anfrage von Hand stellt, kann jede Kennung
|
|
// einsetzen - also wird die Regel hier ein zweites Mal geprueft, an
|
|
// der Stelle, an der sie wirklich entscheidet.
|
|
if ($policy->appliesToLogin() && (int)($credential['attested'] ?? 0) !== 1) {
|
|
Auth::recordSecondFactorFailure($db, 'not_attested');
|
|
Http::fail(
|
|
403,
|
|
'not_attested',
|
|
'Dieser Schluessel hat sich bei der Aufnahme nicht als Hardware ausgewiesen '
|
|
. 'und ist unter der geltenden Regel nicht zugelassen.'
|
|
);
|
|
}
|
|
|
|
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();
|
|
|
|
// Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in
|
|
// einem Konto einzunisten: Der eigene Schluessel gilt danach
|
|
// dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine
|
|
// lebende Sitzung nicht - es braucht eine Authentifizierung aus den
|
|
// letzten Minuten. Der Browser bekommt einen eigenen Code dafuer,
|
|
// damit die Oberflaeche zum Sperrbildschirm schicken kann, statt
|
|
// "verboten" zu melden.
|
|
if (!Auth::reauthFresh()) {
|
|
Http::fail(
|
|
403,
|
|
'reauth_required',
|
|
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie '
|
|
. 'zuerst mit Ihrem Passwort.',
|
|
null,
|
|
['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')]
|
|
);
|
|
}
|
|
|
|
|
|
$userId = Auth::userId();
|
|
|
|
$challenge = WebauthnServer::challenge();
|
|
Auth::storeChallenge($challenge);
|
|
|
|
Http::ok([
|
|
'publicKey' => WebauthnServer::registrationOptions(
|
|
$challenge,
|
|
$userId,
|
|
Auth::username(),
|
|
$repo->rawIdsForUser($userId),
|
|
$policy->conveyance()
|
|
),
|
|
// Die Oberflaeche sagt vorher, was gefordert ist. Ohne diesen
|
|
// Hinweis erfuehre man die Regel erst nach dem Beruehren des
|
|
// Schluessels - und wuesste nicht, ob der Schluessel oder die
|
|
// Regel das Problem war.
|
|
'policy' => $policy->level(),
|
|
]);
|
|
break;
|
|
}
|
|
|
|
case 'register_verify': {
|
|
if (!Auth::isLoggedIn()) {
|
|
Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.');
|
|
}
|
|
Csrf::requireValid();
|
|
|
|
// Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in
|
|
// einem Konto einzunisten: Der eigene Schluessel gilt danach
|
|
// dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine
|
|
// lebende Sitzung nicht - es braucht eine Authentifizierung aus den
|
|
// letzten Minuten. Der Browser bekommt einen eigenen Code dafuer,
|
|
// damit die Oberflaeche zum Sperrbildschirm schicken kann, statt
|
|
// "verboten" zu melden.
|
|
if (!Auth::reauthFresh()) {
|
|
Http::fail(
|
|
403,
|
|
'reauth_required',
|
|
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie '
|
|
. 'zuerst mit Ihrem Passwort.',
|
|
null,
|
|
['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')]
|
|
);
|
|
}
|
|
|
|
|
|
$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,
|
|
$policy->roots()
|
|
);
|
|
} 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.');
|
|
}
|
|
|
|
// Erst jetzt entscheidet die Regel. Die Zeremonie oben war fehlerfrei
|
|
// - der Schluessel ist echt und gehoert zu diesem Konto. Abgelehnt
|
|
// wird er, weil er nicht das ist, was hier gefordert ist. Diese
|
|
// Reihenfolge ist Absicht: Sie trennt "kaputt" von "nicht zugelassen",
|
|
// und nur so laesst sich die zweite Meldung ueberhaupt verstehen.
|
|
$verdict = $policy->acceptsRegistration($registration['attestation']);
|
|
|
|
if (!$verdict['ok']) {
|
|
SecurityLogger::log('credential_rejected', Http::clientIp(), [
|
|
'username' => Auth::username(),
|
|
'format' => (string)($registration['attestation']['format'] ?? '?'),
|
|
'reason' => (string)$verdict['reason'],
|
|
]);
|
|
|
|
Http::fail(403, 'policy_rejected', (string)$verdict['reason'], null, [
|
|
'format' => $registration['attestation']['format'] ?? null,
|
|
'attested' => false,
|
|
]);
|
|
}
|
|
|
|
try {
|
|
$repo->add(
|
|
$userId,
|
|
$registration['credential_id'],
|
|
$registration['public_key'],
|
|
$registration['alg'],
|
|
$registration['sign_count'],
|
|
$registration['aaguid'],
|
|
$label,
|
|
$transports,
|
|
$registration['attestation']
|
|
);
|
|
} 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,
|
|
'attested' => $registration['attestation']['attested'] ?? false,
|
|
'root' => $registration['attestation']['root'] ?? null,
|
|
]);
|
|
|
|
Http::ok([
|
|
'label' => $label,
|
|
'total' => $repo->countForUser($userId),
|
|
'attested' => $registration['attestation']['attested'] ?? false,
|
|
'format' => $registration['attestation']['format'] ?? null,
|
|
'root' => $registration['attestation']['root'] ?? null,
|
|
'aaguid' => Attestation::formatAaguid($registration['aaguid']),
|
|
'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'],
|
|
]);
|
|
}
|