From 071ec671e09e0d80de350c8eea33e16ce38746a9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 09:13:08 +0000 Subject: [PATCH 01/47] Feat: utopia auth --- composer.json | 1 + composer.lock | 61 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 62811e9d32..f76d3005c2 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "utopia-php/abuse": "1.*", "utopia-php/analytics": "0.10.*", "utopia-php/audit": "1.*", + "utopia-php/auth": "0.5.*", "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "0.2.*", diff --git a/composer.lock b/composer.lock index 8faa5a477c..734ad53677 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a184716dd568cd37c015e1e929dd3c24", + "content-hash": "f0d2275cb364a92d9a0988363140281d", "packages": [ { "name": "adhocore/jwt", @@ -3592,6 +3592,61 @@ }, "time": "2025-10-20T07:14:26+00:00" }, + { + "name": "utopia-php/auth", + "version": "0.5.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/auth.git", + "reference": "5ad0ded3a79f153ee904b97b49f8dfe4669e4fd0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/auth/zipball/5ad0ded3a79f153ee904b97b49f8dfe4669e4fd0", + "reference": "5ad0ded3a79f153ee904b97b49f8dfe4669e4fd0", + "shasum": "" + }, + "require": { + "ext-hash": "*", + "ext-scrypt": "*", + "ext-sodium": "*", + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "1.2.*", + "phpstan/phpstan": "1.9.x-dev", + "phpunit/phpunit": "^9.3", + "vimeo/psalm": "4.0.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Auth\\": "src/Auth" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Utopia PHP", + "email": "team@appwrite.io" + } + ], + "description": "A simple PHP authentication library", + "keywords": [ + "Authentication", + "auth", + "php", + "security" + ], + "support": { + "issues": "https://github.com/utopia-php/auth/issues", + "source": "https://github.com/utopia-php/auth/tree/0.5.0" + }, + "time": "2025-10-29T07:11:43+00:00" + }, { "name": "utopia-php/cache", "version": "0.13.1", @@ -8982,7 +9037,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -9006,5 +9061,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } From a575c0746835a92f145adfcadedc8e43e2cb8025 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 09:32:56 +0000 Subject: [PATCH 02/47] Fix user resource --- app/init/resources.php | 120 +++++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 0892da8a82..ba8c74e011 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -24,9 +24,16 @@ use Appwrite\GraphQL\Schema; use Appwrite\Network\Platform; use Appwrite\Network\Validator\Origin; use Appwrite\Utopia\Request; +use Appwrite\Utopia\Response; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; use Utopia\App; +use Utopia\Auth\Hashes\Argon2; +use Utopia\Auth\Hashes\Sha; +use Utopia\Auth\Proofs\Code; +use Utopia\Auth\Proofs\Password; +use Utopia\Auth\Proofs\Token; +use Utopia\Auth\Store; use Utopia\Cache\Adapter\Pool as CachePool; use Utopia\Cache\Adapter\Sharding; use Utopia\Cache\Cache; @@ -226,76 +233,87 @@ App::setResource('platforms', function (Request $request, Document $console, Doc ]; }, ['request', 'console', 'project', 'dbForPlatform']); -App::setResource('user', function ($mode, $project, $console, $request, $response, $dbForProject, $dbForPlatform) { - /** @var Appwrite\Utopia\Request $request */ - /** @var Appwrite\Utopia\Response $response */ - /** @var Utopia\Database\Document $project */ - /** @var Utopia\Database\Database $dbForProject */ - /** @var Utopia\Database\Database $dbForPlatform */ - /** @var string $mode */ +App::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken) { + /** + * Handles user authentication and session validation. + * + * This function follows a series of steps to determine the appropriate user session + * based on cookies, headers, and JWT tokens. + * + * Process: + * 1. Checks the cookie based on mode: + * - If in admin mode, uses console project id for key. + * - Otherwise, sets the key using the project ID + * 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`. + * - If this method is used, returns the header: `X-Debug-Fallback: true`. + * 3. Fetches the user document from the appropriate database based on the mode. + * 4. If the user document is empty or the session key cannot be verified, sets an empty user document. + * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token. + * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`, + * overwriting the previous value. + */ Authorization::setDefaultStatus(true); - Auth::setCookieName('a_session_' . $project->getId()); + $store->setKey('a_session_' . $project->getId()); if (APP_MODE_ADMIN === $mode) { - Auth::setCookieName('a_session_' . $console->getId()); + $store->setKey('a_session_' . $console->getId()); } - $session = Auth::decodeSession( + $store->decode( $request->getCookie( - Auth::$cookieName, // Get sessions - $request->getCookie(Auth::$cookieName . '_legacy', '') + $store->getKey(), // Get sessions + $request->getCookie($store->getKey() . '_legacy', '') ) ); // Get session from header for SSR clients - if (empty($session['id']) && empty($session['secret'])) { + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { $sessionHeader = $request->getHeader('x-appwrite-session', ''); if (!empty($sessionHeader)) { - $session = Auth::decodeSession($sessionHeader); + $store->decode($sessionHeader); } } // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies - if ($response) { + if ($response) { // if in http context - add debug header $response->addHeader('X-Debug-Fallback', 'false'); } - if (empty($session['id']) && empty($session['secret'])) { + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { if ($response) { $response->addHeader('X-Debug-Fallback', 'true'); } $fallback = $request->getHeader('x-fallback-cookies', ''); $fallback = \json_decode($fallback, true); - $session = Auth::decodeSession(((isset($fallback[Auth::$cookieName])) ? $fallback[Auth::$cookieName] : '')); + $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); } - Auth::$unique = $session['id'] ?? ''; - Auth::$secret = $session['secret'] ?? ''; - - $user = new Document([]); - - if (!empty(Auth::$unique)) { - if ($mode === APP_MODE_ADMIN) { - $user = $dbForPlatform->getDocument('users', Auth::$unique); - } elseif (!$project->isEmpty()) { - if ($project->getId() === 'console') { - $user = $dbForPlatform->getDocument('users', Auth::$unique); - } else { - $user = $dbForProject->getDocument('users', Auth::$unique); + if (APP_MODE_ADMIN === $mode) { + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + if ($project->isEmpty()) { + $user = new Document([]); + } else { + if (!empty($store->getProperty('id', ''))) { + if ($project->getId() === 'console') { + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + $user = $dbForProject->getDocument('users', $store->getProperty('id', '')); + } } } } if ( + !$user || $user->isEmpty() // Check a document has been found in the DB - || !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret) + || !Auth::sessionVerify($user->getAttribute('sessions', []), $store->getProperty('secret', ''), $proofForToken) ) { // Validate user has valid login token $user = new Document([]); } - // if (APP_MODE_ADMIN === $mode) { // if ($user->find('teamInternalId', $project->getAttribute('teamInternalId'), 'memberships')) { // Authorization::setDefaultStatus(false); // Cancel security segmentation for admin users. @@ -303,18 +321,14 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons // $user = new Document([]); // } // } - $authJWT = $request->getHeader('x-appwrite-jwt', ''); - if (!empty($authJWT) && !$project->isEmpty()) { // JWT authentication $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); - try { $payload = $jwt->decode($authJWT); } catch (JWTException $error) { throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); } - $jwtUserId = $payload['userId'] ?? ''; if (!empty($jwtUserId)) { if ($mode === APP_MODE_ADMIN) { @@ -323,7 +337,6 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons $user = $dbForProject->getDocument('users', $jwtUserId); } } - $jwtSessionId = $payload['sessionId'] ?? ''; if (!empty($jwtSessionId)) { if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token @@ -331,12 +344,11 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons } } } - $dbForProject->setMetadata('user', $user->getId()); $dbForPlatform->setMetadata('user', $user->getId()); return $user; -}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform']); +}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken']); App::setResource('project', function ($dbForPlatform, $request, $console) { /** @var Appwrite\Utopia\Request $request */ @@ -379,6 +391,36 @@ App::setResource('console', function () { return new Document(Config::getParam('console')); }, []); +App::setResource('store', function (): Store { + return new Store(); +}); + +App::setResource('proofForPassword', function (): Password { + $hash = new Argon2(); + $hash + ->setMemoryCost(7168) + ->setTimeCost(5) + ->setThreads(1); + + $password = new Password(); + $password + ->setHash($hash); + + return $password; +}); + +App::setResource('proofForToken', function (): Token { + $token = new Token(); + $token->setHash(new Sha()); + return $token; +}); + +App::setResource('proofForCode', function (): Code { + $code = new Code(); + $code->setHash(new Sha()); + return $code; +}); + App::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project) { if ($project->isEmpty() || $project->getId() === 'console') { return $dbForPlatform; From 50cd6a737da70368ebfb0a699f58e1b2ceedf971 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 09:39:52 +0000 Subject: [PATCH 03/47] Update resources --- app/init/resources.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index ba8c74e011..5d2ddcd3ae 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -366,18 +366,17 @@ App::setResource('project', function ($dbForPlatform, $request, $console) { return $project; }, ['dbForPlatform', 'request', 'console']); -App::setResource('session', function (Document $user) { +App::setResource('session', function (Document $user, Store $store, Token $proofForToken) { if ($user->isEmpty()) { return; } $sessions = $user->getAttribute('sessions', []); - $sessionId = Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret); + $sessionId = Auth::sessionVerify($user->getAttribute('sessions'), $store->getProperty('secret', ''), $proofForToken); if (!$sessionId) { return; } - foreach ($sessions as $session) {/** @var Document $session */ if ($sessionId === $session->getId()) { return $session; @@ -385,7 +384,7 @@ App::setResource('session', function (Document $user) { } return; -}, ['user']); +}, ['user', 'store', 'proofForToken']); App::setResource('console', function () { return new Document(Config::getParam('console')); From 617c1aaee382aa1382aa06df2e902b2198482eaf Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 02:34:07 +0000 Subject: [PATCH 04/47] update db with custom types support --- composer.json | 2 +- composer.lock | 27 ++++++++++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index f76d3005c2..09bee95335 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "3.*", + "utopia-php/database": "dev-feat-external-type as 3.1.99", "utopia-php/detector": "0.2.*", "utopia-php/domains": "0.9.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index 734ad53677..03e4b20a4e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f0d2275cb364a92d9a0988363140281d", + "content-hash": "6a2ff885772a37c360ab53cda3a72949", "packages": [ { "name": "adhocore/jwt", @@ -3895,16 +3895,16 @@ }, { "name": "utopia-php/database", - "version": "3.1.2", + "version": "dev-feat-external-type", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "b6541a9cd9b21786a5020327f582838afdb159aa" + "reference": "a3384011cd912053192af80f22c72f2e77ffdd14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/b6541a9cd9b21786a5020327f582838afdb159aa", - "reference": "b6541a9cd9b21786a5020327f582838afdb159aa", + "url": "https://api.github.com/repos/utopia-php/database/zipball/a3384011cd912053192af80f22c72f2e77ffdd14", + "reference": "a3384011cd912053192af80f22c72f2e77ffdd14", "shasum": "" }, "require": { @@ -3977,10 +3977,10 @@ "utopia" ], "support": { - "source": "https://github.com/utopia-php/database/tree/3.1.2", + "source": "https://github.com/utopia-php/database/tree/feat-external-type", "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2025-10-30T13:10:13+00:00" + "time": "2025-11-03T10:43:04+00:00" }, { "name": "utopia-php/detector", @@ -9035,9 +9035,18 @@ "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-feat-external-type", + "alias": "3.1.99", + "alias_normalized": "3.1.99.0" + } + ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { From 2ae474971da100cce55ac6cf59e1a5c7b43dec5e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 03:48:57 +0000 Subject: [PATCH 05/47] use new session verify --- app/controllers/api/account.php | 471 ++++++++++-------- app/init/constants.php | 50 ++ app/init/resources.php | 4 + .../Utopia/Database/Documents/User.php | 181 +++++++ 4 files changed, 512 insertions(+), 194 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/Documents/User.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 9d1987591e..b5e033ab46 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -31,6 +31,7 @@ use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; use Appwrite\URL\URL as URLParser; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Identities; use Appwrite\Utopia\Request; @@ -40,6 +41,11 @@ use MaxMind\Db\Reader; use Utopia\Abuse\Abuse; use Utopia\App; use Utopia\Audit\Audit as EventAudit; +use Utopia\Auth\Hashes\Sha; +use Utopia\Auth\Proofs\Code as ProofsCode; +use Utopia\Auth\Proofs\Password as ProofsPassword; +use Utopia\Auth\Proofs\Token as ProofsToken; +use Utopia\Auth\Store; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -190,17 +196,17 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc } ; +$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode) { -$createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails) { - - /** @var Utopia\Database\Document $user */ + /** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */ $userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); if ($userFromRequest->isEmpty()) { throw new Exception(Exception::USER_INVALID_TOKEN); } - $verifiedToken = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), null, $secret); + $verifiedToken = $userFromRequest->tokenVerify(null, $secret, $proofForToken) + ?: $userFromRequest->tokenVerify(null, $secret, $proofForCode); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -208,17 +214,17 @@ $createSession = function (string $userId, string $secret, Request $request, Res $user->setAttributes($userFromRequest->getArrayCopy()); - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $sessionSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); + $sessionSecret = $proofForToken->generate(); $factor = (match ($verifiedToken->getAttribute('type')) { - Auth::TOKEN_TYPE_MAGIC_URL, - Auth::TOKEN_TYPE_OAUTH2, - Auth::TOKEN_TYPE_EMAIL => Type::EMAIL, - Auth::TOKEN_TYPE_PHONE => Type::PHONE, - Auth::TOKEN_TYPE_GENERIC => 'token', + TOKEN_TYPE_MAGIC_URL, + TOKEN_TYPE_OAUTH2, + TOKEN_TYPE_EMAIL => Type::EMAIL, + TOKEN_TYPE_PHONE => Type::PHONE, + TOKEN_TYPE_GENERIC => 'token', default => throw new Exception(Exception::USER_INVALID_TOKEN) }); @@ -228,7 +234,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'provider' => Auth::getSessionProviderByTokenType($verifiedToken->getAttribute('type')), - 'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak + 'secret' => $proofForToken->hash($sessionSecret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => [$factor], @@ -253,11 +259,11 @@ $createSession = function (string $userId, string $secret, Request $request, Res $dbForProject->purgeCachedDocument('users', $user->getId()); // Magic URL + Email OTP - if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_MAGIC_URL || $verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_EMAIL) { + if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_MAGIC_URL || $verifiedToken->getAttribute('type') === TOKEN_TYPE_EMAIL) { $user->setAttribute('emailVerification', true); } - if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_PHONE) { + if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_PHONE) { $user->setAttribute('phoneVerification', true); } @@ -268,8 +274,8 @@ $createSession = function (string $userId, string $secret, Request $request, Res } $isAllowedTokenType = match ($verifiedToken->getAttribute('type')) { - Auth::TOKEN_TYPE_MAGIC_URL, - Auth::TOKEN_TYPE_EMAIL => false, + TOKEN_TYPE_MAGIC_URL, + TOKEN_TYPE_EMAIL => false, default => true }; @@ -289,16 +295,21 @@ $createSession = function (string $userId, string $secret, Request $request, Res ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()); + $encoded = $store + ->setProperty('id', $user->getId()) + ->setProperty('secret', $sessionSecret) + ->encode(); + if (!Config::getParam('domainVerification')) { - $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $sessionSecret)])); + $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $protocol = $request->getProtocol(); $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); @@ -307,7 +318,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res ->setAttribute('current', true) ->setAttribute('countryName', $countryName) ->setAttribute('expire', $expire) - ->setAttribute('secret', Auth::encodeSession($user->getId(), $sessionSecret)) + ->setAttribute('secret', $encoded) ; $response->dynamic($session, Response::MODEL_SESSION); @@ -393,7 +404,9 @@ App::post('/v1/account') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); $passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; - $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); + $proof = new ProofsPassword(); + $hash = $proof->hash($password); + try { $userId = $userId == 'unique()' ? ID::unique() : $userId; $user->setAttributes([ @@ -406,11 +419,11 @@ App::post('/v1/account') 'email' => $email, 'emailVerification' => false, 'status' => true, - 'password' => $password, - 'passwordHistory' => $passwordHistory > 0 ? [$password] : [], + 'password' => $hash, + 'passwordHistory' => $passwordHistory > 0 ? [$hash] : [], 'passwordUpdate' => DateTime::now(), - 'hash' => Auth::DEFAULT_ALGO, - 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + 'hash' => $proof->getHash()->getName(), + 'hashOptions' => $proof->getHash()->getOptions(), 'registration' => DateTime::now(), 'reset' => false, 'name' => $name, @@ -565,12 +578,13 @@ App::get('/v1/account/sessions') ->inject('response') ->inject('user') ->inject('locale') - ->inject('project') - ->action(function (Response $response, Document $user, Locale $locale, Document $project) { + ->inject('store') + ->inject('proofForToken') + ->action(function (Response $response, User $user, Locale $locale, Store $store, ProofsToken $proofForToken) { $sessions = $user->getAttribute('sessions', []); - $current = Auth::sessionVerify($sessions, Auth::$secret); + $current = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); foreach ($sessions as $key => $session) {/** @var Document $session */ $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); @@ -617,7 +631,9 @@ App::delete('/v1/account/sessions') ->inject('locale') ->inject('queueForEvents') ->inject('queueForDeletes') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes) { + ->inject('store') + ->inject('proofForToken') + ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) { $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); @@ -633,13 +649,13 @@ App::delete('/v1/account/sessions') ->setAttribute('current', false) ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))); - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { + if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { $session->setAttribute('current', true); // If current session delete the cookies too $response - ->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); + ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); // Use current session for events. $queueForEvents @@ -683,12 +699,13 @@ App::get('/v1/account/sessions/:sessionId') ->inject('response') ->inject('user') ->inject('locale') - ->inject('project') - ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Document $project) { + ->inject('store') + ->inject('proofForToken') + ->action(function (?string $sessionId, Response $response, User $user, Locale $locale, Store $store, ProofsToken $proofForToken) { $sessions = $user->getAttribute('sessions', []); $sessionId = ($sessionId === 'current') - ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) + ? $user->sessionVerify($store->getProperty('secret', ''), $proofForToken) : $sessionId; foreach ($sessions as $session) {/** @var Document $session */ @@ -696,7 +713,7 @@ App::get('/v1/account/sessions/:sessionId') $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session - ->setAttribute('current', ($session->getAttribute('secret') == Auth::hash(Auth::$secret))) + ->setAttribute('current', ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret')))) ->setAttribute('countryName', $countryName) ->setAttribute('secret', $session->getAttribute('secret', '')) ; @@ -739,12 +756,13 @@ App::delete('/v1/account/sessions/:sessionId') ->inject('locale') ->inject('queueForEvents') ->inject('queueForDeletes') - ->inject('project') - ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Document $project) { + ->inject('store') + ->inject('proofForToken') + ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) { $protocol = $request->getProtocol(); $sessionId = ($sessionId === 'current') - ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) + ? $user->sessionVerify($store->getProperty('secret', ''), $proofForToken) : $sessionId; $sessions = $user->getAttribute('sessions', []); @@ -761,7 +779,7 @@ App::delete('/v1/account/sessions/:sessionId') $session->setAttribute('current', false); - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too + if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { // If current session delete the cookies too $session ->setAttribute('current', true) ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))); @@ -771,8 +789,8 @@ App::delete('/v1/account/sessions/:sessionId') } $response - ->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); + ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -823,10 +841,12 @@ App::patch('/v1/account/sessions/:sessionId') ->inject('dbForProject') ->inject('project') ->inject('queueForEvents') - ->action(function (?string $sessionId, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents) { + ->inject('store') + ->inject('proofForToken') + ->action(function (?string $sessionId, Response $response, User $user, Database $dbForProject, Document $project, Event $queueForEvents, Store $store, ProofsToken $proofForToken) { $sessionId = ($sessionId === 'current') - ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) + ? $user->sessionVerify($store->getProperty('secret', ''), $proofForToken) : $sessionId; $sessions = $user->getAttribute('sessions', []); @@ -843,7 +863,7 @@ App::patch('/v1/account/sessions/:sessionId') } // Extend session - $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $session->setAttribute('expire', DateTime::addSeconds(new \DateTime(), $authDuration)); // Refresh OAuth access token @@ -915,7 +935,10 @@ App::post('/v1/account/sessions/email') ->inject('queueForEvents') ->inject('queueForMails') ->inject('hooks') - ->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks) { + ->inject('store') + ->inject('proofForPassword') + ->inject('proofForToken') + ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -923,7 +946,9 @@ App::post('/v1/account/sessions/email') Query::equal('email', [$email]), ]); - if ($profile->isEmpty() || empty($profile->getAttribute('passwordUpdate')) || !Auth::passwordVerify($password, $profile->getAttribute('password'), $profile->getAttribute('hash'), $profile->getAttribute('hashOptions'))) { + $userProofForPassword = ProofsPassword::createHash($profile->getAttribute('hash', $proofForPassword->getHash()->getName()), $profile->getAttribute('hashOptions', $proofForPassword->getHash()->getOptions())); + + if ($profile->isEmpty() || empty($profile->getAttribute('passwordUpdate')) || !$userProofForPassword->verify($password, $profile->getAttribute('password'))) { throw new Exception(Exception::USER_INVALID_CREDENTIALS); } @@ -935,18 +960,18 @@ App::post('/v1/account/sessions/email') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, false]); - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); + $secret = $proofForToken->generate(); $session = new Document(array_merge( [ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => Auth::SESSION_PROVIDER_EMAIL, + 'provider' => SESSION_PROVIDER_EMAIL, 'providerUid' => $email, - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => ['password'], @@ -961,11 +986,12 @@ App::post('/v1/account/sessions/email') Authorization::setRole(Role::user($user->getId())->toString()); // Re-hash if not using recommended algo - if ($user->getAttribute('hash') !== Auth::DEFAULT_ALGO) { + if ($user->getAttribute('hash') !== $proofForPassword->getHash()->getName()) { + $proofForPasswordUpdated = new ProofsPassword(); $user - ->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS)) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); + ->setAttribute('password', $proofForPasswordUpdated->hash($password)) + ->setAttribute('hash', $proofForPasswordUpdated->getHash()->getName()) + ->setAttribute('hashOptions', $proofForPasswordUpdated->getHash()->getOptions()); $dbForProject->updateDocument('users', $user->getId(), $user); } @@ -977,17 +1003,20 @@ App::post('/v1/account/sessions/email') Permission::delete(Role::user($user->getId())), ])); + $encoded = $store + ->setProperty('id', $user->getId()) + ->setProperty('secret', $secret) + ->encode(); + if (!Config::getParam('domainVerification')) { - $response - ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) - ; + $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED) ; @@ -996,7 +1025,7 @@ App::post('/v1/account/sessions/email') $session ->setAttribute('current', true) ->setAttribute('countryName', $countryName) - ->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)) + ->setAttribute('secret', $encoded) ; $queueForEvents @@ -1050,7 +1079,10 @@ App::post('/v1/account/sessions/anonymous') ->inject('dbForProject') ->inject('geodb') ->inject('queueForEvents') - ->action(function (Request $request, Response $response, Locale $locale, Document $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents) { + ->inject('store') + ->inject('proofForPassword') + ->inject('proofForToken') + ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { $protocol = $request->getProtocol(); if ('console' === $project->getId()) { @@ -1079,8 +1111,8 @@ App::post('/v1/account/sessions/anonymous') 'emailVerification' => false, 'status' => true, 'password' => null, - 'hash' => Auth::DEFAULT_ALGO, - 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + 'hash' => $proofForPassword->getHash()->getName(), + 'hashOptions' => $proofForPassword->getHash()->getOptions(), 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -1098,18 +1130,18 @@ App::post('/v1/account/sessions/anonymous') Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); // Create session token - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); + $secret = $proofForToken->generate(); $session = new Document(array_merge( [ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => Auth::SESSION_PROVIDER_ANONYMOUS, - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'provider' => SESSION_PROVIDER_ANONYMOUS, + 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => ['anonymous'], @@ -1136,15 +1168,20 @@ App::post('/v1/account/sessions/anonymous') ->setParam('sessionId', $session->getId()) ; + $encoded = $store + ->setProperty('id', $user->getId()) + ->setProperty('secret', $secret) + ->encode(); + if (!Config::getParam('domainVerification')) { - $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); + $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED) ; @@ -1153,7 +1190,7 @@ App::post('/v1/account/sessions/anonymous') $session ->setAttribute('current', true) ->setAttribute('countryName', $countryName) - ->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)) + ->setAttribute('secret', $encoded) ; $response->dynamic($session, Response::MODEL_SESSION); @@ -1194,6 +1231,9 @@ App::post('/v1/account/sessions/token') ->inject('geodb') ->inject('queueForEvents') ->inject('queueForMails') + ->inject('store') + ->inject('proofForToken') + ->inject('proofForCode') ->action($createSession); App::get('/v1/account/sessions/oauth2/:provider') @@ -1386,7 +1426,10 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('dbForProject') ->inject('geodb') ->inject('queueForEvents') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, array $platforms, Document $devKey, Document $user, Database $dbForProject, Reader $geodb, Event $queueForEvents) use ($oauthDefaultSuccess) { + ->inject('store') + ->inject('proofForPassword') + ->inject('proofForToken') + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, array $platforms, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1530,7 +1573,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } $sessions = $user->getAttribute('sessions', []); - $current = Auth::sessionVerify($sessions, Auth::$secret); + $current = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); if ($current) { // Delete current session of new one. $currentDocument = $dbForProject->getDocument('sessions', $current); @@ -1611,8 +1654,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'emailVerification' => true, 'status' => true, // Email should already be authenticated by OAuth2 provider 'password' => null, - 'hash' => Auth::DEFAULT_ALGO, - 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + 'hash' => $proofForPassword->getHash()->getName(), + 'hashOptions' => $proofForPassword->getHash()->getOptions(), 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -1711,18 +1754,19 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $state['success'] = URLParser::parse($state['success']); $query = URLParser::parseQuery($state['success']['query']); - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); + $proofsForTokenOAuth2 = new ProofsToken(TOKEN_LENGTH_OAUTH2); // If the `token` param is set, we will return the token in the query string if ($state['token']) { - $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_OAUTH2); + $secret = $proofsForTokenOAuth2->generate(); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => Auth::TOKEN_TYPE_OAUTH2, - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'type' => TOKEN_TYPE_OAUTH2, + 'secret' => $proofsForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -1750,7 +1794,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } else { $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); + $secret = $proofForToken->generate(); $session = new Document(array_merge([ '$id' => ID::unique(), @@ -1760,8 +1804,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'providerUid' => $oauth2ID, 'providerAccessToken' => $accessToken, 'providerRefreshToken' => $refreshToken, - 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry), - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry), + 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => [TYPE::EMAIL, 'oauth2'], // include a special oauth2 factor to bypass MFA checks @@ -1777,8 +1821,13 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $session->setAttribute('expire', $expire); + $encoded = $store + ->setProperty('id', $user->getId()) + ->setProperty('secret', $secret) + ->encode(); + if (!Config::getParam('domainVerification')) { - $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); + $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } $queueForEvents @@ -1791,13 +1840,13 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') if ($state['success']['path'] == $oauthDefaultSuccess) { $query['project'] = $project->getId(); $query['domain'] = Config::getParam('cookieDomain'); - $query['key'] = Auth::$cookieName; - $query['secret'] = Auth::encodeSession($user->getId(), $secret); + $query['key'] = $store->getKey(); + $query['secret'] = $encoded; } $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); } if (isset($sessionUpgrade) && $sessionUpgrade) { @@ -1955,7 +2004,8 @@ App::post('/v1/account/tokens/magic-url') ->inject('locale') ->inject('queueForEvents') ->inject('queueForMails') - ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) { + ->inject('proofForPassword') + ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); } @@ -2001,8 +2051,8 @@ App::post('/v1/account/tokens/magic-url') 'emailVerification' => false, 'status' => true, 'password' => null, - 'hash' => Auth::DEFAULT_ALGO, - 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + 'hash' => $proofForPassword->getHash()->getName(), + 'hashOptions' => $proofForPassword->getHash()->getOptions(), 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -2020,15 +2070,18 @@ App::post('/v1/account/tokens/magic-url') Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); } - $tokenSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_MAGIC_URL); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); + $proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL); + $proofForToken->setHash(new Sha()); + + $tokenSecret = $proofForToken->generate(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => Auth::TOKEN_TYPE_MAGIC_URL, - 'secret' => Auth::hash($tokenSecret), // One way hash encryption to protect DB leak + 'type' => TOKEN_TYPE_MAGIC_URL, + 'secret' => $proofForToken->hash($tokenSecret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -2207,7 +2260,9 @@ App::post('/v1/account/tokens/email') ->inject('locale') ->inject('queueForEvents') ->inject('queueForMails') - ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) { + ->inject('proofForPassword') + ->inject('proofForCode') + ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, ProofsCode $proofForCode) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); } @@ -2251,8 +2306,8 @@ App::post('/v1/account/tokens/email') 'emailVerification' => false, 'status' => true, 'password' => null, - 'hash' => Auth::DEFAULT_ALGO, - 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + 'hash' => $proofForPassword->getHash()->getName(), + 'hashOptions' => $proofForPassword->getHash()->getOptions(), 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -2291,15 +2346,15 @@ App::post('/v1/account/tokens/email') $dbForProject->purgeCachedDocument('users', $user->getId()); } - $tokenSecret = Auth::codeGenerator(6); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_OTP)); + $tokenSecret = $proofForCode->generate(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_OTP)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => Auth::TOKEN_TYPE_EMAIL, - 'secret' => Auth::hash($tokenSecret), // One way hash encryption to protect DB leak + 'type' => TOKEN_TYPE_EMAIL, + 'secret' => $proofForCode->hash($tokenSecret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -2486,7 +2541,13 @@ App::put('/v1/account/sessions/magic-url') ->inject('geodb') ->inject('queueForEvents') ->inject('queueForMails') - ->action($createSession); + ->inject('store') + ->inject('proofForCode') + ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode) use ($createSession) { + $proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL); + $proofForToken->setHash(new Sha()); + $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode); + }); App::put('/v1/account/sessions/phone') ->desc('Update phone session') @@ -2527,6 +2588,9 @@ App::put('/v1/account/sessions/phone') ->inject('geodb') ->inject('queueForEvents') ->inject('queueForMails') + ->inject('store') + ->inject('proofForToken') + ->inject('proofForCode') ->action($createSession); App::post('/v1/account/tokens/phone') @@ -2567,7 +2631,9 @@ App::post('/v1/account/tokens/phone') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('plan') - ->action(function (string $userId, string $phone, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan) { + ->inject('store') + ->inject('proofForCode') + ->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Store $store, ProofsCode $proofForCode) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -2646,15 +2712,15 @@ App::post('/v1/account/tokens/phone') } } - $secret ??= Auth::codeGenerator(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_OTP)); + $secret ??= $proofForCode->generate(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_OTP)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => Auth::TOKEN_TYPE_PHONE, - 'secret' => Auth::hash($secret), + 'type' => TOKEN_TYPE_PHONE, + 'secret' => $proofForCode->hash($secret), 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -2729,7 +2795,11 @@ App::post('/v1/account/tokens/phone') ->setPayload($response->output($token, Response::MODEL_TOKEN), sensitive: ['secret']); // Encode secret for clients - $token->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)); + $encoded = $store + ->setProperty('id', $user->getId()) + ->setProperty('secret', $secret) + ->encode(); + $token->setAttribute('secret', $encoded); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -2760,20 +2830,12 @@ App::post('/v1/account/jwts') ->label('abuse-key', 'url:{url},userId:{userId}') ->inject('response') ->inject('user') - ->inject('dbForProject') - ->action(function (Response $response, Document $user, Database $dbForProject) { + ->inject('store') + ->inject('proofForToken') + ->action(function (Response $response, User $user, Store $store, ProofsToken $proofForToken) { + $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); - - $sessions = $user->getAttribute('sessions', []); - $current = new Document(); - - foreach ($sessions as $session) { /** @var Utopia\Database\Document $session */ - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too - $current = $session; - } - } - - if ($current->isEmpty()) { + if (!$sessionId) { throw new Exception(Exception::USER_SESSION_NOT_FOUND); } @@ -2784,7 +2846,7 @@ App::post('/v1/account/jwts') ->dynamic(new Document([ 'jwt' => $jwt->encode([ 'userId' => $user->getId(), - 'sessionId' => $current->getId(), + 'sessionId' => $sessionId, ]) ]), Response::MODEL_JWT); }); @@ -2835,13 +2897,12 @@ App::get('/v1/account/logs') contentType: ContentType::JSON, )) ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('user') ->inject('locale') ->inject('geodb') ->inject('dbForProject') - ->action(function (array $queries, bool $includeTotal, Response $response, Document $user, Locale $locale, Reader $geodb, Database $dbForProject) { + ->action(function (array $queries, Response $response, User $user, Locale $locale, Reader $geodb, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -2886,7 +2947,7 @@ App::get('/v1/account/logs') } $response->dynamic(new Document([ - 'total' => $includeTotal ? $audit->countLogsByUser($user->getSequence(), $queries) : 0, + 'total' => $audit->countLogsByUser($user->getSequence(), $queries), 'logs' => $output, ]), Response::MODEL_LOG_LIST); }); @@ -2913,12 +2974,11 @@ App::patch('/v1/account/name') contentType: ContentType::JSON )) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.') - ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (string $name, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { + ->action(function (string $name, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { $user->setAttribute('name', $name); @@ -2954,25 +3014,29 @@ App::patch('/v1/account/password') ->label('abuse-limit', 10) ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary']) ->param('oldPassword', '', new Password(), 'Current user password. Must be at least 8 chars.', true) - ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('project') ->inject('dbForProject') ->inject('queueForEvents') ->inject('hooks') - ->action(function (string $password, string $oldPassword, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents, Hooks $hooks) { - + ->inject('store') + ->inject('proofForPassword') + ->inject('proofForToken') + ->action(function (string $password, string $oldPassword, Response $response, User $user, Document $project, Database $dbForProject, Event $queueForEvents, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { + $userProofForPassword = ProofsPassword::createHash($user->getAttribute('hash'), $user->getAttribute('hashOptions')); // Check old password only if its an existing user. - if (!empty($user->getAttribute('passwordUpdate')) && !Auth::passwordVerify($oldPassword, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions'))) { // Double check user password + if (!empty($user->getAttribute('passwordUpdate')) && !$userProofForPassword->verify($oldPassword, $user->getAttribute('password'))) { // Double check user password throw new Exception(Exception::USER_INVALID_CREDENTIALS); } - $newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); + $newPassword = $proofForPassword->hash($password); $historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; + $hash = ProofsPassword::createHash($user->getAttribute('hash'), $user->getAttribute('hashOptions')); $history = $user->getAttribute('passwordHistory', []); + if ($historyLimit > 0) { - $validator = new PasswordHistory($history, $user->getAttribute('hash'), $user->getAttribute('hashOptions')); + $validator = new PasswordHistory($history, $hash); if (!$validator->isValid($password)) { throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED); } @@ -2994,11 +3058,13 @@ App::patch('/v1/account/password') ->setAttribute('password', $newPassword) ->setAttribute('passwordHistory', $history) ->setAttribute('passwordUpdate', DateTime::now()) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); + ->setAttribute('hash', $proofForPassword->getHash()->getName()) + ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()); $sessions = $user->getAttribute('sessions', []); - $current = Auth::sessionVerify($sessions, Auth::$secret); + + $current = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); + $invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false; if ($invalidate && !empty($current)) { foreach ($sessions as $session) { @@ -3046,13 +3112,16 @@ App::patch('/v1/account/email') ->inject('queueForEvents') ->inject('project') ->inject('hooks') - ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks) { + ->inject('proofForPassword') + ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, ProofsPassword $proofForPassword) { // passwordUpdate will be empty if the user has never set a password $passwordUpdate = $user->getAttribute('passwordUpdate'); + $userProofForPassword = ProofsPassword::createHash($user->getAttribute('hash'), $user->getAttribute('hashOptions')); + if ( !empty($passwordUpdate) && - !Auth::passwordVerify($password, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions')) + !$userProofForPassword->verify($password, $user->getAttribute('password')) ) { // Double check user password throw new Exception(Exception::USER_INVALID_CREDENTIALS); } @@ -3079,9 +3148,9 @@ App::patch('/v1/account/email') if (empty($passwordUpdate)) { $user - ->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS)) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) + ->setAttribute('password', $proofForPassword->hash($password)) + ->setAttribute('hash', $proofForPassword->getHash()->getName()) + ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()) ->setAttribute('passwordUpdate', DateTime::now()); } @@ -3136,20 +3205,22 @@ App::patch('/v1/account/phone') )) ->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') - ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') ->inject('project') ->inject('hooks') - ->action(function (string $phone, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks) { + ->inject('proofForPassword') + ->action(function (string $phone, string $password, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, ProofsPassword $proofForPassword) { // passwordUpdate will be empty if the user has never set a password $passwordUpdate = $user->getAttribute('passwordUpdate'); + $userProofForPassword = ProofsPassword::createHash($user->getAttribute('hash'), $user->getAttribute('hashOptions')); + if ( !empty($passwordUpdate) && - !Auth::passwordVerify($password, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions')) + !$userProofForPassword->verify($password, $user->getAttribute('password')) ) { // Double check user password throw new Exception(Exception::USER_INVALID_CREDENTIALS); } @@ -3173,9 +3244,9 @@ App::patch('/v1/account/phone') if (empty($passwordUpdate)) { $user - ->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS)) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) + ->setAttribute('password', $proofForPassword->hash($password)) + ->setAttribute('hash', $proofForPassword->getHash()->getName()) + ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()) ->setAttribute('passwordUpdate', DateTime::now()); } @@ -3258,13 +3329,13 @@ App::patch('/v1/account/status') ], contentType: ContentType::JSON, )) - ->inject('requestTimestamp') ->inject('request') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { + ->inject('store') + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store) { $user->setAttribute('status', false); @@ -3280,8 +3351,8 @@ App::patch('/v1/account/status') $protocol = $request->getProtocol(); $response - ->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ; $response->dynamic($user, Response::MODEL_ACCOUNT); @@ -3321,7 +3392,8 @@ App::post('/v1/account/recovery') ->inject('locale') ->inject('queueForMails') ->inject('queueForEvents') - ->action(function (string $email, string $url, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Mail $queueForMails, Event $queueForEvents) { + ->inject('proofForToken') + ->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, Locale $locale, Mail $queueForMails, Event $queueForEvents, ProofsToken $proofForToken) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); @@ -3343,15 +3415,15 @@ App::post('/v1/account/recovery') throw new Exception(Exception::USER_BLOCKED); } - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_RECOVERY)); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_RECOVERY)); - $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_RECOVERY); + $secret = $proofForToken->generate(); $recovery = new Document([ '$id' => ID::unique(), 'userId' => $profile->getId(), 'userInternalId' => $profile->getSequence(), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'type' => TOKEN_TYPE_RECOVERY, + 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -3499,15 +3571,17 @@ App::put('/v1/account/recovery') ->inject('project') ->inject('queueForEvents') ->inject('hooks') - ->action(function (string $userId, string $secret, string $password, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents, Hooks $hooks) { + ->inject('proofForPassword') + ->inject('proofForToken') + ->action(function (string $userId, string $secret, string $password, Response $response, User $user, Database $dbForProject, Document $project, Event $queueForEvents, Hooks $hooks, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { + /** @var Appwrite\Utopia\Database\Documents\User $profile */ $profile = $dbForProject->getDocument('users', $userId); if ($profile->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $tokens = $profile->getAttribute('tokens', []); - $verifiedToken = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_RECOVERY, $secret); + $verifiedToken = $profile->tokenVerify(TOKEN_TYPE_RECOVERY, $secret, $proofForToken); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -3515,12 +3589,14 @@ App::put('/v1/account/recovery') Authorization::setRole(Role::user($profile->getId())->toString()); - $newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); + $newPassword = $proofForPassword->hash($password); + $hash = ProofsPassword::createHash($profile->getAttribute('hash'), $profile->getAttribute('hashOptions')); $historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; $history = $profile->getAttribute('passwordHistory', []); + if ($historyLimit > 0) { - $validator = new PasswordHistory($history, $profile->getAttribute('hash'), $profile->getAttribute('hashOptions')); + $validator = new PasswordHistory($history, $hash); if (!$validator->isValid($password)) { throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED); } @@ -3532,12 +3608,12 @@ App::put('/v1/account/recovery') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); $profile = $dbForProject->updateDocument('users', $profile->getId(), $profile - ->setAttribute('password', $newPassword) - ->setAttribute('passwordHistory', $history) - ->setAttribute('passwordUpdate', DateTime::now()) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) - ->setAttribute('emailVerification', true)); + ->setAttribute('password', $newPassword) + ->setAttribute('passwordHistory', $history) + ->setAttribute('passwordUpdate', DateTime::now()) + ->setAttribute('hash', $proofForPassword->getHash()->getName()) + ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()) + ->setAttribute('emailVerification', true)); $user->setAttributes($profile->getArrayCopy()); @@ -3611,7 +3687,8 @@ App::post('/v1/account/verifications/email') ->inject('locale') ->inject('queueForEvents') ->inject('queueForMails') - ->action(function (string $url, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) { + ->inject('proofForToken') + ->action(function (string $url, Request $request, Response $response, Document $project, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsToken $proofForToken) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); @@ -3626,15 +3703,15 @@ App::post('/v1/account/verifications/email') throw new Exception(Exception::USER_EMAIL_ALREADY_VERIFIED); } - $verificationSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_VERIFICATION); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); + $verificationSecret = $proofForToken->generate(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); $verification = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => Auth::TOKEN_TYPE_VERIFICATION, - 'secret' => Auth::hash($verificationSecret), // One way hash encryption to protect DB leak + 'type' => TOKEN_TYPE_VERIFICATION, + 'secret' => $proofForToken->hash($verificationSecret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -3823,16 +3900,16 @@ App::put('/v1/account/verifications/email') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { - + ->inject('proofForToken') + ->action(function (string $userId, string $secret, Response $response, User $user, Database $dbForProject, Event $queueForEvents, ProofsToken $proofForToken) { + /** @var Appwrite\Utopia\Database\Documents\User $profile */ $profile = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); if ($profile->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $tokens = $profile->getAttribute('tokens', []); - $verifiedToken = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_VERIFICATION, $secret); + $verifiedToken = $profile->tokenVerify(TOKEN_TYPE_VERIFICATION, $secret, $proofForToken); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -3897,7 +3974,8 @@ App::post('/v1/account/verifications/phone') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('plan') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan) { + ->inject('proofForCode') + ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsCode $proofForCode) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -3922,15 +4000,15 @@ App::post('/v1/account/verifications/phone') } } - $secret ??= Auth::codeGenerator(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); + $secret ??= $proofForCode->generate(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); $verification = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => Auth::TOKEN_TYPE_PHONE, - 'secret' => Auth::hash($secret), + 'type' => TOKEN_TYPE_PHONE, + 'secret' => $proofForCode->hash($secret), 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -4041,15 +4119,16 @@ App::put('/v1/account/verifications/phone') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { - + ->inject('proofForCode') + ->action(function (string $userId, string $secret, Response $response, User $user, Database $dbForProject, Event $queueForEvents, ProofsCode $proofForCode) { + /** @var Appwrite\Utopia\Database\Documents\User $profile */ $profile = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); if ($profile->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $verifiedToken = Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_PHONE, $secret); + $verifiedToken = $profile->tokenVerify(TOKEN_TYPE_PHONE, $secret, $proofForCode); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -4690,15 +4769,18 @@ App::post('/v1/account/mfa/challenge') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('plan') - ->action(function (string $factor, Response $response, Database $dbForProject, Document $user, Locale $locale, Document $project, Request $request, Event $queueForEvents, Messaging $queueForMessaging, Mail $queueForMails, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan) { + ->inject('proofForToken') + ->inject('proofForCode') + ->action(function (string $factor, Response $response, Database $dbForProject, User $user, Locale $locale, Document $project, Request $request, Event $queueForEvents, Messaging $queueForMessaging, Mail $queueForMails, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsToken $proofForToken, ProofsCode $proofForCode) { - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); - $code = Auth::codeGenerator(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); + + $code = $proofForCode->generate(); $challenge = new Document([ 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'type' => $factor, - 'token' => Auth::tokenGenerator(), + 'token' => $proofForToken->generate(), 'code' => $code, 'expire' => $expire, '$permissions' => [ @@ -5043,7 +5125,9 @@ App::post('/v1/account/targets/push') ->inject('request') ->inject('response') ->inject('dbForProject') - ->action(function (string $targetId, string $identifier, string $providerId, Event $queueForEvents, Document $user, Request $request, Response $response, Database $dbForProject) { + ->inject('store') + ->inject('proofForToken') + ->action(function (string $targetId, string $identifier, string $providerId, Event $queueForEvents, User $user, Request $request, Response $response, Database $dbForProject, Store $store, ProofsToken $proofForToken) { $targetId = $targetId == 'unique()' ? ID::unique() : $targetId; $provider = Authorization::skip(fn () => $dbForProject->getDocument('providers', $providerId)); @@ -5059,7 +5143,7 @@ App::post('/v1/account/targets/push') $device = $detector->getDevice(); - $sessionId = Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret); + $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); $session = $dbForProject->getDocument('sessions', $sessionId); try { @@ -5233,11 +5317,10 @@ App::get('/v1/account/identities') contentType: ContentType::JSON )) ->param('queries', [], new Identities(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Identities::ALLOWED_ATTRIBUTES), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('user') ->inject('dbForProject') - ->action(function (array $queries, bool $includeTotal, Response $response, Document $user, Database $dbForProject) { + ->action(function (array $queries, Response $response, User $user, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -5278,7 +5361,7 @@ App::get('/v1/account/identities') } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } - $total = $includeTotal ? $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT) : 0; + $total = $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT); $response->dynamic(new Document([ 'identities' => $results, diff --git a/app/init/constants.php b/app/init/constants.php index afed64f798..ee5e001591 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -294,3 +294,53 @@ const TOKENS_RESOURCE_TYPE_DATABASES = 'databases'; const SCHEDULE_RESOURCE_TYPE_EXECUTION = 'execution'; const SCHEDULE_RESOURCE_TYPE_FUNCTION = 'function'; const SCHEDULE_RESOURCE_TYPE_MESSAGE = 'message'; + + +// Auth +/** + * Token Types. + */ +const TOKEN_TYPE_LOGIN = 1; // Deprecated +const TOKEN_TYPE_VERIFICATION = 2; +const TOKEN_TYPE_RECOVERY = 3; +const TOKEN_TYPE_INVITE = 4; +const TOKEN_TYPE_MAGIC_URL = 5; +const TOKEN_TYPE_PHONE = 6; +const TOKEN_TYPE_OAUTH2 = 7; +const TOKEN_TYPE_GENERIC = 8; +const TOKEN_TYPE_EMAIL = 9; // OTP + +/** + * Session Providers. + */ +const SESSION_PROVIDER_EMAIL = 'email'; +const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; +const SESSION_PROVIDER_MAGIC_URL = 'magic-url'; +const SESSION_PROVIDER_PHONE = 'phone'; +const SESSION_PROVIDER_OAUTH2 = 'oauth2'; +const SESSION_PROVIDER_TOKEN = 'token'; +const SESSION_PROVIDER_SERVER = 'server'; + +/** + * Token Expiration times. + */ +const TOKEN_EXPIRATION_LOGIN_LONG = 31536000; /* 1 year */ +const TOKEN_EXPIRATION_LOGIN_SHORT = 3600; /* 1 hour */ +const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ +const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */ +const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */ +const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */ + +/** + * Token Lengths. + */ +const TOKEN_LENGTH_MAGIC_URL = 64; +const TOKEN_LENGTH_VERIFICATION = 256; +const TOKEN_LENGTH_RECOVERY = 256; +const TOKEN_LENGTH_OAUTH2 = 64; +const TOKEN_LENGTH_SESSION = 256; + +/** + * MFA + */ +const MFA_RECENT_DURATION = 1800; // 30 mins diff --git a/app/init/resources.php b/app/init/resources.php index 5d2ddcd3ae..a79ca033d5 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -23,6 +23,7 @@ use Appwrite\Extend\Exception; use Appwrite\GraphQL\Schema; use Appwrite\Network\Platform; use Appwrite\Network\Validator\Origin; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Executor\Executor; @@ -440,6 +441,7 @@ App::setResource('dbForProject', function (Group $pools, Database $dbForPlatform ->setMetadata('project', $project->getId()) ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + $database->setDocumentType('users', User::class); $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); @@ -469,6 +471,8 @@ App::setResource('dbForPlatform', function (Group $pools, Cache $cache) { ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + $database->setDocumentType('users', User::class); + return $database; }, ['pools', 'cache']); diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php new file mode 100644 index 0000000000..b4111eeff0 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -0,0 +1,181 @@ +getAttribute('email'); + } + + public function getPhone(): ?string + { + return $this->getAttribute('phone'); + } + + /** + * Returns all roles for a user. + * + * @return array + */ + public function getRoles(): array + { + $roles = []; + + if (!$this->isPrivileged(Authorization::getRoles()) && !$this->isApp(Authorization::getRoles())) { + if ($this->getId()) { + $roles[] = Role::user($this->getId())->toString(); + $roles[] = Role::users()->toString(); + + $emailVerified = $this->getAttribute('emailVerification', false); + $phoneVerified = $this->getAttribute('phoneVerification', false); + + if ($emailVerified || $phoneVerified) { + $roles[] = Role::user($this->getId(), Roles::DIMENSION_VERIFIED)->toString(); + $roles[] = Role::users(Roles::DIMENSION_VERIFIED)->toString(); + } else { + $roles[] = Role::user($this->getId(), Roles::DIMENSION_UNVERIFIED)->toString(); + $roles[] = Role::users(Roles::DIMENSION_UNVERIFIED)->toString(); + } + } else { + return [Role::guests()->toString()]; + } + } + + foreach ($this->getAttribute('memberships', []) as $node) { + if (!isset($node['confirm']) || !$node['confirm']) { + continue; + } + + if (isset($node['$id']) && isset($node['teamId'])) { + $roles[] = Role::team($node['teamId'])->toString(); + $roles[] = Role::member($node['$id'])->toString(); + + if (isset($node['roles'])) { + foreach ($node['roles'] as $nodeRole) { // Set all team roles + $roles[] = Role::team($node['teamId'], $nodeRole)->toString(); + } + } + } + } + + foreach ($this->getAttribute('labels', []) as $label) { + $roles[] = 'label:' . $label; + } + + return $roles; + } + + /** + * Check if user is anonymous. + * + * @param Document $this + * @return bool + */ + public function isAnonymous(): bool + { + return is_null($this->getEmail()) + && is_null($this->getPhone()); + } + + /** + * Is Privileged User? + * + * @param array $roles + * + * @return bool + */ + public function isPrivileged(array $roles): bool + { + if ( + in_array(self::ROLE_OWNER, $roles) || + in_array(self::ROLE_DEVELOPER, $roles) || + in_array(self::ROLE_ADMIN, $roles) + ) { + return true; + } + + return false; + } + + /** + * Is App User? + * + * @param array $roles + * + * @return bool + */ + public function isApp(array $roles): bool + { + if (in_array(self::ROLE_APPS, $roles)) { + return true; + } + + return false; + } + + public function tokenVerify(int $type = null, string $secret, Proof $proofForToken): false|Document + { + $tokens = $this->getAttribute('tokens', []); + foreach ($tokens as $token) { + if ( + $token->isSet('secret') && + $token->isSet('expire') && + $token->isSet('type') && + ($type === null || $token->getAttribute('type') === $type) && + $proofForToken->verify($secret, $token->getAttribute('secret')) && + DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now()) + ) { + return $token; + } + } + + return false; + } + + /** + * Verify session and check that its not expired. + * + * @param array $sessions + * @param string $secret + * + * @return bool|string + */ + public function sessionVerify(string $secret, Token $proofForToken) + { + $sessions = $this->getAttribute('sessions', []); + + foreach ($sessions as $session) { + if ( + $session->isSet('secret') && + $session->isSet('provider') && + $proofForToken->verify($secret, $session->getAttribute('secret')) && + DateTime::formatTz(DateTime::format(new \DateTime($session->getAttribute('expire')))) >= DateTime::formatTz(DateTime::now()) + ) { + return $session->getId(); + } + } + + return false; + + return false; + } +} From 68cce715275d6f2226d91e5ce2664fbd6d6b6542 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 05:22:18 +0000 Subject: [PATCH 06/47] refactor realtime --- app/realtime.php | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index e18ab8e10d..01d64381e0 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -16,6 +16,9 @@ use Swoole\Timer; use Utopia\Abuse\Abuse; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; use Utopia\App; +use Utopia\Auth\Hashes\Sha; +use Utopia\Auth\Proofs\Token; +use Utopia\Auth\Store; use Utopia\Cache\Adapter\Pool as CachePool; use Utopia\Cache\Adapter\Sharding; use Utopia\Cache\Cache; @@ -678,15 +681,25 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } - $session = Auth::decodeSession($message['data']['session']); - Auth::$unique = $session['id'] ?? ''; - Auth::$secret = $session['secret'] ?? ''; + $store = new Store(); - $user = $database->getDocument('users', Auth::$unique); + $store->decode($message['data']['session']); + + /** @var Appwrite\Utopia\Database\Documents\User $user */ + $user = $database->getDocument('users', $store->getProperty('id', '')); + + /** + * TODO: + * Moving forward, we should try to use our dependency injection container + * to inject the proof for token. + * This way we will have one source of truth for the proof for token. + */ + $proofForToken = new Token(); + $proofForToken->setHash(new Sha()); if ( empty($user->getId()) // Check a document has been found in the DB - || !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret) // Validate user has valid login token + || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) // Validate user has valid login token ) { // cookie not valid throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.'); From df1f14c853678f9a2f146785b10f1f4c45372b1a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 05:25:20 +0000 Subject: [PATCH 07/47] Refactor realtime --- app/realtime.php | 14 ++++++++------ src/Appwrite/Utopia/Database/Documents/User.php | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 01d64381e0..f41ffc7a90 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -6,6 +6,7 @@ use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Messaging\Adapter\Realtime; use Appwrite\Network\Validator\Origin; use Appwrite\PubSub\Adapter\Pool as PubSubPool; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Swoole\Http\Request as SwooleRequest; @@ -460,9 +461,10 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $project = Authorization::skip(fn () => $consoleDatabase->getDocument('projects', $projectId)); $database = getProjectDB($project); + /** @var Appwrite\Utopia\Database\Documents\User $user */ $user = $database->getDocument('users', $userId); - $roles = Auth::getRoles($user); + $roles = $user->getRoles(); $channels = $realtime->connections[$connection]['channels']; $realtime->unsubscribe($connection); @@ -529,14 +531,14 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, if ( array_key_exists('realtime', $project->getAttribute('apis', [])) && !$project->getAttribute('apis', [])['realtime'] - && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) + && !(User::isPrivileged(Authorization::getRoles()) || User::isApp(Authorization::getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } $timelimit = $app->getResource('timelimit'); $platforms = $app->getResource('platforms'); - $user = $app->getResource('user'); /** @var Document $user */ + $user = $app->getResource('user'); /** @var User $user */ /* * Abuse Check @@ -566,7 +568,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $originValidator->getDescription()); } - $roles = Auth::getRoles($user); + $roles = $user->getRoles(); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); @@ -685,7 +687,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $store->decode($message['data']['session']); - /** @var Appwrite\Utopia\Database\Documents\User $user */ + /** @var User $user */ $user = $database->getDocument('users', $store->getProperty('id', '')); /** @@ -705,7 +707,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.'); } - $roles = Auth::getRoles($user); + $roles = $user->getRoles(); $channels = Realtime::convertChannels(array_flip($realtime->connections[$connection]['channels']), $user->getId()); $realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels); diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index b4111eeff0..44ff27c44c 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -90,7 +90,7 @@ class User extends Document * @param Document $this * @return bool */ - public function isAnonymous(): bool + public static function isAnonymous(): bool { return is_null($this->getEmail()) && is_null($this->getPhone()); @@ -103,7 +103,7 @@ class User extends Document * * @return bool */ - public function isPrivileged(array $roles): bool + public static function isPrivileged(array $roles): bool { if ( in_array(self::ROLE_OWNER, $roles) || @@ -123,7 +123,7 @@ class User extends Document * * @return bool */ - public function isApp(array $roles): bool + public static function isApp(array $roles): bool { if (in_array(self::ROLE_APPS, $roles)) { return true; From 897f512b47a58a9b0d31b6b0cbc6e71124af408f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 05:37:45 +0000 Subject: [PATCH 08/47] Refactor remove moved to utopia auth --- src/Appwrite/Auth/Auth.php | 356 +++------------------- src/Appwrite/Auth/Hash.php | 62 ---- src/Appwrite/Auth/Hash/Argon2.php | 47 --- src/Appwrite/Auth/Hash/Bcrypt.php | 46 --- src/Appwrite/Auth/Hash/Md5.php | 44 --- src/Appwrite/Auth/Hash/Phpass.php | 290 ------------------ src/Appwrite/Auth/Hash/Scrypt.php | 51 ---- src/Appwrite/Auth/Hash/Scryptmodified.php | 80 ----- src/Appwrite/Auth/Hash/Sha.php | 50 --- src/Appwrite/Auth/Key.php | 9 +- 10 files changed, 41 insertions(+), 994 deletions(-) delete mode 100644 src/Appwrite/Auth/Hash.php delete mode 100644 src/Appwrite/Auth/Hash/Argon2.php delete mode 100644 src/Appwrite/Auth/Hash/Bcrypt.php delete mode 100644 src/Appwrite/Auth/Hash/Md5.php delete mode 100644 src/Appwrite/Auth/Hash/Phpass.php delete mode 100644 src/Appwrite/Auth/Hash/Scrypt.php delete mode 100644 src/Appwrite/Auth/Hash/Scryptmodified.php delete mode 100644 src/Appwrite/Auth/Hash/Sha.php diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 9af5045fa4..82bac36a79 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -2,13 +2,9 @@ namespace Appwrite\Auth; -use Appwrite\Auth\Hash\Argon2; -use Appwrite\Auth\Hash\Bcrypt; -use Appwrite\Auth\Hash\Md5; -use Appwrite\Auth\Hash\Phpass; -use Appwrite\Auth\Hash\Scrypt; -use Appwrite\Auth\Hash\Scryptmodified; -use Appwrite\Auth\Hash\Sha; +use Appwrite\Utopia\Database\Documents\User; +use Utopia\Auth\Proof; +use Utopia\Auth\Proofs\Token; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\Role; @@ -17,186 +13,45 @@ use Utopia\Database\Validator\Roles; class Auth { - public const SUPPORTED_ALGOS = [ - 'argon2', - 'bcrypt', - 'md5', - 'sha', - 'phpass', - 'scrypt', - 'scryptMod', - 'plaintext' - ]; - - public const DEFAULT_ALGO = 'argon2'; - public const DEFAULT_ALGO_OPTIONS = ['type' => 'argon2', 'memoryCost' => 2048, 'timeCost' => 4, 'threads' => 3]; - - /** - * User Roles. - */ - public const USER_ROLE_ANY = 'any'; - public const USER_ROLE_GUESTS = 'guests'; - public const USER_ROLE_USERS = 'users'; - public const USER_ROLE_ADMIN = 'admin'; - public const USER_ROLE_DEVELOPER = 'developer'; - public const USER_ROLE_OWNER = 'owner'; - public const USER_ROLE_APPS = 'apps'; - public const USER_ROLE_SYSTEM = 'system'; - - /** - * Activity associated with user or the app. - */ - public const ACTIVITY_TYPE_APP = 'app'; - public const ACTIVITY_TYPE_USER = 'user'; - public const ACTIVITY_TYPE_GUEST = 'guest'; - - /** - * Token Types. - */ - public const TOKEN_TYPE_LOGIN = 1; // Deprecated - public const TOKEN_TYPE_VERIFICATION = 2; - public const TOKEN_TYPE_RECOVERY = 3; - public const TOKEN_TYPE_INVITE = 4; - public const TOKEN_TYPE_MAGIC_URL = 5; - public const TOKEN_TYPE_PHONE = 6; - public const TOKEN_TYPE_OAUTH2 = 7; - public const TOKEN_TYPE_GENERIC = 8; - public const TOKEN_TYPE_EMAIL = 9; // OTP - - /** - * Session Providers. - */ - public const SESSION_PROVIDER_EMAIL = 'email'; - public const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; - public const SESSION_PROVIDER_MAGIC_URL = 'magic-url'; - public const SESSION_PROVIDER_PHONE = 'phone'; - public const SESSION_PROVIDER_OAUTH2 = 'oauth2'; - public const SESSION_PROVIDER_TOKEN = 'token'; - public const SESSION_PROVIDER_SERVER = 'server'; - - /** - * Token Expiration times. - */ - public const TOKEN_EXPIRATION_LOGIN_LONG = 31536000; /* 1 year */ - public const TOKEN_EXPIRATION_LOGIN_SHORT = 3600; /* 1 hour */ - public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ - public const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */ - public const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */ - public const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */ - - /** - * Token Lengths. - */ - public const TOKEN_LENGTH_MAGIC_URL = 64; - public const TOKEN_LENGTH_VERIFICATION = 256; - public const TOKEN_LENGTH_RECOVERY = 256; - public const TOKEN_LENGTH_OAUTH2 = 64; - public const TOKEN_LENGTH_SESSION = 256; - - /** - * MFA - */ - public const MFA_RECENT_DURATION = 1800; // 30 mins - - /** - * @var string - */ - public static $cookieName = 'a_session'; - /** * @var string + * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. */ public static $cookieNamePreview = 'a_jwt_console'; - /** - * User Unique ID. - * - * @var string - */ - public static $unique = ''; - - /** - * User Secret Key. - * - * @var string - */ - public static $secret = ''; - - /** - * Set Cookie Name. - * - * @param $string - * - * @return string - */ - public static function setCookieName($string) - { - return self::$cookieName = $string; - } - - /** - * Encode Session. - * - * @param string $id - * @param string $secret - * - * @return string - */ - public static function encodeSession($id, $secret) - { - return \base64_encode(\json_encode([ - 'id' => $id, - 'secret' => $secret, - ])); - } - /** * Token type to session provider mapping. + * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. + * @param int $type + * + * @return string */ public static function getSessionProviderByTokenType(int $type): string { switch ($type) { - case Auth::TOKEN_TYPE_VERIFICATION: - case Auth::TOKEN_TYPE_RECOVERY: - case Auth::TOKEN_TYPE_INVITE: - return Auth::SESSION_PROVIDER_EMAIL; - case Auth::TOKEN_TYPE_MAGIC_URL: - return Auth::SESSION_PROVIDER_MAGIC_URL; - case Auth::TOKEN_TYPE_PHONE: - return Auth::SESSION_PROVIDER_PHONE; - case Auth::TOKEN_TYPE_OAUTH2: - return Auth::SESSION_PROVIDER_OAUTH2; + case TOKEN_TYPE_VERIFICATION: + case TOKEN_TYPE_RECOVERY: + case TOKEN_TYPE_INVITE: + return SESSION_PROVIDER_EMAIL; + case TOKEN_TYPE_MAGIC_URL: + return SESSION_PROVIDER_MAGIC_URL; + case TOKEN_TYPE_PHONE: + return SESSION_PROVIDER_PHONE; + case TOKEN_TYPE_OAUTH2: + return SESSION_PROVIDER_OAUTH2; default: - return Auth::SESSION_PROVIDER_TOKEN; + return SESSION_PROVIDER_TOKEN; } } - /** - * Decode Session. - * - * @param string $session - * - * @return array - * - * @throws \Exception - */ - public static function decodeSession($session) - { - $session = \json_decode(\base64_decode($session), true); - $default = ['id' => null, 'secret' => '']; - - if (!\is_array($session)) { - return $default; - } - - return \array_merge($default, $session); - } - /** * Encode. * * One-way encryption * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. * @param $string * * @return string @@ -206,124 +61,12 @@ class Auth return \hash('sha256', $string); } - /** - * Password Hash. - * - * One way string hashing for user passwords - * - * @param string $string - * @param string $algo hashing algorithm to use - * @param array $options algo-specific options - * - * @return bool|string|null - */ - public static function passwordHash(string $string, string $algo, array $options = []) - { - // Plain text not supported, just an alias. Switch to recommended algo - if ($algo === 'plaintext') { - $algo = Auth::DEFAULT_ALGO; - $options = Auth::DEFAULT_ALGO_OPTIONS; - } - - if (!\in_array($algo, Auth::SUPPORTED_ALGOS)) { - throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.'); - } - - switch ($algo) { - case 'argon2': - $hasher = new Argon2($options); - return $hasher->hash($string); - case 'bcrypt': - $hasher = new Bcrypt($options); - return $hasher->hash($string); - case 'md5': - $hasher = new Md5($options); - return $hasher->hash($string); - case 'sha': - $hasher = new Sha($options); - return $hasher->hash($string); - case 'phpass': - $hasher = new Phpass($options); - return $hasher->hash($string); - case 'scrypt': - $hasher = new Scrypt($options); - return $hasher->hash($string); - case 'scryptMod': - $hasher = new Scryptmodified($options); - return $hasher->hash($string); - default: - throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.'); - } - } - - /** - * Password verify. - * - * @param string $plain - * @param string $hash - * @param string $algo hashing algorithm used to hash - * @param array $options algo-specific options - * - * @return bool - */ - public static function passwordVerify(string $plain, string $hash, string $algo, array $options = []) - { - // Plain text not supported, just an alias. Switch to recommended algo - if ($algo === 'plaintext') { - $algo = Auth::DEFAULT_ALGO; - $options = Auth::DEFAULT_ALGO_OPTIONS; - } - - if (!\in_array($algo, Auth::SUPPORTED_ALGOS)) { - throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.'); - } - - switch ($algo) { - case 'argon2': - $hasher = new Argon2($options); - return $hasher->verify($plain, $hash); - case 'bcrypt': - $hasher = new Bcrypt($options); - return $hasher->verify($plain, $hash); - case 'md5': - $hasher = new Md5($options); - return $hasher->verify($plain, $hash); - case 'sha': - $hasher = new Sha($options); - return $hasher->verify($plain, $hash); - case 'phpass': - $hasher = new Phpass($options); - return $hasher->verify($plain, $hash); - case 'scrypt': - $hasher = new Scrypt($options); - return $hasher->verify($plain, $hash); - case 'scryptMod': - $hasher = new Scryptmodified($options); - return $hasher->verify($plain, $hash); - default: - throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.'); - } - } - - /** - * Password Generator. - * - * Generate random password string - * - * @param int $length - * - * @return string - */ - public static function passwordGenerator(int $length = 20): string - { - return \bin2hex(\random_bytes($length)); - } - /** * Token Generator. * * Generate random password string * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. * @param int $length Length of returned token * * @return string @@ -340,36 +83,17 @@ class Auth return substr($token, 0, $length); } - /** - * Code Generator. - * - * Generate random code string - * - * @param int $length - * - * @return string - */ - public static function codeGenerator(int $length = 6): string - { - $value = ''; - - for ($i = 0; $i < $length; $i++) { - $value .= random_int(0, 9); - } - - return $value; - } - /** * Verify token and check that its not expired. * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. * @param array $tokens * @param int $type Type of token to verify, if null will verify any type * @param string $secret * * @return false|Document */ - public static function tokenVerify(array $tokens, int $type = null, string $secret): false|Document + public static function tokenVerify(array $tokens, int $type = null, string $secret, Proof $proofForToken): false|Document { foreach ($tokens as $token) { if ( @@ -377,7 +101,7 @@ class Auth $token->isSet('expire') && $token->isSet('type') && ($type === null || $token->getAttribute('type') === $type) && - $token->getAttribute('secret') === self::hash($secret) && + $proofForToken->verify($secret, $token->getAttribute('secret')) && DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now()) ) { return $token; @@ -390,18 +114,19 @@ class Auth /** * Verify session and check that its not expired. * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. * @param array $sessions * @param string $secret * * @return bool|string */ - public static function sessionVerify(array $sessions, string $secret) + public static function sessionVerify(array $sessions, string $secret, Token $proofForToken) { foreach ($sessions as $session) { if ( $session->isSet('secret') && $session->isSet('provider') && - $session->getAttribute('secret') === self::hash($secret) && + $proofForToken->verify($secret, $session->getAttribute('secret')) && DateTime::formatTz(DateTime::format(new \DateTime($session->getAttribute('expire')))) >= DateTime::formatTz(DateTime::now()) ) { return $session->getId(); @@ -414,6 +139,7 @@ class Auth /** * Is Privileged User? * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. * @param array $roles * * @return bool @@ -421,9 +147,9 @@ class Auth public static function isPrivilegedUser(array $roles): bool { if ( - in_array(self::USER_ROLE_OWNER, $roles) || - in_array(self::USER_ROLE_DEVELOPER, $roles) || - in_array(self::USER_ROLE_ADMIN, $roles) + in_array(User::ROLE_OWNER, $roles) || + in_array(User::ROLE_DEVELOPER, $roles) || + in_array(User::ROLE_ADMIN, $roles) ) { return true; } @@ -434,13 +160,14 @@ class Auth /** * Is App User? * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. * @param array $roles * * @return bool */ public static function isAppUser(array $roles): bool { - if (in_array(self::USER_ROLE_APPS, $roles)) { + if (in_array(User::ROLE_APPS, $roles)) { return true; } @@ -450,6 +177,7 @@ class Auth /** * Returns all roles for a user. * + * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. * @param Document $user * @return array */ @@ -500,16 +228,4 @@ class Auth return $roles; } - - /** - * Check if user is anonymous. - * - * @param Document $user - * @return bool - */ - public static function isAnonymousUser(Document $user): bool - { - return is_null($user->getAttribute('email')) - && is_null($user->getAttribute('phone')); - } } diff --git a/src/Appwrite/Auth/Hash.php b/src/Appwrite/Auth/Hash.php deleted file mode 100644 index 7134057581..0000000000 --- a/src/Appwrite/Auth/Hash.php +++ /dev/null @@ -1,62 +0,0 @@ -setOptions($options); - } - - /** - * Set hashing algo options - * - * @param array $options Hashing-algo specific options - */ - public function setOptions(array $options): self - { - $this->options = \array_merge([], $this->getDefaultOptions(), $options); - return $this; - } - - /** - * Get hashing algo options - * - * @return array $options Hashing-algo specific options - */ - public function getOptions(): array - { - return $this->options; - } - - /** - * @param string $password Input password to hash - * - * @return string hash - */ - abstract public function hash(string $password): string; - - /** - * @param string $password Input password to validate - * @param string $hash Hash to verify password against - * - * @return boolean true if password matches hash - */ - abstract public function verify(string $password, string $hash): bool; - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - abstract public function getDefaultOptions(): array; -} diff --git a/src/Appwrite/Auth/Hash/Argon2.php b/src/Appwrite/Auth/Hash/Argon2.php deleted file mode 100644 index c723b077b1..0000000000 --- a/src/Appwrite/Auth/Hash/Argon2.php +++ /dev/null @@ -1,47 +0,0 @@ -getOptions()); - } - - /** - * @param string $password Input password to validate - * @param string $hash Hash to verify password against - * - * @return boolean true if password matches hash - */ - public function verify(string $password, string $hash): bool - { - return \password_verify($password, $hash); - } - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - public function getDefaultOptions(): array - { - return ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 3]; - } -} diff --git a/src/Appwrite/Auth/Hash/Bcrypt.php b/src/Appwrite/Auth/Hash/Bcrypt.php deleted file mode 100644 index 8b6177f33a..0000000000 --- a/src/Appwrite/Auth/Hash/Bcrypt.php +++ /dev/null @@ -1,46 +0,0 @@ -getOptions()); - } - - /** - * @param string $password Input password to validate - * @param string $hash Hash to verify password against - * - * @return boolean true if password matches hash - */ - public function verify(string $password, string $hash): bool - { - return \password_verify($password, $hash); - } - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - public function getDefaultOptions(): array - { - return [ 'cost' => 8 ]; - } -} diff --git a/src/Appwrite/Auth/Hash/Md5.php b/src/Appwrite/Auth/Hash/Md5.php deleted file mode 100644 index 8ade3dd5e2..0000000000 --- a/src/Appwrite/Auth/Hash/Md5.php +++ /dev/null @@ -1,44 +0,0 @@ -hash($password) === $hash; - } - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - public function getDefaultOptions(): array - { - return []; - } -} diff --git a/src/Appwrite/Auth/Hash/Phpass.php b/src/Appwrite/Auth/Hash/Phpass.php deleted file mode 100644 index 988c38cc8d..0000000000 --- a/src/Appwrite/Auth/Hash/Phpass.php +++ /dev/null @@ -1,290 +0,0 @@ - in 2004-2017 and placed in - * the public domain. Revised in subsequent years, still public domain. - * There's absolutely no warranty. - * The homepage URL for the source framework is: http://www.openwall.com/phpass/ - * Please be sure to update the Version line if you edit this file in any way. - * It is suggested that you leave the main version number intact, but indicate - * your project name (after the slash) and add your own revision information. - * Please do not change the "private" password hashing method implemented in - * here, thereby making your hashes incompatible. However, if you must, please - * change the hash type identifier (the "$P$") to something different. - * Obviously, since this code is in the public domain, the above are not - * requirements (there can be none), but merely suggestions. - * - * @author Solar Designer - * @copyright Copyright (C) 2017 All rights reserved. - * @license http://www.opensource.org/licenses/mit-license.html MIT License; see LICENSE.txt - */ - -namespace Appwrite\Auth\Hash; - -use Appwrite\Auth\Hash; - -/* - * PHPass accepted options: - * int iteration_count_log2; The Logarithmic cost value used when generating hash values indicating the number of rounds used to generate hashes - * string portable_hashes - * string random_state; The cached random state - * - * Reference: https://github.com/photodude/phpass -*/ -class Phpass extends Hash -{ - /** - * Alphabet used in itoa64 conversions. - * - * @var string - * @since 0.1.0 - */ - protected string $itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - public function getDefaultOptions(): array - { - $randomState = \microtime(); - if (\function_exists('getmypid')) { - $randomState .= getmypid(); - } - - return ['iteration_count_log2' => 8, 'portable_hashes' => false, 'random_state' => $randomState]; - } - - /** - * @param string $password Input password to hash - * - * @return string hash - */ - public function hash(string $password): string - { - $options = $this->getDefaultOptions(); - - $random = ''; - if (CRYPT_BLOWFISH === 1 && !$options['portable_hashes']) { - $random = $this->getRandomBytes(16, $options); - $hash = crypt($password, $this->gensaltBlowfish($random, $options)); - if (strlen($hash) === 60) { - return $hash; - } - } - if (strlen($random) < 6) { - $random = $this->getRandomBytes(6, $options); - } - $hash = $this->cryptPrivate($password, $this->gensaltPrivate($random, $options)); - if (strlen($hash) === 34) { - return $hash; - } - - /** - * Returning '*' on error is safe here, but would _not_ be safe - * in a crypt(3)-like function used _both_ for generating new - * hashes and for validating passwords against existing hashes. - */ - return '*'; - } - - /** - * @param string $password Input password to validate - * @param string $hash Hash to verify password against - * - * @return boolean true if password matches hash - */ - public function verify(string $password, string $hash): bool - { - $verificationHash = $this->cryptPrivate($password, $hash); - if ($verificationHash[0] === '*') { - $verificationHash = crypt($password, $hash); - } - - /** - * This is not constant-time. In order to keep the code simple, - * for timing safety we currently rely on the salts being - * unpredictable, which they are at least in the non-fallback - * cases (that is, when we use /dev/urandom and bcrypt). - */ - return $hash === $verificationHash; - } - - /** - * @param int $count - * - * @return String $output - * @since 0.1.0 - * @throws Exception Thows an Exception if the $count parameter is not a positive integer. - */ - protected function getRandomBytes(int $count, array $options): string - { - if (!is_int($count) || $count < 1) { - throw new \Exception('Argument count must be a positive integer'); - } - $output = ''; - if (@is_readable('/dev/urandom') && ($fh = @fopen('/dev/urandom', 'rb'))) { - $output = fread($fh, $count); - fclose($fh); - } - - if (strlen($output) < $count) { - $output = ''; - - for ($i = 0; $i < $count; $i += 16) { - $options['iteration_count_log2'] = md5(microtime() . $options['iteration_count_log2']); - $output .= md5($options['iteration_count_log2'], true); - } - - $output = substr($output, 0, $count); - } - - return $output; - } - - /** - * @param String $input - * @param int $count - * - * @return String $output - * @since 0.1.0 - * @throws Exception Thows an Exception if the $count parameter is not a positive integer. - */ - protected function encode64($input, $count) - { - if (!is_int($count) || $count < 1) { - throw new \Exception('Argument count must be a positive integer'); - } - $output = ''; - $i = 0; - do { - $value = ord($input[$i++]); - $output .= $this->itoa64[$value & 0x3f]; - if ($i < $count) { - $value |= ord($input[$i]) << 8; - } - $output .= $this->itoa64[($value >> 6) & 0x3f]; - if ($i++ >= $count) { - break; - } - if ($i < $count) { - $value |= ord($input[$i]) << 16; - } - $output .= $this->itoa64[($value >> 12) & 0x3f]; - if ($i++ >= $count) { - break; - } - $output .= $this->itoa64[($value >> 18) & 0x3f]; - } while ($i < $count); - - return $output; - } - - /** - * @param String $input - * - * @return String $output - * @since 0.1.0 - */ - private function gensaltPrivate($input, $options) - { - $output = '$P$'; - $output .= $this->itoa64[min($options['iteration_count_log2'] + ((PHP_VERSION >= '5') ? 5 : 3), 30)]; - $output .= $this->encode64($input, 6); - - return $output; - } - - /** - * @param String $password - * @param String $setting - * - * @return String $output - * @since 0.1.0 - */ - private function cryptPrivate($password, $setting) - { - $output = '*0'; - if (substr($setting, 0, 2) === $output) { - $output = '*1'; - } - $id = substr($setting, 0, 3); - // We use "$P$", phpBB3 uses "$H$" for the same thing - if ($id !== '$P$' && $id !== '$H$') { - return $output; - } - $count_log2 = strpos($this->itoa64, $setting[3]); - if ($count_log2 < 7 || $count_log2 > 30) { - return $output; - } - $count = 1 << $count_log2; - $salt = substr($setting, 4, 8); - if (strlen($salt) !== 8) { - return $output; - } - /** - * We were kind of forced to use MD5 here since it's the only - * cryptographic primitive that was available in all versions of PHP - * in use. To implement our own low-level crypto in PHP - * would have result in much worse performance and - * consequently in lower iteration counts and hashes that are - * quicker to crack (by non-PHP code). - */ - $hash = md5($salt . $password, true); - do { - $hash = md5($hash . $password, true); - } while (--$count); - $output = substr($setting, 0, 12); - $output .= $this->encode64($hash, 16); - - return $output; - } - - /** - * @param String $input - * - * @return String $output - * @since 0.1.0 - */ - private function gensaltBlowfish($input, $options) - { - /** - * This one needs to use a different order of characters and a - * different encoding scheme from the one in encode64() above. - * We care because the last character in our encoded string will - * only represent 2 bits. While two known implementations of - * bcrypt will happily accept and correct a salt string which - * has the 4 unused bits set to non-zero, we do not want to take - * chances and we also do not want to waste an additional byte - * of entropy. - */ - $itoa64 = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - $output = '$2a$'; - $output .= chr(ord('0') + intval($options['iteration_count_log2'] / 10)); - $output .= chr(ord('0') + $options['iteration_count_log2'] % 10); - $output .= '$'; - $i = 0; - do { - $c1 = ord($input[$i++]); - $output .= $itoa64[$c1 >> 2]; - $c1 = ($c1 & 0x03) << 4; - if ($i >= 16) { - $output .= $itoa64[$c1]; - break; - } - $c2 = ord($input[$i++]); - $c1 |= $c2 >> 4; - $output .= $itoa64[$c1]; - $c1 = ($c2 & 0x0f) << 2; - $c2 = ord($input[$i++]); - $c1 |= $c2 >> 6; - $output .= $itoa64[$c1]; - $output .= $itoa64[$c2 & 0x3f]; - } while (1); - - return $output; - } -} diff --git a/src/Appwrite/Auth/Hash/Scrypt.php b/src/Appwrite/Auth/Hash/Scrypt.php deleted file mode 100644 index 821b1fba69..0000000000 --- a/src/Appwrite/Auth/Hash/Scrypt.php +++ /dev/null @@ -1,51 +0,0 @@ -getOptions(); - - return \scrypt($password, $options['salt'], $options['costCpu'], $options['costMemory'], $options['costParallel'], $options['length']); - } - - /** - * @param string $password Input password to validate - * @param string $hash Hash to verify password against - * - * @return boolean true if password matches hash - */ - public function verify(string $password, string $hash): bool - { - return $hash === $this->hash($password); - } - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - public function getDefaultOptions(): array - { - return [ 'costCpu' => 8, 'costMemory' => 14, 'costParallel' => 1, 'length' => 64 ]; - } -} diff --git a/src/Appwrite/Auth/Hash/Scryptmodified.php b/src/Appwrite/Auth/Hash/Scryptmodified.php deleted file mode 100644 index 7717f324e5..0000000000 --- a/src/Appwrite/Auth/Hash/Scryptmodified.php +++ /dev/null @@ -1,80 +0,0 @@ -getOptions(); - - $derivedKeyBytes = $this->generateDerivedKey($password); - $signerKeyBytes = \base64_decode($options['signerKey']); - - $hashedPassword = $this->hashKeys($signerKeyBytes, $derivedKeyBytes); - - return \base64_encode($hashedPassword); - } - - /** - * @param string $password Input password to validate - * @param string $hash Hash to verify password against - * - * @return boolean true if password matches hash - */ - public function verify(string $password, string $hash): bool - { - return $this->hash($password) === $hash; - } - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - public function getDefaultOptions(): array - { - return [ ]; - } - - private function generateDerivedKey(string $password) - { - $options = $this->getOptions(); - - $saltBytes = \base64_decode($options['salt']); - $saltSeparatorBytes = \base64_decode($options['saltSeparator']); - - $password = mb_convert_encoding($password, 'UTF-8'); - $derivedKey = \scrypt($password, $saltBytes . $saltSeparatorBytes, 16384, 8, 1, 64); - $derivedKey = \hex2bin($derivedKey); - - return $derivedKey; - } - - private function hashKeys($signerKeyBytes, $derivedKeyBytes): string - { - $key = \substr($derivedKeyBytes, 0, 32); - - $iv = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; - - $hash = \openssl_encrypt($signerKeyBytes, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv); - - return $hash; - } -} diff --git a/src/Appwrite/Auth/Hash/Sha.php b/src/Appwrite/Auth/Hash/Sha.php deleted file mode 100644 index c2ae3b52c1..0000000000 --- a/src/Appwrite/Auth/Hash/Sha.php +++ /dev/null @@ -1,50 +0,0 @@ -getOptions()['version']; - - return \hash($algo, $password); - } - - /** - * @param string $password Input password to validate - * @param string $hash Hash to verify password against - * - * @return boolean true if password matches hash - */ - public function verify(string $password, string $hash): bool - { - return $this->hash($password) === $hash; - } - - /** - * Get default options for specific hashing algo - * - * @return array options named array - */ - public function getDefaultOptions(): array - { - return [ 'version' => 'sha3-512' ]; - } -} diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 44a75a6ee3..b1f3836fb6 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -5,6 +5,7 @@ namespace Appwrite\Auth; use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Extend\Exception; +use Appwrite\Utopia\Database\Documents\User; use Utopia\Config\Config; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -110,16 +111,16 @@ class Key $secret = $key; } - $role = Auth::USER_ROLE_APPS; + $role = User::ROLE_APPS; $roles = Config::getParam('roles', []); - $scopes = $roles[Auth::USER_ROLE_APPS]['scopes'] ?? []; + $scopes = $roles[User::ROLE_APPS]['scopes'] ?? []; $expired = false; $guestKey = new Key( $project->getId(), $type, - Auth::USER_ROLE_GUESTS, - $roles[Auth::USER_ROLE_GUESTS]['scopes'] ?? [], + User::ROLE_GUESTS, + $roles[User::ROLE_GUESTS]['scopes'] ?? [], 'UNKNOWN' ); From 437d914e90e14be7a3a56e78f3cd1d61e1d5adc8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 06:08:35 +0000 Subject: [PATCH 09/47] More refactor and fixes --- app/config/console.php | 2 +- app/config/roles.php | 13 +- app/controllers/api/graphql.php | 3 +- app/controllers/api/projects.php | 2 +- app/controllers/api/storage.php | 43 ++--- app/controllers/api/teams.php | 75 ++++---- app/controllers/api/users.php | 177 ++++++++++++------ app/controllers/general.php | 3 +- app/controllers/shared/api.php | 105 ++++++++--- app/controllers/shared/api/auth.php | 7 +- app/init/constants.php | 106 ++++++----- app/init/resources.php | 11 +- src/Appwrite/Auth/MFA/Type.php | 5 +- .../Auth/Validator/PasswordHistory.php | 12 +- src/Appwrite/Migration/Version/V16.php | 2 +- src/Appwrite/Migration/Version/V17.php | 4 +- src/Appwrite/Migration/Version/V20.php | 10 +- .../Collections/Documents/Create.php | 5 +- 18 files changed, 360 insertions(+), 225 deletions(-) diff --git a/app/config/console.php b/app/config/console.php index f8f68a8039..0ac46ee418 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -38,7 +38,7 @@ $console = [ 'mockNumbers' => [], 'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled', 'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user - 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds 'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled', 'invalidateSessions' => true ], diff --git a/app/config/roles.php b/app/config/roles.php index 0f0945a2b4..39e5ac2fd9 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -1,6 +1,7 @@ [ + User::ROLE_GUESTS => [ 'label' => 'Guests', 'scopes' => [ 'global', @@ -112,23 +113,23 @@ return [ 'execution.write', ], ], - Auth::USER_ROLE_USERS => [ + User::ROLE_USERS => [ 'label' => 'Users', 'scopes' => \array_merge($member), ], - Auth::USER_ROLE_ADMIN => [ + User::ROLE_ADMIN => [ 'label' => 'Admin', 'scopes' => \array_merge($admins), ], - Auth::USER_ROLE_DEVELOPER => [ + User::ROLE_DEVELOPER => [ 'label' => 'Developer', 'scopes' => \array_merge($admins), ], - Auth::USER_ROLE_OWNER => [ + User::ROLE_OWNER => [ 'label' => 'Owner', 'scopes' => \array_merge($member, $admins), ], - Auth::USER_ROLE_APPS => [ + User::ROLE_APPS => [ 'label' => 'Applications', 'scopes' => ['global', 'health.read', 'graphql'], ], diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 482b38d698..cdc4685bf7 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -9,6 +9,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use GraphQL\Error\DebugFlag; @@ -32,7 +33,7 @@ App::init() if ( array_key_exists('graphql', $project->getAttribute('apis', [])) && !$project->getAttribute('apis', [])['graphql'] - && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) + && !(User::isPrivileged(Authorization::getRoles()) || User::isApp(Authorization::getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 2b30b1cd08..5f58e9ad23 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -118,7 +118,7 @@ App::post('/v1/projects') 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordDictionary' => false, - 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false, 'mockNumbers' => [], 'sessionAlerts' => false, diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 72acd7b4a2..7197bddeb6 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -13,6 +13,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Buckets; use Appwrite\Utopia\Database\Validator\Queries\Files; @@ -431,8 +432,8 @@ App::post('/v1/storage/buckets/:bucketId/files') $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -464,7 +465,7 @@ App::post('/v1/storage/buckets/:bucketId/files') // Users can only manage their own roles, API keys and Admin users can manage any $roles = Authorization::getRoles(); - if (!Auth::isAppUser($roles) && !Auth::isPrivilegedUser($roles)) { + if (!User::isApp($roles) && !User::isPrivileged($roles)) { foreach (Database::PERMISSIONS as $type) { foreach ($permissions as $permission) { $permission = Permission::parse($permission); @@ -793,8 +794,8 @@ App::get('/v1/storage/buckets/:bucketId/files') ->action(function (string $bucketId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject, string $mode) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -894,8 +895,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId') ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, string $mode) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -976,8 +977,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') /* @type Document $bucket */ $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -1117,7 +1118,7 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; //Do not update transformedAt if it's a console user - if (!Auth::isPrivilegedUser(Authorization::getRoles())) { + if (!User::isPrivileged(Authorization::getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); @@ -1168,8 +1169,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') /* @type Document $bucket */ $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -1329,8 +1330,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') /* @type Document $bucket */ $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -1499,8 +1500,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') throw new Exception(Exception::USER_UNAUTHORIZED); } - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -1656,8 +1657,8 @@ App::put('/v1/storage/buckets/:bucketId/files/:fileId') $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -1686,7 +1687,7 @@ App::put('/v1/storage/buckets/:bucketId/files/:fileId') // Users can only manage their own roles, API keys and Admin users can manage any $roles = Authorization::getRoles(); - if (!Auth::isAppUser($roles) && !Auth::isPrivilegedUser($roles) && !\is_null($permissions)) { + if (!User::isApp($roles) && !User::isPrivileged($roles) && !\is_null($permissions)) { foreach (Database::PERMISSIONS as $type) { foreach ($permissions as $permission) { $permission = Permission::parse($permission); @@ -1770,8 +1771,8 @@ App::delete('/v1/storage/buckets/:bucketId/files/:fileId') ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, Event $queueForEvents, string $mode, Device $deviceForFiles, Delete $queueForDeletes) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 9fb5db0c5b..74b974a086 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -18,6 +18,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Memberships; use Appwrite\Utopia\Database\Validator\Queries\Teams; @@ -28,6 +29,9 @@ use MaxMind\Db\Reader; use Utopia\Abuse\Abuse; use Utopia\App; use Utopia\Audit\Audit; +use Utopia\Auth\Proofs\Password; +use Utopia\Auth\Proofs\Token; +use Utopia\Auth\Store; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -52,7 +56,6 @@ use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; -use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; @@ -170,10 +173,9 @@ App::get('/v1/teams') )) ->param('queries', [], new Teams(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Teams::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -213,7 +215,7 @@ App::get('/v1/teams') $filterQueries = Query::groupByType($queries)['filters']; try { $results = $dbForProject->find('teams', $queries); - $total = $includeTotal ? $dbForProject->count('teams', $filterQueries, APP_LIMIT_COUNT) : 0; + $total = $dbForProject->count('teams', $filterQueries, APP_LIMIT_COUNT); } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -473,10 +475,9 @@ App::post('/v1/teams/:teamId/memberships') ->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) ->param('roles', [], function (Document $project) { if ($project->getId() === 'console') { - ; $roles = array_keys(Config::getParam('roles', [])); - array_filter($roles, function ($role) { - return !in_array($role, [Auth::USER_ROLE_APPS, Auth::USER_ROLE_GUESTS, Auth::USER_ROLE_USERS]); + $roles = array_filter($roles, function ($role) { + return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); }); return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); } @@ -495,7 +496,9 @@ App::post('/v1/teams/:teamId/memberships') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('plan') - ->action(function (string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan) { + ->inject('proofForPassword') + ->inject('proofForToken') + ->action(function (string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Password $proofForPassword, Token $proofForToken) { $isAppUser = Auth::isAppUser(Authorization::getRoles()); $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); @@ -568,6 +571,7 @@ App::post('/v1/teams/:teamId/memberships') try { $userId = ID::unique(); + $hash = $proofForPassword->hash($proofForPassword->generate()); $invitee = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ '$id' => $userId, '$permissions' => [ @@ -581,9 +585,9 @@ App::post('/v1/teams/:teamId/memberships') 'emailVerification' => false, 'status' => true, // TODO: Set password empty? - 'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS), - 'hash' => Auth::DEFAULT_ALGO, - 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + 'password' => $hash, + 'hash' => $proofForPassword->getHash()->getName(), + 'hashOptions' => $proofForPassword->getHash()->getOptions(), /** * Set the password update time to 0 for users created using * team invite and OAuth to allow password updates without an @@ -615,7 +619,7 @@ App::post('/v1/teams/:teamId/memberships') Query::equal('teamInternalId', [$team->getSequence()]), ]); - $secret = Auth::tokenGenerator(); + $secret = $proofForToken->generate(); if ($membership->isEmpty()) { $membershipId = ID::unique(); $membership = new Document([ @@ -635,7 +639,7 @@ App::post('/v1/teams/:teamId/memberships') 'invited' => DateTime::now(), 'joined' => ($isPrivilegedUser || $isAppUser) ? DateTime::now() : null, 'confirm' => ($isPrivilegedUser || $isAppUser), - 'secret' => Auth::hash($secret), + 'secret' => $proofForToken->hash($secret), 'search' => implode(' ', [$membershipId, $invitee->getId()]) ]); @@ -648,7 +652,7 @@ App::post('/v1/teams/:teamId/memberships') } } elseif ($membership->getAttribute('confirm') === false) { - $membership->setAttribute('secret', Auth::hash($secret)); + $membership->setAttribute('secret', $proofForToken->hash($secret)); $membership->setAttribute('invited', DateTime::now()); if ($isPrivilegedUser || $isAppUser) { @@ -840,11 +844,10 @@ App::get('/v1/teams/:teamId/memberships') ->param('teamId', '', new UID(), 'Team ID.') ->param('queries', [], new Memberships(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Memberships::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('project') ->inject('dbForProject') - ->action(function (string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject) { + ->action(function (string $teamId, array $queries, string $search, Response $response, Document $project, Database $dbForProject) { $team = $dbForProject->getDocument('teams', $teamId); if ($team->isEmpty()) { @@ -896,11 +899,11 @@ App::get('/v1/teams/:teamId/memberships') collection: 'memberships', queries: $queries, ); - $total = $includeTotal ? $dbForProject->count( + $total = $dbForProject->count( collection: 'memberships', queries: $filterQueries, max: APP_LIMIT_COUNT - ) : 0; + ); } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1073,7 +1076,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId') if ($project->getId() === 'console') { $roles = array_keys(Config::getParam('roles', [])); array_filter($roles, function ($role) { - return !in_array($role, [Auth::USER_ROLE_APPS, Auth::USER_ROLE_GUESTS, Auth::USER_ROLE_USERS]); + return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); }); return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); } @@ -1188,7 +1191,9 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ->inject('project') ->inject('geodb') ->inject('queueForEvents') - ->action(function (string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Reader $geodb, Event $queueForEvents) { + ->inject('store') + ->inject('proofForToken') + ->action(function (string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) { $protocol = $request->getProtocol(); $membership = $dbForProject->getDocument('memberships', $membershipId); @@ -1207,7 +1212,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH); } - if (Auth::hash($secret) !== $membership->getAttribute('secret')) { + if (!$proofForToken->verify($secret, $membership->getAttribute('secret'))) { throw new Exception(Exception::TEAM_INVALID_SECRET); } @@ -1241,9 +1246,9 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::addSeconds(new \DateTime(), $authDuration); - $secret = Auth::tokenGenerator(); + $secret = $proofForToken->generate(); $session = new Document(array_merge([ '$id' => ID::unique(), '$permissions' => [ @@ -1253,9 +1258,9 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ], 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => Auth::SESSION_PROVIDER_EMAIL, + 'provider' => SESSION_PROVIDER_EMAIL, 'providerUid' => $user->getAttribute('email'), - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => ['email'], @@ -1267,14 +1272,19 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') Authorization::setRole(Role::user($userId)->toString()); + $encoded = $store + ->setProperty('id', $user->getId()) + ->setProperty('secret', $secret) + ->encode(); + if (!Config::getParam('domainVerification')) { - $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); + $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } $response ->addCookie( - name: Auth::$cookieName . '_legacy', - value: Auth::encodeSession($user->getId(), $secret), + name: $store->getKey() . '_legacy', + value: $encoded, expire: (new \DateTime($expire))->getTimestamp(), path: '/', domain: Config::getParam('cookieDomain'), @@ -1282,8 +1292,8 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') httponly: true ) ->addCookie( - name: Auth::$cookieName, - value: Auth::encodeSession($user->getId(), $secret), + name: $store->getKey(), + value: $encoded, expire: (new \DateTime($expire))->getTimestamp(), path: '/', domain: Config::getParam('cookieDomain'), @@ -1433,12 +1443,11 @@ App::get('/v1/teams/:teamId/logs') )) ->param('teamId', '', new UID(), 'Team ID.') ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') ->inject('geodb') - ->action(function (string $teamId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { + ->action(function (string $teamId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { $team = $dbForProject->getDocument('teams', $teamId); @@ -1507,7 +1516,7 @@ App::get('/v1/teams/:teamId/logs') } } $response->dynamic(new Document([ - 'total' => $includeTotal ? $audit->countLogsByResource($resource, $queries) : 0, + 'total' => $audit->countLogsByResource($resource, $queries), 'logs' => $output, ]), Response::MODEL_LOG_LIST); }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 591a22705d..eaa814efad 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1,7 +1,6 @@ getAttribute('auths', [])['passwordHistory'] ?? 0; if (!empty($email)) { @@ -97,7 +107,18 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e } } - $password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null; + $hashedPassword = null; + + if (!empty($password)) { + if ($hash instanceof Plaintext) { // Password was never hashed, hash it with the default hash + $defaultHash = new ProofsPassword(); + $hashedPassword = $defaultHash->hash($password); + $hash = $defaultHash->getHash(); + } else { + $hashedPassword = $password; + } + } + $user = new Document([ '$id' => $userId, '$permissions' => [ @@ -111,11 +132,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'phoneVerification' => false, 'status' => true, 'labels' => [], - 'password' => $password, - 'passwordHistory' => is_null($password) || $passwordHistory === 0 ? [] : [$password], - 'passwordUpdate' => (!empty($password)) ? DateTime::now() : null, - 'hash' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO : $hash, - 'hashOptions' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO_OPTIONS : $hashOptionsObject + ['type' => $hash], + 'password' => $hashedPassword, + 'passwordHistory' => is_null($hashedPassword) || $passwordHistory === 0 ? [] : [$hashedPassword], + 'passwordUpdate' => (!empty($hashedPassword)) ? DateTime::now() : null, + 'hash' => $hash->getName(), + 'hashOptions' => $hash->getOptions(), 'registration' => DateTime::now(), 'reset' => false, 'name' => $name, @@ -126,7 +147,7 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'search' => implode(' ', [$userId, $email, $phone, $name]), ]); - if ($hash === 'plaintext') { + if ($hash instanceof Plaintext) { $hooks->trigger('passwordValidator', [$dbForProject, $project, $plaintextPassword, &$user, true]); } @@ -217,7 +238,9 @@ App::post('/v1/users') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks); + $plaintext = new Plaintext(); + + $user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($user, Response::MODEL_USER); @@ -251,7 +274,10 @@ App::post('/v1/users/bcrypt') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $user = createUser('bcrypt', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $bcrypt = new Bcrypt(); + $bcrypt->setCost(8); // Default cost + + $user = createUser($bcrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -286,7 +312,9 @@ App::post('/v1/users/md5') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $user = createUser('md5', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $md5 = new MD5(); + + $user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -321,7 +349,13 @@ App::post('/v1/users/argon2') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $user = createUser('argon2', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $argon2 = new Argon2(); + $argon2 + ->setMemoryCost(7168) + ->setTimeCost(5) + ->setThreads(1); + + $user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -357,13 +391,12 @@ App::post('/v1/users/sha') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $options = '{}'; - + $sha = new Sha(); if (!empty($passwordVersion)) { - $options = '{"version":"' . $passwordVersion . '"}'; + $sha->setVersion($passwordVersion); } - $user = createUser('sha', $options, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser($sha, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -398,7 +431,9 @@ App::post('/v1/users/phpass') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $user = createUser('phpass', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $phpass = new PHPass(); + + $user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -438,15 +473,15 @@ App::post('/v1/users/scrypt') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $options = [ - 'salt' => $passwordSalt, - 'costCpu' => $passwordCpu, - 'costMemory' => $passwordMemory, - 'costParallel' => $passwordParallel, - 'length' => $passwordLength - ]; + $scrypt = new Scrypt(); + $scrypt + ->setSalt($passwordSalt) + ->setCpuCost($passwordCpu) + ->setMemoryCost($passwordMemory) + ->setParallelCost($passwordParallel) + ->setLength($passwordLength); - $user = createUser('scrypt', \json_encode($options), $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser($scrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -484,7 +519,13 @@ App::post('/v1/users/scrypt-modified') ->inject('dbForProject') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { - $user = createUser('scryptMod', '{"signerKey":"' . $passwordSignerKey . '","saltSeparator":"' . $passwordSaltSeparator . '","salt":"' . $passwordSalt . '"}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $scryptModified = new ScryptModified(); + $scryptModified + ->setSalt($passwordSalt) + ->setSaltSeparator($passwordSaltSeparator) + ->setSignerKey($passwordSignerKey); + + $user = createUser($scryptModified, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -605,10 +646,9 @@ App::get('/v1/users') )) ->param('queries', [], new Users(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Users::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -648,10 +688,10 @@ App::get('/v1/users') $users = []; $total = 0; - $dbForProject->skipFilters(function () use ($dbForProject, $queries, $includeTotal, &$users, &$total) { + $dbForProject->skipFilters(function () use ($dbForProject, $queries, &$users, &$total) { try { $users = $dbForProject->find('users', $queries); - $total = $includeTotal ? $dbForProject->count('users', $queries, APP_LIMIT_COUNT) : 0; + $total = $dbForProject->count('users', $queries, APP_LIMIT_COUNT); } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } catch (QueryException $e) { @@ -785,11 +825,10 @@ App::get('/v1/users/:userId/sessions') ] )) ->param('userId', '', new UID(), 'User ID.') - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') - ->action(function (string $userId, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale) { + ->action(function (string $userId, Response $response, Database $dbForProject, Locale $locale) { $user = $dbForProject->getDocument('users', $userId); @@ -811,7 +850,7 @@ App::get('/v1/users/:userId/sessions') $response->dynamic(new Document([ 'sessions' => $sessions, - 'total' => $includeTotal ? count($sessions) : 0, + 'total' => count($sessions), ]), Response::MODEL_SESSION_LIST); }); @@ -835,10 +874,9 @@ App::get('/v1/users/:userId/memberships') ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Memberships(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Memberships::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $userId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { + ->action(function (string $userId, array $queries, string $search, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); @@ -872,7 +910,7 @@ App::get('/v1/users/:userId/memberships') $response->dynamic(new Document([ 'memberships' => $memberships, - 'total' => $includeTotal ? count($memberships) : 0, + 'total' => count($memberships), ]), Response::MODEL_MEMBERSHIP_LIST); }); @@ -895,12 +933,11 @@ App::get('/v1/users/:userId/logs') )) ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') ->inject('geodb') - ->action(function (string $userId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { + ->action(function (string $userId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { $user = $dbForProject->getDocument('users', $userId); @@ -969,7 +1006,7 @@ App::get('/v1/users/:userId/logs') } $response->dynamic(new Document([ - 'total' => $includeTotal ? $audit->countLogsByUser($user->getSequence(), $queries) : 0, + 'total' => $audit->countLogsByUser($user->getSequence(), $queries), 'logs' => $output, ]), Response::MODEL_LOG_LIST); }); @@ -993,10 +1030,9 @@ App::get('/v1/users/:userId/targets') )) ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Targets(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Targets::ALLOWED_ATTRIBUTES), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $userId, array $queries, bool $includeTotal, Response $response, Database $dbForProject) { + ->action(function (string $userId, array $queries, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { @@ -1036,7 +1072,7 @@ App::get('/v1/users/:userId/targets') } try { $targets = $dbForProject->find('targets', $queries); - $total = $includeTotal ? $dbForProject->count('targets', $queries, APP_LIMIT_COUNT) : 0; + $total = $dbForProject->count('targets', $queries, APP_LIMIT_COUNT); } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1065,17 +1101,15 @@ App::get('/v1/users/identities') )) ->param('queries', [], new Identities(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Identities::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - if (!empty($search)) { $queries[] = Query::search('search', $search); } @@ -1105,9 +1139,10 @@ App::get('/v1/users/identities') $cursor->setValue($cursorDocument); } + $filterQueries = Query::groupByType($queries)['filters']; try { $identities = $dbForProject->find('identities', $queries); - $total = $includeTotal ? $dbForProject->count('identities', $queries, APP_LIMIT_COUNT) : 0; + $total = $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT); } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1341,12 +1376,21 @@ App::patch('/v1/users/:userId/password') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); - $newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); + // Create Argon2 hasher with default settings + $hasher = new Argon2(); + $hasher + ->setMemoryCost(7168) + ->setTimeCost(5) + ->setThreads(1); + $newPassword = $hasher->hash($password); + + $hash = ProofsPassword::createHash($user->getAttribute('hash'), $user->getAttribute('hashOptions')); $historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; $history = $user->getAttribute('passwordHistory', []); + if ($historyLimit > 0) { - $validator = new PasswordHistory($history, $user->getAttribute('hash'), $user->getAttribute('hashOptions')); + $validator = new PasswordHistory($history, $hash); if (!$validator->isValid($password)) { throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED); } @@ -1359,8 +1403,8 @@ App::patch('/v1/users/:userId/password') ->setAttribute('password', $newPassword) ->setAttribute('passwordHistory', $history) ->setAttribute('passwordUpdate', DateTime::now()) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); + ->setAttribute('hash', $hasher->getName()) + ->setAttribute('hashOptions', $hasher->getOptions()); $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -2173,17 +2217,19 @@ App::post('/v1/users/:userId/sessions') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) { + ->inject('store') + ->inject('proofForToken') + ->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); + $secret = $proofForToken->generate(); $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $session = new Document(array_merge( @@ -2191,8 +2237,8 @@ App::post('/v1/users/:userId/sessions') '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => Auth::SESSION_PROVIDER_SERVER, - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'provider' => SESSION_PROVIDER_SERVER, + 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'factors' => ['server'], 'ip' => $request->getIP(), @@ -2216,8 +2262,13 @@ App::post('/v1/users/:userId/sessions') $dbForProject->purgeCachedDocument('users', $user->getId()); + $encoded = $store + ->setProperty('id', $user->getId()) + ->setProperty('secret', $secret) + ->encode(); + $session - ->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)) + ->setAttribute('secret', $encoded) ->setAttribute('countryName', $countryName); $queueForEvents @@ -2252,7 +2303,7 @@ App::post('/v1/users/:userId/tokens') )) ->param('userId', '', new UID(), 'User ID.') ->param('length', 6, new Range(4, 128), 'Token length in characters. The default length is 6 characters', true) - ->param('expire', Auth::TOKEN_EXPIRATION_GENERIC, new Range(60, Auth::TOKEN_EXPIRATION_LOGIN_LONG), 'Token expiration period in seconds. The default expiration is 15 minutes.', true) + ->param('expire', TOKEN_EXPIRATION_GENERIC, new Range(60, TOKEN_EXPIRATION_LOGIN_LONG), 'Token expiration period in seconds. The default expiration is 15 minutes.', true) ->inject('request') ->inject('response') ->inject('dbForProject') @@ -2264,15 +2315,17 @@ App::post('/v1/users/:userId/tokens') throw new Exception(Exception::USER_NOT_FOUND); } - $secret = Auth::tokenGenerator($length); + $proofForToken = new Token($length); + $proofForToken->setHash(new Sha()); + $secret = $proofForToken->generate(); $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $expire)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => Auth::TOKEN_TYPE_GENERIC, - 'secret' => Auth::hash($secret), + 'type' => TOKEN_TYPE_GENERIC, + 'secret' => $proofForToken->hash($secret), 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP() diff --git a/app/controllers/general.php b/app/controllers/general.php index 07de95a38f..f6d51c1079 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -47,6 +47,7 @@ use Utopia\Locale\Locale; use Utopia\Logger\Adapter\Sentry; use Utopia\Logger\Log; use Utopia\Logger\Log\User; +use Appwrite\Utopia\Database\Documents\User as DBUser; use Utopia\Logger\Logger; use Utopia\Platform\Service; use Utopia\System\System; @@ -1256,7 +1257,7 @@ App::error() * If not a publishable error, track usage stats. Publishable errors are >= 500 or those explicitly marked as publish=true in errors.php */ if (!$publish && $project->getId() !== 'console') { - if (!Auth::isPrivilegedUser(Authorization::getRoles())) { + if (!DBUser::isPrivileged(Authorization::getRoles())) { $fileSize = 0; $file = $request->getFiles('file'); if (!empty($file)) { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 122139d48b..5fd693fe62 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -16,13 +16,13 @@ use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\SDK\Method; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Utopia\Abuse\Abuse; use Utopia\App; use Utopia\Cache\Adapter\Filesystem; use Utopia\Cache\Cache; -use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -34,7 +34,7 @@ use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Validator\WhiteList; -$parseLabel = function (string $label, array $responsePayload, array $requestParams, Document $user) { +$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) { preg_match_all('/{(.*?)}/', $label, $matches); foreach ($matches[1] ?? [] as $pos => $match) { $find = $matches[0][$pos]; @@ -232,42 +232,97 @@ App::init() ->inject('mode') ->inject('team') ->inject('apiKey') - ->action(function (App $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, Document $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey) { + ->action(function (App $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey) { $route = $utopia->getRoute(); + /** + * Handle user authentication and session validation. + * + * This function follows a series of steps to determine the appropriate user session + * based on cookies, headers, and JWT tokens. + * + * Process: + * + * Project & Role Validation: + * 1. Check if the project is empty. If so, throw an exception. + * 2. Get the roles configuration. + * 3. Determine the role for the user based on the user document. + * 4. Get the scopes for the role. + * + * API Key Authentication: + * 5. If there is an API key: + * - Verify no user session exists simultaneously + * - Check if key is expired + * - Set role and scopes from API key + * - Handle special app role case + * - For standard keys, update last accessed time + * + * User Activity: + * 6. If the project is not the console and user is not admin: + * - Update user's last activity timestamp + * + * Access Control: + * 7. Get the method from the route + * 8. Validate namespace permissions + * 9. Validate scope permissions + * 10. Check if user is blocked + * + * Security Checks: + * 11. Verify password status (check if reset required) + * 12. Validate MFA requirements: + * - Check if MFA is enabled + * - Verify email status + * - Verify phone status + * - Verify authenticator status + * 13. Handle Multi-Factor Authentication: + * - Check remaining required factors + * - Validate factor completion + * - Throw exception if factors incomplete + */ + + // Step 1: Check if project is empty if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } + // Step 2: Get roles configuration $roles = Config::getParam('roles', []); + // Step 3: Determine role for user + // TODO get scopes from the identity instead of the user roles config. The identity will containn the scopes the user authorized for the access token. + $role = $user->isEmpty() ? Role::guests()->toString() : Role::users()->toString(); + // Step 4: Get scopes for the role $scopes = $roles[$role]['scopes']; - // API Key authentication + // Step 5: API Key Authentication if (!empty($apiKey)) { + // Verify no user session exists simultaneously if (!$user->isEmpty()) { throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } + // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); } + // Set role and scopes from API key $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); - if ($apiKey->getRole() === Auth::USER_ROLE_APPS) { + // Handle special app role case + if ($apiKey->getRole() === User::ROLE_APPS) { // Disable authorization checks for API keys Authorization::setDefaultStatus(false); - $user = new Document([ + $user = new User([ '$id' => '', 'status' => true, - 'type' => Auth::ACTIVITY_TYPE_APP, + 'type' => ACTIVITY_TYPE_APP, 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), 'password' => '', 'name' => $apiKey->getName(), @@ -276,6 +331,7 @@ App::init() $queueForAudits->setUser($user); } + // For standard keys, update last accessed time if ($apiKey->getType() === API_KEY_STANDARD) { $dbKey = $project->find( key: 'secret', @@ -341,11 +397,11 @@ App::init() $scopes = \array_unique($scopes); Authorization::setRole($role); - foreach (Auth::getRoles($user) as $authRole) { + foreach ($user->getRoles() as $authRole) { Authorization::setRole($authRole); } - // Update project last activity + // Step 6: Update project and user last activity if (!$project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { @@ -354,7 +410,6 @@ App::init() } } - // Update user last activity if (!empty($user->getId())) { $accessedAt = $user->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { @@ -368,6 +423,7 @@ App::init() } } + // Steps 7-9: Access Control - Method, Namespace and Scope Validation /** * @var ?Method $method */ @@ -385,27 +441,29 @@ App::init() if ( array_key_exists($namespace, $project->getAttribute('services', [])) && !$project->getAttribute('services', [])[$namespace] - && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) + && !(User::isPrivileged(Authorization::getRoles()) || User::isApp(Authorization::getRoles())) ) { throw new Exception(Exception::GENERAL_SERVICE_DISABLED); } } - // Do now allow access if scope is not allowed + // Step 9: Validate scope permissions $allowed = (array)$route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')'); } - // Do not allow access to blocked accounts + // Step 10: Check if user is blocked if (false === $user->getAttribute('status')) { // Account is blocked throw new Exception(Exception::USER_BLOCKED); } + // Step 11: Verify password status if ($user->getAttribute('reset')) { throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); } + // Step 12: Validate MFA requirements $mfaEnabled = $user->getAttribute('mfa', false); $hasVerifiedEmail = $user->getAttribute('emailVerification', false); $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); @@ -413,6 +471,7 @@ App::init() $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; + // Step 13: Handle Multi-Factor Authentication if (!in_array('mfa', $route->getGroups())) { if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) { throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); @@ -452,7 +511,7 @@ App::init() if ( array_key_exists('rest', $project->getAttribute('apis', [])) && !$project->getAttribute('apis', [])['rest'] - && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) + && !(User::isPrivileged(Authorization::getRoles()) || User::isApp(Authorization::getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } @@ -483,8 +542,8 @@ App::init() $closestLimit = null; $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); + $isPrivilegedUser = User::isPrivileged($roles); + $isAppUser = User::isApp($roles); foreach ($timeLimitArray as $timeLimit) { foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys @@ -539,7 +598,7 @@ App::init() if (!$user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. - $userClone->setAttribute('type', Auth::ACTIVITY_TYPE_USER); + $userClone->setAttribute('type', ACTIVITY_TYPE_USER); $queueForAudits->setUser($userClone); } @@ -582,7 +641,7 @@ App::init() if ($useCache) { $route = $utopia->match($request); $isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview'; - $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && !Auth::isPrivilegedUser(Authorization::getRoles()); + $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && !User::isPrivileged(Authorization::getRoles()); $key = $request->cacheIdentifier(); $cacheLog = Authorization::skip(fn () => $dbForProject->getDocument('cache', $key)); @@ -605,7 +664,7 @@ App::init() $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAppUser && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -635,7 +694,7 @@ App::init() throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } //Do not update transformedAt if it's a console user - if (!Auth::isPrivilegedUser(Authorization::getRoles())) { + if (!User::isPrivileged(Authorization::getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); @@ -783,7 +842,7 @@ App::shutdown() if (!$user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. - $userClone->setAttribute('type', Auth::ACTIVITY_TYPE_USER); + $userClone->setAttribute('type', ACTIVITY_TYPE_USER); $queueForAudits->setUser($userClone); } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) { /** @@ -797,7 +856,7 @@ App::shutdown() $user = new Document([ '$id' => '', 'status' => true, - 'type' => Auth::ACTIVITY_TYPE_GUEST, + 'type' => ACTIVITY_TYPE_GUEST, 'email' => 'guest.' . $project->getId() . '@service.' . $request->getHostname(), 'password' => '', 'name' => 'Guest', @@ -887,7 +946,7 @@ App::shutdown() } if ($project->getId() !== 'console') { - if (!Auth::isPrivilegedUser(Authorization::getRoles())) { + if (!User::isPrivileged(Authorization::getRoles())) { $fileSize = 0; $file = $request->getFiles('file'); if (!empty($file)) { diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php index ecabc641ec..1126145840 100644 --- a/app/controllers/shared/api/auth.php +++ b/app/controllers/shared/api/auth.php @@ -2,6 +2,7 @@ use Appwrite\Auth\Auth; use Appwrite\Extend\Exception; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use MaxMind\Db\Reader; use Utopia\App; @@ -20,7 +21,7 @@ App::init() $lastUpdate = $session->getAttribute('mfaUpdatedAt'); if (!empty($lastUpdate)) { $now = DateTime::now(); - $maxAllowedDate = DateTime::addSeconds(new \DateTime($lastUpdate), Auth::MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge + $maxAllowedDate = DateTime::addSeconds(new \DateTime($lastUpdate), MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge $isSessionFresh = DateTime::formatTz($maxAllowedDate) >= DateTime::formatTz($now); } @@ -49,8 +50,8 @@ App::init() $route = $utopia->match($request); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - $isAppUser = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + $isAppUser = User::isApp(Authorization::getRoles()); if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs return; diff --git a/app/init/constants.php b/app/init/constants.php index ee5e001591..9f7766040a 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -93,6 +93,62 @@ const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io'; const APP_VCS_GITHUB_URL = 'https://github.com/TeamAppwrite'; const APP_BRANDED_EMAIL_BASE_TEMPLATE = 'email-base-styled'; +/** + * Token Expiration times. + */ +const TOKEN_EXPIRATION_LOGIN_LONG = 31536000; /* 1 year */ +const TOKEN_EXPIRATION_LOGIN_SHORT = 3600; /* 1 hour */ +const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ +const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */ +const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */ +const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */ + +/** + * Token Lengths. + */ +const TOKEN_LENGTH_MAGIC_URL = 64; +const TOKEN_LENGTH_VERIFICATION = 256; +const TOKEN_LENGTH_RECOVERY = 256; +const TOKEN_LENGTH_OAUTH2 = 64; +const TOKEN_LENGTH_SESSION = 256; + +/** + * Token Types. + */ +const TOKEN_TYPE_LOGIN = 1; // Deprecated +const TOKEN_TYPE_VERIFICATION = 2; +const TOKEN_TYPE_RECOVERY = 3; +const TOKEN_TYPE_INVITE = 4; +const TOKEN_TYPE_MAGIC_URL = 5; +const TOKEN_TYPE_PHONE = 6; +const TOKEN_TYPE_OAUTH2 = 7; +const TOKEN_TYPE_GENERIC = 8; +const TOKEN_TYPE_EMAIL = 9; // OTP + +/** + * Session Providers. + */ +const SESSION_PROVIDER_EMAIL = 'email'; +const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; +const SESSION_PROVIDER_MAGIC_URL = 'magic-url'; +const SESSION_PROVIDER_PHONE = 'phone'; +const SESSION_PROVIDER_OAUTH2 = 'oauth2'; +const SESSION_PROVIDER_TOKEN = 'token'; +const SESSION_PROVIDER_SERVER = 'server'; + +/** + * Activity associated with user or the app. + */ +const ACTIVITY_TYPE_APP = 'app'; +const ACTIVITY_TYPE_USER = 'user'; +const ACTIVITY_TYPE_GUEST = 'guest'; + +/** + * MFA + */ +const MFA_RECENT_DURATION = 1800; // 30 mins + + // Database Reconnect const DATABASE_RECONNECT_SLEEP = 2; const DATABASE_RECONNECT_MAX_ATTEMPTS = 10; @@ -294,53 +350,3 @@ const TOKENS_RESOURCE_TYPE_DATABASES = 'databases'; const SCHEDULE_RESOURCE_TYPE_EXECUTION = 'execution'; const SCHEDULE_RESOURCE_TYPE_FUNCTION = 'function'; const SCHEDULE_RESOURCE_TYPE_MESSAGE = 'message'; - - -// Auth -/** - * Token Types. - */ -const TOKEN_TYPE_LOGIN = 1; // Deprecated -const TOKEN_TYPE_VERIFICATION = 2; -const TOKEN_TYPE_RECOVERY = 3; -const TOKEN_TYPE_INVITE = 4; -const TOKEN_TYPE_MAGIC_URL = 5; -const TOKEN_TYPE_PHONE = 6; -const TOKEN_TYPE_OAUTH2 = 7; -const TOKEN_TYPE_GENERIC = 8; -const TOKEN_TYPE_EMAIL = 9; // OTP - -/** - * Session Providers. - */ -const SESSION_PROVIDER_EMAIL = 'email'; -const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; -const SESSION_PROVIDER_MAGIC_URL = 'magic-url'; -const SESSION_PROVIDER_PHONE = 'phone'; -const SESSION_PROVIDER_OAUTH2 = 'oauth2'; -const SESSION_PROVIDER_TOKEN = 'token'; -const SESSION_PROVIDER_SERVER = 'server'; - -/** - * Token Expiration times. - */ -const TOKEN_EXPIRATION_LOGIN_LONG = 31536000; /* 1 year */ -const TOKEN_EXPIRATION_LOGIN_SHORT = 3600; /* 1 hour */ -const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ -const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */ -const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */ -const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */ - -/** - * Token Lengths. - */ -const TOKEN_LENGTH_MAGIC_URL = 64; -const TOKEN_LENGTH_VERIFICATION = 256; -const TOKEN_LENGTH_RECOVERY = 256; -const TOKEN_LENGTH_OAUTH2 = 64; -const TOKEN_LENGTH_SESSION = 256; - -/** - * MFA - */ -const MFA_RECENT_DURATION = 1800; // 30 mins diff --git a/app/init/resources.php b/app/init/resources.php index a79ca033d5..fedd25747e 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -293,15 +293,18 @@ App::setResource('user', function (string $mode, Document $project, Document $co } if (APP_MODE_ADMIN === $mode) { + /** @var User $user */ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); } else { if ($project->isEmpty()) { - $user = new Document([]); + $user = new User([]); } else { if (!empty($store->getProperty('id', ''))) { if ($project->getId() === 'console') { + /** @var User $user */ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); } else { + /** @var User $user */ $user = $dbForProject->getDocument('users', $store->getProperty('id', '')); } } @@ -311,7 +314,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co if ( !$user || $user->isEmpty() // Check a document has been found in the DB - || !Auth::sessionVerify($user->getAttribute('sessions', []), $store->getProperty('secret', ''), $proofForToken) + || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) ) { // Validate user has valid login token $user = new Document([]); } @@ -367,13 +370,13 @@ App::setResource('project', function ($dbForPlatform, $request, $console) { return $project; }, ['dbForPlatform', 'request', 'console']); -App::setResource('session', function (Document $user, Store $store, Token $proofForToken) { +App::setResource('session', function (User $user, Store $store, Token $proofForToken) { if ($user->isEmpty()) { return; } $sessions = $user->getAttribute('sessions', []); - $sessionId = Auth::sessionVerify($user->getAttribute('sessions'), $store->getProperty('secret', ''), $proofForToken); + $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); if (!$sessionId) { return; diff --git a/src/Appwrite/Auth/MFA/Type.php b/src/Appwrite/Auth/MFA/Type.php index 3516ec3780..d1e267965a 100644 --- a/src/Appwrite/Auth/MFA/Type.php +++ b/src/Appwrite/Auth/MFA/Type.php @@ -2,8 +2,8 @@ namespace Appwrite\Auth\MFA; -use Appwrite\Auth\Auth; use OTPHP\OTP; +use Utopia\Auth\Proofs\Token; abstract class Type { @@ -51,9 +51,10 @@ abstract class Type public static function generateBackupCodes(int $length = 10, int $total = 6): array { $backups = []; + $token = new Token($length); for ($i = 0; $i < $total; $i++) { - $backups[] = Auth::tokenGenerator($length); + $backups[] = $token->generate(); } return $backups; diff --git a/src/Appwrite/Auth/Validator/PasswordHistory.php b/src/Appwrite/Auth/Validator/PasswordHistory.php index f623ca180d..9b40b6a794 100644 --- a/src/Appwrite/Auth/Validator/PasswordHistory.php +++ b/src/Appwrite/Auth/Validator/PasswordHistory.php @@ -2,7 +2,7 @@ namespace Appwrite\Auth\Validator; -use Appwrite\Auth\Auth; +use Utopia\Auth\Hash; /** * Password. @@ -12,16 +12,14 @@ use Appwrite\Auth\Auth; class PasswordHistory extends Password { protected array $history; - protected string $algo; - protected array $algoOptions; + protected Hash $hash; - public function __construct(array $history, string $algo, array $algoOptions = []) + public function __construct(array $history, Hash $hash) { parent::__construct(); $this->history = $history; - $this->algo = $algo; - $this->algoOptions = $algoOptions; + $this->hash = $hash; } /** @@ -46,7 +44,7 @@ class PasswordHistory extends Password public function isValid($value): bool { foreach ($this->history as $hash) { - if (!empty($hash) && Auth::passwordVerify($value, $hash, $this->algo, $this->algoOptions)) { + if (!empty($hash) && $this->hash->verify($value, $hash)) { return false; } } diff --git a/src/Appwrite/Migration/Version/V16.php b/src/Appwrite/Migration/Version/V16.php index 9d72af9563..4c6739a28e 100644 --- a/src/Appwrite/Migration/Version/V16.php +++ b/src/Appwrite/Migration/Version/V16.php @@ -118,7 +118,7 @@ class V16 extends Migration * Set default authDuration */ $document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [ - 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG ])); /** diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php index fbbd4bfde0..79e2a8377d 100644 --- a/src/Appwrite/Migration/Version/V17.php +++ b/src/Appwrite/Migration/Version/V17.php @@ -2,8 +2,8 @@ namespace Appwrite\Migration\Version; -use Appwrite\Auth\Auth; use Appwrite\Migration\Migration; +use Utopia\Auth\Proofs\Password; use Utopia\CLI\Console; use Utopia\Database\Database; use Utopia\Database\Document; @@ -270,7 +270,7 @@ class V17 extends Migration * Set hashOptions type */ $document->setAttribute('hashOptions', array_merge($document->getAttribute('hashOptions', []), [ - 'type' => $document->getAttribute('hash', Auth::DEFAULT_ALGO) + 'type' => $document->getAttribute('hash', (new Password())->getHash()->getName()) ])); break; } diff --git a/src/Appwrite/Migration/Version/V20.php b/src/Appwrite/Migration/Version/V20.php index 9ff041eb33..18b65ddf72 100644 --- a/src/Appwrite/Migration/Version/V20.php +++ b/src/Appwrite/Migration/Version/V20.php @@ -632,15 +632,15 @@ class V20 extends Migration } break; case 'sessions': - $duration = $this->project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $this->project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::addSeconds(new \DateTime(), $duration); $document->setAttribute('expire', $expire); $factors = match ($document->getAttribute('provider')) { - Auth::SESSION_PROVIDER_EMAIL => ['password'], - Auth::SESSION_PROVIDER_PHONE => ['phone'], - Auth::SESSION_PROVIDER_ANONYMOUS => ['anonymous'], - Auth::SESSION_PROVIDER_TOKEN => ['token'], + SESSION_PROVIDER_EMAIL => ['password'], + SESSION_PROVIDER_PHONE => ['phone'], + SESSION_PROVIDER_ANONYMOUS => ['anonymous'], + SESSION_PROVIDER_TOKEN => ['token'], default => ['email'], }; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 521190d3dc..f53ba16032 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -12,6 +12,7 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Parameter; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; @@ -177,8 +178,8 @@ class Create extends Action $documents = [$data]; } - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($isBulk && !$isAPIKey && !$isPrivilegedUser) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE); From 5ef717d0fd887bc401943523f4a31509140fd6e9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 07:12:34 +0000 Subject: [PATCH 10/47] More replacement for auth checks --- app/controllers/api/teams.php | 44 +++++++++---------- src/Appwrite/Migration/Version/V16.php | 1 - src/Appwrite/Migration/Version/V20.php | 1 - .../Documents/Attribute/Decrement.php | 6 +-- .../Documents/Attribute/Increment.php | 6 +-- .../Collections/Documents/Create.php | 1 - .../Collections/Documents/Delete.php | 6 +-- .../Databases/Collections/Documents/Get.php | 6 +-- .../Collections/Documents/Update.php | 6 +-- .../Collections/Documents/Upsert.php | 6 +-- .../Databases/Collections/Documents/XList.php | 6 +-- .../Transactions/Operations/Create.php | 6 +-- .../Http/Databases/Transactions/Update.php | 9 ++-- .../Functions/Http/Executions/Create.php | 5 ++- .../Modules/Functions/Http/Executions/Get.php | 6 +-- .../Functions/Http/Executions/XList.php | 6 +-- .../Http/Tokens/Buckets/Files/Action.php | 6 +-- src/Appwrite/Utopia/Request.php | 18 ++++---- src/Appwrite/Utopia/Response.php | 5 +-- 19 files changed, 72 insertions(+), 78 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 74b974a086..dca905ea59 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -89,13 +89,13 @@ App::post('/v1/teams') ->inject('queueForEvents') ->action(function (string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - $isAppUser = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + $isAppUser = User::isApp(Authorization::getRoles()); $teamId = $teamId == 'unique()' ? ID::unique() : $teamId; try { - $team = Authorization::skip(fn () => $dbForProject->createDocument('teams', new Document([ + $team = Authorization::skip(fn() => $dbForProject->createDocument('teams', new Document([ '$id' => $teamId, '$permissions' => [ Permission::read(Role::team($teamId)), @@ -483,7 +483,7 @@ App::post('/v1/teams/:teamId/memberships') } return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); }, 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) - ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) // TODO add our own built-in confirm page + ->param('url', '', fn($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) // TODO add our own built-in confirm page ->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true) ->inject('response') ->inject('project') @@ -499,8 +499,8 @@ App::post('/v1/teams/:teamId/memberships') ->inject('proofForPassword') ->inject('proofForToken') ->action(function (string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Password $proofForPassword, Token $proofForToken) { - $isAppUser = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAppUser = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); $url = htmlentities($url); if (empty($url)) { @@ -572,7 +572,7 @@ App::post('/v1/teams/:teamId/memberships') try { $userId = ID::unique(); $hash = $proofForPassword->hash($proofForPassword->generate()); - $invitee = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ + $invitee = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ '$id' => $userId, '$permissions' => [ Permission::read(Role::any()), @@ -644,13 +644,12 @@ App::post('/v1/teams/:teamId/memberships') ]); $membership = ($isPrivilegedUser || $isAppUser) ? - Authorization::skip(fn () => $dbForProject->createDocument('memberships', $membership)) : + Authorization::skip(fn() => $dbForProject->createDocument('memberships', $membership)) : $dbForProject->createDocument('memberships', $membership); if ($isPrivilegedUser || $isAppUser) { - Authorization::skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); + Authorization::skip(fn() => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); } - } elseif ($membership->getAttribute('confirm') === false) { $membership->setAttribute('secret', $proofForToken->hash($secret)); $membership->setAttribute('invited', DateTime::now()); @@ -661,7 +660,7 @@ App::post('/v1/teams/:teamId/memberships') } $membership = ($isPrivilegedUser || $isAppUser) ? - Authorization::skip(fn () => $dbForProject->updateDocument('memberships', $membership->getId(), $membership)) : + Authorization::skip(fn() => $dbForProject->updateDocument('memberships', $membership->getId(), $membership)) : $dbForProject->updateDocument('memberships', $membership->getId(), $membership); } else { throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED); @@ -755,7 +754,6 @@ App::post('/v1/teams/:teamId/memberships') ->setName($invitee->getAttribute('name', '')) ->setVariables($emailVariables) ->trigger(); - } elseif (!empty($phone)) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); @@ -909,7 +907,7 @@ App::get('/v1/teams/:teamId/memberships') } - $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); + $memberships = array_filter($memberships, fn(Document $membership) => !empty($membership->getAttribute('userId'))); $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, @@ -918,8 +916,8 @@ App::get('/v1/teams/:teamId/memberships') ]; $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); + $isPrivilegedUser = User::isPrivileged($roles); + $isAppUser = User::isApp($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; @@ -1009,8 +1007,8 @@ App::get('/v1/teams/:teamId/memberships/:membershipId') ]; $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); + $isPrivilegedUser = User::isPrivileged($roles); + $isAppUser = User::isApp($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; @@ -1105,8 +1103,8 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId') throw new Exception(Exception::USER_NOT_FOUND); } - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - $isAppUser = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); + $isAppUser = User::isApp(Authorization::getRoles()); $isOwner = Authorization::isRole('team:' . $team->getId() . '/owner'); if ($project->getId() === 'console') { @@ -1202,7 +1200,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') throw new Exception(Exception::MEMBERSHIP_NOT_FOUND); } - $team = Authorization::skip(fn () => $dbForProject->getDocument('teams', $teamId)); + $team = Authorization::skip(fn() => $dbForProject->getDocument('teams', $teamId)); if ($team->isEmpty()) { throw new Exception(Exception::TEAM_NOT_FOUND); @@ -1238,7 +1236,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ->setAttribute('confirm', true) ; - Authorization::skip(fn () => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true))); + Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true))); // Create session for the user if not logged in if (!$hasSession) { @@ -1308,7 +1306,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') $dbForProject->purgeCachedDocument('users', $user->getId()); - Authorization::skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); + Authorization::skip(fn() => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); $queueForEvents ->setParam('userId', $user->getId()) @@ -1411,7 +1409,7 @@ App::delete('/v1/teams/:teamId/memberships/:membershipId') $dbForProject->purgeCachedDocument('users', $profile->getId()); if ($membership->getAttribute('confirm')) { // Count only confirmed members - Authorization::skip(fn () => $dbForProject->decreaseDocumentAttribute('teams', $team->getId(), 'total', 1, 0)); + Authorization::skip(fn() => $dbForProject->decreaseDocumentAttribute('teams', $team->getId(), 'total', 1, 0)); } $queueForEvents diff --git a/src/Appwrite/Migration/Version/V16.php b/src/Appwrite/Migration/Version/V16.php index 4c6739a28e..061ace31d7 100644 --- a/src/Appwrite/Migration/Version/V16.php +++ b/src/Appwrite/Migration/Version/V16.php @@ -2,7 +2,6 @@ namespace Appwrite\Migration\Version; -use Appwrite\Auth\Auth; use Appwrite\Migration\Migration; use Utopia\CLI\Console; use Utopia\Config\Config; diff --git a/src/Appwrite/Migration/Version/V20.php b/src/Appwrite/Migration/Version/V20.php index 18b65ddf72..10e2706d0e 100644 --- a/src/Appwrite/Migration/Version/V20.php +++ b/src/Appwrite/Migration/Version/V20.php @@ -2,7 +2,6 @@ namespace Appwrite\Migration\Version; -use Appwrite\Auth\Auth; use Appwrite\Migration\Migration; use Exception; use PDOException; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index 0b3d0e9fb4..9f91456be9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Attribute; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use InvalidArgumentException; use Utopia\Database\Database; @@ -89,8 +89,8 @@ class Decrement extends Action public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan): void { - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index ef64099fa8..3b6b3e83e4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Attribute; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use InvalidArgumentException; use Utopia\Database\Database; @@ -89,8 +89,8 @@ class Increment extends Action public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan): void { - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index f53ba16032..190cfd0b36 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index a4cef59e5f..6abf8ba149 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; -use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -100,8 +100,8 @@ class Delete extends Action ): void { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::DATABASE_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index 3d8cd9198c..95d5514fda 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; -use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; @@ -11,6 +10,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Exception\Query as QueryException; @@ -74,8 +74,8 @@ class Get extends Action public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState): void { - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index ed83f3fdd3..4d7283d7a1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; -use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -99,8 +99,8 @@ class Update extends Action $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::DATABASE_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index 8d47410649..1869d08742 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; -use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; @@ -105,8 +105,8 @@ class Upsert extends Action throw new Exception($this->getMissingPayloadException()); } - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index f159043531..2614015022 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; -use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; @@ -11,6 +10,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -78,8 +78,8 @@ class XList extends Action public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState): void { - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php index bd94c1c7eb..61da52e830 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\Operations; -use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\Action; @@ -10,6 +9,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\Operation; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; @@ -72,8 +72,8 @@ class Create extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty'); } - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); // API keys and admins can read any transaction, regular users need permissions $transaction = ($isAPIKey || $isPrivilegedUser) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index 6e2bd63827..bbe2cba262 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Transactions; -use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -12,6 +11,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -111,8 +111,8 @@ class Update extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time'); } - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); $transaction = ($isAPIKey || $isPrivilegedUser) ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) @@ -228,13 +228,12 @@ class Update extends Action ->setType(DELETE_TYPE_DOCUMENT) ->setDocument($transaction); }); - } catch (NotFoundException $e) { Authorization::skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([ 'status' => 'failed', ]))); throw new Exception(Exception::DOCUMENT_NOT_FOUND, previous: $e); - } catch (DuplicateException|ConflictException $e) { + } catch (DuplicateException | ConflictException $e) { Authorization::skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([ 'status' => 'failed', ]))); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 69af3b7d04..f85543fd20 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -15,6 +15,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Executor\Executor; use MaxMind\Db\Reader; @@ -154,8 +155,8 @@ class Create extends Base $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php index 42d78f8ca8..654d0c7dbf 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -2,12 +2,12 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions; -use Appwrite\Auth\Auth; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; @@ -63,8 +63,8 @@ class Get extends Base ) { $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php index 3d5ec06ace..8033c1d41d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -2,12 +2,12 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions; -use Appwrite\Auth\Auth; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\Queries\Executions; use Appwrite\Utopia\Response; use Utopia\Database\Database; @@ -72,8 +72,8 @@ class XList extends Base ) { $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php index 5708f1b83b..f79dece530 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Tokens\Http\Tokens\Buckets\Files; -use Appwrite\Auth\Auth; use Appwrite\Extend\Exception; +use Appwrite\Utopia\Database\Documents\User; use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Action as UtopiaAction; @@ -14,8 +14,8 @@ class Action extends UtopiaAction { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAPIKey = User::isApp(Authorization::getRoles()); + $isPrivilegedUser = User::isPrivileged(Authorization::getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 558f0cdf09..ce570d2af9 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -2,8 +2,8 @@ namespace Appwrite\Utopia; -use Appwrite\Auth\Auth; use Appwrite\SDK\Method; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request\Filter; use Swoole\Http\Request as SwooleRequest; use Utopia\Database\Validator\Authorization; @@ -199,19 +199,19 @@ class Request extends UtopiaRequest } /** - * Get User Agent - * - * Method for getting User Agent. Preferring forwarded agent for privileged users; otherwise returns default. - * - * @param string $default - * @return string - */ + * Get User Agent + * + * Method for getting User Agent. Preferring forwarded agent for privileged users; otherwise returns default. + * + * @param string $default + * @return string + */ public function getUserAgent(string $default = ''): string { $forwardedUserAgent = $this->getHeader('x-forwarded-user-agent'); if (!empty($forwardedUserAgent)) { $roles = Authorization::getRoles(); - $isAppUser = Auth::isAppUser($roles); + $isAppUser = User::isApp($roles); if ($isAppUser) { return $forwardedUserAgent; diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index b391f0ea72..f89e342c06 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -2,7 +2,6 @@ namespace Appwrite\Utopia; -use Appwrite\Auth\Auth; use Appwrite\Utopia\Fetch\BodyMultipart; use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Model; @@ -810,8 +809,8 @@ class Response extends SwooleResponse if ($rule['sensitive']) { $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); + $isPrivilegedUser = User::isPrivileged($roles); + $isAppUser = User::isApp($roles); if ((!$isPrivilegedUser && !$isAppUser) && !self::$showSensitive) { $data->setAttribute($key, ''); From dff7aa6ce0ba62bb84ef5c0f74dae291c2b52a53 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 07:12:42 +0000 Subject: [PATCH 11/47] format --- app/config/console.php | 1 - app/config/roles.php | 1 - app/controllers/api/graphql.php | 1 - app/controllers/api/projects.php | 1 - app/controllers/api/storage.php | 1 - app/controllers/api/teams.php | 23 +++++++++++------------ app/controllers/general.php | 2 +- app/controllers/shared/api.php | 1 - app/controllers/shared/api/auth.php | 1 - app/init/resources.php | 1 - app/realtime.php | 1 - 11 files changed, 12 insertions(+), 22 deletions(-) diff --git a/app/config/console.php b/app/config/console.php index 0ac46ee418..5c4bf87614 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -4,7 +4,6 @@ * Initializes console project document. */ -use Appwrite\Auth\Auth; use Appwrite\Network\Platform; use Utopia\Database\Helpers\ID; use Utopia\System\System; diff --git a/app/config/roles.php b/app/config/roles.php index 39e5ac2fd9..3bf2297550 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -1,6 +1,5 @@ $dbForProject->createDocument('teams', new Document([ + $team = Authorization::skip(fn () => $dbForProject->createDocument('teams', new Document([ '$id' => $teamId, '$permissions' => [ Permission::read(Role::team($teamId)), @@ -483,7 +482,7 @@ App::post('/v1/teams/:teamId/memberships') } return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); }, 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) - ->param('url', '', fn($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) // TODO add our own built-in confirm page + ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) // TODO add our own built-in confirm page ->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true) ->inject('response') ->inject('project') @@ -572,7 +571,7 @@ App::post('/v1/teams/:teamId/memberships') try { $userId = ID::unique(); $hash = $proofForPassword->hash($proofForPassword->generate()); - $invitee = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([ + $invitee = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ '$id' => $userId, '$permissions' => [ Permission::read(Role::any()), @@ -644,11 +643,11 @@ App::post('/v1/teams/:teamId/memberships') ]); $membership = ($isPrivilegedUser || $isAppUser) ? - Authorization::skip(fn() => $dbForProject->createDocument('memberships', $membership)) : + Authorization::skip(fn () => $dbForProject->createDocument('memberships', $membership)) : $dbForProject->createDocument('memberships', $membership); if ($isPrivilegedUser || $isAppUser) { - Authorization::skip(fn() => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); + Authorization::skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); } } elseif ($membership->getAttribute('confirm') === false) { $membership->setAttribute('secret', $proofForToken->hash($secret)); @@ -660,7 +659,7 @@ App::post('/v1/teams/:teamId/memberships') } $membership = ($isPrivilegedUser || $isAppUser) ? - Authorization::skip(fn() => $dbForProject->updateDocument('memberships', $membership->getId(), $membership)) : + Authorization::skip(fn () => $dbForProject->updateDocument('memberships', $membership->getId(), $membership)) : $dbForProject->updateDocument('memberships', $membership->getId(), $membership); } else { throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED); @@ -907,7 +906,7 @@ App::get('/v1/teams/:teamId/memberships') } - $memberships = array_filter($memberships, fn(Document $membership) => !empty($membership->getAttribute('userId'))); + $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, @@ -1200,7 +1199,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') throw new Exception(Exception::MEMBERSHIP_NOT_FOUND); } - $team = Authorization::skip(fn() => $dbForProject->getDocument('teams', $teamId)); + $team = Authorization::skip(fn () => $dbForProject->getDocument('teams', $teamId)); if ($team->isEmpty()) { throw new Exception(Exception::TEAM_NOT_FOUND); @@ -1236,7 +1235,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ->setAttribute('confirm', true) ; - Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true))); + Authorization::skip(fn () => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true))); // Create session for the user if not logged in if (!$hasSession) { @@ -1306,7 +1305,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') $dbForProject->purgeCachedDocument('users', $user->getId()); - Authorization::skip(fn() => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); + Authorization::skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); $queueForEvents ->setParam('userId', $user->getId()) @@ -1409,7 +1408,7 @@ App::delete('/v1/teams/:teamId/memberships/:membershipId') $dbForProject->purgeCachedDocument('users', $profile->getId()); if ($membership->getAttribute('confirm')) { // Count only confirmed members - Authorization::skip(fn() => $dbForProject->decreaseDocumentAttribute('teams', $team->getId(), 'total', 1, 0)); + Authorization::skip(fn () => $dbForProject->decreaseDocumentAttribute('teams', $team->getId(), 'total', 1, 0)); } $queueForEvents diff --git a/app/controllers/general.php b/app/controllers/general.php index f6d51c1079..d5b764091f 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -17,6 +17,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Transformation\Adapter\Preview; use Appwrite\Transformation\Transformation; +use Appwrite\Utopia\Database\Documents\User as DBUser; use Appwrite\Utopia\Request; use Appwrite\Utopia\Request\Filters\V16 as RequestV16; use Appwrite\Utopia\Request\Filters\V17 as RequestV17; @@ -47,7 +48,6 @@ use Utopia\Locale\Locale; use Utopia\Logger\Adapter\Sentry; use Utopia\Logger\Log; use Utopia\Logger\Log\User; -use Appwrite\Utopia\Database\Documents\User as DBUser; use Utopia\Logger\Logger; use Utopia\Platform\Service; use Utopia\System\System; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5fd693fe62..8a37e6d25a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -1,6 +1,5 @@ Date: Tue, 4 Nov 2025 07:41:42 +0000 Subject: [PATCH 12/47] remove auth test and add user test --- tests/unit/Auth/AuthTest.php | 502 ------------------ .../Utopia/Database/Documents/UserTest.php | 352 ++++++++++++ 2 files changed, 352 insertions(+), 502 deletions(-) delete mode 100644 tests/unit/Auth/AuthTest.php create mode 100644 tests/unit/Utopia/Database/Documents/UserTest.php diff --git a/tests/unit/Auth/AuthTest.php b/tests/unit/Auth/AuthTest.php deleted file mode 100644 index 705da42879..0000000000 --- a/tests/unit/Auth/AuthTest.php +++ /dev/null @@ -1,502 +0,0 @@ -toString()); - } - - public function testCookieName(): void - { - $name = 'cookie-name'; - - $this->assertEquals(Auth::setCookieName($name), $name); - $this->assertEquals(Auth::$cookieName, $name); - } - - public function testEncodeDecodeSession(): void - { - $id = 'id'; - $secret = 'secret'; - $session = 'eyJpZCI6ImlkIiwic2VjcmV0Ijoic2VjcmV0In0='; - - $this->assertEquals(Auth::encodeSession($id, $secret), $session); - $this->assertEquals(Auth::decodeSession($session), ['id' => $id, 'secret' => $secret]); - } - - public function testHash(): void - { - $secret = 'secret'; - $this->assertEquals(Auth::hash($secret), '2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b'); - } - - public function testPassword(): void - { - /* - General tests, using pre-defined hashes generated by online tools - */ - - // Bcrypt - Version Y - $plain = 'secret'; - $hash = '$2y$08$PDbMtV18J1KOBI9tIYabBuyUwBrtXPGhLxCy9pWP6xkldVOKLrLKy'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Bcrypt - Version A - $plain = 'test123'; - $hash = '$2a$12$3f2ZaARQ1AmhtQWx2nmQpuXcWfTj1YV2/Hl54e8uKxIzJe3IfwLiu'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Bcrypt - Cost 5 - $plain = 'hello-world'; - $hash = '$2a$05$IjrtSz6SN7UJ6Sh3l.b5jODEvEG2LMJTPAHIaLWRvlWx7if3VMkFO'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Bcrypt - Cost 15 - $plain = 'super-secret-password'; - $hash = '$2a$15$DS0ZzbsFZYumH/E4Qj5oeOHnBcM3nCCsCA2m4Goigat/0iMVQC4Na'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // MD5 - Short - $plain = 'appwrite'; - $hash = '144fa7eaa4904e8ee120651997f70dcc'; - $generatedHash = Auth::passwordHash($plain, 'md5'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'md5')); - - // MD5 - Long - $plain = 'AppwriteIsAwesomeBackendAsAServiceThatIsAlsoOpenSourced'; - $hash = '8410e96cf7ac64e0b84c3f8517a82616'; - $generatedHash = Auth::passwordHash($plain, 'md5'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'md5')); - - // PHPass - $plain = 'pass123'; - $hash = '$P$BVKPmJBZuLch27D4oiMRTEykGLQ9tX0'; - $generatedHash = Auth::passwordHash($plain, 'phpass'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'phpass')); - - // SHA - $plain = 'developersAreAwesome!'; - $hash = '2455118438cb125354b89bb5888346e9bd23355462c40df393fab514bf2220b5a08e4e2d7b85d7327595a450d0ac965cc6661152a46a157c66d681bed20a4735'; - $generatedHash = Auth::passwordHash($plain, 'sha'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'sha')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'sha')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'sha')); - - // Argon2 - $plain = 'safe-argon-password'; - $hash = '$argon2id$v=19$m=2048,t=3,p=4$MWc5NWRmc2QxZzU2$41mp7rSgBZ49YxLbbxIac7aRaxfp5/e1G45ckwnK0g8'; - $generatedHash = Auth::passwordHash($plain, 'argon2'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'argon2')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'argon2')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'argon2')); - - // Scrypt - $plain = 'some-scrypt-password'; - $hash = 'b448ad7ba88b653b5b56b8053a06806724932d0751988bc9cd0ef7ff059e8ba8a020e1913b7069a650d3f99a1559aba0221f2c277826919513a054e76e339028'; - $generatedHash = Auth::passwordHash($plain, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2]); - - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - $this->assertEquals(false, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-wrong-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - $this->assertEquals(false, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 10, 'costParallel' => 2])); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - - // ScryptModified tested are in provider-specific tests below - - /* - Provider-specific tests, ensuring functionality of specific use-cases - */ - - // Provider #1 (Database) - $plain = 'example-password'; - $hash = '$2a$10$3bIGRWUes86CICsuchGLj.e.BqdCdg2/1Ud9LvBhJr0j7Dze8PBdS'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Provider #2 (Blog) - $plain = 'your-password'; - $hash = '$P$BkiNDJTpAWXtpaMhEUhUdrv7M0I1g6.'; - $generatedHash = Auth::passwordHash($plain, 'phpass'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'phpass')); - - // Provider #2 (Google) - $plain = 'users-password'; - $hash = 'EPKgfALpS9Tvgr/y1ki7ubY4AEGJeWL3teakrnmOacN4XGiyD00lkzEHgqCQ71wGxoi/zb7Y9a4orOtvMV3/Jw=='; - $salt = '56dFqW+kswqktw=='; - $saltSeparator = 'Bw=='; - $signerKey = 'XyEKE9RcTDeLEsL/RjwPDBv/RqDl8fb3gpYEOQaPihbxf1ZAtSOHCjuAAa7Q3oHpCYhXSN9tizHgVOwn6krflQ=='; - - $options = [ 'salt' => $salt, 'saltSeparator' => $saltSeparator, 'signerKey' => $signerKey ]; - $generatedHash = Auth::passwordHash($plain, 'scryptMod', $options); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'scryptMod', $options)); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'scryptMod', $options)); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'scryptMod', $options)); - } - - public function testUnknownAlgo() - { - $this->expectExceptionMessage('Hashing algorithm \'md8\' is not supported.'); - - // Bcrypt - Cost 5 - $plain = 'whatIsMd8?!?'; - $generatedHash = Auth::passwordHash($plain, 'md8'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md8')); - } - - public function testPasswordGenerator(): void - { - $this->assertEquals(\mb_strlen(Auth::passwordGenerator()), 40); - $this->assertEquals(\mb_strlen(Auth::passwordGenerator(5)), 10); - } - - public function testTokenGenerator(): void - { - $this->assertEquals(\strlen(Auth::tokenGenerator()), 256); - $this->assertEquals(\strlen(Auth::tokenGenerator(5)), 5); - } - - public function testCodeGenerator(): void - { - $this->assertEquals(6, \strlen(Auth::codeGenerator())); - $this->assertEquals(\mb_strlen(Auth::codeGenerator(256)), 256); - $this->assertEquals(\mb_strlen(Auth::codeGenerator(10)), 10); - $this->assertTrue(is_numeric(Auth::codeGenerator(5))); - } - - public function testSessionVerify(): void - { - $expireTime1 = 60 * 60 * 24; - - $secret = 'secret1'; - $hash = Auth::hash($secret); - $tokens1 = [ - new Document([ - '$id' => ID::custom('token1'), - 'secret' => $hash, - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), - ]), - new Document([ - '$id' => ID::custom('token2'), - 'secret' => 'secret2', - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), - ]), - ]; - - $expireTime2 = -60 * 60 * 24; - - $tokens2 = [ - new Document([ // Correct secret and type time, wrong expire time - '$id' => ID::custom('token1'), - 'secret' => $hash, - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), - ]), - new Document([ - '$id' => ID::custom('token2'), - 'secret' => 'secret2', - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), - ]), - ]; - - $this->assertEquals(Auth::sessionVerify($tokens1, $secret), 'token1'); - $this->assertEquals(Auth::sessionVerify($tokens1, 'false-secret'), false); - $this->assertEquals(Auth::sessionVerify($tokens2, $secret), false); - $this->assertEquals(Auth::sessionVerify($tokens2, 'false-secret'), false); - } - - public function testTokenVerify(): void - { - $secret = 'secret1'; - $hash = Auth::hash($secret); - $tokens1 = [ - new Document([ - '$id' => ID::custom('token1'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), - 'secret' => $hash, - ]), - new Document([ - '$id' => ID::custom('token2'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => 'secret2', - ]), - ]; - - $tokens2 = [ - new Document([ // Correct secret and type time, wrong expire time - '$id' => ID::custom('token1'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => $hash, - ]), - new Document([ - '$id' => ID::custom('token2'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => 'secret2', - ]), - ]; - - $tokens3 = [ // Correct secret and expire time, wrong type - new Document([ - '$id' => ID::custom('token1'), - 'type' => Auth::TOKEN_TYPE_INVITE, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), - 'secret' => $hash, - ]), - new Document([ - '$id' => ID::custom('token2'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => 'secret2', - ]), - ]; - - $this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, $secret), $tokens1[0]); - $this->assertEquals(Auth::tokenVerify($tokens1, null, $secret), $tokens1[0]); - $this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false); - $this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, $secret), false); - $this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false); - $this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, $secret), false); - $this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false); - } - - public function testIsPrivilegedUser(): void - { - $this->assertEquals(false, Auth::isPrivilegedUser([])); - $this->assertEquals(false, Auth::isPrivilegedUser([Role::guests()->toString()])); - $this->assertEquals(false, Auth::isPrivilegedUser([Role::users()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_ADMIN])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_DEVELOPER])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER])); - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_SYSTEM])); - - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS, Auth::USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER, Auth::USER_ROLE_ADMIN, Auth::USER_ROLE_DEVELOPER])); - } - - public function testIsAppUser(): void - { - $this->assertEquals(false, Auth::isAppUser([])); - $this->assertEquals(false, Auth::isAppUser([Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Role::users()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_ADMIN])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_DEVELOPER])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER])); - $this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_SYSTEM])); - - $this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS, Auth::USER_ROLE_APPS])); - $this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER, Auth::USER_ROLE_ADMIN, Auth::USER_ROLE_DEVELOPER])); - } - - public function testGuestRoles(): void - { - $user = new Document([ - '$id' => '' - ]); - - $roles = Auth::getRoles($user); - $this->assertCount(1, $roles); - $this->assertContains(Role::guests()->toString(), $roles); - } - - public function testUserRoles(): void - { - $user = new Document([ - '$id' => ID::custom('123'), - 'labels' => [ - 'vip', - 'admin' - ], - 'emailVerification' => true, - 'phoneVerification' => true, - 'memberships' => [ - [ - '$id' => ID::custom('456'), - 'teamId' => ID::custom('abc'), - 'confirm' => true, - 'roles' => [ - 'administrator', - 'moderator' - ] - ], - [ - '$id' => ID::custom('abc'), - 'teamId' => ID::custom('def'), - 'confirm' => true, - 'roles' => [ - 'guest' - ] - ] - ] - ]); - - $roles = Auth::getRoles($user); - - $this->assertCount(13, $roles); - $this->assertContains(Role::users()->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'))->toString(), $roles); - $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); - $this->assertContains(Role::member(ID::custom('456'))->toString(), $roles); - $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); - $this->assertContains('label:vip', $roles); - $this->assertContains('label:admin', $roles); - - // Disable all verification - $user['emailVerification'] = false; - $user['phoneVerification'] = false; - - $roles = Auth::getRoles($user); - $this->assertContains(Role::users(Roles::DIMENSION_UNVERIFIED)->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_UNVERIFIED)->toString(), $roles); - - // Enable single verification type - $user['emailVerification'] = true; - - $roles = Auth::getRoles($user); - $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); - } - - public function testPrivilegedUserRoles(): void - { - Authorization::setRole(Auth::USER_ROLE_OWNER); - $user = new Document([ - '$id' => ID::custom('123'), - 'emailVerification' => true, - 'phoneVerification' => true, - 'memberships' => [ - [ - '$id' => ID::custom('def'), - 'teamId' => ID::custom('abc'), - 'confirm' => true, - 'roles' => [ - 'administrator', - 'moderator' - ] - ], - [ - '$id' => ID::custom('abc'), - 'teamId' => ID::custom('def'), - 'confirm' => true, - 'roles' => [ - 'guest' - ] - ] - ] - ]); - - $roles = Auth::getRoles($user); - - $this->assertCount(7, $roles); - $this->assertNotContains(Role::users()->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); - $this->assertNotContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); - $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); - } - - public function testAppUserRoles(): void - { - Authorization::setRole(Auth::USER_ROLE_APPS); - $user = new Document([ - '$id' => ID::custom('123'), - 'memberships' => [ - [ - '$id' => ID::custom('def'), - 'teamId' => ID::custom('abc'), - 'confirm' => true, - 'roles' => [ - 'administrator', - 'moderator' - ] - ], - [ - '$id' => ID::custom('abc'), - 'teamId' => ID::custom('def'), - 'confirm' => true, - 'roles' => [ - 'guest' - ] - ] - ] - ]); - - $roles = Auth::getRoles($user); - - $this->assertCount(7, $roles); - $this->assertNotContains(Role::users()->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); - $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); - } -} diff --git a/tests/unit/Utopia/Database/Documents/UserTest.php b/tests/unit/Utopia/Database/Documents/UserTest.php new file mode 100644 index 0000000000..4c13042dcc --- /dev/null +++ b/tests/unit/Utopia/Database/Documents/UserTest.php @@ -0,0 +1,352 @@ +toString()); + } + + public function testSessionVerify(): void + { + $proofForToken = new Token(); + $expireTime1 = 60 * 60 * 24; + + $secret = 'secret1'; + $hash = $proofForToken->hash($secret); + $tokens1 = [ + new Document([ + '$id' => ID::custom('token1'), + 'secret' => $hash, + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), + ]), + new Document([ + '$id' => ID::custom('token2'), + 'secret' => 'secret2', + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), + ]), + ]; + + $expireTime2 = -60 * 60 * 24; + + $tokens2 = [ + new Document([ // Correct secret and type time, wrong expire time + '$id' => ID::custom('token1'), + 'secret' => $hash, + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), + ]), + new Document([ + '$id' => ID::custom('token2'), + 'secret' => 'secret2', + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), + ]), + ]; + + $user1 = new User([ + '$id' => ID::custom('user1'), + 'tokens' => $tokens1, + + ]); + + $user2 = new User([ + '$id' => ID::custom('user2'), + 'tokens' => $tokens2, + ]); + + $this->assertEquals($user1->sessionVerify($secret, $proofForToken), 'token1'); + $this->assertEquals($user1->sessionVerify('false-secret', $proofForToken), false); + $this->assertEquals($user2->sessionVerify($secret, $proofForToken), false); + $this->assertEquals($user2->sessionVerify('false-secret', $proofForToken), false); + } + + public function testTokenVerify(): void + { + $proofForToken = new Token(); + $secret = 'secret1'; + $hash = $proofForToken->hash($secret); + $tokens1 = [ + new Document([ + '$id' => ID::custom('token1'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), + 'secret' => $hash, + ]), + new Document([ + '$id' => ID::custom('token2'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => 'secret2', + ]), + ]; + + $tokens2 = [ + new Document([ // Correct secret and type time, wrong expire time + '$id' => ID::custom('token1'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => $hash, + ]), + new Document([ + '$id' => ID::custom('token2'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => 'secret2', + ]), + ]; + + $tokens3 = [ // Correct secret and expire time, wrong type + new Document([ + '$id' => ID::custom('token1'), + 'type' => TOKEN_TYPE_INVITE, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), + 'secret' => $hash, + ]), + new Document([ + '$id' => ID::custom('token2'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => 'secret2', + ]), + ]; + + $user1 = new User([ + '$id' => ID::custom('user1'), + 'tokens' => $tokens1, + ]); + + $user2 = new User([ + '$id' => ID::custom('user2'), + 'tokens' => $tokens2, + ]); + + $user3 = new User([ + '$id' => ID::custom('user3'), + 'tokens' => $tokens3, + ]); + + $this->assertEquals($user1->tokenVerify(TOKEN_TYPE_RECOVERY, $secret, $proofForToken), $tokens1[0]); + $this->assertEquals($user1->tokenVerify(null, $secret, $proofForToken), $tokens1[0]); + $this->assertEquals($user1->tokenVerify(TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); + $this->assertEquals($user2->tokenVerify(TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false); + $this->assertEquals($user2->tokenVerify(TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); + $this->assertEquals($user3->tokenVerify(TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false); + $this->assertEquals($user3->tokenVerify(TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); + } + + public function testIsPrivilegedUser(): void + { + $this->assertEquals(false, User::isPrivileged([])); + $this->assertEquals(false, User::isPrivileged([Role::guests()->toString()])); + $this->assertEquals(false, User::isPrivileged([Role::users()->toString()])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_ADMIN])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_DEVELOPER])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER])); + $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS])); + $this->assertEquals(false, User::isPrivileged([User::ROLE_SYSTEM])); + + $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + } + + public function testIsAppUser(): void + { + $this->assertEquals(false, User::isApp([])); + $this->assertEquals(false, User::isApp([Role::guests()->toString()])); + $this->assertEquals(false, User::isApp([Role::users()->toString()])); + $this->assertEquals(false, User::isApp([User::ROLE_ADMIN])); + $this->assertEquals(false, User::isApp([User::ROLE_DEVELOPER])); + $this->assertEquals(false, User::isApp([User::ROLE_OWNER])); + $this->assertEquals(true, User::isApp([User::ROLE_APPS])); + $this->assertEquals(false, User::isApp([User::ROLE_SYSTEM])); + + $this->assertEquals(true, User::isApp([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(true, User::isApp([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(false, User::isApp([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(false, User::isApp([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + } + + public function testGuestRoles(): void + { + $user = new User([ + '$id' => '' + ]); + + $roles = $user->getRoles(); + $this->assertCount(1, $roles); + $this->assertContains(Role::guests()->toString(), $roles); + } + + public function testUserRoles(): void + { + $user = new User([ + '$id' => ID::custom('123'), + 'labels' => [ + 'vip', + 'admin' + ], + 'emailVerification' => true, + 'phoneVerification' => true, + 'memberships' => [ + [ + '$id' => ID::custom('456'), + 'teamId' => ID::custom('abc'), + 'confirm' => true, + 'roles' => [ + 'administrator', + 'moderator' + ] + ], + [ + '$id' => ID::custom('abc'), + 'teamId' => ID::custom('def'), + 'confirm' => true, + 'roles' => [ + 'guest' + ] + ] + ] + ]); + + $roles = $user->getRoles(); + + $this->assertCount(13, $roles); + $this->assertContains(Role::users()->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'))->toString(), $roles); + $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); + $this->assertContains(Role::member(ID::custom('456'))->toString(), $roles); + $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); + $this->assertContains('label:vip', $roles); + $this->assertContains('label:admin', $roles); + + // Disable all verification + $user['emailVerification'] = false; + $user['phoneVerification'] = false; + + $roles = $user->getRoles(); + $this->assertContains(Role::users(Roles::DIMENSION_UNVERIFIED)->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_UNVERIFIED)->toString(), $roles); + + // Enable single verification type + $user['emailVerification'] = true; + + $roles = $user->getRoles(); + $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); + } + + public function testPrivilegedUserRoles(): void + { + Authorization::setRole(User::ROLE_OWNER); + $user = new User([ + '$id' => ID::custom('123'), + 'emailVerification' => true, + 'phoneVerification' => true, + 'memberships' => [ + [ + '$id' => ID::custom('def'), + 'teamId' => ID::custom('abc'), + 'confirm' => true, + 'roles' => [ + 'administrator', + 'moderator' + ] + ], + [ + '$id' => ID::custom('abc'), + 'teamId' => ID::custom('def'), + 'confirm' => true, + 'roles' => [ + 'guest' + ] + ] + ] + ]); + + $roles = $user->getRoles(); + + $this->assertCount(7, $roles); + $this->assertNotContains(Role::users()->toString(), $roles); + $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); + $this->assertNotContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertNotContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); + $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); + } + + public function testAppUserRoles(): void + { + Authorization::setRole(User::ROLE_APPS); + $user = new User([ + '$id' => ID::custom('123'), + 'memberships' => [ + [ + '$id' => ID::custom('def'), + 'teamId' => ID::custom('abc'), + 'confirm' => true, + 'roles' => [ + 'administrator', + 'moderator' + ] + ], + [ + '$id' => ID::custom('abc'), + 'teamId' => ID::custom('def'), + 'confirm' => true, + 'roles' => [ + 'guest' + ] + ] + ] + ]); + + $roles = $user->getRoles(); + + $this->assertCount(7, $roles); + $this->assertNotContains(Role::users()->toString(), $roles); + $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); + $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); + } +} From 4b15fec1d776c9b1b5dde37ce984f6b6cf811ff0 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 07:51:03 +0000 Subject: [PATCH 13/47] Remove further auth static methods --- .../Modules/Functions/Http/Executions/Create.php | 9 +++++++-- .../Tokens/Http/Tokens/Buckets/Files/Create.php | 4 ++-- src/Appwrite/Platform/Tasks/Install.php | 9 ++++++--- src/Appwrite/Platform/Workers/Audits.php | 3 +-- src/Appwrite/Platform/Workers/Deletes.php | 3 +-- src/Appwrite/Utopia/Response/Model/Project.php | 4 ++-- .../Projects/ProjectsConsoleClientTest.php | 7 +++---- tests/unit/Auth/KeyTest.php | 6 +++--- tests/unit/Messaging/MessagingChannelsTest.php | 15 +++++++-------- 9 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index f85543fd20..40d6197f35 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions; use Ahc\Jwt\JWT; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\StatsUsage; @@ -19,6 +18,8 @@ use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Executor\Executor; use MaxMind\Db\Reader; +use Utopia\Auth\Proofs\Token; +use Utopia\Auth\Store; use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Database\Database; @@ -93,6 +94,8 @@ class Create extends Base ->inject('queueForStatsUsage') ->inject('queueForFunctions') ->inject('geodb') + ->inject('store') + ->inject('proofForToken') ->inject('executor') ->callback($this->action(...)); } @@ -115,6 +118,8 @@ class Create extends Base StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, + Store $store, + Token $proofForToken, Executor $executor ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -199,7 +204,7 @@ class Create extends Base foreach ($sessions as $session) { /** @var Utopia\Database\Document $session */ - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too + if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { // Find most recent active session for user ID and JWT headers $current = $session; } } diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php index fe7a0187e9..8c36e3b517 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Tokens\Http\Tokens\Buckets\Files; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; @@ -10,6 +9,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Utopia\Auth\Proofs\Token; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; @@ -92,7 +92,7 @@ class Create extends Action $token = $dbForProject->createDocument('resourceTokens', new Document([ '$id' => ID::unique(), - 'secret' => Auth::tokenGenerator(128), + 'secret' => (new Token(128))->generate(), 'resourceId' => $bucketId . ':' . $fileId, 'resourceInternalId' => $bucket->getSequence() . ':' . $file->getSequence(), 'resourceType' => TOKENS_RESOURCE_TYPE_FILES, diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index c3b4e33593..b210a020b9 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -2,10 +2,11 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Auth\Auth; use Appwrite\Docker\Compose; use Appwrite\Docker\Env; use Appwrite\Utopia\View; +use Utopia\Auth\Proofs\Password; +use Utopia\Auth\Proofs\Token; use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Platform\Action; @@ -149,6 +150,8 @@ class Install extends Action $input = []; + $password = new Password(); + $token = new Token(); foreach ($vars as $var) { if (!empty($var['filter']) && ($interactive !== 'Y' || !Console::isInteractive())) { if ($data && $var['default'] !== null) { @@ -157,12 +160,12 @@ class Install extends Action } if ($var['filter'] === 'token') { - $input[$var['name']] = Auth::tokenGenerator(); + $input[$var['name']] = $token->generate(); continue; } if ($var['filter'] === 'password') { - $input[$var['name']] = Auth::passwordGenerator(); + $input[$var['name']] = $password->generate(); continue; } } diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index a88e2e641f..be542e7811 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Workers; -use Appwrite\Auth\Auth; use Exception; use Throwable; use Utopia\Audit\Audit; @@ -85,7 +84,7 @@ class Audits extends Action $userName = $user->getAttribute('name', ''); $userEmail = $user->getAttribute('email', ''); - $userType = $user->getAttribute('type', Auth::ACTIVITY_TYPE_USER); + $userType = $user->getAttribute('type', ACTIVITY_TYPE_USER); // Create event data $eventData = [ diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 331a2668a3..1c146a335e 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Workers; -use Appwrite\Auth\Auth; use Appwrite\Certificates\Adapter as CertificatesAdapter; use Appwrite\Deletes\Identities; use Appwrite\Deletes\Targets; @@ -708,7 +707,7 @@ class Deletes extends Action private function deleteExpiredSessions(Document $project, callable $getProjectDB): void { $dbForProject = $getProjectDB($project); - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $expired = DateTime::addSeconds(new \DateTime(), -1 * $duration); // Delete Sessions diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index abe67e7e86..8ee3a2bdb6 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -105,7 +105,7 @@ class Project extends Model ->addRule('authDuration', [ 'type' => self::TYPE_INTEGER, 'description' => 'Session duration in seconds.', - 'default' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, + 'default' => TOKEN_EXPIRATION_LOGIN_LONG, 'example' => 60, ]) ->addRule('authLimit', [ @@ -372,7 +372,7 @@ class Project extends Model $auth = Config::getParam('auth', []); $document->setAttribute('authLimit', $authValues['limit'] ?? 0); - $document->setAttribute('authDuration', $authValues['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG); + $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG); $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 4e479344d3..91dce5c09c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2,7 +2,6 @@ namespace Tests\E2E\Services\Projects; -use Appwrite\Auth\Auth; use Appwrite\Extend\Exception; use Appwrite\Tests\Async; use Tests\E2E\Client; @@ -866,7 +865,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(Auth::TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year + $this->assertEquals(TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year /** * Test for SUCCESS @@ -1009,7 +1008,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -1022,7 +1021,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(Auth::TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year + $this->assertEquals(TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year return ['projectId' => $projectId]; } diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 8ae2114697..920608e82f 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -3,8 +3,8 @@ namespace Tests\Unit\Auth; use Ahc\Jwt\JWT; -use Appwrite\Auth\Auth; use Appwrite\Auth\Key; +use Appwrite\Utopia\Database\Documents\User; use PHPUnit\Framework\TestCase; use Utopia\Config\Config; use Utopia\Database\Document; @@ -21,7 +21,7 @@ class KeyTest extends TestCase 'collections.read', 'documents.read', ]; - $roleScopes = Config::getParam('roles', [])[Auth::USER_ROLE_APPS]['scopes']; + $roleScopes = Config::getParam('roles', [])[User::ROLE_APPS]['scopes']; $key = static::generateKey($projectId, $usage, $scopes); $project = new Document(['$id' => $projectId,]); @@ -29,7 +29,7 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); - $this->assertEquals(Auth::USER_ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); } diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php index 8ba0374093..42e433568f 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -2,10 +2,9 @@ namespace Tests\Unit\Messaging; -use Appwrite\Auth\Auth; use Appwrite\Messaging\Adapter\Realtime; +use Appwrite\Utopia\Database\Documents\User; use PHPUnit\Framework\TestCase; -use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; @@ -50,7 +49,7 @@ class MessagingChannelsTest extends TestCase */ for ($i = 0; $i < $this->connectionsPerChannel; $i++) { foreach ($this->allChannels as $index => $channel) { - $user = new Document([ + $user = new User([ '$id' => ID::custom('user' . $this->connectionsCount), 'memberships' => [ [ @@ -59,14 +58,14 @@ class MessagingChannelsTest extends TestCase 'confirm' => true, 'roles' => [ empty($index % 2) - ? Auth::USER_ROLE_ADMIN + ? User::ROLE_ADMIN : 'member', ] ] ] ]); - $roles = Auth::getRoles($user); + $roles = $user->getRoles(); $parsedChannels = Realtime::convertChannels([0 => $channel], $user->getId()); @@ -86,11 +85,11 @@ class MessagingChannelsTest extends TestCase */ for ($i = 0; $i < $this->connectionsPerChannel; $i++) { foreach ($this->allChannels as $index => $channel) { - $user = new Document([ + $user = new User([ '$id' => '' ]); - $roles = Auth::getRoles($user); + $roles = $user->getRoles(); $parsedChannels = Realtime::convertChannels([0 => $channel], $user->getId()); @@ -294,7 +293,7 @@ class MessagingChannelsTest extends TestCase } $role = empty($index % 2) - ? Auth::USER_ROLE_ADMIN + ? User::ROLE_ADMIN : 'member'; $permissions = [ From 81480f4492a837f5fcd9790c7c6f853dfb5e1353 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 07:53:14 +0000 Subject: [PATCH 14/47] remove deprecated methods --- src/Appwrite/Auth/Auth.php | 183 ------------------------------------- 1 file changed, 183 deletions(-) diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 82bac36a79..1af4ea3a04 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -45,187 +45,4 @@ class Auth return SESSION_PROVIDER_TOKEN; } } - - /** - * Encode. - * - * One-way encryption - * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param $string - * - * @return string - */ - public static function hash(string $string) - { - return \hash('sha256', $string); - } - - /** - * Token Generator. - * - * Generate random password string - * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param int $length Length of returned token - * - * @return string - */ - public static function tokenGenerator(int $length = 256): string - { - if ($length <= 0) { - throw new \Exception('Token length must be greater than 0'); - } - - $bytesLength = (int) ceil($length / 2); - $token = \bin2hex(\random_bytes($bytesLength)); - - return substr($token, 0, $length); - } - - /** - * Verify token and check that its not expired. - * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param array $tokens - * @param int $type Type of token to verify, if null will verify any type - * @param string $secret - * - * @return false|Document - */ - public static function tokenVerify(array $tokens, int $type = null, string $secret, Proof $proofForToken): false|Document - { - foreach ($tokens as $token) { - if ( - $token->isSet('secret') && - $token->isSet('expire') && - $token->isSet('type') && - ($type === null || $token->getAttribute('type') === $type) && - $proofForToken->verify($secret, $token->getAttribute('secret')) && - DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now()) - ) { - return $token; - } - } - - return false; - } - - /** - * Verify session and check that its not expired. - * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param array $sessions - * @param string $secret - * - * @return bool|string - */ - public static function sessionVerify(array $sessions, string $secret, Token $proofForToken) - { - foreach ($sessions as $session) { - if ( - $session->isSet('secret') && - $session->isSet('provider') && - $proofForToken->verify($secret, $session->getAttribute('secret')) && - DateTime::formatTz(DateTime::format(new \DateTime($session->getAttribute('expire')))) >= DateTime::formatTz(DateTime::now()) - ) { - return $session->getId(); - } - } - - return false; - } - - /** - * Is Privileged User? - * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param array $roles - * - * @return bool - */ - public static function isPrivilegedUser(array $roles): bool - { - if ( - in_array(User::ROLE_OWNER, $roles) || - in_array(User::ROLE_DEVELOPER, $roles) || - in_array(User::ROLE_ADMIN, $roles) - ) { - return true; - } - - return false; - } - - /** - * Is App User? - * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param array $roles - * - * @return bool - */ - public static function isAppUser(array $roles): bool - { - if (in_array(User::ROLE_APPS, $roles)) { - return true; - } - - return false; - } - - /** - * Returns all roles for a user. - * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param Document $user - * @return array - */ - public static function getRoles(Document $user): array - { - $roles = []; - - if (!self::isPrivilegedUser(Authorization::getRoles()) && !self::isAppUser(Authorization::getRoles())) { - if ($user->getId()) { - $roles[] = Role::user($user->getId())->toString(); - $roles[] = Role::users()->toString(); - - $emailVerified = $user->getAttribute('emailVerification', false); - $phoneVerified = $user->getAttribute('phoneVerification', false); - - if ($emailVerified || $phoneVerified) { - $roles[] = Role::user($user->getId(), Roles::DIMENSION_VERIFIED)->toString(); - $roles[] = Role::users(Roles::DIMENSION_VERIFIED)->toString(); - } else { - $roles[] = Role::user($user->getId(), Roles::DIMENSION_UNVERIFIED)->toString(); - $roles[] = Role::users(Roles::DIMENSION_UNVERIFIED)->toString(); - } - } else { - return [Role::guests()->toString()]; - } - } - - foreach ($user->getAttribute('memberships', []) as $node) { - if (!isset($node['confirm']) || !$node['confirm']) { - continue; - } - - if (isset($node['$id']) && isset($node['teamId'])) { - $roles[] = Role::team($node['teamId'])->toString(); - $roles[] = Role::member($node['$id'])->toString(); - - if (isset($node['roles'])) { - foreach ($node['roles'] as $nodeRole) { // Set all team roles - $roles[] = Role::team($node['teamId'], $nodeRole)->toString(); - } - } - } - } - - foreach ($user->getAttribute('labels', []) as $label) { - $roles[] = 'label:' . $label; - } - - return $roles; - } } From 31ffc9ea92a8399e6ec147fc63487ada847beddc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 4 Nov 2025 08:12:46 +0000 Subject: [PATCH 15/47] format --- src/Appwrite/Auth/Auth.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 1af4ea3a04..cf82b947f3 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -2,14 +2,7 @@ namespace Appwrite\Auth; -use Appwrite\Utopia\Database\Documents\User; -use Utopia\Auth\Proof; use Utopia\Auth\Proofs\Token; -use Utopia\Database\DateTime; -use Utopia\Database\Document; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Roles; class Auth { From 2a7d89ea7bed7546b71fee96682991c5741a9dd8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 01:14:36 +0000 Subject: [PATCH 16/47] Fix missing auth vars --- app/config/collections/common.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 6de7eb224b..68e367646f 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -1,6 +1,6 @@ 256, 'signed' => true, 'required' => false, - 'default' => Auth::DEFAULT_ALGO, + 'default' => (new Argon2())->getName(), 'array' => false, 'filters' => [], ], @@ -184,7 +184,7 @@ return [ 'size' => 65535, 'signed' => true, 'required' => false, - 'default' => Auth::DEFAULT_ALGO_OPTIONS, + 'default' => (new Argon2())->getOptions(), 'array' => false, 'filters' => ['json'], ], From 9130a7102e13d1796b857f0752d8b69f8e586e36 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 01:41:15 +0000 Subject: [PATCH 17/47] Fix use correct user type --- app/cli.php | 2 ++ app/init/resources.php | 9 ++++++--- app/worker.php | 4 +++- src/Appwrite/Utopia/Response.php | 5 +++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/cli.php b/app/cli.php index 71b6464cb9..73134887ea 100644 --- a/app/cli.php +++ b/app/cli.php @@ -9,6 +9,7 @@ use Appwrite\Event\StatsResources; use Appwrite\Event\StatsUsage; use Appwrite\Platform\Appwrite; use Appwrite\Runtimes\Runtimes; +use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Swoole\Runtime; use Swoole\Timer; @@ -76,6 +77,7 @@ CLI::setResource('dbForPlatform', function ($pools, $cache) { ->setNamespace('_console') ->setMetadata('host', \gethostname()) ->setMetadata('project', 'console'); + $dbForPlatform->setDocumentType('users', User::class); // Ensure tables exist $collections = Config::getParam('collections', [])['console']; diff --git a/app/init/resources.php b/app/init/resources.php index 95251a321b..0c05b30a83 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -291,6 +291,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); } + $user = null; if (APP_MODE_ADMIN === $mode) { /** @var User $user */ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); @@ -315,7 +316,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co $user->isEmpty() // Check a document has been found in the DB || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) ) { // Validate user has valid login token - $user = new Document([]); + $user = new User([]); } // if (APP_MODE_ADMIN === $mode) { // if ($user->find('teamInternalId', $project->getAttribute('teamInternalId'), 'memberships')) { @@ -343,7 +344,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co $jwtSessionId = $payload['sessionId'] ?? ''; if (!empty($jwtSessionId)) { if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token - $user = new Document([]); + $user = new User([]); } } } @@ -380,7 +381,8 @@ App::setResource('session', function (User $user, Store $store, Token $proofForT if (!$sessionId) { return; } - foreach ($sessions as $session) {/** @var Document $session */ + foreach ($sessions as $session) { + /** @var Document $session */ if ($sessionId === $session->getId()) { return $session; } @@ -499,6 +501,7 @@ App::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform ->setMetadata('project', $project->getId()) ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + $database->setDocumentType('users', User::class); $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); diff --git a/app/worker.php b/app/worker.php index 60f44ab33f..ce210d7c8b 100644 --- a/app/worker.php +++ b/app/worker.php @@ -17,6 +17,7 @@ use Appwrite\Event\Realtime; use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Platform\Appwrite; +use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Swoole\Runtime; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; @@ -55,7 +56,7 @@ Server::setResource('dbForPlatform', function (Cache $cache, Registry $register) $adapter = new DatabasePool($pools->get('console')); $dbForPlatform = new Database($adapter, $cache); $dbForPlatform->setNamespace('_console'); - + $dbForPlatform->setDocumentType('users', User::class); return $dbForPlatform; }, ['cache', 'register']); @@ -86,6 +87,7 @@ Server::setResource('dbForProject', function (Cache $cache, Registry $register, $adapter = new DatabasePool($pools->get($dsn->getHost())); $database = new Database($adapter, $cache); + $database->setDocumentType('users', User::class); $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index f89e342c06..28811c4db9 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -149,6 +149,7 @@ use Swoole\Http\Response as SwooleHTTPResponse; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Swoole\Response as SwooleResponse; +use Appwrite\Utopia\Database\Documents\User as DBUser; /** * @method int getStatusCode() @@ -809,8 +810,8 @@ class Response extends SwooleResponse if ($rule['sensitive']) { $roles = Authorization::getRoles(); - $isPrivilegedUser = User::isPrivileged($roles); - $isAppUser = User::isApp($roles); + $isPrivilegedUser = DBUser::isPrivileged($roles); + $isAppUser = DBUser::isApp($roles); if ((!$isPrivilegedUser && !$isAppUser) && !self::$showSensitive) { $data->setAttribute($key, ''); From c9d66c52b47c631e0893772ad8b124bb7332f4b1 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 02:04:39 +0000 Subject: [PATCH 18/47] composer update --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 03e4b20a4e..57a58a0900 100644 --- a/composer.lock +++ b/composer.lock @@ -5523,16 +5523,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "42df22195d6457e52e4c819678168470b114a816" + "reference": "cd712674e34136f706e9170641ed6f4ce160e772" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/42df22195d6457e52e4c819678168470b114a816", - "reference": "42df22195d6457e52e4c819678168470b114a816", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/cd712674e34136f706e9170641ed6f4ce160e772", + "reference": "cd712674e34136f706e9170641ed6f4ce160e772", "shasum": "" }, "require": { @@ -5568,9 +5568,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.5.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.5.1" }, - "time": "2025-10-31T10:10:25+00:00" + "time": "2025-11-04T09:55:47+00:00" }, { "name": "doctrine/annotations", From 44b5ed90d5f05c061bfea8058d2ce6a7a638758a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 02:06:14 +0000 Subject: [PATCH 19/47] composer update --- composer.lock | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.lock b/composer.lock index 0afc9ca63a..c50e0b7bb5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c5122fe03bab9e4c6813ec6d9e46b8f4", + "content-hash": "906a58683ad0d15b8899368f6b5a4279", "packages": [ { "name": "adhocore/jwt", @@ -9043,7 +9043,9 @@ } ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { From 5dfee103697b12d17ed6c7722cbd3f16676b7998 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 02:16:37 +0000 Subject: [PATCH 20/47] Fix logs endpoint --- app/controllers/api/account.php | 5 +++-- src/Appwrite/Utopia/Response.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index b5e033ab46..a1bddd82f9 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2897,12 +2897,13 @@ App::get('/v1/account/logs') contentType: ContentType::JSON, )) ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('user') ->inject('locale') ->inject('geodb') ->inject('dbForProject') - ->action(function (array $queries, Response $response, User $user, Locale $locale, Reader $geodb, Database $dbForProject) { + ->action(function (array $queries, bool $includeTotal, Response $response, Document $user, Locale $locale, Reader $geodb, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -2947,7 +2948,7 @@ App::get('/v1/account/logs') } $response->dynamic(new Document([ - 'total' => $audit->countLogsByUser($user->getSequence(), $queries), + 'total' => $includeTotal ? $audit->countLogsByUser($user->getSequence(), $queries) : 0, 'logs' => $output, ]), Response::MODEL_LOG_LIST); }); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 28811c4db9..273e525a5f 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -2,6 +2,7 @@ namespace Appwrite\Utopia; +use Appwrite\Utopia\Database\Documents\User as DBUser; use Appwrite\Utopia\Fetch\BodyMultipart; use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Model; @@ -144,12 +145,11 @@ use Appwrite\Utopia\Response\Model\VcsContent; use Appwrite\Utopia\Response\Model\Webhook; use Exception; use JsonException; -use Swoole\Http\Response as SwooleHTTPResponse; // Keep last +use Swoole\Http\Response as SwooleHTTPResponse; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Swoole\Response as SwooleResponse; -use Appwrite\Utopia\Database\Documents\User as DBUser; /** * @method int getStatusCode() From ae105165ab549cdd1bacf6c43a37e04cbc9530cf Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 02:28:56 +0000 Subject: [PATCH 21/47] fix team logs endpoint --- app/controllers/api/teams.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 694995b5e4..533bd080ce 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -55,6 +55,7 @@ use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; +use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; @@ -172,9 +173,11 @@ App::get('/v1/teams') )) ->param('queries', [], new Teams(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Teams::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { + try { $queries = Query::parseQueries($queries); @@ -214,7 +217,7 @@ App::get('/v1/teams') $filterQueries = Query::groupByType($queries)['filters']; try { $results = $dbForProject->find('teams', $queries); - $total = $dbForProject->count('teams', $filterQueries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('teams', $filterQueries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1440,11 +1443,12 @@ App::get('/v1/teams/:teamId/logs') )) ->param('teamId', '', new UID(), 'Team ID.') ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') ->inject('geodb') - ->action(function (string $teamId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { + ->action(function (string $teamId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { $team = $dbForProject->getDocument('teams', $teamId); @@ -1513,7 +1517,7 @@ App::get('/v1/teams/:teamId/logs') } } $response->dynamic(new Document([ - 'total' => $audit->countLogsByResource($resource, $queries), + 'total' => $includeTotal ? $audit->countLogsByResource($resource, $queries) : 0, 'logs' => $output, ]), Response::MODEL_LOG_LIST); }); From 8adeb40c1d41c26f56e3872cebc57a935c1798a2 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 02:33:22 +0000 Subject: [PATCH 22/47] fix team get membership --- app/controllers/api/teams.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 533bd080ce..ac69124b6c 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -1009,8 +1009,8 @@ App::get('/v1/teams/:teamId/memberships/:membershipId') ]; $roles = Authorization::getRoles(); - $isPrivilegedUser = User::isPrivileged($roles); - $isAppUser = User::isApp($roles); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; From 7ef5a7edaf42b16f2849fd5c05bf82f4de59f41f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 02:39:03 +0000 Subject: [PATCH 23/47] Fix teams --- app/controllers/api/teams.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index ac69124b6c..27e31aaa18 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -844,10 +844,11 @@ App::get('/v1/teams/:teamId/memberships') ->param('teamId', '', new UID(), 'Team ID.') ->param('queries', [], new Memberships(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Memberships::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('project') ->inject('dbForProject') - ->action(function (string $teamId, array $queries, string $search, Response $response, Document $project, Database $dbForProject) { + ->action(function (string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject) { $team = $dbForProject->getDocument('teams', $teamId); if ($team->isEmpty()) { @@ -899,11 +900,11 @@ App::get('/v1/teams/:teamId/memberships') collection: 'memberships', queries: $queries, ); - $total = $dbForProject->count( + $total = $includeTotal ? $dbForProject->count( collection: 'memberships', queries: $filterQueries, max: APP_LIMIT_COUNT - ); + ) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1009,8 +1010,8 @@ App::get('/v1/teams/:teamId/memberships/:membershipId') ]; $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); + $isPrivilegedUser = User::isPrivileged($roles); + $isAppUser = User::isApp($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; From 3cfb5d695f6f3c0e0e3e1e5e5145f8c57e7d5767 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 03:33:02 +0000 Subject: [PATCH 24/47] Fix list identities --- app/controllers/api/account.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a1bddd82f9..324d069841 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -5318,10 +5318,11 @@ App::get('/v1/account/identities') contentType: ContentType::JSON )) ->param('queries', [], new Identities(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Identities::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('user') ->inject('dbForProject') - ->action(function (array $queries, Response $response, User $user, Database $dbForProject) { + ->action(function (array $queries, bool $includeTotal, Response $response, User $user, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -5362,7 +5363,7 @@ App::get('/v1/account/identities') } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } - $total = $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT) : 0; $response->dynamic(new Document([ 'identities' => $results, From 4679eff4b83ac9beedde620eda3f1d6576b2238e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 03:34:52 +0000 Subject: [PATCH 25/47] use argon 2 default --- app/controllers/api/users.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index eaa814efad..f3a76f2404 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -350,10 +350,6 @@ App::post('/v1/users/argon2') ->inject('hooks') ->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) { $argon2 = new Argon2(); - $argon2 - ->setMemoryCost(7168) - ->setTimeCost(5) - ->setThreads(1); $user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); From 965c4738e7701ddcc6ad819e8d554126f59ace89 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 05:06:40 +0000 Subject: [PATCH 26/47] fix user's attributes --- app/controllers/api/users.php | 67 +++++++++++------------------------ 1 file changed, 20 insertions(+), 47 deletions(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index f3a76f2404..bdde62dffa 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -642,9 +642,10 @@ App::get('/v1/users') )) ->param('queries', [], new Users(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Users::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -684,10 +685,10 @@ App::get('/v1/users') $users = []; $total = 0; - $dbForProject->skipFilters(function () use ($dbForProject, $queries, &$users, &$total) { + $dbForProject->skipFilters(function () use ($dbForProject, $queries, $includeTotal, &$users, &$total) { try { $users = $dbForProject->find('users', $queries); - $total = $dbForProject->count('users', $queries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('users', $queries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } catch (QueryException $e) { @@ -821,32 +822,29 @@ App::get('/v1/users/:userId/sessions') ] )) ->param('userId', '', new UID(), 'User ID.') + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') - ->action(function (string $userId, Response $response, Database $dbForProject, Locale $locale) { + ->action(function (string $userId, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $sessions = $user->getAttribute('sessions', []); - foreach ($sessions as $key => $session) { /** @var Document $session */ - $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session->setAttribute('countryName', $countryName); $session->setAttribute('current', false); - $sessions[$key] = $session; } $response->dynamic(new Document([ 'sessions' => $sessions, - 'total' => count($sessions), + 'total' => $includeTotal ? count($sessions) : 0, ]), Response::MODEL_SESSION_LIST); }); @@ -870,43 +868,38 @@ App::get('/v1/users/:userId/memberships') ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Memberships(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Memberships::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $userId, array $queries, string $search, Response $response, Database $dbForProject) { + ->action(function (string $userId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - if (!empty($search)) { $queries[] = Query::search('search', $search); } - // Set internal queries $queries[] = Query::equal('userInternalId', [$user->getSequence()]); - $memberships = array_map(function ($membership) use ($dbForProject, $user) { $team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId')); - $membership ->setAttribute('teamName', $team->getAttribute('name')) ->setAttribute('userName', $user->getAttribute('name')) ->setAttribute('userEmail', $user->getAttribute('email')); - return $membership; }, $dbForProject->find('memberships', $queries)); $response->dynamic(new Document([ 'memberships' => $memberships, - 'total' => count($memberships), + 'total' => $includeTotal ? count($memberships) : 0, ]), Response::MODEL_MEMBERSHIP_LIST); }); @@ -929,46 +922,38 @@ App::get('/v1/users/:userId/logs') )) ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') ->inject('geodb') - ->action(function (string $userId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { + ->action(function (string $userId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - // Temp fix for logs $queries[] = Query::or([ Query::greaterThan('$createdAt', DateTime::format(new \DateTime('2025-02-26T01:30+00:00'))), Query::lessThan('$createdAt', DateTime::format(new \DateTime('2025-02-13T00:00+00:00'))), ]); - $audit = new Audit($dbForProject); - $logs = $audit->getLogsByUser($user->getSequence(), $queries); - $output = []; - foreach ($logs as $i => &$log) { $log['userAgent'] = (!empty($log['userAgent'])) ? $log['userAgent'] : 'UNKNOWN'; - $detector = new Detector($log['userAgent']); $detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then) - $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $output[$i] = new Document([ 'event' => $log['event'], 'userId' => ID::custom($log['data']['userId']), @@ -989,9 +974,7 @@ App::get('/v1/users/:userId/logs') 'deviceBrand' => $device['deviceBrand'], 'deviceModel' => $device['deviceModel'] ]); - $record = $geodb->get($log['ip']); - if ($record) { $output[$i]['countryCode'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), false) ? \strtolower($record['country']['iso_code']) : '--'; $output[$i]['countryName'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), $locale->getText('locale.country.unknown')); @@ -1002,7 +985,7 @@ App::get('/v1/users/:userId/logs') } $response->dynamic(new Document([ - 'total' => $audit->countLogsByUser($user->getSequence(), $queries), + 'total' => $includeTotal ? $audit->countLogsByUser($user->getSequence(), $queries) : 0, 'logs' => $output, ]), Response::MODEL_LOG_LIST); }); @@ -1026,23 +1009,21 @@ App::get('/v1/users/:userId/targets') )) ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Targets(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Targets::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $userId, array $queries, Response $response, Database $dbForProject) { + ->action(function (string $userId, array $queries, bool $includeTotal, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $queries[] = Query::equal('userId', [$userId]); - /** * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries */ @@ -1050,25 +1031,21 @@ App::get('/v1/users/:userId/targets') return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); }); $cursor = reset($cursor); - if ($cursor) { $validator = new Cursor(); if (!$validator->isValid($cursor)) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); } - $targetId = $cursor->getValue(); $cursorDocument = $dbForProject->getDocument('targets', $targetId); - if ($cursorDocument->isEmpty()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Target '{$targetId}' for the 'cursor' value not found."); } - $cursor->setValue($cursorDocument); } try { $targets = $dbForProject->find('targets', $queries); - $total = $dbForProject->count('targets', $queries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('targets', $queries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1097,19 +1074,20 @@ App::get('/v1/users/identities') )) ->param('queries', [], new Identities(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Identities::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + if (!empty($search)) { $queries[] = Query::search('search', $search); } - /** * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries */ @@ -1119,26 +1097,21 @@ App::get('/v1/users/identities') $cursor = reset($cursor); if ($cursor) { /** @var Query $cursor */ - $validator = new Cursor(); if (!$validator->isValid($cursor)) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); } - $identityId = $cursor->getValue(); $cursorDocument = $dbForProject->getDocument('identities', $identityId); - if ($cursorDocument->isEmpty()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "User '{$identityId}' for the 'cursor' value not found."); } - $cursor->setValue($cursorDocument); } - $filterQueries = Query::groupByType($queries)['filters']; try { $identities = $dbForProject->find('identities', $queries); - $total = $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('identities', $queries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } From 0f9b13e251792263c880fbe91b22febf6d1b7e84 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 05:09:36 +0000 Subject: [PATCH 27/47] use argon default --- app/controllers/api/users.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index bdde62dffa..2fbd7296ef 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1347,10 +1347,6 @@ App::patch('/v1/users/:userId/password') // Create Argon2 hasher with default settings $hasher = new Argon2(); - $hasher - ->setMemoryCost(7168) - ->setTimeCost(5) - ->setThreads(1); $newPassword = $hasher->hash($password); From 12c27edaf9b399c87856747463a467767bea0f58 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 05:23:32 +0000 Subject: [PATCH 28/47] Fix user test --- tests/unit/Utopia/Database/Documents/UserTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/Utopia/Database/Documents/UserTest.php b/tests/unit/Utopia/Database/Documents/UserTest.php index 4c13042dcc..4675e8d73f 100644 --- a/tests/unit/Utopia/Database/Documents/UserTest.php +++ b/tests/unit/Utopia/Database/Documents/UserTest.php @@ -68,16 +68,16 @@ class UserTest extends TestCase $user1 = new User([ '$id' => ID::custom('user1'), - 'tokens' => $tokens1, + 'sessions' => $tokens1, ]); $user2 = new User([ '$id' => ID::custom('user2'), - 'tokens' => $tokens2, + 'sessions' => $tokens2, ]); - $this->assertEquals($user1->sessionVerify($secret, $proofForToken), 'token1'); + $this->assertEquals('token1', $user1->sessionVerify($secret, $proofForToken)); $this->assertEquals($user1->sessionVerify('false-secret', $proofForToken), false); $this->assertEquals($user2->sessionVerify($secret, $proofForToken), false); $this->assertEquals($user2->sessionVerify('false-secret', $proofForToken), false); From 343d4439031ba257e017727016d81dc603416783 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 05:31:17 +0000 Subject: [PATCH 29/47] Fix realtime --- app/realtime.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index e5a893d3fa..e253fda689 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -69,6 +69,8 @@ if (!function_exists('getConsoleDB')) { ->setNamespace('_console') ->setMetadata('host', \gethostname()) ->setMetadata('project', '_console'); + + $database->setDocumentType('users', User::class); return $database; } @@ -121,6 +123,8 @@ if (!function_exists('getProjectDB')) { ->setMetadata('host', \gethostname()) ->setMetadata('project', $project->getId()); + $database->setDocumentType('users', User::class); + return $databases[$project->getSequence()] = $database; } } From 6a60b7d25b0e5e976654297f9810b860967f0798 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 05:33:22 +0000 Subject: [PATCH 30/47] format --- app/realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index e253fda689..ea0176cd0d 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -69,7 +69,7 @@ if (!function_exists('getConsoleDB')) { ->setNamespace('_console') ->setMetadata('host', \gethostname()) ->setMetadata('project', '_console'); - + $database->setDocumentType('users', User::class); return $database; From 88cbc36a02a87ae36f55de1782097448edc802f7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 5 Nov 2025 07:09:18 +0000 Subject: [PATCH 31/47] remove Appwrite\Auth\Auth --- app/controllers/api/account.php | 12 +++++- app/controllers/general.php | 5 +-- app/init/constants.php | 3 ++ src/Appwrite/Auth/Auth.php | 41 ------------------- .../Utopia/Response/Model/Project.php | 1 - 5 files changed, 15 insertions(+), 47 deletions(-) delete mode 100644 src/Appwrite/Auth/Auth.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 324d069841..2c44ffeee5 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1,7 +1,6 @@ throw new Exception(Exception::USER_INVALID_TOKEN) }); + $provider = match ($verifiedToken->getAttribute('type')) { + TOKEN_TYPE_VERIFICATION, + TOKEN_TYPE_RECOVERY, + TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, + TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, + TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, + TOKEN_TYPE_OAUTH2 => SESSION_PROVIDER_OAUTH2, + default => SESSION_PROVIDER_TOKEN, + }; $session = new Document(array_merge( [ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => Auth::getSessionProviderByTokenType($verifiedToken->getAttribute('type')), + 'provider' => $provider, 'secret' => $proofForToken->hash($sessionSecret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), diff --git a/app/controllers/general.php b/app/controllers/general.php index d5b764091f..8c93728a25 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -4,7 +4,6 @@ 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; use Appwrite\Event\Event; @@ -223,7 +222,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw */ $requirePreview = \is_null($apiKey) || !$apiKey->isPreviewAuthDisabled(); if ($isPreview && $requirePreview) { - $cookie = $request->getCookie(Auth::$cookieNamePreview, ''); + $cookie = $request->getCookie(COOKIE_NAME_PREVIEW, ''); $authorized = false; // Security checks to mark authorized true @@ -1614,7 +1613,7 @@ App::get('/_appwrite/authorize') $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $response - ->addCookie(Auth::$cookieNamePreview, $jwt, (new \DateTime($expire))->getTimestamp(), '/', $host, ('https' === $protocol), true, null) + ->addCookie(COOKIE_NAME_PREVIEW, $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); diff --git a/app/init/constants.php b/app/init/constants.php index 9f7766040a..0d53d92183 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -350,3 +350,6 @@ const TOKENS_RESOURCE_TYPE_DATABASES = 'databases'; const SCHEDULE_RESOURCE_TYPE_EXECUTION = 'execution'; const SCHEDULE_RESOURCE_TYPE_FUNCTION = 'function'; const SCHEDULE_RESOURCE_TYPE_MESSAGE = 'message'; + +/** Preview cookie */ +const COOKIE_NAME_PREVIEW = 'a_jwt_console'; \ No newline at end of file diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php deleted file mode 100644 index cf82b947f3..0000000000 --- a/src/Appwrite/Auth/Auth.php +++ /dev/null @@ -1,41 +0,0 @@ - Date: Wed, 5 Nov 2025 07:10:47 +0000 Subject: [PATCH 32/47] format --- app/init/constants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/init/constants.php b/app/init/constants.php index 0d53d92183..2e9168c9ae 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -352,4 +352,4 @@ const SCHEDULE_RESOURCE_TYPE_FUNCTION = 'function'; const SCHEDULE_RESOURCE_TYPE_MESSAGE = 'message'; /** Preview cookie */ -const COOKIE_NAME_PREVIEW = 'a_jwt_console'; \ No newline at end of file +const COOKIE_NAME_PREVIEW = 'a_jwt_console'; From 3c93004cde2f98e98774075a409376cfa31fc48d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 6 Nov 2025 02:05:18 +0000 Subject: [PATCH 33/47] Fix: switch database version --- composer.json | 2 +- composer.lock | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 117e949d69..83688ea2c5 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "dev-feat-external-type as 3.1.99", + "utopia-php/database": "dev-feat-custom-type as 3.1.99", "utopia-php/detector": "0.2.*", "utopia-php/domains": "0.9.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index b93bd9ef03..12bdfb0dcc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "906a58683ad0d15b8899368f6b5a4279", + "content-hash": "8bd5b13070de55ea9f32489879056da6", "packages": [ { "name": "adhocore/jwt", @@ -3895,16 +3895,16 @@ }, { "name": "utopia-php/database", - "version": "3.1.5", + "version": "dev-feat-custom-type", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "76568b81f25d89fc1e0c53f0370f139130eeb939" + "reference": "a0ddee09f19fb4f8fa0e13b2599957564f45ea25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/76568b81f25d89fc1e0c53f0370f139130eeb939", - "reference": "76568b81f25d89fc1e0c53f0370f139130eeb939", + "url": "https://api.github.com/repos/utopia-php/database/zipball/a0ddee09f19fb4f8fa0e13b2599957564f45ea25", + "reference": "a0ddee09f19fb4f8fa0e13b2599957564f45ea25", "shasum": "" }, "require": { @@ -3977,10 +3977,10 @@ "utopia" ], "support": { - "source": "https://github.com/utopia-php/database/tree/3.1.5", + "source": "https://github.com/utopia-php/database/tree/feat-custom-type", "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2025-11-05T10:17:55+00:00" + "time": "2025-11-06T02:01:40+00:00" }, { "name": "utopia-php/detector", @@ -9037,13 +9037,15 @@ "aliases": [ { "package": "utopia-php/database", - "version": "dev-feat-external-type", + "version": "dev-feat-custom-type", "alias": "3.1.99", "alias_normalized": "3.1.99.0" } ], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { From 13f9900802d2848dcb068203d8b0de26364bd10a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 6 Nov 2025 02:12:00 +0000 Subject: [PATCH 34/47] fix version --- composer.json | 2 +- composer.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 83688ea2c5..d995049d49 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "dev-feat-custom-type as 3.1.99", + "utopia-php/database": "dev-feat-custom-type as 3.1.5", "utopia-php/detector": "0.2.*", "utopia-php/domains": "0.9.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index 12bdfb0dcc..74dcda70f3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8bd5b13070de55ea9f32489879056da6", + "content-hash": "28e51c4775f5c65cf09a51eca67fce49", "packages": [ { "name": "adhocore/jwt", @@ -9038,8 +9038,8 @@ { "package": "utopia-php/database", "version": "dev-feat-custom-type", - "alias": "3.1.99", - "alias_normalized": "3.1.99.0" + "alias": "3.1.5", + "alias_normalized": "3.1.5.0" } ], "minimum-stability": "stable", From fa2b46c7e790e171719e1cf8d1829291d113de07 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 6 Nov 2025 02:26:47 +0000 Subject: [PATCH 35/47] Fix: dev dependency --- composer.json | 2 +- composer.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index d995049d49..83688ea2c5 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "dev-feat-custom-type as 3.1.5", + "utopia-php/database": "dev-feat-custom-type as 3.1.99", "utopia-php/detector": "0.2.*", "utopia-php/domains": "0.9.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index 74dcda70f3..12bdfb0dcc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "28e51c4775f5c65cf09a51eca67fce49", + "content-hash": "8bd5b13070de55ea9f32489879056da6", "packages": [ { "name": "adhocore/jwt", @@ -9038,8 +9038,8 @@ { "package": "utopia-php/database", "version": "dev-feat-custom-type", - "alias": "3.1.5", - "alias_normalized": "3.1.5.0" + "alias": "3.1.99", + "alias_normalized": "3.1.99.0" } ], "minimum-stability": "stable", From 9fe74bdd22647628b4ba621fb283358436d4c59c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 6 Nov 2025 05:57:36 +0000 Subject: [PATCH 36/47] update database library --- composer.json | 2 +- composer.lock | 27 +++++++++------------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 83688ea2c5..c9c1d74563 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "dev-feat-custom-type as 3.1.99", + "utopia-php/database": "3.2.*", "utopia-php/detector": "0.2.*", "utopia-php/domains": "0.9.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index 12bdfb0dcc..94c0cf75d5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8bd5b13070de55ea9f32489879056da6", + "content-hash": "83cdce512b67057f3f2562279a003bf4", "packages": [ { "name": "adhocore/jwt", @@ -3895,16 +3895,16 @@ }, { "name": "utopia-php/database", - "version": "dev-feat-custom-type", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "a0ddee09f19fb4f8fa0e13b2599957564f45ea25" + "reference": "f2d01b6b38057891184f62107bf70a55bc2ea068" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/a0ddee09f19fb4f8fa0e13b2599957564f45ea25", - "reference": "a0ddee09f19fb4f8fa0e13b2599957564f45ea25", + "url": "https://api.github.com/repos/utopia-php/database/zipball/f2d01b6b38057891184f62107bf70a55bc2ea068", + "reference": "f2d01b6b38057891184f62107bf70a55bc2ea068", "shasum": "" }, "require": { @@ -3977,10 +3977,10 @@ "utopia" ], "support": { - "source": "https://github.com/utopia-php/database/tree/feat-custom-type", + "source": "https://github.com/utopia-php/database/tree/3.2.0", "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2025-11-06T02:01:40+00:00" + "time": "2025-11-06T05:41:54+00:00" }, { "name": "utopia-php/detector", @@ -9034,18 +9034,9 @@ "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [ - { - "package": "utopia-php/database", - "version": "dev-feat-custom-type", - "alias": "3.1.99", - "alias_normalized": "3.1.99.0" - } - ], + "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "utopia-php/database": 20 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { From b3df65cf33dc84f2d3f56928550e1f7d8cfaefb1 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 10 Nov 2025 09:01:09 +0000 Subject: [PATCH 37/47] remove unused var --- app/controllers/api/account.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 2c44ffeee5..f6c0039571 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1580,7 +1580,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $sessionUpgrade = true; } - $sessions = $user->getAttribute('sessions', []); $current = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); if ($current) { // Delete current session of new one. From c8e6fcc090f2320f72629a5dbb4f5db96a4f5d5f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 10 Nov 2025 09:01:12 +0000 Subject: [PATCH 38/47] fix token verify --- src/Appwrite/Utopia/Database/Documents/User.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index 44ff27c44c..994c099a40 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -141,7 +141,7 @@ class User extends Document $token->isSet('expire') && $token->isSet('type') && ($type === null || $token->getAttribute('type') === $type) && - $proofForToken->verify($secret, $token->getAttribute('secret')) && + $proofForToken->verify($proofForToken->hash($secret), $token->getAttribute('secret')) && DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now()) ) { return $token; From 47b653fd649eea2abcbd7ee4b4f2dcc7135abc8d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 10 Nov 2025 11:38:47 +0000 Subject: [PATCH 39/47] Use correct hashing algorithm for oauth2 token --- app/controllers/api/account.php | 7 ++++--- src/Appwrite/Utopia/Database/Documents/User.php | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index f6c0039571..c4657a5e4d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1764,16 +1764,17 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); - $proofsForTokenOAuth2 = new ProofsToken(TOKEN_LENGTH_OAUTH2); + $proofForTokenOAuth2 = new ProofsToken(TOKEN_LENGTH_OAUTH2); + $proofForTokenOAuth2->setHash(new Sha()); // If the `token` param is set, we will return the token in the query string if ($state['token']) { - $secret = $proofsForTokenOAuth2->generate(); + $secret = $proofForTokenOAuth2->generate(); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'type' => TOKEN_TYPE_OAUTH2, - 'secret' => $proofsForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak + 'secret' => $proofForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index 994c099a40..44ff27c44c 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -141,7 +141,7 @@ class User extends Document $token->isSet('expire') && $token->isSet('type') && ($type === null || $token->getAttribute('type') === $type) && - $proofForToken->verify($proofForToken->hash($secret), $token->getAttribute('secret')) && + $proofForToken->verify($secret, $token->getAttribute('secret')) && DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now()) ) { return $token; From 5a1c257a37cc279e0df10d1f92bce0a5529c99db Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 23 Nov 2025 06:46:15 +0000 Subject: [PATCH 40/47] composer update --- composer.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/composer.lock b/composer.lock index d0e1512995..3ddbf3002e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dc4eb1888275a24d596468924e272e25", + "content-hash": "b4743ea3702b0c46864372c7a8d84817", "packages": [ { "name": "adhocore/jwt", @@ -4511,16 +4511,16 @@ }, { "name": "utopia-php/migration", - "version": "1.3.3", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "731b3a963c58c30e0b2368695d57a7e8fcb7455c" + "reference": "18bd7d39dcee09280f40edb12879c727ecec98d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/731b3a963c58c30e0b2368695d57a7e8fcb7455c", - "reference": "731b3a963c58c30e0b2368695d57a7e8fcb7455c", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/18bd7d39dcee09280f40edb12879c727ecec98d3", + "reference": "18bd7d39dcee09280f40edb12879c727ecec98d3", "shasum": "" }, "require": { @@ -4560,9 +4560,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.3.3" + "source": "https://github.com/utopia-php/migration/tree/1.4.0" }, - "time": "2025-10-28T04:02:08+00:00" + "time": "2025-11-21T06:08:59+00:00" }, { "name": "utopia-php/mongo", @@ -5434,16 +5434,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.5.7", + "version": "1.5.8", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "dc6720ba92ed98e2c62b2a319d4371f167ccc808" + "reference": "05367bc4a4c3e020e9aca114ae875b626ce8fc55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/dc6720ba92ed98e2c62b2a319d4371f167ccc808", - "reference": "dc6720ba92ed98e2c62b2a319d4371f167ccc808", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/05367bc4a4c3e020e9aca114ae875b626ce8fc55", + "reference": "05367bc4a4c3e020e9aca114ae875b626ce8fc55", "shasum": "" }, "require": { @@ -5479,9 +5479,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.5.7" + "source": "https://github.com/appwrite/sdk-generator/tree/1.5.8" }, - "time": "2025-11-18T05:57:01+00:00" + "time": "2025-11-20T11:00:34+00:00" }, { "name": "doctrine/annotations", @@ -8948,7 +8948,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -8972,5 +8972,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } From 149fee503f9a4ef1a07f0da0448fa252afd8dab6 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 23 Nov 2025 07:01:11 +0000 Subject: [PATCH 41/47] Format --- app/controllers/api/users.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index bb1b6c2e37..c78a148e14 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -108,7 +108,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor throw new Exception(Exception::USER_PASSWORD_PERSONAL_DATA); } } - + try { $emailCanonical = new Email($email); } catch (Throwable) { From 4fd0af509e65f69b86816ec4dedcf163725f97f4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Nov 2025 10:53:44 +0000 Subject: [PATCH 42/47] composer update --- composer.lock | 193 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 151 insertions(+), 42 deletions(-) diff --git a/composer.lock b/composer.lock index aa029febe9..2201400308 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1d3d4b19a835b3be79a63146fcdd389b", + "content-hash": "479b161d08b29d22267c4c7798751842", "packages": [ { "name": "adhocore/jwt", @@ -1236,16 +1236,16 @@ }, { "name": "open-telemetry/api", - "version": "1.7.0", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522" + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/610b79ad9d6d97e8368bcb6c4d42394fbb87b522", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", "shasum": "" }, "require": { @@ -1265,7 +1265,7 @@ ] }, "branch-alias": { - "dev-main": "1.7.x-dev" + "dev-main": "1.8.x-dev" } }, "autoload": { @@ -1298,11 +1298,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-10-19T10:49:48+00:00" }, { "name": "open-telemetry/context", @@ -1365,16 +1365,16 @@ }, { "name": "open-telemetry/exporter-otlp", - "version": "1.3.2", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2" + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/07b02bc71838463f6edcc78d3485c04b48fb263d", + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d", "shasum": "" }, "require": { @@ -1421,11 +1421,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-16T00:24:51+00:00" + "time": "2025-11-13T08:04:37+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -1492,16 +1492,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e" + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", "shasum": "" }, "require": { @@ -1581,11 +1581,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-11-25T10:59:15+00:00" }, { "name": "open-telemetry/sem-conv", @@ -3996,6 +3996,54 @@ }, "time": "2025-11-24T15:52:51+00:00" }, + { + "name": "utopia-php/di", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/di.git", + "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "laravel/pint": "^1.2", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.25", + "swoole/ide-helper": "4.8.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/", + "Tests\\E2E\\": "tests/e2e" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple and lite library for managing dependency injections", + "keywords": [ + "framework", + "http", + "php", + "upf" + ], + "support": { + "issues": "https://github.com/utopia-php/di/issues", + "source": "https://github.com/utopia-php/di/tree/0.1.0" + }, + "time": "2024-08-08T14:35:19+00:00" + }, { "name": "utopia-php/dns", "version": "1.1.3", @@ -4263,28 +4311,33 @@ }, { "name": "utopia-php/framework", - "version": "0.33.30", + "version": "0.33.31", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "07cf699a7c47bd1a03b4da1812f1719a66b3c924" + "reference": "cb01940ffe620b3d09f934dc2b8c72c2406309e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/07cf699a7c47bd1a03b4da1812f1719a66b3c924", - "reference": "07cf699a7c47bd1a03b4da1812f1719a66b3c924", + "url": "https://api.github.com/repos/utopia-php/http/zipball/cb01940ffe620b3d09f934dc2b8c72c2406309e7", + "reference": "cb01940ffe620b3d09f934dc2b8c72c2406309e7", "shasum": "" }, "require": { + "ext-swoole": "*", "php": ">=8.1", "utopia-php/compression": "0.1.*", - "utopia-php/telemetry": "0.1.*" + "utopia-php/servers": "0.2.*", + "utopia-php/telemetry": "0.1.*", + "utopia-php/validators": "0.0.*" }, "require-dev": { + "ext-xdebug": "*", "laravel/pint": "^1.2", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5.25" + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "^9.5.25", + "swoole/ide-helper": "4.8.3" }, "type": "library", "autoload": { @@ -4296,17 +4349,18 @@ "license": [ "MIT" ], - "description": "A simple, light and advanced PHP framework", + "description": "A simple, light and advanced PHP HTTP framework", "keywords": [ "framework", + "http", "php", "upf" ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.30" + "source": "https://github.com/utopia-php/http/tree/1.2.0" }, - "time": "2025-11-18T12:18:00+00:00" + "time": "2025-10-21T10:53:55+00:00" }, { "name": "utopia-php/image", @@ -4948,6 +5002,60 @@ }, "time": "2021-03-10T10:45:22+00:00" }, + { + "name": "utopia-php/servers", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/servers.git", + "reference": "c6ec23b0597f657020a75d4a2f8ef03177d7edaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/servers/zipball/c6ec23b0597f657020a75d4a2f8ef03177d7edaa", + "reference": "c6ec23b0597f657020a75d4a2f8ef03177d7edaa", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "utopia-php/di": "0.1.*", + "utopia-php/validators": "0.0.*" + }, + "require-dev": { + "laravel/pint": "^0.2.3", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Servers\\": "src/Servers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A base library for building Utopia style servers.", + "keywords": [ + "framework", + "php", + "servers", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/servers/issues", + "source": "https://github.com/utopia-php/servers/tree/0.2.1" + }, + "time": "2025-10-21T10:08:19+00:00" + }, { "name": "utopia-php/storage", "version": "0.18.14", @@ -5709,16 +5817,16 @@ }, { "name": "laravel/pint", - "version": "v1.25.1", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", "shasum": "" }, "require": { @@ -5729,13 +5837,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.87.2", - "illuminate/view": "^11.46.0", - "larastan/larastan": "^3.7.1", - "laravel-zero/framework": "^11.45.0", + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.1", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -5761,6 +5869,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -5771,7 +5880,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-09-19T02:57:12+00:00" + "time": "2025-11-25T21:15:52+00:00" }, { "name": "matthiasmullie/minify", @@ -8972,5 +9081,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From f6b04ae63ec3e0a9240c05c0e497a3b9357fcf07 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Nov 2025 10:56:56 +0000 Subject: [PATCH 43/47] Fix type --- app/controllers/shared/api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 47ba69fca4..05477d679a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -797,7 +797,7 @@ App::shutdown() ->inject('queueForWebhooks') ->inject('queueForRealtime') ->inject('dbForProject') - ->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject) use ($parseLabel) { + ->action(function (App $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject) use ($parseLabel) { $responsePayload = $response->getPayload(); From cbc8b2ca0dcb29aa4a61ba04d41b378b0aa4201c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Nov 2025 11:02:00 +0000 Subject: [PATCH 44/47] Fix: validator not working --- app/controllers/api/users.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index c78a148e14..dcc69e5131 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -115,9 +115,10 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor $emailCanonical = null; } $hashedPassword = null; - + + $isHashed = !$hash instanceof Plaintext; if (!empty($password)) { - if ($hash instanceof Plaintext) { // Password was never hashed, hash it with the default hash + if (!$isHashed) { // Password was never hashed, hash it with the default hash $defaultHash = new ProofsPassword(); $hashedPassword = $defaultHash->hash($password); $hash = $defaultHash->getHash(); @@ -159,7 +160,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor 'emailIsFree' => $emailCanonical?->isFree(), ]); - if ($hash instanceof Plaintext) { + if (!$isHashed) { $hooks->trigger('passwordValidator', [$dbForProject, $project, $plaintextPassword, &$user, true]); } From 5dd3f9c57448a78dd15b6e3e8b4dd8a865af8fba Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Nov 2025 11:08:13 +0000 Subject: [PATCH 45/47] Format --- app/controllers/api/users.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index dcc69e5131..8ff7c12cae 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -115,7 +115,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor $emailCanonical = null; } $hashedPassword = null; - + $isHashed = !$hash instanceof Plaintext; if (!empty($password)) { if (!$isHashed) { // Password was never hashed, hash it with the default hash From ef8d7a45f993f9ac0996d82e96fbebd501ba4df6 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 27 Nov 2025 00:29:15 +0000 Subject: [PATCH 46/47] composer update --- composer.lock | 194 +++++++++++--------------------------------------- 1 file changed, 43 insertions(+), 151 deletions(-) diff --git a/composer.lock b/composer.lock index 2201400308..65dd0dd236 100644 --- a/composer.lock +++ b/composer.lock @@ -3996,66 +3996,18 @@ }, "time": "2025-11-24T15:52:51+00:00" }, - { - "name": "utopia-php/di", - "version": "0.1.0", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/di.git", - "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31", - "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "laravel/pint": "^1.2", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5.25", - "swoole/ide-helper": "4.8.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\": "src/", - "Tests\\E2E\\": "tests/e2e" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A simple and lite library for managing dependency injections", - "keywords": [ - "framework", - "http", - "php", - "upf" - ], - "support": { - "issues": "https://github.com/utopia-php/di/issues", - "source": "https://github.com/utopia-php/di/tree/0.1.0" - }, - "time": "2024-08-08T14:35:19+00:00" - }, { "name": "utopia-php/dns", - "version": "1.1.3", + "version": "1.1.4", "source": { "type": "git", "url": "https://github.com/utopia-php/dns.git", - "reference": "1e6b4bac735329c9e5ec69a6a5d899ec2d050707" + "reference": "eea6b9299a1420ae6c574f16eb1e9da8689ac56b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/dns/zipball/1e6b4bac735329c9e5ec69a6a5d899ec2d050707", - "reference": "1e6b4bac735329c9e5ec69a6a5d899ec2d050707", + "url": "https://api.github.com/repos/utopia-php/dns/zipball/eea6b9299a1420ae6c574f16eb1e9da8689ac56b", + "reference": "eea6b9299a1420ae6c574f16eb1e9da8689ac56b", "shasum": "" }, "require": { @@ -4063,7 +4015,7 @@ "utopia-php/console": "0.0.*", "utopia-php/domains": "0.9.*", "utopia-php/telemetry": "0.1.*", - "utopia-php/validators": "^0.0.2" + "utopia-php/validators": "0.*" }, "require-dev": { "laravel/pint": "1.25.*", @@ -4097,32 +4049,32 @@ ], "support": { "issues": "https://github.com/utopia-php/dns/issues", - "source": "https://github.com/utopia-php/dns/tree/1.1.3" + "source": "https://github.com/utopia-php/dns/tree/1.1.4" }, - "time": "2025-11-06T19:08:29+00:00" + "time": "2025-11-26T13:38:10+00:00" }, { "name": "utopia-php/domains", - "version": "0.9.1", + "version": "0.9.2", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "99b4ec95d5d6b7a5c990a66c56412212d9af37e7" + "reference": "52b654f8a0e170bfa2e54cb47755b256822477c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/99b4ec95d5d6b7a5c990a66c56412212d9af37e7", - "reference": "99b4ec95d5d6b7a5c990a66c56412212d9af37e7", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/52b654f8a0e170bfa2e54cb47755b256822477c7", + "reference": "52b654f8a0e170bfa2e54cb47755b256822477c7", "shasum": "" }, "require": { - "php": ">=8.0", + "php": ">=8.2", "utopia-php/cache": "0.13.*", - "utopia-php/validators": "0.0.*" + "utopia-php/validators": "0.*" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "1.9.x-dev", + "laravel/pint": "^1.18", + "phpstan/phpstan": "^1.12", "phpunit/phpunit": "^9.3" }, "type": "library", @@ -4159,9 +4111,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/0.9.1" + "source": "https://github.com/utopia-php/domains/tree/0.9.2" }, - "time": "2025-10-21T14:52:27+00:00" + "time": "2025-11-26T12:16:36+00:00" }, { "name": "utopia-php/dsn", @@ -4212,16 +4164,16 @@ }, { "name": "utopia-php/emails", - "version": "0.6.2", + "version": "0.6.3", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9" + "reference": "9524d7f7bd1651a06fef8a3d964f774b04fe2918" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", - "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/9524d7f7bd1651a06fef8a3d964f774b04fe2918", + "reference": "9524d7f7bd1651a06fef8a3d964f774b04fe2918", "shasum": "" }, "require": { @@ -4229,7 +4181,7 @@ "utopia-php/cli": "^0.15", "utopia-php/domains": "^0.9", "utopia-php/fetch": "^0.4", - "utopia-php/validators": "^0.0.2" + "utopia-php/validators": "0.*" }, "require-dev": { "laravel/pint": "1.25.*", @@ -4266,9 +4218,9 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.2" + "source": "https://github.com/utopia-php/emails/tree/0.6.3" }, - "time": "2025-10-28T16:08:17+00:00" + "time": "2025-11-26T12:27:47+00:00" }, { "name": "utopia-php/fetch", @@ -4311,33 +4263,29 @@ }, { "name": "utopia-php/framework", - "version": "0.33.31", + "version": "0.33.33", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "cb01940ffe620b3d09f934dc2b8c72c2406309e7" + "reference": "838e3a28276e73187bc34a314f014096dc92191b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/cb01940ffe620b3d09f934dc2b8c72c2406309e7", - "reference": "cb01940ffe620b3d09f934dc2b8c72c2406309e7", + "url": "https://api.github.com/repos/utopia-php/http/zipball/838e3a28276e73187bc34a314f014096dc92191b", + "reference": "838e3a28276e73187bc34a314f014096dc92191b", "shasum": "" }, "require": { - "ext-swoole": "*", "php": ">=8.1", "utopia-php/compression": "0.1.*", - "utopia-php/servers": "0.2.*", "utopia-php/telemetry": "0.1.*", - "utopia-php/validators": "0.0.*" + "utopia-php/validators": "0.1.*" }, "require-dev": { - "ext-xdebug": "*", "laravel/pint": "^1.2", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "^9.5.25", - "swoole/ide-helper": "4.8.3" + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.25" }, "type": "library", "autoload": { @@ -4349,18 +4297,17 @@ "license": [ "MIT" ], - "description": "A simple, light and advanced PHP HTTP framework", + "description": "A simple, light and advanced PHP framework", "keywords": [ "framework", - "http", "php", "upf" ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/1.2.0" + "source": "https://github.com/utopia-php/http/tree/0.33.33" }, - "time": "2025-10-21T10:53:55+00:00" + "time": "2025-11-25T10:21:13+00:00" }, { "name": "utopia-php/image", @@ -5002,60 +4949,6 @@ }, "time": "2021-03-10T10:45:22+00:00" }, - { - "name": "utopia-php/servers", - "version": "0.2.1", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/servers.git", - "reference": "c6ec23b0597f657020a75d4a2f8ef03177d7edaa" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/servers/zipball/c6ec23b0597f657020a75d4a2f8ef03177d7edaa", - "reference": "c6ec23b0597f657020a75d4a2f8ef03177d7edaa", - "shasum": "" - }, - "require": { - "php": ">=8.0", - "utopia-php/di": "0.1.*", - "utopia-php/validators": "0.0.*" - }, - "require-dev": { - "laravel/pint": "^0.2.3", - "phpstan/phpstan": "^1.8", - "phpunit/phpunit": "^9.5.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Servers\\": "src/Servers" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Team Appwrite", - "email": "team@appwrite.io" - } - ], - "description": "A base library for building Utopia style servers.", - "keywords": [ - "framework", - "php", - "servers", - "upf", - "utopia" - ], - "support": { - "issues": "https://github.com/utopia-php/servers/issues", - "source": "https://github.com/utopia-php/servers/tree/0.2.1" - }, - "time": "2025-10-21T10:08:19+00:00" - }, { "name": "utopia-php/storage", "version": "0.18.14", @@ -5273,26 +5166,25 @@ }, { "name": "utopia-php/validators", - "version": "0.0.2", + "version": "0.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "894210695c5d35fa248fb65f7fe7237b6ff4fb0b" + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/894210695c5d35fa248fb65f7fe7237b6ff4fb0b", - "reference": "894210695c5d35fa248fb65f7fe7237b6ff4fb0b", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/5c57d5b6cf964f8981807c1d3ea8df620c869080", + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.0" }, "require-dev": { - "ext-xdebug": "*", - "laravel/pint": "^1.2", + "laravel/pint": "1.*", "phpstan/phpstan": "1.*", - "phpunit/phpunit": "^9.5.25" + "phpunit/phpunit": "11.*" }, "type": "library", "autoload": { @@ -5313,9 +5205,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.0.2" + "source": "https://github.com/utopia-php/validators/tree/0.1.0" }, - "time": "2025-10-20T21:52:28+00:00" + "time": "2025-11-18T11:05:46+00:00" }, { "name": "utopia-php/vcs", From 06c876906c1522f63306f5057cdb7f6a7b36035c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 27 Nov 2025 00:58:32 +0000 Subject: [PATCH 47/47] AI suggested fixes --- app/controllers/api/teams.php | 2 +- src/Appwrite/Utopia/Database/Documents/User.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 52a2c179a6..6628cec50a 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -1092,7 +1092,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId') ->param('roles', [], function (Document $project) { if ($project->getId() === 'console') { $roles = array_keys(Config::getParam('roles', [])); - array_filter($roles, function ($role) { + $roles = array_filter($roles, function ($role) { return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); }); return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index 44ff27c44c..a85b0a897c 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -90,7 +90,7 @@ class User extends Document * @param Document $this * @return bool */ - public static function isAnonymous(): bool + public function isAnonymous(): bool { return is_null($this->getEmail()) && is_null($this->getPhone()); @@ -167,6 +167,7 @@ class User extends Document if ( $session->isSet('secret') && $session->isSet('provider') && + $session->isSet('expire') && $proofForToken->verify($secret, $session->getAttribute('secret')) && DateTime::formatTz(DateTime::format(new \DateTime($session->getAttribute('expire')))) >= DateTime::formatTz(DateTime::now()) ) {