From b20c493fa69b36b96feb271ca7d02b2cf6ae300c Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Tue, 11 Nov 2025 07:50:42 +0000 Subject: [PATCH] changes --- .../MFA}/Authenticators/Create.php | 2 +- .../MFA}/Authenticators/Delete.php | 2 +- .../MFA}/Authenticators/Update.php | 2 +- .../{Mfa => Account/MFA}/Challenge/Create.php | 5 +- .../{Mfa => Account/MFA}/Challenge/Update.php | 5 +- .../Http/Account/MFA/Challenges/Create.php | 330 ++++++++++++++++++ .../Http/Account/MFA/Challenges/Update.php | 161 +++++++++ .../{Mfa => Account/MFA}/Factors/XList.php | 2 +- .../MFA}/RecoveryCodes/Create.php | 2 +- .../MFA}/RecoveryCodes/Get.php | 2 +- .../MFA}/RecoveryCodes/Update.php | 2 +- .../Http/{Mfa => Account/MFA}/Update.php | 2 +- .../Modules/Account/Services/Http.php | 20 +- 13 files changed, 515 insertions(+), 22 deletions(-) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/Authenticators/Create.php (98%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/Authenticators/Delete.php (97%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/Authenticators/Update.php (98%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/Challenge/Create.php (98%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/Challenge/Update.php (97%) create mode 100644 src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php create mode 100644 src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/Factors/XList.php (97%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/RecoveryCodes/Create.php (97%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/RecoveryCodes/Get.php (97%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/RecoveryCodes/Update.php (97%) rename src/Appwrite/Platform/Modules/Account/Http/{Mfa => Account/MFA}/Update.php (98%) diff --git a/src/Appwrite/Platform/Modules/Account/Http/Mfa/Authenticators/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Create.php similarity index 98% rename from src/Appwrite/Platform/Modules/Account/Http/Mfa/Authenticators/Create.php rename to src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Create.php index e024f06a6e..a7bec0ec53 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Mfa/Authenticators/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/account/mfa/challenge') + ->setHttpPath('/v1/account/mfa/challenges') + ->httpAlias('/v1/account/mfa/challenge') ->desc('Create MFA challenge') ->groups(['api', 'account', 'mfa']) ->label('scope', 'account') diff --git a/src/Appwrite/Platform/Modules/Account/Http/Mfa/Challenge/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenge/Update.php similarity index 97% rename from src/Appwrite/Platform/Modules/Account/Http/Mfa/Challenge/Update.php rename to src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenge/Update.php index d4bb6a1490..c0139d8c4f 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Mfa/Challenge/Update.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenge/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) - ->setHttpPath('/v1/account/mfa/challenge') + ->setHttpPath('/v1/account/mfa/challenges') + ->httpAlias('/v1/account/mfa/challenge') ->desc('Update MFA challenge (confirmation)') ->groups(['api', 'account', 'mfa']) ->label('scope', 'account') diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php new file mode 100644 index 0000000000..10d3ce53e3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -0,0 +1,330 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/account/mfa/challenges') + ->httpAlias('/v1/account/mfa/challenge') + ->desc('Create MFA challenge') + ->groups(['api', 'account', 'mfa']) + ->label('scope', 'account') + ->label('event', 'users.[userId].challenges.[challengeId].create') + ->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, + 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 . '`.') + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('locale') + ->inject('project') + ->inject('request') + ->inject('queueForEvents') + ->inject('queueForMessaging') + ->inject('queueForMails') + ->inject('timelimit') + ->inject('queueForStatsUsage') + ->inject('plan') + ->callback($this->action(...)); + } + + public function action( + string $factor, + Response $response, + Database $dbForProject, + Document $user, + Locale $locale, + Document $project, + Request $request, + Event $queueForEvents, + Messaging $queueForMessaging, + Mail $queueForMails, + callable $timelimit, + StatsUsage $queueForStatsUsage, + array $plan + ): void { + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); + $code = Auth::codeGenerator(); + $challenge = new Document([ + 'userId' => $user->getId(), + 'userInternalId' => $user->getSequence(), + 'type' => $factor, + 'token' => Auth::tokenGenerator(), + 'code' => $code, + 'expire' => $expire, + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], + ]); + + $challenge = $dbForProject->createDocument('challenges', $challenge); + + $templatesPath = \dirname(__DIR__, 7) . '/app/config/locale/templates'; + + switch ($factor) { + case Type::PHONE: + if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { + throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); + } + if (empty($user->getAttribute('phone'))) { + throw new Exception(Exception::USER_PHONE_NOT_FOUND); + } + if (!$user->getAttribute('phoneVerification')) { + throw new Exception(Exception::USER_PHONE_NOT_VERIFIED); + } + + $message = Template::fromFile($templatesPath . '/sms-base.tpl'); + + $customTemplate = $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? []; + if (!empty($customTemplate)) { + $message = $customTemplate['message'] ?? $message; + } + + $messageContent = Template::fromString($locale->getText("sms.verification.body")); + $messageContent + ->setParam('{{project}}', $project->getAttribute('name')) + ->setParam('{{secret}}', $code); + $messageContent = \strip_tags($messageContent->render()); + $message = $message->setParam('{{token}}', $messageContent); + + $message = $message->render(); + + $phone = $user->getAttribute('phone'); + $queueForMessaging + ->setType(MESSAGE_SEND_TYPE_INTERNAL) + ->setMessage(new Document([ + '$id' => $challenge->getId(), + 'data' => [ + 'content' => $code, + ], + ])) + ->setRecipients([$phone]) + ->setProviderType(MESSAGE_TYPE_SMS); + + if (isset($plan['authPhone'])) { + $timelimit = $timelimit('organization:{organizationId}', $plan['authPhone'], 30 * 24 * 60 * 60); // 30 days + $timelimit + ->setParam('{organizationId}', $project->getAttribute('teamId')); + + $abuse = new Abuse($timelimit); + if ($abuse->check() && System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') === 'enabled') { + $helper = PhoneNumberUtil::getInstance(); + $countryCode = $helper->parse($phone)->getCountryCode(); + + if (!empty($countryCode)) { + $queueForStatsUsage + ->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); + } + } + $queueForStatsUsage + ->addMetric(METRIC_AUTH_METHOD_PHONE, 1) + ->setProject($project) + ->trigger(); + } + break; + case Type::EMAIL: + if (empty(System::getEnv('_APP_SMTP_HOST'))) { + throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); + } + if (empty($user->getAttribute('email'))) { + throw new Exception(Exception::USER_EMAIL_NOT_FOUND); + } + if (!$user->getAttribute('emailVerification')) { + throw new Exception(Exception::USER_EMAIL_NOT_VERIFIED); + } + + $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 = $templatesPath . '/' . $smtpBaseTemplate . '.tpl'; + + $detector = new Detector($request->getUserAgent('UNKNOWN')); + $agentOs = $detector->getOS(); + $agentClient = $detector->getClient(); + $agentDevice = $detector->getDevice(); + + $message = Template::fromFile($templatesPath . '/email-mfa-challenge.tpl'); + $message + ->setParam('{{hello}}', $locale->getText("emails.mfaChallenge.hello")) + ->setParam('{{description}}', $locale->getText("emails.mfaChallenge.description")) + ->setParam('{{clientInfo}}', $locale->getText("emails.mfaChallenge.clientInfo")) + ->setParam('{{thanks}}', $locale->getText("emails.mfaChallenge.thanks")) + ->setParam('{{signature}}', $locale->getText("emails.mfaChallenge.signature")); + + $body = $message->render(); + + $smtp = $project->getAttribute('smtp', []); + $smtpEnabled = $smtp['enabled'] ?? false; + + $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); + $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); + $replyTo = ""; + + if ($smtpEnabled) { + if (!empty($smtp['senderEmail'])) { + $senderEmail = $smtp['senderEmail']; + } + if (!empty($smtp['senderName'])) { + $senderName = $smtp['senderName']; + } + if (!empty($smtp['replyTo'])) { + $replyTo = $smtp['replyTo']; + } + + $queueForMails + ->setSmtpHost($smtp['host'] ?? '') + ->setSmtpPort($smtp['port'] ?? '') + ->setSmtpUsername($smtp['username'] ?? '') + ->setSmtpPassword($smtp['password'] ?? '') + ->setSmtpSecure($smtp['secure'] ?? ''); + + if (!empty($customTemplate)) { + if (!empty($customTemplate['senderEmail'])) { + $senderEmail = $customTemplate['senderEmail']; + } + if (!empty($customTemplate['senderName'])) { + $senderName = $customTemplate['senderName']; + } + if (!empty($customTemplate['replyTo'])) { + $replyTo = $customTemplate['replyTo']; + } + + $body = $customTemplate['message'] ?? ''; + $subject = $customTemplate['subject'] ?? $subject; + } + + $queueForMails + ->setSmtpReplyTo($replyTo) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName); + } + + $emailVariables = [ + 'heading' => $heading, + 'direction' => $locale->getText('settings.direction'), + 'user' => $user->getAttribute('name'), + 'project' => $project->getAttribute('name'), + 'otp' => $code, + 'agentDevice' => $agentDevice['deviceBrand'] ?? $agentDevice['deviceBrand'] ?? 'UNKNOWN', + 'agentClient' => $agentClient['clientName'] ?? '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(); + break; + } + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('challengeId', $challenge->getId()); + + $response->dynamic($challenge, Response::MODEL_MFA_CHALLENGE); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php new file mode 100644 index 0000000000..40d17afa18 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php @@ -0,0 +1,161 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/account/mfa/challenges') + ->httpAlias('/v1/account/mfa/challenge') + ->desc('Update MFA challenge (confirmation)') + ->groups(['api', 'account', 'mfa']) + ->label('scope', 'account') + ->label('event', 'users.[userId].sessions.[sessionId].create') + ->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, + 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.') + ->param('otp', '', new Text(256), 'Valid verification token.') + ->inject('project') + ->inject('response') + ->inject('user') + ->inject('session') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $challengeId, + string $otp, + Document $project, + Response $response, + Document $user, + Document $session, + Database $dbForProject, + Event $queueForEvents + ): void { + $challenge = $dbForProject->getDocument('challenges', $challengeId); + + if ($challenge->isEmpty()) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $type = $challenge->getAttribute('type'); + + $recoveryCodeChallenge = function (Document $challenge, Document $user, string $otp) use ($dbForProject) { + if ( + $challenge->isSet('type') && + $challenge->getAttribute('type') === \strtolower(Type::RECOVERY_CODE) + ) { + $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); + if (\in_array($otp, $mfaRecoveryCodes)) { + $mfaRecoveryCodes = \array_diff($mfaRecoveryCodes, [$otp]); + $mfaRecoveryCodes = \array_values($mfaRecoveryCodes); + $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); + $dbForProject->updateDocument('users', $user->getId(), $user); + + return true; + } + + return false; + } + + return false; + }; + + $success = (match ($type) { + Type::TOTP => Challenge\TOTP::challenge($challenge, $user, $otp), + Type::PHONE => Challenge\Phone::challenge($challenge, $user, $otp), + Type::EMAIL => Challenge\Email::challenge($challenge, $user, $otp), + \strtolower(Type::RECOVERY_CODE) => $recoveryCodeChallenge($challenge, $user, $otp), + default => false + }); + + if (!$success) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $dbForProject->deleteDocument('challenges', $challengeId); + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $factors = $session->getAttribute('factors', []); + $factors[] = $type; + $factors = \array_values(\array_unique($factors)); + + $session + ->setAttribute('factors', $factors) + ->setAttribute('mfaUpdatedAt', DateTime::now()); + + $dbForProject->updateDocument('sessions', $session->getId(), $session); + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('sessionId', $session->getId()); + + $response->dynamic($session, Response::MODEL_SESSION); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Mfa/Factors/XList.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Factors/XList.php similarity index 97% rename from src/Appwrite/Platform/Modules/Account/Http/Mfa/Factors/XList.php rename to src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Factors/XList.php index 8e99ad1283..c60f599cfc 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Mfa/Factors/XList.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Factors/XList.php @@ -1,6 +1,6 @@