1
0
Fork 0
mirror of synced 2024-09-29 17:01:37 +13:00

Update error message

This commit is contained in:
Khushboo Verma 2024-02-02 19:30:54 +05:30
commit 7b4f334f31
41 changed files with 1582 additions and 53 deletions

2
.gitmodules vendored
View file

@ -1,4 +1,4 @@
[submodule "app/console"] [submodule "app/console"]
path = app/console path = app/console
url = https://github.com/appwrite/console url = https://github.com/appwrite/console
branch = 3.2.16 branch = feat-mfa

View file

@ -245,6 +245,61 @@ $commonCollections = [
'array' => false, 'array' => false,
'filters' => [], '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'), '$id' => ID::custom('sessions'),
'type' => Database::VAR_STRING, 'type' => Database::VAR_STRING,
@ -267,6 +322,17 @@ $commonCollections = [
'array' => false, 'array' => false,
'filters' => ['subQueryTokens'], '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'), '$id' => ID::custom('memberships'),
'type' => Database::VAR_STRING, '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' => [ 'sessions' => [
'$collection' => ID::custom(Database::METADATA), '$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('sessions'), '$id' => ID::custom('sessions'),
@ -738,6 +885,17 @@ $commonCollections = [
'array' => false, 'array' => false,
'filters' => [], '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'), '$id' => ID::custom('expire'),
'type' => Database::VAR_DATETIME, 'type' => Database::VAR_DATETIME,

View file

@ -4,8 +4,8 @@
* List of server wide error codes and their respective messages. * List of server wide error codes and their respective messages.
*/ */
use Appwrite\Enum\MessageStatus;
use Appwrite\Extend\Exception; use Appwrite\Extend\Exception;
use Appwrite\Messaging\Status as MessageStatus;
return [ return [
/** General Errors */ /** General Errors */
@ -242,6 +242,11 @@ return [
'description' => 'Missing ID from OAuth2 provider.', 'description' => 'Missing ID from OAuth2 provider.',
'code' => 400, '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 => [ Exception::USER_OAUTH2_BAD_REQUEST => [
'name' => Exception::USER_OAUTH2_BAD_REQUEST, 'name' => Exception::USER_OAUTH2_BAD_REQUEST,
'description' => 'OAuth2 provider rejected the bad request.', 'description' => 'OAuth2 provider rejected the bad request.',

View file

@ -185,7 +185,7 @@ return [
[ [
'key' => 'web', 'key' => 'web',
'name' => 'Console', 'name' => 'Console',
'version' => '0.5.0', 'version' => '0.6.0-rc.8',
'url' => 'https://github.com/appwrite/sdk-for-console', 'url' => 'https://github.com/appwrite/sdk-for-console',
'package' => '', 'package' => '',
'enabled' => true, 'enabled' => true,
@ -196,7 +196,7 @@ return [
'prism' => 'javascript', 'prism' => 'javascript',
'source' => \realpath(__DIR__ . '/../sdks/console-web'), 'source' => \realpath(__DIR__ . '/../sdks/console-web'),
'gitUrl' => 'https://github.com/appwrite/sdk-for-console.git', 'gitUrl' => 'https://github.com/appwrite/sdk-for-console.git',
'gitBranch' => 'main', 'gitBranch' => '1.5.x',
'gitRepoName' => 'sdk-for-console', 'gitRepoName' => 'sdk-for-console',
'gitUserName' => 'appwrite', 'gitUserName' => 'appwrite',
], ],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,9 @@
use Ahc\Jwt\JWT; use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth; 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\OAuth2\Exception as OAuth2Exception;
use Appwrite\Auth\Validator\Password; use Appwrite\Auth\Validator\Password;
use Appwrite\Auth\Validator\Phone; use Appwrite\Auth\Validator\Phone;
@ -146,6 +149,8 @@ App::post('/v1/account')
'registration' => DateTime::now(), 'registration' => DateTime::now(),
'reset' => false, 'reset' => false,
'name' => $name, 'name' => $name,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(), 'prefs' => new \stdClass(),
'sessions' => null, 'sessions' => null,
'tokens' => 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 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(), 'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration) 'expire' => DateTime::addSeconds(new \DateTime(), $duration)
], ],
@ -462,8 +468,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
->label('abuse-limit', 50) ->label('abuse-limit', 50)
->label('abuse-key', 'ip:{ip}') ->label('abuse-key', 'ip:{ip}')
->label('docs', false) ->label('docs', false)
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('usage.params', ['provider:{request.provider}'])
->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 provider.') ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 provider.')
->param('code', '', new Text(2048, 0), 'OAuth2 code. This is a temporary code that the will be later exchanged for an access token.', true) ->param('code', '', new Text(2048, 0), 'OAuth2 code. This is a temporary code that the will be later exchanged for an access token.', true)
->param('state', '', new Text(2048), 'OAuth2 state params.', true) ->param('state', '', new Text(2048), 'OAuth2 state params.', true)
@ -689,6 +693,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'registration' => DateTime::now(), 'registration' => DateTime::now(),
'reset' => false, 'reset' => false,
'name' => $name, 'name' => $name,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(), 'prefs' => new \stdClass(),
'sessions' => null, 'sessions' => null,
'tokens' => null, 'tokens' => null,
@ -833,6 +839,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(), 'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration) 'expire' => DateTime::addSeconds(new \DateTime(), $duration)
], $detector->getOS(), $detector->getClient(), $detector->getDevice())); ], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
@ -886,7 +893,6 @@ App::get('/v1/account/identities')
->desc('List Identities') ->desc('List Identities')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'accounts.read') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'listIdentities') ->label('sdk.method', 'listIdentities')
@ -1053,6 +1059,8 @@ App::post('/v1/account/tokens/magic-url')
'passwordUpdate' => null, 'passwordUpdate' => null,
'registration' => DateTime::now(), 'registration' => DateTime::now(),
'reset' => false, 'reset' => false,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(), 'prefs' => new \stdClass(),
'sessions' => null, 'sessions' => null,
'tokens' => null, 'tokens' => null,
@ -1463,6 +1471,15 @@ $createSession = function (string $userId, string $secret, Request $request, Res
$record = $geodb->get($request->getIP()); $record = $geodb->get($request->getIP());
$sessionSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); $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( $session = new Document(array_merge(
[ [
'$id' => ID::unique(), '$id' => ID::unique(),
@ -1472,6 +1489,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(), 'ip' => $request->getIP(),
'factors' => [$factor],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration) 'expire' => DateTime::addSeconds(new \DateTime(), $duration)
], ],
@ -1544,7 +1562,6 @@ App::put('/v1/account/sessions/magic-url')
->label('audits.event', 'session.create') ->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}') ->label('audits.userId', '{response.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('sdk.auth', []) ->label('sdk.auth', [])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', ['updateMagicURLSession', 'updatePhoneSession']) ->label('sdk.method', ['updateMagicURLSession', 'updatePhoneSession'])
@ -1574,7 +1591,6 @@ App::post('/v1/account/sessions/token')
->label('audits.event', 'session.create') ->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}') ->label('audits.userId', '{response.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('sdk.auth', []) ->label('sdk.auth', [])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'createSession') ->label('sdk.method', 'createSession')
@ -1830,6 +1846,8 @@ App::post('/v1/account/sessions/anonymous')
'registration' => DateTime::now(), 'registration' => DateTime::now(),
'reset' => false, 'reset' => false,
'name' => null, 'name' => null,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(), 'prefs' => new \stdClass(),
'sessions' => null, 'sessions' => null,
'tokens' => null, 'tokens' => null,
@ -1855,6 +1873,7 @@ App::post('/v1/account/sessions/anonymous')
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(), 'ip' => $request->getIP(),
'factors' => ['anonymous'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration) 'expire' => DateTime::addSeconds(new \DateTime(), $duration)
], ],
@ -2022,7 +2041,6 @@ App::get('/v1/account')
->desc('Get account') ->desc('Get account')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'accounts.read') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'get') ->label('sdk.method', 'get')
@ -2046,7 +2064,6 @@ App::get('/v1/account/prefs')
->desc('Get account preferences') ->desc('Get account preferences')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'accounts.read') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'getPrefs') ->label('sdk.method', 'getPrefs')
@ -2069,7 +2086,6 @@ App::get('/v1/account/sessions')
->desc('List sessions') ->desc('List sessions')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'accounts.read') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'listSessions') ->label('sdk.method', 'listSessions')
@ -2105,7 +2121,6 @@ App::get('/v1/account/logs')
->desc('List logs') ->desc('List logs')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'accounts.read') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'listLogs') ->label('sdk.method', 'listLogs')
@ -2166,7 +2181,6 @@ App::get('/v1/account/sessions/:sessionId')
->desc('Get session') ->desc('Get session')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'accounts.read') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'getSession') ->label('sdk.method', 'getSession')
@ -2180,9 +2194,8 @@ App::get('/v1/account/sessions/:sessionId')
->inject('response') ->inject('response')
->inject('user') ->inject('user')
->inject('locale') ->inject('locale')
->inject('dbForProject')
->inject('project') ->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', []); $sessions = $user->getAttribute('sessions', []);
$sessionId = ($sessionId === 'current') $sessionId = ($sessionId === 'current')
@ -2190,7 +2203,7 @@ App::get('/v1/account/sessions/:sessionId')
: $sessionId; : $sessionId;
foreach ($sessions as $session) {/** @var Document $session */ 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')); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
$session $session
@ -3416,6 +3429,391 @@ App::put('/v1/account/verification/phone')
$response->dynamic($verificationDocument, Response::MODEL_TOKEN); $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') App::put('/v1/account/targets/:targetId/push')
->desc('Update Account\'s push target') ->desc('Update Account\'s push target')
->groups(['api', 'account']) ->groups(['api', 'account'])
@ -3480,7 +3878,6 @@ App::delete('/v1/account')
->label('scope', 'accounts.write') ->label('scope', 'accounts.write')
->label('audits.event', 'user.delete') ->label('audits.event', 'user.delete')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.delete')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'delete') ->label('sdk.method', 'delete')

View file

@ -2,11 +2,11 @@
use Appwrite\Auth\Validator\Phone; use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector; use Appwrite\Detector\Detector;
use Appwrite\Enum\MessageStatus;
use Appwrite\Event\Delete; use Appwrite\Event\Delete;
use Appwrite\Event\Event; use Appwrite\Event\Event;
use Appwrite\Event\Messaging; use Appwrite\Event\Messaging;
use Appwrite\Extend\Exception; use Appwrite\Extend\Exception;
use Appwrite\Messaging\Status as MessageStatus;
use Appwrite\Network\Validator\Email; use Appwrite\Network\Validator\Email;
use Appwrite\Permission; use Appwrite\Permission;
use Appwrite\Role; use Appwrite\Role;

View file

@ -744,6 +744,7 @@ App::get('/v1/teams/:teamId/memberships')
$user = $dbForProject->getDocument('users', $membership->getAttribute('userId')); $user = $dbForProject->getDocument('users', $membership->getAttribute('userId'));
$membership $membership
->setAttribute('mfa', $user->getAttribute('mfa'))
->setAttribute('teamName', $team->getAttribute('name')) ->setAttribute('teamName', $team->getAttribute('name'))
->setAttribute('userName', $user->getAttribute('name')) ->setAttribute('userName', $user->getAttribute('name'))
->setAttribute('userEmail', $user->getAttribute('email')) ->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 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(), 'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $authDuration) 'expire' => DateTime::addSeconds(new \DateTime(), $authDuration)
], $detector->getOS(), $detector->getClient(), $detector->getDevice())); ], $detector->getOS(), $detector->getClient(), $detector->getDevice()));

