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>
This commit is contained in:
+66
-56
@@ -1,9 +1,6 @@
|
||||
<?php
|
||||
// admin/bootstrap.php
|
||||
ob_start(); // fängt zufälligen Output ab, verhindert "headers already sent" Folgeschäden
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once(__DIR__ . "/../inc/config.inc.php");
|
||||
require_once(__DIR__ . "/../inc/functions.inc.php");
|
||||
@@ -14,61 +11,74 @@ if (!empty($_POST['email']) && !empty($_POST['passwort'])) {
|
||||
$email = $_POST['email'];
|
||||
$passwort = $_POST['passwort'];
|
||||
|
||||
$statement = $pdo->prepare("SELECT * FROM users WHERE email = :email LIMIT 1");
|
||||
$statement->execute(['email' => $email]);
|
||||
$user = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
// 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 ($user && password_verify($passwort, $user['passwort'])) {
|
||||
|
||||
// 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;
|
||||
if ($sperreSekunden > 0) {
|
||||
$error_msg = login_sperr_text($sperreSekunden);
|
||||
} else {
|
||||
$error_msg = "E-Mail oder Passwort war ungültig<br><br>";
|
||||
|
||||
$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>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user