Files
order/app/Controllers/AuthController.php
T
Ty Clifford 5b217956e9 - Administrator email 2FA is now explicitly disabled.
Database value: two_factor_enabled = 0
Removed outstanding administrator verification codes
Seeded administrator explicitly defaults to 0
Confirmed fresh login routes directly to /admin, not /verify
2026-06-10 22:31:33 -04:00

282 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, two_factor_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 0)',
[
$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');
}
}