diff --git a/.gitpod.yml b/.gitpod.yml index d740c467c..478b62fc8 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -17,7 +17,6 @@ tasks: ports: - port: 8080 - onOpen: open-preview visibility: public vscode: diff --git a/app/config/collections.php b/app/config/collections.php index e27105974..3c61dd4e9 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -287,7 +287,7 @@ $commonCollections = [ 'required' => false, 'default' => [], 'array' => true, - 'filters' => [], + 'filters' => ['encrypt'], ], [ '$id' => ID::custom('authenticators'), @@ -979,6 +979,17 @@ $commonCollections = [ 'array' => false, 'filters' => ['datetime'], ], + [ + '$id' => ID::custom('mfaUpdatedAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], ], 'indexes' => [ [ diff --git a/app/config/errors.php b/app/config/errors.php index 68611e40d..1202dabdd 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -242,6 +242,11 @@ return [ 'description' => 'Authenticator could not be found on the current user.', 'code' => 404, ], + Exception::USER_RECOVERY_CODES_NOT_FOUND => [ + 'name' => Exception::USER_RECOVERY_CODES_NOT_FOUND, + 'description' => 'Recovery codes could not be found on the current user.', + 'code' => 404, + ], Exception::USER_AUTHENTICATOR_ALREADY_VERIFIED => [ 'name' => Exception::USER_AUTHENTICATOR_ALREADY_VERIFIED, 'description' => 'This authenticator is already verified on the current user.', @@ -262,6 +267,11 @@ return [ 'description' => 'More factors are required to complete the sign in process.', 'code' => 401, ], + Exception::USER_CHALLENGE_REQUIRED => [ + 'name' => Exception::USER_CHALLENGE_REQUIRED, + 'description' => 'A recently succeessful challenge is required to complete this action. A challenge is considered recent for 5 minutes.', + 'code' => 401, + ], Exception::USER_OAUTH2_BAD_REQUEST => [ 'name' => Exception::USER_OAUTH2_BAD_REQUEST, 'description' => 'OAuth2 provider rejected the bad request.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 845a16c71..7d20ede72 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2154,6 +2154,10 @@ App::get('/v1/account/sessions') ->inject('project') ->action(function (Response $response, Document $user, Locale $locale, Document $project) { + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + $sessions = $user->getAttribute('sessions', []); $current = Auth::sessionVerify($sessions, Auth::$secret); @@ -2162,6 +2166,8 @@ App::get('/v1/account/sessions') $session->setAttribute('countryName', $countryName); $session->setAttribute('current', ($current == $session->getId()) ? true : false); + $session->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $session->getAttribute('secret', '') : ''); + $sessions[$key] = $session; } @@ -2256,6 +2262,10 @@ App::get('/v1/account/sessions/:sessionId') ->inject('project') ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Document $project) { + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + $sessions = $user->getAttribute('sessions', []); $sessionId = ($sessionId === 'current') ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) @@ -2268,6 +2278,7 @@ App::get('/v1/account/sessions/:sessionId') $session ->setAttribute('current', ($session->getAttribute('secret') == Auth::hash(Auth::$secret))) ->setAttribute('countryName', $countryName) + ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $session->getAttribute('secret', '') : '') ; return $response->dynamic($session, Response::MODEL_SESSION); @@ -3712,17 +3723,16 @@ App::post('/v1/account/mfa/recovery-codes') ->label('sdk.namespace', 'account') ->label('sdk.method', 'createMfaRecoveryCodes') ->label('sdk.description', '/docs/references/account/create-mfa-recovery-codes.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_USER) + ->label('sdk.response.model', Response::MODEL_MFA_RECOVERY_CODES) ->label('sdk.offline.model', '/account') ->label('sdk.offline.key', 'current') ->inject('response') ->inject('user') - ->inject('project') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents) { + ->action(function (Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { $mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []); @@ -3743,6 +3753,77 @@ App::post('/v1/account/mfa/recovery-codes') $response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES); }); +App::patch('/v1/account/mfa/recovery-codes') + ->desc('Regenerate MFA Recovery Codes') + ->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.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updateMfaRecoveryCodes') + ->label('sdk.description', '/docs/references/account/update-mfa-recovery-codes.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_RECOVERY_CODES) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->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('Get MFA Recovery Codes') + ->groups(['api', 'account', 'mfaProtected']) + ->label('scope', 'account') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'getMfaRecoveryCodes') + ->label('sdk.description', '/docs/references/account/get-mfa-recovery-codes.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_RECOVERY_CODES) + ->label('sdk.offline.model', '/account') + ->label('sdk.offline.key', 'current') + ->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']) @@ -4063,7 +4144,11 @@ App::put('/v1/account/mfa/challenge') $sessionId = Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret, $authDuration); $session = $dbForProject->getDocument('sessions', $sessionId); - $dbForProject->updateDocument('sessions', $sessionId, $session->setAttribute('factors', $type, Document::SET_TYPE_APPEND)); + $session = $session + ->setAttribute('factors', $type, Document::SET_TYPE_APPEND) + ->setAttribute('mfaUpdatedAt', DateTime::now()); + + $dbForProject->updateDocument('sessions', $sessionId, $session); $queueForEvents ->setParam('userId', $user->getId()) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 67dd5de73..e91837c3e 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1591,6 +1591,132 @@ App::get('/v1/users/:userId/mfa/factors') $response->dynamic($factors, Response::MODEL_MFA_FACTORS); }); +App::get('/v1/users/:userId/mfa/recovery-codes') + ->desc('Get MFA Recovery Codes') + ->groups(['api', 'users']) + ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'getMfaRecoveryCodes') + ->label('sdk.description', '/docs/references/users/get-mfa-recovery-codes.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_RECOVERY_CODES) + ->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); + } + + $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::patch('/v1/users/:userId/mfa/recovery-codes') + ->desc('Create MFA Recovery Codes') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].create.mfa.recovery-codes') + ->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', 'createMfaRecoveryCodes') + ->label('sdk.description', '/docs/references/users/create-mfa-recovery-codes.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_RECOVERY_CODES) + ->param('userId', '', new UID(), 'User ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $userId, Response $response, Database $dbForProject, Event $queueForEvents) { + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $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::put('/v1/users/:userId/mfa/recovery-codes') + ->desc('Regenerate MFA Recovery Codes') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].update.mfa.recovery-codes') + ->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', 'updateMfaRecoveryCodes') + ->label('sdk.description', '/docs/references/users/update-mfa-recovery-codes.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MFA_RECOVERY_CODES) + ->param('userId', '', new UID(), 'User ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $userId, Response $response, Database $dbForProject, Event $queueForEvents) { + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $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::delete('/v1/users/:userId/mfa/authenticators/:type') ->desc('Delete Authenticator') ->groups(['api', 'users']) diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php index 0ae538372..2f436a143 100644 --- a/app/controllers/shared/api/auth.php +++ b/app/controllers/shared/api/auth.php @@ -7,6 +7,26 @@ use Appwrite\Extend\Exception; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use MaxMind\Db\Reader; +use Utopia\Database\DateTime; + +App::init() + ->groups(['mfaProtected']) + ->inject('session') + ->action(function (Document $session) { + $isSessionFresh = false; + + $lastUpdate = $session->getAttribute('mfaUpdatedAt'); + if (!empty($lastUpdate)) { + $now = DateTime::now(); + $maxAllowedDate = DateTime::addSeconds($lastUpdate, Auth::MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge + + $isSessionFresh = DateTime::formatTz($maxAllowedDate) >= DateTime::formatTz($now); + } + + if (!$isSessionFresh) { + throw new Exception(Exception::USER_CHALLENGE_REQUIRED); + } + }); App::init() ->groups(['auth']) diff --git a/docs/references/account/get-mfa-recovery-codes.md b/docs/references/account/get-mfa-recovery-codes.md new file mode 100644 index 000000000..f3265cae8 --- /dev/null +++ b/docs/references/account/get-mfa-recovery-codes.md @@ -0,0 +1 @@ +Get recovery codes that can be used as backup for MFA flow. Before getting codes, they must be generated using [createMfaRecoveryCodes](/docs/references/cloud/client-web/account#createMfaRecoveryCodes) method. An OTP challenge is required to read recovery codes. \ No newline at end of file diff --git a/docs/references/account/update-mfa-recovery-codes.md b/docs/references/account/update-mfa-recovery-codes.md new file mode 100644 index 000000000..2d3b26620 --- /dev/null +++ b/docs/references/account/update-mfa-recovery-codes.md @@ -0,0 +1 @@ +Regenerate recovery codes that can be used as backup for MFA flow. Before regenerating codes, they must be first generated using [createMfaRecoveryCodes](/docs/references/cloud/client-web/account#createMfaRecoveryCodes) method. An OTP challenge is required to regenreate recovery codes. \ No newline at end of file diff --git a/docs/references/users/create-mfa-recovery-codes.md b/docs/references/users/create-mfa-recovery-codes.md new file mode 100644 index 000000000..dc9a587af --- /dev/null +++ b/docs/references/users/create-mfa-recovery-codes.md @@ -0,0 +1 @@ +Generate recovery codes used as backup for MFA flow for User ID. Recovery codes can be used as a MFA verification type in [createMfaChallenge](/docs/references/cloud/client-web/account#createMfaChallenge) method by client SDK. \ No newline at end of file diff --git a/docs/references/users/get-mfa-recovery-codes.md b/docs/references/users/get-mfa-recovery-codes.md new file mode 100644 index 000000000..59c5c9e99 --- /dev/null +++ b/docs/references/users/get-mfa-recovery-codes.md @@ -0,0 +1 @@ +Get recovery codes that can be used as backup for MFA flow by User ID. Before getting codes, they must be generated using [createMfaRecoveryCodes](/docs/references/cloud/client-web/account#createMfaRecoveryCodes) method. \ No newline at end of file diff --git a/docs/references/users/update-mfa-recovery-codes.md b/docs/references/users/update-mfa-recovery-codes.md new file mode 100644 index 000000000..9a2e8af52 --- /dev/null +++ b/docs/references/users/update-mfa-recovery-codes.md @@ -0,0 +1 @@ +Regenerate recovery codes that can be used as backup for MFA flow by User ID. Before regenerating codes, they must be first generated using [createMfaRecoveryCodes](/docs/references/cloud/client-web/account#createMfaRecoveryCodes) method. \ No newline at end of file diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 42caaefc0..a25c6123e 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -86,6 +86,11 @@ class Auth public const TOKEN_LENGTH_OAUTH2 = 64; public const TOKEN_LENGTH_SESSION = 256; + /** + * MFA + */ + public const MFA_RECENT_DURATION = 1800; // 30 mins + /** * @var string */ diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 70ab83179..c5bd53fe7 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -92,6 +92,8 @@ class Exception extends \Exception public const USER_AUTHENTICATOR_NOT_FOUND = 'user_authenticator_not_found'; public const USER_AUTHENTICATOR_ALREADY_VERIFIED = 'user_authenticator_already_verified'; public const USER_RECOVERY_CODES_ALREADY_EXISTS = 'user_recovery_codes_already_exists'; + public const USER_RECOVERY_CODES_NOT_FOUND = 'user_recovery_codes_not_found'; + public const USER_CHALLENGE_REQUIRED = 'user_challenge_required'; 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/Model/Session.php b/src/Appwrite/Utopia/Response/Model/Session.php index cb01157ba..8b219ef1e 100644 --- a/src/Appwrite/Utopia/Response/Model/Session.php +++ b/src/Appwrite/Utopia/Response/Model/Session.php @@ -173,6 +173,12 @@ class Session extends Model 'default' => '', 'example' => '5e5bb8c16897e', ]) + ->addRule('mfaUpdatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Most recent date in ISO 8601 format when the session successfully passed MFA challenge.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) ; } diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 9b0d2ee0e..3f7609aca 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -234,6 +234,7 @@ class AccountCustomClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(2, $response['body']['total']); $this->assertEquals($sessionId, $response['body']['sessions'][0]['$id']); + $this->assertEmpty($response['body']['sessions'][0]['secret']); $this->assertEquals('Windows', $response['body']['sessions'][0]['osName']); $this->assertEquals('WIN', $response['body']['sessions'][0]['osCode']); @@ -1770,6 +1771,7 @@ class AccountCustomClientTest extends Scope ])); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEmpty($response['body']['secret']); $this->assertEquals('123456', $response['body']['providerAccessToken']); $this->assertEquals('tuvwxyz', $response['body']['providerRefreshToken']); $this->assertGreaterThan(DateTime::addSeconds(new \DateTime(), 14400 - 5), $response['body']['providerAccessTokenExpiry']); // 5 seconds allowed networking delay @@ -1805,6 +1807,7 @@ class AccountCustomClientTest extends Scope ])); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEmpty($response['body']['secret']); $this->assertEquals($response['body']['provider'], 'anonymous'); $sessionID = $response['body']['$id']; @@ -1817,6 +1820,7 @@ class AccountCustomClientTest extends Scope ])); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEmpty($response['body']['secret']); $this->assertEquals($response['body']['provider'], 'anonymous'); $response = $this->client->call(Client::METHOD_GET, '/account/sessions/97823askjdkasd80921371980', array_merge([