diff --git a/app/controllers/general.php b/app/controllers/general.php index 36ee66d580..07ff22f034 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -3,6 +3,7 @@ require_once __DIR__ . '/../init.php'; use Ahc\Jwt\JWT; +use Ahc\Jwt\JWTException; use Appwrite\Auth\Auth; use Appwrite\Auth\Key; use Appwrite\Event\Certificate; @@ -155,6 +156,80 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $path .= '?' . $query; } + $protocol = $request->getProtocol(); + + /** + Ensure preview authorization + - Authorization is skippable for tests, and build screenshot + - If cookie is not sent by client -> not authorized + - If JWT in cookie is invalid or expired -> not authorized + - If user is blocked or removed -> not authorized + - If user's session is removed or expired -> not authorized + - If user is not member of team of this deployment -> not authorized + - If not authorized, redirect to Console redirect UI + - If authorized, continue as if auth was not required + */ + $requirePreview = \is_null($apiKey) || !$apiKey->isPreviewAuthDisabled(); + if ($isPreview && $requirePreview) { + $cookie = $request->getCookie(Auth::$cookieNamePreview, ''); + $authorized = false; + + // Security checks to mark authorized true + if (!empty($cookie)) { + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); + + $payload = []; + try { + $payload = $jwt->decode($cookie); + } catch (JWTException $error) { + // Authorized remains false + } + + $userExists = false; + $userId = $payload['userId'] ?? ''; + if (!empty($userId)) { + $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); + if (!$user->isEmpty() && $user->getAttribute('status', false)) { + $userExists = true; + } + } + + $sessionExists = false; + $jwtSessionId = $payload['sessionId'] ?? ''; + if (!empty($jwtSessionId) && !empty($user->find('$id', $jwtSessionId, 'sessions'))) { + $sessionExists = true; + } + + $membershipExists = false; + $project = Authorization::skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + if (!$project->isEmpty()) { + $teamId = $project->getAttribute('teamId', ''); + $membership = $user->find('teamId', $teamId, 'memberships'); + if (!empty($membership)) { + $membershipExists = true; + } + } + + if ($userExists && $sessionExists && $membershipExists) { + $authorized = true; + } + } + + if (!$authorized) { + $url = (System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https') . "://" . System::getEnv('_APP_DOMAIN'); + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($url . '/console/auth/preview?' + . \http_build_query([ + 'projectId' => $projectId, + 'origin' => $protocol . '://' . $host, + 'path' => $path + ])); + return true; + } + } + $body = $swooleRequest->getContent() ?? ''; $method = $swooleRequest->server['request_method']; @@ -1272,6 +1347,34 @@ App::get('/v1/ping') $response->text('Pong!'); }); +// Preview authorization +App::get('/_appwrite/authorize') + ->inject('request') + ->inject('response') + ->inject('previewHostname') + ->action(function (Request $request, Response $response, string $previewHostname) { + + $host = $request->getHostname() ?? ''; + if (!empty($previewHostname)) { + $host = $previewHostname; + } + + $referrer = $request->getReferer(); + $protocol = \parse_url($request->getOrigin($referrer), PHP_URL_SCHEME); + + $jwt = $request->getParam('jwt', ''); + $path = $request->getParam('path', ''); + + $duration = 60 * 60 * 24; // 1 day in seconds + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); + + $response + ->addCookie(Auth::$cookieNamePreview, $jwt, (new \DateTime($expire))->getTimestamp(), '/', $host, ('https' === $protocol), true, null) + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($protocol . '://' . $host . $path); + }); + App::wildcard() ->groups(['api']) ->label('scope', 'global') diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 8555d5cb00..9af5045fa4 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -103,6 +103,11 @@ class Auth */ public static $cookieName = 'a_session'; + /** + * @var string + */ + public static $cookieNamePreview = 'a_jwt_console'; + /** * User Unique ID. * diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 83f8dd408d..89c28c4727 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -23,6 +23,7 @@ class Key protected bool $hostnameOverride = false, protected bool $bannerDisabled = false, protected bool $projectCheckDisabled = false, + protected bool $previewAuthDisabled = false, ) { } @@ -73,6 +74,11 @@ class Key return $this->bannerDisabled; } + public function isPreviewAuthDisabled(): bool + { + return $this->previewAuthDisabled; + } + public function isProjectCheckDisabled(): bool { return $this->projectCheckDisabled; @@ -132,6 +138,7 @@ class Key $hostnameOverride = $payload['hostnameOverride'] ?? false; $bannerDisabled = $payload['bannerDisabled'] ?? false; $projectCheckDisabled = $payload['projectCheckDisabled'] ?? false; + $previewAuthDisabled = $payload['previewAuthDisabled'] ?? false; $scopes = \array_merge($payload['scopes'] ?? [], $scopes); if (!$projectCheckDisabled && $projectId !== $project->getId()) { @@ -148,7 +155,8 @@ class Key $disabledMetrics, $hostnameOverride, $bannerDisabled, - $projectCheckDisabled + $projectCheckDisabled, + $previewAuthDisabled ); case API_KEY_STANDARD: $key = $project->find( diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 5d6a518e10..d8fd4d8a93 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -742,7 +742,8 @@ class Builds extends Action $apiKey = $jwtObj->encode([ 'hostnameOverride' => true, 'bannerDisabled' => true, - 'projectCheckDisabled' => true + 'projectCheckDisabled' => true, + 'previewAuthDisabled' => true, ]); // TODO: @Meldiron if becomes too slow, do concurrently diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index dc80808b14..278d1cd0d5 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -164,7 +164,7 @@ class Client * @return array * @throws Exception */ - public function call(string $method, string $path = '', array $headers = [], mixed $params = [], bool $decode = true): array + public function call(string $method, string $path = '', array $headers = [], mixed $params = [], bool $decode = true, bool $followRedirects = true): array { $headers = array_merge($this->headers, $headers); $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); @@ -192,7 +192,7 @@ class Client curl_setopt($ch, CURLOPT_PATH_AS_IS, 1); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $followRedirects); curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36'); curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index b961596fc6..694485a8a2 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Sites; +use Ahc\Jwt\JWT; use Appwrite\Platform\Modules\Compute\Specification; use Appwrite\Tests\Retry; use Tests\E2E\Client; @@ -1519,8 +1520,18 @@ class SitesCustomServerTest extends Scope $proxyClient = new Client(); $proxyClient->setEndpoint('http://' . $previewDomain); - $response = $proxyClient->call(Client::METHOD_GET, '/'); + $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('/console/auth/preview', $response['headers']['location']); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0); + $apiKey = $jwtObj->encode([ + 'projectCheckDisabled' => true, + 'previewAuthDisabled' => true, + ]); + $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ + 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertStringContainsString("Hello Appwrite", $response['body']); $this->assertStringContainsString("Preview by", $response['body']); @@ -1876,4 +1887,165 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + + public function testPreviewDomain(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'framework' => 'other', + 'name' => 'Authorized preview site', + 'siteId' => ID::unique(), + 'adapter' => 'static', + ]); + $this->assertNotEmpty($siteId); + + $deploymentId = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentId); + + $domain = $this->getDeploymentDomain($deploymentId); + $this->assertNotEmpty($domain); + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('/console/auth/preview', $response['headers']['location']); + $this->assertStringContainsString('projectId=' . $this->getProject()['$id'], $response['headers']['location']); + $this->assertStringContainsString('origin=', $response['headers']['location']); + $this->assertStringContainsString('path=%2Fcontact', $response['headers']['location']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ]), [ + 'email' => $this->getRoot()['email'], + 'password' => 'password' + ]); + $this->assertEquals(201, $session['headers']['status-code']); + $this->assertNotEmpty($session['cookies']['a_session_console']); + $this->assertNotEmpty($session['body']['$id']); + $cookie = 'a_session_console=' . $session['cookies']['a_session_console']; + + $jwt = $this->client->call(Client::METHOD_POST, '/account/jwts', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => $cookie, + 'x-appwrite-project' => 'console', + ]), []); + $this->assertEquals(201, $jwt['headers']['status-code']); + $this->assertNotEmpty($jwt['body']['jwt']); + + $response = $proxyClient->call(Client::METHOD_GET, '/_appwrite/authorize', params: [ + 'jwt' => $jwt['body']['jwt'], + 'path' => '/contact' + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertArrayHasKey('set-cookie', $response['headers']); + $this->assertStringContainsString('a_jwt_console=', $response['headers']['set-cookie']); + $this->assertStringContainsString('httponly', $response['headers']['set-cookie']); + $this->assertStringContainsString('domain=' . $domain, $response['headers']['set-cookie']); + $this->assertStringContainsString('path=/', $response['headers']['set-cookie']); + $this->assertNotEmpty($response['cookies']['a_jwt_console']); + $this->assertEquals($jwt['body']['jwt'], $response['cookies']['a_jwt_console']); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [ + 'cookie' => 'a_jwt_console=' . $jwt['body']['jwt'] + ], followRedirects: false); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Contact page", $response['body']); + $this->assertStringContainsString("Preview by", $response['body']); + + // Failure: Session missing (old bad, new ok) + $session = $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => $cookie, + 'x-appwrite-project' => 'console', + ]), []); + $this->assertEquals(204, $session['headers']['status-code']); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [ + 'cookie' => 'a_jwt_console=' . $jwt['body']['jwt'] + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('/console/auth/preview', $response['headers']['location']); + + // Failure: User missing + $cookie = 'a_session_console=' .$this->getRoot()['session']; + $jwt = $this->client->call(Client::METHOD_POST, '/account/jwts', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => $cookie, + 'x-appwrite-project' => 'console', + ]), []); + $this->assertEquals(201, $jwt['headers']['status-code']); + $this->assertNotEmpty($jwt['body']['jwt']); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [ + 'cookie' => 'a_jwt_console=' . $jwt['body']['jwt'] + ], followRedirects: false); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Contact page", $response['body']); + $this->assertStringContainsString("Preview by", $response['body']); + + $user = $this->client->call(Client::METHOD_PATCH, '/account/status', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => $cookie, + 'x-appwrite-project' => 'console', + ]), []); + $this->assertEquals(200, $user['headers']['status-code']); + $this->assertFalse($user['body']['status']); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [ + 'cookie' => 'a_jwt_console=' . $jwt['body']['jwt'] + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('/console/auth/preview', $response['headers']['location']); + + // Failure: Membership missing + $user = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], [ + 'userId' => ID::unique(), + 'email' => 'newuser@appwrite.io', + 'password' => 'password' + ]); + $this->assertEquals(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], [ + 'email' => 'newuser@appwrite.io', + 'password' => 'password', + ]); + $this->assertEquals(201, $session['headers']['status-code']); + $this->assertNotEmpty($session['cookies']['a_session_console']); + $cookie = 'a_session_console=' . $session['cookies']['a_session_console']; + + $jwt = $this->client->call(Client::METHOD_POST, '/account/jwts', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => $cookie, + 'x-appwrite-project' => 'console', + ]), []); + $this->assertEquals(201, $jwt['headers']['status-code']); + $this->assertNotEmpty($jwt['body']['jwt']); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [ + 'cookie' => 'a_jwt_console=' . $jwt['body']['jwt'] + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('/console/auth/preview', $response['headers']['location']); + + $this->cleanupSite($siteId); + } }