diff --git a/.gitmodules b/.gitmodules index 0c2321bcfa..ad08c2d4e3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "app/console"] path = app/console url = https://github.com/appwrite/console - branch = 3.2.16 + branch = feat-mfa diff --git a/app/config/collections.php b/app/config/collections.php index 59050b9b47..3fb44cdea9 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -245,6 +245,61 @@ $commonCollections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('mfa'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('totp'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('totpVerification'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('totpSecret'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('totpBackup'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 6, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => true, + 'filters' => [], + ], [ '$id' => ID::custom('sessions'), 'type' => Database::VAR_STRING, @@ -267,6 +322,17 @@ $commonCollections = [ 'array' => false, 'filters' => ['subQueryTokens'], ], + [ + '$id' => ID::custom('challenges'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['subQueryChallenges'], + ], [ '$id' => ID::custom('memberships'), 'type' => Database::VAR_STRING, @@ -480,6 +546,87 @@ $commonCollections = [ ], ], + 'challenges' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('challenges'), + 'name' => 'Challenges', + 'attributes' => [ + [ + '$id' => ID::custom('userInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('userId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('provider'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('token'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 512, // https://www.tutorialspoint.com/how-long-is-the-sha256-hash-in-mysql (512 for encryption) + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['encrypt'], + ], [ + '$id' => ID::custom('code'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 512, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['encrypt'], + ], [ + '$id' => ID::custom('expire'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ] + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_user'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['userInternalId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ] + ], + ], + 'sessions' => [ '$collection' => ID::custom(Database::METADATA), '$id' => ID::custom('sessions'), @@ -738,6 +885,17 @@ $commonCollections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('factors'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => new \stdClass(), + 'array' => true, + 'filters' => ['json'], + ], [ '$id' => ID::custom('expire'), 'type' => Database::VAR_DATETIME, diff --git a/app/config/errors.php b/app/config/errors.php index 2ecef0ea09..1f551b1ca9 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -237,6 +237,11 @@ return [ 'description' => 'Missing ID from OAuth2 provider.', 'code' => 400, ], + Exception::USER_MORE_FACTORS_REQUIRED => [ + 'name' => Exception::USER_MORE_FACTORS_REQUIRED, + 'description' => 'More factors are required to complete the sign in process.', + 'code' => 400, + ], Exception::USER_OAUTH2_BAD_REQUEST => [ 'name' => Exception::USER_OAUTH2_BAD_REQUEST, 'description' => 'OAuth2 provider rejected the bad request.', diff --git a/app/config/platforms.php b/app/config/platforms.php index f3b795d256..83d777c8ac 100644 --- a/app/config/platforms.php +++ b/app/config/platforms.php @@ -185,7 +185,7 @@ return [ [ 'key' => 'web', 'name' => 'Console', - 'version' => '0.5.0', + 'version' => '0.6.0-rc.8', 'url' => 'https://github.com/appwrite/sdk-for-console', 'package' => '', 'enabled' => true, @@ -196,7 +196,7 @@ return [ 'prism' => 'javascript', 'source' => \realpath(__DIR__ . '/../sdks/console-web'), 'gitUrl' => 'https://github.com/appwrite/sdk-for-console.git', - 'gitBranch' => 'main', + 'gitBranch' => '1.5.x', 'gitRepoName' => 'sdk-for-console', 'gitUserName' => 'appwrite', ], diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index b6532cac6a..0ec078efbc 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2,6 +2,9 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Auth; +use Appwrite\Auth\MFA\Challenge; +use Appwrite\Auth\MFA\Provider; +use Appwrite\Auth\MFA\Provider\TOTP; use Appwrite\Auth\OAuth2\Exception as OAuth2Exception; use Appwrite\Auth\Validator\Password; use Appwrite\Auth\Validator\Phone; @@ -146,6 +149,8 @@ App::post('/v1/account') 'registration' => DateTime::now(), 'reset' => false, 'name' => $name, + 'mfa' => false, + 'totp' => false, 'prefs' => new \stdClass(), 'sessions' => null, 'tokens' => null, @@ -255,6 +260,7 @@ App::post('/v1/account/sessions/email') 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), + 'factors' => ['email'], 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'expire' => DateTime::addSeconds(new \DateTime(), $duration) ], @@ -687,6 +693,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'registration' => DateTime::now(), 'reset' => false, 'name' => $name, + 'mfa' => false, + 'totp' => false, 'prefs' => new \stdClass(), 'sessions' => null, 'tokens' => null, @@ -831,6 +839,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), + 'factors' => ['email'], 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'expire' => DateTime::addSeconds(new \DateTime(), $duration) ], $detector->getOS(), $detector->getClient(), $detector->getDevice())); @@ -1050,6 +1059,8 @@ App::post('/v1/account/tokens/magic-url') 'passwordUpdate' => null, 'registration' => DateTime::now(), 'reset' => false, + 'mfa' => false, + 'totp' => false, 'prefs' => new \stdClass(), 'sessions' => null, 'tokens' => null, @@ -1460,6 +1471,15 @@ $createSession = function (string $userId, string $secret, Request $request, Res $record = $geodb->get($request->getIP()); $sessionSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); + $factor = match ($verifiedToken->getAttribute('type')) { + Auth::TOKEN_TYPE_MAGIC_URL, + Auth::TOKEN_TYPE_OAUTH2, + Auth::TOKEN_TYPE_EMAIL => 'email', + Auth::TOKEN_TYPE_PHONE => 'phone', + Auth::TOKEN_TYPE_GENERIC => 'token', + default => throw new Exception(Exception::USER_INVALID_TOKEN) + }; + $session = new Document(array_merge( [ '$id' => ID::unique(), @@ -1469,6 +1489,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res 'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), + 'factors' => [$factor], 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'expire' => DateTime::addSeconds(new \DateTime(), $duration) ], @@ -1825,6 +1846,8 @@ App::post('/v1/account/sessions/anonymous') 'registration' => DateTime::now(), 'reset' => false, 'name' => null, + 'mfa' => false, + 'totp' => false, 'prefs' => new \stdClass(), 'sessions' => null, 'tokens' => null, @@ -1850,6 +1873,7 @@ App::post('/v1/account/sessions/anonymous') 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), + 'factors' => ['anonymous'], 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'expire' => DateTime::addSeconds(new \DateTime(), $duration) ], @@ -2170,9 +2194,8 @@ App::get('/v1/account/sessions/:sessionId') ->inject('response') ->inject('user') ->inject('locale') - ->inject('dbForProject') ->inject('project') - ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Database $dbForProject, Document $project) { + ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Document $project) { $sessions = $user->getAttribute('sessions', []); $sessionId = ($sessionId === 'current') @@ -2180,7 +2203,7 @@ App::get('/v1/account/sessions/:sessionId') : $sessionId; foreach ($sessions as $session) {/** @var Document $session */ - if ($sessionId == $session->getId()) { + if ($sessionId === $session->getId()) { $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $session @@ -3406,6 +3429,391 @@ App::put('/v1/account/verification/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', 'accounts.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updateMFA') + ->label('sdk.description', '/docs/references/account/update-mfa.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USER) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->param('mfa', null, new Boolean(), 'Enable or disable MFA.') + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (bool $mfa, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { + + $user->setAttribute('mfa', $mfa); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); + + $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', 'accounts.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'listFactors') + ->label('sdk.description', '/docs/references/account/get.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_PROVIDERS) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->inject('response') + ->inject('user') + ->action(function (Response $response, Document $user) { + + $providers = new Document([ + 'totp' => $user->getAttribute('totp', false) && $user->getAttribute('totpVerification', false), + 'email' => $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false), + 'phone' => $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false) + ]); + + $response->dynamic($providers, Response::MODEL_MFA_PROVIDERS); + }); + +App::post('/v1/account/mfa/:factor') + ->desc('Add Authenticator') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'accounts.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'addAuthenticator') + ->label('sdk.description', '/docs/references/account/update-mfa.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_PROVIDER) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->param('factor', null, new WhiteList(['totp']), 'Factor.') + ->inject('requestTimestamp') + ->inject('response') + ->inject('project') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $factor, ?\DateTime $requestTimestamp, Response $response, Document $project, Document $user, Database $dbForProject, Event $queueForEvents) { + + $otp = match ($factor) { + 'totp' => new TOTP(), + default => throw new Exception(Exception::GENERAL_UNKNOWN, 'Unknown provider.') + }; + + $otp->setLabel($user->getAttribute('email')); + $otp->setIssuer($project->getAttribute('name')); + + $backups = Provider::generateBackupCodes(); + + if ($user->getAttribute('totp') && $user->getAttribute('totpVerification')) { + throw new Exception(Exception::GENERAL_UNKNOWN, 'TOTP already exists.'); + } + + $user + ->setAttribute('totp', true) + ->setAttribute('totpVerification', false) + ->setAttribute('totpBackup', $backups) + ->setAttribute('totpSecret', $otp->getSecret()); + + $model = new Document(); + $model + ->setAttribute('backups', $backups) + ->setAttribute('secret', $otp->getSecret()) + ->setAttribute('uri', $otp->getProvisioningUri()); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->dynamic($model, Response::MODEL_MFA_PROVIDER); + }); + +App::put('/v1/account/mfa/:factor') + ->desc('Verify Authenticator') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'accounts.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'verifyAuthenticator') + ->label('sdk.description', '/docs/references/account/update-mfa.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USER) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->param('factor', null, new WhiteList(['totp']), 'Factor.') + ->param('otp', '', new Text(256), 'Valid verification token.') + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('project') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $factor, string $otp, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents) { + + $success = match ($factor) { + 'totp' => Challenge\TOTP::verify($user, $otp), + default => false + }; + + if (!$success) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + if (!$user->getAttribute('totp')) { + throw new Exception(Exception::GENERAL_UNKNOWN, 'TOTP not added.'); + } elseif ($user->getAttribute('totpVerification')) { + throw new Exception(Exception::GENERAL_UNKNOWN, 'TOTP already verified.'); + } + + $user->setAttribute('totpVerification', true); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); + + $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $sessionId = Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret, $authDuration); + $session = $dbForProject->getDocument('sessions', $sessionId); + $dbForProject->updateDocument('sessions', $sessionId, $session->setAttribute('factors', $provider, Document::SET_TYPE_APPEND)); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->dynamic($user, Response::MODEL_ACCOUNT); + }); + +App::delete('/v1/account/mfa/:provider') + ->desc('Delete Authenticator') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].delete.mfa') + ->label('scope', 'accounts.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'deleteAuthenticator') + ->label('sdk.description', '/docs/references/account/delete-mfa.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USER) + ->param('provider', null, new WhiteList(['totp']), 'Provider.') + ->param('otp', '', new Text(256), 'Valid verification token.') + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $provider, string $otp, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { + + $success = match ($provider) { + 'totp' => Challenge\TOTP::verify($user, $otp), + default => false + }; + + if (!$success) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + if (!$user->getAttribute('totp')) { + throw new Exception(Exception::GENERAL_UNKNOWN, 'TOTP not added.'); + } + + $user + ->setAttribute('totp', false) + ->setAttribute('totpVerification', false) + ->setAttribute('totpSecret', null) + ->setAttribute('totpBackup', null); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->noContent(); + }); + +App::post('/v1/account/mfa/challenge') + ->desc('Create MFA Challenge') + ->groups(['api', 'account', 'mfa']) + ->label('scope', 'accounts.write') + ->label('event', 'users.[userId].challenges.[challengeId].create') + ->label('auth.type', 'createChallenge') + ->label('audits.event', 'challenge.create') + ->label('audits.resource', 'user/{response.userId}') + ->label('audits.userId', '{response.userId}') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createChallenge') + ->label('sdk.description', '/docs/references/account/create-challenge.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_CHALLENGE) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},token:{param-token}') + ->param('provider', '', new WhiteList(['totp', 'phone', 'email']), 'provider.') + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('project') + ->inject('queueForEvents') + ->inject('queueForMessaging') + ->inject('queueForMails') + ->inject('locale') + ->action(function (string $provider, Response $response, Database $dbForProject, Document $user, Document $project, Event $queueForEvents, Messaging $queueForMessaging, Mail $queueForMails, Locale $locale) { + + $expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM); + $code = Auth::codeGenerator(); + $challenge = new Document([ + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'provider' => $provider, + '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 ($provider) { + case 'phone': + if (empty(App::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); + } + + $queueForMessaging + ->setMessage(new Document([ + '$id' => $challenge->getId(), + 'data' => [ + 'content' => $code, + ], + ])) + ->setRecipients([$user->getAttribute('phone')]) + ->trigger(); + break; + case 'email': + if (empty(App::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); + } + + $queueForMails + ->setSubject("{$code} is your 6-digit code") + ->setBody($code) + ->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('Create MFA Challenge (confirmation)') + ->groups(['api', 'account', 'mfa']) + ->label('scope', 'accounts.write') + ->label('event', 'users.[userId].sessions.[tokenId].create') + ->label('audits.event', 'challenges.update') + ->label('audits.resource', 'user/{response.userId}') + ->label('audits.userId', '{response.userId}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updateChallenge') + ->label('sdk.description', '/docs/references/account/update-challenge.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_SESSION) + ->label('abuse-limit', 10) + ->label('abuse-key', 'userId:{param-userId}') + ->param('challengeId', '', new Text(256), 'Valid verification token.') + ->param('otp', '', new Text(256), 'Valid verification token.') + ->inject('project') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $challengeId, string $otp, Document $project, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { + + $challenge = $dbForProject->getDocument('challenges', $challengeId); + + if ($challenge->isEmpty()) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $provider = $challenge->getAttribute('provider'); + $success = match ($provider) { + 'totp' => Challenge\TOTP::challenge($challenge, $user, $otp), + 'phone' => Challenge\Phone::challenge($challenge, $user, $otp), + 'email' => Challenge\Email::challenge($challenge, $user, $otp), + default => false + }; + + if (!$success && $provider === 'totp') { + $backups = $user->getAttribute('mfaBackups', []); + if (in_array($otp, $backups)) { + $success = true; + $backups = array_diff($backups, [$otp]); + $user->setAttribute('mfaBackups', $backups); + $dbForProject->updateDocument('users', $user->getId(), $user); + } + } + + if (!$success) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $dbForProject->deleteDocument('challenges', $challengeId); + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $sessionId = Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret, $authDuration); + $session = $dbForProject->getDocument('sessions', $sessionId); + + $dbForProject->updateDocument('sessions', $sessionId, $session->setAttribute('factors', $provider, Document::SET_TYPE_APPEND)); + + $response->dynamic($session, Response::MODEL_SESSION); + }); + App::put('/v1/account/targets/:targetId/push') ->desc('Update Account\'s push target') ->groups(['api', 'account']) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 1dce2b3b88..685c230f4c 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -744,6 +744,7 @@ App::get('/v1/teams/:teamId/memberships') $user = $dbForProject->getDocument('users', $membership->getAttribute('userId')); $membership + ->setAttribute('mfa', $user->getAttribute('mfa')) ->setAttribute('teamName', $team->getAttribute('name')) ->setAttribute('userName', $user->getAttribute('name')) ->setAttribute('userEmail', $user->getAttribute('email')) @@ -961,6 +962,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'userAgent' => $request->getUserAgent('UNKNOWN'), 'ip' => $request->getIP(), + 'factors' => ['email'], 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'expire' => DateTime::addSeconds(new \DateTime(), $authDuration) ], $detector->getOS(), $detector->getClient(), $detector->getDevice())); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 58568c67a0..861c90f389 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1,6 +1,7 @@ dynamic($target, Response::MODEL_TARGET); }); +App::patch('/v1/users/:userId/mfa') + ->desc('Update MFA') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].update.mfa') + ->label('scope', 'users.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'updateMfa') + ->label('sdk.description', '/docs/references/users/update-user-mfa.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USER) + ->param('userId', '', new UID(), 'User ID.') + ->param('mfa', null, new Boolean(), 'Enable or disable MFA.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $userId, bool $mfa, Response $response, Database $dbForProject, Event $queueForEvents) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $user->setAttribute('mfa', $mfa); + + $user = $dbForProject->updateDocument('users', $user->getId(), $user); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->dynamic($user, Response::MODEL_USER); + }); + +App::get('/v1/users/:userId/providers') + ->desc('List Providers') + ->groups(['api', 'users']) + ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'listProviders') + ->label('sdk.description', '/docs/references/users/list-providers.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_PROVIDERS) + ->param('userId', '', new UID(), 'User ID.') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $userId, Response $response, Database $dbForProject) { + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $providers = new Document([ + 'totp' => $user->getAttribute('totp', false) && $user->getAttribute('totpVerification', false), + 'email' => $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false), + 'phone' => $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false) + ]); + + $response->dynamic($providers, Response::MODEL_MFA_PROVIDERS); + }); + +App::delete('/v1/users/:userId/mfa/:provider') + ->desc('Delete Authenticator') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].delete.mfa') + ->label('scope', 'users.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'deleteAuthenticator') + ->label('sdk.description', '/docs/references/users/delete-mfa.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USER) + ->param('userId', '', new UID(), 'User ID.') + ->param('provider', null, new WhiteList(['totp']), 'Provider.') + ->param('otp', '', new Text(256), 'Valid verification token.') + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $userId, string $provider, string $otp, ?\DateTime $requestTimestamp, Response $response, Database $dbForProject, Event $queueForEvents) { + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $success = match ($provider) { + 'totp' => Challenge\TOTP::verify($user, $otp), + default => false + }; + + if (!$success) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + if (!$user->getAttribute('totp')) { + throw new Exception(Exception::GENERAL_UNKNOWN, 'TOTP not added.'); + } + + $user + ->setAttribute('totp', false) + ->setAttribute('totpVerification', false) + ->setAttribute('totpSecret', null) + ->setAttribute('totpBackup', null); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); + + $queueForEvents->setParam('userId', $user->getId()); + + $response->noContent(); + }); + +App::post('/v1/users/:userId/sessions') + ->desc('Create session') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].sessions.[sessionId].create') + ->label('scope', 'users.write') + ->label('audits.event', 'session.create') + ->label('audits.resource', 'user/{request.userId}') + ->label('usage.metric', 'sessions.{scope}.requests.create') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'createSession') + ->label('sdk.description', '/docs/references/users/create-session.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_SESSION) + ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('locale') + ->inject('geodb') + ->inject('queueForEvents') + ->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) { + $user = $dbForProject->getDocument('users', $userId); + if ($user === false || $user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $secret = Auth::codeGenerator(); + $detector = new Detector($request->getUserAgent('UNKNOWN')); + $record = $geodb->get($request->getIP()); + + $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); + + $session = new Document(array_merge( + [ + '$id' => ID::unique(), + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'provider' => Auth::SESSION_PROVIDER_SERVER, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', + ], + $detector->getOS(), + $detector->getClient(), + $detector->getDevice() + )); + + $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); + + $session = $dbForProject->createDocument('sessions', $session); + $session + ->setAttribute('secret', $secret) + ->setAttribute('expire', $expire) + ->setAttribute('countryName', $countryName); + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('sessionId', $session->getId()) + ->setPayload($response->output($session, Response::MODEL_SESSION)); + + return $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($session, Response::MODEL_SESSION); + }); + App::post('/v1/users/:userId/tokens') ->desc('Create token') ->groups(['api', 'users']) diff --git a/app/controllers/general.php b/app/controllers/general.php index 50505bbf9f..74f12017b1 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -9,8 +9,6 @@ use Utopia\Logger\Logger; use Utopia\Logger\Log; use Utopia\Logger\Log\User; use Swoole\Http\Request as SwooleRequest; -use Utopia\Cache\Cache; -use Utopia\Pools\Group; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Appwrite\Utopia\View; @@ -210,12 +208,13 @@ App::init() ->inject('localeCodes') ->inject('clients') ->inject('servers') + ->inject('session') + ->inject('mode') ->inject('queueForCertificates') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $localeCodes, array $clients, array $servers, Certificate $queueForCertificates) { + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $localeCodes, array $clients, array $servers, ?Document $session, string $mode, Certificate $queueForCertificates) { /* * Appwrite Router */ - $host = $request->getHostname() ?? ''; $mainDomain = App::getEnv('_APP_DOMAIN', ''); // Only run Router when external domain @@ -563,6 +562,21 @@ App::init() if ($user->getAttribute('reset')) { throw new AppwriteException(AppwriteException::USER_PASSWORD_RESET_REQUIRED); } + + if ($mode !== APP_MODE_ADMIN) { + $mfaEnabled = $user->getAttribute('mfa', false); + $hasVerifiedAuthenticator = $user->getAttribute('totpVerification', false); + $hasVerifiedEmail = $user->getAttribute('emailVerification', false); + $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); + $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; + $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; + + if (!in_array('mfa', $route->getGroups())) { + if ($session && \count($session->getAttribute('factors')) < $minimumFactors) { + throw new AppwriteException(AppwriteException::USER_MORE_FACTORS_REQUIRED); + } + } + } }); App::options() diff --git a/app/http.php b/app/http.php index 781df53e98..0b17ae5b83 100644 --- a/app/http.php +++ b/app/http.php @@ -20,6 +20,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Swoole\Files; use Appwrite\Utopia\Request; +use Swoole\Coroutine; use Utopia\Logger\Log; use Utopia\Logger\Log\User; use Utopia\Pools\Group; diff --git a/app/init.php b/app/init.php index d75ab6341a..16a33955fd 100644 --- a/app/init.php +++ b/app/init.php @@ -445,6 +445,20 @@ Database::addFilter( } ); +Database::addFilter( + 'subQueryChallenges', + function (mixed $value) { + return null; + }, + function (mixed $value, Document $document, Database $database) { + return Authorization::skip(fn() => $database + ->find('challenges', [ + Query::equal('userInternalId', [$document->getInternalId()]), + Query::limit(APP_LIMIT_SUBQUERY), + ])); + } +); + Database::addFilter( 'subQueryMemberships', function (mixed $value) { @@ -1201,6 +1215,28 @@ App::setResource('project', function ($dbForConsole, $request, $console) { return $project; }, ['dbForConsole', 'request', 'console']); +App::setResource('session', function (Document $user, Document $project) { + if ($user->isEmpty()) { + return null; + } + + $sessions = $user->getAttribute('sessions', []); + $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $sessionId = Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret, $authDuration); + + if (!$sessionId) { + return null; + } + + foreach ($sessions as $session) {/** @var Document $session */ + if ($sessionId === $session->getId()) { + return $session; + } + } + + return null; +}, ['user', 'project']); + App::setResource('console', function () { return new Document([ '$id' => ID::custom('console'), diff --git a/composer.json b/composer.json index b6951e9a43..e1b861054c 100644 --- a/composer.json +++ b/composer.json @@ -73,6 +73,7 @@ "phpmailer/phpmailer": "6.8.0", "chillerlan/php-qrcode": "4.3.4", "adhocore/jwt": "1.1.2", + "spomky-labs/otphp": "^10.0", "webonyx/graphql-php": "14.11.*", "league/csv": "^9.14" }, diff --git a/composer.lock b/composer.lock index d284878dc3..a6e646e93c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4b9b6ff602a179493e0196636d961e9c", + "content-hash": "3b43bf6f0fca50a3a2834e1bbaa90d63", "packages": [ { "name": "adhocore/jwt", @@ -197,6 +197,73 @@ ], "time": "2023-11-22T15:36:00+00:00" }, + { + "name": "beberlei/assert", + "version": "v3.3.2", + "source": { + "type": "git", + "url": "https://github.com/beberlei/assert.git", + "reference": "cb70015c04be1baee6f5f5c953703347c0ac1655" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/beberlei/assert/zipball/cb70015c04be1baee6f5f5c953703347c0ac1655", + "reference": "cb70015c04be1baee6f5f5c953703347c0ac1655", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "php": "^7.0 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "*", + "phpstan/phpstan": "*", + "phpunit/phpunit": ">=6.0.0", + "yoast/phpunit-polyfills": "^0.1.0" + }, + "suggest": { + "ext-intl": "Needed to allow Assertion::count(), Assertion::isCountable(), Assertion::minCount(), and Assertion::maxCount() to operate on ResourceBundles" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Assert/functions.php" + ], + "psr-4": { + "Assert\\": "lib/Assert" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de", + "role": "Lead Developer" + }, + { + "name": "Richard Quadling", + "email": "rquadling@gmail.com", + "role": "Collaborator" + } + ], + "description": "Thin assertion library for input validation in business models.", + "keywords": [ + "assert", + "assertion", + "validation" + ], + "support": { + "issues": "https://github.com/beberlei/assert/issues", + "source": "https://github.com/beberlei/assert/tree/v3.3.2" + }, + "time": "2021-12-16T21:41:27+00:00" + }, { "name": "chillerlan/php-qrcode", "version": "4.3.4", @@ -738,6 +805,73 @@ ], "time": "2019-09-10T13:16:29+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-06-14T06:56:20+00:00" + }, { "name": "phpmailer/phpmailer", "version": "v6.8.0", @@ -818,6 +952,81 @@ ], "time": "2023-03-06T14:43:22+00:00" }, + { + "name": "spomky-labs/otphp", + "version": "v10.0.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "9784d9f7c790eed26e102d6c78f12c754036c366" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/9784d9f7c790eed26e102d6c78f12c754036c366", + "reference": "9784d9f7c790eed26e102d6c78f12c754036c366", + "shasum": "" + }, + "require": { + "beberlei/assert": "^3.0", + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.0", + "php": "^7.2|^8.0", + "thecodingmachine/safe": "^0.1.14|^1.0|^2.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-beberlei-assert": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^8.0", + "thecodingmachine/phpstan-safe-rule": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "v10.0": "10.0.x-dev", + "v9.0": "9.0.x-dev", + "v8.3": "8.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OTPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/otphp/contributors" + } + ], + "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator", + "homepage": "https://github.com/Spomky-Labs/otphp", + "keywords": [ + "FreeOTP", + "RFC 4226", + "RFC 6238", + "google authenticator", + "hotp", + "otp", + "totp" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/otphp/issues", + "source": "https://github.com/Spomky-Labs/otphp/tree/v10.0.3" + }, + "time": "2022-03-17T08:00:35+00:00" + }, { "name": "symfony/polyfill-php80", "version": "v1.28.0", @@ -901,6 +1110,145 @@ ], "time": "2023-01-26T09:26:14+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/3115ecd6b4391662b4931daac4eba6b07a2ac1f0", + "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.5", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.2", + "thecodingmachine/phpstan-strict-rules": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2.x-dev" + } + }, + "autoload": { + "files": [ + "deprecated/apc.php", + "deprecated/array.php", + "deprecated/datetime.php", + "deprecated/libevent.php", + "deprecated/misc.php", + "deprecated/password.php", + "deprecated/mssql.php", + "deprecated/stats.php", + "deprecated/strings.php", + "lib/special_cases.php", + "deprecated/mysqli.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "deprecated/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v2.5.0" + }, + "time": "2023-04-05T11:54:14+00:00" + }, { "name": "utopia-php/abuse", "version": "0.36.0", @@ -5175,5 +5523,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 2fc3ab86df..42caaefc0f 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -346,8 +346,8 @@ class Auth /** * Verify token and check that its not expired. * - * @param array $tokens - * @param int $type Type of token to verify, if null will verify any type + * @param array $tokens + * @param int $type Type of token to verify, if null will verify any type * @param string $secret * * @return false|Document @@ -355,7 +355,6 @@ class Auth public static function tokenVerify(array $tokens, int $type = null, string $secret): false|Document { foreach ($tokens as $token) { - /** @var Document $token */ if ( $token->isSet('secret') && $token->isSet('expire') && @@ -374,7 +373,7 @@ class Auth /** * Verify session and check that its not expired. * - * @param array $sessions + * @param array $sessions * @param string $secret * * @return bool|string @@ -382,7 +381,6 @@ class Auth public static function sessionVerify(array $sessions, string $secret) { foreach ($sessions as $session) { - /** @var Document $session */ if ( $session->isSet('secret') && $session->isSet('provider') && @@ -399,7 +397,7 @@ class Auth /** * Is Privileged User? * - * @param array $roles + * @param array $roles * * @return bool */ @@ -419,7 +417,7 @@ class Auth /** * Is App User? * - * @param array $roles + * @param array $roles * * @return bool */ @@ -436,7 +434,7 @@ class Auth * Returns all roles for a user. * * @param Document $user - * @return array + * @return array */ public static function getRoles(Document $user): array { @@ -486,6 +484,12 @@ class Auth return $roles; } + /** + * Check if user is anonymous. + * + * @param Document $user + * @return bool + */ public static function isAnonymousUser(Document $user): bool { return is_null($user->getAttribute('email')) diff --git a/src/Appwrite/Auth/MFA/Challenge.php b/src/Appwrite/Auth/MFA/Challenge.php new file mode 100644 index 0000000000..9d0457b273 --- /dev/null +++ b/src/Appwrite/Auth/MFA/Challenge.php @@ -0,0 +1,11 @@ +getAttribute('code') === $otp; + } + + public static function challenge(Document $challenge, Document $user, string $otp): bool + { + if ( + $challenge->isSet('provider') && + $challenge->getAttribute('provider') === 'email' + ) { + return self::verify($challenge, $otp); + } + + return false; + } +} diff --git a/src/Appwrite/Auth/MFA/Challenge/Phone.php b/src/Appwrite/Auth/MFA/Challenge/Phone.php new file mode 100644 index 0000000000..bb0af7382c --- /dev/null +++ b/src/Appwrite/Auth/MFA/Challenge/Phone.php @@ -0,0 +1,26 @@ +getAttribute('code') === $otp; + } + + public static function challenge(Document $challenge, Document $user, string $otp): bool + { + if ( + $challenge->isSet('provider') && + $challenge->getAttribute('provider') === 'phone' + ) { + return self::verify($challenge, $otp); + } + + return false; + } +} diff --git a/src/Appwrite/Auth/MFA/Challenge/TOTP.php b/src/Appwrite/Auth/MFA/Challenge/TOTP.php new file mode 100644 index 0000000000..dfb2fe3c03 --- /dev/null +++ b/src/Appwrite/Auth/MFA/Challenge/TOTP.php @@ -0,0 +1,29 @@ +getAttribute('totpSecret')); + + return $instance->now() === $otp; + } + + public static function challenge(Document $challenge, Document $user, string $otp): bool + { + if ( + $challenge->isSet('provider') && + $challenge->getAttribute('provider') === 'totp' + ) { + return self::verify($user, $otp); + } + + return false; + } +} diff --git a/src/Appwrite/Auth/MFA/Provider.php b/src/Appwrite/Auth/MFA/Provider.php new file mode 100644 index 0000000000..a5c2a29af0 --- /dev/null +++ b/src/Appwrite/Auth/MFA/Provider.php @@ -0,0 +1,56 @@ +instance->setLabel($label); + + return $this; + } + + public function getLabel(): ?string + { + return $this->instance->getLabel(); + } + + public function setIssuer(string $issuer): self + { + $this->instance->setIssuer($issuer); + + return $this; + } + + public function getIssuer(): ?string + { + return $this->instance->getIssuer(); + } + + public function getSecret(): string + { + return $this->instance->getSecret(); + } + + public function getProvisioningUri(): string + { + return $this->instance->getProvisioningUri(); + } + + public static function generateBackupCodes(int $length = 6, int $total = 6): array + { + $backups = []; + + for ($i = 0; $i < $total; $i++) { + $backups[] = Auth::codeGenerator($length); + } + + return $backups; + } +} diff --git a/src/Appwrite/Auth/MFA/Provider/TOTP.php b/src/Appwrite/Auth/MFA/Provider/TOTP.php new file mode 100644 index 0000000000..c1d85448cc --- /dev/null +++ b/src/Appwrite/Auth/MFA/Provider/TOTP.php @@ -0,0 +1,14 @@ +instance = TOTPLibrary::create($secret); + } +} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index d81589ed9d..fd8b1911a5 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -82,7 +82,12 @@ class Exception extends \Exception public const USER_AUTH_METHOD_UNSUPPORTED = 'user_auth_method_unsupported'; public const USER_PHONE_ALREADY_EXISTS = 'user_phone_already_exists'; public const USER_PHONE_NOT_FOUND = 'user_phone_not_found'; + public const USER_PHONE_NOT_VERIFIED = 'user_phone_not_verified'; + public const USER_EMAIL_NOT_FOUND = 'user_email_not_found'; + public const USER_EMAIL_NOT_VERIFIED = 'user_email_not_verified'; public const USER_MISSING_ID = 'user_missing_id'; + public const USER_MORE_FACTORS_REQUIRED = 'user_more_factors_required'; + public const USER_INVALID_CHALLENGE = 'user_invalid_challenge'; public const USER_OAUTH2_BAD_REQUEST = 'user_oauth2_bad_request'; public const USER_OAUTH2_UNAUTHORIZED = 'user_oauth2_unauthorized'; public const USER_OAUTH2_PROVIDER_ERROR = 'user_oauth2_provider_error'; diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 449f1ee9c2..7ee01f53ec 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -62,7 +62,6 @@ use Appwrite\Utopia\Response\Model\Metric; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\Platform; use Appwrite\Utopia\Response\Model\Project; -use Appwrite\Utopia\Response\Model\Rule; use Appwrite\Utopia\Response\Model\Deployment; use Appwrite\Utopia\Response\Model\Detection; use Appwrite\Utopia\Response\Model\Headers; @@ -75,6 +74,9 @@ use Appwrite\Utopia\Response\Model\HealthQueue; use Appwrite\Utopia\Response\Model\HealthStatus; use Appwrite\Utopia\Response\Model\HealthTime; use Appwrite\Utopia\Response\Model\HealthVersion; +use Appwrite\Utopia\Response\Model\MFAChallenge; +use Appwrite\Utopia\Response\Model\MFAProvider; +use Appwrite\Utopia\Response\Model\MFAProviders; use Appwrite\Utopia\Response\Model\Installation; use Appwrite\Utopia\Response\Model\LocaleCode; use Appwrite\Utopia\Response\Model\MetricBreakdown; @@ -101,6 +103,7 @@ use Appwrite\Utopia\Response\Model\MigrationFirebaseProject; use Appwrite\Utopia\Response\Model\MigrationReport; // Keep last use Appwrite\Utopia\Response\Model\Mock; +use Appwrite\Utopia\Response\Model\Rule; /** * @method int getStatusCode() @@ -165,6 +168,12 @@ class Response extends SwooleResponse public const MODEL_JWT = 'jwt'; public const MODEL_PREFERENCES = 'preferences'; + // MFA + public const MODEL_MFA_PROVIDER = 'mfaProvider'; + public const MODEL_MFA_PROVIDERS = 'mfaProviders'; + public const MODEL_MFA_OTP = 'mfaTotp'; + public const MODEL_MFA_CHALLENGE = 'mfaChallenge'; + // Users password algos public const MODEL_ALGO_MD5 = 'algoMd5'; public const MODEL_ALGO_SHA = 'algoSha'; @@ -430,6 +439,9 @@ class Response extends SwooleResponse ->setModel(new TemplateSMS()) ->setModel(new TemplateEmail()) ->setModel(new ConsoleVariables()) + ->setModel(new MFAChallenge()) + ->setModel(new MFAProvider()) + ->setModel(new MFAProviders()) ->setModel(new Provider()) ->setModel(new Message()) ->setModel(new Topic()) @@ -438,8 +450,6 @@ class Response extends SwooleResponse ->setModel(new Migration()) ->setModel(new MigrationReport()) ->setModel(new MigrationFirebaseProject()) - // Verification - // Recovery // Tests (keep last) ->setModel(new Mock()); @@ -654,7 +664,7 @@ class Response extends SwooleResponse $this ->setContentType(Response::CONTENT_TYPE_YAML) - ->send(yaml_emit($data, YAML_UTF8_ENCODING)); + ->send(\yaml_emit($data, YAML_UTF8_ENCODING)); } /** diff --git a/src/Appwrite/Utopia/Response/Model/MFAChallenge.php b/src/Appwrite/Utopia/Response/Model/MFAChallenge.php new file mode 100644 index 0000000000..3d1ba3fbc4 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/MFAChallenge.php @@ -0,0 +1,59 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Token ID.', + 'default' => '', + 'example' => 'bb8ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Token creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('userId', [ + 'type' => self::TYPE_STRING, + 'description' => 'User ID.', + 'default' => '', + 'example' => '5e5ea5c168bb8', + ]) + ->addRule('expire', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Token expiration date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'MFA Challenge'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_MFA_CHALLENGE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/MFAProvider.php b/src/Appwrite/Utopia/Response/Model/MFAProvider.php new file mode 100644 index 0000000000..fec6c94f5e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/MFAProvider.php @@ -0,0 +1,54 @@ +addRule('backups', [ + 'type' => self::TYPE_STRING, + 'description' => 'Backup codes.', + 'array' => true, + 'default' => [], + 'example' => true + ]) + ->addRule('secret', [ + 'type' => self::TYPE_STRING, + 'description' => 'Secret token used for TOTP factor.', + 'default' => '', + 'example' => true + ]) + ->addRule('uri', [ + 'type' => self::TYPE_STRING, + 'description' => 'URI for authenticator apps.', + 'default' => '', + 'example' => true + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'MFAProvider'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_MFA_PROVIDER; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/MFAProviders.php b/src/Appwrite/Utopia/Response/Model/MFAProviders.php new file mode 100644 index 0000000000..acba916de8 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/MFAProviders.php @@ -0,0 +1,53 @@ +addRule('totp', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'TOTP', + 'default' => false, + 'example' => true + ]) + ->addRule('phone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Phone', + 'default' => false, + 'example' => true + ]) + ->addRule('email', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Email', + 'default' => false, + 'example' => true + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'MFAProviders'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_MFA_PROVIDERS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php index c134142185..64283bd4a8 100644 --- a/src/Appwrite/Utopia/Response/Model/Membership.php +++ b/src/Appwrite/Utopia/Response/Model/Membership.php @@ -76,6 +76,12 @@ class Membership extends Model 'default' => false, 'example' => false, ]) + ->addRule('mfa', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Multi factor authentication status, true if the user has MFA enabled or false otherwise.', + 'default' => false, + 'example' => false, + ]) ->addRule('roles', [ 'type' => self::TYPE_STRING, 'description' => 'User list of roles', diff --git a/src/Appwrite/Utopia/Response/Model/Session.php b/src/Appwrite/Utopia/Response/Model/Session.php index d249897d76..b1e41e5419 100644 --- a/src/Appwrite/Utopia/Response/Model/Session.php +++ b/src/Appwrite/Utopia/Response/Model/Session.php @@ -160,6 +160,12 @@ class Session extends Model 'default' => false, 'example' => true, ]) + ->addRule('factors', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Returns a list of active session factors.', + 'default' => 1, + 'example' => 1, + ]) ->addRule('secret', [ 'type' => self::TYPE_STRING, 'description' => 'Secret used to authenticate the user. Only included if the request was made with an API key', diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index d6988b47f4..98ff3bcf6e 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -114,6 +114,18 @@ class User extends Model 'default' => false, 'example' => true, ]) + ->addRule('mfa', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Multi factor authentication status.', + 'default' => false, + 'example' => true, + ]) + ->addRule('totp', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'TOTP status.', + 'default' => false, + 'example' => true, + ]) ->addRule('prefs', [ 'type' => Response::MODEL_PREFERENCES, 'description' => 'User preferences as a key-value object', diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 87787c9ea1..3ea8e2723b 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2248,7 +2248,7 @@ class AccountCustomClientTest extends Scope $this->assertEmpty($response['body']['secret']); $this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire'])); - \sleep(2); + \sleep(5); $smsRequest = $this->getLastRequest(); diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 2ad269acfc..2213ae6d19 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -425,6 +425,7 @@ trait TeamsBaseClient 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); + $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(true, $response['body']['emailVerification']);