mini_sms_login_test.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. <?php
  2. /**
  3. * SMS login with a stubbed sender: no message ever leaves the process.
  4. * Every account, code and session change is rolled back at the end.
  5. */
  6. require dirname(__DIR__) . '/vendor/autoload.php';
  7. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  8. \support\App::loadAllConfig(['route']);
  9. use app\miniapi\auth\SessionVerifier;
  10. use app\miniapi\auth\SmsCode;
  11. use app\miniapi\exception\ApiException;
  12. use app\miniapi\model\MiniSmsCode;
  13. use app\miniapi\model\MiniUser;
  14. use app\miniapi\service\IdentityManagement;
  15. use app\miniapi\service\SmsLogin;
  16. use app\miniapi\service\SmsSender;
  17. final class RecordingSender implements SmsSender
  18. {
  19. public array $sent = [];
  20. public bool $fail = false;
  21. public function send(string $phone, string $content): void
  22. {
  23. if ($this->fail) {
  24. throw new ApiException(50302, '短信服务暂时不可用,请稍后再试', 503);
  25. }
  26. $this->sent[] = [$phone, $content];
  27. }
  28. public function lastCode(): string
  29. {
  30. $last = end($this->sent);
  31. if (!$last || !preg_match('/(\d{6})/', (string) $last[1], $match)) {
  32. throw new RuntimeException('No code was delivered');
  33. }
  34. return (string) $match[1];
  35. }
  36. }
  37. $checks = 0;
  38. function check(bool $ok, string $label): void
  39. {
  40. global $checks;
  41. $checks++;
  42. if (!$ok) {
  43. throw new RuntimeException($label);
  44. }
  45. }
  46. function reject(callable $action, int $code, string $label = ''): void
  47. {
  48. try {
  49. $action();
  50. } catch (ApiException $error) {
  51. check($error->getCode() === $code, ($label ?: 'Expected rejection') . ' ' . $code . ' but got ' . $error->getCode());
  52. return;
  53. }
  54. throw new RuntimeException('Missing rejection ' . $code . ' ' . $label);
  55. }
  56. function settings(array $changes): void
  57. {
  58. $property = new ReflectionProperty(\Webman\Config::class, 'config');
  59. $all = $property->getValue();
  60. $all['mini_api'] = array_replace($all['mini_api'], $changes);
  61. $property->setValue(null, $all);
  62. (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
  63. }
  64. $original = config('mini_api');
  65. $phone = '13800138000';
  66. $other = '13900139000';
  67. $sender = new RecordingSender();
  68. $db = (new MiniUser())->getConnection();
  69. $db->beginTransaction();
  70. try {
  71. $login = new SmsLogin($sender);
  72. $input = ['phone' => $phone];
  73. // Every entry point refuses while the switch is off or the provider account is incomplete.
  74. settings(['sms_login' => false, 'sms_user' => 'u', 'sms_password' => 'p']);
  75. reject(fn () => $login->sendCode($input, '127.0.0.1'), 50301, 'switch off');
  76. reject(fn () => $login->login($input + ['code' => '123456']), 50301, 'switch off');
  77. settings(['sms_login' => true, 'sms_user' => '', 'sms_password' => 'p']);
  78. reject(fn () => $login->sendCode($input, '127.0.0.1'), 50301, 'missing account');
  79. settings(['sms_user' => 'u', 'sms_password' => 'p', 'sms_resend_interval' => 60]);
  80. // An unbound phone is indistinguishable from a bound one and costs no SMS.
  81. check($login->sendCode($input, '127.0.0.1') === ['sent' => true, 'expires_in' => SmsCode::ttl()], 'unbound phone reports success');
  82. check($sender->sent === [], 'unbound phone sends nothing');
  83. check(MiniSmsCode::query()->where('phone', $phone)->count() === 0, 'unbound phone stores nothing');
  84. // Field and format policy.
  85. reject(fn () => $login->sendCode(['phone' => '12345'], '127.0.0.1'), 42201, 'too short');
  86. reject(fn () => $login->sendCode(['phone' => '12800138000'], '127.0.0.1'), 42201, 'invalid prefix');
  87. reject(fn () => $login->sendCode(['phone' => '01900139000'], '127.0.0.1'), 42201, 'not a mobile number');
  88. reject(fn () => $login->sendCode(['phone' => $phone, 'extra' => 1], '127.0.0.1'), 42201, 'unknown field');
  89. reject(fn () => $login->sendCode([], '127.0.0.1'), 42201, 'missing number');
  90. $now = date('Y-m-d H:i:s');
  91. $userId = (int) MiniUser::query()->insertGetId([
  92. 'appid' => 'sms-fixture-appid',
  93. 'openid' => 'sms-fixture-openid',
  94. 'nickname' => 'sms',
  95. 'phone' => $phone,
  96. 'status' => 1,
  97. 'created_at' => $now,
  98. 'updated_at' => $now,
  99. ]);
  100. // Issuing keeps only a hash and hands the plaintext to the sender.
  101. $login->sendCode($input, '10.0.0.9');
  102. check(count($sender->sent) === 1, 'one message delivered');
  103. check(preg_match('/^[1-9][0-9]{5}$/D', $sender->lastCode()) === 1, 'six-digit random code delivered');
  104. $row = MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->first();
  105. check(preg_match('/^[a-f0-9]{64}$/D', (string) $row->code_hash) === 1, 'code stored as a hash');
  106. check((string) $row->request_ip === '10.0.0.9', 'request address recorded');
  107. check(!str_contains((string) $row->code_hash, $sender->lastCode()), 'plaintext absent from the hash');
  108. check(MiniSmsCode::query()->where('code_hash', $sender->lastCode())->exists() === false, 'plaintext never stored');
  109. // Send limits. The address cap counts rows stored under that same address.
  110. reject(fn () => $login->sendCode($input, '127.0.0.1'), 42901, 'resend window');
  111. settings(['sms_resend_interval' => 0, 'sms_per_ip_per_hour' => 1]);
  112. reject(fn () => $login->sendCode($input, '10.0.0.9'), 42901, 'per address hourly');
  113. settings(['sms_resend_interval' => 0, 'sms_per_ip_per_hour' => 30]);
  114. MiniSmsCode::query()->where('phone', $phone)->update(['created_at' => date('Y-m-d H:i:s', time() - 7200)]);
  115. $login->sendCode($input, '127.0.0.1');
  116. check(count($sender->sent) === 2, 'second code issued');
  117. settings(['sms_daily_per_phone' => 1]);
  118. reject(fn () => $login->sendCode($input, '127.0.0.1'), 42901, 'daily cap');
  119. settings(['sms_daily_per_phone' => 10]);
  120. // Wrong guesses burn attempts and eventually the code itself.
  121. reject(fn () => $login->login($input + ['code' => '000000']), 40102, 'wrong code');
  122. check((int) MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->value('attempts') === 1, 'attempt counted');
  123. settings(['sms_max_attempts' => 2]);
  124. reject(fn () => $login->login($input + ['code' => '000000']), 40102, 'second wrong guess');
  125. check(MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->value('consumed_at') !== null, 'code burned at the limit');
  126. settings(['sms_max_attempts' => 5]);
  127. // Expired codes are refused.
  128. $login->sendCode($input, '127.0.0.1');
  129. $expired = $sender->lastCode();
  130. MiniSmsCode::query()->where('phone', $phone)->update(['expires_at' => date('Y-m-d H:i:s', time() - 1)]);
  131. reject(fn () => $login->login($input + ['code' => $expired]), 40102, 'expired code');
  132. // A delivery failure must not leave a usable code behind.
  133. $sender->fail = true;
  134. reject(fn () => $login->sendCode($input, '127.0.0.1'), 50302, 'provider failure');
  135. $sender->fail = false;
  136. check(
  137. MiniSmsCode::query()->where('phone', $phone)->whereNull('consumed_at')
  138. ->where('expires_at', '>', date('Y-m-d H:i:s'))->count() === 0,
  139. 'undelivered code discarded'
  140. );
  141. // Happy path, then single use.
  142. MiniSmsCode::query()->where('phone', $phone)->delete();
  143. $login->sendCode($input, '127.0.0.1');
  144. $live = $sender->lastCode();
  145. $wrong = $live === '123456' ? '654321' : '123456';
  146. reject(fn () => $login->login($input + ['code' => $wrong]), 40102, 'no fixed-code bypass');
  147. $session = $login->login($input + ['code' => $live]);
  148. check($session['user_id'] === (string) $userId, 'logged into the bound account');
  149. check(is_array($session['identities']), 'identities returned');
  150. check((new SessionVerifier())->verify($session['access_token'])->userId === (string) $userId, 'session authenticates');
  151. reject(fn () => $login->login($input + ['code' => $live]), 40102, 'code is single use');
  152. // A phone may only ever point at one account.
  153. $otherId = (int) MiniUser::query()->insertGetId([
  154. 'appid' => 'sms-fixture-appid',
  155. 'openid' => 'sms-fixture-openid-2',
  156. 'nickname' => 'other',
  157. 'phone' => $other,
  158. 'status' => 1,
  159. 'created_at' => $now,
  160. 'updated_at' => $now,
  161. ]);
  162. $admin = new IdentityManagement();
  163. reject(fn () => $admin->bindPhone(2, ['user_id' => $otherId, 'phone' => '13900139001']), 40301, 'not a super admin');
  164. reject(fn () => $admin->bindPhone(1, ['user_id' => 999999999, 'phone' => $other]), 40401, 'unknown account');
  165. reject(fn () => $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => $phone]), 40903, 'already bound');
  166. reject(fn () => $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => '12345']), 42201, 'bad number');
  167. $bound = $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => $other]);
  168. check($bound['user_id'] === $otherId && str_contains($bound['phone'], '*'), 'binding reports a masked number');
  169. check((string) MiniUser::query()->where('id', $otherId)->value('phone') === $other, 'binding stored');
  170. $listed = (new IdentityManagement())->users(1, ['user_id' => $otherId]);
  171. check(str_contains((string) $listed['items'][0]['phone'], '*'), 'list masks the number');
  172. check(!str_contains((string) json_encode($listed), $other), 'list never exposes the plain number');
  173. } finally {
  174. $db->rollBack();
  175. settings($original);
  176. }
  177. echo "PASS: $checks SMS login checks; no message sent and all changes rolled back.\n";