SmsCode.php 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. <?php
  2. declare(strict_types=1);
  3. namespace app\miniapi\auth;
  4. use app\miniapi\exception\ApiException;
  5. use app\miniapi\model\MiniSmsCode;
  6. /**
  7. * One-time login codes. The plaintext code never touches the database and is never logged:
  8. * only a keyed hash is stored, so a leaked table cannot be brute forced offline.
  9. */
  10. final class SmsCode
  11. {
  12. public const PURPOSE = 'login';
  13. public static function enabled(): bool
  14. {
  15. return config('mini_api.sms_login', false) === true
  16. && (string) config('mini_api.sms_user', '') !== ''
  17. && (string) config('mini_api.sms_password', '') !== '';
  18. }
  19. public static function ttl(): int
  20. {
  21. return (int) config('mini_api.sms_code_ttl', 300);
  22. }
  23. /**
  24. * Applies every send limit before a code is generated, so a caller who is already
  25. * throttled never causes an SMS to leave the server.
  26. */
  27. public static function assertSendAllowed(string $phone, string $ip): void
  28. {
  29. $interval = (int) config('mini_api.sms_resend_interval', 60);
  30. if (MiniSmsCode::query()->where('phone', $phone)->where('created_at', '>', date('Y-m-d H:i:s', time() - $interval))->exists()) {
  31. throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => $interval]);
  32. }
  33. $perIp = (int) config('mini_api.sms_per_ip_per_hour', 30);
  34. if ($perIp > 0 && MiniSmsCode::query()->where('request_ip', $ip)
  35. ->where('created_at', '>', date('Y-m-d H:i:s', time() - 3600))->count() >= $perIp) {
  36. throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => 3600]);
  37. }
  38. $perPhone = (int) config('mini_api.sms_daily_per_phone', 10);
  39. if ($perPhone > 0 && MiniSmsCode::query()->where('phone', $phone)
  40. ->where('created_at', '>=', date('Y-m-d 00:00:00'))->count() >= $perPhone) {
  41. throw new ApiException(42901, '今日验证码发送次数已达上限,请明日再试', 429, ['retry_after' => 86400]);
  42. }
  43. }
  44. /** Returns the plaintext code for delivery. The caller sends it and never persists it. */
  45. public static function issue(string $phone, string $ip): string
  46. {
  47. self::assertSendAllowed($phone, $ip);
  48. $code = (string) random_int(100000, 999999);
  49. MiniSmsCode::query()->insert([
  50. 'phone' => $phone,
  51. 'purpose' => self::PURPOSE,
  52. 'code_hash' => self::hash($phone, $code),
  53. 'attempts' => 0,
  54. 'expires_at' => date('Y-m-d H:i:s', time() + self::ttl()),
  55. 'request_ip' => $ip,
  56. 'created_at' => date('Y-m-d H:i:s'),
  57. ]);
  58. self::purge();
  59. return $code;
  60. }
  61. /**
  62. * Consumes the newest live code for the phone in a transaction so two concurrent requests
  63. * cannot both redeem it. The outcome is decided inside the transaction but the rejection is
  64. * raised only after it commits, otherwise a failed guess would roll back the very attempt
  65. * counter that is meant to stop the guessing.
  66. */
  67. public static function consume(string $phone, string $code): void
  68. {
  69. $max = (int) config('mini_api.sms_max_attempts', 5);
  70. $outcome = (new MiniSmsCode())->getConnection()->transaction(function () use ($phone, $code, $max) {
  71. $row = MiniSmsCode::query()->where('phone', $phone)->where('purpose', self::PURPOSE)
  72. ->whereNull('consumed_at')->where('expires_at', '>', date('Y-m-d H:i:s'))
  73. ->orderByDesc('id')->lockForUpdate()->first();
  74. if (!$row) {
  75. return ['expired', false];
  76. }
  77. if (!hash_equals((string) $row->code_hash, self::hash($phone, $code))) {
  78. $attempts = (int) $row->attempts + 1;
  79. $locked = $attempts >= $max;
  80. MiniSmsCode::query()->where('id', $row->id)->update([
  81. 'attempts' => $attempts,
  82. // Burning the row on the last miss stops guessing without a separate lock table.
  83. 'consumed_at' => $locked ? date('Y-m-d H:i:s') : null,
  84. ]);
  85. return ['mismatch', $locked];
  86. }
  87. MiniSmsCode::query()->where('id', $row->id)->update([
  88. 'attempts' => (int) $row->attempts + 1,
  89. 'consumed_at' => date('Y-m-d H:i:s'),
  90. ]);
  91. return ['ok', false];
  92. });
  93. match ($outcome[0]) {
  94. 'ok' => null,
  95. 'mismatch' => throw new ApiException(40102, $outcome[1] ? '验证码错误次数过多,请重新获取' : '验证码错误', 401),
  96. default => throw new ApiException(40102, '验证码已失效,请重新获取', 401),
  97. };
  98. }
  99. /** Removes a code that was never delivered, so it cannot be guessed or redeemed later. */
  100. public static function discard(string $phone, string $code): void
  101. {
  102. MiniSmsCode::query()->where('phone', $phone)->where('code_hash', self::hash($phone, $code))->delete();
  103. }
  104. private static function hash(string $phone, string $code): string
  105. {
  106. // Keyed so an offline attacker holding the table still cannot search a 6-digit space.
  107. $key = (string) config('mini_api.sms_code_pepper', '') ?: (string) config('mini_api.sms_password', '');
  108. return hash_hmac('sha256', $code, $key . '|' . $phone . '|' . self::PURPOSE);
  109. }
  110. /** Opportunistic cleanup keeps the table bounded without needing a scheduled job. */
  111. private static function purge(): void
  112. {
  113. MiniSmsCode::query()->where('created_at', '<', date('Y-m-d H:i:s', time() - 86400))->delete();
  114. }
  115. }