|
@@ -0,0 +1,195 @@
|
|
|
|
|
+<?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');
|
|
|
|
|
+$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');
|
|
|
|
|
+ $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();
|
|
|
|
|
+ $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";
|