| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132 |
- <?php
- declare(strict_types=1);
- namespace app\miniapi\auth;
- use app\miniapi\exception\ApiException;
- use app\miniapi\model\MiniSmsCode;
- /**
- * One-time login codes. The plaintext code never touches the database and is never logged:
- * only a keyed hash is stored, so a leaked table cannot be brute forced offline.
- */
- final class SmsCode
- {
- public const PURPOSE = 'login';
- public static function enabled(): bool
- {
- return config('mini_api.sms_login', false) === true
- && (string) config('mini_api.sms_user', '') !== ''
- && (string) config('mini_api.sms_password', '') !== '';
- }
- public static function ttl(): int
- {
- return (int) config('mini_api.sms_code_ttl', 300);
- }
- /**
- * Applies every send limit before a code is generated, so a caller who is already
- * throttled never causes an SMS to leave the server.
- */
- public static function assertSendAllowed(string $phone, string $ip): void
- {
- $interval = (int) config('mini_api.sms_resend_interval', 60);
- if (MiniSmsCode::query()->where('phone', $phone)->where('created_at', '>', date('Y-m-d H:i:s', time() - $interval))->exists()) {
- throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => $interval]);
- }
- $perIp = (int) config('mini_api.sms_per_ip_per_hour', 30);
- if ($perIp > 0 && MiniSmsCode::query()->where('request_ip', $ip)
- ->where('created_at', '>', date('Y-m-d H:i:s', time() - 3600))->count() >= $perIp) {
- throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => 3600]);
- }
- $perPhone = (int) config('mini_api.sms_daily_per_phone', 10);
- if ($perPhone > 0 && MiniSmsCode::query()->where('phone', $phone)
- ->where('created_at', '>=', date('Y-m-d 00:00:00'))->count() >= $perPhone) {
- throw new ApiException(42901, '今日验证码发送次数已达上限,请明日再试', 429, ['retry_after' => 86400]);
- }
- }
- /** Returns the plaintext code for delivery. The caller sends it and never persists it. */
- public static function issue(string $phone, string $ip): string
- {
- self::assertSendAllowed($phone, $ip);
- $code = (string) random_int(100000, 999999);
- MiniSmsCode::query()->insert([
- 'phone' => $phone,
- 'purpose' => self::PURPOSE,
- 'code_hash' => self::hash($phone, $code),
- 'attempts' => 0,
- 'expires_at' => date('Y-m-d H:i:s', time() + self::ttl()),
- 'request_ip' => $ip,
- 'created_at' => date('Y-m-d H:i:s'),
- ]);
- self::purge();
- return $code;
- }
- /**
- * Consumes the newest live code for the phone in a transaction so two concurrent requests
- * cannot both redeem it. The outcome is decided inside the transaction but the rejection is
- * raised only after it commits, otherwise a failed guess would roll back the very attempt
- * counter that is meant to stop the guessing.
- */
- public static function consume(string $phone, string $code): void
- {
- $max = (int) config('mini_api.sms_max_attempts', 5);
- $outcome = (new MiniSmsCode())->getConnection()->transaction(function () use ($phone, $code, $max) {
- $row = MiniSmsCode::query()->where('phone', $phone)->where('purpose', self::PURPOSE)
- ->whereNull('consumed_at')->where('expires_at', '>', date('Y-m-d H:i:s'))
- ->orderByDesc('id')->lockForUpdate()->first();
- if (!$row) {
- return ['expired', false];
- }
- if (!hash_equals((string) $row->code_hash, self::hash($phone, $code))) {
- $attempts = (int) $row->attempts + 1;
- $locked = $attempts >= $max;
- MiniSmsCode::query()->where('id', $row->id)->update([
- 'attempts' => $attempts,
- // Burning the row on the last miss stops guessing without a separate lock table.
- 'consumed_at' => $locked ? date('Y-m-d H:i:s') : null,
- ]);
- return ['mismatch', $locked];
- }
- MiniSmsCode::query()->where('id', $row->id)->update([
- 'attempts' => (int) $row->attempts + 1,
- 'consumed_at' => date('Y-m-d H:i:s'),
- ]);
- return ['ok', false];
- });
- match ($outcome[0]) {
- 'ok' => null,
- 'mismatch' => throw new ApiException(40102, $outcome[1] ? '验证码错误次数过多,请重新获取' : '验证码错误', 401),
- default => throw new ApiException(40102, '验证码已失效,请重新获取', 401),
- };
- }
- /** Removes a code that was never delivered, so it cannot be guessed or redeemed later. */
- public static function discard(string $phone, string $code): void
- {
- MiniSmsCode::query()->where('phone', $phone)->where('code_hash', self::hash($phone, $code))->delete();
- }
- private static function hash(string $phone, string $code): string
- {
- // Keyed so an offline attacker holding the table still cannot search a 6-digit space.
- $key = (string) config('mini_api.sms_code_pepper', '') ?: (string) config('mini_api.sms_password', '');
- return hash_hmac('sha256', $code, $key . '|' . $phone . '|' . self::PURPOSE);
- }
- /** Opportunistic cleanup keeps the table bounded without needing a scheduled job. */
- private static function purge(): void
- {
- MiniSmsCode::query()->where('created_at', '<', date('Y-m-d H:i:s', time() - 86400))->delete();
- }
- }
|