Merge pull request #10793 from appwrite/feat-SER-448-improve-MFA-docs-endpoint-order

refactor(account): move MFA endpoints to module actions
This commit is contained in:
Matej Bačo 2025-11-25 09:40:39 +01:00 committed by GitHub
commit 7efbae4310
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1429 additions and 961 deletions

View file

@ -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'])

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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());

View file

@ -0,0 +1,141 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Authenticators;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createMFAAuthenticator';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Authenticators;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\WhiteList;
class Delete extends Action
{
use HTTP;
public static function getName(): string
{
return 'deleteMFAAuthenticator';
}
public function __construct()
{
$this
->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();
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Authenticators;
use Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Update extends Action
{
use HTTP;
public static function getName(): string
{
return 'updateMFAAuthenticator';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,330 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Challenges;
use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Type;
use Appwrite\Detector\Detector;
use Appwrite\Event\Event;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use libphonenumber\PhoneNumberUtil;
use Utopia\Abuse\Abuse;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Locale\Locale;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Validator\FileName;
use Utopia\System\System;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createMFAChallenge';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Challenges;
use Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\MFA\Type;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName(): string
{
return 'updateMFAChallenge';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Factors;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listMFAFactors';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes;
use Appwrite\Auth\MFA\Type;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createMFARecoveryCodes';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getMFARecoveryCodes';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes;
use Appwrite\Auth\MFA\Type;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Update extends Action
{
use HTTP;
public static function getName(): string
{
return 'updateMFARecoveryCodes';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Appwrite\Platform\Modules\Account\Http\Account\MFA;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Event\Event;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class Update extends Action
{
use HTTP;
public static function getName(): string
{
return 'updateMFA';
}
public function __construct()
{
$this
->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);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Account;
use Appwrite\Platform\Modules\Account\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Appwrite\Platform\Modules\Account\Services;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Authenticators\Create as CreateAuthenticator;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Authenticators\Delete as DeleteAuthenticator;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Authenticators\Update as UpdateAuthenticator;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Challenges\Create as CreateChallenge;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Challenges\Update as UpdateChallenge;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Factors\XList as ListFactors;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes\Create as CreateRecoveryCodes;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes\Get as GetRecoveryCodes;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\RecoveryCodes\Update as UpdateRecoveryCodes;
use Appwrite\Platform\Modules\Account\Http\Account\MFA\Update as UpdateMfa;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->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());
}
}