1
0
Fork 0
mirror of synced 2024-06-28 19:20:25 +12:00

Fix MFA flows and docs

This commit is contained in:
Matej Bačo 2024-03-01 16:22:51 +00:00
parent fa8d132402
commit 5cefca1c22
20 changed files with 169 additions and 48 deletions

View file

@ -278,6 +278,17 @@ $commonCollections = [
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('mfaRecoveryCodes'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
[
'$id' => ID::custom('authenticators'),
'type' => Database::VAR_STRING,
@ -365,7 +376,7 @@ $commonCollections = [
'default' => null,
'array' => false,
'filters' => ['datetime'],
]
],
],
'indexes' => [
[

View file

@ -232,6 +232,11 @@ return [
'description' => 'A user with the same phone number already exists in the current project.',
'code' => 409,
],
Exception::USER_RECOVERY_CODES_ALREADY_EXISTS => [
'name' => Exception::USER_RECOVERY_CODES_ALREADY_EXISTS,
'description' => 'The current user already generated recovery codes and they can only be read once for security reasons.',
'code' => 409,
],
Exception::USER_PHONE_NOT_FOUND => [
'name' => Exception::USER_PHONE_NOT_FOUND,
'description' => 'The current user does not have a phone number associated with their account.',

View file

@ -3543,8 +3543,8 @@ App::get('/v1/account/mfa/factors')
->label('scope', 'account')
->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/list-factors.md')
->label('sdk.method', 'listMfaFactors')
->label('sdk.description', '/docs/references/account/list-mfa-factors.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_MFA_FACTORS)
@ -3565,7 +3565,7 @@ App::get('/v1/account/mfa/factors')
$response->dynamic($factors, Response::MODEL_MFA_FACTORS);
});
App::post('/v1/account/mfa/:type')
App::post('/v1/account/mfa/authenticators/:type')
->desc('Add Authenticator')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.mfa')
@ -3575,8 +3575,8 @@ App::post('/v1/account/mfa/:type')
->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/add-authenticator.md')
->label('sdk.method', 'createMfaAuthenticator')
->label('sdk.description', '/docs/references/account/create-mfa-authenticator.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_MFA_TYPE)
@ -3599,8 +3599,6 @@ App::post('/v1/account/mfa/:type')
$otp->setLabel($user->getAttribute('email'));
$otp->setIssuer($project->getAttribute('name'));
$backups = Type::generateBackupCodes();
$authenticator = TOTP::getAuthenticatorFromUser($user);
if ($authenticator) {
@ -3618,7 +3616,6 @@ App::post('/v1/account/mfa/:type')
'verified' => false,
'data' => [
'secret' => $otp->getSecret(),
'backups' => $backups
],
'$permissions' => [
Permission::read(Role::user($user->getId())),
@ -3628,7 +3625,6 @@ App::post('/v1/account/mfa/:type')
]);
$model = new Document([
'backups' => $backups,
'secret' => $otp->getSecret(),
'uri' => $otp->getProvisioningUri()
]);
@ -3641,7 +3637,7 @@ App::post('/v1/account/mfa/:type')
$response->dynamic($model, Response::MODEL_MFA_TYPE);
});
App::put('/v1/account/mfa/:type')
App::put('/v1/account/mfa/authenticators/:type')
->desc('Verify Authenticator')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.mfa')
@ -3651,8 +3647,8 @@ App::put('/v1/account/mfa/:type')
->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/verify-authenticator.md')
->label('sdk.method', 'updateMfaAuthenticator')
->label('sdk.description', '/docs/references/account/update-mfa-authenticator.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER)
@ -3704,7 +3700,50 @@ App::put('/v1/account/mfa/:type')
$response->dynamic($user, Response::MODEL_ACCOUNT);
});
App::delete('/v1/account/mfa/:type')
App::post('/v1/account/mfa/recovery-codes')
->desc('Create MFA Recovery Codes')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.mfa')
->label('scope', 'account')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('audits.userId', '{response.$id}')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->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.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER)
->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) {
$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::delete('/v1/account/mfa/authenticators/:type')
->desc('Delete Authenticator')
->groups(['api', 'account'])
->label('event', 'users.[userId].delete.mfa')
@ -3714,8 +3753,8 @@ App::delete('/v1/account/mfa/:type')
->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.method', 'deleteMfaAuthenticator')
->label('sdk.description', '/docs/references/account/delete-mfa-authenticator.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER)
@ -3736,8 +3775,22 @@ App::delete('/v1/account/mfa/:type')
throw new Exception(Exception::GENERAL_UNKNOWN, 'Authenticator not found.');
}
$recoveryCodeVerify = function (Document $user, string $otp) use ($dbForProject) {
$mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []);
if (in_array($otp, $mfaRecoveryCodes)) {
$mfaRecoveryCodes = array_diff($mfaRecoveryCodes, [$otp]);
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
return true;
}
return false;
};
$success = (match ($type) {
Type::TOTP => Challenge\TOTP::verify($user, $otp),
Type::RECOVERY_CODE => $recoveryCodeVerify($user, $otp),
default => false
});
@ -3763,14 +3816,14 @@ App::post('/v1/account/mfa/challenge')
->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.method', 'createMfaChallenge')
->label('sdk.description', '/docs/references/account/create-mfa-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('factor', '', new WhiteList([Type::EMAIL, Type::PHONE, Type::TOTP]), 'Factor used for verification. Must be one of following: `' . Type::EMAIL . '`, `' . Type::PHONE . '`, `' . Type::TOTP . '`.')
->param('factor', '', new WhiteList([Type::EMAIL, Type::PHONE, Type::TOTP, Type::RECOVERY_CODE]), 'Factor used for verification. Must be one of following: `' . Type::EMAIL . '`, `' . Type::PHONE . '`, `' . Type::TOTP . '`, `' . Type::RECOVERY_CODE . '`.')
->inject('response')
->inject('dbForProject')
->inject('user')
@ -3951,8 +4004,8 @@ App::put('/v1/account/mfa/challenge')
->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.method', 'updateMfaChallenge')
->label('sdk.description', '/docs/references/account/update-mfa-challenge.md')
->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT)
->label('sdk.response.model', Response::MODEL_SESSION)
->label('abuse-limit', 10)
@ -3974,25 +4027,34 @@ App::put('/v1/account/mfa/challenge')
$type = $challenge->getAttribute('type');
$recoveryCodeChallenge = function (Document $challenge, Document $user, string $otp) use ($dbForProject) {
if (
$challenge->isSet('type') &&
$challenge->getAttribute('type') === Type::RECOVERY_CODE
) {
$mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []);
if (in_array($otp, $mfaRecoveryCodes)) {
$mfaRecoveryCodes = array_diff($mfaRecoveryCodes, [$otp]);
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
return true;
}
return false;
}
return false;
};
$success = (match ($type) {
Type::TOTP => Challenge\TOTP::challenge($challenge, $user, $otp),
Type::PHONE => Challenge\Phone::challenge($challenge, $user, $otp),
Type::EMAIL => Challenge\Email::challenge($challenge, $user, $otp),
Type::RECOVERY_CODE => $recoveryCodeChallenge($challenge, $user, $otp),
default => false
});
if (!$success && $type === Type::TOTP) {
$authenticator = TOTP::getAuthenticatorFromUser($user);
$data = $authenticator?->getAttribute('data', []);
if (in_array($otp, $data['backups'] ?? [])) {
$success = true;
$backups = array_diff($data['backups'], [$otp]);
$authenticator->setAttribute('data', array_merge($data, ['backups' => $backups]));
$dbForProject->updateDocument('authenticators', $authenticator->getId(), $authenticator);
$dbForProject->purgeCachedDocument('users', $user->getId());
}
}
if (!$success) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}

