SmsCode.php 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  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', 120);
  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. $code = (string) random_int(100000, 999999);
  48. (new MiniSmsCode())->getConnection()->transaction(function () use ($phone, $ip, $code) {
  49. self::assertSendAllowed($phone, $ip);
  50. $now = date('Y-m-d H:i:s');
  51. MiniSmsCode::query()->where('phone', $phone)->where('purpose', self::PURPOSE)
  52. ->whereNull('consumed_at')->update(['consumed_at' => $now]);
  53. MiniSmsCode::query()->insert([
  54. 'phone' => $phone,
  55. 'purpose' => self::PURPOSE,
  56. 'code_hash' => self::hash($phone, $code),
  57. 'attempts' => 0,
  58. 'expires_at' => date('Y-m-d H:i:s', time() + self::ttl()),
  59. 'request_ip' => $ip,
  60. 'created_at' => $now,
  61. ]);
  62. });
  63. self::purge();
  64. return $code;
  65. }
  66. /**
  67. * Consumes the newest live code for the phone in a transaction so two concurrent requests
  68. * cannot both redeem it. The outcome is decided inside the transaction but the rejection is
  69. * raised only after it commits, otherwise a failed guess would roll back the very attempt
  70. * counter that is meant to stop the guessing.
  71. */
  72. public static function consume(string $phone, string $code): void
  73. {
  74. $max = (int) config('mini_api.sms_max_attempts', 5);
  75. $outcome = (new MiniSmsCode())->getConnection()->transaction(function () use ($phone, $code, $max) {
  76. // Never fall back to an older code after the latest code was used or expired.
  77. $row = MiniSmsCode::query()->where('phone', $phone)->where('purpose', self::PURPOSE)
  78. ->orderByDesc('id')->lockForUpdate()->first();
  79. $now = time();
  80. if (!$row || $row->consumed_at !== null || strtotime((string) $row->expires_at) <= $now
  81. || strtotime((string) $row->created_at) + self::ttl() <= $now) {
  82. return ['expired', false];
  83. }
  84. if (!hash_equals((string) $row->code_hash, self::hash($phone, $code))) {
  85. $attempts = (int) $row->attempts + 1;
  86. $locked = $attempts >= $max;
  87. MiniSmsCode::query()->where('id', $row->id)->update([
  88. 'attempts' => $attempts,
  89. // Burning the row on the last miss stops guessing without a separate lock table.
  90. 'consumed_at' => $locked ? date('Y-m-d H:i:s') : null,
  91. ]);
  92. return ['mismatch', $locked];
  93. }
  94. MiniSmsCode::query()->where('id', $row->id)->update([
  95. 'attempts' => (int) $row->attempts + 1,
  96. 'consumed_at' => date('Y-m-d H:i:s'),
  97. ]);
  98. return ['ok', false];
  99. });
  100. match ($outcome[0]) {
  101. 'ok' => null,
  102. 'mismatch' => throw new ApiException(40102, $outcome[1] ? '验证码错误次数过多,请重新获取' : '验证码错误', 401),
  103. default => throw new ApiException(40102, '验证码已失效,请重新获取', 401),
  104. };
  105. }
  106. /** Removes a code that was never delivered, so it cannot be guessed or redeemed later. */
  107. public static function discard(string $phone, string $code): void
  108. {
  109. MiniSmsCode::query()->where('phone', $phone)->where('code_hash', self::hash($phone, $code))->delete();
  110. }
  111. private static function hash(string $phone, string $code): string
  112. {
  113. // Keyed so an offline attacker holding the table still cannot search a 6-digit space.
  114. $key = (string) config('mini_api.sms_code_pepper', '') ?: (string) config('mini_api.sms_password', '');
  115. return hash_hmac('sha256', $code, $key . '|' . $phone . '|' . self::PURPOSE);
  116. }
  117. /** Opportunistic cleanup keeps the table bounded without needing a scheduled job. */
  118. private static function purge(): void
  119. {
  120. MiniSmsCode::query()->where('created_at', '<', date('Y-m-d H:i:s', time() - 86400))->delete();
  121. }
  122. }