Files
Deploymentcenter/public/login.php
T
Deploymentcenter BotandClaude Opus 5 0c20698fd5 fix(2.9.2): Fehlermeldung lesbar machen, Modellliste ins Formularraster
Zwei Befunde aus der Abnahme auf der Testinstanz.

1. Die Ablehnung eines Softwarekeys erschien als "[object Object]".
   Die API verpackt Fehler als { error: { code, message } }; das
   JavaScript las "data.message || data.error" und griff damit auf das
   OBJEKT zu - ausgerechnet an der Stelle, an der die Begruendung stehen
   sollte, warum ein Schluessel abgelehnt wurde. Eine Regel, die ihren
   Grund nicht nennt, ist in der Bedienung dasselbe wie ein Defekt.

   Derselbe Fehler steckte in login.php. Dort waere er schwerer wiegend:
   An der zweiten Stufe ist diese Meldung der einzige Hinweis, warum es
   nicht weitergeht. Beide lesen jetzt beide Formen - die verschachtelte
   und die flache, die weiterhin vorkommt.

2. Die Textbox fuer die Modellliste stand neben statt unter ihrer
   Beschriftung und behielt ihre Eigengroesse. Der umschliessende
   form-group fehlte; genau der setzt im Stylesheet display:flex mit
   column und laesst das Feld die Breite fuellen. Alle anderen
   Textboxen der Oberflaeche haben ihn.

Zusaetzlich docs/HARDWAREKEY.md: Konzept, Beispielcode und Prueflíste
zur gleichwertigen Uebernahme in ein anderes Projekt. Der Schwerpunkt
liegt auf den drei Dingen, die wie eine Pruefung aussehen und keine
sind - cross-platform, response.authenticatorAttachment und ein blosses
x5c -, samt dem Gegentest, ohne den nichts belegt ist.

Kein Eingriff in src/, sql/ oder public/api/ - der Kern ist unveraendert
gegenueber dem Lauf mit 184 von 184 gruenen Testfaellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:37:49 +02:00

596 lines
21 KiB
PHP

