commit
d65a8d05a4
35 changed files with 1578 additions and 37 deletions
2
.gitmodules
vendored
2
.gitmodules
vendored
|
@ -1,4 +1,4 @@
|
|||
[submodule "app/console"]
|
||||
path = app/console
|
||||
url = https://github.com/appwrite/console
|
||||
branch = 3.2.16
|
||||
branch = feat-mfa
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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
|
@ -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'])
|
||||
|
|
|
@ -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()));
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
|
|
36
app/init.php
36
app/init.php
|
@ -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'),
|
||||
|
|
|
@ -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
364
composer.lock
generated
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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'))
|
||||
|
|
11
src/Appwrite/Auth/MFA/Challenge.php
Normal file
11
src/Appwrite/Auth/MFA/Challenge.php
Normal 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;
|
||||
}
|
26
src/Appwrite/Auth/MFA/Challenge/Email.php
Normal file
26
src/Appwrite/Auth/MFA/Challenge/Email.php
Normal 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;
|
||||
}
|
||||
}
|
26
src/Appwrite/Auth/MFA/Challenge/Phone.php
Normal file
26
src/Appwrite/Auth/MFA/Challenge/Phone.php
Normal 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;
|
||||
}
|
||||
}
|
29
src/Appwrite/Auth/MFA/Challenge/TOTP.php
Normal file
29
src/Appwrite/Auth/MFA/Challenge/TOTP.php
Normal 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;
|
||||
}
|
||||
}
|
56
src/Appwrite/Auth/MFA/Provider.php
Normal file
56
src/Appwrite/Auth/MFA/Provider.php
Normal 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;
|
||||
}
|
||||
}
|
14
src/Appwrite/Auth/MFA/Provider/TOTP.php
Normal file
14
src/Appwrite/Auth/MFA/Provider/TOTP.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
59
src/Appwrite/Utopia/Response/Model/MFAChallenge.php
Normal file
59
src/Appwrite/Utopia/Response/Model/MFAChallenge.php
Normal 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;
|
||||
}
|
||||
}
|
54
src/Appwrite/Utopia/Response/Model/MFAProvider.php
Normal file
54
src/Appwrite/Utopia/Response/Model/MFAProvider.php
Normal 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;
|
||||
}
|
||||
}
|
53
src/Appwrite/Utopia/Response/Model/MFAProviders.php
Normal file
53
src/Appwrite/Utopia/Response/Model/MFAProviders.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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']);
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue