1
0
Fork 0
mirror of synced 2024-10-03 19:53:33 +13:00

Remove 1FA from Webauthn PR

This commit is contained in:
Bradley Schofield 2024-07-03 20:07:58 +09:00
parent fdd9676449
commit f36b4a6a20
7 changed files with 1 additions and 629 deletions

View file

@ -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,
]
];

View file

@ -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'),

View file

@ -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'])

View file

@ -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');
}

View file

@ -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) {

View file

@ -48,7 +48,7 @@ services:
build:
context: .
args:
DEBUG: true
DEBUG: false
TESTING: true
VERSION: dev
ports:

View file

@ -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.