Security hardening: CSRF, rate limiting, session/password policy, audit log

Fixes critical pre-existing issues found during review: bulk_action.php had no
auth check at all (unauthenticated download/delete of any qrcode) and built a
table name from unwhitelisted user input (SQL injection); the QR generator
classes wrote files from unvalidated filename/format, allowing path traversal
and arbitrary file writes. Also pins chillerlan/php-qrcode to 5.0.5 since
master now requires PHP 8.4, breaking the PHP 8.3 build.

- CSRF tokens on all POST forms and the bulk_action.php JSON endpoint
- Login rate limiting (5 attempts / 15 min) via new login_attempts table
- Hardened sessions: httponly/samesite cookies, 30 min idle timeout,
  session regeneration on login
- Forced password change for the default superadmin/superadmin account
- Server-side validation in Users/DynamicQrcode/Qrcode classes
- Audit log table for auth, user, and qrcode actions
- Checked-in db schema (db/init.sql, migrations/) instead of relying on an
  opaque prebuilt db image
- Production docker-compose with Nginx + php-fpm instead of the PHP dev server
This commit is contained in:
2026-07-08 15:00:33 +02:00
parent 8db3158239
commit 3afe3b7698
48 changed files with 946 additions and 127 deletions
+57 -34
View File
@@ -1,66 +1,89 @@
<?php
require_once 'config/config.php';
session_start();
require_once 'includes/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST')
{
csrf_verify_or_die();
$username = filter_input(INPUT_POST, 'username');
$password = filter_input(INPUT_POST, 'password');
$remember = filter_input(INPUT_POST, 'remember');
if (!$username || !$password) {
$_SESSION['login_failure'] = 'Invalid username or password';
header('Location: login.php');
exit;
}
if (qr_is_login_locked_out($username)) {
$_SESSION['login_failure'] = 'Too many failed login attempts. Try again in 15 minutes.';
header('Location: login.php');
exit;
}
// Get DB instance.
$db = getDbInstance();
$db->where('username', $username);
$row = $db->getOne('users');
if ($db->count >= 1)
if ($db->count >= 1 && password_verify($password, $row['password']))
{
$db_password = $row['password'];
qr_record_login_attempt($username, true);
// Voorkom session fixation: nieuwe sessie-id na een geslaagde login.
session_regenerate_id(true);
$_SESSION['user_logged_in'] = TRUE;
$_SESSION['type'] = $row['type'];
$_SESSION['user_id'] = $row['id'];
$_SESSION['username'] = $row['username'];
$_SESSION['must_change_password'] = !empty($row['must_change_password']);
$_SESSION['last_activity'] = time();
audit_log('login_success');
$user_id = $row['id'];
if (password_verify($password, $db_password))
if ($remember)
{
$_SESSION['user_logged_in'] = TRUE;
$_SESSION['type'] = $row['type'];
$_SESSION['user_id'] = $row['id'];
$series_id = randomString(16);
$remember_token = getSecureRandomToken(20);
$encryted_remember_token = password_hash($remember_token,PASSWORD_DEFAULT);
if ($remember)
{
$series_id = randomString(16);
$remember_token = getSecureRandomToken(20);
$encryted_remember_token = password_hash($remember_token,PASSWORD_DEFAULT);
$expiry_time = date('Y-m-d H:i:s', strtotime(' + 30 days'));
$expires = strtotime($expiry_time);
$is_https = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$expiry_time = date('Y-m-d H:i:s', strtotime(' + 30 days'));
$expires = strtotime($expiry_time);
$cookie_options = [
'expires' => $expires,
'path' => '/',
'secure' => $is_https,
'httponly' => true,
'samesite' => 'Lax',
];
setcookie('series_id', $series_id, $expires, '/');
setcookie('remember_token', $remember_token, $expires, '/');
setcookie('series_id', $series_id, $cookie_options);
setcookie('remember_token', $remember_token, $cookie_options);
$db = getDbInstance();
$db->where ('id',$user_id);
$db = getDbInstance();
$db->where ('id',$user_id);
$update_remember = array(
'series_id'=> $series_id,
'remember_token' => $encryted_remember_token,
'expires' =>$expiry_time
);
$db->update('users', $update_remember);
}
// Authentication successfull redirect user
header('Location: index.php');
}
else
{
$_SESSION['login_failure'] = 'Invalid username or password';
header('Location: login.php');
$update_remember = array(
'series_id'=> $series_id,
'remember_token' => $encryted_remember_token,
'expires' =>$expiry_time
);
$db->update('users', $update_remember);
}
// Authentication successfull redirect user
header('Location: index.php');
exit;
}
else
{
qr_record_login_attempt($username, false);
$_SESSION['login_failure'] = 'Invalid username or password';
header('Location: login.php');
exit;