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', '
Enter this code to finish signing in. It expires in 10 minutes.
' . '' . $code . '
' . 'If you did not try to sign in, you can ignore this email.
' . '