From 45ef31e61f1427b94920af751b2c45b7aea4bf62 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Wed, 10 Jun 2026 16:40:22 -0400 Subject: [PATCH] - Email 2FA is now optional and disabled by default. Existing and new accounts default to 2FA off. Registration signs users in directly. Users can enable it under My Account. Administrators can manage it under Users & Staff. Verified password-only and opted-in email-code login flows. PHP/JavaScript checks and browser console passed. --- README.md | 11 ++++--- app/Controllers/AdminController.php | 8 +++-- app/Controllers/AuthController.php | 49 +++++++++++++++++++---------- app/Core/Auth.php | 5 ++- app/Core/Database.php | 14 +++++++++ views/account/index.php | 7 +++++ views/admin/users.php | 4 ++- views/auth/login.php | 2 +- views/auth/register.php | 2 +- views/auth/verify.php | 2 +- 10 files changed, 74 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5ca6d40..e87c1bc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A dependency-light online food ordering system for Fat Bottom Grille at - Persistent cart and abandoned-cart tracking - Configurable state, city, and county tax rates - Pickup checkout with Square Web Payments and Payments API support -- Customer accounts with email verification, email 2FA, captcha, and honeypots +- Customer and staff accounts with optional email 2FA, captcha, and honeypots - Customer order history, saved pickup details, password changes, and newsletter preferences - Staff order queue, status history, internal notes, and Square refunds - Staff roster and role management @@ -73,14 +73,17 @@ and assets automatically include that subdirectory. - Email: `admin@fatbottomgrille.com` - Password: `ChangeMe123!` -Mailgun is disabled by default, so the six-digit sign-in code appears in the +Email 2FA is disabled by default for every account. It can be enabled from +**My Account** or managed by an administrator in **Users & Staff**. When 2FA is +enabled while Mailgun is disabled, the six-digit sign-in code appears in the browser and is also written to `storage/logs/mail.log`. Change the initial password from **My Account** before deploying the site. ## Dashboard -Sign in, complete email verification, and open `/admin`. +Sign in and open `/admin`. Accounts that opt into email 2FA complete the +six-digit email check after entering a valid password. The dashboard controls: @@ -117,7 +120,7 @@ In **Dashboard > Settings**, enter the Mailgun domain, API key, sender name, and sender email, then enable Mailgun. When Mailgun is disabled, messages are stored locally in -`storage/logs/mail.log`. This keeps registration, 2FA, order confirmation, and +`storage/logs/mail.log`. This keeps optional 2FA, order confirmation, and newsletter workflows testable without an email service. ## SQLite and MySQL/MariaDB diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 0dd332c..efe3d95 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -398,6 +398,7 @@ final class AdminController extends BaseController if ($id > 0) { $this->db->execute( 'UPDATE users SET full_name = ?, phone = ?, role = ?, active = ?, newsletter_opt_in = ?, + two_factor_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [ Security::clean((string) ($_POST['full_name'] ?? '')), @@ -405,6 +406,7 @@ final class AdminController extends BaseController $role, isset($_POST['active']) ? 1 : 0, isset($_POST['newsletter_opt_in']) ? 1 : 0, + isset($_POST['two_factor_enabled']) ? 1 : 0, $id, ] ); @@ -417,8 +419,9 @@ final class AdminController extends BaseController } $this->db->execute( 'INSERT INTO users - (email, password_hash, full_name, phone, role, active, newsletter_opt_in, email_verified_at) - VALUES (?, ?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP)', + (email, password_hash, full_name, phone, role, active, newsletter_opt_in, + two_factor_enabled, email_verified_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)', [ $email, password_hash($password, PASSWORD_DEFAULT), @@ -426,6 +429,7 @@ final class AdminController extends BaseController Security::clean((string) ($_POST['phone'] ?? '')), $role, isset($_POST['newsletter_opt_in']) ? 1 : 0, + isset($_POST['two_factor_enabled']) ? 1 : 0, ] ); } diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 754ecc1..4b1e4f6 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -55,15 +55,27 @@ final class AuthController extends BaseController Http::redirect('/login'); } - unset($_SESSION['pending_verification_purpose']); - $_SESSION['pending_2fa_user_id'] = (int) $user['id']; - $developmentCode = $this->twoFactor->issue($user, 'login'); - if ($developmentCode !== null) { - Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.'); - } else { - Http::flash('success', 'We emailed you a six-digit verification code.'); + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_verification_purpose']); + if ((bool) ($user['two_factor_enabled'] ?? false)) { + $_SESSION['pending_2fa_user_id'] = (int) $user['id']; + try { + $developmentCode = $this->twoFactor->issue($user, 'login'); + } catch (\Throwable) { + unset($_SESSION['pending_2fa_user_id']); + Http::flash('error', 'We could not send your sign-in code. Please try again or ask an administrator to disable email 2FA for your account.'); + Http::redirect('/login'); + } + if ($developmentCode !== null) { + Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.'); + } else { + Http::flash('success', 'We emailed you a six-digit verification code.'); + } + Http::redirect('/verify'); } - Http::redirect('/verify'); + + $this->auth->login($user); + Http::flash('success', 'Welcome, ' . $user['full_name'] . '.'); + Http::redirect(in_array($user['role'], ['admin', 'manager', 'staff'], true) ? '/admin' : '/account'); } public function registerForm(): void @@ -137,13 +149,13 @@ final class AuthController extends BaseController ] ); $user = $this->db->fetch('SELECT * FROM users WHERE email = ?', [$clean['email']]); - $_SESSION['pending_2fa_user_id'] = (int) $user['id']; - $_SESSION['pending_verification_purpose'] = 'register'; - $developmentCode = $this->twoFactor->issue($user, 'register'); - if ($developmentCode !== null) { - Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.'); + if ($user === null) { + Http::flash('error', 'Your account could not be loaded after registration.'); + Http::redirect('/login'); } - Http::redirect('/verify'); + $this->auth->login($user); + Http::flash('success', 'Welcome, ' . $user['full_name'] . '. Your account is ready.'); + Http::redirect('/account'); } public function verifyForm(): void @@ -170,10 +182,10 @@ final class AuthController extends BaseController if ($user === null) { Http::redirect('/login'); } - if ($purpose === 'register') { + if ($purpose === 'register' || empty($user['email_verified_at'])) { $this->db->execute('UPDATE users SET email_verified_at = CURRENT_TIMESTAMP WHERE id = ?', [$userId]); - unset($_SESSION['pending_verification_purpose']); } + unset($_SESSION['pending_verification_purpose']); $this->auth->login($user); Http::flash('success', 'Welcome, ' . $user['full_name'] . '.'); Http::redirect(in_array($user['role'], ['admin', 'manager', 'staff'], true) ? '/admin' : '/account'); @@ -205,6 +217,7 @@ final class AuthController extends BaseController Security::verifyCsrf(); $user = $this->auth->requireLogin(); $newsletter = isset($_POST['newsletter_opt_in']) ? 1 : 0; + $twoFactorEnabled = isset($_POST['two_factor_enabled']) ? 1 : 0; $newPassword = (string) ($_POST['new_password'] ?? ''); if ( $newPassword !== '' @@ -220,7 +233,8 @@ final class AuthController extends BaseController $this->db->execute( 'UPDATE users SET full_name = ?, phone = ?, street_address = ?, city = ?, state = ?, - postal_code = ?, newsletter_opt_in = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + postal_code = ?, newsletter_opt_in = ?, two_factor_enabled = ?, + updated_at = CURRENT_TIMESTAMP WHERE id = ?', [ Security::clean((string) ($_POST['full_name'] ?? '')), Security::clean((string) ($_POST['phone'] ?? '')), @@ -229,6 +243,7 @@ final class AuthController extends BaseController strtoupper(substr(Security::clean((string) ($_POST['state'] ?? 'WV')), 0, 2)), Security::clean((string) ($_POST['postal_code'] ?? '')), $newsletter, + $twoFactorEnabled, (int) $user['id'], ] ); diff --git a/app/Core/Auth.php b/app/Core/Auth.php index 0b2dc89..14c4e36 100644 --- a/app/Core/Auth.php +++ b/app/Core/Auth.php @@ -40,14 +40,14 @@ final class Auth { session_regenerate_id(true); $_SESSION['user_id'] = (int) $user['id']; - unset($_SESSION['pending_2fa_user_id']); + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_verification_purpose']); $this->resolved = true; $this->cachedUser = $user; } public function logout(): void { - unset($_SESSION['user_id'], $_SESSION['pending_2fa_user_id']); + unset($_SESSION['user_id'], $_SESSION['pending_2fa_user_id'], $_SESSION['pending_verification_purpose']); session_regenerate_id(true); $this->resolved = true; $this->cachedUser = null; @@ -81,4 +81,3 @@ final class Auth return $user; } } - diff --git a/app/Core/Database.php b/app/Core/Database.php index ab589e7..17db959 100644 --- a/app/Core/Database.php +++ b/app/Core/Database.php @@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS users ( postal_code TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'customer', newsletter_opt_in INTEGER NOT NULL DEFAULT 1, + two_factor_enabled INTEGER NOT NULL DEFAULT 0, email_verified_at TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -264,9 +265,22 @@ CREATE INDEX IF NOT EXISTS idx_users_newsletter ON users(newsletter_opt_in, acti SQL; $this->pdo->exec($schema); + $this->ensureSqliteColumn('users', 'two_factor_enabled', 'INTEGER NOT NULL DEFAULT 0'); $this->seed(); } + private function ensureSqliteColumn(string $table, string $column, string $definition): void + { + $columns = $this->pdo->query("PRAGMA table_info(`{$table}`)")->fetchAll(); + foreach ($columns as $existing) { + if (($existing['name'] ?? '') === $column) { + return; + } + } + + $this->pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); + } + private function seed(): void { $categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn(); diff --git a/views/account/index.php b/views/account/index.php index 2fea1fb..2b33ef2 100644 --- a/views/account/index.php +++ b/views/account/index.php @@ -43,6 +43,13 @@ You can change this any time. +
Change password
diff --git a/views/admin/users.php b/views/admin/users.php index e5bfc4c..ee583a4 100644 --- a/views/admin/users.php +++ b/views/admin/users.php @@ -15,7 +15,7 @@
- · + · · Email 2FA Edit @@ -35,6 +35,7 @@
+
@@ -57,6 +58,7 @@
+
diff --git a/views/auth/login.php b/views/auth/login.php index 79fd7c9..aa0836f 100644 --- a/views/auth/login.php +++ b/views/auth/login.php @@ -28,7 +28,7 @@ -

We will email a six-digit verification code after your password is accepted.

+

Email verification is requested only when optional email 2FA is enabled for your account.

New here? Create an account

diff --git a/views/auth/register.php b/views/auth/register.php index 7015176..04fdfd2 100644 --- a/views/auth/register.php +++ b/views/auth/register.php @@ -5,7 +5,7 @@

Faster pickup starts here.

Create an account to keep your contact details, see order history, and hear about new specials.

diff --git a/views/auth/verify.php b/views/auth/verify.php index 53dee39..9c49da9 100644 --- a/views/auth/verify.php +++ b/views/auth/verify.php @@ -4,7 +4,7 @@
6
- One more step + Email security check

Check your email

Enter the six-digit code we sent you. It is valid for 10 minutes.