Compare commits

...
Author SHA1 Message Date
Bradley SchofieldandGitHub 34d96c053d Merge branch '1.6.x' into feat-implement-webauthn 2024-07-17 12:01:13 +09:00
Bradley Schofield 810b24c497 Update WebAuthn lib 2024-07-16 15:52:54 +09:00
Bradley Schofield 1e45515d19 Merge remote-tracking branch 'origin/1.6.x' into feat-implement-webauthn 2024-07-16 03:59:10 +00:00
Bradley Schofield 37de2ba196 1.6 Fixes 2024-07-10 20:04:01 +09:00
Bradley Schofield 2722ec76b6 Run Linter 2024-07-08 13:36:26 +09:00
Bradley Schofield ef64105e14 Add documentation, remove 1FA routes 2024-07-08 13:34:20 +09:00
Bradley Schofield a71948edee Update Webauthn deletion endpoint 2024-07-08 13:14:04 +09:00
Bradley Schofield c935ff9ec2 Merge branch 'feat-update-delete-authenticator' into feat-implement-webauthn 2024-07-08 13:11:55 +09:00
Bradley Schofield 7a0b682105 Run Linter 2024-07-05 16:22:32 +09:00
Bradley Schofield 5251f6d780 Merge branch 'main' into feat-implement-webauthn 2024-07-05 16:20:29 +09:00
Bradley Schofield 27681bfdeb Finish recovery code support 2024-07-05 16:16:41 +09:00
Bradley Schofield f0badcd567 Add webauthn authenticator deletion 2024-07-05 14:59:22 +09:00
Bradley Schofield a60e04358e Regen Specs, fix missing vars 2024-07-03 20:12:07 +09:00
Bradley Schofield f36b4a6a20 Remove 1FA from Webauthn PR 2024-07-03 20:07:58 +09:00
Bradley Schofield fdd9676449 Get MFA fully working in API 2024-07-03 20:00:58 +09:00
Bradley Schofield 8f747a1249 Update account.php 2024-06-24 15:51:46 +09:00
Bradley Schofield 5d10fcbf54 Fix encoding errors and comment class helper 2024-06-24 15:49:05 +09:00
Bradley Schofield 16e64dba3b Complete Webauthn MFA Flow 2024-06-22 18:16:28 +09:00
Bradley Schofield 5002139b1b Update WebAuthn.php 2024-06-21 22:42:48 +09:00
Bradley Schofield a080e7c583 Refactor things into it's own class 2024-06-21 22:42:44 +09:00
Bradley Schofield 976411d853 Clean up webauthn code 2024-06-20 14:00:32 +09:00
Bradley Schofield 7c36795d37 Run Linter 2024-06-18 15:00:29 +09:00
Bradley Schofield 0add8ba5ff Add webauthn to auth.php and add index for credentialKeys 2024-06-18 14:58:00 +09:00
Bradley Schofield a676b98823 Update Specs 2024-06-18 14:54:17 +09:00
Bradley Schofield 295992f177 Implement MVP API 2024-06-18 14:49:43 +09:00
Bradley Schofield a86ba4df47 Begin work on WebAuthn 2024-06-17 15:27:42 +09:00
22 changed files with 1864 additions and 76 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+321 -10
View File
@@ -5,6 +5,7 @@ use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Challenge;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Auth\MFA\Type\WebAuthn;
use Appwrite\Auth\OAuth2\Exception as OAuth2Exception;
use Appwrite\Auth\Phrase;
use Appwrite\Auth\Validator\Password;
@@ -470,7 +471,6 @@ App::delete('/v1/account')
$response->noContent();
});
App::get('/v1/account/sessions')
->desc('List sessions')
->groups(['api', 'account'])
@@ -3682,12 +3682,22 @@ App::get('/v1/account/mfa/factors')
$recoveryCodeEnabled = \is_array($mfaRecoveryCodes) && \count($mfaRecoveryCodes) > 0;
$totp = TOTP::getAuthenticatorFromUser($user);
$webauthnAuths = WebAuthn::getAuthenticatorsFromUser($user) ?? [];
$webauthnVerified = false;
foreach ($webauthnAuths as $authenticator) {
if ($authenticator->getAttribute('verified', false)) {
$webauthnVerified = true;
break;
}
}
$factors = new Document([
Type::TOTP => $totp !== null && $totp->getAttribute('verified', false),
Type::EMAIL => $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false),
Type::PHONE => $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false),
Type::RECOVERY_CODE => $recoveryCodeEnabled
Type::RECOVERY_CODE => $recoveryCodeEnabled,
Type::WEBAUTHN => $webauthnVerified,
]);
$response->dynamic($factors, Response::MODEL_MFA_FACTORS);
@@ -3830,6 +3840,133 @@ App::put('/v1/account/mfa/authenticators/:type')
$response->dynamic($user, Response::MODEL_ACCOUNT);
});
App::post('/v1/account/mfa/authenticators/webauthn')
->desc('Add WebAuthn Authenticator')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.mfa')
->label('scope', 'account')
->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', 'createWebauthnMfaAuthenticator')
->label('sdk.description', '/docs/references/account/create-webauthn-mfa-authenticator.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_WEBAUTHN_REGISTER_CHALLENGE)
->label('sdk.offline.model', '/account')
->label('sdk.offline.key', 'current')
->inject('request')
->inject('response')
->inject('project')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Event $queueForEvents) {
// Clean up any previous challenges not completed
$authenticators = array_filter($user->getAttribute('authenticators', []), fn ($authenticator) => $authenticator['type'] === Type::WEBAUTHN);
foreach ($authenticators as $authenticator) {
/** @var Document $authenticator */
if (empty($authenticator->getAttribute('verified', false))) {
$dbForProject->deleteDocument('authenticators', $authenticator->getId());
}
}
$webauthn = new WebAuthn();
$relyingParty = $webauthn->createRelyingParty($project, $request);
$userEntity = $webauthn->createUserEntity($user);
$challenge = $webauthn->createRegisterChallenge($relyingParty, $userEntity, 60 * 5);
$authenticator = new Document([
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'type' => Type::WEBAUTHN,
'verified' => false,
'data' => json_encode($challenge),
'$permissions' => [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]
]);
$authenticator = $dbForProject->createDocument('authenticators', $authenticator);
$dbForProject->purgeCachedDocument('users', $user->getId());
$queueForEvents->setParam('userId', $user->getId());
$model = new Document($challenge);
$response->dynamic($model, Response::MODEL_WEBAUTHN_REGISTER_CHALLENGE);
});
App::put('/v1/account/mfa/authenticators/webauthn')
->desc('Verify WebAuthn Authenticator')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.mfa')
->label('scope', 'account')
->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', 'updateWebauthnMfaAuthenticator')
->label('sdk.description', '/docs/references/account/update-webauthn-mfa-authenticator.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('challengeResponse', '', new Text(8192), 'Valid verification token.')
->inject('response')
->inject('user')
->inject('session')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $challengeResponse, Response $response, Document $user, Document $session, Database $dbForProject, Event $queueForEvents) {
/** @var ?Document $authenticator */
$authenticator = null;
foreach ($user->getAttribute('authenticators', []) as $auth) {
if ($auth['type'] === Type::WEBAUTHN && empty($auth['verified'])) {
$authenticator = $auth;
}
};
if ($authenticator === null) {
throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND);
}
$webauthn = new WebAuthn();
$challenge = $authenticator->getAttribute('data');
$publicKeyCredentials = null;
try {
$publicKeyCredentials = $webauthn->verifyRegisterChallenge($challenge, $challengeResponse);
} catch (\Exception $e) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
$authenticator->setAttribute('verified', true);
$authenticator->setAttribute('data', json_encode($publicKeyCredentials));
$dbForProject->updateDocument('authenticators', $authenticator->getId(), $authenticator);
$dbForProject->purgeCachedDocument('authenticators', $authenticator->getId());
$dbForProject->purgeCachedDocument('users', $user->getId());
$factors = $session->getAttribute('factors', []);
$factors[] = Type::WEBAUTHN;
$factors = \array_unique($factors);
$session->setAttribute('factors', $factors);
$dbForProject->updateDocument('sessions', $session->getId(), $session);
$queueForEvents->setParam('userId', $user->getId());
$response->dynamic($user, Response::MODEL_ACCOUNT);
});
App::post('/v1/account/mfa/recovery-codes')
->desc('Create MFA Recovery Codes')
->groups(['api', 'account'])
@@ -3957,25 +4094,32 @@ App::delete('/v1/account/mfa/authenticators/:type')
->label('sdk.description', '/docs/references/account/delete-mfa-authenticator.md')
->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT)
->label('sdk.response.model', Response::MODEL_NONE)
->param('type', null, new WhiteList([Type::TOTP]), 'Type of authenticator.')
->param('type', null, new WhiteList([Type::TOTP, Type::WEBAUTHN]), 'Type of authenticator.')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $type, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) {
$authenticators = [];
$authenticator = (match ($type) {
Type::TOTP => TOTP::getAuthenticatorFromUser($user),
default => null
});
switch ($type) {
case Type::TOTP:
$authenticators[] = TOTP::getAuthenticatorFromUser($user);
break;
case Type::WEBAUTHN:
$authenticators = WebAuthn::getAuthenticatorsFromUser($user);
break;
}
if (!$authenticator) {
if (empty($authenticators)) {
throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND);
}
$dbForProject->deleteDocument('authenticators', $authenticator->getId());
$dbForProject->purgeCachedDocument('users', $user->getId());
foreach ($authenticators as $authenticator) {
$dbForProject->deleteDocument('authenticators', $authenticator->getId());
}
$dbForProject->purgeCachedDocument('users', $user->getId());
$queueForEvents->setParam('userId', $user->getId());
$response->noContent();
@@ -4256,6 +4400,173 @@ App::put('/v1/account/mfa/challenge')
$response->dynamic($session, Response::MODEL_SESSION);
});
App::post('/v1/account/mfa/challenge/webauthn')
->desc('Create WebAuthn MFA Challenge')
->groups(['api', 'account', 'mfa'])
->label('scope', 'account')
->label('event', 'users.[userId].challenges.[challengeId].create')
->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', 'createWebauthnMfaChallenge')
->label('sdk.description', '/docs/references/account/create-webauthn-mfa-challenge.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', 'url:{url},token:{param-token}')
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('locale')
->inject('project')
->inject('request')
->inject('queueForEvents')
->action(function (Response $response, Database $dbForProject, Document $user, Locale $locale, Document $project, Request $request, Event $queueForEvents) {
$expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_WEBAUTHN);
$webauthn = new WebAuthn();
$allowedCredentials = $webauthn->getAllowedCredentials($user);
if (empty($allowedCredentials)) {
throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND);
}
$relyingParty = $webauthn->createRelyingParty($project, $request);
$webAuthnChallenge = $webauthn->createLoginChallenge($relyingParty, $allowedCredentials, Auth::TOKEN_EXPIRATION_WEBAUTHN);
// Store challenge
$challenge = new Document([
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'type' => Type::WEBAUTHN,
'code' => $webAuthnChallenge['challenge'],
'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);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('challengeId', $challenge->getId());
// Send challenge
$response->dynamic(new Document(array_merge(
[
'$id' => $challenge->getId(),
],
$webAuthnChallenge
)), Response::MODEL_WEBAUTHN_LOGIN_CHALLENGE);
});
App::put('/v1/account/mfa/challenge/webauthn')
->desc('Create WebAuthn MFA Challenge (confirmation)')
->groups(['api', 'account', 'mfa'])
->label('scope', 'account')
->label('event', 'users.[userId].sessions.[sessionId].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', 'updateWebauthnMfaChallenge')
->label('sdk.description', '/docs/references/account/update-webauthn-mfa-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), 'ID of the challenge.')
->param('challengeResponse', '', new Text(8192), 'Valid verification token.')
->inject('project')
->inject('response')
->inject('request')
->inject('user')
->inject('session')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $challengeId, string $challengeResponse, Document $project, Response $response, Request $request, Document $user, Document $session, Database $dbForProject, Event $queueForEvents) {
$challenge = $dbForProject->getDocument('challenges', $challengeId);
if ($challenge->isEmpty()) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
$authenticators = array_filter(Webauthn::getAuthenticatorsFromUser($user), function ($auth) {
return !empty($auth['verified']);
});
$webauthn = new WebAuthn();
$relyingParty = $webauthn->createRelyingParty($project, $request);
$responseJson = json_decode($challengeResponse, true);
// Find authenticator used
$authenticator = null;
foreach ($authenticators as $auth) {
$data = $auth['data'];
if ($data['publicKeyCredentialId'] == $responseJson['id']) {
$authenticator = $auth;
break;
}
}
if ($authenticator === null) {
throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND);
}
/** @var Document $authenticator */
// Check challenge
$publicKeyCredential = null;
try {
$publicKeyCredential = $webauthn->verifyLoginChallenge(
challenge: $challenge->getArrayCopy(),
challengeResponse: $challengeResponse,
hostname: $request->gethostname(),
timeout: Auth::TOKEN_EXPIRATION_WEBAUTHN,
allowCredentials: $webauthn->getAllowedCredentials($user),
rpEntity: $relyingParty,
authenticatorPublicKey: $webauthn->deserializePublicKeyCredentialSource($authenticator->getAttribute('data', []))
);
} catch (\Exception $e) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
// Update authenticator as counter has changed
$dbForProject->updateDocument('authenticators', $authenticator->getId(), new Document([
'data' => json_encode($publicKeyCredential)
]));
// Update Session
Authorization::skip(function () use ($dbForProject, $challengeId) {
$dbForProject->deleteDocument('challenges', $challengeId);
});
$dbForProject->purgeCachedDocument('users', $user->getId());
$factors = $session->getAttribute('factors', []);
$factors[] = TYPE::WEBAUTHN;
$factors = \array_unique($factors);
$session
->setAttribute('factors', $factors)
->setAttribute('mfaUpdatedAt', DateTime::now());
$dbForProject->updateDocument('sessions', $session->getId(), $session);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId());
$response->dynamic($session, Response::MODEL_SESSION);
});
App::post('/v1/account/targets/push')
->desc('Create push target')
->groups(['api', 'account'])
+11 -1
View File
@@ -4,6 +4,7 @@ use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Auth\MFA\Type\WebAuthn;
use Appwrite\Event\Audit;
use Appwrite\Event\Build;
use Appwrite\Event\Database as EventDatabase;
@@ -330,7 +331,16 @@ App::init()
$hasVerifiedEmail = $user->getAttribute('emailVerification', false);
$hasVerifiedPhone = $user->getAttribute('phoneVerification', false);
$hasVerifiedAuthenticator = TOTP::getAuthenticatorFromUser($user)?->getAttribute('verified') ?? false;
$hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator;
$webauthnAuthenticators = WebAuthn::getAuthenticatorsFromUser($user) ?? [];
$hasVerifiedWebAuthn = false;
foreach ($webauthnAuthenticators as $authenticator) {
if ($authenticator->getAttribute('verified')) {
$hasVerifiedWebAuthn = true;
break;
}
}
$hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator || $hasVerifiedWebAuthn;
$minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1;
if (!in_array('mfa', $route->getGroups())) {
+2 -1
View File
@@ -78,7 +78,8 @@
"adhocore/jwt": "1.1.2",
"spomky-labs/otphp": "^10.0",
"webonyx/graphql-php": "14.11.*",
"league/csv": "9.14.*"
"league/csv": "9.14.*",
"web-auth/webauthn-lib": "4.9.*"
},
"require-dev": {
"ext-fileinfo": "*",
Generated
+1026 -57
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
Add an webauthn authenticator to be used as an MFA factor. Verify the authenticator using the [verify webauthn authenticator](/docs/references/cloud/client-web/account#updateWebauthnMfaChallenge) method.
@@ -0,0 +1 @@
Begin the process of MFA verification after sign-in. Finish the flow with [updateWebauthnMfaChallenge](/docs/references/cloud/client-web/account#updateWebauthnMfaChallenge) method.
@@ -0,0 +1 @@
Verify an authenticator app after adding it using the [add authenticator](/docs/references/cloud/client-web/account#createWebauthnMfaAuthenticator) method.
@@ -0,0 +1 @@
Complete the MFA challenge by providing the credential generated by the CredentialManager. To begin the flow, use [createWebauthnMfaChallenge](/docs/references/cloud/client-web/account#createWebauthnMfaChallenge) method.
+1
View File
@@ -75,6 +75,7 @@ class Auth
public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */
public const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */
public const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */
public const TOKEN_EXPIRATION_WEBAUTHN = 60 * 5; /* 5 minutes */
public const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */
/**
+1
View File
@@ -13,6 +13,7 @@ abstract class Type
public const EMAIL = 'email';
public const PHONE = 'phone';
public const RECOVERY_CODE = 'recoveryCode';
public const WEBAUTHN = 'webauthn';
public function setLabel(string $label): self
{
+318
View File
@@ -0,0 +1,318 @@
<?php
namespace Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type;
use Appwrite\Extend\Exception;
use Appwrite\Utopia\Request;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Utopia\App;
use Utopia\Database\Document;
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;
class WebAuthn extends Type
{
protected PublicKeyCredentialLoader $publicKeyCredentialLoader;
protected AuthenticatorAttestationResponseValidator $authenticatorAttestationResponseValidator;
protected AuthenticatorAssertionResponseValidator $authenticatiorAssertionResponseValdiator;
public function __construct()
{
$attestationSupportManager = AttestationStatementSupportManager::create();
$attestationObjectLoader = AttestationObjectLoader::create(
$attestationSupportManager
);
$this->publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader);
$this->authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create(
$attestationSupportManager
);
$this->authenticatiorAssertionResponseValdiator = AuthenticatorAssertionResponseValidator::create();
}
/**
* Create a new relying party entity, uses the platform if possible
*
* @param Document $project
* @param Request $request
* @return PublicKeyCredentialRpEntity
*/
public static function createRelyingParty(Document $project, Request $request): PublicKeyCredentialRpEntity
{
// Calculate Relying Party ID and Name
$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';
if (App::isDevelopment()) {
$platformId = 'localhost';
} else {
$platformId = App::getEnv('_APP_DOMAIN', '');
}
}
return new PublicKeyCredentialRpEntity(
$platformName,
$platformId
);
}
/**
* Create a new user entity from an Appwrite user document
*
* @param Document $user
* @return PublicKeyCredentialUserEntity
*/
public static function createUserEntity(Document $user): PublicKeyCredentialUserEntity
{
$name = $user->getAttribute('name') ?? $user->getAttribute('email');
return new PublicKeyCredentialUserEntity(
$name,
$user->getId(),
$name,
);
}
/**
* Create a new register challenge
*
* @param PublicKeyCredentialRpEntity $rpEntity
* @param PublicKeyCredentialUserEntity $userEntity
* @param int $timeout Timeout in seconds
* @return array
*/
public static function createRegisterChallenge(PublicKeyCredentialRpEntity $rpEntity, PublicKeyCredentialUserEntity $userEntity, int $timeout): array
{
$nonce = random_bytes(32);
return [
'rp' => $rpEntity->jsonSerialize(),
'user' => $userEntity->jsonSerialize(),
'challenge' => Base64UrlSafe::encode($nonce),
'pubKeyCredParams' => [],
'timeout' => $timeout * 1000, // Convert seconds to milliseconds
];
}
/**
* Create a new login challenge
*
* @param PublicKeyCredentialRpEntity $rpEntity
* @param PublicKeyCredentialSource[] $allowedCredentials
* @param int $timeout Timeout in seconds
* @return PublicKeyCredentialRequestOptions
*/
public static function createLoginChallenge(PublicKeyCredentialRpEntity $rpEntity, array $allowedCredentials, int $timeout): array
{
$nonce = random_bytes(32);
return [
'rpId' => $rpEntity->id,
'challenge' => Base64UrlSafe::encode($nonce),
'userVerification' => PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DEFAULT,
'timeout' => $timeout * 1000,
'allowCredentials' => array_map(function ($credential) {
/** @var PublicKeyCredentialSource $credential */
return $credential->jsonSerialize();
}, $allowedCredentials),
];
}
public static function deserializePublicKeyCredentialSource(array $publicKeyCredentialSource): PublicKeyCredentialSource
{
return PublicKeyCredentialSource::createFromArray($publicKeyCredentialSource);
}
/**
* Get all allowed credentials for a user
*
* @param Document $user
* @return PublicKeyCredentialSource[]
*/
public static function getAllowedCredentials(Document $user): array
{
$authenticators = self::getAuthenticatorsFromUser($user);
if (empty($authenticators)) {
throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND);
}
$authenticators = array_filter($authenticators, function ($authenticator) {
/** @var Document $authenticator */
return $authenticator->getAttribute('verified') === true;
});
if (empty($authenticators)) {
return [];
}
return array_map(function ($authenticator) {
/** @var Document $authenticator */
return PublicKeyCredentialSource::createFromArray($authenticator->getAttribute('data', ''))->getPublicKeyCredentialDescriptor();
}, $authenticators);
}
/**
* Verify a register challenge
*
* @param array $challenge The challenge data deserialized from the database
* @param string $challengeResponse The challenge response from the client
*
* @return PublicKeyCredentialSource
* @throws \Throwable
*/
public function verifyRegisterChallenge(array $challenge, string $challengeResponse): PublicKeyCredentialSource
{
$publicKeyCredential = $this->publicKeyCredentialLoader->load($challengeResponse);
$relyingParty = PublicKeyCredentialRpEntity::create(
$challenge['rp']['name'],
$challenge['rp']['id']
);
$userEntity = PublicKeyCredentialUserEntity::create(
$challenge['user']['name'],
$challenge['user']['id'],
$challenge['user']['displayName'],
);
$publicKeyCreationOptions = PublicKeyCredentialCreationOptions::create(
$relyingParty,
$userEntity,
Base64UrlSafe::decode($challenge['challenge']),
);
return $this->authenticatorAttestationResponseValidator->check(
$publicKeyCredential->response,
$publicKeyCreationOptions,
$challenge['rp']['id'],
App::isDevelopment() ? ['localhost'] : [],
);
}
/**
* Verify a login challenge
*
* @param array $challenge The challenge data deserialized from the database
* @param string $challengeResponse The challenge response from the client
* @param string $hostname The hostname of the request
* @param int $timeout The timeout of the challenge, MUST be the same as the challenge
* @param array $allowCredentials The allowed credentials for the challenge, MUST be the same as the challenge
* @param PublicKeyCredentialSource $authenticatorPublicKey The public key of the authenticator
*
* @throws \Throwable
*/
public function verifyLoginChallenge(array $challenge, string $challengeResponse, string $hostname, int $timeout, array $allowCredentials, PublicKeyCredentialRpEntity $rpEntity, PublicKeyCredentialSource $authenticatorPublicKey): PublicKeyCredentialSource
{
$publicKeyCredential = $this->publicKeyCredentialLoader->load($challengeResponse);
if (!$publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
throw new Exception('Invalid response');
}
$requestOptions = PublicKeyCredentialRequestOptions::create(
rpId: $rpEntity->id,
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DEFAULT,
challenge: Base64UrlSafe::decode($challenge['code']),
timeout: $timeout * 1000,
allowCredentials: $allowCredentials
);
return $this->authenticatiorAssertionResponseValdiator->check(
credentialId: $authenticatorPublicKey,
authenticatorAssertionResponse: $publicKeyCredential->response,
publicKeyCredentialRequestOptions: $requestOptions,
request: $hostname,
userHandle: $authenticatorPublicKey->userHandle,
securedRelyingPartyId: App::isDevelopment() ? ['localhost'] : [],
);
}
/**
* Get all authenticators from a user
*
* @param Document $user
* @return Document[]|null
* @throws Exception
*/
public static function getAuthenticatorsFromUser(Document $user): ?array
{
$authenticators = array_filter($user->getAttribute('authenticators', []), function ($authenticator) {
/** @var Document $authenticator */
return $authenticator->getAttribute('type') === Type::WEBAUTHN;
});
if (empty($authenticators)) {
return null;
}
return $authenticators;
}
}
+6
View File
@@ -102,6 +102,8 @@ use Appwrite\Utopia\Response\Model\UsageUsers;
use Appwrite\Utopia\Response\Model\User;
use Appwrite\Utopia\Response\Model\Variable;
use Appwrite\Utopia\Response\Model\VcsContent;
use Appwrite\Utopia\Response\Model\WebauthnLoginChallenge;
use Appwrite\Utopia\Response\Model\WebauthnRegisterChallenge;
use Appwrite\Utopia\Response\Model\Webhook;
use Exception;
use Swoole\Http\Response as SwooleHTTPResponse;
@@ -171,6 +173,8 @@ class Response extends SwooleResponse
public const MODEL_TOKEN = 'token';
public const MODEL_JWT = 'jwt';
public const MODEL_PREFERENCES = 'preferences';
public const MODEL_WEBAUTHN_REGISTER_CHALLENGE = 'webauthnRegisterChallenge';
public const MODEL_WEBAUTHN_LOGIN_CHALLENGE = 'webauthnLoginChallenge';
// MFA
public const MODEL_MFA_TYPE = 'mfaType';
@@ -425,6 +429,8 @@ class Response extends SwooleResponse
->setModel(new AuthProvider())
->setModel(new Platform())
->setModel(new Variable())
->setModel(new WebauthnLoginChallenge())
->setModel(new WebauthnRegisterChallenge())
->setModel(new Country())
->setModel(new Continent())
->setModel(new Language())
@@ -35,6 +35,12 @@ class MFAFactors extends Model
'default' => false,
'example' => true
])
->addRule(Type::WEBAUTHN, [
'type' => self::TYPE_BOOLEAN,
'description' => 'Can WebAuthn be used for MFA challenge for this account.',
'default' => false,
'example' => true
])
;
}
@@ -0,0 +1,72 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class WebauthnLoginChallenge extends Model
{
public function __construct()
{
$this
->addRule('$id', [
'type' => self::TYPE_STRING,
'description' => 'Challenge ID.',
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('rpId', [
'type' => self::TYPE_STRING,
'description' => 'Relying Party ID.',
'default' => '',
'example' => 'localhost',
])
->addRule('challenge', [
'type' => self::TYPE_STRING,
'description' => 'Base64 encoded challenge.',
'default' => '',
'example' => 'a1b2c3d4',
'array' => false
])
->addRule('timeout', [
'type' => self::TYPE_INTEGER,
'description' => 'Challenge expiration date in seconds.',
'default' => '',
'example' => 60000,
])
->addRule('allowCredentials', [
'type' => self::TYPE_JSON,
'description' => 'List of allowed credentials.',
'default' => [],
'example' => [
[
'type' => 'public-key',
'id' => 'a1b2c3d4',
],
],
'array' => true
])
;
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'WebauthnLoginChallenge';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_WEBAUTHN_LOGIN_CHALLENGE;
}
}
@@ -0,0 +1,89 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class WebauthnRegisterChallenge extends Model
{
public function __construct()
{
$this
->addRule('$id', [
'type' => self::TYPE_STRING,
'description' => 'Challenge ID.',
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('userId', [
'type' => self::TYPE_STRING,
'description' => 'User ID.',
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('rp', [
'type' => self::TYPE_JSON,
'description' => 'The relying party information.',
'default' => '',
'example' => [
'id' => 'localhost',
'name' => 'Appwrite',
]
])
->addRule('user', [
'type' => self::TYPE_JSON,
'description' => 'The user entity information.',
'default' => '',
'example' => [
'id' => '5e5ea5c16897e',
'name' => 'John Doe',
'displayName' => 'John',
]
])
->addRule('challenge', [
'type' => self::TYPE_STRING,
'description' => 'Base64 encoded challenge.',
'default' => '',
'example' => 'a1b2c3d4',
'array' => false
])
->addRule('pubKeyCredParams', [
'type' => self::TYPE_STRING,
'description' => 'Public key credential parameters.',
'default' => '',
'example' => [
[
'type' => 'public-key'
]
]
])
->addRule('timeout', [
'type' => self::TYPE_INTEGER,
'description' => 'Challenge expiration date in seconds.',
'default' => '',
'example' => 60000,
])
;
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'WebauthnRegisterChallenge';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_WEBAUTHN_REGISTER_CHALLENGE;
}
}