Files
Ty Clifford 6686388834 - Implemented the complete first-run experience:
Account creation and guarded onboarding flow.
Current-cash starting balance with automatic rollover enabled.
Dynamic bill generation, shared/custom categories, recurring targets, 
and optional same-day onboarding transactions.
Optional dormant reserve and additional categories.
Mailgun email or authenticator-app 2FA configuration.
Responsive dark/light UI with no overflow at 390px.
Fixed administrator session-ID creation bug.
Core implementation: [OnboardingService.php (line 
59)](/Users/tyemeclifford/Documents/GH/budget/app/OnboardingService.php:59), 
[onboarding.php (line 
19)](/Users/tyemeclifford/Documents/GH/budget/views/onboarding.php:19), 
and [index.php (line 
143)](/Users/tyemeclifford/Documents/GH/budget/public/index.php:143).
Verification passed: financial self-test, onboarding integration test, 
full browser walkthrough, mobile layout, routing guards, and zero 
browser console errors. Live email delivery requires actual Mailgun 
credentials and was not sent during testing.
2026-06-15 14:51:44 -04:00

182 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Budget;
use PDO;
final class Auth
{
public function __construct(private PDO $pdo, private Settings $settings)
{
}
public function needsSetup(): bool
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() === 0;
}
public function setup(string $email, string $password, bool $enableTwoFactor): ?string
{
if (!$this->needsSetup()) {
throw new \RuntimeException('The administrator account already exists.');
}
$secret = $enableTwoFactor ? Totp::generateSecret() : null;
$statement = $this->pdo->prepare(
'INSERT INTO users (email, password_hash, totp_secret) VALUES (:email, :password_hash, :secret)'
);
$statement->execute([
'email' => strtolower(trim($email)),
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'secret' => $secret,
]);
$userId = (int) $this->pdo->lastInsertId();
$this->settings->set('alerts.email', strtolower(trim($email)));
$this->settings->set('security.2fa_required', $enableTwoFactor);
$this->settings->set('onboarding.completed', false);
$_SESSION['user_id'] = $userId;
session_regenerate_id(true);
return $secret;
}
public function attempt(string $email, string $password): string
{
$statement = $this->pdo->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
$statement->execute(['email' => strtolower(trim($email))]);
$user = $statement->fetch();
if (!$user || !password_verify($password, (string) $user['password_hash'])) {
return 'invalid';
}
if ((bool) $this->settings->get('security.2fa_required', false)) {
$method = (string) $this->settings->get('security.2fa_method', 'totp');
if ($method === 'email') {
$this->startEmailChallenge($user);
} else {
if (empty($user['totp_secret'])) {
return 'unconfigured_2fa';
}
$_SESSION['pending_2fa_user'] = (int) $user['id'];
$_SESSION['pending_2fa_method'] = 'totp';
}
return '2fa';
}
$this->completeLogin((int) $user['id']);
return 'ok';
}
public function verifyTwoFactor(string $code): bool
{
$userId = (int) ($_SESSION['pending_2fa_user'] ?? 0);
if ($userId === 0) {
return false;
}
if (($_SESSION['pending_2fa_method'] ?? 'totp') === 'email') {
return $this->verifyEmailChallenge($userId, $code);
}
$statement = $this->pdo->prepare('SELECT totp_secret FROM users WHERE id = :id');
$statement->execute(['id' => $userId]);
$secret = (string) $statement->fetchColumn();
if ($secret === '' || !Totp::verify($secret, $code)) {
return false;
}
unset($_SESSION['pending_2fa_user'], $_SESSION['pending_2fa_method']);
$this->completeLogin($userId);
return true;
}
public function check(): bool
{
return isset($_SESSION['user_id']);
}
public function id(): ?int
{
return $this->check() ? (int) $_SESSION['user_id'] : null;
}
public function user(): ?array
{
if (!$this->check()) {
return null;
}
$statement = $this->pdo->prepare('SELECT id, email, totp_secret, created_at FROM users WHERE id = :id');
$statement->execute(['id' => $this->id()]);
return $statement->fetch() ?: null;
}
public function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$parameters = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $parameters['path'], $parameters['domain'], $parameters['secure'], $parameters['httponly']);
}
session_destroy();
}
private function completeLogin(int $userId): void
{
$_SESSION['user_id'] = $userId;
session_regenerate_id(true);
}
private function startEmailChallenge(array $user): void
{
$mailgun = new Mailgun($this->settings);
if (!$mailgun->configured()) {
throw new \RuntimeException('Email two-factor authentication requires complete Mailgun settings.');
}
$code = (string) random_int(100000, 999999);
$appName = (string) $this->settings->get('app.name', 'Neon Ledger');
$mailgun->send(
(string) $user['email'],
$appName . ' sign-in code',
'<div style="font-family:system-ui,sans-serif;max-width:520px;margin:auto;padding:28px">'
. '<h1 style="font-size:22px">Your sign-in code</h1>'
. '<p>Enter this code to finish signing in. It expires in 10 minutes.</p>'
. '<p style="font-size:32px;font-weight:800;letter-spacing:8px">' . $code . '</p>'
. '<p style="color:#687187">If you did not try to sign in, you can ignore this email.</p>'
. '</div>'
);
$_SESSION['pending_2fa_user'] = (int) $user['id'];
$_SESSION['pending_2fa_method'] = 'email';
$_SESSION['pending_2fa_code'] = password_hash($code, PASSWORD_DEFAULT);
$_SESSION['pending_2fa_expires'] = time() + 600;
$_SESSION['pending_2fa_attempts'] = 0;
}
private function verifyEmailChallenge(int $userId, string $code): bool
{
$expires = (int) ($_SESSION['pending_2fa_expires'] ?? 0);
$attempts = (int) ($_SESSION['pending_2fa_attempts'] ?? 0);
$hash = (string) ($_SESSION['pending_2fa_code'] ?? '');
if ($expires < time() || $attempts >= 5 || $hash === '') {
$this->clearPendingChallenge();
return false;
}
$_SESSION['pending_2fa_attempts'] = $attempts + 1;
if (!preg_match('/^\d{6}$/', $code) || !password_verify($code, $hash)) {
return false;
}
$this->clearPendingChallenge();
$this->completeLogin($userId);
return true;
}
private function clearPendingChallenge(): void
{
unset(
$_SESSION['pending_2fa_user'],
$_SESSION['pending_2fa_method'],
$_SESSION['pending_2fa_code'],
$_SESSION['pending_2fa_expires'],
$_SESSION['pending_2fa_attempts']
);
}
}