Files
praxis-creutzburg-web/admin/login.php
T
clemensandClaude Opus 5 19f1ac7ec8 CSRF-Schutz, Brute-Force-Bremse und wirksame Session-Cookie-Flags
Neu: inc/security.inc.php, eingebunden von inc/config.inc.php.

CSRF
Der Adminbereich hatte keinerlei Schutz gegen fremde POSTs. Jetzt haengt
csrf_inject_output() als Ausgabefilter das Token an jedes POST-Formular und
csrf_require() weist POSTs ohne gueltiges Token ab. Beides wird nur fuer
Skripte unterhalb von admin/ aktiviert, der oeffentliche Bereich bleibt
unveraendert. Der Umweg ueber den Ausgabefilter erspart es, die rund 60
bestehenden Formulare einzeln anzufassen; der AJAX-Aufruf auf
mailtemplate.php schickt das Token als Feld mit.

Session-Cookie-Flags
config.inc.php setzt secure, httponly und samesite - aber 25 Dateien in
admin/ und intern/ riefen session_start() vor dem Include auf, womit die
Parameter wirkungslos waren. Die vorgezogenen Aufrufe sind entfernt,
config.inc.php startet die Sitzung nur noch, wenn keine laeuft.
admin/logout.php musste umgestellt werden, weil dort session_destroy()
vor dem Include stand; die Cookies werden jetzt mit denselben Parametern
geloescht, mit denen sie gesetzt wurden.

Brute-Force
Nach 5 Fehlversuchen je Konto oder 20 je IP ist die Anmeldung 15 Minuten
gesperrt, gezaehlt in der neuen Tabelle login_attempts. Fehlt die Tabelle,
laeuft der Login wie bisher - gleiche Vorgehensweise wie bei
securitytokensHatAblaufspalte(). Eine erfolgreiche Anmeldung raeumt die
Fehlversuche des Kontos ab.

Passwort vergessen
admin/passwortvergessen.php uebergab $mail und $body an SendMailMessage();
beide Variablen gibt es dort nicht, sie heissen $empfaenger und $text. Die
Mail ging deshalb nie raus, obwohl der Reset-Code gesetzt wurde.

Getestet gegen einen lokalen PHP-Server: Token wird eingesetzt, POST ohne
Token liefert 403, mit Token laeuft der Login normal, der sechste
Fehlversuch wird gesperrt, das Sitzungscookie traegt secure/HttpOnly/
SameSite. Oeffentliche Seiten sind unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:18:59 +02:00

118 lines
4.4 KiB
PHP

<?php
// admin/bootstrap.php
ob_start(); // fängt zufälligen Output ab, verhindert "headers already sent" Folgeschäden
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
$error_msg = "";
if (!empty($_POST['email']) && !empty($_POST['passwort'])) {
$email = $_POST['email'];
$passwort = $_POST['passwort'];
// Brute-Force-Bremse: nach mehreren Fehlversuchen ist die Anmeldung fuer
// dieses Konto bzw. diese IP zeitweise gesperrt. Die Pruefung laeuft vor
// password_verify(), damit gesperrte Versuche gar nicht erst rechnen.
$sperreSekunden = login_gesperrt($pdo, $email);
if ($sperreSekunden > 0) {
$error_msg = login_sperr_text($sperreSekunden);
} else {
$statement = $pdo->prepare("SELECT * FROM users WHERE email = :email LIMIT 1");
$statement->execute(['email' => $email]);
$user = $statement->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($passwort, $user['passwort'])) {
login_versuch_merken($pdo, $email, true);
// Session IMMER setzen
session_regenerate_id(true);
$_SESSION['auth'] = [
'type' => 'admin',
'id' => (int)$user['id'],
];
// "Angemeldet bleiben" optional
if (!empty($_POST['angemeldet_bleiben'])) {
$identifier = bin2hex(random_bytes(16));
$securitytoken = bin2hex(random_bytes(32)); // stärker als random_string()
$hash = hash('sha256', $securitytoken);
// Ablaufdatum nur setzen, wenn die Spalte existiert - der Code laeuft
// damit vor und nach der Migration. Ablauf per NOW() in SQL, weil
// Webserver und DB-Server in verschiedenen Zeitzonen laufen.
if (securitytokensHatAblaufspalte($pdo)) {
$insert = $pdo->prepare("
INSERT INTO securitytokens (user_id, identifier, securitytoken, user_type, expires_at)
VALUES (:user_id, :identifier, :securitytoken, 'admin', DATE_ADD(NOW(), INTERVAL 30 DAY))
");
} else {
$insert = $pdo->prepare("
INSERT INTO securitytokens (user_id, identifier, securitytoken, user_type)
VALUES (:user_id, :identifier, :securitytoken, 'admin')
");
}
$insert->execute([
'user_id' => (int)$user['id'],
'identifier' => $identifier,
'securitytoken' => $hash
]);
$cookieOpts = [
'expires' => time() + 30*24*3600, // deckungsgleich mit dem Token-Ablauf
'path' => '/',
'secure' => true, // nur wenn HTTPS
'httponly' => true,
'samesite' => 'Lax',
];
setcookie('identifier', $identifier, $cookieOpts);
setcookie('securitytoken', $securitytoken, $cookieOpts);
}
header("Location: index.php");
exit;
} else {
login_versuch_merken($pdo, $email, false);
$error_msg = "E-Mail oder Passwort war ungültig<br><br>";
}
}
}
$email_value = isset($_POST['email']) ? htmlentities($_POST['email']) : "";
include("templates/header.inc.php");
?>
<div class="container small-container-330 form-signin">
<form action="login.php" method="post">
<h2 class="form-signin-heading">Login</h2>
<?php
if(isset($error_msg) && !empty($error_msg)) {
echo $error_msg;
}
?>
<label for="inputEmail" class="sr-only">E-Mail</label>
<input type="email" name="email" id="inputEmail" class="form-control" placeholder="E-Mail" value="<?php echo $email_value; ?>" required autofocus>
<label for="inputPassword" class="sr-only">Passwort</label>
<input type="password" name="passwort" id="inputPassword" class="form-control" placeholder="Passwort" required>
<div class="checkbox">
<label>
<input type="checkbox" value="remember-me" name="angemeldet_bleiben" value="1" checked> Angemeldet bleiben
</label>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">Login</button>
<br>
<a href="passwortvergessen.php">Passwort vergessen</a>
</form>
</div> <!-- /container -->
<?php
include("templates/footer.inc.php")
?>