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

Merge pull request #7346 from appwrite/feat-mfa

feat: mfa
This commit is contained in:
Torsten Dittmann 2024-02-02 14:30:51 +01:00 committed by GitHub
commit d65a8d05a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1578 additions and 37 deletions

2
.gitmodules vendored
View file

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

View file

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

View file

@ -237,6 +237,11 @@ return [
'description' => 'Missing ID from OAuth2 provider.',
'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 => [
'name' => Exception::USER_OAUTH2_BAD_REQUEST,
'description' => 'OAuth2 provider rejected the bad request.',

View file

@ -185,7 +185,7 @@ return [
[
'key' => 'web',
'name' => 'Console',
'version' => '0.5.0',
'version' => '0.6.0-rc.8',
'url' => 'https://github.com/appwrite/sdk-for-console',
'package' => '',
'enabled' => true,
@ -196,7 +196,7 @@ return [
'prism' => 'javascript',
'source' => \realpath(__DIR__ . '/../sdks/console-web'),
'gitUrl' => 'https://github.com/appwrite/sdk-for-console.git',
'gitBranch' => 'main',
'gitBranch' => '1.5.x',
'gitRepoName' => 'sdk-for-console',
'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 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\Validator\Password;
use Appwrite\Auth\Validator\Phone;
@ -146,6 +149,8 @@ App::post('/v1/account')
'registration' => DateTime::now(),
'reset' => false,
'name' => $name,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(),
'sessions' => 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
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
],
@ -687,6 +693,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'registration' => DateTime::now(),
'reset' => false,
'name' => $name,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(),
'sessions' => null,
'tokens' => null,
@ -831,6 +839,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
@ -1050,6 +1059,8 @@ App::post('/v1/account/tokens/magic-url')
'passwordUpdate' => null,
'registration' => DateTime::now(),
'reset' => false,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(),
'sessions' => null,
'tokens' => null,
@ -1460,6 +1471,15 @@ $createSession = function (string $userId, string $secret, Request $request, Res
$record = $geodb->get($request->getIP());
$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(
[
'$id' => ID::unique(),
@ -1469,6 +1489,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => [$factor],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
],
@ -1825,6 +1846,8 @@ App::post('/v1/account/sessions/anonymous')
'registration' => DateTime::now(),
'reset' => false,
'name' => null,
'mfa' => false,
'totp' => false,
'prefs' => new \stdClass(),
'sessions' => null,
'tokens' => null,
@ -1850,6 +1873,7 @@ App::post('/v1/account/sessions/anonymous')
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => ['anonymous'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => DateTime::addSeconds(new \DateTime(), $duration)
],
@ -2170,9 +2194,8 @@ App::get('/v1/account/sessions/:sessionId')
->inject('response')
->inject('user')
->inject('locale')
->inject('dbForProject')
->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', []);
$sessionId = ($sessionId === 'current')
@ -2180,7 +2203,7 @@ App::get('/v1/account/sessions/:sessionId')
: $sessionId;
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'));
$session
@ -3406,6 +3429,391 @@ App::put('/v1/account/verification/phone')
$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')
->desc('Update Account\'s push target')
->groups(['api', 'account'])

View file

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

View file

@ -1,6 +1,7 @@
<?php
use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\Validator\Password;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
@ -1417,6 +1418,201 @@ App::patch('/v1/users/:userId/targets/:targetId')
->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')
->desc('Create token')
->groups(['api', 'users'])

View file

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

View file

@ -20,6 +20,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Swoole\Files;
use Appwrite\Utopia\Request;
use Swoole\Coroutine;
use Utopia\Logger\Log;
use Utopia\Logger\Log\User;
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(
'subQueryMemberships',
function (mixed $value) {
@ -1201,6 +1215,28 @@ App::setResource('project', function ($dbForConsole, $request, $console) {
return $project;
}, ['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 () {
return new Document([
'$id' => ID::custom('console'),

View file

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

364
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4b9b6ff602a179493e0196636d961e9c",
"content-hash": "3b43bf6f0fca50a3a2834e1bbaa90d63",
"packages": [
{
"name": "adhocore/jwt",
@ -197,6 +197,73 @@
],
"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",
"version": "4.3.4",
@ -738,6 +805,73 @@
],
"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",
"version": "v6.8.0",
@ -818,6 +952,81 @@
],
"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",
"version": "v1.28.0",
@ -901,6 +1110,145 @@
],
"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",
"version": "0.36.0",
@ -1195,16 +1543,16 @@
},
{
"name": "utopia-php/database",
"version": "0.48.0",
"version": "0.48.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
"reference": "2651f41b9d3909dc123d26becfb6a3a44fb63077"
"reference": "52abe057180a76fe354a516300344b33f268f6ea"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/database/zipball/2651f41b9d3909dc123d26becfb6a3a44fb63077",
"reference": "2651f41b9d3909dc123d26becfb6a3a44fb63077",
"url": "https://api.github.com/repos/utopia-php/database/zipball/52abe057180a76fe354a516300344b33f268f6ea",
"reference": "52abe057180a76fe354a516300344b33f268f6ea",
"shasum": ""
},
"require": {
@ -1245,9 +1593,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/database/issues",
"source": "https://github.com/utopia-php/database/tree/0.48.0"
"source": "https://github.com/utopia-php/database/tree/0.48.1"
},
"time": "2024-01-19T08:17:22+00:00"
"time": "2024-02-02T04:54:13+00:00"
},
{
"name": "utopia-php/domains",
@ -5175,5 +5523,5 @@
"platform-overrides": {
"php": "8.2"
},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.3.0"
}

View file

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

@ -82,7 +82,12 @@ class Exception extends \Exception
public const USER_AUTH_METHOD_UNSUPPORTED = 'user_auth_method_unsupported';
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_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_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_UNAUTHORIZED = 'user_oauth2_unauthorized';
public const USER_OAUTH2_PROVIDER_ERROR = 'user_oauth2_provider_error';

View file

@ -62,7 +62,6 @@ use Appwrite\Utopia\Response\Model\Metric;
use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\Platform;
use Appwrite\Utopia\Response\Model\Project;
use Appwrite\Utopia\Response\Model\Rule;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Utopia\Response\Model\Detection;
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\HealthTime;
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\LocaleCode;
use Appwrite\Utopia\Response\Model\MetricBreakdown;
@ -101,6 +103,7 @@ use Appwrite\Utopia\Response\Model\MigrationFirebaseProject;
use Appwrite\Utopia\Response\Model\MigrationReport;
// Keep last
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\Rule;
/**
* @method int getStatusCode()
@ -165,6 +168,12 @@ class Response extends SwooleResponse
public const MODEL_JWT = 'jwt';
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
public const MODEL_ALGO_MD5 = 'algoMd5';
public const MODEL_ALGO_SHA = 'algoSha';
@ -430,6 +439,9 @@ class Response extends SwooleResponse
->setModel(new TemplateSMS())
->setModel(new TemplateEmail())
->setModel(new ConsoleVariables())
->setModel(new MFAChallenge())
->setModel(new MFAProvider())
->setModel(new MFAProviders())
->setModel(new Provider())
->setModel(new Message())
->setModel(new Topic())
@ -438,8 +450,6 @@ class Response extends SwooleResponse
->setModel(new Migration())
->setModel(new MigrationReport())
->setModel(new MigrationFirebaseProject())
// Verification
// Recovery
// Tests (keep last)
->setModel(new Mock());
@ -654,7 +664,7 @@ class Response extends SwooleResponse
$this
->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,
'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', [
'type' => self::TYPE_STRING,
'description' => 'User list of roles',

View file

@ -160,6 +160,12 @@ class Session extends Model
'default' => false,
'example' => true,
])
->addRule('factors', [
'type' => self::TYPE_INTEGER,
'description' => 'Returns a list of active session factors.',
'default' => 1,
'example' => 1,
])
->addRule('secret', [
'type' => self::TYPE_STRING,
'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,
'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', [
'type' => Response::MODEL_PREFERENCES,
'description' => 'User preferences as a key-value object',

View file

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

View file

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