- 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.
This commit is contained in:
Ty Clifford
2026-06-10 16:40:22 -04:00
parent 34ca1be9b7
commit 45ef31e61f
10 changed files with 74 additions and 30 deletions
+7 -4
View File
@@ -10,7 +10,7 @@ A dependency-light online food ordering system for Fat Bottom Grille at
- Persistent cart and abandoned-cart tracking - Persistent cart and abandoned-cart tracking
- Configurable state, city, and county tax rates - Configurable state, city, and county tax rates
- Pickup checkout with Square Web Payments and Payments API support - 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 - Customer order history, saved pickup details, password changes, and newsletter preferences
- Staff order queue, status history, internal notes, and Square refunds - Staff order queue, status history, internal notes, and Square refunds
- Staff roster and role management - Staff roster and role management
@@ -73,14 +73,17 @@ and assets automatically include that subdirectory.
- Email: `admin@fatbottomgrille.com` - Email: `admin@fatbottomgrille.com`
- Password: `ChangeMe123!` - 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`. browser and is also written to `storage/logs/mail.log`.
Change the initial password from **My Account** before deploying the site. Change the initial password from **My Account** before deploying the site.
## Dashboard ## 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: The dashboard controls:
@@ -117,7 +120,7 @@ In **Dashboard > Settings**, enter the Mailgun domain, API key, sender name,
and sender email, then enable Mailgun. and sender email, then enable Mailgun.
When Mailgun is disabled, messages are stored locally in 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. newsletter workflows testable without an email service.
## SQLite and MySQL/MariaDB ## SQLite and MySQL/MariaDB
+6 -2
View File
@@ -398,6 +398,7 @@ final class AdminController extends BaseController
if ($id > 0) { if ($id > 0) {
$this->db->execute( $this->db->execute(
'UPDATE users SET full_name = ?, phone = ?, role = ?, active = ?, newsletter_opt_in = ?, 'UPDATE users SET full_name = ?, phone = ?, role = ?, active = ?, newsletter_opt_in = ?,
two_factor_enabled = ?,
updated_at = CURRENT_TIMESTAMP WHERE id = ?', updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[ [
Security::clean((string) ($_POST['full_name'] ?? '')), Security::clean((string) ($_POST['full_name'] ?? '')),
@@ -405,6 +406,7 @@ final class AdminController extends BaseController
$role, $role,
isset($_POST['active']) ? 1 : 0, isset($_POST['active']) ? 1 : 0,
isset($_POST['newsletter_opt_in']) ? 1 : 0, isset($_POST['newsletter_opt_in']) ? 1 : 0,
isset($_POST['two_factor_enabled']) ? 1 : 0,
$id, $id,
] ]
); );
@@ -417,8 +419,9 @@ final class AdminController extends BaseController
} }
$this->db->execute( $this->db->execute(
'INSERT INTO users 'INSERT INTO users
(email, password_hash, full_name, phone, role, active, newsletter_opt_in, email_verified_at) (email, password_hash, full_name, phone, role, active, newsletter_opt_in,
VALUES (?, ?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP)', two_factor_enabled, email_verified_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)',
[ [
$email, $email,
password_hash($password, PASSWORD_DEFAULT), password_hash($password, PASSWORD_DEFAULT),
@@ -426,6 +429,7 @@ final class AdminController extends BaseController
Security::clean((string) ($_POST['phone'] ?? '')), Security::clean((string) ($_POST['phone'] ?? '')),
$role, $role,
isset($_POST['newsletter_opt_in']) ? 1 : 0, isset($_POST['newsletter_opt_in']) ? 1 : 0,
isset($_POST['two_factor_enabled']) ? 1 : 0,
] ]
); );
} }
+25 -10
View File
@@ -55,9 +55,16 @@ final class AuthController extends BaseController
Http::redirect('/login'); Http::redirect('/login');
} }
unset($_SESSION['pending_verification_purpose']); 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']; $_SESSION['pending_2fa_user_id'] = (int) $user['id'];
try {
$developmentCode = $this->twoFactor->issue($user, 'login'); $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) { if ($developmentCode !== null) {
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.'); Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
} else { } else {
@@ -66,6 +73,11 @@ final class AuthController extends BaseController
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 public function registerForm(): void
{ {
if ($this->auth->check()) { if ($this->auth->check()) {
@@ -137,13 +149,13 @@ final class AuthController extends BaseController
] ]
); );
$user = $this->db->fetch('SELECT * FROM users WHERE email = ?', [$clean['email']]); $user = $this->db->fetch('SELECT * FROM users WHERE email = ?', [$clean['email']]);
$_SESSION['pending_2fa_user_id'] = (int) $user['id']; if ($user === null) {
$_SESSION['pending_verification_purpose'] = 'register'; Http::flash('error', 'Your account could not be loaded after registration.');
$developmentCode = $this->twoFactor->issue($user, 'register'); Http::redirect('/login');
if ($developmentCode !== null) {
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
} }
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 public function verifyForm(): void
@@ -170,10 +182,10 @@ final class AuthController extends BaseController
if ($user === null) { if ($user === null) {
Http::redirect('/login'); 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]); $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); $this->auth->login($user);
Http::flash('success', 'Welcome, ' . $user['full_name'] . '.'); Http::flash('success', 'Welcome, ' . $user['full_name'] . '.');
Http::redirect(in_array($user['role'], ['admin', 'manager', 'staff'], true) ? '/admin' : '/account'); Http::redirect(in_array($user['role'], ['admin', 'manager', 'staff'], true) ? '/admin' : '/account');
@@ -205,6 +217,7 @@ final class AuthController extends BaseController
Security::verifyCsrf(); Security::verifyCsrf();
$user = $this->auth->requireLogin(); $user = $this->auth->requireLogin();
$newsletter = isset($_POST['newsletter_opt_in']) ? 1 : 0; $newsletter = isset($_POST['newsletter_opt_in']) ? 1 : 0;
$twoFactorEnabled = isset($_POST['two_factor_enabled']) ? 1 : 0;
$newPassword = (string) ($_POST['new_password'] ?? ''); $newPassword = (string) ($_POST['new_password'] ?? '');
if ( if (
$newPassword !== '' $newPassword !== ''
@@ -220,7 +233,8 @@ final class AuthController extends BaseController
$this->db->execute( $this->db->execute(
'UPDATE users SET full_name = ?, phone = ?, street_address = ?, city = ?, state = ?, '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['full_name'] ?? '')),
Security::clean((string) ($_POST['phone'] ?? '')), Security::clean((string) ($_POST['phone'] ?? '')),
@@ -229,6 +243,7 @@ final class AuthController extends BaseController
strtoupper(substr(Security::clean((string) ($_POST['state'] ?? 'WV')), 0, 2)), strtoupper(substr(Security::clean((string) ($_POST['state'] ?? 'WV')), 0, 2)),
Security::clean((string) ($_POST['postal_code'] ?? '')), Security::clean((string) ($_POST['postal_code'] ?? '')),
$newsletter, $newsletter,
$twoFactorEnabled,
(int) $user['id'], (int) $user['id'],
] ]
); );
+2 -3
View File
@@ -40,14 +40,14 @@ final class Auth
{ {
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id']; $_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->resolved = true;
$this->cachedUser = $user; $this->cachedUser = $user;
} }
public function logout(): void 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); session_regenerate_id(true);
$this->resolved = true; $this->resolved = true;
$this->cachedUser = null; $this->cachedUser = null;
@@ -81,4 +81,3 @@ final class Auth
return $user; return $user;
} }
} }
+14
View File
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS users (
postal_code TEXT NOT NULL DEFAULT '', postal_code TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'customer', role TEXT NOT NULL DEFAULT 'customer',
newsletter_opt_in INTEGER NOT NULL DEFAULT 1, newsletter_opt_in INTEGER NOT NULL DEFAULT 1,
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
email_verified_at TEXT, email_verified_at TEXT,
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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; SQL;
$this->pdo->exec($schema); $this->pdo->exec($schema);
$this->ensureSqliteColumn('users', 'two_factor_enabled', 'INTEGER NOT NULL DEFAULT 0');
$this->seed(); $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 private function seed(): void
{ {
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn(); $categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
+7
View File
@@ -43,6 +43,13 @@
<small>You can change this any time.</small> <small>You can change this any time.</small>
</span> </span>
</label> </label>
<label class="check-control">
<input type="checkbox" name="two_factor_enabled" value="1" <?= checked($currentUser['two_factor_enabled'] ?? 0) ?>>
<span>
<strong>Use email two-factor authentication</strong>
<small>Optional. When enabled, each sign-in requires a six-digit code sent to <?= e($currentUser['email']) ?>.</small>
</span>
</label>
<details class="account-password"> <details class="account-password">
<summary>Change password</summary> <summary>Change password</summary>
<div class="form-stack"> <div class="form-stack">
+3 -1
View File
@@ -15,7 +15,7 @@
<details> <details>
<summary> <summary>
<span class="avatar"><?= e(strtoupper(substr((string) $user['full_name'], 0, 1))) ?></span> <span class="avatar"><?= e(strtoupper(substr((string) $user['full_name'], 0, 1))) ?></span>
<span><strong><?= e($user['full_name']) ?></strong><small><?= e($user['email']) ?> &middot; <?= e($user['phone']) ?></small></span> <span><strong><?= e($user['full_name']) ?></strong><small><?= e($user['email']) ?> &middot; <?= e($user['phone']) ?> &middot; Email 2FA <?= !empty($user['two_factor_enabled']) ? 'on' : 'off' ?></small></span>
<span class="role-badge"><?= e(ucfirst((string) $user['role'])) ?></span> <span class="role-badge"><?= e(ucfirst((string) $user['role'])) ?></span>
<span class="status-dot <?= $user['active'] ? 'is-on' : '' ?>"><?= $user['active'] ? 'Active' : 'Disabled' ?></span> <span class="status-dot <?= $user['active'] ? 'is-on' : '' ?>"><?= $user['active'] ? 'Active' : 'Disabled' ?></span>
<span class="edit-label">Edit</span> <span class="edit-label">Edit</span>
@@ -35,6 +35,7 @@
<div class="check-row"> <div class="check-row">
<label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($user['active']) ?>><span><strong>Active account</strong></span></label> <label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($user['active']) ?>><span><strong>Active account</strong></span></label>
<label class="check-control compact"><input type="checkbox" name="newsletter_opt_in" value="1" <?= checked($user['newsletter_opt_in']) ?>><span><strong>News opted in</strong></span></label> <label class="check-control compact"><input type="checkbox" name="newsletter_opt_in" value="1" <?= checked($user['newsletter_opt_in']) ?>><span><strong>News opted in</strong></span></label>
<label class="check-control compact"><input type="checkbox" name="two_factor_enabled" value="1" <?= checked($user['two_factor_enabled'] ?? 0) ?>><span><strong>Email 2FA</strong></span></label>
</div> </div>
<button class="button button-small" type="submit">Save User</button> <button class="button button-small" type="submit">Save User</button>
</form> </form>
@@ -57,6 +58,7 @@
<div class="check-row"> <div class="check-row">
<label class="check-control compact"><input type="checkbox" name="active" value="1" checked><span><strong>Active account</strong></span></label> <label class="check-control compact"><input type="checkbox" name="active" value="1" checked><span><strong>Active account</strong></span></label>
<label class="check-control compact"><input type="checkbox" name="newsletter_opt_in" value="1"><span><strong>News opted in</strong></span></label> <label class="check-control compact"><input type="checkbox" name="newsletter_opt_in" value="1"><span><strong>News opted in</strong></span></label>
<label class="check-control compact"><input type="checkbox" name="two_factor_enabled" value="1"><span><strong>Email 2FA</strong></span></label>
</div> </div>
<button class="button button-small" type="submit">Create User</button> <button class="button button-small" type="submit">Create User</button>
</form> </form>
+1 -1
View File
@@ -28,7 +28,7 @@
<input type="number" name="captcha" required inputmode="numeric" autocomplete="off"> <input type="number" name="captcha" required inputmode="numeric" autocomplete="off">
</label> </label>
<button class="button button-large button-block" type="submit">Continue Securely</button> <button class="button button-large button-block" type="submit">Continue Securely</button>
<p class="form-footnote">We will email a six-digit verification code after your password is accepted.</p> <p class="form-footnote">Email verification is requested only when optional email 2FA is enabled for your account.</p>
<p class="auth-switch">New here? <a href="<?= e(url('/register')) ?>">Create an account</a></p> <p class="auth-switch">New here? <a href="<?= e(url('/register')) ?>">Create an account</a></p>
</form> </form>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@
<h1>Faster pickup starts here.</h1> <h1>Faster pickup starts here.</h1>
<p>Create an account to keep your contact details, see order history, and hear about new specials.</p> <p>Create an account to keep your contact details, see order history, and hear about new specials.</p>
<ul class="check-list"> <ul class="check-list">
<li>Email verification protects your account</li> <li>Optional email 2FA can be enabled from My Account</li>
<li>Newsletter preferences stay in your control</li> <li>Newsletter preferences stay in your control</li>
<li>Your payment details are handled by Square, not stored here</li> <li>Your payment details are handled by Square, not stored here</li>
</ul> </ul>
+1 -1
View File
@@ -4,7 +4,7 @@
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="verification-icon">6</div> <div class="verification-icon">6</div>
<div> <div>
<span class="eyebrow">One more step</span> <span class="eyebrow">Email security check</span>
<h1>Check your email</h1> <h1>Check your email</h1>
<p>Enter the six-digit code we sent you. It is valid for 10 minutes.</p> <p>Enter the six-digit code we sent you. It is valid for 10 minutes.</p>
</div> </div>