View file

@ -1,6 +1,7 @@
<?php <?php
use Appwrite\Auth\Auth; use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\Validator\Password; use Appwrite\Auth\Validator\Password;
use Appwrite\Auth\Validator\Phone; use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector; use Appwrite\Detector\Detector;
@ -1417,6 +1418,201 @@ App::patch('/v1/users/:userId/targets/:targetId')
->dynamic($target, Response::MODEL_TARGET); ->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') App::post('/v1/users/:userId/tokens')
->desc('Create token') ->desc('Create token')
->groups(['api', 'users']) ->groups(['api', 'users'])
@ -1424,7 +1620,6 @@ App::post('/v1/users/:userId/tokens')
->label('scope', 'users.write') ->label('scope', 'users.write')
->label('audits.event', 'tokens.create') ->label('audits.event', 'tokens.create')
->label('audits.resource', 'user/{request.userId}') ->label('audits.resource', 'user/{request.userId}')
->label('usage.metric', 'tokens.requests.create')
->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'users') ->label('sdk.namespace', 'users')
->label('sdk.method', 'createToken') ->label('sdk.method', 'createToken')

View file

@ -9,8 +9,6 @@ use Utopia\Logger\Logger;
use Utopia\Logger\Log; use Utopia\Logger\Log;
use Utopia\Logger\Log\User; use Utopia\Logger\Log\User;
use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Request as SwooleRequest;
use Utopia\Cache\Cache;
use Utopia\Pools\Group;
use Appwrite\Utopia\Request; use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response; use Appwrite\Utopia\Response;
use Appwrite\Utopia\View; use Appwrite\Utopia\View;
@ -210,12 +208,13 @@ App::init()
->inject('localeCodes') ->inject('localeCodes')
->inject('clients') ->inject('clients')
->inject('servers') ->inject('servers')
->inject('session')
->inject('mode')
->inject('queueForCertificates') ->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 * Appwrite Router
*/ */
$host = $request->getHostname() ?? ''; $host = $request->getHostname() ?? '';
$mainDomain = App::getEnv('_APP_DOMAIN', ''); $mainDomain = App::getEnv('_APP_DOMAIN', '');
// Only run Router when external domain // Only run Router when external domain
@ -563,6 +562,21 @@ App::init()
if ($user->getAttribute('reset')) { if ($user->getAttribute('reset')) {
throw new AppwriteException(AppwriteException::USER_PASSWORD_RESET_REQUIRED); 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() App::options()

View file

@ -20,6 +20,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document; use Utopia\Database\Document;
use Utopia\Swoole\Files; use Utopia\Swoole\Files;
use Appwrite\Utopia\Request; use Appwrite\Utopia\Request;
use Swoole\Coroutine;
use Utopia\Logger\Log; use Utopia\Logger\Log;
use Utopia\Logger\Log\User; use Utopia\Logger\Log\User;
use Utopia\Pools\Group; use Utopia\Pools\Group;

View file

@ -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( Database::addFilter(
'subQueryMemberships', 'subQueryMemberships',
function (mixed $value) { function (mixed $value) {
@ -1201,6 +1215,28 @@ App::setResource('project', function ($dbForConsole, $request, $console) {
return $project; return $project;
}, ['dbForConsole', 'request', 'console']); }, ['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 () { App::setResource('console', function () {
return new Document([ return new Document([
'$id' => ID::custom('console'), '$id' => ID::custom('console'),

View file

@ -15,7 +15,7 @@ $image = $this->getParam('image', '');
services: services:
traefik: traefik:
image: traefik:2.9 image: traefik:2.11
container_name: appwrite-traefik container_name: appwrite-traefik
<<: *x-logging <<: *x-logging
command: command:
@ -735,7 +735,7 @@ services:
- OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET - OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET
mariadb: mariadb:
image: mariadb:10.7 # fix issues when upgrading using: mysql_upgrade -u root -p image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p
container_name: appwrite-mariadb container_name: appwrite-mariadb
<<: *x-logging <<: *x-logging
restart: unless-stopped restart: unless-stopped
@ -751,7 +751,7 @@ services:
command: 'mysqld --innodb-flush-method=fsync' command: 'mysqld --innodb-flush-method=fsync'
redis: redis:
image: redis:7.0.4-alpine image: redis:7.2.4-alpine
container_name: appwrite-redis container_name: appwrite-redis
<<: *x-logging <<: *x-logging
restart: unless-stopped restart: unless-stopped

View file

@ -73,6 +73,7 @@
"phpmailer/phpmailer": "6.8.0", "phpmailer/phpmailer": "6.8.0",
"chillerlan/php-qrcode": "4.3.4", "chillerlan/php-qrcode": "4.3.4",
"adhocore/jwt": "1.1.2", "adhocore/jwt": "1.1.2",
"spomky-labs/otphp": "^10.0",
"webonyx/graphql-php": "14.11.*", "webonyx/graphql-php": "14.11.*",
"league/csv": "^9.14" "league/csv": "^9.14"
}, },

350
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f146300fe86e42d31f9cd99dd0807bbe", "content-hash": "3b43bf6f0fca50a3a2834e1bbaa90d63",
"packages": [ "packages": [
{ {
"name": "adhocore/jwt", "name": "adhocore/jwt",
@ -197,6 +197,73 @@
], ],
"time": "2023-11-22T15:36:00+00:00" "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", "name": "chillerlan/php-qrcode",
"version": "4.3.4", "version": "4.3.4",
@ -738,6 +805,73 @@
], ],
"time": "2019-09-10T13:16:29+00:00" "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", "name": "phpmailer/phpmailer",
"version": "v6.8.0", "version": "v6.8.0",
@ -818,6 +952,81 @@
], ],
"time": "2023-03-06T14:43:22+00:00" "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", "name": "symfony/polyfill-php80",
"version": "v1.28.0", "version": "v1.28.0",
@ -901,6 +1110,145 @@
], ],
"time": "2023-01-26T09:26:14+00:00" "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", "name": "utopia-php/abuse",
"version": "0.36.0", "version": "0.36.0",

View file

@ -14,7 +14,7 @@ version: '3'
services: services:
traefik: traefik:
image: traefik:2.9 image: traefik:2.11
<<: *x-logging <<: *x-logging
container_name: appwrite-traefik container_name: appwrite-traefik
command: command:
@ -917,7 +917,7 @@ services:
# - SMARTHOST_PORT=587 # - SMARTHOST_PORT=587
redis: redis:
image: redis:7.0.4-alpine image: redis:7.2.4-alpine
<<: *x-logging <<: *x-logging
container_name: appwrite-redis container_name: appwrite-redis
command: > command: >

View file

@ -346,8 +346,8 @@ class Auth
/** /**
* Verify token and check that its not expired. * Verify token and check that its not expired.
* *
* @param array $tokens * @param array<Document> $tokens
* @param int $type Type of token to verify, if null will verify any type * @param int $type Type of token to verify, if null will verify any type
* @param string $secret * @param string $secret
* *
* @return false|Document * @return false|Document
@ -355,7 +355,6 @@ class Auth
public static function tokenVerify(array $tokens, int $type = null, string $secret): false|Document public static function tokenVerify(array $tokens, int $type = null, string $secret): false|Document
{ {
foreach ($tokens as $token) { foreach ($tokens as $token) {
/** @var Document $token */
if ( if (
$token->isSet('secret') && $token->isSet('secret') &&
$token->isSet('expire') && $token->isSet('expire') &&
@ -374,7 +373,7 @@ class Auth
/** /**
* Verify session and check that its not expired. * Verify session and check that its not expired.
* *
* @param array $sessions * @param array<Document> $sessions
* @param string $secret * @param string $secret
* *
* @return bool|string * @return bool|string
@ -382,7 +381,6 @@ class Auth
public static function sessionVerify(array $sessions, string $secret) public static function sessionVerify(array $sessions, string $secret)
{ {
foreach ($sessions as $session) { foreach ($sessions as $session) {
/** @var Document $session */
if ( if (
$session->isSet('secret') && $session->isSet('secret') &&
$session->isSet('provider') && $session->isSet('provider') &&
@ -399,7 +397,7 @@ class Auth
/** /**
* Is Privileged User? * Is Privileged User?
* *
* @param array $roles * @param array<string> $roles
* *
* @return bool * @return bool
*/ */
@ -419,7 +417,7 @@ class Auth
/** /**
* Is App User? * Is App User?
* *
* @param array $roles * @param array<string> $roles
* *
* @return bool * @return bool
*/ */
@ -436,7 +434,7 @@ class Auth
* Returns all roles for a user. * Returns all roles for a user.
* *
* @param Document $user * @param Document $user
* @return array * @return array<string>
*/ */
public static function getRoles(Document $user): array public static function getRoles(Document $user): array
{ {
@ -486,6 +484,12 @@ class Auth
return $roles; return $roles;
} }
/**
* Check if user is anonymous.
*
* @param Document $user
* @return bool
*/
public static function isAnonymousUser(Document $user): bool public static function isAnonymousUser(Document $user): bool
{ {
return is_null($user->getAttribute('email')) return is_null($user->getAttribute('email'))

View file

@ -0,0 +1,11 @@
<?php
namespace Appwrite\Auth\MFA;
use Utopia\Database\Document;
abstract class Challenge
{
abstract public static function verify(Document $user, string $otp): bool;
abstract public static function challenge(Document $challenge, Document $user, string $otp): bool;
}

View file

@ -0,0 +1,26 @@
<?php
namespace Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\MFA\Challenge;
use Utopia\Database\Document;
class Email extends Challenge
{
public static function verify(Document $challenge, string $otp): bool
{
return $challenge->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;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\MFA\Challenge;
use Utopia\Database\Document;
class Phone extends Challenge
{
public static function verify(Document $challenge, string $otp): bool
{
return $challenge->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;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\MFA\Challenge;
use OTPHP\TOTP as TOTPLibrary;
use Utopia\Database\Document;
class TOTP extends Challenge
{
public static function verify(Document $user, string $otp): bool
{
$instance = TOTPLibrary::create($user->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;
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace Appwrite\Auth\MFA;
use Appwrite\Auth\Auth;
use OTPHP\OTP;
abstract class Provider
{
protected OTP $instance;
public function setLabel(string $label): self
{
$this->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;
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Appwrite\Auth\MFA\Provider;
use Appwrite\Auth\MFA\Provider;
use OTPHP\TOTP as TOTPLibrary;
class TOTP extends Provider
{
public function __construct(?string $secret = null)
{
$this->instance = TOTPLibrary::create($secret);
}
}

View file

@ -83,7 +83,12 @@ class Exception extends \Exception
public const USER_AUTH_METHOD_UNSUPPORTED = 'user_auth_method_unsupported'; public const USER_AUTH_METHOD_UNSUPPORTED = 'user_auth_method_unsupported';
public const USER_PHONE_ALREADY_EXISTS = 'user_phone_already_exists'; 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_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_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_BAD_REQUEST = 'user_oauth2_bad_request';
public const USER_OAUTH2_UNAUTHORIZED = 'user_oauth2_unauthorized'; public const USER_OAUTH2_UNAUTHORIZED = 'user_oauth2_unauthorized';
public const USER_OAUTH2_PROVIDER_ERROR = 'user_oauth2_provider_error'; public const USER_OAUTH2_PROVIDER_ERROR = 'user_oauth2_provider_error';

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Appwrite\Enum; namespace Appwrite\Messaging;
class MessageStatus class Status
{ {
/** /**
* Message that is not ready to be sent * Message that is not ready to be sent

View file

@ -2,8 +2,8 @@
namespace Appwrite\Platform\Workers; namespace Appwrite\Platform\Workers;
use Appwrite\Enum\MessageStatus;
use Appwrite\Extend\Exception; use Appwrite\Extend\Exception;
use Appwrite\Messaging\Status as MessageStatus;
use Utopia\App; use Utopia\App;
use Utopia\CLI\Console; use Utopia\CLI\Console;
use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\ID;

View file

@ -62,7 +62,6 @@ use Appwrite\Utopia\Response\Model\Metric;
use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\Platform; use Appwrite\Utopia\Response\Model\Platform;
use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Project;
use Appwrite\Utopia\Response\Model\Rule;
use Appwrite\Utopia\Response\Model\Deployment; use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Utopia\Response\Model\Detection; use Appwrite\Utopia\Response\Model\Detection;
use Appwrite\Utopia\Response\Model\Headers; 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\HealthStatus;
use Appwrite\Utopia\Response\Model\HealthTime; use Appwrite\Utopia\Response\Model\HealthTime;
use Appwrite\Utopia\Response\Model\HealthVersion; 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\Installation;
use Appwrite\Utopia\Response\Model\LocaleCode; use Appwrite\Utopia\Response\Model\LocaleCode;
use Appwrite\Utopia\Response\Model\MetricBreakdown; use Appwrite\Utopia\Response\Model\MetricBreakdown;
@ -101,6 +103,7 @@ use Appwrite\Utopia\Response\Model\MigrationFirebaseProject;
use Appwrite\Utopia\Response\Model\MigrationReport; use Appwrite\Utopia\Response\Model\MigrationReport;
// Keep last // Keep last
use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\Rule;
/** /**
* @method int getStatusCode() * @method int getStatusCode()
@ -165,6 +168,12 @@ class Response extends SwooleResponse
public const MODEL_JWT = 'jwt'; public const MODEL_JWT = 'jwt';
public const MODEL_PREFERENCES = 'preferences'; 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 // Users password algos
public const MODEL_ALGO_MD5 = 'algoMd5'; public const MODEL_ALGO_MD5 = 'algoMd5';
public const MODEL_ALGO_SHA = 'algoSha'; public const MODEL_ALGO_SHA = 'algoSha';
@ -430,6 +439,9 @@ class Response extends SwooleResponse
->setModel(new TemplateSMS()) ->setModel(new TemplateSMS())
->setModel(new TemplateEmail()) ->setModel(new TemplateEmail())
->setModel(new ConsoleVariables()) ->setModel(new ConsoleVariables())
->setModel(new MFAChallenge())
->setModel(new MFAProvider())
->setModel(new MFAProviders())
->setModel(new Provider()) ->setModel(new Provider())
->setModel(new Message()) ->setModel(new Message())
->setModel(new Topic()) ->setModel(new Topic())
@ -438,8 +450,6 @@ class Response extends SwooleResponse
->setModel(new Migration()) ->setModel(new Migration())
->setModel(new MigrationReport()) ->setModel(new MigrationReport())
->setModel(new MigrationFirebaseProject()) ->setModel(new MigrationFirebaseProject())
// Verification
// Recovery
// Tests (keep last) // Tests (keep last)
->setModel(new Mock()); ->setModel(new Mock());
@ -654,7 +664,7 @@ class Response extends SwooleResponse
$this $this
->setContentType(Response::CONTENT_TYPE_YAML) ->setContentType(Response::CONTENT_TYPE_YAML)
->send(yaml_emit($data, YAML_UTF8_ENCODING)); ->send(\yaml_emit($data, YAML_UTF8_ENCODING));
} }
/** /**

View file

@ -0,0 +1,59 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class MFAChallenge extends Model
{
public function __construct()
{
$this
->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;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class MFAProvider 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.',
'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;
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class MFAProviders extends Model
{
public function __construct()
{
$this
->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;
}
}

View file

@ -76,6 +76,12 @@ class Membership extends Model
'default' => false, 'default' => false,
'example' => 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', [ ->addRule('roles', [
'type' => self::TYPE_STRING, 'type' => self::TYPE_STRING,
'description' => 'User list of roles', 'description' => 'User list of roles',

View file

@ -160,6 +160,12 @@ class Session extends Model
'default' => false, 'default' => false,
'example' => true, 'example' => true,
]) ])
->addRule('factors', [
'type' => self::TYPE_INTEGER,
'description' => 'Returns a list of active session factors.',
'default' => 1,
'example' => 1,
])
->addRule('secret', [ ->addRule('secret', [
'type' => self::TYPE_STRING, 'type' => self::TYPE_STRING,
'description' => 'Secret used to authenticate the user. Only included if the request was made with an API key', 'description' => 'Secret used to authenticate the user. Only included if the request was made with an API key',

View file

@ -114,6 +114,18 @@ class User extends Model
'default' => false, 'default' => false,
'example' => true, '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', [ ->addRule('prefs', [
'type' => Response::MODEL_PREFERENCES, 'type' => Response::MODEL_PREFERENCES,
'description' => 'User preferences as a key-value object', 'description' => 'User preferences as a key-value object',

View file

@ -2248,7 +2248,7 @@ class AccountCustomClientTest extends Scope
$this->assertEmpty($response['body']['secret']); $this->assertEmpty($response['body']['secret']);
$this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire'])); $this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire']));
\sleep(2); \sleep(5);
$smsRequest = $this->getLastRequest(); $smsRequest = $this->getLastRequest();

View file

@ -2,7 +2,7 @@
namespace Tests\E2E\Services\Messaging; namespace Tests\E2E\Services\Messaging;
use Appwrite\Enum\MessageStatus; use Appwrite\Messaging\Status as MessageStatus;
use Tests\E2E\Client; use Tests\E2E\Client;
use Utopia\App; use Utopia\App;
use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\ID;

View file

@ -425,6 +425,7 @@ trait TeamsBaseClient
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
])); ]));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(true, $response['body']['emailVerification']); $this->assertEquals(true, $response['body']['emailVerification']);