mini_test_phone_test.php 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. <?php
  2. /** Real sessions in a transaction; all account/session changes are rolled back. */
  3. require dirname(__DIR__) . '/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. use app\miniapi\auth\TestPhoneIdentityProvider;
  7. use app\miniapi\auth\SessionVerifier;
  8. use app\miniapi\controller\AuthController;
  9. use app\miniapi\exception\ApiException;
  10. use app\miniapi\model\MiniUser;
  11. use app\miniapi\model\MiniSession;
  12. use app\miniapi\service\TestPhoneLogin;
  13. use app\miniapi\service\AuthService;
  14. final class TestPhoneRequest extends \support\Request
  15. {
  16. public function __construct(private readonly string $source, array $input, string $forwarded = '')
  17. {
  18. $body = json_encode($input, JSON_THROW_ON_ERROR);
  19. parent::__construct(
  20. "POST /api/mini/v1/auth/test-phone HTTP/1.1\r\nHost: localhost\r\n"
  21. . "Content-Type: application/json\r\nX-Forwarded-For: " . $forwarded . "\r\n"
  22. . 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body
  23. );
  24. }
  25. public function getRemoteIp(): string
  26. {
  27. return $this->source;
  28. }
  29. }
  30. $checks = 0;
  31. function check(bool $ok, string $label): void
  32. {
  33. global $checks;
  34. $checks++;
  35. if (!$ok) {
  36. throw new RuntimeException($label);
  37. }
  38. }
  39. function reject(callable $action, int $code): void
  40. {
  41. try {
  42. $action();
  43. } catch (ApiException $error) {
  44. check($error->getCode() === $code, 'Expected rejection ' . $code);
  45. return;
  46. }
  47. throw new RuntimeException('Missing rejection ' . $code);
  48. }
  49. function settings(array $changes): void
  50. {
  51. $property = new ReflectionProperty(\Webman\Config::class, 'config');
  52. $all = $property->getValue();
  53. $all['mini_api'] = array_replace($all['mini_api'], $changes);
  54. $property->setValue(null, $all);
  55. (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
  56. }
  57. $original = config('mini_api');
  58. $rateFile = tempnam(sys_get_temp_dir(), 'mini-test-phone-');
  59. $db = (new MiniUser())->getConnection();
  60. $db->beginTransaction();
  61. try {
  62. $service = new TestPhoneLogin();
  63. $input = ['phone' => TestPhoneIdentityProvider::PHONES['enterprise_admin'], 'code' => '66666'];
  64. settings(['environment' => 'production', 'test_phone_login' => true]);
  65. check(TestPhoneIdentityProvider::enabled(), 'Production permits test accounts when explicitly enabled');
  66. settings(['test_phone_login' => false]);
  67. reject(fn () => $service->login($input), 50301);
  68. settings([
  69. 'test_phone_login' => true,
  70. 'login_throttle_file' => $rateFile,
  71. 'trusted_proxies' => ['127.0.0.1', '::1'],
  72. ]);
  73. reject(fn () => $service->login($input + ['user_id' => 1]), 42201);
  74. reject(fn () => $service->login(['phone' => '13012345681', 'code' => '66666']), 40102);
  75. reject(fn () => $service->login(['phone' => '13012345678', 'code' => '66665']), 40102);
  76. reject(fn () => $service->login(['phone' => ['13012345678'], 'code' => '66666']), 42201);
  77. $users = [];
  78. foreach (TestPhoneIdentityProvider::PHONES as $role => $phone) {
  79. $users[$role] = MiniUser::query()->firstOrCreate(
  80. ['appid' => TestPhoneIdentityProvider::APP_ID, 'openid' => $phone],
  81. ['nickname' => 'test', 'status' => 1, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s')]
  82. );
  83. }
  84. $count = MiniUser::query()->count();
  85. $sessions = [];
  86. foreach (TestPhoneIdentityProvider::PHONES as $role => $phone) {
  87. $sessions[$role] = $service->login(['phone' => $phone, 'code' => '66666']);
  88. check($sessions[$role]['user_id'] === (string) $users[$role]->id, 'Correct account for ' . $role);
  89. check(count($sessions[$role]['identities']) <= 1, 'At most one role for ' . $role);
  90. }
  91. check(count(array_unique(array_column($sessions, 'user_id'))) === 3, 'Distinct test accounts');
  92. check(MiniUser::query()->count() === $count, 'No duplicate users');
  93. // Exercise the real HTTP controller, not only the service. The original regression
  94. // happened before credential validation, at the controller's source-IP gate.
  95. $controller = new AuthController();
  96. foreach ([
  97. ['192.168.1.20', ''],
  98. ['203.0.113.20', ''],
  99. ['2001:db8::20', ''],
  100. ['127.0.0.1', '198.51.100.25'],
  101. ] as [$source, $forwarded]) {
  102. foreach (TestPhoneIdentityProvider::PHONES as $role => $phone) {
  103. $request = new TestPhoneRequest($source, ['phone' => $phone, 'code' => '66666'], $forwarded);
  104. $response = $controller->testPhone($request);
  105. $body = json_decode($response->rawBody(), true, 512, JSON_THROW_ON_ERROR);
  106. check($response->getStatusCode() === 200 && $body['code'] === 0, 'HTTP test login succeeds from ' . $source);
  107. check($body['data']['user_id'] === (string) $users[$role]->id, 'HTTP returns the correct account for ' . $role);
  108. check(
  109. (new SessionVerifier())->verify($body['data']['access_token'])->userId === (string) $users[$role]->id,
  110. 'HTTP session authenticates for ' . $role
  111. );
  112. }
  113. }
  114. reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', ['phone' => $input['phone'], 'code' => '66665'])), 40102);
  115. reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', ['phone' => '13800138000', 'code' => '66666'])), 40102);
  116. settings(['test_phone_login' => false]);
  117. reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', $input)), 50301);
  118. settings(['test_phone_login' => true]);
  119. $one = $sessions['enterprise_admin'];
  120. $two = $service->login($input);
  121. check($one['user_id'] === $two['user_id'], 'Stable identity');
  122. check($one['access_token'] !== $two['access_token'], 'Fresh random sessions');
  123. check((new SessionVerifier())->verify($one['access_token'])->userId === (string) $users['enterprise_admin']->id, 'Session authenticated');
  124. check(MiniSession::query()->where('token_hash', hash('sha256', $one['access_token']))->exists(), 'Token hash stored');
  125. settings(['test_phone_login' => false]);
  126. reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101);
  127. settings(['test_phone_login' => true, 'environment' => 'production']);
  128. check(
  129. (new SessionVerifier())->verify($one['access_token'])->userId === (string) $users['enterprise_admin']->id,
  130. 'Production sessions remain valid when test login is enabled'
  131. );
  132. settings(['environment' => 'local']);
  133. MiniUser::query()->where('id', $users['enterprise_admin']->id)->update(['status' => 0]);
  134. reject(fn () => $service->login($input), 40302);
  135. reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101);
  136. MiniUser::query()->where('id', $users['enterprise_admin']->id)->update(['status' => 1]);
  137. (new AuthService())->logout($one['access_token']);
  138. reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101);
  139. check((new SessionVerifier())->verify($two['access_token'])->userId === (string) $users['enterprise_admin']->id, 'Other session still works');
  140. } finally {
  141. $db->rollBack();
  142. settings($original);
  143. unlink($rateFile);
  144. }
  145. echo "PASS: $checks test phone checks, database changes rolled back.\n";