From a692304748eed1f919834e2437d5a3553513bb23 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Tue, 14 Jul 2026 04:05:25 +0200 Subject: [PATCH] Add self-registration: free accounts via email + self-hosted CAPTCHA New public register.php flow: email + a GD-rendered math CAPTCHA (no third-party service), a mailed temporary password doubling as email verification, forced password change on first login. Gated behind a new ALLOW_SELF_REGISTRATION toggle (default off). Login moves from username to email (falls back to username for pre-migration accounts without one yet, mirroring qr-vip's existing migration 006 pattern) - self-registration needs email as the identifier. New set_email.php interstitial for legacy accounts. Adds a small PHPMailer-based Mailer class (SMTP, with an unauthenticated-relay option via MAIL_SMTP_AUTH=false) since no mail infrastructure existed in this app before. --- .env.example | 16 +++++ Dockerfile | 1 + db/migrations/006_self_registration.sql | 51 ++++++++++++++ docker-compose.prod.yml | 9 +++ docker-compose.yml | 15 +++++ src/authenticate.php | 27 +++++--- src/captcha.php | 41 ++++++++++++ src/config/environment.php | 11 +++ src/includes/bootstrap.php | 1 + src/includes/security.php | 37 +++++++++- src/lib/Mailer/Mailer.php | 56 ++++++++++++++++ src/lib/Users/Users.php | 86 +++++++++++++++++++++++- src/login.php | 12 +++- src/register.php | 86 ++++++++++++++++++++++++ src/set_email.php | 89 +++++++++++++++++++++++++ 15 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 db/migrations/006_self_registration.sql create mode 100644 src/captcha.php create mode 100644 src/lib/Mailer/Mailer.php create mode 100644 src/register.php create mode 100644 src/set_email.php diff --git a/.env.example b/.env.example index 3376bef..c4f8a7d 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,19 @@ DATABASE_PREFIX= DATABASE_CHARSET=utf8 MYSQL_ROOT_PASSWORD=change-me-to-a-strong-root-password + +# Self-registration: lets visitors create their own free 'admin' account +# (email + CAPTCHA -> mailed password -> forced reset on first login). +ALLOW_SELF_REGISTRATION=false + +# Only needed when ALLOW_SELF_REGISTRATION=true. +MAIL_HOST= +MAIL_PORT=587 +MAIL_ENCRYPTION=tls +# Set to false to relay unauthenticated through an internal mail server (no +# MAIL_USERNAME/MAIL_PASSWORD needed in that case). +MAIL_SMTP_AUTH=true +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM_ADDRESS=noreply@example.com +MAIL_FROM_NAME=QRForge diff --git a/Dockerfile b/Dockerfile index 1b45880..e24b8b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,7 @@ RUN cp -R ./php-qrcode/src /var/www/html/ WORKDIR /var/www/html RUN composer update +RUN composer require phpmailer/phpmailer:^6.9 COPY ./src ./ RUN chmod 755 *; diff --git a/db/migrations/006_self_registration.sql b/db/migrations/006_self_registration.sql new file mode 100644 index 0000000..753bb55 --- /dev/null +++ b/db/migrations/006_self_registration.sql @@ -0,0 +1,51 @@ +-- Self-registration: adds email as the login identifier (mirrors qr-vip's +-- 006_vip_and_create_rights.sql email migration) plus a marker for +-- self-registered accounts. + +SET @db := DATABASE(); + +-- 1. email: becomes the login identifier going forward. Nullable so existing accounts (which +-- have no email) don't violate a NOT NULL constraint; a unique index still allows unlimited +-- NULLs in InnoDB, so pre-existing NULL-email rows never collide with each other. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email' +); +SET @sql := IF(@col_exists = 0, + 'ALTER TABLE `users` ADD COLUMN `email` VARCHAR(255) DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND INDEX_NAME = 'email' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2. must_set_email: forces existing (pre-migration) accounts through a one-time "set your +-- email" interstitial on next login, mirroring must_change_password. New accounts created +-- after this migration always have an email from creation, so they never get this flag. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'must_set_email' +); +SET @sql := IF(@col_exists = 0, + 'ALTER TABLE `users` ADD COLUMN `must_set_email` TINYINT(1) NOT NULL DEFAULT 0', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE `users` SET `must_set_email` = 1 WHERE `email` IS NULL; + +-- 3. self_registered_at: NULL for accounts created by an admin/super, set for accounts created +-- through register.php. Purely informational/reporting for now. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'self_registered_at' +); +SET @sql := IF(@col_exists = 0, + 'ALTER TABLE `users` ADD COLUMN `self_registered_at` DATETIME DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9059728..1b0a10e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -31,6 +31,15 @@ services: DATABASE_PASSWORD: "${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}" DATABASE_PREFIX: "${DATABASE_PREFIX:-}" DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}" + ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}" + MAIL_HOST: "${MAIL_HOST:-}" + MAIL_PORT: "${MAIL_PORT:-587}" + MAIL_ENCRYPTION: "${MAIL_ENCRYPTION:-tls}" + MAIL_SMTP_AUTH: "${MAIL_SMTP_AUTH:-true}" + MAIL_USERNAME: "${MAIL_USERNAME:-}" + MAIL_PASSWORD: "${MAIL_PASSWORD:-}" + MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS:-noreply@example.com}" + MAIL_FROM_NAME: "${MAIL_FROM_NAME:-QRForge}" depends_on: qrforge-db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 7b9876a..8cb6a2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,13 @@ services: DATABASE_PASSWORD: "${DATABASE_PASSWORD:?zet DATABASE_PASSWORD in .env}" DATABASE_PREFIX: "${DATABASE_PREFIX:-}" DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}" + ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}" + MAIL_HOST: "mailhog" + MAIL_PORT: "1025" + MAIL_ENCRYPTION: "" + MAIL_SMTP_AUTH: "false" + MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS:-noreply@example.com}" + MAIL_FROM_NAME: "${MAIL_FROM_NAME:-QRForge}" ports: - "80:80" depends_on: @@ -25,6 +32,14 @@ services: networks: - qrforge-network + mailhog: + image: "mailhog/mailhog:v1.0.1" + restart: "unless-stopped" + ports: + - "8025:8025" # web UI: http://localhost:8025 + networks: + - qrforge-network + qrforge-db: image: "mysql:8.0" restart: "unless-stopped" diff --git a/src/authenticate.php b/src/authenticate.php index 3c76e44..87a4fb5 100644 --- a/src/authenticate.php +++ b/src/authenticate.php @@ -6,17 +6,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { csrf_verify_or_die(); - $username = filter_input(INPUT_POST, 'username'); + // Login moves from username to email. Accept either during the transition - + // existing pre-migration accounts have no email yet (see must_set_email/set_email.php), + // so a plain username must keep working until they've set one. + $identifier = filter_input(INPUT_POST, 'email'); $password = filter_input(INPUT_POST, 'password'); $remember = filter_input(INPUT_POST, 'remember'); - if (!$username || !$password) { - $_SESSION['login_failure'] = 'Invalid username or password'; + if (!$identifier || !$password) { + $_SESSION['login_failure'] = 'Invalid email or password'; header('Location: login.php'); exit; } - if (qr_is_login_locked_out($username)) { + if (qr_is_login_locked_out($identifier)) { $_SESSION['login_failure'] = 'Too many failed login attempts. Try again in 15 minutes.'; header('Location: login.php'); exit; @@ -25,12 +28,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') // Get DB instance. $db = getDbInstance(); - $db->where('username', $username); + $db->where('email', $identifier); $row = $db->getOne('users'); + if ($db->count < 1) { + // Compatibility fallback for accounts that haven't set an email yet. + $db = getDbInstance(); + $db->where('username', $identifier); + $row = $db->getOne('users'); + } + if ($db->count >= 1 && password_verify($password, $row['password'])) { - qr_record_login_attempt($username, true); + qr_record_login_attempt($identifier, true); // Voorkom session fixation: nieuwe sessie-id na een geslaagde login. session_regenerate_id(true); @@ -40,6 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') $_SESSION['user_id'] = $row['id']; $_SESSION['username'] = $row['username']; $_SESSION['must_change_password'] = !empty($row['must_change_password']); + $_SESSION['must_set_email'] = !empty($row['must_set_email']); $_SESSION['can_view_static'] = !empty($row['can_view_static']); $_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']); $_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row); @@ -86,8 +97,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') } else { - qr_record_login_attempt($username, false); - $_SESSION['login_failure'] = 'Invalid username or password'; + qr_record_login_attempt($identifier, false); + $_SESSION['login_failure'] = 'Invalid email or password'; header('Location: login.php'); exit; } diff --git a/src/captcha.php b/src/captcha.php new file mode 100644 index 0000000..59ba0b9 --- /dev/null +++ b/src/captcha.php @@ -0,0 +1,41 @@ +send($toEmail, $subject, $body); + } + + private function send($toEmail, $subject, $body) { + $mail = new PHPMailer(true); + + try { + $mail->isSMTP(); + $mail->Host = MAIL_HOST; + $mail->Port = MAIL_PORT; + $mail->SMTPAuth = MAIL_SMTP_AUTH; + + if (MAIL_SMTP_AUTH) { + $mail->Username = MAIL_USERNAME; + $mail->Password = MAIL_PASSWORD; + } + + if (MAIL_ENCRYPTION !== '') { + $mail->SMTPSecure = MAIL_ENCRYPTION; + } + + $mail->setFrom(MAIL_FROM_ADDRESS, MAIL_FROM_NAME); + $mail->addAddress($toEmail); + $mail->Subject = $subject; + $mail->Body = $body; + $mail->isHTML(false); + + $mail->send(); + return true; + } catch (PHPMailerException $e) { + error_log('Mailer: failed to send to ' . $toEmail . ': ' . $mail->ErrorInfo); + return false; + } + } +} diff --git a/src/lib/Users/Users.php b/src/lib/Users/Users.php index a8b9d52..b70dddb 100644 --- a/src/lib/Users/Users.php +++ b/src/lib/Users/Users.php @@ -1,5 +1,6 @@ success('User added successfully'); } } - + + /** + * Public self-registration (register.php). No session/permission checks - this is + * the one path where an unauthenticated visitor creates their own account. Always + * creates a free-forever 'admin' (self-scoped, no tenant), matching what a manually + * created OSS admin gets. Returns ['ok' => true] on success or + * ['ok' => false, 'error' => string] - callers are responsible for flash/redirect, + * unlike addUser()/editUser() which redirect themselves (this runs pre-login, on a + * page with its own layout). + */ + public function registerSelfUser($email) { + if (!ALLOW_SELF_REGISTRATION) { + return ['ok' => false, 'error' => 'Self-registration is not enabled.']; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => 'Please enter a valid email address.']; + } + + $db = getDbInstance(); + $db->where('email', $email); + $existing = $db->getOne('users'); + + if (!empty($existing)) { + return ['ok' => false, 'error' => 'An account with this email already exists.']; + } + + $username = $this->deriveUniqueUsername($email); + $tempPassword = bin2hex(random_bytes(8)); + + $data_to_db = [ + 'username' => $username, + 'password' => password_hash($tempPassword, PASSWORD_DEFAULT), + 'type' => 'admin', + 'owner_admin_id' => null, + 'email' => $email, + 'must_change_password' => 1, + 'self_registered_at' => date('Y-m-d H:i:s'), + ]; + + $db = getDbInstance(); + $last_id = $db->insert('users', $data_to_db); + + if (!$last_id) { + return ['ok' => false, 'error' => 'Could not create the account: ' . $db->getLastError()]; + } + + audit_log('user_self_registered', 'user', $last_id); + + $mailer = new Mailer(); + $mailer->sendInitialPassword($email, $tempPassword); + + return ['ok' => true]; + } + + /** + * Derives a username candidate from the email's local part (letters/digits/dot/ + * underscore/hyphen only, matching validateUsernameAndType()'s rules), appending a + * numeric suffix if it's already taken. + */ + private function deriveUniqueUsername($email) { + $localPart = strtolower(strstr($email, '@', true) ?: $email); + $base = preg_replace('/[^a-z0-9._-]/', '', $localPart); + $base = substr($base, 0, 45) ?: 'user'; + + if (strlen($base) < 3) { + $base = str_pad($base, 3, '0'); + } + + $candidate = $base; + $suffix = 1; + + $db = getDbInstance(); + $db->where('username', $candidate); + while ($db->getOne('users') !== null) { + $candidate = $base . $suffix; + $suffix++; + $db = getDbInstance(); + $db->where('username', $candidate); + } + + return $candidate; + } + /** * Edit user. * diff --git a/src/login.php b/src/login.php index 4ef6665..707efe1 100644 --- a/src/login.php +++ b/src/login.php @@ -40,6 +40,7 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token'])) $_SESSION['type'] = $row['type']; $_SESSION['username'] = $row['username']; $_SESSION['must_change_password'] = !empty($row['must_change_password']); + $_SESSION['must_set_email'] = !empty($row['must_set_email']); $_SESSION['can_view_static'] = !empty($row['can_view_static']); $_SESSION['can_view_dynamic'] = !empty($row['can_view_dynamic']); $_SESSION['scope_owner_id'] = qr_compute_scope_owner_id($row); @@ -81,10 +82,12 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
+ +
- +
@@ -132,8 +135,11 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
- - + + +

