* @link https://www.oxidmodule.com */ declare(strict_types=1); namespace D3\Totp\Application\Model; use BaconQrCode\Renderer\RendererInterface; use BaconQrCode\Writer; use D3\Totp\Application\Factory\BaconQrCodeFactory; use D3\Totp\Application\Model\Exceptions\d3totp_wrongOtpException; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver\Exception; use Doctrine\DBAL\Exception as DBALException; use Doctrine\DBAL\Query\QueryBuilder; use OTPHP\TOTP; use OxidEsales\Eshop\Application\Model\User; use OxidEsales\Eshop\Core\Model\BaseModel; use OxidEsales\Eshop\Core\Registry; use OxidEsales\EshopCommunity\Internal\Container\ContainerFactory; use OxidEsales\EshopCommunity\Internal\Framework\Database\ConnectionProviderInterface; use OxidEsales\EshopCommunity\Internal\Framework\Database\QueryBuilderFactoryInterface; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; class d3totp extends BaseModel { protected const ENC_KEY = 'fq45QS09_fqyx09239QQ'; public string $tableName = 'd3totp'; public null|string $userId = null; public null|TOTP $totp = null; protected int $timeWindow = 2; /** * d3totp constructor. */ public function __construct() { $this->init($this->tableName); parent::__construct(); } /** * @param string $userId * @throws ContainerExceptionInterface * @throws DBALException * @throws Exception * @throws NotFoundExceptionInterface */ public function loadByUserId(string $userId): void { $this->userId = $userId; if ($this->getDbConnection() ->prepare("SHOW TABLES LIKE ".$this->getDbConnection()->quote($this->tableName)) ->executeQuery() ->fetchOne() ) { $qb = $this->getQueryBuilder(); $qb->select('oxid') ->from($this->getViewName()) ->where( $qb->expr()->eq('oxuserid', $qb->createNamedParameter($userId)) ) ->setMaxResults(1); $this->load($qb->execute()->fetchOne()); } } /** * @param string $userId * @return bool * @throws ContainerExceptionInterface * @throws DBALException * @throws Exception * @throws NotFoundExceptionInterface */ public function checkIfAlreadyExist(string $userId): bool { $qb = $this->getQueryBuilder(); $qb->select('1') ->from($this->getViewName()) ->where( $qb->expr()->eq('oxuserid', $qb->createNamedParameter($userId)) ) ->setMaxResults(1); return (bool) $qb->execute()->fetchOne(); } /** * @return Connection * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ public function getDbConnection(): Connection { return ContainerFactory::getInstance()->getContainer()->get(ConnectionProviderInterface::class)->get(); } /** * @return QueryBuilder * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ public function getQueryBuilder(): QueryBuilder { return ContainerFactory::getInstance()->getContainer()->get(QueryBuilderFactoryInterface::class)->create(); } /** * @return User */ public function getUser(): User { $userId = $this->userId ?? $this->getFieldData('oxuserid'); $user = $this->d3GetUser(); $user->load($userId); return $user; } /** * @return User */ public function d3GetUser(): User { return oxNew(User::class); } /** * @return bool */ public function isActive(): bool { return false == Registry::getConfig()->getConfigParam('blDisableTotpGlobally') && $this->UserUseTotp(); } /** * @return bool */ public function UserUseTotp(): bool { return $this->getFieldData('usetotp') && $this->getFieldData('seed'); } /** * @return string|null */ public function getSavedSecret(): ?string { $seed_enc = $this->getFieldData('seed'); if ($seed_enc) { $seed = $this->decrypt($seed_enc); if ($seed) { return $seed; } } return null; } /** * @param string|null $seed * @return TOTP */ public function getTotp(string $seed = null): TOTP { if (null == $this->totp) { $this->totp = TOTP::create($seed ?: $this->getSavedSecret()); $this->totp->setLabel($this->getUser()->getFieldData('oxusername') ?: ''); $this->totp->setIssuer(Registry::getConfig()->getActiveShop()->getFieldData('oxname')); } return $this->totp; } /** * @return string */ public function getQrCodeElement(): string { $renderer = BaconQrCodeFactory::renderer(200); $writer = $this->d3GetWriter($renderer); return $writer->writeString($this->getTotp()->getProvisioningUri()); } /** * @param RendererInterface $renderer * @return Writer */ public function d3GetWriter(RendererInterface $renderer): Writer { return oxNew(Writer::class, $renderer); } /** * @return string */ public function getSecret(): string { return trim($this->getTotp()->getSecret()); } /** * @param string $seed */ public function saveSecret(string $seed): void { $this->assign([ 'seed' => $this->encrypt($seed), ]); } /** * @param string $totp * @param string|null $seed * @return bool * @throws ContainerExceptionInterface * @throws DBALException * @throws d3totp_wrongOtpException * @throws NotFoundExceptionInterface * @throws Exception */ public function verify(string $totp, string $seed = null): bool { $blNotVerified = $this->getTotp($seed)->verify($totp, null, $this->timeWindow) == false; if ($blNotVerified && null == $seed) { $oBC = $this->d3GetBackupCodeListObject(); $blNotVerified = $oBC->verify($totp) == false; if ($blNotVerified) { /** @var d3totp_wrongOtpException $exception */ $exception = oxNew(d3totp_wrongOtpException::class); throw $exception; } } elseif ($blNotVerified && $seed !== null) { /** @var d3totp_wrongOtpException $exception */ $exception = oxNew(d3totp_wrongOtpException::class); throw $exception; } return !$blNotVerified; } /** * @return d3backupcodelist */ public function d3GetBackupCodeListObject(): d3backupcodelist { return oxNew(d3backupcodelist::class); } /** * @param string $plaintext * @return string */ public function encrypt(string $plaintext): string { $ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC"); $iv = openssl_random_pseudo_bytes($ivlen); $ciphertext_raw = openssl_encrypt($plaintext, $cipher, self::ENC_KEY, OPENSSL_RAW_DATA, $iv); $hmac = hash_hmac('sha256', $ciphertext_raw, self::ENC_KEY, true); return base64_encode($iv.$hmac.$ciphertext_raw); } /** * @param string $ciphertext * @return false|string */ public function decrypt(string $ciphertext): false|string { $c = $this->d3Base64_decode($ciphertext); $ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC"); $iv = substr($c, 0, $ivlen); $hmac = substr($c, $ivlen, $sha2len = 32); $ciphertext_raw = substr($c, $ivlen + $sha2len); $original_plaintext = openssl_decrypt($ciphertext_raw, $cipher, self::ENC_KEY, OPENSSL_RAW_DATA, $iv); $calcmac = hash_hmac('sha256', $ciphertext_raw, self::ENC_KEY, true); if (hash_equals($hmac, $calcmac)) { // PHP 5.6+ compute attack-safe comparison return $original_plaintext; } return false; } /** * required for unit tests * @param string $source * @return string */ public function d3Base64_decode(string $source): string { return base64_decode($source); } /** * @param string|null $oxid * @return bool * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ public function delete($oxid = null): bool { $oBackupCodeList = $this->d3GetBackupCodeListObject(); $oBackupCodeList->deleteAllFromUser($this->getFieldData('oxuserid')); return parent::delete($oxid); } }