6686388834
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.
182 lines
6.3 KiB
PHP
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']
|
|
);
|
|
}
|
|
}
|