Register for free

+ +
diff --git a/src/register.php b/src/register.php new file mode 100644 index 0000000..5668afe --- /dev/null +++ b/src/register.php @@ -0,0 +1,86 @@ +registerSelfUser($email); + + if ($result['ok']) { + $_SESSION['success'] = 'Account created! Check your inbox for a temporary password.'; + header('Location: login.php'); + exit; + } + + $_SESSION['failure'] = $result['error']; + } +} +?> + + +Register - QRForge + + + +
+ + +
+ +
+
+ + + + + + + + diff --git a/src/set_email.php b/src/set_email.php new file mode 100644 index 0000000..38fa54f --- /dev/null +++ b/src/set_email.php @@ -0,0 +1,89 @@ +where('email', $email); + $db->where('id', $_SESSION['user_id'], '!='); + $existing = $db->getOne('users'); + + if (!empty($existing['email'])) { + $_SESSION['failure'] = 'An account with this email already exists.'; + } else { + $db = getDbInstance(); + $db->where('id', $_SESSION['user_id']); + $db->update('users', [ + 'email' => $email, + 'must_set_email' => 0, + ]); + + $_SESSION['must_set_email'] = false; + audit_log('email_set'); + + $_SESSION['success'] = 'Email address saved.'; + header('Location: index.php'); + exit; + } + } +} +?> + + +Set email - QRForge + + + +
+ + +
+ +
+
+ + + + + + +