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 66327e2f3d..5563fc6a59 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -25,6 +25,7 @@ use Appwrite\Network\Validator\Redirect; use Appwrite\OpenSSL\OpenSSL; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; +use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; @@ -57,6 +58,7 @@ use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; use Utopia\Locale\Locale; +use Utopia\Storage\Validator\FileName; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; @@ -71,6 +73,7 @@ $oauthDefaultFailure = '/console/auth/oauth2/failure'; function sendSessionAlert(Locale $locale, Document $user, Document $project, Document $session, Mail $queueForMails) { $subject = $locale->getText("emails.sessionAlert.subject"); + $preview = $locale->getText("emails.sessionAlert.preview"); $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl'); @@ -132,6 +135,16 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc ->setSmtpSenderName($senderName); } + // session alerts should always have a client name! + $clientName = $session->getAttribute('clientName'); + if (empty($clientName)) { + // fallback to the user agent and then unknown! + $userAgent = $session->getAttribute('userAgent'); + $clientName = !empty($userAgent) ? $userAgent : 'UNKNOWN'; + + $session->setAttribute('clientName', $clientName); + } + $emailVariables = [ 'direction' => $locale->getText('settings.direction'), 'date' => (new \DateTime())->format('F j'), @@ -148,11 +161,13 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setVariables($emailVariables) ->setRecipient($email) ->trigger(); -}; +} +; $createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails) { @@ -825,7 +840,7 @@ App::patch('/v1/account/sessions/:sessionId') $session ->setAttribute('providerAccessToken', $oauth2->getAccessToken('')) ->setAttribute('providerRefreshToken', $oauth2->getRefreshToken('')) - ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$oauth2->getAccessTokenExpiry(''))); + ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $oauth2->getAccessTokenExpiry(''))); } // Save changes @@ -969,9 +984,11 @@ App::post('/v1/account/sessions/email') ; if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) { - if ($dbForProject->count('sessions', [ - Query::equal('userId', [$user->getId()]), - ]) !== 1) { + if ( + $dbForProject->count('sessions', [ + Query::equal('userId', [$user->getId()]), + ]) !== 1 + ) { sendSessionAlert($locale, $user, $project, $session, $queueForMails); } } @@ -1085,7 +1102,7 @@ App::post('/v1/account/sessions/anonymous') Authorization::setRole(Role::user($user->getId())->toString()); - $session = $dbForProject->createDocument('sessions', $session-> setAttribute('$permissions', [ + $session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [ Permission::read(Role::user($user->getId())), Permission::update(Role::user($user->getId())), Permission::delete(Role::user($user->getId())), @@ -1522,22 +1539,22 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') */ $isVerified = $oauth2->isEmailVerified($accessToken); - $userWithEmail = $dbForProject->findOne('users', [ - Query::equal('email', [$email]), + $identity = $dbForProject->findOne('identities', [ + Query::equal('provider', [$provider]), + Query::equal('providerUid', [$oauth2ID]), ]); - if (!$userWithEmail->isEmpty()) { - $user->setAttributes($userWithEmail->getArrayCopy()); + + if (!$identity->isEmpty()) { + $user = $dbForProject->getDocument('users', $identity->getAttribute('userId')); } // If user is not found, check if there is an identity with the same provider user ID if ($user === false || $user->isEmpty()) { - $identity = $dbForProject->findOne('identities', [ - Query::equal('provider', [$provider]), - Query::equal('providerUid', [$oauth2ID]), + $userWithEmail = $dbForProject->findOne('users', [ + Query::equal('email', [$email]), ]); - - if (!$identity->isEmpty()) { - $user = $dbForProject->getDocument('users', $identity->getAttribute('userId')); + if (!$userWithEmail->isEmpty()) { + $user->setAttributes($userWithEmail->getArrayCopy()); } } @@ -1646,13 +1663,13 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'providerEmail' => $email, 'providerAccessToken' => $accessToken, 'providerRefreshToken' => $refreshToken, - 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry), + 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry), ])); } else { $identity ->setAttribute('providerAccessToken', $accessToken) ->setAttribute('providerRefreshToken', $refreshToken) - ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry)); + ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry)); $dbForProject->updateDocument('identities', $identity->getId(), $identity); } @@ -1722,7 +1739,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'providerUid' => $oauth2ID, 'providerAccessToken' => $accessToken, 'providerRefreshToken' => $refreshToken, - 'providerAccessTokenExpiry' => DateTime::addSeconds(new \DateTime(), (int)$accessTokenExpiry), + '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(), @@ -2025,6 +2042,7 @@ App::post('/v1/account/tokens/magic-url') $url = Template::unParseURL($url); $subject = $locale->getText("emails.magicSession.subject"); + $preview = $locale->getText("emails.magicSession.preview"); $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); @@ -2113,6 +2131,7 @@ App::post('/v1/account/tokens/magic-url') $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setVariables($emailVariables) ->setRecipient($email) @@ -2225,7 +2244,30 @@ App::post('/v1/account/tokens/email') ]); $user->removeAttribute('$sequence'); - Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); + $user = Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); + try { + $target = Authorization::skip(fn () => $dbForProject->createDocument('targets', new Document([ + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], + 'userId' => $user->getId(), + 'userInternalId' => $user->getSequence(), + 'providerType' => MESSAGE_TYPE_EMAIL, + 'identifier' => $email, + ]))); + $user->setAttribute('targets', [...$user->getAttribute('targets', []), $target]); + } catch (Duplicate) { + $existingTarget = $dbForProject->findOne('targets', [ + Query::equal('identifier', [$email]), + ]); + if (!$existingTarget->isEmpty()) { + $user->setAttribute('targets', $existingTarget, Document::SET_TYPE_APPEND); + } + } + + $dbForProject->purgeCachedDocument('users', $user->getId()); } $tokenSecret = Auth::codeGenerator(6); @@ -2254,7 +2296,18 @@ App::post('/v1/account/tokens/email') $dbForProject->purgeCachedDocument('users', $user->getId()); $subject = $locale->getText("emails.otpSession.subject"); + $preview = $locale->getText("emails.otpSession.preview"); + $heading = $locale->getText("emails.otpSession.heading"); + $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? []; + $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); + + $validator = new FileName(); + if (!$validator->isValid($smtpBaseTemplate)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path'); + } + + $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl'; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2324,6 +2377,7 @@ App::post('/v1/account/tokens/email') } $emailVariables = [ + 'heading' => $heading, 'direction' => $locale->getText('settings.direction'), // {{user}}, {{project}} and {{otp}} are required in the templates 'user' => $user->getAttribute('name'), @@ -2337,9 +2391,23 @@ App::post('/v1/account/tokens/email') 'team' => '', ]; + if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { + $emailVariables = array_merge($emailVariables, [ + 'accentColor' => APP_EMAIL_ACCENT_COLOR, + 'logoUrl' => APP_EMAIL_LOGO_URL, + 'twitterUrl' => APP_SOCIAL_TWITTER, + 'discordUrl' => APP_SOCIAL_DISCORD, + 'githubUrl' => APP_SOCIAL_GITHUB_APPWRITE, + 'termsUrl' => APP_EMAIL_TERMS_URL, + 'privacyUrl' => APP_EMAIL_PRIVACY_URL, + ]); + } + $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) + ->setBodyTemplate($bodyTemplate) ->setVariables($emailVariables) ->setRecipient($email) ->trigger(); @@ -2379,7 +2447,10 @@ App::put('/v1/account/sessions/magic-url') ) ], contentType: ContentType::JSON, - deprecated: true, + deprecated: new Deprecated( + since: '1.6.0', + replaceWith: 'account.createSession' + ), )) ->label('abuse-limit', 10) ->label('abuse-key', 'ip:{ip},userId:{param-userId}') @@ -2417,7 +2488,10 @@ App::put('/v1/account/sessions/phone') ) ], contentType: ContentType::JSON, - deprecated: true, + deprecated: new Deprecated( + since: '1.6.0', + replaceWith: 'account.createSession' + ), )) ->label('abuse-limit', 10) ->label('abuse-key', 'ip:{ip},userId:{param-userId}') @@ -2686,10 +2760,12 @@ App::post('/v1/account/jwts') $response ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(['jwt' => $jwt->encode([ - 'userId' => $user->getId(), - 'sessionId' => $current->getId(), - ])]), Response::MODEL_JWT); + ->dynamic(new Document([ + 'jwt' => $jwt->encode([ + 'userId' => $user->getId(), + 'sessionId' => $current->getId(), + ]) + ]), Response::MODEL_JWT); }); App::get('/v1/account/prefs') @@ -2899,6 +2975,18 @@ App::patch('/v1/account/password') ->setAttribute('hash', Auth::DEFAULT_ALGO) ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); + $sessions = $user->getAttribute('sessions', []); + $current = Auth::sessionVerify($sessions, Auth::$secret); + $invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false; + if ($invalidate && !empty($current)) { + foreach ($sessions as $session) { + /** @var Document $session */ + if ($session->getId() !== $current) { + $dbForProject->deleteDocument('sessions', $session->getId()); + } + } + } + $user = $dbForProject->updateDocument('users', $user->getId(), $user); $queueForEvents->setParam('userId', $user->getId()); @@ -3110,7 +3198,7 @@ App::patch('/v1/account/prefs') ], contentType: ContentType::JSON )) - ->param('prefs', [], new Assoc(), 'Prefs key-value JSON object.') + ->param('prefs', [], new Assoc(), 'Prefs key-value JSON object.', example: '{"language":"en","timezone":"UTC","darkTheme":true}') ->inject('requestTimestamp') ->inject('response') ->inject('user') @@ -3265,6 +3353,7 @@ App::post('/v1/account/recovery') $projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]'); $body = $locale->getText("emails.recovery.body"); $subject = $locale->getText("emails.recovery.subject"); + $preview = $locale->getText("emails.recovery.preview"); $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); @@ -3339,6 +3428,7 @@ App::post('/v1/account/recovery') ->setBody($body) ->setVariables($emailVariables) ->setSubject($subject) + ->setPreview($preview) ->trigger(); $recovery->setAttribute('secret', $secret); @@ -3420,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', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) - ->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()); @@ -3446,27 +3536,48 @@ App::put('/v1/account/recovery') $response->dynamic($recoveryDocument, Response::MODEL_TOKEN); }); -App::post('/v1/account/verification') +App::post('/v1/account/verifications/email') + ->alias('/v1/account/verification') ->desc('Create email verification') ->groups(['api', 'account']) ->label('scope', 'account') ->label('event', 'users.[userId].verification.[tokenId].create') ->label('audits.event', 'verification.create') ->label('audits.resource', 'user/{response.userId}') - ->label('sdk', new Method( - namespace: 'account', - group: 'verification', - name: 'createVerification', - description: '/docs/references/account/create-email-verification.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_TOKEN, - ) - ], - contentType: ContentType::JSON, - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'verification', + name: 'createEmailVerification', + description: '/docs/references/account/create-email-verification.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_TOKEN, + ) + ], + contentType: ContentType::JSON, + ), + new Method( + namespace: 'account', + group: 'verification', + name: 'createVerification', + description: '/docs/references/account/create-email-verification.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_TOKEN, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.createEmailVerification' + ), + ) + ]) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},userId:{userId}') ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the verification email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['platforms', 'devKey']) // TODO add built-in confirm page @@ -3484,6 +3595,10 @@ App::post('/v1/account/verification') throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); } + if (empty($user->getAttribute('email'))) { + throw new Exception(Exception::USER_EMAIL_NOT_FOUND); + } + $url = htmlentities($url); if ($user->getAttribute('emailVerification')) { throw new Exception(Exception::USER_EMAIL_ALREADY_VERIFIED); @@ -3520,8 +3635,19 @@ App::post('/v1/account/verification') $projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]'); $body = $locale->getText("emails.verification.body"); + $preview = $locale->getText("emails.verification.preview"); $subject = $locale->getText("emails.verification.subject"); + $heading = $locale->getText("emails.verification.heading"); + $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? []; + $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); + + $validator = new FileName(); + if (!$validator->isValid($smtpBaseTemplate)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path'); + } + + $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl'; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -3581,6 +3707,7 @@ App::post('/v1/account/verification') } $emailVariables = [ + 'heading' => $heading, 'direction' => $locale->getText('settings.direction'), // {{user}}, {{redirect}} and {{project}} are required in default and custom templates 'user' => $user->getAttribute('name'), @@ -3590,9 +3717,23 @@ App::post('/v1/account/verification') 'team' => '', ]; + if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { + $emailVariables = array_merge($emailVariables, [ + 'accentColor' => APP_EMAIL_ACCENT_COLOR, + 'logoUrl' => APP_EMAIL_LOGO_URL, + 'twitterUrl' => APP_SOCIAL_TWITTER, + 'discordUrl' => APP_SOCIAL_DISCORD, + 'githubUrl' => APP_SOCIAL_GITHUB_APPWRITE, + 'termsUrl' => APP_EMAIL_TERMS_URL, + 'privacyUrl' => APP_EMAIL_PRIVACY_URL, + ]); + } + $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) + ->setBodyTemplate($bodyTemplate) ->setVariables($emailVariables) ->setRecipient($user->getAttribute('email')) ->setName($user->getAttribute('name') ?? '') @@ -3610,27 +3751,48 @@ App::post('/v1/account/verification') ->dynamic($verification, Response::MODEL_TOKEN); }); -App::put('/v1/account/verification') +App::put('/v1/account/verifications/email') + ->alias('/v1/account/verification') ->desc('Update email verification (confirmation)') ->groups(['api', 'account']) ->label('scope', 'public') ->label('event', 'users.[userId].verification.[tokenId].update') ->label('audits.event', 'verification.update') ->label('audits.resource', 'user/{response.userId}') - ->label('sdk', new Method( - namespace: 'account', - group: 'verification', - name: 'updateVerification', - description: '/docs/references/account/update-email-verification.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_TOKEN, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'verification', + name: 'updateEmailVerification', + description: '/docs/references/account/update-email-verification.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_TOKEN, + ) + ], + contentType: ContentType::JSON + ), + new Method( + namespace: 'account', + group: 'verification', + name: 'updateVerification', + description: '/docs/references/account/update-email-verification.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_TOKEN, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.updateEmailVerification' + ), + ) + ]) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},userId:{param-userId}') ->param('userId', '', new UID(), 'User ID.') @@ -3677,7 +3839,8 @@ App::put('/v1/account/verification') $response->dynamic($verification, Response::MODEL_TOKEN); }); -App::post('/v1/account/verification/phone') +App::post('/v1/account/verifications/phone') + ->alias('/v1/account/verification/phone') ->desc('Create phone verification') ->groups(['api', 'account', 'auth']) ->label('scope', 'account') @@ -3826,7 +3989,8 @@ App::post('/v1/account/verification/phone') ->dynamic($verification, Response::MODEL_TOKEN); }); -App::put('/v1/account/verification/phone') +App::put('/v1/account/verifications/phone') + ->alias('/v1/account/verification/phone') ->desc('Update phone verification (confirmation)') ->groups(['api', 'account']) ->label('scope', 'public') @@ -3953,20 +4117,40 @@ App::get('/v1/account/mfa/factors') ->desc('List factors') ->groups(['api', 'account', 'mfa']) ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'listMfaFactors', - description: '/docs/references/account/list-mfa-factors.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MFA_FACTORS, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'listMfaFactors', + description: '/docs/references/account/list-mfa-factors.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_FACTORS, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.listMFAFactors', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'listMFAFactors', + description: '/docs/references/account/list-mfa-factors.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_FACTORS, + ) + ], + contentType: ContentType::JSON + ) + ]) ->inject('response') ->inject('user') ->action(function (Response $response, Document $user) { @@ -3994,20 +4178,40 @@ App::post('/v1/account/mfa/authenticators/:type') ->label('audits.event', 'user.update') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'createMfaAuthenticator', - description: '/docs/references/account/create-mfa-authenticator.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MFA_TYPE, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'createMfaAuthenticator', + description: '/docs/references/account/create-mfa-authenticator.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_TYPE, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.createMFAAuthenticator', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'createMFAAuthenticator', + description: '/docs/references/account/create-mfa-authenticator.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_TYPE, + ) + ], + contentType: ContentType::JSON + ) + ]) ->param('type', null, new WhiteList([Type::TOTP]), 'Type of authenticator. Must be `' . Type::TOTP . '`') ->inject('requestTimestamp') ->inject('response') @@ -4071,20 +4275,40 @@ App::put('/v1/account/mfa/authenticators/:type') ->label('audits.event', 'user.update') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'updateMfaAuthenticator', - description: '/docs/references/account/update-mfa-authenticator.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_USER, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'updateMfaAuthenticator', + description: '/docs/references/account/update-mfa-authenticator.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_USER, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.updateMFAAuthenticator', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'updateMFAAuthenticator', + description: '/docs/references/account/update-mfa-authenticator.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_USER, + ) + ], + contentType: ContentType::JSON + ) + ]) ->param('type', null, new WhiteList([Type::TOTP]), 'Type of authenticator.') ->param('otp', '', new Text(256), 'Valid verification token.') ->inject('response') @@ -4141,20 +4365,40 @@ App::post('/v1/account/mfa/recovery-codes') ->label('audits.event', 'user.update') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'createMfaRecoveryCodes', - description: '/docs/references/account/create-mfa-recovery-codes.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_MFA_RECOVERY_CODES, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'createMfaRecoveryCodes', + description: '/docs/references/account/create-mfa-recovery-codes.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_MFA_RECOVERY_CODES, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.createMFARecoveryCodes', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'createMFARecoveryCodes', + description: '/docs/references/account/create-mfa-recovery-codes.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_MFA_RECOVERY_CODES, + ) + ], + contentType: ContentType::JSON + ) + ]) ->inject('response') ->inject('user') ->inject('dbForProject') @@ -4188,20 +4432,40 @@ App::patch('/v1/account/mfa/recovery-codes') ->label('audits.event', 'user.update') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'updateMfaRecoveryCodes', - description: '/docs/references/account/update-mfa-recovery-codes.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MFA_RECOVERY_CODES, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'updateMfaRecoveryCodes', + description: '/docs/references/account/update-mfa-recovery-codes.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_RECOVERY_CODES, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.updateMFARecoveryCodes', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'updateMFARecoveryCodes', + description: '/docs/references/account/update-mfa-recovery-codes.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_RECOVERY_CODES, + ) + ], + contentType: ContentType::JSON + ) + ]) ->inject('dbForProject') ->inject('response') ->inject('user') @@ -4230,20 +4494,40 @@ App::get('/v1/account/mfa/recovery-codes') ->desc('List MFA recovery codes') ->groups(['api', 'account', 'mfaProtected']) ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'getMfaRecoveryCodes', - description: '/docs/references/account/get-mfa-recovery-codes.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MFA_RECOVERY_CODES, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'getMfaRecoveryCodes', + description: '/docs/references/account/get-mfa-recovery-codes.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_RECOVERY_CODES, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.getMFARecoveryCodes', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'getMFARecoveryCodes', + description: '/docs/references/account/get-mfa-recovery-codes.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MFA_RECOVERY_CODES, + ) + ], + contentType: ContentType::JSON + ) + ]) ->inject('response') ->inject('user') ->action(function (Response $response, Document $user) { @@ -4269,20 +4553,40 @@ App::delete('/v1/account/mfa/authenticators/:type') ->label('audits.event', 'user.update') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'deleteMfaAuthenticator', - description: '/docs/references/account/delete-mfa-authenticator.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'deleteMfaAuthenticator', + description: '/docs/references/account/delete-mfa-authenticator.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.deleteMFAAuthenticator', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'deleteMFAAuthenticator', + description: '/docs/references/account/delete-mfa-authenticator.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + ) + ]) ->param('type', null, new WhiteList([Type::TOTP]), 'Type of authenticator.') ->inject('response') ->inject('user') @@ -4315,20 +4619,40 @@ App::post('/v1/account/mfa/challenge') ->label('audits.event', 'challenge.create') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'createMfaChallenge', - description: '/docs/references/account/create-mfa-challenge.md', - auth: [], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_MFA_CHALLENGE, - ) - ], - contentType: ContentType::JSON, - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'createMfaChallenge', + description: '/docs/references/account/create-mfa-challenge.md', + auth: [], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_MFA_CHALLENGE, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.createMFAChallenge', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'createMFAChallenge', + description: '/docs/references/account/create-mfa-challenge.md', + auth: [], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_MFA_CHALLENGE, + ) + ], + contentType: ContentType::JSON + ) + ]) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},userId:{userId}') ->param('factor', '', new WhiteList([Type::EMAIL, Type::PHONE, Type::TOTP, Type::RECOVERY_CODE]), 'Factor used for verification. Must be one of following: `' . Type::EMAIL . '`, `' . Type::PHONE . '`, `' . Type::TOTP . '`, `' . Type::RECOVERY_CODE . '`.') @@ -4437,7 +4761,18 @@ App::post('/v1/account/mfa/challenge') } $subject = $locale->getText("emails.mfaChallenge.subject"); + $preview = $locale->getText("emails.mfaChallenge.preview"); + $heading = $locale->getText("emails.mfaChallenge.heading"); + $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? []; + $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); + + $validator = new FileName(); + if (!$validator->isValid($smtpBaseTemplate)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path'); + } + + $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl'; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -4501,6 +4836,7 @@ App::post('/v1/account/mfa/challenge') } $emailVariables = [ + 'heading' => $heading, 'direction' => $locale->getText('settings.direction'), // {{user}}, {{project}} and {{otp}} are required in the templates 'user' => $user->getAttribute('name'), @@ -4508,12 +4844,26 @@ App::post('/v1/account/mfa/challenge') 'otp' => $code, 'agentDevice' => $agentDevice['deviceBrand'] ?? $agentDevice['deviceBrand'] ?? 'UNKNOWN', 'agentClient' => $agentClient['clientName'] ?? 'UNKNOWN', - 'agentOs' => $agentOs['osName'] ?? 'UNKNOWN' + 'agentOs' => $agentOs['osName'] ?? 'UNKNOWN', ]; + if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { + $emailVariables = array_merge($emailVariables, [ + 'accentColor' => APP_EMAIL_ACCENT_COLOR, + 'logoUrl' => APP_EMAIL_LOGO_URL, + 'twitterUrl' => APP_SOCIAL_TWITTER, + 'discordUrl' => APP_SOCIAL_DISCORD, + 'githubUrl' => APP_SOCIAL_GITHUB_APPWRITE, + 'termsUrl' => APP_EMAIL_TERMS_URL, + 'privacyUrl' => APP_EMAIL_PRIVACY_URL, + ]); + } + $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) + ->setBodyTemplate($bodyTemplate) ->setVariables($emailVariables) ->setRecipient($user->getAttribute('email')) ->trigger(); @@ -4535,20 +4885,40 @@ App::put('/v1/account/mfa/challenge') ->label('audits.event', 'challenges.update') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') - ->label('sdk', new Method( - namespace: 'account', - group: 'mfa', - name: 'updateMfaChallenge', - description: '/docs/references/account/update-mfa-challenge.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SESSION, - ) - ], - contentType: ContentType::JSON - )) + ->label('sdk', [ + new Method( + namespace: 'account', + group: 'mfa', + name: 'updateMfaChallenge', + description: '/docs/references/account/update-mfa-challenge.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_SESSION, + ) + ], + contentType: ContentType::JSON, + deprecated: new Deprecated( + since: '1.8.0', + replaceWith: 'account.updateMFAChallenge', + ), + ), + new Method( + namespace: 'account', + group: 'mfa', + name: 'updateMFAChallenge', + description: '/docs/references/account/update-mfa-challenge.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_SESSION, + ) + ], + contentType: ContentType::JSON + ) + ]) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},challengeId:{param-challengeId}') ->param('challengeId', '', new Text(256), 'ID of the challenge.') @@ -4616,8 +4986,8 @@ App::put('/v1/account/mfa/challenge') $dbForProject->updateDocument('sessions', $session->getId(), $session); $queueForEvents - ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()); + ->setParam('userId', $user->getId()) + ->setParam('sessionId', $session->getId()); $response->dynamic($session, Response::MODEL_SESSION); }); @@ -4680,7 +5050,7 @@ App::post('/v1/account/targets/push') ], 'providerId' => !empty($providerId) ? $providerId : null, 'providerInternalId' => !empty($providerId) ? $provider->getSequence() : null, - 'providerType' => MESSAGE_TYPE_PUSH, + 'providerType' => MESSAGE_TYPE_PUSH, 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'sessionId' => $session->getId(), @@ -4855,8 +5225,8 @@ App::get('/v1/account/identities') $queries[] = Query::equal('userInternalId', [$user->getSequence()]); /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ $cursor = \array_filter($queries, function ($query) { return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); }); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 65fe278834..4abe4e0723 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 a7829d7adc..fdf363d793 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; @@ -476,9 +473,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); } @@ -497,9 +495,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()); @@ -572,7 +568,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' => [ @@ -586,9 +581,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 @@ -620,7 +615,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([ @@ -640,7 +635,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()]) ]); @@ -653,7 +648,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) { @@ -1078,7 +1073,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); } @@ -1193,9 +1188,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); @@ -1214,7 +1207,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); } @@ -1248,9 +1241,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' => [ @@ -1260,9 +1253,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'], @@ -1274,19 +1267,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'), @@ -1294,8 +1282,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 da24d1174d..0a0990ad39 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) @@ -1116,6 +1075,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); } @@ -1382,21 +1342,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); } @@ -1409,8 +1360,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); @@ -2223,19 +2174,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( @@ -2243,8 +2192,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(), @@ -2268,13 +2217,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 @@ -2309,7 +2253,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') @@ -2321,17 +2265,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 b426c5ddcd..08ef7ef8ed 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": "407c1717bfef580d733ff2bbb232ec8a", "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