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
+310
-16
@@ -9,6 +9,7 @@ use Deploymentcenter\Core\Csrf;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\Webauthn\CredentialRepo;
|
||||
|
||||
Auth::startSession();
|
||||
|
||||
@@ -18,8 +19,61 @@ if (Auth::isLoggedIn()) {
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$notice = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// -----------------------------------------------------------------------
|
||||
// Zweite Stufe: Notfalltoken und Abbruch
|
||||
//
|
||||
// Beides laeuft als gewoehnliches Formular und nicht ueber die JSON-API.
|
||||
// Der Hardwarekey braucht JavaScript, weil navigator.credentials sich
|
||||
// anders nicht bedienen laesst - der Ersatzweg soll diese Voraussetzung
|
||||
// gerade nicht teilen.
|
||||
// -----------------------------------------------------------------------
|
||||
$step = (string)($_POST['step'] ?? '');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'cancel') {
|
||||
Auth::clearPending();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === 'recovery') {
|
||||
if (!Csrf::isValid(is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : null)) {
|
||||
$error = 'Sitzung abgelaufen. Bitte erneut versuchen.';
|
||||
} elseif (Auth::pendingUserId() <= 0) {
|
||||
$error = 'Die Anmeldung ist abgelaufen. Bitte von vorn beginnen.';
|
||||
} else {
|
||||
try {
|
||||
$pdo = Db::init();
|
||||
$repo = new CredentialRepo($pdo);
|
||||
|
||||
if ($repo->consumeRecoveryToken(Auth::pendingUserId(), (string)($_POST['recovery_token'] ?? ''))) {
|
||||
Auth::completeSecondFactor($pdo, 'recovery_token');
|
||||
|
||||
// Der Token ist jetzt verbraucht. Das muss der Betreiber
|
||||
// erfahren, und zwar dort, wo er nach dem Anmelden landet -
|
||||
// also ueber die allgemeine Meldezeile und nicht in einem
|
||||
// Unterreiter, den er vielleicht wochenlang nicht aufschlaegt.
|
||||
$_SESSION['dc_flash'] = [
|
||||
'type' => 'danger',
|
||||
'message' => 'Sie haben sich mit dem Notfalltoken angemeldet. '
|
||||
. 'Er ist damit verbraucht. Unter System → Benutzer einen neuen erzeugen, '
|
||||
. 'sonst bleibt nur noch der Hardwarekey.',
|
||||
];
|
||||
header('Location: /index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
Auth::recordSecondFactorFailure($pdo, 'recovery_token_invalid');
|
||||
$error = 'Der Notfalltoken stimmt nicht.';
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error('Notfalltoken-Pruefung fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
$error = 'Anmeldung derzeit nicht moeglich. Bitte spaeter erneut versuchen.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
|
||||
@@ -44,10 +98,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($accLockout / 60)
|
||||
);
|
||||
} elseif (Auth::login($pdo, $username, $password)) {
|
||||
header('Location: /index.php');
|
||||
exit;
|
||||
} else {
|
||||
$result = Auth::startLogin($pdo, $username, $password);
|
||||
|
||||
if ($result === Auth::LOGIN_COMPLETE) {
|
||||
header('Location: /index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($result === Auth::LOGIN_SECOND_FACTOR) {
|
||||
// Nicht weiterleiten: Die zweite Stufe wird unten
|
||||
// gerendert. Ein Redirect wuerde nur eine Runde kosten
|
||||
// und der Adresszeile ansehen lassen, wie weit jemand
|
||||
// gekommen ist.
|
||||
$error = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($error === null && Auth::pendingUserId() <= 0 && !Auth::isLoggedIn()) {
|
||||
// Pruefen, ob das Konto oder die IP durch diesen Fehlversuch soeben gesperrt wurde
|
||||
$newAccLockout = Auth::accountLockoutSeconds($pdo, $username);
|
||||
$newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
|
||||
@@ -74,6 +142,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Erst hier abfragen, nicht weiter oben: Der Zwischenzustand entsteht unter
|
||||
// Umstaenden erst durch die Auswertung darueber.
|
||||
$pendingUsername = Auth::pendingUsername();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
@@ -244,6 +316,97 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
color: var(--text-muted);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* --- Zweite Stufe: Hardwarekey --- */
|
||||
|
||||
.step-note {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.step-note strong { color: var(--text-main); }
|
||||
|
||||
.key-status {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
min-height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-status.is-error {
|
||||
background: var(--danger-bg);
|
||||
border-color: var(--danger-border);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--card-border);
|
||||
}
|
||||
|
||||
.recovery summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.recovery summary:hover { color: var(--text-main); }
|
||||
|
||||
.recovery[open] summary { margin-bottom: 0.75rem; }
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.4rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.btn-quiet {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-quiet:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cancel-form { text-align: center; margin-top: 1.25rem; }
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-button:hover { color: var(--text-main); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -255,31 +418,162 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="brand-title">Deploymentcenter</h1>
|
||||
<p class="brand-subtitle">Bitte melden Sie sich an, um fortzufahren</p>
|
||||
<p class="brand-subtitle">
|
||||
<?= $pendingUsername !== '' ? 'Zweiter Schritt: Hardwarekey' : 'Bitte melden Sie sich an, um fortzufahren' ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="login.php">
|
||||
<?= Csrf::field() ?>
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus placeholder="z. B. admin">
|
||||
<?php if ($pendingUsername !== ''): ?>
|
||||
|
||||
<div class="step-note">
|
||||
Passwort erkannt für <strong><?= htmlspecialchars($pendingUsername) ?></strong>.
|
||||
Bitte jetzt den Hardwarekey anstecken und berühren.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required placeholder="••••••••">
|
||||
</div>
|
||||
<div id="keyStatus" class="key-status">Warte auf den Hardwarekey …</div>
|
||||
|
||||
<button type="submit" class="btn-submit">Anmelden</button>
|
||||
</form>
|
||||
<button type="button" class="btn-submit" id="btnKey">Hardwarekey verwenden</button>
|
||||
|
||||
<div class="divider"><span>oder</span></div>
|
||||
|
||||
<details class="recovery" <?= $error !== null ? 'open' : '' ?>>
|
||||
<summary>Hardwarekey nicht zur Hand?</summary>
|
||||
<form method="POST" action="login.php">
|
||||
<?= Csrf::field() ?>
|
||||
<input type="hidden" name="step" value="recovery">
|
||||
<div class="form-group">
|
||||
<label for="recovery_token" class="form-label">Notfalltoken</label>
|
||||
<input type="text" id="recovery_token" name="recovery_token" class="form-control"
|
||||
required autocomplete="off" spellcheck="false"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX">
|
||||
<p class="hint">
|
||||
Wird dabei verbraucht. Nach der Anmeldung unter
|
||||
<em>System → Benutzer</em> einen neuen erzeugen.
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-submit btn-quiet">Mit Notfalltoken anmelden</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<form method="POST" action="login.php" class="cancel-form">
|
||||
<?= Csrf::field() ?>
|
||||
<input type="hidden" name="step" value="cancel">
|
||||
<button type="submit" class="link-button">Abbrechen und von vorn beginnen</button>
|
||||
</form>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<form method="POST" action="login.php">
|
||||
<?= Csrf::field() ?>
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="footer-note">
|
||||
Deploymentcenter © <?= date('Y') ?> • Unified License & Monitoring Platform
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($pendingUsername !== ''): ?>
|
||||
<script>
|
||||
(function () {
|
||||
const status = document.getElementById('keyStatus');
|
||||
const button = document.getElementById('btnKey');
|
||||
|
||||
// base64url, nicht base64: Der Standard schreibt es so vor, und die
|
||||
// Umkehrfunktion muss zum Server passen.
|
||||
const fromB64u = value => {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const toB64u = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
const setStatus = (text, isError) => {
|
||||
status.textContent = text;
|
||||
status.classList.toggle('is-error', !!isError);
|
||||
};
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
setStatus('Dieser Browser kennt keine Hardwarekeys. Bitte den Notfalltoken benutzen.', true);
|
||||
button.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const post = async (action, payload) => {
|
||||
const response = await fetch('/api/auth/v1/?action=' + action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Unerwartete Antwort des Servers.');
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
setStatus('Anfrage wird vorbereitet …', false);
|
||||
const options = (await post('login_options')).publicKey;
|
||||
|
||||
options.challenge = fromB64u(options.challenge);
|
||||
options.allowCredentials = (options.allowCredentials || []).map(c => ({
|
||||
...c, id: fromB64u(c.id)
|
||||
}));
|
||||
|
||||
setStatus('Bitte den Hardwarekey jetzt berühren.', false);
|
||||
const assertion = await navigator.credentials.get({ publicKey: options });
|
||||
|
||||
setStatus('Antwort wird geprüft …', false);
|
||||
const result = await post('login_verify', {
|
||||
id: assertion.id,
|
||||
clientDataJSON: toB64u(assertion.response.clientDataJSON),
|
||||
authenticatorData: toB64u(assertion.response.authenticatorData),
|
||||
signature: toB64u(assertion.response.signature)
|
||||
});
|
||||
|
||||
window.location.href = result.redirect || '/index.php';
|
||||
} catch (err) {
|
||||
// NotAllowedError heisst in der Praxis: abgebrochen oder zu lange
|
||||
// gewartet. Die Rohmeldung des Browsers hilft dabei niemandem.
|
||||
const message = err && err.name === 'NotAllowedError'
|
||||
? 'Abgebrochen oder zu lange gewartet. Bitte erneut versuchen.'
|
||||
: (err && err.message ? err.message : 'Der Hardwarekey konnte nicht gelesen werden.');
|
||||
setStatus(message, true);
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('click', run);
|
||||
|
||||
// Von selbst loslaufen: Der Normalfall ist, dass der Key schon steckt.
|
||||
// Der Knopf bleibt fuer den zweiten Anlauf.
|
||||
run();
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user