Files
order/app/Controllers/AuthController.php
T
Ty Clifford 45ef31e61f - 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.
2026-06-10 16:40:22 -04:00

281 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace FatBottom\Controllers;
use FatBottom\Core\Auth;
use FatBottom\Core\Config;
use FatBottom\Core\Database;
use FatBottom\Core\Http;
use FatBottom\Core\Security;
use FatBottom\Core\View;
use FatBottom\Services\CartService;
use FatBottom\Services\TwoFactorService;
final class AuthController extends BaseController
{
public function __construct(
Config $config,
Auth $auth,
CartService $cart,
private readonly Database $db,
private readonly TwoFactorService $twoFactor
) {
parent::__construct($config, $auth, $cart);
}
public function loginForm(): void
{
if ($this->auth->check()) {
Http::redirect('/account');
}
View::render('auth/login', $this->shared([
'title' => 'Sign In',
'captcha' => Security::newCaptcha(),
]));
}
public function login(): void
{
Security::verifyCsrf();
$input = $_POST;
$email = strtolower(Security::clean((string) ($input['email'] ?? '')));
if (!Security::verifyCaptcha($input)) {
Http::old(['email' => $email]);
Http::flash('error', 'Please complete the anti-spam check.');
Http::redirect('/login');
}
$user = $this->db->fetch('SELECT * FROM users WHERE email = ? AND active = 1', [$email]);
if ($user === null || !password_verify((string) ($input['password'] ?? ''), (string) $user['password_hash'])) {
usleep(350000);
Http::old(['email' => $email]);
Http::flash('error', 'The email address or password was not recognized.');
Http::redirect('/login');
}
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');
}
$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
{
if ($this->auth->check()) {
Http::redirect('/account');
}
View::render('auth/register', $this->shared([
'title' => 'Create Account',
'captcha' => Security::newCaptcha(),
]));
}
public function register(): void
{
Security::verifyCsrf();
$input = $_POST;
$clean = [
'email' => strtolower(Security::clean((string) ($input['email'] ?? ''))),
'full_name' => Security::clean((string) ($input['full_name'] ?? '')),
'phone' => Security::clean((string) ($input['phone'] ?? '')),
'street_address' => Security::clean((string) ($input['street_address'] ?? '')),
'city' => Security::clean((string) ($input['city'] ?? '')),
'state' => strtoupper(substr(Security::clean((string) ($input['state'] ?? 'WV')), 0, 2)),
'postal_code' => Security::clean((string) ($input['postal_code'] ?? '')),
];
if (!Security::verifyCaptcha($input)) {
Http::old($clean);
Http::flash('error', 'Please complete the anti-spam check.');
Http::redirect('/register');
}
if (
!filter_var($clean['email'], FILTER_VALIDATE_EMAIL)
|| $clean['full_name'] === ''
|| $clean['phone'] === ''
|| $clean['street_address'] === ''
|| $clean['city'] === ''
|| $clean['state'] === ''
|| $clean['postal_code'] === ''
|| strlen((string) ($input['password'] ?? '')) < 10
) {
Http::old($clean);
Http::flash('error', 'Complete your contact and address details, enter a valid email, and use a password of at least 10 characters.');
Http::redirect('/register');
}
if (($input['password'] ?? '') !== ($input['password_confirmation'] ?? '')) {
Http::old($clean);
Http::flash('error', 'The password confirmation does not match.');
Http::redirect('/register');
}
if ($this->db->fetch('SELECT id FROM users WHERE email = ?', [$clean['email']])) {
Http::old($clean);
Http::flash('error', 'An account already exists for that email address.');
Http::redirect('/login');
}
$this->db->execute(
'INSERT INTO users
(email, password_hash, full_name, phone, street_address, city, state, postal_code, newsletter_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)',
[
$clean['email'],
password_hash((string) $input['password'], PASSWORD_DEFAULT),
$clean['full_name'],
$clean['phone'],
$clean['street_address'],
$clean['city'],
$clean['state'],
$clean['postal_code'],
]
);
$user = $this->db->fetch('SELECT * FROM users WHERE email = ?', [$clean['email']]);
if ($user === null) {
Http::flash('error', 'Your account could not be loaded after registration.');
Http::redirect('/login');
}
$this->auth->login($user);
Http::flash('success', 'Welcome, ' . $user['full_name'] . '. Your account is ready.');
Http::redirect('/account');
}
public function verifyForm(): void
{
if (empty($_SESSION['pending_2fa_user_id'])) {
Http::redirect('/login');
}
View::render('auth/verify', $this->shared([
'title' => 'Verify Your Email',
]));
}
public function verify(): void
{
Security::verifyCsrf();
$userId = (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
$purpose = (string) ($_SESSION['pending_verification_purpose'] ?? 'login');
if (!$this->twoFactor->verify($userId, Security::clean((string) ($_POST['code'] ?? '')), $purpose)) {
Http::flash('error', 'That code is invalid or has expired.');
Http::redirect('/verify');
}
$user = $this->db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
if ($user === null) {
Http::redirect('/login');
}
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']);
$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 logout(): void
{
Security::verifyCsrf();
$this->auth->logout();
Http::flash('success', 'You have been signed out.');
Http::redirect('/');
}
public function account(): void
{
$user = $this->auth->requireLogin();
$orders = $this->db->fetchAll(
'SELECT * FROM orders WHERE user_id = ? ORDER BY placed_at DESC LIMIT 25',
[(int) $user['id']]
);
View::render('account/index', $this->shared([
'title' => 'My Account',
'orders' => $orders,
]));
}
public function updateAccount(): void
{
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 !== ''
&& (
!password_verify((string) ($_POST['current_password'] ?? ''), (string) $user['password_hash'])
|| strlen($newPassword) < 10
|| $newPassword !== (string) ($_POST['new_password_confirmation'] ?? '')
)
) {
Http::flash('error', 'Password was not changed. Check your current password and use a matching new password of at least 10 characters.');
Http::redirect('/account');
}
$this->db->execute(
'UPDATE users SET full_name = ?, phone = ?, street_address = ?, city = ?, state = ?,
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'] ?? '')),
Security::clean((string) ($_POST['street_address'] ?? '')),
Security::clean((string) ($_POST['city'] ?? '')),
strtoupper(substr(Security::clean((string) ($_POST['state'] ?? 'WV')), 0, 2)),
Security::clean((string) ($_POST['postal_code'] ?? '')),
$newsletter,
$twoFactorEnabled,
(int) $user['id'],
]
);
if ($newPassword !== '') {
$this->db->execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[password_hash($newPassword, PASSWORD_DEFAULT), (int) $user['id']]
);
}
Http::flash('success', 'Your account has been updated.');
Http::redirect('/account');
}
public function unsubscribe(): void
{
$userId = (int) ($_GET['user'] ?? 0);
$signature = (string) ($_GET['signature'] ?? '');
$expected = hash_hmac('sha256', (string) $userId, $this->newsletterKey());
if ($userId > 0 && hash_equals($expected, $signature)) {
$this->db->execute('UPDATE users SET newsletter_opt_in = 0 WHERE id = ?', [$userId]);
Http::flash('success', 'You have been unsubscribed from email news.');
} else {
Http::flash('error', 'That unsubscribe link is invalid.');
}
Http::redirect('/');
}
private function newsletterKey(): string
{
return hash('sha256', (string) $this->config->get('app.key') . '|newsletter');
}
}