From ddde13a78fd621f9fee091f87d3f48df4352ad5a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 21 Oct 2025 14:59:30 +1300 Subject: [PATCH 1/2] Revert "Merge pull request #10468 from appwrite/feat-apps-module-dl" This reverts commit 9dd1939d1fb1fe81c0d7ab009bc2d43418fad3d2, reversing changes made to 8dfdfcb522d3dcabbfdb5e1d576b4c645b8a1619. # Conflicts: # app/config/collections/common.php # app/controllers/api/users.php # app/init/resources.php # composer.lock --- app/config/collections/common.php | 7 +- app/config/console.php | 3 +- app/config/roles.php | 13 +- app/controllers/api/account.php | 441 +++++++----------- app/controllers/api/projects.php | 3 +- app/controllers/api/teams.php | 56 +-- app/controllers/api/users.php | 144 ++---- app/controllers/general.php | 28 +- app/controllers/shared/api.php | 79 +--- app/controllers/shared/api/auth.php | 2 +- app/init/constants.php | 66 --- app/init/resources.php | 113 ++--- app/realtime.php | 22 +- composer.json | 1 - composer.lock | 57 +-- src/Appwrite/Auth/Auth.php | 351 ++++++++++++-- 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 | 8 +- src/Appwrite/Auth/MFA/Type.php | 5 +- .../Auth/Validator/PasswordHistory.php | 12 +- src/Appwrite/Migration/Version/V16.php | 3 +- src/Appwrite/Migration/Version/V17.php | 4 +- src/Appwrite/Migration/Version/V20.php | 11 +- .../Functions/Http/Executions/Create.php | 8 +- src/Appwrite/Platform/Tasks/Install.php | 9 +- src/Appwrite/Platform/Workers/Audits.php | 3 +- src/Appwrite/Platform/Workers/Deletes.php | 3 +- .../Utopia/Response/Model/Project.php | 4 +- tests/e2e/Scopes/ProjectCustom.php | 1 - .../Account/AccountConsoleClientTest.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 7 +- tests/unit/Auth/AuthTest.php | 268 +++++++++-- tests/unit/Auth/KeyTest.php | 5 +- .../unit/Messaging/MessagingChannelsTest.php | 4 +- tests/unit/Migration/MigrationTest.php | 2 +- 42 files changed, 1560 insertions(+), 855 deletions(-) create mode 100644 src/Appwrite/Auth/Hash.php create mode 100644 src/Appwrite/Auth/Hash/Argon2.php create mode 100644 src/Appwrite/Auth/Hash/Bcrypt.php create mode 100644 src/Appwrite/Auth/Hash/Md5.php create mode 100644 src/Appwrite/Auth/Hash/Phpass.php create mode 100644 src/Appwrite/Auth/Hash/Scrypt.php create mode 100644 src/Appwrite/Auth/Hash/Scryptmodified.php create mode 100644 src/Appwrite/Auth/Hash/Sha.php diff --git a/app/config/collections/common.php b/app/config/collections/common.php index eebc11e17f..6de7eb224b 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -1,5 +1,6 @@ 256, 'signed' => true, 'required' => false, - 'default' => 'argon2', + 'default' => Auth::DEFAULT_ALGO, 'array' => false, 'filters' => [], ], @@ -183,7 +184,7 @@ return [ 'size' => 65535, 'signed' => true, 'required' => false, - 'default' => ['type' => 'argon2', 'memoryCost' => 2048, 'timeCost' => 4, 'threads' => 3], + 'default' => Auth::DEFAULT_ALGO_OPTIONS, 'array' => false, 'filters' => ['json'], ], @@ -1114,9 +1115,9 @@ return [ [ '$id' => ID::custom('expire'), 'type' => Database::VAR_DATETIME, + 'format' => '', 'size' => 0, 'required' => false, - 'format' => '', 'signed' => false, 'default' => null, 'array' => false, diff --git a/app/config/console.php b/app/config/console.php index 5c4bf87614..f8f68a8039 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -4,6 +4,7 @@ * Initializes console project document. */ +use Appwrite\Auth\Auth; use Appwrite\Network\Platform; use Utopia\Database\Helpers\ID; use Utopia\System\System; @@ -37,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' => TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds + 'duration' => Auth::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 966d24663f..0f0945a2b4 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -1,5 +1,6 @@ [ + Auth::USER_ROLE_GUESTS => [ 'label' => 'Guests', 'scopes' => [ 'global', @@ -111,23 +112,23 @@ return [ 'execution.write', ], ], - USER_ROLE_USERS => [ + Auth::USER_ROLE_USERS => [ 'label' => 'Users', 'scopes' => \array_merge($member), ], - USER_ROLE_ADMIN => [ + Auth::USER_ROLE_ADMIN => [ 'label' => 'Admin', 'scopes' => \array_merge($admins), ], - USER_ROLE_DEVELOPER => [ + Auth::USER_ROLE_DEVELOPER => [ 'label' => 'Developer', 'scopes' => \array_merge($admins), ], - USER_ROLE_OWNER => [ + Auth::USER_ROLE_OWNER => [ 'label' => 'Owner', 'scopes' => \array_merge($member, $admins), ], - USER_ROLE_APPS => [ + Auth::USER_ROLE_APPS => [ 'label' => 'Applications', 'scopes' => ['global', 'health.read', 'graphql'], ], diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 418770fc9c..5563fc6a59 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -40,11 +40,6 @@ 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; @@ -174,7 +169,8 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc } ; -$createSession = function (string $userId, string $secret, Request $request, Response $response, Document $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 */ $userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); @@ -183,8 +179,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res throw new Exception(Exception::USER_INVALID_TOKEN); } - $verifiedToken = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), null, $secret, $proofForToken) - ?: Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), null, $secret, $proofForCode); + $verifiedToken = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), null, $secret); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -192,17 +187,17 @@ $createSession = function (string $userId, string $secret, Request $request, Res $user->setAttributes($userFromRequest->getArrayCopy()); - $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $sessionSecret = $proofForToken->generate(); + $sessionSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); $factor = (match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_MAGIC_URL, - TOKEN_TYPE_OAUTH2, - TOKEN_TYPE_EMAIL => Type::EMAIL, - TOKEN_TYPE_PHONE => Type::PHONE, - TOKEN_TYPE_GENERIC => 'token', + 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', default => throw new Exception(Exception::USER_INVALID_TOKEN) }); @@ -212,7 +207,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'provider' => Auth::getSessionProviderByTokenType($verifiedToken->getAttribute('type')), - 'secret' => $proofForToken->hash($sessionSecret), // One way hash encryption to protect DB leak + 'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => [$factor], @@ -237,11 +232,11 @@ $createSession = function (string $userId, string $secret, Request $request, Res $dbForProject->purgeCachedDocument('users', $user->getId()); // Magic URL + Email OTP - if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_MAGIC_URL || $verifiedToken->getAttribute('type') === TOKEN_TYPE_EMAIL) { + if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_MAGIC_URL || $verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_EMAIL) { $user->setAttribute('emailVerification', true); } - if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_PHONE) { + if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_PHONE) { $user->setAttribute('phoneVerification', true); } @@ -252,8 +247,8 @@ $createSession = function (string $userId, string $secret, Request $request, Res } $isAllowedTokenType = match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_MAGIC_URL, - TOKEN_TYPE_EMAIL => false, + Auth::TOKEN_TYPE_MAGIC_URL, + Auth::TOKEN_TYPE_EMAIL => false, default => true }; @@ -273,21 +268,16 @@ $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([$store->getKey() => $encoded])); + $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $sessionSecret)])); } $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $protocol = $request->getProtocol(); $response - ->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')) + ->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')) ->setStatusCode(Response::STATUS_CODE_CREATED); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); @@ -296,7 +286,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res ->setAttribute('current', true) ->setAttribute('countryName', $countryName) ->setAttribute('expire', $expire) - ->setAttribute('secret', $encoded) + ->setAttribute('secret', Auth::encodeSession($user->getId(), $sessionSecret)) ; $response->dynamic($session, Response::MODEL_SESSION); @@ -382,9 +372,7 @@ App::post('/v1/account') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); $passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; - $proof = new ProofsPassword(); - $hash = $proof->hash($password); - + $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); try { $userId = $userId == 'unique()' ? ID::unique() : $userId; $user->setAttributes([ @@ -397,11 +385,11 @@ App::post('/v1/account') 'email' => $email, 'emailVerification' => false, 'status' => true, - 'password' => $hash, - 'passwordHistory' => $passwordHistory > 0 ? [$hash] : [], + 'password' => $password, + 'passwordHistory' => $passwordHistory > 0 ? [$password] : [], 'passwordUpdate' => DateTime::now(), - 'hash' => $proof->getHash()->getName(), - 'hashOptions' => $proof->getHash()->getOptions(), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'registration' => DateTime::now(), 'reset' => false, 'name' => $name, @@ -556,13 +544,12 @@ App::get('/v1/account/sessions') ->inject('response') ->inject('user') ->inject('locale') - ->inject('store') - ->inject('proofForToken') - ->action(function (Response $response, Document $user, Locale $locale, Store $store, ProofsToken $proofForToken) { + ->inject('project') + ->action(function (Response $response, Document $user, Locale $locale, Document $project) { $sessions = $user->getAttribute('sessions', []); - $current = Auth::sessionVerify($sessions, $store->getProperty('secret', ''), $proofForToken); + $current = Auth::sessionVerify($sessions, Auth::$secret); foreach ($sessions as $key => $session) {/** @var Document $session */ $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); @@ -609,9 +596,7 @@ App::delete('/v1/account/sessions') ->inject('locale') ->inject('queueForEvents') ->inject('queueForDeletes') - ->inject('store') - ->inject('proofForToken') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) { + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes) { $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); @@ -627,13 +612,13 @@ App::delete('/v1/account/sessions') ->setAttribute('current', false) ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))); - if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { + if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { $session->setAttribute('current', true); // If current session delete the cookies too $response - ->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')); + ->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')); // Use current session for events. $queueForEvents @@ -677,13 +662,12 @@ App::get('/v1/account/sessions/:sessionId') ->inject('response') ->inject('user') ->inject('locale') - ->inject('store') - ->inject('proofForToken') - ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Store $store, ProofsToken $proofForToken) { + ->inject('project') + ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Document $project) { $sessions = $user->getAttribute('sessions', []); $sessionId = ($sessionId === 'current') - ? Auth::sessionVerify($user->getAttribute('sessions'), $store->getProperty('secret', ''), $proofForToken) + ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) : $sessionId; foreach ($sessions as $session) {/** @var Document $session */ @@ -691,7 +675,7 @@ App::get('/v1/account/sessions/:sessionId') $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session - ->setAttribute('current', ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret')))) + ->setAttribute('current', ($session->getAttribute('secret') == Auth::hash(Auth::$secret))) ->setAttribute('countryName', $countryName) ->setAttribute('secret', $session->getAttribute('secret', '')) ; @@ -734,13 +718,12 @@ App::delete('/v1/account/sessions/:sessionId') ->inject('locale') ->inject('queueForEvents') ->inject('queueForDeletes') - ->inject('store') - ->inject('proofForToken') - ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) { + ->inject('project') + ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Document $project) { $protocol = $request->getProtocol(); $sessionId = ($sessionId === 'current') - ? Auth::sessionVerify($user->getAttribute('sessions'), $store->getProperty('secret', ''), $proofForToken) + ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) : $sessionId; $sessions = $user->getAttribute('sessions', []); @@ -757,7 +740,7 @@ App::delete('/v1/account/sessions/:sessionId') $session->setAttribute('current', false); - if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { // If current session delete the cookies too + if ($session->getAttribute('secret') == Auth::hash(Auth::$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'))); @@ -767,8 +750,8 @@ App::delete('/v1/account/sessions/:sessionId') } $response - ->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')); + ->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')); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -819,12 +802,10 @@ App::patch('/v1/account/sessions/:sessionId') ->inject('dbForProject') ->inject('project') ->inject('queueForEvents') - ->inject('store') - ->inject('proofForToken') - ->action(function (?string $sessionId, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents, Store $store, ProofsToken $proofForToken) { + ->action(function (?string $sessionId, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents) { $sessionId = ($sessionId === 'current') - ? Auth::sessionVerify($user->getAttribute('sessions'), $store->getProperty('secret', ''), $proofForToken) + ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) : $sessionId; $sessions = $user->getAttribute('sessions', []); @@ -841,7 +822,7 @@ App::patch('/v1/account/sessions/:sessionId') } // Extend session - $authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $session->setAttribute('expire', DateTime::addSeconds(new \DateTime(), $authDuration)); // Refresh OAuth access token @@ -913,10 +894,7 @@ App::post('/v1/account/sessions/email') ->inject('queueForEvents') ->inject('queueForMails') ->inject('hooks') - ->inject('store') - ->inject('proofForPassword') - ->inject('proofForToken') - ->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, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { + ->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) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -924,9 +902,7 @@ App::post('/v1/account/sessions/email') Query::equal('email', [$email]), ]); - $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'))) { + if ($profile->isEmpty() || empty($profile->getAttribute('passwordUpdate')) || !Auth::passwordVerify($password, $profile->getAttribute('password'), $profile->getAttribute('hash'), $profile->getAttribute('hashOptions'))) { throw new Exception(Exception::USER_INVALID_CREDENTIALS); } @@ -938,18 +914,18 @@ App::post('/v1/account/sessions/email') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, false]); - $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); $session = new Document(array_merge( [ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => SESSION_PROVIDER_EMAIL, + 'provider' => Auth::SESSION_PROVIDER_EMAIL, 'providerUid' => $email, - 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => ['password'], @@ -964,12 +940,11 @@ App::post('/v1/account/sessions/email') Authorization::setRole(Role::user($user->getId())->toString()); // Re-hash if not using recommended algo - if ($user->getAttribute('hash') !== $proofForPassword->getHash()->getName()) { - $proofForPasswordUpdated = new ProofsPassword(); + if ($user->getAttribute('hash') !== Auth::DEFAULT_ALGO) { $user - ->setAttribute('password', $proofForPasswordUpdated->hash($password)) - ->setAttribute('hash', $proofForPasswordUpdated->getHash()->getName()) - ->setAttribute('hashOptions', $proofForPasswordUpdated->getHash()->getOptions()); + ->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS)) + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); $dbForProject->updateDocument('users', $user->getId(), $user); } @@ -981,20 +956,17 @@ 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([$store->getKey() => $encoded])); + $response + ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) + ; } $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $response - ->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')) + ->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')) ->setStatusCode(Response::STATUS_CODE_CREATED) ; @@ -1003,7 +975,7 @@ App::post('/v1/account/sessions/email') $session ->setAttribute('current', true) ->setAttribute('countryName', $countryName) - ->setAttribute('secret', $encoded) + ->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)) ; $queueForEvents @@ -1057,10 +1029,7 @@ App::post('/v1/account/sessions/anonymous') ->inject('dbForProject') ->inject('geodb') ->inject('queueForEvents') - ->inject('store') - ->inject('proofForPassword') - ->inject('proofForToken') - ->action(function (Request $request, Response $response, Locale $locale, Document $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { + ->action(function (Request $request, Response $response, Locale $locale, Document $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents) { $protocol = $request->getProtocol(); if ('console' === $project->getId()) { @@ -1089,8 +1058,8 @@ App::post('/v1/account/sessions/anonymous') 'emailVerification' => false, 'status' => true, 'password' => null, - 'hash' => $proofForPassword->getHash()->getName(), - 'hashOptions' => $proofForPassword->getHash()->getOptions(), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -1108,18 +1077,18 @@ App::post('/v1/account/sessions/anonymous') Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); // Create session token - $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); $session = new Document(array_merge( [ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => SESSION_PROVIDER_ANONYMOUS, - 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak + 'provider' => Auth::SESSION_PROVIDER_ANONYMOUS, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => ['anonymous'], @@ -1146,20 +1115,15 @@ 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([$store->getKey() => $encoded])); + $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); } $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $response - ->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')) + ->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')) ->setStatusCode(Response::STATUS_CODE_CREATED) ; @@ -1168,7 +1132,7 @@ App::post('/v1/account/sessions/anonymous') $session ->setAttribute('current', true) ->setAttribute('countryName', $countryName) - ->setAttribute('secret', $encoded) + ->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)) ; $response->dynamic($session, Response::MODEL_SESSION); @@ -1209,9 +1173,6 @@ 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') @@ -1404,10 +1365,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('dbForProject') ->inject('geodb') ->inject('queueForEvents') - ->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, Document $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) use ($oauthDefaultSuccess) { + ->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) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1551,7 +1509,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } $sessions = $user->getAttribute('sessions', []); - $current = Auth::sessionVerify($sessions, $store->getProperty('secret', ''), $proofForToken); + $current = Auth::sessionVerify($sessions, Auth::$secret); if ($current) { // Delete current session of new one. $currentDocument = $dbForProject->getDocument('sessions', $current); @@ -1632,8 +1590,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'emailVerification' => true, 'status' => true, // Email should already be authenticated by OAuth2 provider 'password' => null, - 'hash' => $proofForPassword->getHash()->getName(), - 'hashOptions' => $proofForPassword->getHash()->getOptions(), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -1732,19 +1690,18 @@ 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'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::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 = $proofsForTokenOAuth2->generate(); + $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_OAUTH2); $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 + 'type' => Auth::TOKEN_TYPE_OAUTH2, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -1772,7 +1729,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } else { $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); $session = new Document(array_merge([ '$id' => ID::unique(), @@ -1782,8 +1739,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'providerUid' => $oauth2ID, 'providerAccessToken' => $accessToken, 'providerRefreshToken' => $refreshToken, - 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry), - 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak + 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry), + 'secret' => Auth::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 @@ -1799,13 +1756,8 @@ 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([$store->getKey() => $encoded])); + $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); } $queueForEvents @@ -1818,13 +1770,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'] = $store->getKey(); - $query['secret'] = $encoded; + $query['key'] = Auth::$cookieName; + $query['secret'] = Auth::encodeSession($user->getId(), $secret); } $response - ->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')); + ->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')); } if (isset($sessionUpgrade) && $sessionUpgrade) { @@ -1982,8 +1934,7 @@ App::post('/v1/account/tokens/magic-url') ->inject('locale') ->inject('queueForEvents') ->inject('queueForMails') - ->inject('proofForPassword') - ->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, ProofsPassword $proofForPassword) { + ->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) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); } @@ -2029,8 +1980,8 @@ App::post('/v1/account/tokens/magic-url') 'emailVerification' => false, 'status' => true, 'password' => null, - 'hash' => $proofForPassword->getHash()->getName(), - 'hashOptions' => $proofForPassword->getHash()->getOptions(), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -2048,18 +1999,15 @@ App::post('/v1/account/tokens/magic-url') Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); } - $proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL); - $proofForToken->setHash(new Sha()); - - $tokenSecret = $proofForToken->generate(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); + $tokenSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_MAGIC_URL); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => TOKEN_TYPE_MAGIC_URL, - 'secret' => $proofForToken->hash($tokenSecret), // One way hash encryption to protect DB leak + 'type' => Auth::TOKEN_TYPE_MAGIC_URL, + 'secret' => Auth::hash($tokenSecret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -2238,9 +2186,7 @@ App::post('/v1/account/tokens/email') ->inject('locale') ->inject('queueForEvents') ->inject('queueForMails') - ->inject('proofForPassword') - ->inject('proofForCode') - ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, ProofsCode $proofForCode) { + ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); } @@ -2284,8 +2230,8 @@ App::post('/v1/account/tokens/email') 'emailVerification' => false, 'status' => true, 'password' => null, - 'hash' => $proofForPassword->getHash()->getName(), - 'hashOptions' => $proofForPassword->getHash()->getOptions(), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, @@ -2324,15 +2270,15 @@ App::post('/v1/account/tokens/email') $dbForProject->purgeCachedDocument('users', $user->getId()); } - $tokenSecret = $proofForCode->generate(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_OTP)); + $tokenSecret = Auth::codeGenerator(6); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_OTP)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => TOKEN_TYPE_EMAIL, - 'secret' => $proofForCode->hash($tokenSecret), // One way hash encryption to protect DB leak + 'type' => Auth::TOKEN_TYPE_EMAIL, + 'secret' => Auth::hash($tokenSecret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -2519,13 +2465,7 @@ App::put('/v1/account/sessions/magic-url') ->inject('geodb') ->inject('queueForEvents') ->inject('queueForMails') - ->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); - }); + ->action($createSession); App::put('/v1/account/sessions/phone') ->desc('Update phone session') @@ -2566,9 +2506,6 @@ 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') @@ -2609,9 +2546,7 @@ App::post('/v1/account/tokens/phone') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('plan') - ->inject('store') - ->inject('proofForCode') - ->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, Store $store, ProofsCode $proofForCode) { + ->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) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -2690,15 +2625,15 @@ App::post('/v1/account/tokens/phone') } } - $secret ??= $proofForCode->generate(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_OTP)); + $secret ??= Auth::codeGenerator(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_OTP)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => TOKEN_TYPE_PHONE, - 'secret' => $proofForCode->hash($secret), + 'type' => Auth::TOKEN_TYPE_PHONE, + 'secret' => Auth::hash($secret), 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -2773,11 +2708,7 @@ App::post('/v1/account/tokens/phone') ->setPayload($response->output($token, Response::MODEL_TOKEN), sensitive: ['secret']); // Encode secret for clients - $encoded = $store - ->setProperty('id', $user->getId()) - ->setProperty('secret', $secret) - ->encode(); - $token->setAttribute('secret', $encoded); + $token->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -2808,16 +2739,20 @@ App::post('/v1/account/jwts') ->label('abuse-key', 'url:{url},userId:{userId}') ->inject('response') ->inject('user') - ->inject('store') - ->inject('proofForToken') - ->action(function (Response $response, Document $user, Store $store, ProofsToken $proofForToken) { + ->inject('dbForProject') + ->action(function (Response $response, Document $user, Database $dbForProject) { $sessions = $user->getAttribute('sessions', []); + $current = new Document(); - $sessionId = Auth::sessionVerify($sessions, $store->getProperty('secret', ''), $proofForToken); + 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 (!$sessionId) { + if ($current->isEmpty()) { throw new Exception(Exception::USER_SESSION_NOT_FOUND); } @@ -2828,7 +2763,7 @@ App::post('/v1/account/jwts') ->dynamic(new Document([ 'jwt' => $jwt->encode([ 'userId' => $user->getId(), - 'sessionId' => $sessionId, + 'sessionId' => $current->getId(), ]) ]), Response::MODEL_JWT); }); @@ -3004,23 +2939,18 @@ App::patch('/v1/account/password') ->inject('dbForProject') ->inject('queueForEvents') ->inject('hooks') - ->inject('store') - ->inject('proofForPassword') - ->inject('proofForToken') - ->action(function (string $password, string $oldPassword, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { - $userProofForPassword = ProofsPassword::createHash($user->getAttribute('hash'), $user->getAttribute('hashOptions')); + ->action(function (string $password, string $oldPassword, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents, Hooks $hooks) { + // Check old password only if its an existing user. - if (!empty($user->getAttribute('passwordUpdate')) && !$userProofForPassword->verify($oldPassword, $user->getAttribute('password'))) { // Double check user password + if (!empty($user->getAttribute('passwordUpdate')) && !Auth::passwordVerify($oldPassword, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions'))) { // Double check user password throw new Exception(Exception::USER_INVALID_CREDENTIALS); } - $newPassword = $proofForPassword->hash($password); + $newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); $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, $hash); + $validator = new PasswordHistory($history, $user->getAttribute('hash'), $user->getAttribute('hashOptions')); if (!$validator->isValid($password)) { throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED); } @@ -3042,13 +2972,11 @@ App::patch('/v1/account/password') ->setAttribute('password', $newPassword) ->setAttribute('passwordHistory', $history) ->setAttribute('passwordUpdate', DateTime::now()) - ->setAttribute('hash', $proofForPassword->getHash()->getName()) - ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()); + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); $sessions = $user->getAttribute('sessions', []); - - $current = Auth::sessionVerify($sessions, $store->getProperty('secret', ''), $proofForToken); - + $current = Auth::sessionVerify($sessions, Auth::$secret); $invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false; if ($invalidate && !empty($current)) { foreach ($sessions as $session) { @@ -3096,16 +3024,13 @@ App::patch('/v1/account/email') ->inject('queueForEvents') ->inject('project') ->inject('hooks') - ->inject('proofForPassword') - ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, ProofsPassword $proofForPassword) { + ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks) { // 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) && - !$userProofForPassword->verify($password, $user->getAttribute('password')) + !Auth::passwordVerify($password, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions')) ) { // Double check user password throw new Exception(Exception::USER_INVALID_CREDENTIALS); } @@ -3132,9 +3057,9 @@ App::patch('/v1/account/email') if (empty($passwordUpdate)) { $user - ->setAttribute('password', $proofForPassword->hash($password)) - ->setAttribute('hash', $proofForPassword->getHash()->getName()) - ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()) + ->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS)) + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) ->setAttribute('passwordUpdate', DateTime::now()); } @@ -3196,16 +3121,13 @@ App::patch('/v1/account/phone') ->inject('queueForEvents') ->inject('project') ->inject('hooks') - ->inject('proofForPassword') - ->action(function (string $phone, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, ProofsPassword $proofForPassword) { + ->action(function (string $phone, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks) { // 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) && - !$userProofForPassword->verify($password, $user->getAttribute('password')) + !Auth::passwordVerify($password, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions')) ) { // Double check user password throw new Exception(Exception::USER_INVALID_CREDENTIALS); } @@ -3229,9 +3151,9 @@ App::patch('/v1/account/phone') if (empty($passwordUpdate)) { $user - ->setAttribute('password', $proofForPassword->hash($password)) - ->setAttribute('hash', $proofForPassword->getHash()->getName()) - ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()) + ->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS)) + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) ->setAttribute('passwordUpdate', DateTime::now()); } @@ -3320,8 +3242,7 @@ App::patch('/v1/account/status') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('store') - ->action(function (?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store) { + ->action(function (?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { $user->setAttribute('status', false); @@ -3337,8 +3258,8 @@ App::patch('/v1/account/status') $protocol = $request->getProtocol(); $response - ->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')) + ->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')) ; $response->dynamic($user, Response::MODEL_ACCOUNT); @@ -3378,8 +3299,7 @@ App::post('/v1/account/recovery') ->inject('locale') ->inject('queueForMails') ->inject('queueForEvents') - ->inject('proofForToken') - ->action(function (string $email, string $url, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Mail $queueForMails, Event $queueForEvents, ProofsToken $proofForToken) { + ->action(function (string $email, string $url, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Mail $queueForMails, Event $queueForEvents) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); @@ -3401,15 +3321,15 @@ App::post('/v1/account/recovery') throw new Exception(Exception::USER_BLOCKED); } - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_RECOVERY)); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_RECOVERY)); - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_RECOVERY); $recovery = new Document([ '$id' => ID::unique(), 'userId' => $profile->getId(), 'userInternalId' => $profile->getSequence(), - 'type' => TOKEN_TYPE_RECOVERY, - 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak + 'type' => Auth::TOKEN_TYPE_RECOVERY, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -3557,9 +3477,7 @@ App::put('/v1/account/recovery') ->inject('project') ->inject('queueForEvents') ->inject('hooks') - ->inject('proofForPassword') - ->inject('proofForToken') - ->action(function (string $userId, string $secret, string $password, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents, Hooks $hooks, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { + ->action(function (string $userId, string $secret, string $password, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents, Hooks $hooks) { $profile = $dbForProject->getDocument('users', $userId); if ($profile->isEmpty()) { @@ -3567,7 +3485,7 @@ App::put('/v1/account/recovery') } $tokens = $profile->getAttribute('tokens', []); - $verifiedToken = Auth::tokenVerify($tokens, TOKEN_TYPE_RECOVERY, $secret, $proofForToken); + $verifiedToken = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_RECOVERY, $secret); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -3575,14 +3493,12 @@ App::put('/v1/account/recovery') Authorization::setRole(Role::user($profile->getId())->toString()); - $newPassword = $proofForPassword->hash($password); + $newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); - $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, $hash); + $validator = new PasswordHistory($history, $profile->getAttribute('hash'), $profile->getAttribute('hashOptions')); if (!$validator->isValid($password)) { throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED); } @@ -3594,12 +3510,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', $proofForPassword->getHash()->getName()) - ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()) - ->setAttribute('emailVerification', true)); + ->setAttribute('password', $newPassword) + ->setAttribute('passwordHistory', $history) + ->setAttribute('passwordUpdate', DateTime::now()) + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) + ->setAttribute('emailVerification', true)); $user->setAttributes($profile->getArrayCopy()); @@ -3673,8 +3589,7 @@ App::post('/v1/account/verifications/email') ->inject('locale') ->inject('queueForEvents') ->inject('queueForMails') - ->inject('proofForToken') - ->action(function (string $url, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsToken $proofForToken) { + ->action(function (string $url, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); @@ -3689,15 +3604,15 @@ App::post('/v1/account/verifications/email') throw new Exception(Exception::USER_EMAIL_ALREADY_VERIFIED); } - $verificationSecret = $proofForToken->generate(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); + $verificationSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_VERIFICATION); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); $verification = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => TOKEN_TYPE_VERIFICATION, - 'secret' => $proofForToken->hash($verificationSecret), // One way hash encryption to protect DB leak + 'type' => Auth::TOKEN_TYPE_VERIFICATION, + 'secret' => Auth::hash($verificationSecret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -3886,8 +3801,7 @@ App::put('/v1/account/verifications/email') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('proofForToken') - ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, ProofsToken $proofForToken) { + ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { $profile = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); @@ -3896,7 +3810,7 @@ App::put('/v1/account/verifications/email') } $tokens = $profile->getAttribute('tokens', []); - $verifiedToken = Auth::tokenVerify($tokens, TOKEN_TYPE_VERIFICATION, $secret, $proofForToken); + $verifiedToken = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_VERIFICATION, $secret); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -3961,8 +3875,7 @@ App::post('/v1/account/verifications/phone') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('plan') - ->inject('proofForCode') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsCode $proofForCode) { + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -3987,15 +3900,15 @@ App::post('/v1/account/verifications/phone') } } - $secret ??= $proofForCode->generate(); - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); + $secret ??= Auth::codeGenerator(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); $verification = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => TOKEN_TYPE_PHONE, - 'secret' => $proofForCode->hash($secret), + 'type' => Auth::TOKEN_TYPE_PHONE, + 'secret' => Auth::hash($secret), 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), @@ -4106,8 +4019,7 @@ App::put('/v1/account/verifications/phone') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('proofForCode') - ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, ProofsCode $proofForCode) { + ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { $profile = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); @@ -4115,7 +4027,7 @@ App::put('/v1/account/verifications/phone') throw new Exception(Exception::USER_NOT_FOUND); } - $verifiedToken = Auth::tokenVerify($user->getAttribute('tokens', []), TOKEN_TYPE_PHONE, $secret, $proofForCode); + $verifiedToken = Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_PHONE, $secret); if (!$verifiedToken) { throw new Exception(Exception::USER_INVALID_TOKEN); @@ -4756,18 +4668,15 @@ App::post('/v1/account/mfa/challenge') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('plan') - ->inject('proofForToken') - ->inject('proofForCode') - ->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, ProofsToken $proofForToken, ProofsCode $proofForCode) { + ->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) { - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CONFIRM)); - - $code = $proofForCode->generate(); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); + $code = Auth::codeGenerator(); $challenge = new Document([ 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'type' => $factor, - 'token' => $proofForToken->generate(), + 'token' => Auth::tokenGenerator(), 'code' => $code, 'expire' => $expire, '$permissions' => [ @@ -5112,9 +5021,7 @@ App::post('/v1/account/targets/push') ->inject('request') ->inject('response') ->inject('dbForProject') - ->inject('store') - ->inject('proofForToken') - ->action(function (string $targetId, string $identifier, string $providerId, Event $queueForEvents, Document $user, Request $request, Response $response, Database $dbForProject, Store $store, ProofsToken $proofForToken) { + ->action(function (string $targetId, string $identifier, string $providerId, Event $queueForEvents, Document $user, Request $request, Response $response, Database $dbForProject) { $targetId = $targetId == 'unique()' ? ID::unique() : $targetId; $provider = Authorization::skip(fn () => $dbForProject->getDocument('providers', $providerId)); @@ -5130,7 +5037,7 @@ App::post('/v1/account/targets/push') $device = $detector->getDevice(); - $sessionId = Auth::sessionVerify($user->getAttribute('sessions', []), $store->getProperty('secret', ''), $proofForToken); + $sessionId = Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret); $session = $dbForProject->getDocument('sessions', $sessionId); try { diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 1d68377d8c..80d407322e 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,6 +1,7 @@ APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordDictionary' => false, - 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, + 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false, 'mockNumbers' => [], 'sessionAlerts' => false, diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 387fb7d48b..7398e451b5 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -28,9 +28,6 @@ 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; @@ -474,9 +471,10 @@ 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', [])); - $roles = array_filter($roles, function ($role) { - return !in_array($role, [USER_ROLE_APPS, USER_ROLE_GUESTS, USER_ROLE_USERS]); + array_filter($roles, function ($role) { + return !in_array($role, [Auth::USER_ROLE_APPS, Auth::USER_ROLE_GUESTS, Auth::USER_ROLE_USERS]); }); return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); } @@ -495,9 +493,7 @@ App::post('/v1/teams/:teamId/memberships') ->inject('timelimit') ->inject('queueForStatsUsage') ->inject('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) { + ->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) { $isAppUser = Auth::isAppUser(Authorization::getRoles()); $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); @@ -570,7 +566,6 @@ 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' => [ @@ -584,9 +579,9 @@ App::post('/v1/teams/:teamId/memberships') 'emailVerification' => false, 'status' => true, // TODO: Set password empty? - 'password' => $hash, - 'hash' => $proofForPassword->getHash()->getName(), - 'hashOptions' => $proofForPassword->getHash()->getOptions(), + 'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, /** * Set the password update time to 0 for users created using * team invite and OAuth to allow password updates without an @@ -618,7 +613,7 @@ App::post('/v1/teams/:teamId/memberships') Query::equal('teamInternalId', [$team->getSequence()]), ]); - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator(); if ($membership->isEmpty()) { $membershipId = ID::unique(); $membership = new Document([ @@ -638,7 +633,7 @@ App::post('/v1/teams/:teamId/memberships') 'invited' => DateTime::now(), 'joined' => ($isPrivilegedUser || $isAppUser) ? DateTime::now() : null, 'confirm' => ($isPrivilegedUser || $isAppUser), - 'secret' => $proofForToken->hash($secret), + 'secret' => Auth::hash($secret), 'search' => implode(' ', [$membershipId, $invitee->getId()]) ]); @@ -651,7 +646,7 @@ App::post('/v1/teams/:teamId/memberships') } } elseif ($membership->getAttribute('confirm') === false) { - $membership->setAttribute('secret', $proofForToken->hash($secret)); + $membership->setAttribute('secret', Auth::hash($secret)); $membership->setAttribute('invited', DateTime::now()); if ($isPrivilegedUser || $isAppUser) { @@ -1075,7 +1070,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, [USER_ROLE_APPS, USER_ROLE_GUESTS, USER_ROLE_USERS]); + return !in_array($role, [Auth::USER_ROLE_APPS, Auth::USER_ROLE_GUESTS, Auth::USER_ROLE_USERS]); }); return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); } @@ -1190,9 +1185,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ->inject('project') ->inject('geodb') ->inject('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) { + ->action(function (string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Reader $geodb, Event $queueForEvents) { $protocol = $request->getProtocol(); $membership = $dbForProject->getDocument('memberships', $membershipId); @@ -1211,7 +1204,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH); } - if (!$proofForToken->verify($secret, $membership->getAttribute('secret'))) { + if (Auth::hash($secret) !== $membership->getAttribute('secret')) { throw new Exception(Exception::TEAM_INVALID_SECRET); } @@ -1245,9 +1238,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'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::addSeconds(new \DateTime(), $authDuration); - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator(); $session = new Document(array_merge([ '$id' => ID::unique(), '$permissions' => [ @@ -1257,9 +1250,9 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ], 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => SESSION_PROVIDER_EMAIL, + 'provider' => Auth::SESSION_PROVIDER_EMAIL, 'providerUid' => $user->getAttribute('email'), - 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), 'factors' => ['email'], @@ -1271,19 +1264,14 @@ 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([$store->getKey() => $encoded])); + $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); } $response ->addCookie( - name: $store->getKey() . '_legacy', - value: $encoded, + name: Auth::$cookieName . '_legacy', + value: Auth::encodeSession($user->getId(), $secret), expire: (new \DateTime($expire))->getTimestamp(), path: '/', domain: Config::getParam('cookieDomain'), @@ -1291,8 +1279,8 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') httponly: true ) ->addCookie( - name: $store->getKey(), - value: $encoded, + name: Auth::$cookieName, + value: Auth::encodeSession($user->getId(), $secret), expire: (new \DateTime($expire))->getTimestamp(), path: '/', domain: Config::getParam('cookieDomain'), diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 1dfa5c2603..5498a33bf5 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1,6 +1,7 @@ getAttribute('auths', [])['passwordHistory'] ?? 0; if (!empty($email)) { @@ -107,18 +97,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor } } - $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; - } - } - + $password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null; $user = new Document([ '$id' => $userId, '$permissions' => [ @@ -132,11 +111,11 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor 'phoneVerification' => false, 'status' => true, 'labels' => [], - 'password' => $hashedPassword, - 'passwordHistory' => is_null($hashedPassword) || $passwordHistory === 0 ? [] : [$hashedPassword], - 'passwordUpdate' => (!empty($hashedPassword)) ? DateTime::now() : null, - 'hash' => $hash->getName(), - 'hashOptions' => $hash->getOptions(), + '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], 'registration' => DateTime::now(), 'reset' => false, 'name' => $name, @@ -147,7 +126,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor 'search' => implode(' ', [$userId, $email, $phone, $name]), ]); - if ($hash instanceof Plaintext) { + if ($hash === 'plaintext') { $hooks->trigger('passwordValidator', [$dbForProject, $project, $plaintextPassword, &$user, true]); } @@ -238,9 +217,7 @@ 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) { - $plaintext = new Plaintext(); - - $user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks); + $user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($user, Response::MODEL_USER); @@ -274,10 +251,7 @@ 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) { - $bcrypt = new Bcrypt(); - $bcrypt->setCost(8); // Default cost - - $user = createUser($bcrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser('bcrypt', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -312,9 +286,7 @@ 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) { - $md5 = new MD5(); - - $user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser('md5', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -349,13 +321,7 @@ 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) { - $argon2 = new Argon2(); - $argon2 - ->setMemoryCost(2048) - ->setTimeCost(4) - ->setThreads(3); - - $user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser('argon2', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -391,12 +357,13 @@ 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) { - $sha = new Sha(); + $options = '{}'; + if (!empty($passwordVersion)) { - $sha->setVersion($passwordVersion); + $options = '{"version":"' . $passwordVersion . '"}'; } - $user = createUser($sha, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser('sha', $options, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -431,9 +398,7 @@ 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) { - $phpass = new PHPass(); - - $user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser('phpass', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -473,15 +438,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) { - $scrypt = new Scrypt(); - $scrypt - ->setSalt($passwordSalt) - ->setCpuCost($passwordCpu) - ->setMemoryCost($passwordMemory) - ->setParallelCost($passwordParallel) - ->setLength($passwordLength); + $options = [ + 'salt' => $passwordSalt, + 'costCpu' => $passwordCpu, + 'costMemory' => $passwordMemory, + 'costParallel' => $passwordParallel, + 'length' => $passwordLength + ]; - $user = createUser($scrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser('scrypt', \json_encode($options), $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -519,13 +484,7 @@ 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) { - $scryptModified = new ScryptModified(); - $scryptModified - ->setSalt($passwordSalt) - ->setSaltSeparator($passwordSaltSeparator) - ->setSignerKey($passwordSignerKey); - - $user = createUser($scryptModified, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); + $user = createUser('scryptMod', '{"signerKey":"' . $passwordSignerKey . '","saltSeparator":"' . $passwordSaltSeparator . '","salt":"' . $passwordSalt . '"}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -1110,6 +1069,7 @@ App::get('/v1/users/identities') } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + if (!empty($search)) { $queries[] = Query::search('search', $search); } @@ -1376,21 +1336,12 @@ App::patch('/v1/users/:userId/password') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); - // Create Argon2 hasher with default settings - $hasher = new Argon2(); - $hasher - ->setMemoryCost(2048) - ->setTimeCost(4) - ->setThreads(3); + $newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); - $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, $hash); + $validator = new PasswordHistory($history, $user->getAttribute('hash'), $user->getAttribute('hashOptions')); if (!$validator->isValid($password)) { throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED); } @@ -1403,8 +1354,8 @@ App::patch('/v1/users/:userId/password') ->setAttribute('password', $newPassword) ->setAttribute('passwordHistory', $history) ->setAttribute('passwordUpdate', DateTime::now()) - ->setAttribute('hash', $hasher->getName()) - ->setAttribute('hashOptions', $hasher->getOptions()); + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -2217,19 +2168,17 @@ App::post('/v1/users/:userId/sessions') ->inject('locale') ->inject('geodb') ->inject('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) { + ->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); - $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $session = new Document(array_merge( @@ -2237,8 +2186,8 @@ App::post('/v1/users/:userId/sessions') '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'provider' => SESSION_PROVIDER_SERVER, - 'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak + 'provider' => Auth::SESSION_PROVIDER_SERVER, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'factors' => ['server'], 'ip' => $request->getIP(), @@ -2262,13 +2211,8 @@ App::post('/v1/users/:userId/sessions') $dbForProject->purgeCachedDocument('users', $user->getId()); - $encoded = $store - ->setProperty('id', $user->getId()) - ->setProperty('secret', $secret) - ->encode(); - $session - ->setAttribute('secret', $encoded) + ->setAttribute('secret', Auth::encodeSession($user->getId(), $secret)) ->setAttribute('countryName', $countryName); $queueForEvents @@ -2303,7 +2247,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', TOKEN_EXPIRATION_GENERIC, new Range(60, TOKEN_EXPIRATION_LOGIN_LONG), 'Token expiration period in seconds. The default expiration is 15 minutes.', 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) ->inject('request') ->inject('response') ->inject('dbForProject') @@ -2315,17 +2259,15 @@ App::post('/v1/users/:userId/tokens') throw new Exception(Exception::USER_NOT_FOUND); } - $proofForToken = new Token($length); - $proofForToken->setHash(new Sha()); - $secret = $proofForToken->generate(); + $secret = Auth::tokenGenerator($length); $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $expire)); $token = new Document([ '$id' => ID::unique(), 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), - 'type' => TOKEN_TYPE_GENERIC, - 'secret' => $proofForToken->hash($secret), + 'type' => Auth::TOKEN_TYPE_GENERIC, + 'secret' => Auth::hash($secret), 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP() diff --git a/app/controllers/general.php b/app/controllers/general.php index 5ab30ee885..07de95a38f 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1185,29 +1185,17 @@ App::error() $trace = $error->getTrace(); if (php_sapi_name() === 'cli') { - $logLevel = $code >= 500 || $code == 0 ? 'error' : 'warning'; - $logPrefix = $code >= 500 || $code == 0 ? '[Error]' : '[Warning]'; - - Console::{$logLevel}($logPrefix . ' Timestamp: ' . date('c', time())); + Console::error('[Error] Timestamp: ' . date('c', time())); if ($route) { - Console::{$logLevel}($logPrefix . ' Status Code: ' . $code); - Console::{$logLevel}($logPrefix . ' URL: ' . $route->getMethod() . ' ' . $route->getPath()); + Console::error('[Error] Method: ' . $route->getMethod()); + Console::error('[Error] URL: ' . $route->getPath()); } - Console::{$logLevel}($logPrefix . ' Type: ' . get_class($error)); - Console::{$logLevel}($logPrefix . ' Message: ' . $message); - Console::{$logLevel}($logPrefix . ' File: ' . $file); - Console::{$logLevel}($logPrefix . ' Line: ' . $line); - Console::{$logLevel}($logPrefix . ' Trace:'); - foreach ($trace as $index => $entry) { - $traceFile = $entry['file'] ?? 'unknown'; - $traceLine = $entry['line'] ?? 0; - $traceFunction = $entry['function'] ?? 'unknown'; - $traceClass = $entry['class'] ?? ''; - $traceType = $entry['type'] ?? ''; - Console::{$logLevel}(" #{$index} {$traceFile}({$traceLine}): {$traceClass}{$traceType}{$traceFunction}()"); - } - Console::{$logLevel}(''); + + Console::error('[Error] Type: ' . get_class($error)); + Console::error('[Error] Message: ' . $message); + Console::error('[Error] File: ' . $file); + Console::error('[Error] Line: ' . $line); } switch ($class) { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 203433ead3..959ee77b7d 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -222,94 +222,39 @@ App::init() ->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) { $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']; - // Step 5: API Key Authentication + // 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(); - // Handle special app role case - if ($apiKey->getRole() === USER_ROLE_APPS) { + if ($apiKey->getRole() === Auth::USER_ROLE_APPS) { // Disable authorization checks for API keys Authorization::setDefaultStatus(false); $user = new Document([ '$id' => '', 'status' => true, - 'type' => ACTIVITY_TYPE_APP, + 'type' => Auth::ACTIVITY_TYPE_APP, 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), 'password' => '', 'name' => $apiKey->getName(), @@ -318,7 +263,6 @@ App::init() $queueForAudits->setUser($user); } - // For standard keys, update last accessed time if ($apiKey->getType() === API_KEY_STANDARD) { $dbKey = $project->find( key: 'secret', @@ -388,7 +332,7 @@ App::init() Authorization::setRole($authRole); } - // Step 6: Update project and user last activity + // Update project last activity if (!$project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { @@ -397,6 +341,7 @@ 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) { @@ -410,7 +355,6 @@ App::init() } } - // Steps 7-9: Access Control - Method, Namespace and Scope Validation /** * @var ?Method $method */ @@ -434,23 +378,21 @@ App::init() } } - // Step 9: Validate scope permissions + // Do now allow access if scope is not allowed $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) . ')'); } - // Step 10: Check if user is blocked + // Do not allow access to blocked accounts 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); @@ -458,7 +400,6 @@ 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); @@ -585,7 +526,7 @@ App::init() if (!$user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. - $userClone->setAttribute('type', ACTIVITY_TYPE_USER); + $userClone->setAttribute('type', Auth::ACTIVITY_TYPE_USER); $queueForAudits->setUser($userClone); } @@ -825,7 +766,7 @@ App::shutdown() if (!$user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. - $userClone->setAttribute('type', ACTIVITY_TYPE_USER); + $userClone->setAttribute('type', Auth::ACTIVITY_TYPE_USER); $queueForAudits->setUser($userClone); } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) { /** @@ -839,7 +780,7 @@ App::shutdown() $user = new Document([ '$id' => '', 'status' => true, - 'type' => ACTIVITY_TYPE_GUEST, + 'type' => Auth::ACTIVITY_TYPE_GUEST, 'email' => 'guest.' . $project->getId() . '@service.' . $request->getHostname(), 'password' => '', 'name' => 'Guest', diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php index 8f5e981362..ecabc641ec 100644 --- a/app/controllers/shared/api/auth.php +++ b/app/controllers/shared/api/auth.php @@ -20,7 +20,7 @@ App::init() $lastUpdate = $session->getAttribute('mfaUpdatedAt'); if (!empty($lastUpdate)) { $now = DateTime::now(); - $maxAllowedDate = DateTime::addSeconds(new \DateTime($lastUpdate), MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge + $maxAllowedDate = DateTime::addSeconds(new \DateTime($lastUpdate), Auth::MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge $isSessionFresh = DateTime::formatTz($maxAllowedDate) >= DateTime::formatTz($now); } diff --git a/app/init/constants.php b/app/init/constants.php index aaa3e1e206..3c8485aa4f 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -92,72 +92,6 @@ const APP_VCS_GITHUB_USERNAME = 'Appwrite'; const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io'; const APP_BRANDED_EMAIL_BASE_TEMPLATE = 'email-base-styled'; -// User Roles -const USER_ROLE_ANY = 'any'; -const USER_ROLE_GUESTS = 'guests'; -const USER_ROLE_USERS = 'users'; -const USER_ROLE_ADMIN = 'admin'; -const USER_ROLE_DEVELOPER = 'developer'; -const USER_ROLE_OWNER = 'owner'; -const USER_ROLE_APPS = 'apps'; -const USER_ROLE_SYSTEM = 'system'; - -/** - * 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; diff --git a/app/init/resources.php b/app/init/resources.php index 48a6a102e3..f91d18f698 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -24,16 +24,9 @@ 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; @@ -233,93 +226,72 @@ App::setResource('platforms', function (Request $request, Document $console, Doc ]; }, ['request', 'console', 'project', 'dbForPlatform']); -App::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken) { +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 */ - /** @var Utopia\Auth\Store $store */ - - /** - * 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); - $store->setKey('a_session_' . $project->getId()); + Auth::setCookieName('a_session_' . $project->getId()); if (APP_MODE_ADMIN === $mode) { - $store->setKey('a_session_' . $console->getId()); + Auth::setCookieName('a_session_' . $console->getId()); } - $store->decode( + $session = Auth::decodeSession( $request->getCookie( - $store->getKey(), // Get sessions - $request->getCookie($store->getKey() . '_legacy', '') + Auth::$cookieName, // Get sessions + $request->getCookie(Auth::$cookieName . '_legacy', '') ) ); // Get session from header for SSR clients - if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + if (empty($session['id']) && empty($session['secret'])) { $sessionHeader = $request->getHeader('x-appwrite-session', ''); if (!empty($sessionHeader)) { - $store->decode($sessionHeader); + $session = Auth::decodeSession($sessionHeader); } } // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies - if ($response) { // if in http context - add debug header + if ($response) { $response->addHeader('X-Debug-Fallback', 'false'); } - if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + if (empty($session['id']) && empty($session['secret'])) { if ($response) { $response->addHeader('X-Debug-Fallback', 'true'); } $fallback = $request->getHeader('x-fallback-cookies', ''); $fallback = \json_decode($fallback, true); - $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); + $session = Auth::decodeSession(((isset($fallback[Auth::$cookieName])) ? $fallback[Auth::$cookieName] : '')); } + Auth::$unique = $session['id'] ?? ''; + Auth::$secret = $session['secret'] ?? ''; + $user = new Document([]); - 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 (!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 ( $user->isEmpty() // Check a document has been found in the DB - || !Auth::sessionVerify($user->getAttribute('sessions', []), $store->getProperty('secret', ''), $proofForToken) + || !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret) ) { // Validate user has valid login token $user = new Document([]); } @@ -364,7 +336,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co $dbForPlatform->setMetadata('user', $user->getId()); return $user; -}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken']); +}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform']); App::setResource('project', function ($dbForPlatform, $request, $console) { /** @var Appwrite\Utopia\Request $request */ @@ -382,13 +354,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 (Document $user) { if ($user->isEmpty()) { return; } $sessions = $user->getAttribute('sessions', []); - $sessionId = Auth::sessionVerify($user->getAttribute('sessions'), $store->getProperty('secret', ''), $proofForToken); + $sessionId = Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret); if (!$sessionId) { return; @@ -401,7 +373,7 @@ App::setResource('session', function (Document $user, Store $store, Token $proof } return; -}, ['user', 'store', 'proofForToken']); +}, ['user']); App::setResource('console', function () { return new Document(Config::getParam('console')); @@ -975,37 +947,6 @@ App::setResource('apiKey', function (Request $request, Document $project): ?Key return Key::decode($project, $key); }, ['request', 'project']); - -App::setResource('store', function (): Store { - return new Store(); -}); - -App::setResource('proofForPassword', function (): Password { - $hash = new Argon2(); - $hash - ->setMemoryCost(2048) - ->setTimeCost(4) - ->setThreads(3); - - $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('executor', fn () => new Executor()); App::setResource('resourceToken', function ($project, $dbForProject, $request) { diff --git a/app/realtime.php b/app/realtime.php index 6084d32df1..e18ab8e10d 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -16,9 +16,6 @@ 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; @@ -681,24 +678,15 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } - $store = new Store(); + $session = Auth::decodeSession($message['data']['session']); + Auth::$unique = $session['id'] ?? ''; + Auth::$secret = $session['secret'] ?? ''; - $store->decode($message['data']['session']); - - $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()); + $user = $database->getDocument('users', Auth::$unique); if ( empty($user->getId()) // Check a document has been found in the DB - || !Auth::sessionVerify($user->getAttribute('sessions', []), $store->getProperty('secret', ''), $proofForToken) // Validate user has valid login token + || !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret) // Validate user has valid login token ) { // cookie not valid throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.'); diff --git a/composer.json b/composer.json index df6fe95d3a..bb843fd771 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,6 @@ "ext-sockets": "*", "appwrite/php-runtimes": "0.19.*", "appwrite/php-clamav": "2.0.*", - "utopia-php/auth": "0.4.*", "utopia-php/abuse": "1.*", "utopia-php/analytics": "0.10.*", "utopia-php/audit": "1.*", diff --git a/composer.lock b/composer.lock index f65e715647..f2efa0d785 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": "3e8df036b4cb47d2eae34be382e04800", + "content-hash": "568800edca746c4e8d0d50648b25f589", "packages": [ { "name": "adhocore/jwt", @@ -3592,61 +3592,6 @@ }, "time": "2025-10-20T07:14:26+00:00" }, - { - "name": "utopia-php/auth", - "version": "0.4.0", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/auth.git", - "reference": "02415e1a89cdbc14e3e16a7856ecf7f868869449" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/auth/zipball/02415e1a89cdbc14e3e16a7856ecf7f868869449", - "reference": "02415e1a89cdbc14e3e16a7856ecf7f868869449", - "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.4.0" - }, - "time": "2025-04-29T19:29:28+00:00" - }, { "name": "utopia-php/cache", "version": "0.13.1", diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 86d1e197bf..9af5045fa4 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -2,8 +2,13 @@ namespace Appwrite\Auth; -use Utopia\Auth\Proof; -use Utopia\Auth\Proofs\Token; +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 Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\Role; @@ -12,45 +17,186 @@ 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'; /** - * Token type to session provider mapping. + * User Unique ID. * - * @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible. - * @param int $type + * @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. + */ public static function getSessionProviderByTokenType(int $type): string { switch ($type) { - 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; + 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; default: - return SESSION_PROVIDER_TOKEN; + return Auth::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 @@ -60,12 +206,124 @@ 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 @@ -82,17 +340,36 @@ 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, Proof $proofForToken): false|Document + public static function tokenVerify(array $tokens, int $type = null, string $secret): false|Document { foreach ($tokens as $token) { if ( @@ -100,7 +377,7 @@ class Auth $token->isSet('expire') && $token->isSet('type') && ($type === null || $token->getAttribute('type') === $type) && - $proofForToken->verify($secret, $token->getAttribute('secret')) && + $token->getAttribute('secret') === self::hash($secret) && DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now()) ) { return $token; @@ -113,19 +390,18 @@ 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, Token $proofForToken) + public static function sessionVerify(array $sessions, string $secret) { foreach ($sessions as $session) { if ( $session->isSet('secret') && $session->isSet('provider') && - $proofForToken->verify($secret, $session->getAttribute('secret')) && + $session->getAttribute('secret') === self::hash($secret) && DateTime::formatTz(DateTime::format(new \DateTime($session->getAttribute('expire')))) >= DateTime::formatTz(DateTime::now()) ) { return $session->getId(); @@ -138,7 +414,6 @@ 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 @@ -146,9 +421,9 @@ class Auth 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) + in_array(self::USER_ROLE_OWNER, $roles) || + in_array(self::USER_ROLE_DEVELOPER, $roles) || + in_array(self::USER_ROLE_ADMIN, $roles) ) { return true; } @@ -159,14 +434,13 @@ 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(USER_ROLE_APPS, $roles)) { + if (in_array(self::USER_ROLE_APPS, $roles)) { return true; } @@ -176,7 +450,6 @@ 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 */ @@ -227,4 +500,16 @@ 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 new file mode 100644 index 0000000000..7134057581 --- /dev/null +++ b/src/Appwrite/Auth/Hash.php @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000000..c723b077b1 --- /dev/null +++ b/src/Appwrite/Auth/Hash/Argon2.php @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000000..8b6177f33a --- /dev/null +++ b/src/Appwrite/Auth/Hash/Bcrypt.php @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000000..8ade3dd5e2 --- /dev/null +++ b/src/Appwrite/Auth/Hash/Md5.php @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000000..988c38cc8d --- /dev/null +++ b/src/Appwrite/Auth/Hash/Phpass.php @@ -0,0 +1,290 @@ + 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 new file mode 100644 index 0000000000..821b1fba69 --- /dev/null +++ b/src/Appwrite/Auth/Hash/Scrypt.php @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000000..7717f324e5 --- /dev/null +++ b/src/Appwrite/Auth/Hash/Scryptmodified.php @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000000..c2ae3b52c1 --- /dev/null +++ b/src/Appwrite/Auth/Hash/Sha.php @@ -0,0 +1,50 @@ +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 09493c802f..44a75a6ee3 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -110,16 +110,16 @@ class Key $secret = $key; } - $role = USER_ROLE_APPS; + $role = Auth::USER_ROLE_APPS; $roles = Config::getParam('roles', []); - $scopes = $roles[USER_ROLE_APPS]['scopes'] ?? []; + $scopes = $roles[Auth::USER_ROLE_APPS]['scopes'] ?? []; $expired = false; $guestKey = new Key( $project->getId(), $type, - USER_ROLE_GUESTS, - $roles[USER_ROLE_GUESTS]['scopes'] ?? [], + Auth::USER_ROLE_GUESTS, + $roles[Auth::USER_ROLE_GUESTS]['scopes'] ?? [], 'UNKNOWN' ); diff --git a/src/Appwrite/Auth/MFA/Type.php b/src/Appwrite/Auth/MFA/Type.php index d1e267965a..3516ec3780 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,10 +51,9 @@ 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[] = $token->generate(); + $backups[] = Auth::tokenGenerator($length); } return $backups; diff --git a/src/Appwrite/Auth/Validator/PasswordHistory.php b/src/Appwrite/Auth/Validator/PasswordHistory.php index 9b40b6a794..f623ca180d 100644 --- a/src/Appwrite/Auth/Validator/PasswordHistory.php +++ b/src/Appwrite/Auth/Validator/PasswordHistory.php @@ -2,7 +2,7 @@ namespace Appwrite\Auth\Validator; -use Utopia\Auth\Hash; +use Appwrite\Auth\Auth; /** * Password. @@ -12,14 +12,16 @@ use Utopia\Auth\Hash; class PasswordHistory extends Password { protected array $history; - protected Hash $hash; + protected string $algo; + protected array $algoOptions; - public function __construct(array $history, Hash $hash) + public function __construct(array $history, string $algo, array $algoOptions = []) { parent::__construct(); $this->history = $history; - $this->hash = $hash; + $this->algo = $algo; + $this->algoOptions = $algoOptions; } /** @@ -44,7 +46,7 @@ class PasswordHistory extends Password public function isValid($value): bool { foreach ($this->history as $hash) { - if (!empty($hash) && $this->hash->verify($value, $hash)) { + if (!empty($hash) && Auth::passwordVerify($value, $hash, $this->algo, $this->algoOptions)) { return false; } } diff --git a/src/Appwrite/Migration/Version/V16.php b/src/Appwrite/Migration/Version/V16.php index 061ace31d7..9d72af9563 100644 --- a/src/Appwrite/Migration/Version/V16.php +++ b/src/Appwrite/Migration/Version/V16.php @@ -2,6 +2,7 @@ namespace Appwrite\Migration\Version; +use Appwrite\Auth\Auth; use Appwrite\Migration\Migration; use Utopia\CLI\Console; use Utopia\Config\Config; @@ -117,7 +118,7 @@ class V16 extends Migration * Set default authDuration */ $document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [ - 'duration' => TOKEN_EXPIRATION_LOGIN_LONG + 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG ])); /** diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php index 79e2a8377d..fbbd4bfde0 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', (new Password())->getHash()->getName()) + 'type' => $document->getAttribute('hash', Auth::DEFAULT_ALGO) ])); break; } diff --git a/src/Appwrite/Migration/Version/V20.php b/src/Appwrite/Migration/Version/V20.php index 10e2706d0e..9ff041eb33 100644 --- a/src/Appwrite/Migration/Version/V20.php +++ b/src/Appwrite/Migration/Version/V20.php @@ -2,6 +2,7 @@ namespace Appwrite\Migration\Version; +use Appwrite\Auth\Auth; use Appwrite\Migration\Migration; use Exception; use PDOException; @@ -631,15 +632,15 @@ class V20 extends Migration } break; case 'sessions': - $duration = $this->project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $this->project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $expire = DateTime::addSeconds(new \DateTime(), $duration); $document->setAttribute('expire', $expire); $factors = match ($document->getAttribute('provider')) { - SESSION_PROVIDER_EMAIL => ['password'], - SESSION_PROVIDER_PHONE => ['phone'], - SESSION_PROVIDER_ANONYMOUS => ['anonymous'], - SESSION_PROVIDER_TOKEN => ['token'], + Auth::SESSION_PROVIDER_EMAIL => ['password'], + Auth::SESSION_PROVIDER_PHONE => ['phone'], + Auth::SESSION_PROVIDER_ANONYMOUS => ['anonymous'], + Auth::SESSION_PROVIDER_TOKEN => ['token'], default => ['email'], }; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index b12c32cb23..69af3b7d04 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -18,8 +18,6 @@ use Appwrite\SDK\Response as SDKResponse; 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; @@ -94,8 +92,6 @@ class Create extends Base ->inject('queueForStatsUsage') ->inject('queueForFunctions') ->inject('geodb') - ->inject('store') - ->inject('proofForToken') ->inject('executor') ->callback($this->action(...)); } @@ -118,8 +114,6 @@ class Create extends Base StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, - Store $store, - Token $proofForToken, Executor $executor ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -204,7 +198,7 @@ class Create extends Base foreach ($sessions as $session) { /** @var Utopia\Database\Document $session */ - if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { // Find most recent active session for user ID and JWT headers + if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too $current = $session; } } diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index b210a020b9..c3b4e33593 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -2,11 +2,10 @@ 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; @@ -150,8 +149,6 @@ 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) { @@ -160,12 +157,12 @@ class Install extends Action } if ($var['filter'] === 'token') { - $input[$var['name']] = $token->generate(); + $input[$var['name']] = Auth::tokenGenerator(); continue; } if ($var['filter'] === 'password') { - $input[$var['name']] = $password->generate(); + $input[$var['name']] = Auth::passwordGenerator(); continue; } } diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index be542e7811..a88e2e641f 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Auth\Auth; use Exception; use Throwable; use Utopia\Audit\Audit; @@ -84,7 +85,7 @@ class Audits extends Action $userName = $user->getAttribute('name', ''); $userEmail = $user->getAttribute('email', ''); - $userType = $user->getAttribute('type', ACTIVITY_TYPE_USER); + $userType = $user->getAttribute('type', Auth::ACTIVITY_TYPE_USER); // Create event data $eventData = [ diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 1c146a335e..331a2668a3 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Auth\Auth; use Appwrite\Certificates\Adapter as CertificatesAdapter; use Appwrite\Deletes\Identities; use Appwrite\Deletes\Targets; @@ -707,7 +708,7 @@ class Deletes extends Action private function deleteExpiredSessions(Document $project, callable $getProjectDB): void { $dbForProject = $getProjectDB($project); - $duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG; + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::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 8ee3a2bdb6..abe67e7e86 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' => TOKEN_EXPIRATION_LOGIN_LONG, + 'default' => Auth::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'] ?? TOKEN_EXPIRATION_LOGIN_LONG); + $document->setAttribute('authDuration', $authValues['duration'] ?? Auth::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/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 9ebc3f03cf..c2b4896814 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,7 +169,6 @@ trait ProjectCustom $project = [ '$id' => $project['body']['$id'], 'name' => $project['body']['name'], - 'teamId' => $team['body']['$id'], 'apiKey' => $key['body']['secret'], 'devKey' => $devKey['body']['secret'], 'webhookId' => $webhook['body']['$id'], diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 51de5731bd..1df9ef6c18 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -45,6 +45,7 @@ class AccountConsoleClientTest extends Scope $this->assertEquals($response['headers']['status-code'], 201); $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + // create team $team = $this->client->call(Client::METHOD_POST, '/teams', [ 'origin' => 'http://localhost', @@ -55,7 +56,6 @@ class AccountConsoleClientTest extends Scope 'teamId' => 'unique()', 'name' => 'myteam' ]); - $this->assertEquals($team['headers']['status-code'], 201); $teamId = $team['body']['$id']; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 91dce5c09c..4e479344d3 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Projects; +use Appwrite\Auth\Auth; use Appwrite\Extend\Exception; use Appwrite\Tests\Async; use Tests\E2E\Client; @@ -865,7 +866,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year + $this->assertEquals(Auth::TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year /** * Test for SUCCESS @@ -1008,7 +1009,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, + 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -1021,7 +1022,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year + $this->assertEquals(Auth::TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year return ['projectId' => $projectId]; } diff --git a/tests/unit/Auth/AuthTest.php b/tests/unit/Auth/AuthTest.php index 7d69dc7f3e..705da42879 100644 --- a/tests/unit/Auth/AuthTest.php +++ b/tests/unit/Auth/AuthTest.php @@ -4,7 +4,6 @@ namespace Tests\Unit\Auth; use Appwrite\Auth\Auth; use PHPUnit\Framework\TestCase; -use Utopia\Auth\Proofs\Token; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; @@ -23,25 +22,203 @@ class AuthTest extends TestCase Authorization::setRole(Role::any()->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 { - $proofForToken = new Token(); $expireTime1 = 60 * 60 * 24; $secret = 'secret1'; - $hash = $proofForToken->hash($secret); + $hash = Auth::hash($secret); $tokens1 = [ new Document([ '$id' => ID::custom('token1'), 'secret' => $hash, - 'provider' => SESSION_PROVIDER_EMAIL, + 'provider' => Auth::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, + 'provider' => Auth::SESSION_PROVIDER_EMAIL, 'providerUid' => 'test@example.com', 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), ]), @@ -53,40 +230,39 @@ class AuthTest extends TestCase new Document([ // Correct secret and type time, wrong expire time '$id' => ID::custom('token1'), 'secret' => $hash, - 'provider' => SESSION_PROVIDER_EMAIL, + 'provider' => Auth::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, + 'provider' => Auth::SESSION_PROVIDER_EMAIL, 'providerUid' => 'test@example.com', 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), ]), ]; - $this->assertEquals(Auth::sessionVerify($tokens1, $secret, $proofForToken), 'token1'); - $this->assertEquals(Auth::sessionVerify($tokens1, 'false-secret', $proofForToken), false); - $this->assertEquals(Auth::sessionVerify($tokens2, $secret, $proofForToken), false); - $this->assertEquals(Auth::sessionVerify($tokens2, 'false-secret', $proofForToken), false); + $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 { - $proofForToken = new Token(); $secret = 'secret1'; - $hash = $proofForToken->hash($secret); + $hash = Auth::hash($secret); $tokens1 = [ new Document([ '$id' => ID::custom('token1'), - 'type' => TOKEN_TYPE_RECOVERY, + 'type' => Auth::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, + 'type' => Auth::TOKEN_TYPE_RECOVERY, 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), 'secret' => 'secret2', ]), @@ -95,13 +271,13 @@ class AuthTest extends TestCase $tokens2 = [ new Document([ // Correct secret and type time, wrong expire time '$id' => ID::custom('token1'), - 'type' => TOKEN_TYPE_RECOVERY, + 'type' => Auth::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, + 'type' => Auth::TOKEN_TYPE_RECOVERY, 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), 'secret' => 'secret2', ]), @@ -110,25 +286,25 @@ class AuthTest extends TestCase $tokens3 = [ // Correct secret and expire time, wrong type new Document([ '$id' => ID::custom('token1'), - 'type' => TOKEN_TYPE_INVITE, + 'type' => Auth::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, + 'type' => Auth::TOKEN_TYPE_RECOVERY, 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), 'secret' => 'secret2', ]), ]; - $this->assertEquals(Auth::tokenVerify($tokens1, TOKEN_TYPE_RECOVERY, $secret, $proofForToken), $tokens1[0]); - $this->assertEquals(Auth::tokenVerify($tokens1, null, $secret, $proofForToken), $tokens1[0]); - $this->assertEquals(Auth::tokenVerify($tokens1, TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); - $this->assertEquals(Auth::tokenVerify($tokens2, TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false); - $this->assertEquals(Auth::tokenVerify($tokens2, TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); - $this->assertEquals(Auth::tokenVerify($tokens3, TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false); - $this->assertEquals(Auth::tokenVerify($tokens3, TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); + $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 @@ -136,16 +312,16 @@ class AuthTest extends TestCase $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([USER_ROLE_ADMIN])); - $this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_DEVELOPER])); - $this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_OWNER])); - $this->assertEquals(false, Auth::isPrivilegedUser([USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isPrivilegedUser([USER_ROLE_SYSTEM])); + $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([USER_ROLE_APPS, USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isPrivilegedUser([USER_ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_OWNER, USER_ROLE_ADMIN, USER_ROLE_DEVELOPER])); + $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 @@ -153,16 +329,16 @@ class AuthTest extends TestCase $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([USER_ROLE_ADMIN])); - $this->assertEquals(false, Auth::isAppUser([USER_ROLE_DEVELOPER])); - $this->assertEquals(false, Auth::isAppUser([USER_ROLE_OWNER])); - $this->assertEquals(true, Auth::isAppUser([USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isAppUser([USER_ROLE_SYSTEM])); + $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([USER_ROLE_APPS, USER_ROLE_APPS])); - $this->assertEquals(true, Auth::isAppUser([USER_ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([USER_ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([USER_ROLE_OWNER, USER_ROLE_ADMIN, USER_ROLE_DEVELOPER])); + $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 @@ -242,7 +418,7 @@ class AuthTest extends TestCase public function testPrivilegedUserRoles(): void { - Authorization::setRole(USER_ROLE_OWNER); + Authorization::setRole(Auth::USER_ROLE_OWNER); $user = new Document([ '$id' => ID::custom('123'), 'emailVerification' => true, @@ -286,7 +462,7 @@ class AuthTest extends TestCase public function testAppUserRoles(): void { - Authorization::setRole(USER_ROLE_APPS); + Authorization::setRole(Auth::USER_ROLE_APPS); $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 56232dcc6b..8ae2114697 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Auth; use Ahc\Jwt\JWT; +use Appwrite\Auth\Auth; use Appwrite\Auth\Key; use PHPUnit\Framework\TestCase; use Utopia\Config\Config; @@ -20,7 +21,7 @@ class KeyTest extends TestCase 'collections.read', 'documents.read', ]; - $roleScopes = Config::getParam('roles', [])[USER_ROLE_APPS]['scopes']; + $roleScopes = Config::getParam('roles', [])[Auth::USER_ROLE_APPS]['scopes']; $key = static::generateKey($projectId, $usage, $scopes); $project = new Document(['$id' => $projectId,]); @@ -28,7 +29,7 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); - $this->assertEquals(USER_ROLE_APPS, $decoded->getRole()); + $this->assertEquals(Auth::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 536228b504..8ba0374093 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -59,7 +59,7 @@ class MessagingChannelsTest extends TestCase 'confirm' => true, 'roles' => [ empty($index % 2) - ? USER_ROLE_ADMIN + ? Auth::USER_ROLE_ADMIN : 'member', ] ] @@ -294,7 +294,7 @@ class MessagingChannelsTest extends TestCase } $role = empty($index % 2) - ? USER_ROLE_ADMIN + ? Auth::USER_ROLE_ADMIN : 'member'; $permissions = [ diff --git a/tests/unit/Migration/MigrationTest.php b/tests/unit/Migration/MigrationTest.php index 2dc47b9b2b..bb6c49d2fc 100644 --- a/tests/unit/Migration/MigrationTest.php +++ b/tests/unit/Migration/MigrationTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase; use ReflectionMethod; use Utopia\Database\Document; -class MigrationTest extends TestCase +abstract class MigrationTest extends TestCase { /** * @var Migration From f4efb81832d35c3d81c0db80e63f3e223f01025f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 21 Oct 2025 15:07:53 +1300 Subject: [PATCH 2/2] Update lock --- composer.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/composer.lock b/composer.lock index f2efa0d785..0dfe37ce9b 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": "568800edca746c4e8d0d50648b25f589", + "content-hash": "407c1717bfef580d733ff2bbb232ec8a", "packages": [ { "name": "adhocore/jwt", @@ -3792,16 +3792,16 @@ }, { "name": "utopia-php/database", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "da0d583e1590e37515edfa338d8684c01833455f" + "reference": "b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/da0d583e1590e37515edfa338d8684c01833455f", - "reference": "da0d583e1590e37515edfa338d8684c01833455f", + "url": "https://api.github.com/repos/utopia-php/database/zipball/b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e", + "reference": "b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e", "shasum": "" }, "require": { @@ -3811,7 +3811,7 @@ "php": ">=8.1", "utopia-php/cache": "0.13.*", "utopia-php/framework": "0.33.*", - "utopia-php/mongo": "0.10.*", + "utopia-php/mongo": "0.11.*", "utopia-php/pools": "0.8.*" }, "require-dev": { @@ -3844,9 +3844,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/3.0.0" + "source": "https://github.com/utopia-php/database/tree/3.0.2" }, - "time": "2025-10-20T05:51:31+00:00" + "time": "2025-10-20T23:58:56+00:00" }, { "name": "utopia-php/detector", @@ -4402,16 +4402,16 @@ }, { "name": "utopia-php/mongo", - "version": "0.10.0", + "version": "0.11.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18" + "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18", - "reference": "ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34bc0cda8ea368cde68702a6fffe2c3ac625398e", + "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e", "shasum": "" }, "require": { @@ -4457,9 +4457,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/0.10.0" + "source": "https://github.com/utopia-php/mongo/tree/0.11.0" }, - "time": "2025-10-02T04:50:07+00:00" + "time": "2025-10-20T11:11:23+00:00" }, { "name": "utopia-php/orchestration",