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.