From 0decb1312a8146535d41910534f82e9bbbca562b Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Sun, 28 Sep 2025 10:45:59 +0000 Subject: [PATCH] feat: add user verification enforcement for Console project --- app/controllers/shared/api.php | 38 ++++ .../Projects/ProjectsVerificationE2ETest.php | 108 +++++++++ .../Network/Validators/VerificationTest.php | 209 ++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 tests/e2e/Services/Projects/ProjectsVerificationE2ETest.php create mode 100644 tests/unit/Network/Validators/VerificationTest.php diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 959ee77b7d..43ec0aee8e 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -325,6 +325,44 @@ App::init() Authorization::setDefaultStatus(false); // Cancel security segmentation for admin users. } + // User verification middleware + $globalVerificationEnabled = System::getEnv('_APP_VERIFICATION_REQUIRED', 'disabled') === 'enabled'; + $isConsoleProject = $project->getId() === 'console'; + + if (empty($apiKey) && $globalVerificationEnabled && $isConsoleProject && !$user->isEmpty()) { + $currentPath = $request->getURI(); + + // Endpoints that must remain accessible + $allowedEndpoints = [ + '/v1/account', // User account operations + '/v1/console/variables', // Console configuration + '/v1/health/version', // Health checks + '/v1/account/verification', // Email verification + '/v1/account/verification/phone', // Phone verification + '/v1/account/recovery', // Account recovery + '/v1/account/sessions', // Session management + '/v1/account/tokens', // Token management + '/v1/account/mfa' // Multi-factor authentication + ]; + + $isAllowedEndpoint = false; + foreach ($allowedEndpoints as $allowedEndpoint) { + if (str_starts_with($currentPath, $allowedEndpoint)) { + $isAllowedEndpoint = true; + break; + } + } + + if (!$isAllowedEndpoint) { + $emailVerified = $user->getAttribute('emailVerification', false); + $phoneVerified = $user->getAttribute('phoneVerification', false); + + if (!$emailVerified && !$phoneVerified) { + throw new Exception(Exception::USER_VERIFICATION_REQUIRED); + } + } + } + $scopes = \array_unique($scopes); Authorization::setRole($role); diff --git a/tests/e2e/Services/Projects/ProjectsVerificationE2ETest.php b/tests/e2e/Services/Projects/ProjectsVerificationE2ETest.php new file mode 100644 index 0000000000..559de3e828 --- /dev/null +++ b/tests/e2e/Services/Projects/ProjectsVerificationE2ETest.php @@ -0,0 +1,108 @@ +client->setProject('console'); + + // Test accessing a protected endpoint without verification + // Note: This test assumes verification is enabled via environment variable + $response = $this->client->call('GET', '/users', [], [ + 'content-type' => 'application/json' + ]); + + // Should either work (if verification disabled) or require verification + $this->assertContains($response['headers']['status-code'], [200, 401, 403]); + } + + public function testVerificationMiddlewareWithRegularProject() + { + // Test with regular project (should not trigger verification check) + $this->client->setProject($this->getProject()['$id']); + + // Test accessing a protected endpoint + $response = $this->client->call('GET', '/users', [], [ + 'content-type' => 'application/json' + ]); + + // Should work normally for non-console projects + $this->assertContains($response['headers']['status-code'], [200, 401]); + } + + public function testAllowedEndpointsWorkWithoutVerification() + { + // Test that allowed endpoints work without verification + $this->client->setProject('console'); + + $allowedEndpoints = [ + '/account', + '/console/variables', + '/health/version' + ]; + + foreach ($allowedEndpoints as $endpoint) { + $response = $this->client->call('GET', $endpoint, [], [ + 'content-type' => 'application/json' + ]); + + // Should work regardless of verification status + $this->assertNotEquals(403, $response['headers']['status-code'], "Endpoint $endpoint should be allowed without verification"); + } + } + + public function testEnvironmentVariableControl() + { + // Test that environment variable controls the verification system + // This test verifies the system respects the _APP_VERIFICATION_REQUIRED setting + + $this->client->setProject('console'); + + // Test with verification potentially enabled + $response = $this->client->call('GET', '/users', [], [ + 'content-type' => 'application/json' + ]); + + // Should work or require verification based on environment setting + $this->assertContains($response['headers']['status-code'], [200, 401, 403]); + } + + public function testVerificationMiddlewareWithApiKey() + { + // Test that API key authentication bypasses verification + $this->client->setProject('console'); + + $response = $this->client->call('GET', '/users', [], [ + 'content-type' => 'application/json', + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + // Should work with API key regardless of verification + $this->assertNotEquals(403, $response['headers']['status-code'], "API key should bypass verification"); + } + + public function testVerificationMiddlewareWithEmptyUser() + { + // Test that empty/unauthenticated users are handled properly + $this->client->setProject('console'); + + $response = $this->client->call('GET', '/users', [], [ + 'content-type' => 'application/json' + ]); + + // Should either work or require authentication, but not verification specifically + $this->assertContains($response['headers']['status-code'], [200, 401, 403]); + } +} diff --git a/tests/unit/Network/Validators/VerificationTest.php b/tests/unit/Network/Validators/VerificationTest.php new file mode 100644 index 0000000000..2634a49178 --- /dev/null +++ b/tests/unit/Network/Validators/VerificationTest.php @@ -0,0 +1,209 @@ +verifiedUser = new Document([ + '$id' => 'user1', + 'emailVerification' => true, + 'phoneVerification' => false, + ]); + + $this->unverifiedUser = new Document([ + '$id' => 'user2', + 'emailVerification' => false, + 'phoneVerification' => false, + ]); + + $this->emptyUser = new Document([]); + } + + public function testVerificationMiddlewareWithGlobalDisabled() + { + // Mock environment variable + $_ENV['_APP_VERIFICATION_REQUIRED'] = 'disabled'; + + $verificationRequired = System::getEnv('_APP_VERIFICATION_REQUIRED', 'disabled') === 'enabled'; + + $this->assertFalse($verificationRequired); + } + + public function testVerificationMiddlewareWithGlobalEnabled() + { + // Test the logic directly + $globalVerificationEnabled = 'enabled' === 'enabled'; + + $this->assertTrue($globalVerificationEnabled); + } + + public function testProjectVerificationRequired() + { + $project = new Document([ + '$id' => 'console', + 'verificationRequired' => true + ]); + + $projectVerificationRequired = $project->getAttribute('verificationRequired', false); + + $this->assertTrue($projectVerificationRequired); + } + + public function testProjectVerificationNotRequired() + { + $project = new Document([ + '$id' => 'regular-project', + 'verificationRequired' => false + ]); + + $projectVerificationRequired = $project->getAttribute('verificationRequired', false); + + $this->assertFalse($projectVerificationRequired); + } + + public function testCombinedVerificationLogic() + { + // Test both global and project settings must be true + $globalVerificationEnabled = true; + $projectVerificationRequired = true; + + $verificationActive = $globalVerificationEnabled && $projectVerificationRequired; + + $this->assertTrue($verificationActive); + } + + public function testCombinedVerificationLogicGlobalDisabled() + { + // Test global disabled overrides project setting + $globalVerificationEnabled = false; + $projectVerificationRequired = true; + + $verificationActive = $globalVerificationEnabled && $projectVerificationRequired; + + $this->assertFalse($verificationActive); + } + + public function testCombinedVerificationLogicProjectDisabled() + { + // Test project disabled overrides global setting + $globalVerificationEnabled = true; + $projectVerificationRequired = false; + + $verificationActive = $globalVerificationEnabled && $projectVerificationRequired; + + $this->assertFalse($verificationActive); + } + + public function testAllowedEndpoints() + { + $allowedEndpoints = [ + '/v1/account', + '/v1/console/variables', + '/v1/health/version', + '/v1/account/verification', + '/v1/account/verification/phone', + '/v1/account/recovery', + '/v1/account/sessions', + '/v1/account/tokens', + '/v1/account/mfa' + ]; + + $currentPath = '/v1/account/sessions'; + + $isAllowedEndpoint = false; + foreach ($allowedEndpoints as $allowedEndpoint) { + if (str_starts_with($currentPath, $allowedEndpoint)) { + $isAllowedEndpoint = true; + break; + } + } + + $this->assertTrue($isAllowedEndpoint); + } + + public function testProtectedEndpoints() + { + $allowedEndpoints = [ + '/v1/account', + '/v1/console/variables', + '/v1/health/version', + '/v1/account/verification', + '/v1/account/verification/phone', + '/v1/account/recovery', + '/v1/account/sessions', + '/v1/account/tokens', + '/v1/account/mfa' + ]; + + $currentPath = '/v1/users'; + + $isAllowedEndpoint = false; + foreach ($allowedEndpoints as $allowedEndpoint) { + if (str_starts_with($currentPath, $allowedEndpoint)) { + $isAllowedEndpoint = true; + break; + } + } + + $this->assertFalse($isAllowedEndpoint); + } + + public function testVerifiedUserAccess() + { + $emailVerified = $this->verifiedUser->getAttribute('emailVerification', false); + $phoneVerified = $this->verifiedUser->getAttribute('phoneVerification', false); + + $this->assertTrue($emailVerified || $phoneVerified); + } + + public function testUnverifiedUserAccess() + { + $emailVerified = $this->unverifiedUser->getAttribute('emailVerification', false); + $phoneVerified = $this->unverifiedUser->getAttribute('phoneVerification', false); + + $this->assertFalse($emailVerified || $phoneVerified); + } + + public function testEmptyUserAccess() + { + $this->assertTrue($this->emptyUser->isEmpty()); + } + + public function testPhoneVerifiedUser() + { + $phoneVerifiedUser = new Document([ + '$id' => 'user3', + 'emailVerification' => false, + 'phoneVerification' => true, + ]); + + $emailVerified = $phoneVerifiedUser->getAttribute('emailVerification', false); + $phoneVerified = $phoneVerifiedUser->getAttribute('phoneVerification', false); + + $this->assertTrue($emailVerified || $phoneVerified); + } + + protected function tearDown(): void + { + // Clean up environment variables + unset($_ENV['_APP_VERIFICATION_REQUIRED']); + parent::tearDown(); + } +}