mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge pull request #9357 from appwrite/feat-authroized-previews
Feat: Authorized previews
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -103,6 +103,11 @@ class Auth
|
||||
*/
|
||||
public static $cookieName = 'a_session';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public static $cookieNamePreview = 'a_jwt_console';
|
||||
|
||||
/**
|
||||
* User Unique ID.
|
||||
*
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user