1
0
Fork 0
mirror of synced 2024-06-27 02:31:04 +12:00

Merge pull request #7713 from appwrite/feat-more-mfa-endpoints

Feat: More Recovery code endpoints
This commit is contained in:
Torsten Dittmann 2024-03-04 10:01:41 +01:00 committed by GitHub
commit f48b03e3b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 280 additions and 7 deletions

View file

@ -17,7 +17,6 @@ tasks:
ports:
- port: 8080
onOpen: open-preview
visibility: public
vscode:

View file

@ -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' => [
[

View file

@ -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.',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
])
;
}

View file

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