WebOrbiton
v1.0.0.1

StocketBase

130 lines · 4.1 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/totp.php';
  7. ​
  8. final class TwoFactor
  9. {
  10. private const RECOVERY_CODE_COUNT = 8;
  11. ​
  12. public static function isEnabled(array $account): bool
  13. {
  14. return (int) ($account['totp_enabled'] ?? 0) === 1 && (string) ($account['totp_secret'] ?? '') !== '';
  15. }
  16. ​
  17. public static function enable(int $accountId, string $secret, int $step): array
  18. {
  19. $codes = self::newRecoveryCodes();
  20. ​
  21. Database::site()->prepare(
  22. 'UPDATE team_accounts SET totp_secret = :secret, totp_enabled = 1, totp_recovery = :recovery, totp_last_step = :step WHERE id = :id'
  23. )->execute([
  24. 'secret' => $secret,
  25. 'recovery' => self::hashCodes($codes),
  26. 'step' => $step,
  27. 'id' => $accountId,
  28. ]);
  29. ​
  30. return $codes;
  31. }
  32. ​
  33. public static function disable(int $accountId): void
  34. {
  35. Database::site()->prepare(
  36. 'UPDATE team_accounts SET totp_secret = NULL, totp_enabled = 0, totp_recovery = NULL, totp_last_step = NULL WHERE id = :id'
  37. )->execute(['id' => $accountId]);
  38. }
  39. ​
  40. public static function regenerateRecoveryCodes(int $accountId): array
  41. {
  42. $codes = self::newRecoveryCodes();
  43. ​
  44. Database::site()->prepare('UPDATE team_accounts SET totp_recovery = :recovery WHERE id = :id AND totp_enabled = 1')
  45. ->execute(['recovery' => self::hashCodes($codes), 'id' => $accountId]);
  46. ​
  47. return $codes;
  48. }
  49. ​
  50. public static function remainingRecoveryCodes(array $account): int
  51. {
  52. $hashes = json_decode((string) ($account['totp_recovery'] ?? ''), true);
  53. ​
  54. return is_array($hashes) ? count($hashes) : 0;
  55. }
  56. ​
  57. public static function verify(array $account, string $input): bool
  58. {
  59. if (!self::isEnabled($account)) {
  60. return false;
  61. }
  62. ​
  63. $lastStep = $account['totp_last_step'] !== null ? (int) $account['totp_last_step'] : null;
  64. $step = Totp::verify((string) $account['totp_secret'], $input, $lastStep);
  65. ​
  66. if ($step !== null) {
  67. $statement = Database::site()->prepare(
  68. 'UPDATE team_accounts SET totp_last_step = :step WHERE id = :id AND (totp_last_step IS NULL OR totp_last_step < :previous)'
  69. );
  70. $statement->execute(['step' => $step, 'id' => (int) $account['id'], 'previous' => $step]);
  71. ​
  72. return $statement->rowCount() === 1;
  73. }
  74. ​
  75. return self::consumeRecoveryCode($account, $input);
  76. }
  77. ​
  78. private static function consumeRecoveryCode(array $account, string $input): bool
  79. {
  80. $normalized = strtolower((string) preg_replace('/[^0-9a-fA-F]/', '', $input));
  81. if (strlen($normalized) !== 10) {
  82. return false;
  83. }
  84. ​
  85. $stored = (string) ($account['totp_recovery'] ?? '');
  86. $hashes = json_decode($stored, true);
  87. if (!is_array($hashes)) {
  88. return false;
  89. }
  90. ​
  91. foreach ($hashes as $index => $hash) {
  92. if (is_string($hash) && password_verify($normalized, $hash)) {
  93. unset($hashes[$index]);
  94. ​
  95. $statement = Database::site()->prepare(
  96. 'UPDATE team_accounts SET totp_recovery = :new WHERE id = :id AND totp_recovery = :old'
  97. );
  98. $statement->execute([
  99. 'new' => json_encode(array_values($hashes)),
  100. 'id' => (int) $account['id'],
  101. 'old' => $stored,
  102. ]);
  103. ​
  104. return $statement->rowCount() === 1;
  105. }
  106. }
  107. ​
  108. return false;
  109. }
  110. ​
  111. private static function newRecoveryCodes(): array
  112. {
  113. $codes = [];
  114. for ($i = 0; $i < self::RECOVERY_CODE_COUNT; $i++) {
  115. $raw = bin2hex(random_bytes(5));
  116. $codes[] = substr($raw, 0, 5) . '-' . substr($raw, 5);
  117. }
  118. ​
  119. return $codes;
  120. }
  121. ​
  122. private static function hashCodes(array $codes): string
  123. {
  124. return (string) json_encode(array_map(
  125. static fn (string $code): string => password_hash(str_replace('-', '', $code), PASSWORD_DEFAULT),
  126. $codes
  127. ));
  128. }
  129. }
  130. ​