View file

@ -1565,8 +1565,8 @@ App::get('/v1/users/:userId/mfa/factors')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'users')
->label('sdk.method', 'listFactors')
->label('sdk.description', '/docs/references/users/list-factors.md')
->label('sdk.method', 'listMfaFactors')
->label('sdk.description', '/docs/references/users/list-mfa-factors.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_MFA_FACTORS)
@ -1591,7 +1591,7 @@ App::get('/v1/users/:userId/mfa/factors')
$response->dynamic($factors, Response::MODEL_MFA_FACTORS);
});
App::delete('/v1/users/:userId/mfa/:type')
App::delete('/v1/users/:userId/mfa/authenticators/:type')
->desc('Delete Authenticator')
->groups(['api', 'users'])
->label('event', 'users.[userId].delete.mfa')
@ -1602,8 +1602,8 @@ App::delete('/v1/users/:userId/mfa/:type')
->label('usage.metric', 'users.{scope}.requests.update')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'users')
->label('sdk.method', 'deleteAuthenticator')
->label('sdk.description', '/docs/references/users/delete-mfa.md')
->label('sdk.method', 'deleteMfaAuthenticator')
->label('sdk.description', '/docs/references/users/delete-mfa-authenticator.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER)

View file

@ -0,0 +1 @@
Begin the process of MFA verification after sign-in. Finish the flow with [updateMfaChallenge](/docs/references/cloud/client-web/account#updateMfaChallenge) method.

View file

@ -0,0 +1 @@
Generate recovery codes as backup for MFA flow. It's recommended to generate and show then immediately after user successfully adds their authehticator. Recovery codes can be used as a MFA verification type in [createMfaChallenge](/docs/references/cloud/client-web/account#createMfaChallenge) method.

View file

@ -1 +0,0 @@
Complete the MFA challenge by providing the one-time password.

View file

@ -0,0 +1 @@
Complete the MFA challenge by providing the one-time password. Finish the process of MFA verification by providing the one-time password. To begin the flow, use [createMfaChallenge](/docs/references/cloud/client-web/account#createMfaChallenge) method.

View file

@ -12,6 +12,7 @@ abstract class Type
public const TOTP = 'totp';
public const EMAIL = 'email';
public const PHONE = 'phone';
public const RECOVERY_CODE = 'recoveryCode';
public function setLabel(string $label): self
{
@ -47,12 +48,12 @@ abstract class Type
return $this->instance->getProvisioningUri();
}
public static function generateBackupCodes(int $length = 6, int $total = 6): array
public static function generateBackupCodes(int $length = 10, int $total = 6): array
{
$backups = [];
for ($i = 0; $i < $total; $i++) {
$backups[] = Auth::codeGenerator($length);
$backups[] = Auth::tokenGenerator($length);
}
return $backups;

View file

@ -89,6 +89,7 @@ class Exception extends \Exception
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_RECOVERY_CODES_ALREADY_EXISTS = 'user_recovery_codes_already_exists';
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

@ -82,6 +82,7 @@ use Appwrite\Utopia\Response\Model\MetricBreakdown;
use Appwrite\Utopia\Response\Model\Provider;
use Appwrite\Utopia\Response\Model\Message;
use Appwrite\Utopia\Response\Model\MFAFactors;
use Appwrite\Utopia\Response\Model\MFARecoveryCodes;
use Appwrite\Utopia\Response\Model\MFAType;
use Appwrite\Utopia\Response\Model\Subscriber;
use Appwrite\Utopia\Response\Model\Topic;
@ -174,6 +175,7 @@ class Response extends SwooleResponse
public const MODEL_MFA_FACTORS = 'mfaFactors';
public const MODEL_MFA_OTP = 'mfaTotp';
public const MODEL_MFA_CHALLENGE = 'mfaChallenge';
public const MODEL_MFA_RECOVERY_CODES = 'mfaRecoveryCodes';
// Users password algos
public const MODEL_ALGO_MD5 = 'algoMd5';
@ -443,6 +445,7 @@ class Response extends SwooleResponse
->setModel(new TemplateEmail())
->setModel(new ConsoleVariables())
->setModel(new MFAChallenge())
->setModel(new MFARecoveryCodes())
->setModel(new MFAType())
->setModel(new MFAFactors())
->setModel(new Provider())

View file

@ -13,6 +13,7 @@ class Account extends User
$this
->removeRule('password')
->removeRule('hash')
->removeRule('mfaRecoveryCodes')
->removeRule('hashOptions');
}

View file

@ -0,0 +1,42 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class MFARecoveryCodes extends Model
{
public function __construct()
{
$this
->addRule('recoveryCodes', [
'type' => self::TYPE_STRING,
'description' => 'Recovery codes.',
'array' => true,
'default' => [],
'example' => ['a3kf0-s0cl2', 's0co1-as98s']
])
;
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'MFA Recovery Codes';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_MFA_RECOVERY_CODES;
}
}

View file

@ -10,13 +10,6 @@ class MFAType extends Model
public function __construct()
{
$this
->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.',