loginRequired = true; $this->apiKeyAllowed = false; $this->userVerificationRequired = false; } protected function verifyAuthData(AuthenticationData $authData): bool { $domain = getCurrentHostName(); if (!$authData->verifyIntegrity($domain)) { return $this->createError("mismatched rpIDHash. expected: " . hash("sha256", $domain) . " got: " . bin2hex($authData->getHash())); } else if (!$authData->isUserPresent()) { return $this->createError("No user present"); } else if ($this->userVerificationRequired && !$authData->isUserVerified()) { return $this->createError("user was not verified on device (PIN/Biometric/...)"); } else if ($authData->hasExtensionData()) { return $this->createError("No extensions supported"); } return true; } protected function verifyClientDataJSON(array $jsonData, KeyBasedTwoFactorToken $token): bool { $settings = $this->context->getSettings(); $expectedType = $token->isConfirmed() ? "webauthn.get" : "webauthn.create"; $type = $jsonData["type"] ?? "null"; if ($type !== $expectedType) { return $this->createError("Invalid client data json type. Expected: '$expectedType', Got: '$type'"); } else if (base64url_decode($token->getChallenge()) !== base64url_decode($jsonData["challenge"] ?? "")) { return $this->createError("Challenge does not match"); } $origin = $jsonData["origin"] ?? null; if ($origin !== $settings->getBaseURL()) { $baseUrl = $settings->getBaseURL(); // return $this->createError("Origin does not match. Expected: '$baseUrl', Got: '$origin'"); } return true; } } } namespace Core\API\TFA { use Core\API\Parameter\StringType; use Core\API\TfaAPI; use Core\Driver\SQL\Query\Insert; use Core\Objects\Context; use Core\Objects\TwoFactor\AttestationObject; use Core\Objects\TwoFactor\AuthenticationData; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; use Core\Objects\TwoFactor\TimeBasedTwoFactorToken; // General class Remove extends TfaAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, [ "password" => new StringType("password", 0, true) ]); } public function _execute(): bool { $currentUser = $this->context->getUser(); $token = $currentUser->getTwoFactorToken(); if (!$token) { return $this->createError("You do not have an active 2FA-Token"); } $sql = $this->context->getSQL(); $password = $this->getParam("password"); if ($password) { if (!password_verify($password, $currentUser->password)) { return $this->createError("Wrong password"); } } else if ($token->isConfirmed()) { // if the token is fully confirmed, require a password to remove it return $this->createError("Missing parameter: password"); } $this->success = $token->delete($sql) !== false; $this->lastError = $sql->getLastError(); if ($this->success && $token->isConfirmed()) { // send an email $email = $currentUser->getEmail(); if ($email) { $settings = $this->context->getSettings(); $req = new \Core\API\Template\Render($this->context); $this->success = $req->execute([ "file" => "mail/2fa_remove.twig", "parameters" => [ "username" => $currentUser->getFullName() ?? $currentUser->getUsername(), "site_name" => $settings->getSiteName(), "sender_mail" => $settings->getMailSender() ] ]); if ($this->success) { $body = $req->getResult()["html"]; $gpg = $currentUser->getGPG(); $siteName = $settings->getSiteName(); $req = new \Core\API\Mail\Send($this->context); $this->success = $req->execute([ "to" => $email, "subject" => "[$siteName] 2FA-Authentication removed", "body" => $body, "gpgFingerprint" => $gpg?->getFingerprint() ]); } $this->lastError = $req->getLastError(); } } return $this->success; } public static function getDefaultACL(Insert $insert): void { $insert->addRow(self::getEndpoint(), [], "Allows users to remove their 2FA-Tokens", true); } } // TOTP class GenerateQR extends TfaAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall); $this->csrfTokenRequired = false; } public function _execute(): bool { $currentUser = $this->context->getUser(); $twoFactorToken = $currentUser->getTwoFactorToken(); if ($twoFactorToken && $twoFactorToken->isConfirmed()) { return $this->createError("You already added a two factor token"); } else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) { $sql = $this->context->getSQL(); $twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32")); $this->success = $twoFactorToken->save($sql) !== false; $this->lastError = $sql->getLastError(); if ($this->success) { $currentUser->setTwoFactorToken($twoFactorToken); $this->success = $currentUser->save($sql, ["twoFactorToken"]); $this->lastError = $sql->getLastError(); } if (!$this->success) { return false; } } header("Content-Type: image/png"); $this->disableCache(); die($twoFactorToken->generateQRCode($this->context)); } public static function getDefaultACL(Insert $insert): void { $insert->addRow(self::getEndpoint(), [], "Allows users generate a QR-code to add a time-based 2FA-Token", true); } } class ConfirmTotp extends VerifyTotp { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall); $this->loginRequired = true; } public function _execute(): bool { $currentUser = $this->context->getUser(); $twoFactorToken = $currentUser->getTwoFactorToken(); if ($twoFactorToken->isConfirmed()) { return $this->createError("Your two factor token is already confirmed."); } if (!parent::_execute()) { return false; } $sql = $this->context->getSQL(); $this->success = $twoFactorToken->confirm($sql) !== false; $this->lastError = $sql->getLastError(); if ($this->success) { $this->context->invalidateSessions(true); } return $this->success; } public static function getDefaultACL(Insert $insert): void { $insert->addRow(self::getEndpoint(), [], "Allows users to confirm their time-based 2FA-Token", true); } } class VerifyTotp extends TfaAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, [ "code" => new StringType("code", 6) ]); $this->loginRequired = true; $this->csrfTokenRequired = false; } public function _execute(): bool { $currentUser = $this->context->getUser(); $twoFactorToken = $currentUser->getTwoFactorToken(); if (!$twoFactorToken) { return $this->createError("You did not add a two factor token yet."); } else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) { return $this->createError("Invalid 2FA-token endpoint"); } $code = $this->getParam("code"); if (!$twoFactorToken->verify($code)) { return $this->createError("Code does not match"); } $twoFactorToken->authenticate(); return $this->success; } public static function getDefaultACL(Insert $insert): void { $insert->addRow(self::getEndpoint(), [], "Allows users to verify time-based 2FA-Tokens", true); } } // Key class RegisterKey extends TfaAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, [ "clientDataJSON" => new StringType("clientDataJSON", 0, true, "{}"), "attestationObject" => new StringType("attestationObject", 0, true, "") ]); $this->loginRequired = true; } public function _execute(): bool { $currentUser = $this->context->getUser(); $clientDataJSON = json_decode($this->getParam("clientDataJSON"), true); $attestationObjectRaw = base64_decode($this->getParam("attestationObject")); $twoFactorToken = $currentUser->getTwoFactorToken(); $settings = $this->context->getSettings(); $relyingParty = $settings->getSiteName(); $sql = $this->context->getSQL(); $domain = getCurrentHostName(); if (!$clientDataJSON || !$attestationObjectRaw) { $challenge = null; if ($twoFactorToken) { if ($twoFactorToken->isConfirmed()) { return $this->createError("You already added a two factor token"); } else if ($twoFactorToken instanceof KeyBasedTwoFactorToken) { $challenge = $twoFactorToken->getChallenge(); } else { $twoFactorToken->delete($sql); } } if ($challenge === null) { $twoFactorToken = KeyBasedTwoFactorToken::create(); $challenge = $twoFactorToken->getChallenge(); $this->success = ($twoFactorToken->save($sql) !== false); $this->lastError = $sql->getLastError(); if (!$this->success) { return false; } $currentUser->setTwoFactorToken($twoFactorToken); $this->success = $currentUser->save($sql, ["twoFactorToken"]) !== false; $this->lastError = $sql->getLastError(); if (!$this->success) { return false; } } $this->result["data"] = [ "challenge" => $challenge, "relyingParty" => [ "name" => $relyingParty, "id" => $domain ], ]; } else { if ($twoFactorToken === null) { return $this->createError("Request a registration first."); } else if (!($twoFactorToken instanceof KeyBasedTwoFactorToken)) { return $this->createError("You already got a 2FA token"); } if (!$this->verifyClientDataJSON($clientDataJSON, $twoFactorToken)) { return false; } $attestationObject = new AttestationObject($attestationObjectRaw); $authData = $attestationObject->getAuthData(); if (!$this->verifyAuthData($authData)) { return false; } $publicKey = $authData->getPublicKey(); if ($publicKey->getUsedAlgorithm() !== -7) { return $this->createError("Unsupported key type. Expected: -7"); } $twoFactorToken->authenticate(); $this->success = $twoFactorToken->confirmKeyBased($sql, base64_encode($authData->getCredentialID()), $publicKey) !== false; $this->lastError = $sql->getLastError(); if ($this->success) { $this->result["twoFactorToken"] = $twoFactorToken->jsonSerialize(); $this->context->invalidateSessions(true); } } return $this->success; } public static function getDefaultACL(Insert $insert): void { $insert->addRow(self::getEndpoint(), [], "Allows users to register a 2FA hardware-key", true); } } class VerifyKey extends TfaAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, [ "credentialID" => new StringType("credentialID"), "clientDataJSON" => new StringType("clientDataJSON"), "authData" => new StringType("authData"), "signature" => new StringType("signature"), ]); $this->loginRequired = true; $this->csrfTokenRequired = false; } public function _execute(): bool { $currentUser = $this->context->getUser(); if (!$currentUser) { return $this->createError("You are not logged in."); } $twoFactorToken = $currentUser->getTwoFactorToken(); if (!$twoFactorToken) { return $this->createError("You did not add a two factor token yet."); } else if (!($twoFactorToken instanceof KeyBasedTwoFactorToken)) { return $this->createError("Invalid 2FA-token endpoint"); } else if (!$twoFactorToken->isConfirmed()) { return $this->createError("2FA-Key not confirmed yet"); } $credentialID = base64url_decode($this->getParam("credentialID")); if ($credentialID !== $twoFactorToken->getCredentialId()) { return $this->createError("credential ID does not match"); } $jsonData = $this->getParam("clientDataJSON"); if (!$this->verifyClientDataJSON(json_decode($jsonData, true), $twoFactorToken)) { return false; } $authDataRaw = base64_decode($this->getParam("authData")); $authData = new AuthenticationData($authDataRaw); if (!$this->verifyAuthData($authData)) { return false; } $clientDataHash = hash("sha256", $jsonData, true); $signature = base64_decode($this->getParam("signature")); $this->success = $twoFactorToken->verify($signature, $authDataRaw . $clientDataHash); if ($this->success) { $twoFactorToken->authenticate(); } else { $this->lastError = "Verification failed"; } return $this->success; } public static function getDefaultACL(Insert $insert): void { $insert->addRow(self::getEndpoint(), [], "Allows users to verify a 2FA hardware-key", true); } } }