v1.0.0.1
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
- require_once __DIR__ . '/totp.php';
-
- final class TwoFactor
- {
- private const RECOVERY_CODE_COUNT = 8;
-
- public static function isEnabled(array $account): bool
- {
- return (int) ($account['totp_enabled'] ?? 0) === 1 && (string) ($account['totp_secret'] ?? '') !== '';
- }
-
- public static function enable(int $accountId, string $secret, int $step): array
- {
- $codes = self::newRecoveryCodes();
-
- Database::site()->prepare(
- 'UPDATE team_accounts SET totp_secret = :secret, totp_enabled = 1, totp_recovery = :recovery, totp_last_step = :step WHERE id = :id'
- )->execute([
- 'secret' => $secret,
- 'recovery' => self::hashCodes($codes),
- 'step' => $step,
- 'id' => $accountId,
- ]);
-
- return $codes;
- }
-
- public static function disable(int $accountId): void
- {
- Database::site()->prepare(
- 'UPDATE team_accounts SET totp_secret = NULL, totp_enabled = 0, totp_recovery = NULL, totp_last_step = NULL WHERE id = :id'
- )->execute(['id' => $accountId]);
- }
-
- public static function regenerateRecoveryCodes(int $accountId): array
- {
- $codes = self::newRecoveryCodes();
-
- Database::site()->prepare('UPDATE team_accounts SET totp_recovery = :recovery WHERE id = :id AND totp_enabled = 1')
- ->execute(['recovery' => self::hashCodes($codes), 'id' => $accountId]);
-
- return $codes;
- }
-
- public static function remainingRecoveryCodes(array $account): int
- {
- $hashes = json_decode((string) ($account['totp_recovery'] ?? ''), true);
-
- return is_array($hashes) ? count($hashes) : 0;
- }
-
- public static function verify(array $account, string $input): bool
- {
- if (!self::isEnabled($account)) {
- return false;
- }
-
- $lastStep = $account['totp_last_step'] !== null ? (int) $account['totp_last_step'] : null;
- $step = Totp::verify((string) $account['totp_secret'], $input, $lastStep);
-
- if ($step !== null) {
- $statement = Database::site()->prepare(
- 'UPDATE team_accounts SET totp_last_step = :step WHERE id = :id AND (totp_last_step IS NULL OR totp_last_step < :previous)'
- );
- $statement->execute(['step' => $step, 'id' => (int) $account['id'], 'previous' => $step]);
-
- return $statement->rowCount() === 1;
- }
-
- return self::consumeRecoveryCode($account, $input);
- }
-
- private static function consumeRecoveryCode(array $account, string $input): bool
- {
- $normalized = strtolower((string) preg_replace('/[^0-9a-fA-F]/', '', $input));
- if (strlen($normalized) !== 10) {
- return false;
- }
-
- $stored = (string) ($account['totp_recovery'] ?? '');
- $hashes = json_decode($stored, true);
- if (!is_array($hashes)) {
- return false;
- }
-
- foreach ($hashes as $index => $hash) {
- if (is_string($hash) && password_verify($normalized, $hash)) {
- unset($hashes[$index]);
-
- $statement = Database::site()->prepare(
- 'UPDATE team_accounts SET totp_recovery = :new WHERE id = :id AND totp_recovery = :old'
- );
- $statement->execute([
- 'new' => json_encode(array_values($hashes)),
- 'id' => (int) $account['id'],
- 'old' => $stored,
- ]);
-
- return $statement->rowCount() === 1;
- }
- }
-
- return false;
- }
-
- private static function newRecoveryCodes(): array
- {
- $codes = [];
- for ($i = 0; $i < self::RECOVERY_CODE_COUNT; $i++) {
- $raw = bin2hex(random_bytes(5));
- $codes[] = substr($raw, 0, 5) . '-' . substr($raw, 5);
- }
-
- return $codes;
- }
-
- private static function hashCodes(array $codes): string
- {
- return (string) json_encode(array_map(
- static fn (string $code): string => password_hash(str_replace('-', '', $code), PASSWORD_DEFAULT),
- $codes
- ));
- }
- }
-