<?php
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\Webauthn\CredentialRepo;
Auth::startSession();
if (Auth::isLoggedIn()) {
header('Location: /index.php');
exit;
}
$error = null;
$notice = null;
// -----------------------------------------------------------------------
// 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'] ?? '');
// CSRF-Schutz auch am Login: verhindert erzwungene Fremdanmeldungen.
if (!Csrf::isValid(is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : null)) {
$error = 'Sitzung abgelaufen. Bitte erneut versuchen.';
} elseif ($username === '' || $password === '') {
$error = 'Bitte fuellen Sie alle Felder aus.';
} else {
try {
$pdo = Db::init();
$ipLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
$accLockout = Auth::accountLockoutSeconds($pdo, $username);
if ($ipLockout > 0) {
$error = sprintf(
'Zu viele fehlgeschlagene Anmeldeversuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($ipLockout / 60)
);
} elseif ($accLockout > 0) {
$error = sprintf(
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($accLockout / 60)
);
} 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());
if ($newAccLockout > 0) {
$error = sprintf(
'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.',
(int)ceil($newAccLockout / 60)
);
} elseif ($newIpLockout > 0) {
$error = sprintf(
'Zu viele Fehlversuche. Ihre IP-Adresse wurde für %d Minute(n) gesperrt.',
(int)ceil($newIpLockout / 60)
);
} else {
$error = 'Ungueltige Anmeldedaten.';
}
}
} catch (\Throwable $e) {
// Die urspruengliche Fassung gab hier die rohe PDO-Meldung samt
// Hostname und Benutzernamen an jeden anonymen Besucher aus.
Logger::error('Anmeldung fehlgeschlagen (technischer Fehler)', ['error' => $e->getMessage()]);
$error = 'Anmeldung derzeit nicht moeglich. Bitte spaeter erneut versuchen.';
}
}
}
// Erst hier abfragen, nicht weiter oben: Der Zwischenzustand entsteht unter
// Umstaenden erst durch die Auswertung darueber.
$pendingUsername = Auth::pendingUsername();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmeldung - Deploymentcenter</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
--card-bg: rgba(30, 41, 59, 0.7);
--card-border: rgba(255, 255, 255, 0.1);
--primary: #6366f1;
--primary-hover: #4f46e5;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--danger-bg: rgba(239, 68, 68, 0.15);
--danger-border: rgba(239, 68, 68, 0.3);
--danger-text: #fca5a5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg-gradient);
color: var(--text-main);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
background: var(--card-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 2.5rem;
width: 100%;
max-width: 420px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.brand-header {
text-align: center;
margin-bottom: 2rem;
}
.brand-logo {
width: 56px;
height: 56px;
background: linear-gradient(135deg, #6366f1, #a855f7);
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
box-shadow: 0 10px 25px -5px rgba(99, 102, 241, 0.4);
}
.brand-logo svg {
width: 30px;
height: 30px;
fill: none;
stroke: #ffffff;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.brand-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.025em;
background: linear-gradient(to right, #ffffff, #cbd5e1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.brand-subtitle {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.alert-danger {
background: var(--danger-bg);
border: 1px solid var(--danger-border);
color: var(--danger-text);
padding: 0.875rem 1rem;
border-radius: 10px;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.form-control {
width: 100%;
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 0.75rem 1rem;
font-size: 0.95rem;
color: var(--text-main);
outline: none;
transition: all 0.2s ease;
}
.form-control:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25);
}
.btn-submit {
width: 100%;
background: linear-gradient(135deg, var(--primary), #4f46e5);
color: #ffffff;
border: none;
border-radius: 10px;
padding: 0.875rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 10px 20px -5px rgba(99, 102, 241, 0.4);
margin-top: 0.5rem;
}
.btn-submit:hover {
transform: translateY(-1px);
box-shadow: 0 15px 25px -5px rgba(99, 102, 241, 0.5);
}
.btn-submit:active {
transform: translateY(0);
}
.footer-note {
text-align: center;
font-size: 0.75rem;
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>
<div class="login-card">
<div class="brand-header">
<div class="brand-logo">
<svg viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path>
</svg>
</div>
<h1 class="brand-title">Deploymentcenter</h1>
<p class="brand-subtitle">
<?= $pendingUsername !== '' ? 'Zweiter Schritt: Hardwarekey' : 'Bitte melden Sie sich an, um fortzufahren' ?>
</p>
</div>
<?php if ($error): ?>
<div class="alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($pendingUsername !== ''): ?>
<div class="step-note">
Passwort erkannt für <strong><?= htmlspecialchars($pendingUsername) ?></strong>.
Bitte jetzt den Hardwarekey anstecken und berühren.
</div>
<div id="keyStatus" class="key-status">Warte auf den Hardwarekey …</div>
<button type="button" class="btn-submit" id="btnKey">Hardwarekey verwenden</button>
<div class="divider"><span>oder</span></div>
<details class="recovery" <?= $error !== null ? 'open' : '' ?>>
<summary>Hardwarekey nicht zur Hand?</summary>
<form method="POST" action="login.php">
<?= Csrf::field() ?>
<input type="hidden" name="step" value="recovery">
<div class="form-group">
<label for="recovery_token" class="form-label">Notfalltoken</label>
<input type="text" id="recovery_token" name="recovery_token" class="form-control"
required autocomplete="off" spellcheck="false"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX">
<p class="hint">
Wird dabei verbraucht. Nach der Anmeldung unter
<em>System → Benutzer</em> einen neuen erzeugen.
</p>
</div>
<button type="submit" class="btn-submit btn-quiet">Mit Notfalltoken anmelden</button>
</form>
</details>
<form method="POST" action="login.php" class="cancel-form">
<?= Csrf::field() ?>
<input type="hidden" name="step" value="cancel">
<button type="submit" class="link-button">Abbrechen und von vorn beginnen</button>
</form>
<?php else: ?>
<form method="POST" action="login.php">
<?= Csrf::field() ?>
<div class="form-group">
<label for="username" class="form-label">Benutzername</label>
<input type="text" id="username" name="username" class="form-control" required autofocus>
</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 &copy; <?= date('Y') ?> &bull; Unified License & Monitoring Platform
</div>
</div>
<?php if ($pendingUsername !== ''): ?>
<script>
(function () {
const status = document.getElementById('keyStatus');
const button = document.getElementById('btnKey');
// base64url, nicht base64: Der Standard schreibt es so vor, und die
// Umkehrfunktion muss zum Server passen.
const fromB64u = value => {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
return bytes;
};
const toB64u = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const setStatus = (text, isError) => {
status.textContent = text;
status.classList.toggle('is-error', !!isError);
};
/**
* Der lesbare Text aus einer Fehlerantwort.
*
* Die API verpackt Fehler als { error: { code, message } }. Wer nur
* data.error liest, zeigt "[object Object]" an - hier waere das
* besonders misslich, denn an dieser Stelle steht der einzige Hinweis,
* warum die Anmeldung nicht weitergeht.
*/
const fehlertext = data => {
const huelle = data && data.error;
if (huelle && typeof huelle === 'object' && huelle.message) { return huelle.message; }
if (data && typeof data.message === 'string') { return data.message; }
if (typeof huelle === 'string') { return huelle; }
return 'Unerwartete Antwort des Servers.';
};
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(fehlertext(data));
}
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>