Fix MFA flows and docs
This commit is contained in:
parent
fa8d132402
commit
5cefca1c22
|
@ -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' => [
|
||||
[
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
1
docs/references/account/create-mfa-challenge.md
Normal file
1
docs/references/account/create-mfa-challenge.md
Normal 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.
|
1
docs/references/account/create-mfa-recovery-codes.md
Normal file
1
docs/references/account/create-mfa-recovery-codes.md
Normal 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.
|
|
@ -1 +0,0 @@
|
|||
Complete the MFA challenge by providing the one-time password.
|
1
docs/references/account/update-mfa-challenge.md
Normal file
1
docs/references/account/update-mfa-challenge.md
Normal 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.
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -13,6 +13,7 @@ class Account extends User
|
|||
$this
|
||||
->removeRule('password')
|
||||
->removeRule('hash')
|
||||
->removeRule('mfaRecoveryCodes')
|
||||
->removeRule('hashOptions');
|
||||
}
|
||||
|
||||
|
|
42
src/Appwrite/Utopia/Response/Model/MFARecoveryCodes.php
Normal file
42
src/Appwrite/Utopia/Response/Model/MFARecoveryCodes.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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.',
|
||||
|
|
Loading…
Reference in a new issue