diff --git a/app/config/auth.php b/app/config/auth.php index 64d9e3ecd8..4c732db394 100644 --- a/app/config/auth.php +++ b/app/config/auth.php @@ -51,12 +51,5 @@ return [ 'icon' => '/images/users/phone.png', 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreatePhoneToken', 'enabled' => true, - ], - 'webauthn' => [ - 'name' => 'Webauthn', - 'key' => 'webauthn', - 'icon' => '/images/users/webauthn.png', - 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreateWebAuthnSession', - 'enabled' => true, ] ]; diff --git a/app/config/collections.php b/app/config/collections.php index db2b6f5fa4..72d126e343 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -289,17 +289,6 @@ $commonCollections = [ 'array' => true, 'filters' => ['encrypt'], ], - [ - '$id' => ID::custom('credentialSources'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => true, - 'filters' => ['subQueryCredentialSources'], - ], [ '$id' => ID::custom('authenticators'), 'type' => Database::VAR_STRING, @@ -463,245 +452,6 @@ $commonCollections = [ ], ], - 'webauthnChallenges' => [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('webauthnChallenges'), - 'name' => 'Webauthn Challenges', - 'attributes' => [ - [ - '$id' => ID::custom('userId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('email'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('type'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('rp'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 4096, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('user'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 4096, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('challenge'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('pubKeyCredParams'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 2048, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('expire'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_user'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['userId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - ] - ], - - 'credentialSources' => [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('credentialSources'), - 'name' => 'Webauthn Authenticators', - 'attributes' => [ - [ - '$id' => ID::custom('userInternalId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('publicKeyCredentialId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 1024, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('type'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('transports'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => true, - 'filters' => [], - ], - [ - '$id' => ID::custom('attestationType'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 1024, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('aaguid'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 1024, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('trustPath'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 2048, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('credentialPublicKey'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 2048, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('userHandle'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 1024, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('counter'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 64, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ] - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_userInternalId'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['userInternalId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_publicKeyCredentialId'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['publicKeyCredentialId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - ], - ], - 'tokens' => [ '$collection' => ID::custom(Database::METADATA), '$id' => ID::custom('tokens'), diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index dcf406a654..5d2568ede4 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -56,17 +56,6 @@ use Utopia\Validator\Host; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; -use Webauthn\AttestationStatement\AttestationObjectLoader; -use Webauthn\AttestationStatement\AttestationStatementSupportManager; -use Webauthn\AuthenticatorAssertionResponse; -use Webauthn\AuthenticatorAssertionResponseValidator; -use Webauthn\AuthenticatorAttestationResponseValidator; -use Webauthn\PublicKeyCredentialCreationOptions; -use Webauthn\PublicKeyCredentialLoader; -use Webauthn\PublicKeyCredentialRequestOptions; -use Webauthn\PublicKeyCredentialRpEntity; -use Webauthn\PublicKeyCredentialSource; -use Webauthn\PublicKeyCredentialUserEntity; $oauthDefaultSuccess = '/auth/oauth2/success'; $oauthDefaultFailure = '/auth/oauth2/failure'; @@ -179,18 +168,6 @@ $createSession = function (string $userId, string $secret, Request $request, Res $response->dynamic($session, Response::MODEL_SESSION); }; -$attestationSupportManager = AttestationStatementSupportManager::create(); -$attestationObjectLoader = AttestationObjectLoader::create( - $attestationSupportManager -); -$publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader); - -$authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( - $attestationSupportManager -); - -$authenticationAssertionResponseValdiator = AuthenticatorAssertionResponseValidator::create(); - App::post('/v1/account') ->desc('Create account') ->groups(['api', 'account', 'auth']) @@ -1200,333 +1177,6 @@ App::post('/v1/account/sessions/token') ->inject('queueForEvents') ->action($createSession); -App::post('/v1/account/sessions/webauthn') - ->desc('Create WebAuthn session') - ->groups(['api', 'account', 'session']) - ->label('scope', 'sessions.write') - ->label('sdk.auth', []) - ->label('sdk.namespace', 'account') - ->label('sdk.method', 'createWebauthnSession') - ->label('sdk.description', '/docs/references/account/create-webauthn-session.md') - ->label('sdk.response.code', Response::STATUS_CODE_CREATED) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_WEBAUTHN_LOGIN_CHALLENGE) - ->label('abuse-limit', 10) - ->label('abuse-key', 'ip:{ip},name:{param-name}') - ->param('name', '', new Text(256), 'Username.') - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->action(function (string $name, Request $request, Response $response, Database $dbForProject, Document $project) { - $profile = $dbForProject->findOne('users', [ - Query::equal('name', [$name]), - ]); - - if (!$profile) { - throw new Exception(Exception::USER_INVALID_CREDENTIALS); - } - - $authenticators = Authorization::skip(fn () => $dbForProject->find('credentialSources', [ - Query::equal('userInternalId', [$profile->getInternalId()]), - ])); - - if (empty($authenticators)) { - throw new Exception(Exception::USER_INVALID_CREDENTIALS); - } - - if (false === $profile->getAttribute('status')) { // Account is blocked - throw new Exception(Exception::USER_BLOCKED); // User is in status blocked - } - - $allowedCredentials = []; - - foreach ($authenticators as $authenticator) { - $credentialSource = PublicKeyCredentialSource::createFromArray( - $authenticator->getArrayCopy() - ); - - $allowedCredentials[] = ($credentialSource->getPublicKeyCredentialDescriptor())->jsonSerialize(); - } - - $platforms = $project->getAttribute('platforms', []); - $platformName = ''; - $platformId = ''; - - // Detect platform and set platform name and id for Relying Party. - switch ($request->getHeader('x-sdk-name', '')) { - case 'Flutter': - $packageName = explode('/', $request->getHeader('user-agent', ''))[0] ?? ''; - - foreach ($platforms as $platform) { - if (str_starts_with($platform['type'], 'flutter') && $platform['key'] === $packageName) { - $platformName = $platform['name']; - $platformId = $platform['hostname']; - break; - } - } - break; - - case 'Apple': - $packageName = explode('/', $request->getHeader('user-agent', ''))[0] ?? ''; - - foreach ($platforms as $platform) { - if (str_starts_with($platform['type'], 'apple') && $platform['key'] === $packageName) { - $platformName = $platform['name']; - $platformId = $platform['hostname']; - break; - } - } - break; - - case 'Android': - $packageName = explode('/', $request->getHeader('user-agent', ''))[0] ?? ''; - - foreach ($platforms as $platform) { - if ($platform['type'] === 'android' && $platform['key'] === $packageName) { - $platformName = $platform['name']; - $platformId = $platform['hostname']; - break; - } - } - break; - - case 'Web': - default: - // Fallback to any web platform that matches the domain - foreach ($platforms as $platform) { - if ($platform['type'] === 'web' && $platform['hostname'] == $request->getHostname()) { - $platformName = $platform['name']; - $platformId = $platform['hostname']; - break; - } - } - break; - } - - // Console - if ($project->getId() === 'console') { - $platformName = 'Appwrite'; - $platformId = 'localhost'; // TODO: Replace with hostname from _APP_DOMAIN - } - - $rpEntity = PublicKeyCredentialRpEntity::create( - $platformName, - $platformId, - ); - - $timeout = 60 * 5 * 1000; // 5 minutes in milliseconds - - $publicKeyCredentialRequestOptions = - PublicKeyCredentialRequestOptions::create( - random_bytes(32), // Challenge - userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED, - timeout: $timeout, - allowCredentials: $allowedCredentials, - rpId: $platformId, - ); - - // Store challenge - $expire = DateTime::addSeconds(new \DateTime(), $timeout / 1000); - $id = ID::unique(); - $dbForProject->createDocument('webauthnChallenges', new Document([ - '$id' => $id, - 'userId' => $profile->getId(), - 'type' => 'session_create', - 'rp' => json_encode($rpEntity), - 'challenge' => Base64UrlSafe::encodeUnpadded($publicKeyCredentialRequestOptions->challenge), - 'expire' => $expire, - ])); - - // Send challenge - $response->dynamic(new Document( - array_merge( - [ - '$id' => $id, - ], - $publicKeyCredentialRequestOptions->jsonSerialize() - ) - ), Response::MODEL_WEBAUTHN_LOGIN_CHALLENGE); - }); - -App::put('/v1/account/sessions/webauthn') - ->desc('Create WebAuthn session (validation)') - ->label('event', 'users.[userId].sessions.[sessionId].create') - ->groups(['api', 'account', 'session']) - ->label('scope', 'sessions.write') - ->label('audits.event', 'session.create') - ->label('audits.resource', 'user/{response.userId}') - ->label('audits.userId', '{response.userId}') - ->label('sdk.auth', []) - ->label('sdk.namespace', 'account') - ->label('sdk.method', 'createWebauthnSession') - ->label('sdk.description', '/docs/references/account/create-webauthn-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) - ->label('abuse-limit', 10) - ->label('abuse-key', 'ip:{ip},name:{param-name}') - ->param('challengeId', '', new UID(), 'Challenge ID.') - ->param('challengeResponse', '', new Text(8196), 'Challenge response.') - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('user') - ->inject('locale') - ->inject('geodb') - ->inject('queueForEvents') - ->action(function (string $challengeId, string $challengeResponse, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Locale $locale, Reader $geodb, Event $queueForEvents) use ($publicKeyCredentialLoader, $authenticationAssertionResponseValdiator, $createSession) { - $protocol = $request->getProtocol(); - - // Get challenge - $challengeDoc = Authorization::skip(fn () => $dbForProject->getDocument('webauthnChallenges', $challengeId)); - - if (empty($challengeDoc)) { - throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Challenge not found'); - } - - if ($challengeDoc->getAttribute('expire') < DateTime::now()) { - throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Challenge expired'); - } - - $profile = $dbForProject->getDocument('users', $challengeDoc->getAttribute('userId')); - - try { - $publicKeyCredential = $publicKeyCredentialLoader->load($challengeResponse); - if (!$publicKeyCredential->response instanceof AuthenticatorAssertionResponse) { - //e.g. process here with a redirection to the public key login/MFA page. - } - } catch (\Throwable $e) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid Challenge Response'); - } - - $credentialId = Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId); - - $sourceCredentialSource = Authorization::skip(fn () => $dbForProject->findOne('credentialSources', [ - Query::equal('publicKeyCredentialId', [$credentialId]), - Query::equal('userInternalId', [$profile->getInternalId()]), - ])); - - if (empty($sourceCredentialSource)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Authenticator not found'); - } - - $authenticators = Authorization::skip(fn () => $dbForProject->find('credentialSources', [ - Query::equal('userInternalId', [$profile->getInternalId()]), - ])); - - $allowedCredentials = []; - $credentialRecieved = false; - - foreach ($authenticators as $authenticator) { - $credentialSource = PublicKeyCredentialSource::createFromArray( - $authenticator->getArrayCopy() - ); - - if (Base64UrlSafe::encodeUnpadded($credentialSource->publicKeyCredentialId) === $credentialId) { - $credentialRecieved = $credentialSource; - } - - $allowedCredentials[] = $credentialSource->getPublicKeyCredentialDescriptor(); - } - - $rpId = $challengeDoc->getAttribute('rp')['id'] ?? ''; - - $requestOptions = PublicKeyCredentialRequestOptions::create( - Base64UrlSafe::decodeNoPadding($challengeDoc->getAttribute('challenge')), - rpId: $rpId, - allowCredentials: $allowedCredentials, - timeout: 60000, - ); - - try { - $publicKeyCredentialSource = $authenticationAssertionResponseValdiator->check( - credentialId: $credentialRecieved, - authenticatorAssertionResponse: $publicKeyCredential->response, - publicKeyCredentialRequestOptions: $requestOptions, - request: $request->getHostname(), // Replace with platform ID - userHandle: $credentialRecieved->userHandle, - securedRelyingPartyId: ['localhost'] // Replace with platform hostname - ); - - $sourceCredentialSource->setAttribute('counter', $publicKeyCredentialSource->counter); - - // Store new public key credential source (counter has been updated) - Authorization::skip(fn () => $dbForProject->updateDocument('credentialSources', $sourceCredentialSource->getId(), $sourceCredentialSource)); - } catch (\Throwable $e) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid Challenge Response'); - } - - // Create session - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); - - $user->setAttributes($profile->getArrayCopy()); - $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; - $detector = new Detector($request->getUserAgent('UNKNOWN')); - $record = $geodb->get($request->getIP()); - $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION); - $session = new Document(array_merge( - [ - '$id' => ID::unique(), - 'userId' => $user->getId(), - 'userInternalId' => $user->getInternalId(), - 'provider' => Auth::SESSION_PROVIDER_WEBAUTHN, - 'providerUid' => $sourceCredentialSource->getAttribute('credentialId'), - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak - 'userAgent' => $request->getUserAgent('UNKNOWN'), - 'ip' => $request->getIP(), - 'factors' => ['webauthn'], - 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', - 'expire' => DateTime::addSeconds(new \DateTime(), $duration) - ], - $detector->getOS(), - $detector->getClient(), - $detector->getDevice() - )); - - Authorization::setRole(Role::user($user->getId())->toString()); - - $dbForProject->purgeCachedDocument('users', $user->getId()); - - $session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [ - Permission::read(Role::user($user->getId())), - Permission::update(Role::user($user->getId())), - Permission::delete(Role::user($user->getId())), - ])); - - if (!Config::getParam('domainVerification')) { - $response - ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) - ; - } - - $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); - - $response - ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) - ->setStatusCode(Response::STATUS_CODE_CREATED) - ; - - $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); - - $session - ->setAttribute('current', true) - ->setAttribute('countryName', $countryName) - ->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '') - ; - - $queueForEvents - ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()) - ; - - $response->dynamic($session, Response::MODEL_SESSION); - }); - App::get('/v1/account/sessions/oauth2/:provider') ->desc('Create OAuth2 session') ->groups(['api', 'account']) diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php index 406f4c87ea..53aacabe21 100644 --- a/app/controllers/shared/api/auth.php +++ b/app/controllers/shared/api/auth.php @@ -99,12 +99,6 @@ App::init() } break; - case 'webauthn': - if (($auths['webauthn'] ?? true) === false) { - throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Webauthn authentication is disabled for this project'); - } - break; - default: throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route'); } diff --git a/app/init.php b/app/init.php index 8be5170541..6df2c02908 100644 --- a/app/init.php +++ b/app/init.php @@ -490,20 +490,6 @@ Database::addFilter( } ); -Database::addFilter( - 'subQueryCredentialSources', - function (mixed $value) { - return; - }, - function (mixed $value, Document $document, Database $database) { - return Authorization::skip(fn () => $database - ->find('credentialSources', [ - Query::equal('userInternalId', [$document->getInternalId()]), - Query::limit(APP_LIMIT_SUBQUERY), - ])); - } -); - Database::addFilter( 'subQueryMemberships', function (mixed $value) { diff --git a/docker-compose.yml b/docker-compose.yml index 5351dfbe54..b1e30a1e07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,7 @@ services: build: context: . args: - DEBUG: true + DEBUG: false TESTING: true VERSION: dev ports: diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 4569bea0fa..c7ae768434 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -66,7 +66,6 @@ class Auth public const SESSION_PROVIDER_OAUTH2 = 'oauth2'; public const SESSION_PROVIDER_TOKEN = 'token'; public const SESSION_PROVIDER_SERVER = 'server'; - public const SESSION_PROVIDER_WEBAUTHN = 'webauthn'; /** * Token Expiration times.