diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index b7959bb6a9..36b36ca5f2 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2,9 +2,7 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Auth; -use Appwrite\Auth\MFA\Challenge; use Appwrite\Auth\MFA\Type; -use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Auth\OAuth2\Exception as OAuth2Exception; use Appwrite\Auth\Phrase; use Appwrite\Auth\Validator\Password; @@ -4153,943 +4151,6 @@ App::put('/v1/account/verifications/phone') $response->dynamic($verificationDocument, Response::MODEL_TOKEN); }); -App::patch('/v1/account/mfa') - ->desc('Update MFA') - ->groups(['api', 'account']) - ->label('event', 'users.[userId].update.mfa') - ->label('scope', 'account') - ->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: 'updateMFA', - description: '/docs/references/account/update-mfa.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_USER, - ) - ], - contentType: ContentType::JSON - )) - ->param('mfa', null, new Boolean(), 'Enable or disable MFA.') - ->inject('requestTimestamp') - ->inject('response') - ->inject('user') - ->inject('session') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (bool $mfa, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $session, Database $dbForProject, Event $queueForEvents) { - - $user->setAttribute('mfa', $mfa); - - $user = $dbForProject->updateDocument('users', $user->getId(), $user); - - if ($mfa) { - $factors = $session->getAttribute('factors', []); - $totp = TOTP::getAuthenticatorFromUser($user); - if ($totp !== null && $totp->getAttribute('verified', false)) { - $factors[] = Type::TOTP; - } - if ($user->getAttribute('email', false) && $user->getAttribute('emailVerification', false)) { - $factors[] = Type::EMAIL; - } - if ($user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false)) { - $factors[] = Type::PHONE; - } - $factors = \array_values(\array_unique($factors)); - - $session->setAttribute('factors', $factors); - $dbForProject->updateDocument('sessions', $session->getId(), $session); - } - - $queueForEvents->setParam('userId', $user->getId()); - - $response->dynamic($user, Response::MODEL_ACCOUNT); - }); - -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, - 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) { - - $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); - $recoveryCodeEnabled = \is_array($mfaRecoveryCodes) && \count($mfaRecoveryCodes) > 0; - - $totp = TOTP::getAuthenticatorFromUser($user); - - $factors = new Document([ - Type::TOTP => $totp !== null && $totp->getAttribute('verified', false), - Type::EMAIL => $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false), - Type::PHONE => $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false), - Type::RECOVERY_CODE => $recoveryCodeEnabled - ]); - - $response->dynamic($factors, Response::MODEL_MFA_FACTORS); - }); - -App::post('/v1/account/mfa/authenticators/:type') - ->desc('Create authenticator') - ->groups(['api', 'account']) - ->label('event', 'users.[userId].update.mfa') - ->label('scope', 'account') - ->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, - 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') - ->inject('project') - ->inject('user') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $type, ?\DateTime $requestTimestamp, Response $response, Document $project, Document $user, Database $dbForProject, Event $queueForEvents) { - - $otp = (match ($type) { - Type::TOTP => new TOTP(), - default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Unknown type.') // Ideally never happens if param validator stays always in sync - }); - - $otp->setLabel($user->getAttribute('email')); - $otp->setIssuer($project->getAttribute('name')); - - $authenticator = TOTP::getAuthenticatorFromUser($user); - - if ($authenticator) { - if ($authenticator->getAttribute('verified')) { - throw new Exception(Exception::USER_AUTHENTICATOR_ALREADY_VERIFIED); - } - $dbForProject->deleteDocument('authenticators', $authenticator->getId()); - } - - $authenticator = new Document([ - '$id' => ID::unique(), - 'userId' => $user->getId(), - 'userInternalId' => $user->getSequence(), - 'type' => Type::TOTP, - 'verified' => false, - 'data' => [ - 'secret' => $otp->getSecret(), - ], - '$permissions' => [ - Permission::read(Role::user($user->getId())), - Permission::update(Role::user($user->getId())), - Permission::delete(Role::user($user->getId())), - ] - ]); - - $model = new Document([ - 'secret' => $otp->getSecret(), - 'uri' => $otp->getProvisioningUri() - ]); - - $authenticator = $dbForProject->createDocument('authenticators', $authenticator); - $dbForProject->purgeCachedDocument('users', $user->getId()); - - $queueForEvents->setParam('userId', $user->getId()); - - $response->dynamic($model, Response::MODEL_MFA_TYPE); - }); - -App::put('/v1/account/mfa/authenticators/:type') - ->desc('Update authenticator (confirmation)') - ->groups(['api', 'account']) - ->label('event', 'users.[userId].update.mfa') - ->label('scope', 'account') - ->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, - 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') - ->inject('user') - ->inject('session') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $type, string $otp, Response $response, Document $user, Document $session, Database $dbForProject, Event $queueForEvents) { - - $authenticator = (match ($type) { - Type::TOTP => TOTP::getAuthenticatorFromUser($user), - default => null - }); - - if ($authenticator === null) { - throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND); - } - - if ($authenticator->getAttribute('verified')) { - throw new Exception(Exception::USER_AUTHENTICATOR_ALREADY_VERIFIED); - } - - $success = (match ($type) { - Type::TOTP => Challenge\TOTP::verify($user, $otp), - default => false - }); - - if (!$success) { - throw new Exception(Exception::USER_INVALID_TOKEN); - } - - $authenticator->setAttribute('verified', true); - - $dbForProject->updateDocument('authenticators', $authenticator->getId(), $authenticator); - $dbForProject->purgeCachedDocument('users', $user->getId()); - - $factors = $session->getAttribute('factors', []); - $factors[] = $type; - $factors = \array_values(\array_unique($factors)); - - $session->setAttribute('factors', $factors); - $dbForProject->updateDocument('sessions', $session->getId(), $session); - - $queueForEvents->setParam('userId', $user->getId()); - - $response->dynamic($user, Response::MODEL_ACCOUNT); - }); - -App::post('/v1/account/mfa/recovery-codes') - ->desc('Create MFA recovery codes') - ->groups(['api', 'account']) - ->label('event', 'users.[userId].update.mfa') - ->label('scope', 'account') - ->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, - 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') - ->inject('queueForEvents') - ->action(function (Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { - - $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); - - if (!empty($mfaRecoveryCodes)) { - throw new Exception(Exception::USER_RECOVERY_CODES_ALREADY_EXISTS); - } - - $mfaRecoveryCodes = Type::generateBackupCodes(); - $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); - $dbForProject->updateDocument('users', $user->getId(), $user); - - $queueForEvents->setParam('userId', $user->getId()); - - $document = new Document([ - 'recoveryCodes' => $mfaRecoveryCodes - ]); - - $response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES); - }); - -App::patch('/v1/account/mfa/recovery-codes') - ->desc('Update MFA recovery codes (regenerate)') - ->groups(['api', 'account', 'mfaProtected']) - ->label('event', 'users.[userId].update.mfa') - ->label('scope', 'account') - ->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, - 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') - ->inject('queueForEvents') - ->action(function (Database $dbForProject, Response $response, Document $user, Event $queueForEvents) { - - $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); - if (empty($mfaRecoveryCodes)) { - throw new Exception(Exception::USER_RECOVERY_CODES_NOT_FOUND); - } - - $mfaRecoveryCodes = Type::generateBackupCodes(); - $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); - $dbForProject->updateDocument('users', $user->getId(), $user); - - $queueForEvents->setParam('userId', $user->getId()); - - $document = new Document([ - 'recoveryCodes' => $mfaRecoveryCodes - ]); - - $response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES); - }); - -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, - 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) { - - $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); - - if (empty($mfaRecoveryCodes)) { - throw new Exception(Exception::USER_RECOVERY_CODES_NOT_FOUND); - } - - $document = new Document([ - 'recoveryCodes' => $mfaRecoveryCodes - ]); - - $response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES); - }); - -App::delete('/v1/account/mfa/authenticators/:type') - ->desc('Delete authenticator') - ->groups(['api', 'account', 'mfaProtected']) - ->label('event', 'users.[userId].delete.mfa') - ->label('scope', 'account') - ->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, - 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') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $type, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { - - $authenticator = (match ($type) { - Type::TOTP => TOTP::getAuthenticatorFromUser($user), - default => null - }); - - if (!$authenticator) { - throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND); - } - - $dbForProject->deleteDocument('authenticators', $authenticator->getId()); - $dbForProject->purgeCachedDocument('users', $user->getId()); - - $queueForEvents->setParam('userId', $user->getId()); - - $response->noContent(); - }); - -App::post('/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') - ->action(function (string $factor, Response $response, Database $dbForProject, Document $user, Locale $locale, Document $project, Request $request, Event $queueForEvents, Messaging $queueForMessaging, Mail $queueForMails, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan) { - - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 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); - - 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(__DIR__ . '/../../config/locale/templates/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 = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl'; - - $detector = new Detector($request->getUserAgent('UNKNOWN')); - $agentOs = $detector->getOS(); - $agentClient = $detector->getClient(); - $agentDevice = $detector->getDevice(); - - $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/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}}, {{project}} and {{otp}} are required in the templates - '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); - }); - -App::put('/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') - ->action(function (string $challengeId, string $otp, Document $project, Response $response, Document $user, Document $session, Database $dbForProject, Event $queueForEvents) { - - $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); - }); - App::post('/v1/account/targets/push') ->desc('Create push target') ->groups(['api', 'account']) diff --git a/docs/examples/1.5.x/client-rest/examples/account/create-challenge.md b/docs/examples/1.5.x/client-rest/examples/account/create-challenge.md index dbdbc1f16a..8d803e56a9 100644 --- a/docs/examples/1.5.x/client-rest/examples/account/create-challenge.md +++ b/docs/examples/1.5.x/client-rest/examples/account/create-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.5.0 diff --git a/docs/examples/1.5.x/client-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.5.x/client-rest/examples/account/create-mfa-challenge.md index 2019a1e52c..0553c4b5ba 100644 --- a/docs/examples/1.5.x/client-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.5.x/client-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: <REGION>.cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.5.x/client-rest/examples/account/create2f-a-challenge.md b/docs/examples/1.5.x/client-rest/examples/account/create2f-a-challenge.md index dbdbc1f16a..8d803e56a9 100644 --- a/docs/examples/1.5.x/client-rest/examples/account/create2f-a-challenge.md +++ b/docs/examples/1.5.x/client-rest/examples/account/create2f-a-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.5.0 diff --git a/docs/examples/1.5.x/client-rest/examples/account/update-challenge.md b/docs/examples/1.5.x/client-rest/examples/account/update-challenge.md index 5e50a0e88d..00c85da373 100644 --- a/docs/examples/1.5.x/client-rest/examples/account/update-challenge.md +++ b/docs/examples/1.5.x/client-rest/examples/account/update-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.5.0 diff --git a/docs/examples/1.5.x/client-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.5.x/client-rest/examples/account/update-mfa-challenge.md index 520b587562..093cc37930 100644 --- a/docs/examples/1.5.x/client-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.5.x/client-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: <REGION>.cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.5.x/server-rest/examples/account/create-challenge.md b/docs/examples/1.5.x/server-rest/examples/account/create-challenge.md index dbdbc1f16a..8d803e56a9 100644 --- a/docs/examples/1.5.x/server-rest/examples/account/create-challenge.md +++ b/docs/examples/1.5.x/server-rest/examples/account/create-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.5.0 diff --git a/docs/examples/1.5.x/server-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.5.x/server-rest/examples/account/create-mfa-challenge.md index 2019a1e52c..0553c4b5ba 100644 --- a/docs/examples/1.5.x/server-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.5.x/server-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: <REGION>.cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.5.x/server-rest/examples/account/create2f-a-challenge.md b/docs/examples/1.5.x/server-rest/examples/account/create2f-a-challenge.md index dbdbc1f16a..8d803e56a9 100644 --- a/docs/examples/1.5.x/server-rest/examples/account/create2f-a-challenge.md +++ b/docs/examples/1.5.x/server-rest/examples/account/create2f-a-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.5.0 diff --git a/docs/examples/1.5.x/server-rest/examples/account/update-challenge.md b/docs/examples/1.5.x/server-rest/examples/account/update-challenge.md index 5e50a0e88d..00c85da373 100644 --- a/docs/examples/1.5.x/server-rest/examples/account/update-challenge.md +++ b/docs/examples/1.5.x/server-rest/examples/account/update-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.5.0 diff --git a/docs/examples/1.5.x/server-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.5.x/server-rest/examples/account/update-mfa-challenge.md index 520b587562..093cc37930 100644 --- a/docs/examples/1.5.x/server-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.5.x/server-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: <REGION>.cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.6.x/client-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.6.x/client-rest/examples/account/create-mfa-challenge.md index 95bf2c4926..c3007fc290 100644 --- a/docs/examples/1.6.x/client-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.6.x/client-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.6.x/client-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.6.x/client-rest/examples/account/update-mfa-challenge.md index 5bd401cc4e..779aeb2ecd 100644 --- a/docs/examples/1.6.x/client-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.6.x/client-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.6.x/server-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.6.x/server-rest/examples/account/create-mfa-challenge.md index 95bf2c4926..c3007fc290 100644 --- a/docs/examples/1.6.x/server-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.6.x/server-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.6.x/server-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.6.x/server-rest/examples/account/update-mfa-challenge.md index 5bd401cc4e..779aeb2ecd 100644 --- a/docs/examples/1.6.x/server-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.6.x/server-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.6.0 diff --git a/docs/examples/1.7.x/client-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.7.x/client-rest/examples/account/create-mfa-challenge.md index 9a84c0ef69..bda2de889d 100644 --- a/docs/examples/1.7.x/client-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.7.x/client-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.7.0 diff --git a/docs/examples/1.7.x/client-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.7.x/client-rest/examples/account/update-mfa-challenge.md index ddc27ae334..506059dc3d 100644 --- a/docs/examples/1.7.x/client-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.7.x/client-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.7.0 diff --git a/docs/examples/1.7.x/server-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.7.x/server-rest/examples/account/create-mfa-challenge.md index 9a84c0ef69..bda2de889d 100644 --- a/docs/examples/1.7.x/server-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.7.x/server-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.7.0 diff --git a/docs/examples/1.7.x/server-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.7.x/server-rest/examples/account/update-mfa-challenge.md index ddc27ae334..506059dc3d 100644 --- a/docs/examples/1.7.x/server-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.7.x/server-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.7.0 diff --git a/docs/examples/1.8.x/client-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.8.x/client-rest/examples/account/create-mfa-challenge.md index dd5ef4c731..e5a5b0ea05 100644 --- a/docs/examples/1.8.x/client-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.8.x/client-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.8.0 diff --git a/docs/examples/1.8.x/client-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.8.x/client-rest/examples/account/update-mfa-challenge.md index b6a7e92b28..df2cd9a1e8 100644 --- a/docs/examples/1.8.x/client-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.8.x/client-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.8.0 diff --git a/docs/examples/1.8.x/server-rest/examples/account/create-mfa-challenge.md b/docs/examples/1.8.x/server-rest/examples/account/create-mfa-challenge.md index dd5ef4c731..e5a5b0ea05 100644 --- a/docs/examples/1.8.x/server-rest/examples/account/create-mfa-challenge.md +++ b/docs/examples/1.8.x/server-rest/examples/account/create-mfa-challenge.md @@ -1,4 +1,4 @@ -POST /v1/account/mfa/challenge HTTP/1.1 +POST /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.8.0 diff --git a/docs/examples/1.8.x/server-rest/examples/account/update-mfa-challenge.md b/docs/examples/1.8.x/server-rest/examples/account/update-mfa-challenge.md index b6a7e92b28..df2cd9a1e8 100644 --- a/docs/examples/1.8.x/server-rest/examples/account/update-mfa-challenge.md +++ b/docs/examples/1.8.x/server-rest/examples/account/update-mfa-challenge.md @@ -1,4 +1,4 @@ -PUT /v1/account/mfa/challenge HTTP/1.1 +PUT /v1/account/mfa/challenges HTTP/1.1 Host: cloud.appwrite.io Content-Type: application/json X-Appwrite-Response-Format: 1.8.0 diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index a77f894be7..4aa135c4f1 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform; +use Appwrite\Platform\Modules\Account; use Appwrite\Platform\Modules\Console; use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; @@ -17,6 +18,7 @@ class Appwrite extends Platform public function __construct() { parent::__construct(new Core()); + $this->addModule(new Account\Module()); $this->addModule(new Databases\Module()); $this->addModule(new Projects\Module()); $this->addModule(new Functions\Module()); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Create.php new file mode 100644 index 0000000000..2d83599964 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Create.php @@ -0,0 +1,141 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/account/mfa/authenticators/:type') + ->desc('Create authenticator') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'account') + ->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, + 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('response') + ->inject('project') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $type, + Response $response, + Document $project, + Document $user, + Database $dbForProject, + Event $queueForEvents + ): void { + $otp = (match ($type) { + Type::TOTP => new TOTP(), + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Unknown type.') + }); + + $otp->setLabel($user->getAttribute('email')); + $otp->setIssuer($project->getAttribute('name')); + + $authenticator = TOTP::getAuthenticatorFromUser($user); + + if ($authenticator) { + if ($authenticator->getAttribute('verified')) { + throw new Exception(Exception::USER_AUTHENTICATOR_ALREADY_VERIFIED); + } + $dbForProject->deleteDocument('authenticators', $authenticator->getId()); + } + + $authenticator = new Document([ + '$id' => ID::unique(), + 'userId' => $user->getId(), + 'userInternalId' => $user->getSequence(), + 'type' => Type::TOTP, + 'verified' => false, + 'data' => [ + 'secret' => $otp->getSecret(), + ], + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ] + ]); + + $model = new Document([ + 'secret' => $otp->getSecret(), + 'uri' => $otp->getProvisioningUri() + ]); + + $authenticator = $dbForProject->createDocument('authenticators', $authenticator); + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->dynamic($model, Response::MODEL_MFA_TYPE); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php new file mode 100644 index 0000000000..5c92bfff5c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/account/mfa/authenticators/:type') + ->desc('Delete authenticator') + ->groups(['api', 'account', 'mfaProtected']) + ->label('event', 'users.[userId].delete.mfa') + ->label('scope', 'account') + ->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, + 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') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $type, + Response $response, + Document $user, + Database $dbForProject, + Event $queueForEvents + ): void { + $authenticator = (match ($type) { + Type::TOTP => TOTP::getAuthenticatorFromUser($user), + default => null + }); + + if (!$authenticator) { + throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND); + } + + $dbForProject->deleteDocument('authenticators', $authenticator->getId()); + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Update.php new file mode 100644 index 0000000000..b68a55c20b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Update.php @@ -0,0 +1,135 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/account/mfa/authenticators/:type') + ->desc('Update authenticator (confirmation)') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'account') + ->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, + 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') + ->inject('user') + ->inject('session') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $otp, + Response $response, + Document $user, + Document $session, + Database $dbForProject, + Event $queueForEvents + ): void { + $authenticator = (match ($type) { + Type::TOTP => TOTP::getAuthenticatorFromUser($user), + default => null + }); + + if ($authenticator === null) { + throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND); + } + + if ($authenticator->getAttribute('verified')) { + throw new Exception(Exception::USER_AUTHENTICATOR_ALREADY_VERIFIED); + } + + $success = (match ($type) { + Type::TOTP => Challenge\TOTP::verify($user, $otp), + default => false + }); + + if (!$success) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $authenticator->setAttribute('verified', true); + + $dbForProject->updateDocument('authenticators', $authenticator->getId(), $authenticator); + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $factors = $session->getAttribute('factors', []); + $factors[] = $type; + $factors = \array_values(\array_unique($factors)); + + $session->setAttribute('factors', $factors); + $dbForProject->updateDocument('sessions', $session->getId(), $session); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->dynamic($user, Response::MODEL_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..2ac53c2d46 --- /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'] ?? '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/Account/MFA/Factors/XList.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Factors/XList.php new file mode 100644 index 0000000000..c60f599cfc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Factors/XList.php @@ -0,0 +1,89 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/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, + 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') + ->callback($this->action(...)); + } + + public function action(Response $response, Document $user): void + { + $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); + $recoveryCodeEnabled = \is_array($mfaRecoveryCodes) && \count($mfaRecoveryCodes) > 0; + + $totp = TOTP::getAuthenticatorFromUser($user); + + $factors = new Document([ + Type::TOTP => $totp !== null && $totp->getAttribute('verified', false), + Type::EMAIL => $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false), + Type::PHONE => $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false), + Type::RECOVERY_CODE => $recoveryCodeEnabled + ]); + + $response->dynamic($factors, Response::MODEL_MFA_FACTORS); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Create.php new file mode 100644 index 0000000000..fc26142991 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Create.php @@ -0,0 +1,105 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/account/mfa/recovery-codes') + ->desc('Create MFA recovery codes') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'account') + ->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, + 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') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Document $user, + Database $dbForProject, + Event $queueForEvents + ): void { + $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); + + if (!empty($mfaRecoveryCodes)) { + throw new Exception(Exception::USER_RECOVERY_CODES_ALREADY_EXISTS); + } + + $mfaRecoveryCodes = Type::generateBackupCodes(); + $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); + $dbForProject->updateDocument('users', $user->getId(), $user); + + $queueForEvents->setParam('userId', $user->getId()); + + $document = new Document([ + 'recoveryCodes' => $mfaRecoveryCodes + ]); + + $response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Get.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Get.php new file mode 100644 index 0000000000..8a85c361ca --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Get.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/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, + 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') + ->callback($this->action(...)); + } + + public function action(Response $response, Document $user): void + { + $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); + + if (empty($mfaRecoveryCodes)) { + throw new Exception(Exception::USER_RECOVERY_CODES_NOT_FOUND); + } + + $document = new Document([ + 'recoveryCodes' => $mfaRecoveryCodes + ]); + + $response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Update.php new file mode 100644 index 0000000000..5cc2783e75 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Update.php @@ -0,0 +1,104 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/account/mfa/recovery-codes') + ->desc('Update MFA recovery codes (regenerate)') + ->groups(['api', 'account', 'mfaProtected']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'account') + ->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, + 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') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + Database $dbForProject, + Response $response, + Document $user, + Event $queueForEvents + ): void { + $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); + if (empty($mfaRecoveryCodes)) { + throw new Exception(Exception::USER_RECOVERY_CODES_NOT_FOUND); + } + + $mfaRecoveryCodes = Type::generateBackupCodes(); + $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); + $dbForProject->updateDocument('users', $user->getId(), $user); + + $queueForEvents->setParam('userId', $user->getId()); + + $document = new Document([ + 'recoveryCodes' => $mfaRecoveryCodes + ]); + + $response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php new file mode 100644 index 0000000000..00068c7441 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php @@ -0,0 +1,99 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/account/mfa') + ->desc('Update MFA') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'account') + ->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: 'updateMFA', + description: '/docs/references/account/update-mfa.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_USER, + ) + ], + contentType: ContentType::JSON + )) + ->param('mfa', null, new Boolean(), 'Enable or disable MFA.') + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('session') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $mfa, + ?\DateTime $requestTimestamp, + Response $response, + Document $user, + Document $session, + Database $dbForProject, + Event $queueForEvents + ): void { + $user->setAttribute('mfa', $mfa); + + $user = $dbForProject->updateDocument('users', $user->getId(), $user); + + if ($mfa) { + $factors = $session->getAttribute('factors', []); + $totp = TOTP::getAuthenticatorFromUser($user); + if ($totp !== null && $totp->getAttribute('verified', false)) { + $factors[] = Type::TOTP; + } + if ($user->getAttribute('email', false) && $user->getAttribute('emailVerification', false)) { + $factors[] = Type::EMAIL; + } + if ($user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false)) { + $factors[] = Type::PHONE; + } + $factors = \array_values(\array_unique($factors)); + + $session->setAttribute('factors', $factors); + $dbForProject->updateDocument('sessions', $session->getId(), $session); + } + + $queueForEvents->setParam('userId', $user->getId()); + + $response->dynamic($user, Response::MODEL_ACCOUNT); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Module.php b/src/Appwrite/Platform/Modules/Account/Module.php new file mode 100644 index 0000000000..3ad50d388a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Services/Http.php b/src/Appwrite/Platform/Modules/Account/Services/Http.php new file mode 100644 index 0000000000..ae2e841636 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Account/Services/Http.php @@ -0,0 +1,34 @@ +type = Service::TYPE_HTTP; + $this + ->addAction(UpdateMfa::getName(), new UpdateMfa()) + ->addAction(ListFactors::getName(), new ListFactors()) + ->addAction(CreateAuthenticator::getName(), new CreateAuthenticator()) + ->addAction(UpdateAuthenticator::getName(), new UpdateAuthenticator()) + ->addAction(DeleteAuthenticator::getName(), new DeleteAuthenticator()) + ->addAction(CreateRecoveryCodes::getName(), new CreateRecoveryCodes()) + ->addAction(UpdateRecoveryCodes::getName(), new UpdateRecoveryCodes()) + ->addAction(GetRecoveryCodes::getName(), new GetRecoveryCodes()) + ->addAction(CreateChallenge::getName(), new CreateChallenge()) + ->addAction(UpdateChallenge::getName(), new UpdateChallenge()); + } +}