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'); $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. $login->sendCode($input, '10.0.0.9'); 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(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'); // 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";