|
@@ -7,12 +7,31 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
|
|
|
|
use app\miniapi\auth\TestPhoneIdentityProvider;
|
|
use app\miniapi\auth\TestPhoneIdentityProvider;
|
|
|
use app\miniapi\auth\SessionVerifier;
|
|
use app\miniapi\auth\SessionVerifier;
|
|
|
|
|
+use app\miniapi\controller\AuthController;
|
|
|
use app\miniapi\exception\ApiException;
|
|
use app\miniapi\exception\ApiException;
|
|
|
use app\miniapi\model\MiniUser;
|
|
use app\miniapi\model\MiniUser;
|
|
|
use app\miniapi\model\MiniSession;
|
|
use app\miniapi\model\MiniSession;
|
|
|
use app\miniapi\service\TestPhoneLogin;
|
|
use app\miniapi\service\TestPhoneLogin;
|
|
|
use app\miniapi\service\AuthService;
|
|
use app\miniapi\service\AuthService;
|
|
|
|
|
|
|
|
|
|
+final class TestPhoneRequest extends \support\Request
|
|
|
|
|
+{
|
|
|
|
|
+ public function __construct(private readonly string $source, array $input, string $forwarded = '')
|
|
|
|
|
+ {
|
|
|
|
|
+ $body = json_encode($input, JSON_THROW_ON_ERROR);
|
|
|
|
|
+ parent::__construct(
|
|
|
|
|
+ "POST /api/mini/v1/auth/test-phone HTTP/1.1\r\nHost: localhost\r\n"
|
|
|
|
|
+ . "Content-Type: application/json\r\nX-Forwarded-For: " . $forwarded . "\r\n"
|
|
|
|
|
+ . 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function getRemoteIp(): string
|
|
|
|
|
+ {
|
|
|
|
|
+ return $this->source;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
$checks = 0;
|
|
$checks = 0;
|
|
|
function check(bool $ok, string $label): void
|
|
function check(bool $ok, string $label): void
|
|
|
{
|
|
{
|
|
@@ -42,19 +61,21 @@ function settings(array $changes): void
|
|
|
(new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
|
|
(new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
|
|
|
}
|
|
}
|
|
|
$original = config('mini_api');
|
|
$original = config('mini_api');
|
|
|
|
|
+$rateFile = tempnam(sys_get_temp_dir(), 'mini-test-phone-');
|
|
|
$db = (new MiniUser())->getConnection();
|
|
$db = (new MiniUser())->getConnection();
|
|
|
$db->beginTransaction();
|
|
$db->beginTransaction();
|
|
|
try {
|
|
try {
|
|
|
$service = new TestPhoneLogin();
|
|
$service = new TestPhoneLogin();
|
|
|
$input = ['phone' => TestPhoneIdentityProvider::PHONES['enterprise_admin'], 'code' => '66666'];
|
|
$input = ['phone' => TestPhoneIdentityProvider::PHONES['enterprise_admin'], 'code' => '66666'];
|
|
|
settings(['environment' => 'production', 'test_phone_login' => true]);
|
|
settings(['environment' => 'production', 'test_phone_login' => true]);
|
|
|
|
|
+ check(TestPhoneIdentityProvider::enabled(), 'Production permits test accounts when explicitly enabled');
|
|
|
|
|
+ settings(['test_phone_login' => false]);
|
|
|
reject(fn () => $service->login($input), 50301);
|
|
reject(fn () => $service->login($input), 50301);
|
|
|
- settings(['environment' => 'local', 'test_phone_login' => false]);
|
|
|
|
|
- reject(fn () => $service->login($input), 50301);
|
|
|
|
|
- settings(['environment' => 'local', 'test_phone_login' => true]);
|
|
|
|
|
- settings(['test_phone_allowed_ips' => ['127.0.0.1', '::1']]);
|
|
|
|
|
- $service->assertSourceAllowed('127.0.0.1');
|
|
|
|
|
- reject(fn () => $service->assertSourceAllowed('192.168.1.10'), 40301);
|
|
|
|
|
|
|
+ settings([
|
|
|
|
|
+ 'test_phone_login' => true,
|
|
|
|
|
+ 'login_throttle_file' => $rateFile,
|
|
|
|
|
+ 'trusted_proxies' => ['127.0.0.1', '::1'],
|
|
|
|
|
+ ]);
|
|
|
reject(fn () => $service->login($input + ['user_id' => 1]), 42201);
|
|
reject(fn () => $service->login($input + ['user_id' => 1]), 42201);
|
|
|
reject(fn () => $service->login(['phone' => '13012345681', 'code' => '66666']), 40102);
|
|
reject(fn () => $service->login(['phone' => '13012345681', 'code' => '66666']), 40102);
|
|
|
reject(fn () => $service->login(['phone' => '13012345678', 'code' => '66665']), 40102);
|
|
reject(fn () => $service->login(['phone' => '13012345678', 'code' => '66665']), 40102);
|
|
@@ -76,6 +97,34 @@ try {
|
|
|
}
|
|
}
|
|
|
check(count(array_unique(array_column($sessions, 'user_id'))) === 3, 'Distinct test accounts');
|
|
check(count(array_unique(array_column($sessions, 'user_id'))) === 3, 'Distinct test accounts');
|
|
|
check(MiniUser::query()->count() === $count, 'No duplicate users');
|
|
check(MiniUser::query()->count() === $count, 'No duplicate users');
|
|
|
|
|
+
|
|
|
|
|
+ // Exercise the real HTTP controller, not only the service. The original regression
|
|
|
|
|
+ // happened before credential validation, at the controller's source-IP gate.
|
|
|
|
|
+ $controller = new AuthController();
|
|
|
|
|
+ foreach ([
|
|
|
|
|
+ ['192.168.1.20', ''],
|
|
|
|
|
+ ['203.0.113.20', ''],
|
|
|
|
|
+ ['2001:db8::20', ''],
|
|
|
|
|
+ ['127.0.0.1', '198.51.100.25'],
|
|
|
|
|
+ ] as [$source, $forwarded]) {
|
|
|
|
|
+ foreach (TestPhoneIdentityProvider::PHONES as $role => $phone) {
|
|
|
|
|
+ $request = new TestPhoneRequest($source, ['phone' => $phone, 'code' => '66666'], $forwarded);
|
|
|
|
|
+ $response = $controller->testPhone($request);
|
|
|
|
|
+ $body = json_decode($response->rawBody(), true, 512, JSON_THROW_ON_ERROR);
|
|
|
|
|
+ check($response->getStatusCode() === 200 && $body['code'] === 0, 'HTTP test login succeeds from ' . $source);
|
|
|
|
|
+ check($body['data']['user_id'] === (string) $users[$role]->id, 'HTTP returns the correct account for ' . $role);
|
|
|
|
|
+ check(
|
|
|
|
|
+ (new SessionVerifier())->verify($body['data']['access_token'])->userId === (string) $users[$role]->id,
|
|
|
|
|
+ 'HTTP session authenticates for ' . $role
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', ['phone' => $input['phone'], 'code' => '66665'])), 40102);
|
|
|
|
|
+ reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', ['phone' => '13800138000', 'code' => '66666'])), 40102);
|
|
|
|
|
+ settings(['test_phone_login' => false]);
|
|
|
|
|
+ reject(fn () => $controller->testPhone(new TestPhoneRequest('203.0.113.20', $input)), 50301);
|
|
|
|
|
+ settings(['test_phone_login' => true]);
|
|
|
|
|
+
|
|
|
$one = $sessions['enterprise_admin'];
|
|
$one = $sessions['enterprise_admin'];
|
|
|
$two = $service->login($input);
|
|
$two = $service->login($input);
|
|
|
check($one['user_id'] === $two['user_id'], 'Stable identity');
|
|
check($one['user_id'] === $two['user_id'], 'Stable identity');
|
|
@@ -85,7 +134,10 @@ try {
|
|
|
settings(['test_phone_login' => false]);
|
|
settings(['test_phone_login' => false]);
|
|
|
reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101);
|
|
reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101);
|
|
|
settings(['test_phone_login' => true, 'environment' => 'production']);
|
|
settings(['test_phone_login' => true, 'environment' => 'production']);
|
|
|
- reject(fn () => (new SessionVerifier())->verify($one['access_token']), 40101);
|
|
|
|
|
|
|
+ check(
|
|
|
|
|
+ (new SessionVerifier())->verify($one['access_token'])->userId === (string) $users['enterprise_admin']->id,
|
|
|
|
|
+ 'Production sessions remain valid when test login is enabled'
|
|
|
|
|
+ );
|
|
|
settings(['environment' => 'local']);
|
|
settings(['environment' => 'local']);
|
|
|
MiniUser::query()->where('id', $users['enterprise_admin']->id)->update(['status' => 0]);
|
|
MiniUser::query()->where('id', $users['enterprise_admin']->id)->update(['status' => 0]);
|
|
|
reject(fn () => $service->login($input), 40302);
|
|
reject(fn () => $service->login($input), 40302);
|
|
@@ -97,5 +149,6 @@ try {
|
|
|
} finally {
|
|
} finally {
|
|
|
$db->rollBack();
|
|
$db->rollBack();
|
|
|
settings($original);
|
|
settings($original);
|
|
|
|
|
+ unlink($rateFile);
|
|
|
}
|
|
}
|
|
|
echo "PASS: $checks test phone checks, database changes rolled back.\n";
|
|
echo "PASS: $checks test phone checks, database changes rolled back.\n";
|