5b217956e9
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
282 lines
11 KiB
PHP
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');
|
|
}
|
|
}
|