mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34d96c053d | ||
|
|
810b24c497 | ||
|
|
1e45515d19 | ||
|
|
37de2ba196 | ||
|
|
2722ec76b6 | ||
|
|
ef64105e14 | ||
|
|
a71948edee | ||
|
|
c935ff9ec2 | ||
|
|
7a0b682105 | ||
|
|
5251f6d780 | ||
|
|
27681bfdeb | ||
|
|
f0badcd567 | ||
|
|
a60e04358e | ||
|
|
f36b4a6a20 | ||
|
|
fdd9676449 | ||
|
|
8f747a1249 | ||
|
|
5d10fcbf54 | ||
|
|
16e64dba3b | ||
|
|
5002139b1b | ||
|
|
a080e7c583 | ||
|
|
976411d853 | ||
|
|
7c36795d37 | ||
|
|
0add8ba5ff | ||
|
|
a676b98823 | ||
|
|
295992f177 | ||
|
|
a86ba4df47 |
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
+1
-1
Submodule app/console updated: f978cecfaf...412bc33318
+321
-10
@@ -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'])
|
||||
|
||||
@@ -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
@@ -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
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.
|
||||
@@ -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 */
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user