mirror of
https://github.com/appwrite/appwrite
synced 2026-05-22 00:18:25 +00:00
Merge remote-tracking branch 'origin/1.5.x' into fix-spec-generation
# Conflicts: # app/config/specs/open-api3-latest-client.json # app/config/specs/open-api3-latest-console.json # app/config/specs/open-api3-latest-server.json # app/config/specs/swagger2-latest-client.json # app/config/specs/swagger2-latest-console.json # app/config/specs/swagger2-latest-server.json
This commit is contained in:
commit
f14c0cc0aa
29 changed files with 1566 additions and 25 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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
352
composer.lock
generated
352
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",
|
||||
|
|
@ -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