| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- <?php
- /**
- * SMS login with a stubbed sender: no message ever leaves the process.
- * Every account, code and session change is rolled back at the end.
- */
- require dirname(__DIR__) . '/vendor/autoload.php';
- \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
- \support\App::loadAllConfig(['route']);
- use app\miniapi\auth\SessionVerifier;
- use app\miniapi\auth\SmsCode;
- use app\miniapi\exception\ApiException;
- use app\miniapi\model\MiniSmsCode;
- use app\miniapi\model\MiniUser;
- use app\miniapi\service\IdentityManagement;
- use app\miniapi\service\SmsLogin;
- use app\miniapi\service\SmsSender;
- final class RecordingSender implements SmsSender
- {
- public array $sent = [];
- public bool $fail = false;
- public function send(string $phone, string $content): void
- {
- if ($this->fail) {
- throw new ApiException(50302, '短信服务暂时不可用,请稍后再试', 503);
- }
- $this->sent[] = [$phone, $content];
- }
- public function lastCode(): string
- {
- $last = end($this->sent);
- if (!$last || !preg_match('/(\d{6})/', (string) $last[1], $match)) {
- throw new RuntimeException('No code was delivered');
- }
- return (string) $match[1];
- }
- }
- $checks = 0;
- function check(bool $ok, string $label): void
- {
- global $checks;
- $checks++;
- if (!$ok) {
- throw new RuntimeException($label);
- }
- }
- function reject(callable $action, int $code, string $label = ''): void
- {
- try {
- $action();
- } catch (ApiException $error) {
- check($error->getCode() === $code, ($label ?: 'Expected rejection') . ' ' . $code . ' but got ' . $error->getCode());
- return;
- }
- throw new RuntimeException('Missing rejection ' . $code . ' ' . $label);
- }
- function settings(array $changes): void
- {
- $property = new ReflectionProperty(\Webman\Config::class, 'config');
- $all = $property->getValue();
- $all['mini_api'] = array_replace($all['mini_api'], $changes);
- $property->setValue(null, $all);
- (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
- }
- $original = config('mini_api');
- check(SmsCode::ttl() === 120, 'login codes expire in two minutes');
- $phone = '13800138000';
- $other = '13900139000';
- $sender = new RecordingSender();
- $db = (new MiniUser())->getConnection();
- $db->beginTransaction();
- try {
- $login = new SmsLogin($sender);
- $input = ['phone' => $phone];
- // Every entry point refuses while the switch is off or the provider account is incomplete.
- settings(['sms_login' => false, 'sms_user' => 'u', 'sms_password' => 'p']);
- reject(fn () => $login->sendCode($input, '127.0.0.1'), 50301, 'switch off');
- reject(fn () => $login->login($input + ['code' => '123456']), 50301, 'switch off');
- settings(['sms_login' => true, 'sms_user' => '', 'sms_password' => 'p']);
- reject(fn () => $login->sendCode($input, '127.0.0.1'), 50301, 'missing account');
- settings(['sms_user' => 'u', 'sms_password' => 'p', 'sms_resend_interval' => 60]);
- // An unbound phone is indistinguishable from a bound one and costs no SMS.
- check($login->sendCode($input, '127.0.0.1') === ['sent' => true, 'expires_in' => SmsCode::ttl()], 'unbound phone reports success');
- check($sender->sent === [], 'unbound phone sends nothing');
- check(MiniSmsCode::query()->where('phone', $phone)->count() === 0, 'unbound phone stores nothing');
- // Field and format policy.
- reject(fn () => $login->sendCode(['phone' => '12345'], '127.0.0.1'), 42201, 'too short');
- reject(fn () => $login->sendCode(['phone' => '12800138000'], '127.0.0.1'), 42201, 'invalid prefix');
- reject(fn () => $login->sendCode(['phone' => '01900139000'], '127.0.0.1'), 42201, 'not a mobile number');
- reject(fn () => $login->sendCode(['phone' => $phone, 'extra' => 1], '127.0.0.1'), 42201, 'unknown field');
- reject(fn () => $login->sendCode([], '127.0.0.1'), 42201, 'missing number');
- $now = date('Y-m-d H:i:s');
- $userId = (int) MiniUser::query()->insertGetId([
- 'appid' => 'sms-fixture-appid',
- 'openid' => 'sms-fixture-openid',
- 'nickname' => 'sms',
- 'phone' => $phone,
- 'status' => 1,
- 'created_at' => $now,
- 'updated_at' => $now,
- ]);
- // Issuing keeps only a hash and hands the plaintext to the sender.
- $issued = $login->sendCode($input, '10.0.0.9');
- check($issued['expires_in'] === 120, 'client receives the two-minute validity');
- check(count($sender->sent) === 1, 'one message delivered');
- check(preg_match('/^[1-9][0-9]{5}$/D', $sender->lastCode()) === 1, 'six-digit random code delivered');
- $row = MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->first();
- check(strtotime((string) $row->expires_at) - strtotime((string) $row->created_at) === 120, 'stored validity is 120 seconds');
- check(str_contains($sender->sent[0][1], '2 分钟内有效'), 'message states the same validity');
- check(preg_match('/^[a-f0-9]{64}$/D', (string) $row->code_hash) === 1, 'code stored as a hash');
- check((string) $row->request_ip === '10.0.0.9', 'request address recorded');
- check(!str_contains((string) $row->code_hash, $sender->lastCode()), 'plaintext absent from the hash');
- check(MiniSmsCode::query()->where('code_hash', $sender->lastCode())->exists() === false, 'plaintext never stored');
- // Send limits. The address cap counts rows stored under that same address.
- reject(fn () => $login->sendCode($input, '127.0.0.1'), 42901, 'resend window');
- settings(['sms_resend_interval' => 0, 'sms_per_ip_per_hour' => 1]);
- reject(fn () => $login->sendCode($input, '10.0.0.9'), 42901, 'per address hourly');
- settings(['sms_resend_interval' => 0, 'sms_per_ip_per_hour' => 30]);
- MiniSmsCode::query()->where('phone', $phone)->update(['created_at' => date('Y-m-d H:i:s', time() - 7200)]);
- $login->sendCode($input, '127.0.0.1');
- check(count($sender->sent) === 2, 'second code issued');
- settings(['sms_daily_per_phone' => 1]);
- reject(fn () => $login->sendCode($input, '127.0.0.1'), 42901, 'daily cap');
- settings(['sms_daily_per_phone' => 10]);
- // Wrong guesses burn attempts and eventually the code itself.
- reject(fn () => $login->login($input + ['code' => '000000']), 40102, 'wrong code');
- check((int) MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->value('attempts') === 1, 'attempt counted');
- settings(['sms_max_attempts' => 2]);
- reject(fn () => $login->login($input + ['code' => '000000']), 40102, 'second wrong guess');
- check(MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->value('consumed_at') !== null, 'code burned at the limit');
- settings(['sms_max_attempts' => 5]);
- // Expired codes are refused.
- $login->sendCode($input, '127.0.0.1');
- $expired = $sender->lastCode();
- MiniSmsCode::query()->where('phone', $phone)->update(['expires_at' => date('Y-m-d H:i:s', time() - 1)]);
- reject(fn () => $login->login($input + ['code' => $expired]), 40102, 'expired code');
- // Codes issued under the former five-minute policy also stop at two minutes.
- $login->sendCode($input, '127.0.0.1');
- $legacy = $sender->lastCode();
- MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->limit(1)->update([
- 'created_at' => date('Y-m-d H:i:s', time() - 120),
- 'expires_at' => date('Y-m-d H:i:s', time() + 180),
- ]);
- reject(fn () => $login->login($input + ['code' => $legacy]), 40102, 'two-minute boundary rejects former five-minute code');
- // The latest record is authoritative even when an older code is still unconsumed.
- MiniSmsCode::query()->where('phone', $phone)->delete();
- $login->sendCode($input, '127.0.0.1');
- $older = $sender->lastCode();
- $login->sendCode($input, '127.0.0.1');
- $newest = $sender->lastCode();
- $latestId = (int) MiniSmsCode::query()->where('phone', $phone)->max('id');
- check(!MiniSmsCode::query()->where('phone', $phone)->where('id', '<', $latestId)
- ->whereNull('consumed_at')->exists(), 'resending invalidates previous codes');
- // Recreate an unconsumed legacy row to verify there is no fallback after deployment.
- MiniSmsCode::query()->where('phone', $phone)->where('id', '<', $latestId)->update(['consumed_at' => null]);
- MiniSmsCode::query()->where('id', $latestId)->update(['expires_at' => date('Y-m-d H:i:s', time() - 1)]);
- reject(fn () => $login->login($input + ['code' => $older]), 40102, 'expired latest code cannot revive older code');
- MiniSmsCode::query()->where('id', $latestId)->update(['expires_at' => date('Y-m-d H:i:s', time() + 60)]);
- $login->login($input + ['code' => $newest]);
- reject(fn () => $login->login($input + ['code' => $older]), 40102, 'used latest code cannot revive older code');
- $sender->fail = true;
- reject(fn () => $login->sendCode($input, '127.0.0.1'), 50302, 'failed resend');
- $sender->fail = false;
- reject(fn () => $login->login($input + ['code' => $older]), 40102, 'failed resend cannot revive older code');
- MiniSmsCode::query()->where('phone', $phone)->update(['expires_at' => date('Y-m-d H:i:s', time() - 1)]);
- // A delivery failure must not leave a usable code behind.
- $sender->fail = true;
- reject(fn () => $login->sendCode($input, '127.0.0.1'), 50302, 'provider failure');
- $sender->fail = false;
- check(
- MiniSmsCode::query()->where('phone', $phone)->whereNull('consumed_at')
- ->where('expires_at', '>', date('Y-m-d H:i:s'))->count() === 0,
- 'undelivered code discarded'
- );
- // Happy path, then single use.
- MiniSmsCode::query()->where('phone', $phone)->delete();
- $login->sendCode($input, '127.0.0.1');
- $live = $sender->lastCode();
- $wrong = $live === '123456' ? '654321' : '123456';
- reject(fn () => $login->login($input + ['code' => $wrong]), 40102, 'no fixed-code bypass');
- $session = $login->login($input + ['code' => $live]);
- check($session['user_id'] === (string) $userId, 'logged into the bound account');
- check(is_array($session['identities']), 'identities returned');
- check((new SessionVerifier())->verify($session['access_token'])->userId === (string) $userId, 'session authenticates');
- reject(fn () => $login->login($input + ['code' => $live]), 40102, 'code is single use');
- // A phone may only ever point at one account.
- $otherId = (int) MiniUser::query()->insertGetId([
- 'appid' => 'sms-fixture-appid',
- 'openid' => 'sms-fixture-openid-2',
- 'nickname' => 'other',
- 'phone' => $other,
- 'status' => 1,
- 'created_at' => $now,
- 'updated_at' => $now,
- ]);
- $admin = new IdentityManagement();
- reject(fn () => $admin->bindPhone(2, ['user_id' => $otherId, 'phone' => '13900139001']), 40301, 'not a super admin');
- reject(fn () => $admin->bindPhone(1, ['user_id' => 999999999, 'phone' => $other]), 40401, 'unknown account');
- reject(fn () => $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => $phone]), 40903, 'already bound');
- reject(fn () => $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => '12345']), 42201, 'bad number');
- $bound = $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => $other]);
- check($bound['user_id'] === $otherId && str_contains($bound['phone'], '*'), 'binding reports a masked number');
- check((string) MiniUser::query()->where('id', $otherId)->value('phone') === $other, 'binding stored');
- $listed = (new IdentityManagement())->users(1, ['user_id' => $otherId]);
- check(str_contains((string) $listed['items'][0]['phone'], '*'), 'list masks the number');
- check(!str_contains((string) json_encode($listed), $other), 'list never exposes the plain number');
- } finally {
- $db->rollBack();
- settings($original);
- }
- echo "PASS: $checks SMS login checks; no message sent and all changes rolled back.\n";
|