commit c8607205a20ae0554a94de90c783a27e12b1c9b9 Author: dqj Date: Mon Oct 6 21:45:33 2025 +0900 Init Gitea diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Controller/PasskeysAuthCustomerController.php b/Controller/PasskeysAuthCustomerController.php new file mode 100644 index 0000000..e045d86 --- /dev/null +++ b/Controller/PasskeysAuthCustomerController.php @@ -0,0 +1,155 @@ +customerRepository = $customerRepository; + $this->customerPasskeysAuthService = $customerPasskeysAuthService; + $this->twig = $twig; + } + + /** + * Passkey authentication page. + * + * @Route("/passkeys", name="plg_customer_passkey_page", methods={"GET", "POST"}) + * @Template("Passkeys/Resource/template/default/passkey.twig") + */ + public function passkeyAuth(Request $request) + { + //log_info('Passkey authentication page1.'); + if ($this->isPasskeysAuthed()) { + return $this->redirectToRoute($this->getCallbackRoute()); + } + + //log_info('Passkey authentication page2.'); + + /** @var Customer $Customer */ + $Customer = $this->getUser(); + + $error = null; + + if ('POST' === $request->getMethod()) { + $mode = $request->get('mode'); + switch ($mode) { + case 'login_succ': + $rp = $request->get('rp'); + $session = $request->get('pk_session'); + log_info('Passkey authentication Post2.'.$rp."|".$session); + if($session != null){ + //Check session is valid + $session_valid = $this->customerPasskeysAuthService->checkSession($session, $rp); + + if($session_valid){ + log_info('Passkey authentication Post3. sesson valid:'.$session); + $response = $this->redirectToRoute($this->getCallbackRoute()); + $response->headers->setCookie( + $this->customerPasskeysAuthService->createAuthedCookie( + $Customer, + $this->getCallbackRoute() + )); + return $response; + } + } + + break; + case 'no_webauthn'://TODO: Add config(force logout or pass as current process) on shop config page for this case + log_info('Browser without webauthn support, pass to success page.'); + $response = $this->redirectToRoute($this->getCallbackRoute()); + $response->headers->setCookie( + $this->customerPasskeysAuthService->createAuthedCookie( + $Customer, + $this->getCallbackRoute() + )); + return $response; + break; + default: + log_info('Unknown mode:'.$mode); + break; + } + } + + log_info('Passkey authentication page3:'.$this->getCallbackRoute()); + return [ + //'form' => $form->createView(), + 'Customer' => $Customer, + 'error' => $error, + 'succ_route' => $this->getCallbackRoute(), + ]; + } + + /** + * 認証済みか否か. + * + * @return boolean + */ + protected function isPasskeysAuthed(): bool + { + /** @var Customer $Customer */ + $Customer = $this->getUser(); + if ($Customer != null && !$this->customerPasskeysAuthService->isAuthed($Customer, $this->getCallbackRoute())) { + return false; + } + + return true; + } + + /** + * コールバックルートの取得. + * + * @return string + */ + protected function getCallbackRoute(): string + { + $route = $this->session->get(CustomerPasskeysAuthService::SESSION_CALL_BACK_URL); + log_info('Passkey getCallbackRoute:'.$route); + return ($route != null) ? $route : 'mypage'; + } +} diff --git a/Entity/BaseInfoTrait.php b/Entity/BaseInfoTrait.php new file mode 100644 index 0000000..59b3838 --- /dev/null +++ b/Entity/BaseInfoTrait.php @@ -0,0 +1,51 @@ +passkeys_use); + return $this->passkeys_use; + } + + /** + * @param bool $passkeys_use + */ + public function setPasskeysUse(bool $passkeys_use): void + { + $this->passkeys_use = $passkeys_use; + log_info('setPasskeysUse:'.$this->passkeys_use); + } + +} diff --git a/Entity/CustomerTrait.php b/Entity/CustomerTrait.php new file mode 100644 index 0000000..026e27e --- /dev/null +++ b/Entity/CustomerTrait.php @@ -0,0 +1,71 @@ +enable_passkeys; + } + + /** + * @param bool $enable_passkeys + */ + public function setEnablePasskeys(bool $enable_passkeys): void + { + $this->enable_passkeys = $enable_passkeys; + } + + /** + * @return Collection + */ + public function getPasskeysCustomerCookies(): Collection + { + return $this->PasskeysCustomerCookies; + } + + /** + * @param Collection $PasskeysCustomerCookies + */ + public function setPasskeysCustomerCookies(Collection $PasskeysCustomerCookies): void + { + $this->PasskeysCustomerCookies = $PasskeysCustomerCookies; + } +} diff --git a/Entity/PasskeysAuthConfig.php b/Entity/PasskeysAuthConfig.php new file mode 100644 index 0000000..13d7f7e --- /dev/null +++ b/Entity/PasskeysAuthConfig.php @@ -0,0 +1,241 @@ +id; + } + + /** + * Get api_key. + * + * @return string + */ + public function getApiKey() + { + return $this->api_key; + } + + /** + * Set api_key. + * + * @param string $apiKey + * + * @return PasskeysAuthConfig + */ + public function setApiKey($apiKey) + { + $this->api_key = $apiKey; + + return $this; + } + + /** + * Get api_secret. + * + * @return string + */ + public function getApiSecret() + { + return $this->api_secret; + } + + /** + * Set api_secret. + * + * @param string $apiSecret + * + * @return PasskeysAuthConfig + */ + public function setApiSecret($apiSecret) + { + $this->api_secret = $apiSecret; + + return $this; + } + + /** + * Get from phone number. + * + * @return string + */ + public function getFromPhoneNumber() + { + return $this->from_phone_number; + } + + /** + * Set from phone number. + * + * @param string $fromPhoneNumber + * + * @return PasskeysAuthConfig + */ + public function setFromPhoneNumber(string $fromPhoneNumber) + { + $this->from_phone_number = $fromPhoneNumber; + + return $this; + } + + public function addIncludeRoute(string $route) + { + $routes = $this->getRoutes($this->getIncludeRoutes()); + + if (!in_array($route, $routes)) { + $this->setIncludeRoutes($this->include_routes.PHP_EOL.$route); + } + + return $this; + } + + private function getRoutes(?string $routes): array + { + if (!$routes) { + return []; + } + + return explode(PHP_EOL, $routes); + } + + /** + * Get include_routes. + * + * @return string|null + */ + public function getIncludeRoutes() + { + return $this->include_routes; + } + + /** + * Set include_routes. + * + * @param string|null $include_routes + * + * @return PasskeysAuthConfig + */ + public function setIncludeRoutes($include_routes = null) + { + $this->include_routes = $include_routes; + + return $this; + } + + public function removeIncludeRoute(string $route) + { + $routes = $this->getRoutes($this->getIncludeRoutes()); + + if (in_array($route, $routes)) { + $routes = array_diff($routes, [$route]); + $this->setIncludeRoutes($this->getRoutesAsString($routes)); + } + + return $this; + } + + private function getRoutesAsString(array $routes): string + { + return implode(PHP_EOL, $routes); + } + + /** + * @param string|null $plain_api_secret + * + * @return PasskeysAuthConfig + */ + public function setPlainApiSecret(?string $plain_api_secret): PasskeysAuthConfig + { + $this->plain_api_secret = $plain_api_secret; + + return $this; + } + + /** + * @return mixed + */ + public function getPlainApiSecret(): ?string + { + return $this->plain_api_secret; + } +} diff --git a/Entity/PasskeysAuthType.php b/Entity/PasskeysAuthType.php new file mode 100644 index 0000000..2a987ca --- /dev/null +++ b/Entity/PasskeysAuthType.php @@ -0,0 +1,142 @@ +id; + } + + /** + * Get name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set name. + * + * @param string $name + * + * @return PasskeysAuthType + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get route. + * + * @return string + */ + public function getRoute() + { + return $this->route; + } + + /** + * Set route. + * + * @param string $route + * + * @return PasskeysAuthType + */ + public function setRoute($route) + { + $this->route = $route; + + return $this; + } + + /** + * @return bool + */ + public function isDisabled(): bool + { + return $this->isDisabled; + } + + /** + * @param bool $isDisabled + */ + public function setIsDisabled(bool $isDisabled): void + { + $this->isDisabled = $isDisabled; + } +} diff --git a/Entity/PasskeysCustomerCookie.php b/Entity/PasskeysCustomerCookie.php new file mode 100644 index 0000000..6a290da --- /dev/null +++ b/Entity/PasskeysCustomerCookie.php @@ -0,0 +1,197 @@ +setUpdatedAt(new \DateTime('now')); + if (!isset($this->createdAt) || $this->getCreatedAt() === null) { + $this->setCreatedAt(new \DateTime('now')); + } + } + + /** + * @return \DateTime + */ + public function getCreatedAt(): \DateTime + { + return $this->createdAt; + } + + /** + * @param \DateTime $createdAt + */ + public function setCreatedAt(\DateTime $createdAt): void + { + $this->createdAt = $createdAt; + } + + /** + * @return int + */ + public function getId(): int + { + return $this->id; + } + + /** + * @return Customer + */ + public function getCustomer(): Customer + { + return $this->Customer; + } + + /** + * @param Customer $Customer + */ + public function setCustomer(Customer $Customer): void + { + $this->Customer = $Customer; + } + + /** + * @return string + */ + public function getCookieName(): string + { + return $this->cookie_name; + } + + /** + * @param string $cookie_name + */ + public function setCookieName(string $cookie_name): void + { + $this->cookie_name = $cookie_name; + } + + /** + * @return string + */ + public function getCookieValue(): string + { + return $this->cookie_value; + } + + /** + * @param string $cookie_value + */ + public function setCookieValue(string $cookie_value): void + { + $this->cookie_value = $cookie_value; + } + + /** + * @return \DateTime + */ + public function getCookieExpireDate(): \DateTime + { + return $this->cookie_expire_date; + } + + /** + * @param \DateTime $cookie_expire_date + */ + public function setCookieExpireDate(\DateTime $cookie_expire_date): void + { + $this->cookie_expire_date = $cookie_expire_date; + } + + /** + * @return \DateTime + */ + public function getUpdatedAt(): \DateTime + { + return $this->updatedAt; + } + + /** + * @param \DateTime $updatedAt + */ + public function setUpdatedAt(\DateTime $updatedAt): void + { + $this->updatedAt = $updatedAt; + } +} diff --git a/Event.php b/Event.php new file mode 100644 index 0000000..dfc5ca2 --- /dev/null +++ b/Event.php @@ -0,0 +1,68 @@ +hasActiveAuthType = $PasskeysAuthTypeRepository->count(['isDisabled' => false]) > 0; + } + + public static function getSubscribedEvents(): array + { + return [ + '@admin/Setting/Shop/shop_master.twig' => 'onRenderAdminShopSettingEdit', + '@admin/Customer/edit.twig' => 'onRenderAdminCustomerEdit', + ]; + } + + /** + * [/admin/setting/shop]表示の時のEvent Hook. + * Open/Close passkeys. + * + * @param TemplateEvent $event + */ + public function onRenderAdminShopSettingEdit(TemplateEvent $event) + { + + $twig = 'Passkeys/Resource/template/admin/shop_edit_tfa.twig'; + $event->addSnippet($twig); + } + + /** + * [/admin/customer/edit]表示の時のEvent Hook. + * Personal passkeys enable/disable. + * + * @param TemplateEvent $event + */ + public function onRenderAdminCustomerEdit(TemplateEvent $event) + { + // add twig + $twig = 'Passkeys/Resource/template/admin/customer_edit.twig'; + $event->addSnippet($twig); + } +} diff --git a/EventListener/CustomerPasskeysAuthListener.php b/EventListener/CustomerPasskeysAuthListener.php new file mode 100755 index 0000000..9cddec9 --- /dev/null +++ b/EventListener/CustomerPasskeysAuthListener.php @@ -0,0 +1,410 @@ +requestContext = $requestContext; + $this->router = $router; + $this->customerPasskeysAuthService = $customerPasskeysAuthService; + $this->baseInfo = $baseInfoRepository->find(1); + $this->PasskeysAuthTypeRepository = $PasskeysAuthTypeRepository; + $this->PasskeysAuthCustomerCookieRepository = $PasskeysAuthCustomerCookieRepository; + $this->session = $session; + + $this->default_routes = $this->customerPasskeysAuthService->getDefaultAuthRoutes(); + $this->include_routes = $this->customerPasskeysAuthService->getIncludeRoutes(); + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelController', 7], + KernelEvents::REQUEST => 'onKernelRequest', + LoginSuccessEvent::class => ['onLoginSuccess'], + LogoutEvent::class => 'logoutEvent', + ]; + } + + public function onKernelRequest(RequestEvent $event) + { + log_info('pk:onKernelRequest'); + + if (!$event->isMainRequest()) { + log_info('pk:onKernelRequest-NotMainRequest'); + return; + } + + if ($this->requestContext->isAdmin()) { + log_info('pk:onKernelRequest-isAdmin'); + return; + } + + if (!$this->baseInfo->isPasskeysUse()) { + log_info('pk:onKernelRequest-NoPK'); + return; + } + + $route = $event->getRequest()->attributes->get('_route'); + if($route !== 'mypage_login' && $route !== 'plg_customer_passkey_page'){ + $this->setCallbackRoute($route); + log_info('pk:onKernelRequest set:'.$route); + } + } + + /** + * リクエスト受信時イベントハンドラ. + * + * @param ControllerArgumentsEvent $event + */ + public function onKernelController(ControllerArgumentsEvent $event) + { + //log_info('pk:onKernelController'); + + if (!$event->isMainRequest()) { + // サブリクエストの場合、処理なし + log_info('pk:onKernelController-NotMainRequest'); + return; + } + + if ($this->requestContext->isAdmin()) { + // バックエンドURLの場合、処理なし + log_info('pk:onKernelController-isAdmin'); + return; + } + + if (!$this->baseInfo->isPasskeysUse()) { + log_info('pk:onKernelControllerNoPK'); + return; + } + + $route = $event->getRequest()->attributes->get('_route'); + $uri = $event->getRequest()->getRequestUri(); + + if (!$this->isDefaultRoute($route, $uri) && !$this->isIncludeRoute($route, $uri)) { + // 重要操作指定ではなく、マイページ系列ではない場合、処理なし + return; + } + + $Customer = $this->requestContext->getCurrentUser(); + log_info('pk:onKernelController2:'.$Customer); + + //TODO: may try passkeys before form login in the future. + //$this->passkeyProcess($event, $Customer, $route); + + if ($Customer instanceof Customer) { + if(!$Customer->isEnablePasskeys()){ + log_info('pk:onKernelController:passkey disabled by user'); + return; + }else{//for debug + log_info('pk:onKernelController:passkey enabled by user'); + } + + + if ($Customer->getStatus()->getId() !== CustomerStatus::REGULAR) { + // ログインしていない場合、処理なし + return; + } + + if (!$this->isDefaultRoute($route, $uri) && !$this->isIncludeRoute($route, $uri)) { + // 重要操作指定ではなく、マイページ系列ではない場合、処理なし + return; + } + + $this->passkeyProcess($event, $Customer, $route); + + }else{ + log_info('pk:onKernelController:no customer obj yet'); + //TODO: Jump to original URL before login page. + //$referer = $event->getRequest()->headers->get('referer'); + //$this->setCallbackRoute($uri); + } + } + + /** + * ログイン完了 イベントハンドラ. + * + * @param LoginSuccessEvent $event + * + * @return RedirectResponse|void + */ + public function onLoginSuccess(LoginSuccessEvent $event) + { + //log_info('pk:onLoginSuccess1'); + if ($this->requestContext->isAdmin()) { + // バックエンドURLの場合処理なし + return; + } + //log_info('pk:onLoginSuccess2'); + if (!$this->baseInfo->isPasskeysUse()) { + // Return if non Passkeys + return; + } + //log_info('pk:onLoginSuccess3'); + if ($this->requestContext->getCurrentUser() === null) { + // ログインしていない場合は処理なし + return; + } + + log_info('pk:onLoginSuccess5'); + + $Customer = $this->requestContext->getCurrentUser(); + + if(!$Customer->isEnablePasskeys()){ + log_info('pk:onKernelController:passkey disabled by user'); + return; + }else{//for debug + log_info('pk:onKernelController:passkey enabled by user'); + } + + $this->passkeyProcess( + $event, + $Customer, + $event->getRequest()->attributes->get('_route')); + } + + /** + * ログアウトする前に全ての2FA認証クッキーを消す + * + * @param LogoutEvent $logoutEvent ログアウトイベント + * + * @return void + */ + public function logoutEvent(LogoutEvent $logoutEvent) + { + $this->customerPasskeysAuthService->clearPKAuthCookies($logoutEvent->getRequest(), $logoutEvent->getResponse()); + $Customer = $this->requestContext->getCurrentUser(); + if ($Customer !== null) { + $this->PasskeysAuthCustomerCookieRepository->deleteByCustomer($Customer); + } + $this->session->remove(CustomerPasskeysAuthService::SESSION_CALL_BACK_URL); + } + + + /** + * ルート・URIが個別認証対象かチェック. + * + * @param string $route + * @param string $uri + * + * @return bool + */ + private function isDefaultRoute(string $route, string $uri): bool + { + return $this->isTargetRoute($this->default_routes, $route, $uri); + } + + /** + * ルート・URIが対象であるかチェック. + * + * @param array $targetRoutes + * @param string $route + * @param string $uri + * + * @return bool + */ + private function isTargetRoute(array $targetRoutes, string $route, string $uri): bool + { + // ルートで認証 + if (in_array($route, $targetRoutes)) { + return true; + } + + // URIで認証 + foreach ($targetRoutes as $r) { + if ($r != '' && $r !== '/' && strpos($uri, $r) === 0) { + return true; + } + } + + return false; + } + + /** + * ルート・URIが個別認証対象かチェック. + * + * @param string $route + * @param string $uri + * + * @return bool + */ + private function isIncludeRoute(string $route, string $uri): bool + { + return $this->isTargetRoute($this->include_routes, $route, $uri); + } + + /** + * Passkey authentication + * + * @param Event $event + * @param Customer $Customer + * @param string $route + * + * @return mixed + */ + private function passkeyProcess($event, $Customer, $route) + { + log_info('pk:passkeyProcess1'); + if (!$this->baseInfo->isPasskeysUse()) { + return; + } + + //log_info('pk:passkeyProcess2'); + $is_auth = $this->customerPasskeysAuthService->isAuthed($Customer, $route); + + if ($is_auth) { + $my_route = $this->session->get(CustomerPasskeysAuthService::SESSION_CALL_BACK_URL); + log_info('pk:passkey auth done:'.$route.'|'.$my_route); + if($my_route !== null && $my_route !== $route){ + //$this->session->remove(CustomerPasskeysAuthService::SESSION_CALL_BACK_URL); + $my_url = $this->router->generate($my_route, [], UrlGeneratorInterface::ABSOLUTE_PATH); + if ($event instanceof ControllerArgumentsEvent) { + log_info('pk:passkey auth done1:'.$my_url.'|||'); + $event->setController(function () use ($my_url) { + return new RedirectResponse($my_url, $status = 302); + }); + } else { + log_info('pk:passkey auth done2:'.$my_url.'|||'); + $event->setResponse(new RedirectResponse($my_url, $status = 302)); + } + } + else{ + log_info('pk:passkey auth done3: remove SESSION_CALL_BACK_URL'); + $this->session->remove(CustomerPasskeysAuthService::SESSION_CALL_BACK_URL); + } + return; + } + + //$this->setCallbackRoute($route); + + log_info('pk:passkeyProcess3'); + + $url = $this->router->generate('plg_customer_passkey_page', [], UrlGeneratorInterface::ABSOLUTE_PATH); + + //TODO: may try passkeys before form ligin in the future. + /*if($Customer !== null && $Customer->getStatus()->getId() === CustomerStatus::REGULAR){ + $url.= '?pkreg=1'; + }*/ + + log_info('pk:plg_customer_passkey_page_process:'.$url.'|||'); + + if ($event instanceof ControllerArgumentsEvent) { + log_info('pk:setController:'.$url.'|||'); + $event->setController(function () use ($url) { + return new RedirectResponse($url, $status = 302); + }); + } else { + log_info('pk:setResponse:'.$url.'|||'); + $event->setResponse(new RedirectResponse($url, $status = 302)); + } + } + + /** + * コールバックルートをセッションへ設定. + * + * @param string|null $route + */ + private function setCallbackRoute(?string $route) + { + log_info('pk:setCallbackRoute:'.$route); + if ($route) { + log_info('pk:setCallbackRoute1:'.($this->session !=null ).'|'.CustomerPasskeysAuthService::SESSION_CALL_BACK_URL); + $this->session->set(CustomerPasskeysAuthService::SESSION_CALL_BACK_URL, $route); + } + } + +} diff --git a/Form/Type/Extension/Admin/PasskeysAuthBaseSettingTypeExtension.php b/Form/Type/Extension/Admin/PasskeysAuthBaseSettingTypeExtension.php new file mode 100644 index 0000000..c38fbf5 --- /dev/null +++ b/Form/Type/Extension/Admin/PasskeysAuthBaseSettingTypeExtension.php @@ -0,0 +1,72 @@ +entityManager = $entityManager; + } + + /** + * {@inheritDoc} + */ + public static function getExtendedTypes(): iterable + { + yield ShopMasterType::class; + } + + /** + * buildForm. + * + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (!empty($options['skip_add_form'])) { + return; + } + + $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) { + $form = $event->getForm(); + $form->add('passkeys_use', ToggleSwitchType::class, [ + 'required' => false, + 'mapped' => true, + ]); + + }); + } +} diff --git a/Form/Type/Extension/Admin/PasskeysAuthCustomerTypeExtension.php b/Form/Type/Extension/Admin/PasskeysAuthCustomerTypeExtension.php new file mode 100755 index 0000000..7c694d8 --- /dev/null +++ b/Form/Type/Extension/Admin/PasskeysAuthCustomerTypeExtension.php @@ -0,0 +1,73 @@ +entityManager = $entityManager; + } + + /** + * {@inheritDoc} + */ + public static function getExtendedTypes(): iterable + { + yield CustomerType::class; + } + + /** + * buildForm. + * + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (!empty($options['skip_add_form'])) { + return; + } + + $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) { + $form = $event->getForm(); + $form->add('enable_passkeys', ToggleSwitchType::class, [ + 'required' => false, + 'mapped' => true, + ]); + }); + } +} diff --git a/Form/Type/PasskeysAuthConfigType.php b/Form/Type/PasskeysAuthConfigType.php new file mode 100644 index 0000000..5aae47e --- /dev/null +++ b/Form/Type/PasskeysAuthConfigType.php @@ -0,0 +1,125 @@ +eccubeConfig = $eccubeConfig; + $this->validator = $validator; + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('api_key', TextType::class, [ + 'required' => true, + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]), + new Assert\Regex( + [ + 'pattern' => '/^[a-zA-Z0-9]+$/i', + 'message' => 'form_error.graph_only', + ] + ), + ], + ]) + ->add('plain_api_secret', TextType::class, [ + 'required' => true, + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]), + ], + ]) + ->add('from_phone_number', TextType::class, [ + 'required' => true, + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]), + new Assert\Regex( + [ + 'pattern' => '/^[0-9]+$/i', + 'message' => 'form_error.numeric_only', + ] + ), + ], + ]) + ->add('include_routes', TextareaType::class, [ + 'required' => false, + 'constraints' => [ + new Assert\Length([ + 'max' => $this->eccubeConfig['eccube_ltext_len'], + ]), + ], + ]); + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) { + $form = $event->getForm(); + $data = $event->getData(); + + if ($data['plain_api_secret'] !== $this->eccubeConfig['eccube_default_password']) { + $errors = $this->validator->validate($data['plain_api_secret'], [ + new Assert\Regex([ + 'pattern' => '/^[a-zA-Z0-9]+$/i', + 'message' => 'form_error.graph_only', + ]), + ]); + if ($errors) { + foreach ($errors as $error) { + $form['plain_api_secret']->addError(new FormError($error->getMessage())); + } + } + } + }); + } + + /** + * {@inheritDoc} + * + * @see AbstractType::configureOptions + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => PasskeysAuthConfig::class, + ]); + } +} diff --git a/Form/Type/PasskeysAuthTypeCustomer.php b/Form/Type/PasskeysAuthTypeCustomer.php new file mode 100644 index 0000000..8e3eaac --- /dev/null +++ b/Form/Type/PasskeysAuthTypeCustomer.php @@ -0,0 +1,51 @@ +add('two_factor_auth_type', EntityType::class, [ + 'label' => 'front.setting.system.two_factor_auth.type', + 'class' => PasskeysAuthType::class, + 'required' => true, + 'query_builder' => function (EntityRepository $er) { + return $er->createQueryBuilder('tfat') + ->where('tfat.isDisabled = :id') + ->setParameter('id', false); + }, + 'choice_label' => 'name', + 'mapped' => true, + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix() + { + return 'plg_customer_2fa'; + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4362b49 --- /dev/null +++ b/LICENSE @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/PluginManager.php b/PluginManager.php new file mode 100644 index 0000000..2612c24 --- /dev/null +++ b/PluginManager.php @@ -0,0 +1,185 @@ +get('doctrine')->getManager(); + + $this->createConfig($em); + + // twigファイルを追加 + $this->copyTwigFiles($container); + + // ページ登録 + $this->createPages($em); + } + + /** + * 設定の登録. + * + * @param EntityManagerInterface $em + */ + protected function createConfig(EntityManagerInterface $em) + { + $PasskeysAuthConfig = $em->find(PasskeysAuthConfig::class, 1); + if ($PasskeysAuthConfig) { + return; + } + + // 初期値を保存 + $PasskeysAuthConfig = new PasskeysAuthConfig(); + $em->persist($PasskeysAuthConfig); + $em->flush(); + } + + /** + * Twigファイルの登録 + * + * @param ContainerInterface $container + */ + protected function copyTwigFiles(ContainerInterface $container) + { + // テンプレートファイルコピー + $templatePath = $container->getParameter('eccube_theme_front_dir') + . '/Passkeys/Resource/template/default'; + $fs = new Filesystem(); + if ($fs->exists($templatePath)) { + return; + } + $fs->mkdir($templatePath); + $fs->mirror(__DIR__ . '/Resource/template/default', $templatePath); + } + + /** + * ページ情報の登録 + * + * @param EntityManagerInterface $em + */ + protected function createPages(EntityManagerInterface $em) + { + foreach ($this->pages as $p) { + log_info('pk:createPages:'.$p[0]); + + $hasPage = $em->getRepository(Page::class)->count(['url' => $p[0]]) > 0; + if (!$hasPage) { + /** @var Page $Page */ + $Page = $em->getRepository(Page::class)->newPage(); + $Page->setEditType(Page::EDIT_TYPE_DEFAULT); + $Page->setUrl($p[0]); + $Page->setName($p[1]); + $Page->setFileName($p[2]); + $Page->setMetaRobots('noindex'); + + $em->persist($Page); + $em->flush(); + + $Layout = $em->getRepository(Layout::class)->find(Layout::DEFAULT_LAYOUT_UNDERLAYER_PAGE); + $PageLayout = new PageLayout(); + $PageLayout->setPage($Page) + ->setPageId($Page->getId()) + ->setLayout($Layout) + ->setLayoutId($Layout->getId()) + ->setSortNo(0); + $em->persist($PageLayout); + $em->flush(); + } + } + } + + /** + * @param array $meta + * @param ContainerInterface $container + */ + public function disable(array $meta, ContainerInterface $container) + { + $em = $container->get('doctrine')->getManager(); + + // twigファイルを削除 + $this->removeTwigFiles($container); + + // ページ削除 + $this->removePages($em); + } + + /** + * Twigファイルの削除 + * + * @param ContainerInterface $container + */ + protected function removeTwigFiles(ContainerInterface $container) + { + $templatePath = $container->getParameter('eccube_theme_front_dir') + . '/Passkeys'; + $fs = new Filesystem(); + $fs->remove($templatePath); + } + + /** + * ページ情報の削除 + * + * @param EntityManagerInterface $em + */ + protected function removePages(EntityManagerInterface $em) + { + foreach ($this->pages as $p) { + $Page = $em->getRepository(Page::class)->findOneBy(['url' => $p[0]]); + if ($Page !== null) { + $Layout = $em->getRepository(Layout::class)->find(Layout::DEFAULT_LAYOUT_UNDERLAYER_PAGE); + $PageLayout = $em->getRepository(PageLayout::class)->findOneBy(['Page' => $Page, 'Layout' => $Layout]); + + $em->remove($PageLayout); + $em->remove($Page); + $em->flush(); + } + } + } + + /** + * @param array $meta + * @param ContainerInterface $container + */ + public function uninstall(array $meta, ContainerInterface $container) + { + $em = $container->get('doctrine')->getManager(); + + // twigファイルを削除 + $this->removeTwigFiles($container); + + // ページ削除 + $this->removePages($em); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..87e1841 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Passkeys2段階認証プラグイン diff --git a/Repository/PasskeysAuthConfigRepository.php b/Repository/PasskeysAuthConfigRepository.php new file mode 100644 index 0000000..32cfe84 --- /dev/null +++ b/Repository/PasskeysAuthConfigRepository.php @@ -0,0 +1,46 @@ +findOneBy([], ['id' => 'DESC']); + } +} diff --git a/Repository/PasskeysAuthCustomerCookieRepository.php b/Repository/PasskeysAuthCustomerCookieRepository.php new file mode 100644 index 0000000..75e71bb --- /dev/null +++ b/Repository/PasskeysAuthCustomerCookieRepository.php @@ -0,0 +1,154 @@ +findOldCookies($customer, $cookieName); + foreach ($previousCookies as $cookie) { + $this->getEntityManager()->remove($cookie); + } + $this->getEntityManager()->flush(); + + $cookie = new PasskeysCustomerCookie(); + $cookie->setCookieName($cookieName); + $cookie->setCookieValue(StringUtil::random($CookieValueCharacterLength)); + $cookie->setCookieExpireDate($expireSeconds != 0 ? Carbon::now()->addSeconds($expireSeconds) : null); + $cookie->setCustomer($customer); + $cookie->updatedTimestamps(); + + return $cookie; + } + + /** + * 過去のクッキーデータの取得 + * + * @param Customer $customer + * @param string $cookieName + * + * @return float|int|mixed|string + */ + public function findOldCookies(Customer $customer, string $cookieName) + { + $expireDate = Carbon::now()->setTimezone('UTC')->format('Y-m-d H:i:s'); + + return $this->createQueryBuilder('tfcc') + ->where('tfcc.Customer = :customer_id') + ->andWhere('tfcc.cookie_name = :cookie_name') + ->andWhere('tfcc.cookie_expire_date < :expire_date') + ->setParameters([ + 'customer_id' => $customer->getId(), + 'cookie_name' => $cookieName, + 'expire_date' => $expireDate, + ]) + ->getQuery() + ->getResult(); + } + + /** + * @return PasskeysCustomerCookie|null $result + */ + public function findOne() + { + return $this->findOneBy([], ['id' => 'DESC']); + } + + /*** + * 有効クッキーを取得する + * + * @param Customer $customer + * @param string $cookieName + * @return PasskeysCustomerCookie[]|null + */ + public function searchForCookie(Customer $customer, string $cookieName) + { + $expireDate = Carbon::now()->setTimezone('UTC')->format('Y-m-d H:i:s'); + + return $this->createQueryBuilder('tfcc') + ->where('tfcc.Customer = :customer_id') + ->andWhere('tfcc.cookie_name = :cookie_name') + ->andWhere('tfcc.cookie_expire_date > :expire_date') + ->setParameters([ + 'customer_id' => $customer->getId(), + 'cookie_name' => $cookieName, + 'expire_date' => $expireDate, + ]) + ->getQuery() + ->getResult(); + } + + /*** + * 会員のクッキーを削除 + * + * @param Customer $customer + */ + public function deleteByCustomer(Customer $customer) + { + $em = $this->getEntityManager(); + $em->beginTransaction(); + + $this->createQueryBuilder('tfcc') + ->delete() + ->where('tfcc.Customer = :customer') + ->setParameter('customer', $customer) + ->getQuery() + ->execute(); + + $em->flush(); + + $em->commit(); + } + +} diff --git a/Repository/PasskeysAuthTypeRepository.php b/Repository/PasskeysAuthTypeRepository.php new file mode 100644 index 0000000..092259c --- /dev/null +++ b/Repository/PasskeysAuthTypeRepository.php @@ -0,0 +1,45 @@ +findOneBy([], ['id' => 'DESC']); + } +} diff --git a/Resource/assets/dfido2-lib.js b/Resource/assets/dfido2-lib.js new file mode 100644 index 0000000..cb3aba6 --- /dev/null +++ b/Resource/assets/dfido2-lib.js @@ -0,0 +1,616 @@ +const DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION = 'fido2_user_session' +const DFIDO2_LIB_LOCALSTG_NAME_REGISTERED = 'dfido2_lib_registered' +const DFIDO2_LIB_LOCALSTG_NAME_SVR_URL = 'dfido2_lib_svr_url' + +/** ===APIs=== */ + +if(!localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL)){ + localStorage.setItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL, 'https://fido2.amipro.me') +} + +function setFidoServerURL(url){ + localStorage.setItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL, url); +} + +function canTryAutoAuthentication(){ + //const session_text = localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION) + //alert('canTryAuth:'+session_text+"|"+(null != localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_REGISTERED))) + return null != localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_REGISTERED) +} + +/** + * + * @param {String} userId + * @param {String} rpId + */ +async function authenticateFido2(userId = null, rpId = null) { + var result + result = await doAssertion(userId, rpId); + if(result.status === 'ok'){ + sessionStorage.setItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION, + JSON.stringify({session:result.session, uid:result.username})) + localStorage.setItem(DFIDO2_LIB_LOCALSTG_NAME_REGISTERED, new Date()); + } + return result +} + +/** + * + * @param {String} userId + * @param {String} rpId + */ +async function registerFido2(userId, userDisplay, rpId = null) { + if (isWebAuthnSupported()) { + const result = await doAttestation(userId, userDisplay, rpId); + if(result.status === 'ok'){ + localStorage.setItem(DFIDO2_LIB_LOCALSTG_NAME_REGISTERED, new Date()); + sessionStorage.setItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION, JSON.stringify({session:result.session, uid:result.username})) + } + return result + }else return {status:'failed', errorMessage: getI18NErrorMessage('Fido2LibErr101:')} +} + +/** + * + * @param {String} rpId + * @returns + */ +async function listUserDevicesFido2(rpId = null) { + try { + const session_text = sessionStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION) + if(!session_text) return {status:'ok', devices:[]} + + const session_data = JSON.parse(session_text) + + let req = {session: session_data.session} + if (rpId && 0 < rpId.length) { + req.rp = { id: rpId }; + } + + const response = await fetch(localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL) + "/usr/dvs/lst", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(req) + }); + const resp = await response.json(); + if ('ok' === resp.status && resp.session === session_data.session) { + return {status:'ok', devices:resp.devices} + } else { + return {status:'failed', errorMessage: resp.errorMessage} + } + } catch (err) { + console.log(err) + let msg = err.message ? err.message : err; + //console.error("Assertion err: ", err); + var errRtn = {status:'failed', errorMessage: msg}; + if(err.name) errRtn.name = err.name + return errRtn; + } +} + +async function delUserDeviceFido2(device_id, rpId = null) { + try { + const session_text = sessionStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION) + const session_data = JSON.parse(session_text) + + let req = {session: session_data.session, device_id: device_id} + if (rpId && 0 < rpId.length) { + req.rp = { id: rpId }; + } + + const response = await fetch(localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL) + "/usr/dvs/rm", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(req) + }); + const resp = await response.json(); + + return resp + } catch (err) { + console.log(err) + let msg = err.message ? err.message : err; + //console.error("Assertion err: ", err); + var errRtn = {status:'failed', errorMessage: msg}; + if(err.name) errRtn.name = err.name + return errRtn; + } +} + +function getSessionId() { + var rtn = null + try { + const session_text = sessionStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION) + if(session_text){ + const session_data = JSON.parse(session_text) + + rtn = session_data.session + } + + return rtn + } catch (err) { + console.log(err) + return null; + } +} + +async function validSession(rpId = null) { + try { + const session_text = sessionStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION) + if(!session_text) return false + + const session_data = JSON.parse(session_text) + + let req = {session: session_data.session} + if (rpId && 0 < rpId.length) { + req.rp = { id: rpId }; + } + + const response = await fetch(localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL) + "/usr/validsession", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(req) + }); + const resp = await response.json(); + + return resp.status === 'ok' + } catch (err) { + console.log(err) + return false; + } +} + +async function logoutFido2UserSession(){ + const session_text = sessionStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION) + if(!session_text) return + + const session_data = JSON.parse(session_text) + let req = {session: session_data['session'], username: session_data['uid']} + const response = await fetch(localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL) + "/usr/delsession", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(req) + }); + + sessionStorage.removeItem(DFIDO2_LIB_LOCALSTG_NAME_USER_SESSION); +} + +async function getRegistrationUser(reg_session_id){ + try { + let req = {session_id: reg_session_id} + const response = await fetch(localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL) + "/reg/username", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(req) + }); + const resp = await response.json(); + + return resp.username + } catch (err) { + console.log(err) + return false; + } +} + +function errProcessFido2(result){ + alert(errMessageFido2(result)); +} + +function errMessageFido2(result){ + var rtn + if(result.errCode && fido2LibErrCodes.unknown != result.errCode ){ + switch (result.errCode){ + case fido2LibErrCodes.user_canceled: + rtn=getI18NErrorMessage('Fido2LibErr102:'); + break; + case fido2LibErrCodes.timeout: + rtn=getI18NErrorMessage('Fido2LibErr103:'); + break; + default: + rtn=result.errorMessage?result.errorMessage:getI18NErrorMessage('Fido2LibErr104:'); + } + }else if(result.name && "InvalidStateError" === result.name){ + rtn=getI18NErrorMessage('Fido2LibErr105:'); + }else if(result.errorMessage){ + const msg = getI18NErrorMessage(result.errorMessage); + rtn=msg?msg:result.errorMessage; + }else{ + rtn=getI18NErrorMessage(i18n_messages, 'Fido2LibErr104:'); + } + + return rtn; +} + +const fido2LibErrCodes = { + user_canceled : -101, + timeout : -102, + unknown : -999 +} + +const errMsgs = new Map(); +const fido2LibErrMsgLanguages = { + english: 'en-US', + japanese: 'ja', + chinese_cn: 'zh-CN', + //chinese_tw: 'zh-TW', +} +errMsgs.set(fido2LibErrMsgLanguages.english, new Map()); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr101:', 'Unregistered enterprise authenticator aaguid!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr102:', 'Unable to authenticate with a unique device binding key from another device!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr103:', 'Unable to verify signature!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr104:', 'Key not found!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr105:', 'Username does not exist!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr106:', 'Unique Device ID is null!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr107:', '/attestation/result request body has no ID field!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr108:', 'ID field is not Base64Url encoded in /attestation/result request body!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr109:', '/attestation/result request body has no TYPE field!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr110:', 'TYPE field is not a DOMString in /attestation/result request body!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr111:', 'The TYPE field is not a public key in the /attestation/result request body!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr112:', 'ID field is not a DOMString in /attestation/result request body!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr115:', 'authenticatorData not found!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr116:', 'authenticatorData is not base64 URL encoded!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr117:', 'Signature not found!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr118:', 'Signature is not base64 URL encoded!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr119:', 'No user session!'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('SvrErr120:', 'User has reached the device limit!'); + +errMsgs.get(fido2LibErrMsgLanguages.english).set('Fido2LibErr101:', 'Your browser does not support FIDO2.'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('Fido2LibErr102:', 'The user canceled.'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('Fido2LibErr103:', 'The process timeout.'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('Fido2LibErr104:', 'System error.'); +errMsgs.get(fido2LibErrMsgLanguages.english).set('Fido2LibErr105:', 'The same authenticator cannot be registered again.'); + +errMsgs.set(fido2LibErrMsgLanguages.japanese, new Map()); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr101:', '登録されていないエンタープライズ認証デバイス aaguid!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr102:', '別のデバイスからの一意のデバイス バインド キーで認証できません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr103:', '署名を認証できません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr104:', 'キーが見つかりません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr105:', 'ユーザー名は存在しません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr106:', '固有のデバイス ID が null です!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr107:', '/attestation/result request の本文に ID フィールドがありません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr108:', 'ID フィールドは、/attestation/result リクエストの本文でエンコードされた Base64Url ではありません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr109:', '/attestation/result リクエストのボディに TYPE フィールドがありません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr110:', 'TYPE フィールドは、/attestation/result リクエストの本文の DOMString ではありません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr111:', 'TYPE フィールドは、/attestation/result リクエストの本文の公開鍵ではありません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr112:', 'ID フィールドは、/attestation/result リクエストの本文の DOMString ではありません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr115:', 'authenticatorData が見つかりません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr116:', 'authenticatorData は base64 URL エンコードされていません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr117:', '署名が見つかりません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr118:', '署名は base64 URL エンコードされていません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr119:', 'ユーザーセッションがありません!'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('SvrErr120:', 'ユーザーはデバイスの制限数に達しました!'); + +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('Fido2LibErr101:', 'お使いのブラウザは FIDO2 をサポートしていません。'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('Fido2LibErr102:', 'ユーザーがキャンセルしました。'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('Fido2LibErr103:', 'プロセスがタイムアウトしました。'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('Fido2LibErr104:', 'システムエラー。'); +errMsgs.get(fido2LibErrMsgLanguages.japanese).set('Fido2LibErr105:', '同じ認証デバイスを再登録することはできません。'); + +errMsgs.set(fido2LibErrMsgLanguages.chinese_cn, new Map()); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr101:', '未注册的企业认证器 aaguid!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr102:', '无法使用来自其他设备的唯一设备绑定密钥进行身份验证!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr103:', '无法验证签名!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr104:', '认证Key未找到!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr105:', '用户名不存在!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr106:', 'Unique Device ID 为 null!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr107:', '/attestation/result请求体没有ID字段!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr108:', 'ID字段不是/attestation/result请求体中编码的Base64Url!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr109:', '/attestation/result请求体没有TYPE字段!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr110:', '/attestation/result 请求正文中的 TYPE 字段不是 DOMString!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr111:', 'TYPE字段不是/attestation/result请求体中的公钥!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr112:', 'ID 字段不是 /attestation/result 请求体中的 DOMString!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr115:', 'authenticatorData 未找到!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr116:', 'authenticatorData 不是 base64 URL 编码!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr117:', '未找到签名!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr118:', '签名不是 base64 URL 编码!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr119:', '未建立用户会话!'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('SvrErr120:', '用户已达到设备限制数!'); + +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('Fido2LibErr101:', '您的浏览器不支持FIDO2.'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('Fido2LibErr102:', '用户取消了操作。'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('Fido2LibErr103:', '操作超时。'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('Fido2LibErr104:', '系统错误。'); +errMsgs.get(fido2LibErrMsgLanguages.chinese_cn).set('Fido2LibErr105:', '无法再次注册相同的认证器。'); + +/** + * + * @param {String} errorMessage + * @param {errMsgLanguages} language + */ +function getI18NErrorMessage(errorMessage, language = null){ + var lang = language ? language : window.navigator.language + var msgs = errMsgs.get(lang) + if(!msgs)msgs = errMsgs.get(fido2LibErrMsgLanguages.english) + if(errorMessage){ + const msgHeader = 0 process_time_limit){ + errRtn.errCode = fido2LibErrCodes.timeout + }else{ + errRtn.errCode = fido2LibErrCodes.user_canceled + } + }else errRtn.errCode = fido2LibErrCodes.unknown + + return errRtn; + } +} + +async function doAssertion(username = null, rpId = null, userVerification = 'preferred') { + var process_time_limit = Number.MAX_SAFE_INTEGER + try { + let authnOptions; + /*if (!username) { + authnOptions = { + authenticatorSelection: { + //authenticatorAttachment: "platform", + userVerification: "discouraged" + } + }; + } else { + authnOptions = { + username: username, + authenticatorSelection: { + //authenticatorAttachment: "platform", + userVerification: "preferred" + } + }; + }*/ + authnOptions = { + username: username, + authenticatorSelection: { + //authenticatorAttachment: "platform", + userVerification: userVerification + } + }; + + if (rpId && 0 < rpId.length) { + authnOptions.rp = { id: rpId }; + } + + const response = await fetch(localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL) + "/assertion/options", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(authnOptions) + }); + const resp = await response.json(); + if ('ok' === resp.status) { + process_time_limit = (new Date()).getTime() + resp.timeout; + resp.allowCredentials = resp.allowCredentials || []; + let mappedAllowCreds = resp.allowCredentials.map(x => { + return { + id: _base64ToArrayBuffer(_fromBase64URL(x.id)), + type: x.type, + transports: x.transports // can set like ['internal', 'usb'] to override server settings + }; + }); + + const cred = await navigator.credentials.get({ + publicKey: { + challenge: _base64ToArrayBuffer(_fromBase64URL(resp.challenge)), + timeout: resp.timeout, + rpId: resp.rpId, + userVerification: resp.userVerification, + allowCredentials: mappedAllowCreds + } + }); + + if (cred) { + let authRequest = { + id: cred.id, + rawId: Array.from(new Uint8Array(cred.rawId)), + type: cred.type, + response: { + authenticatorData: _toBase64URL(btoa(_bufferToString(cred.response.authenticatorData))), + clientDataJSON: _toBase64URL(btoa(_bufferToString(cred.response.clientDataJSON))), + signature: _toBase64URL(btoa(_bufferToString(cred.response.signature))), + userHandle: _toBase64URL(btoa(_bufferToString(cred.response.userHandle))) //_toBase64URL(btoa(_bufferToString(cred.response.userHandle))) + } + }; + const res = await fetch(localStorage.getItem(DFIDO2_LIB_LOCALSTG_NAME_SVR_URL) + "/assertion/result", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(authRequest) + }); + const result = await res.json(); + if (result.status === 'ok') { + return result + } else { + return {status:'failed', errorMessage: result.errorMessage} + } + } else { + return {status:'failed', errorMessage: 'Fido2LibErr999:Undefined Result'}; + } + } else { + return {status:'failed', errorMessage: resp.errorMessage} + } + } catch (err) { + var errRtn = {status:'failed', errorMessage: err.message}; + if(err.name) errRtn.name = err.name + if(err.name && 'NotAllowedError' === err.name){ + const nowtm = (new Date()).getTime() + if(nowtm > process_time_limit){ + errRtn.errCode = fido2LibErrCodes.timeout + }else{ + errRtn.errCode = fido2LibErrCodes.user_canceled + } + }else errRtn.errCode = fido2LibErrCodes.unknown + + return errRtn; + } +} + +function _toBase64URL(s) { + return (s = (s = (s = s.split("=")[0]).replace(/\+/g, "-")).replace(/\//g, "_")); +} + +function _base64ToArrayBuffer(base64) { + var binary_string = window.atob(base64); + var len = binary_string.length; + var bytes = new Uint8Array(len); + for (var i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; +} + +function _stringToArrayBuffer(src) { + return (new Uint8Array([].map.call(src, function (c) { + return c.charCodeAt(0) + }))).buffer; +} + +function _fromBase64URL(s) { + var chk = (s = s.replace(/-/g, "+").replace(/_/g, "/")).length % 4; + if (chk) { + if (1 === chk) throw new Error("Base64url string is wrong."); + s += new Array(5 - chk).join("="); + } + return s; +} + +function _bufferToString(s) { + return new Uint8Array(s).reduce((s, e) => s + String.fromCodePoint(e), ""); +} diff --git a/Resource/config/services.yaml b/Resource/config/services.yaml new file mode 100644 index 0000000..41bf873 --- /dev/null +++ b/Resource/config/services.yaml @@ -0,0 +1,42 @@ +eccube: + rate_limiter: + plg_customer_2fa_device_auth_input_onetime: + # 実行するルーティングを指定します。 + route: plg_customer_2fa_device_auth_input_onetime + # 実行するmethodを指定します。デフォルトはPOSTです。 + method: [ 'POST' ] + # スロットリングの制御方法を設定します。ip・customerを指定できます。 + type: [ 'ip', 'customer' ] + # 試行回数を設定します。 + limit: 5 + # インターバルを設定します。 + interval: '30 minutes' + plg_customer_2fa_device_auth_send_onetime: + # 実行するルーティングを指定します。 + route: plg_customer_2fa_device_auth_send_onetime + # 実行するmethodを指定します。デフォルトはPOSTです。 + method: [ 'POST' ] + # スロットリングの制御方法を設定します。ip・customerを指定できます。 + type: [ 'ip', 'customer' ] + # 試行回数を設定します。 + limit: 5 + # インターバルを設定します。 + interval: '30 minutes' + device_auth_request_email: + route: ~ + limit: 10 + interval: '30 minutes' + +parameters: + env(PLUGIN_ECCUBE_PASSKEYS_CUSTOMER_COOKIE_NAME): 'plugin_eccube_customer_passkeys' + env(PLUGIN_ECCUBE_PASSKEYS_CUSTOMER_EXPIRE): '3600' + env(PLUGIN_ECCUBE_PASSKEYS_ROUTE_CUSTOMER_COOKIE_NAME): 'plugin_eccube_route_customer_2fa' + env(PLUGIN_ECCUBE_PASSKEYS_ROUTE_CUSTOMER_EXPIRE): '3600' + env(PLUGIN_ECCUBE_PASSKEYS_ROUTE_COOKIE_VALUE_CHARACTER_LENGTH): '64' + + plugin_eccube_passkeys_customer_cookie_name: '%env(PLUGIN_ECCUBE_PASSKEYS_CUSTOMER_COOKIE_NAME)%' + plugin_eccube_passkeys_route_customer_cookie_name: '%env(PLUGIN_ECCUBE_PASSKEYS_ROUTE_CUSTOMER_COOKIE_NAME)%' + plugin_eccube_passkeys_customer_expire: '%env(PLUGIN_ECCUBE_PASSKEYS_CUSTOMER_EXPIRE)%' + plugin_eccube_passkeys_route_customer_expire: '%env(PLUGIN_ECCUBE_PASSKEYS_ROUTE_CUSTOMER_EXPIRE)%' + plugin_eccube_passkeys_route_cookie_value_character_length: '%env(PLUGIN_ECCUBE_PASSKEYS_ROUTE_COOKIE_VALUE_CHARACTER_LENGTH)%' + \ No newline at end of file diff --git a/Resource/locale/messages.ja.yml b/Resource/locale/messages.ja.yml new file mode 100644 index 0000000..2b40f72 --- /dev/null +++ b/Resource/locale/messages.ja.yml @@ -0,0 +1,16 @@ +admin.setting.shop.shop.customer_passkey_auth: パスキー多要素認証を利用 +admin.setting.shop.shop.customer_passkey_auth_tooltip: ログイン時、通常(メールアドレス・パスワード)認証に加え、パスキー認証を実施します。 + +admin.customer.passkyes.title: パスキー認証設定 +admin.customer.passkeys.authed: パスキー多要素認証を利用 + +front.passkeys.title: パスキー認証 +front.passkeys.email: ユーザーID +front.passkeys.register: パスキーを登録 +front.passkeys.auth: パスキー認証 +front.passkeys.message: | + 自動処理中。。。 +front.2fa.device_auth.input.message: | + 携帯電話に送信された認証コードを入力してください。 + 送信されていない場合は「認証コードを再送信」をクリックしてください。 + diff --git a/Resource/template/admin/customer_edit.twig b/Resource/template/admin/customer_edit.twig new file mode 100755 index 0000000..17b82e7 --- /dev/null +++ b/Resource/template/admin/customer_edit.twig @@ -0,0 +1,42 @@ +{# +This file is part of EC-CUBE + +Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved. +http://www.ec-cube.co.jp/ + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +#} + + +
+
+
+
{{ 'admin.customer.passkyes.title'|trans }} +
+
+ +
+
+
+
+
+
+
+ {{ 'admin.customer.passkeys.authed'|trans }} +
+
+ {{ form_widget(form.enable_passkeys) }} + {{ form_errors(form.enable_passkeys) }} +
+
+
+
+
diff --git a/Resource/template/admin/shop_edit_tfa.twig b/Resource/template/admin/shop_edit_tfa.twig new file mode 100644 index 0000000..d27111f --- /dev/null +++ b/Resource/template/admin/shop_edit_tfa.twig @@ -0,0 +1,30 @@ +{# +This file is part of EC-CUBE + +Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved. +http://www.ec-cube.co.jp/ + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +#} + + +
+
+
+ {{ 'admin.setting.shop.shop.customer_passkey_auth'|trans }} + +
+
+
+ {{ form_widget(form.passkeys_use) }} + {{ form_errors(form.passkeys_use) }} +
+
+ diff --git a/Resource/template/default/passkey.twig b/Resource/template/default/passkey.twig new file mode 100644 index 0000000..2c583c4 --- /dev/null +++ b/Resource/template/default/passkey.twig @@ -0,0 +1,192 @@ +{# +This file is part of EC-CUBE + +Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved. + +http://www.ec-cube.co.jp/ + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +#} +{% extends 'default_frame.twig' %} + +{% set body_class = 'mypage' %} + +{% block stylesheet %} + +{% endblock %} + +{% block javascript %} + + +{% endblock javascript %} + +{% block main %} +
+
+

{{ 'front.passkeys.title'|trans }}

+
+
+
+
+ + + + + + +
+
+
+
+{% endblock %} diff --git a/Service/CustomerPasskeysAuthService.php b/Service/CustomerPasskeysAuthService.php new file mode 100644 index 0000000..a57da72 --- /dev/null +++ b/Service/CustomerPasskeysAuthService.php @@ -0,0 +1,409 @@ +entityManager = $entityManager; + $this->eccubeConfig = $eccubeConfig; + + $this->baseInfo = $baseInfoRepository->find(1); + $this->request = $requestStack->getCurrentRequest(); + $this->cookieName = $this->eccubeConfig->get('plugin_eccube_passkeys_customer_cookie_name'); + $this->routeCookieName = $this->eccubeConfig->get('plugin_eccube_passkeys_route_customer_cookie_name'); + + $this->expire = (int) $this->eccubeConfig->get('plugin_eccube_passkeys_customer_expire'); + $this->route_expire = (int) $this->eccubeConfig->get('plugin_eccube_passkeys_route_customer_expire'); + + $this->PasskeysAuthConfig = $PasskeysAuthConfigRepository->findOne(); + $this->passkeysCustomerCookieRepository = $passkeysCustomerCookieRepository; + $this->hashFactory = $hashFactory; + } + + /** + * @return array + */ + public function getDefaultAuthRoutes() + { + return $this->default_tfa_routes; + } + + /** + * @required + */ + public function setContainer(ContainerInterface $container): ?ContainerInterface + { + $previous = $this->container; + $this->container = $container; + + return $previous; + } + + /** + * 2段階認証用Cookie生成. + * + * @param Customer $Customer + * @param null $route + * + * @return Cookie + */ + public function createAuthedCookie($Customer, $route = null): Cookie + { + $expire = $this->expire; + $cookieName = $this->cookieName; + if ($route != null) { + $includeRouts = $this->getIncludeRoutes(); + if (in_array($route, $includeRouts) && $this->isAuthed($Customer, 'mypage')) { + $cookieName = $this->routeCookieName.'_'.$route; + $expire = $this->route_expire; + } + } + + return $this->createRouteAuthCookie($Customer, $cookieName, $expire); + } + + /** + * 要認証ルートを取得. + * + * @return array + */ + public function getIncludeRoutes(): array + { + $routes = []; + $include = $this->PasskeysAuthConfig->getIncludeRoutes(); + if ($include) { + $routes = preg_split('/\R/', $include); + } + + return $routes; + } + + /** + * 認証済みか? + * + * @param Customer $Customer + * @param null $route + * + * @return boolean + */ + public function isAuthed(Customer $Customer, $route = null): bool + { + $expire = $this->expire; + if ($route != null) { + $includeRouts = $this->getIncludeRoutes(); + if (in_array($route, $includeRouts) && $this->isAuthed($Customer, 'mypage')) { + // 重要操作ルーティングの場合、 + $cookieName = $this->routeCookieName.'_'.$route; + $expire = $this->route_expire; + } else { + // デフォルトルーティングの場合、 + $cookieName = $this->cookieName; + } + + return $this->isRouteAuthed($Customer, $cookieName, $expire); + } + + return false; + } + + /** + * デフォルトルート・重要操作ルーティングは認証済みか + * データベースの中に保存しているデータとクッキー値を比較する + * + * @param Customer $Customer + * @param string $cookieName + * @param int $expire + * + * @return bool + */ + public function isRouteAuthed(Customer $Customer, string $cookieName, int $expire): bool + { + if ($json = $this->request->cookies->get($cookieName)) { + $configs = json_decode($json); + + /** @var PasskeysCustomerCookie[]|null $activeCookies */ + $activeCookies = $this + ->passkeysCustomerCookieRepository + ->searchForCookie($Customer, $cookieName); + + foreach ($activeCookies as $activeCookie) { + if ( + $configs + && isset($configs->{$Customer->getId()}) + && ($config = $configs->{$Customer->getId()}) + && property_exists($config, 'key') + && $config->key === $activeCookie->getCookieValue() + && ( + $this->expire == 0 + || (property_exists($config, 'date') && ($config->date && $config->date > date('U', strtotime('-'.$expire)))) + ) + ) { + return true; + } + } + } + + return false; + } + + /** + * 2段階認証用Cookie生成. + * クッキーデータをデータベースに保存する + * + * @param Customer $Customer + * @param string $cookieName + * @param int $expire + * + * @return mixed + */ + public function createRouteAuthCookie(Customer $Customer, string $cookieName, int $expire) + { + return $this->entityManager->wrapInTransaction(function (EntityManagerInterface $em) use ($expire, $cookieName, $Customer) { + $cookieData = $this->passkeysCustomerCookieRepository->generateCookieData( + $Customer, + $cookieName, + $expire, + $this->eccubeConfig->get('plugin_eccube_passkeys_route_cookie_value_character_length') + ); + + $configs = json_decode('{}'); + if ($json = $this->request->cookies->get($cookieName)) { + $configs = json_decode($json); + } + + $configs->{$Customer->getId()} = [ + 'key' => $cookieData->getCookieValue(), + 'date' => time(), + ]; + + $em->persist($cookieData); + $em->flush(); + + return new Cookie( + $cookieData->getCookieName(), // name + json_encode($configs), // value + $cookieData->getCookieExpireDate()->getTimestamp(), // expire + $this->request->getBasePath(), // path + null, // domain + $this->eccubeConfig->get('eccube_force_ssl') ? true : false, // secure + true, // httpOnly + false, // raw + $this->eccubeConfig->get('eccube_force_ssl') ? Cookie::SAMESITE_NONE : null // sameSite + ); + }); + } + + /** + * 二段階認証設定が有効か? + * + * @return bool + */ + public function isEnabled(): bool + { + return $this->baseInfo->isPasskeysUse(); + } + + /** + * Passkey認証に関係しているクッキーだけを消す + * + * @param Request $request + * @param Response $response + * + * @return void + */ + public function clearPKAuthCookies(Request $request, Response $response) + { + foreach ($request->cookies->all() as $key => $cookie) { + if ( + $this->str_contains($key, $this->cookieName) || + $this->str_contains($key, $this->routeCookieName) + ) { + // クッキーを消す + $response->headers->clearCookie($key); + } + } + } + + /** + * Passkey sessionをチェックする + * + * @param String $session_id + * + * @return bool + */ + public function checkSession($session_id, $rp) + { + //Post request to server using curl + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, 'https://fido2.amipro.me/usr/validsession');//'https://mac-air-m2.dqj-home.com/usr/validsession'); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); + //$json_body = '{"session": "'.$session_id.'", "rp": {"id": "'.$rp.'"}}'; + + $data = array( + 'session'=>$session_id, + 'rp'=> array( + 'id'=>$rp + ), + 'debug_src'=>'checkSession' + ); + $json_body = json_encode($data); + log_info('pk: checkSession json: ' . $json_body); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json_body);//json_encode(['session' => $session_id, 'rp' => ['id' => $rp] ])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + log_info('pk: checkSession req: ' . $session_id . '|' . $rp); + + $response = curl_exec($ch); + $status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + curl_close($ch); + + log_info('pk: checkSession resp: ' . $status_code . '|' . $response); + $resp = json_decode($response, true); + return $resp && $resp['status'] && $resp['status'] === 'ok'; + } + + + /*** + * @param string $haystack + * @param string $needle + * @return bool + * + * @deprecated ECCUBEの最低PHPバージョンは8.0になったら, この関数を消してphp8.0からのstr_containsを利用する + */ + private function str_contains(string $haystack, string $needle) + { + return $needle !== '' && mb_strpos($haystack, $needle) !== false; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4b5f660 --- /dev/null +++ b/composer.json @@ -0,0 +1,12 @@ +{ + "name": "ec-cube\/passkeys", + "version": "1.0.0", + "description": "Passkeys authentication for Customers EC-CUBE42", + "type": "eccube-plugin", + "extra": { + "code": "Passkeys" + }, + "require": { + "ec-cube\/plugin-installer": "^2.0" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..ebda498 --- /dev/null +++ b/composer.lock @@ -0,0 +1,55 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "7b8581d4ae9b39b858953fb3dca8af05", + "packages": [ + { + "name": "ec-cube/plugin-installer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/EC-CUBE/eccube-plugin-installer.git", + "reference": "2cb574d0fda477af98b6199ddcb99e1a2c7e228a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/EC-CUBE/eccube-plugin-installer/zipball/2cb574d0fda477af98b6199ddcb99e1a2c7e228a", + "reference": "2cb574d0fda477af98b6199ddcb99e1a2c7e228a", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Eccube\\Composer\\EccubePluginInstallerPlugin" + }, + "autoload": { + "psr-0": { + "Eccube": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "EC-CUBE plugin installer.", + "support": { + "source": "https://github.com/EC-CUBE/eccube-plugin-installer/tree/2.0.1" + }, + "time": "2021-07-20T01:13:11+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dd5996d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + ./Tests + + + + + + + + + ./ + + ./Tests + ./Resource + ./PluginManager.php + + + + + + + + + diff --git a/vendor/ec-cube/plugin-installer/.gitignore b/vendor/ec-cube/plugin-installer/.gitignore new file mode 100644 index 0000000..1439335 --- /dev/null +++ b/vendor/ec-cube/plugin-installer/.gitignore @@ -0,0 +1,4 @@ +composer.phar +/vendor/ +.idea +*.php~ diff --git a/vendor/ec-cube/plugin-installer/composer.json b/vendor/ec-cube/plugin-installer/composer.json new file mode 100644 index 0000000..65f10aa --- /dev/null +++ b/vendor/ec-cube/plugin-installer/composer.json @@ -0,0 +1,18 @@ +{ + "name": "ec-cube/plugin-installer", + "version": "2.0.1", + "type": "composer-plugin", + "description": "EC-CUBE plugin installer.", + "license": "MIT", + "autoload": { + "psr-0": { + "Eccube": "src/" + } + }, + "extra": { + "class": "Eccube\\Composer\\EccubePluginInstallerPlugin" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + } +} diff --git a/vendor/ec-cube/plugin-installer/src/Eccube/Composer/EccubePluginInstallerPlugin.php b/vendor/ec-cube/plugin-installer/src/Eccube/Composer/EccubePluginInstallerPlugin.php new file mode 100644 index 0000000..b046c0f --- /dev/null +++ b/vendor/ec-cube/plugin-installer/src/Eccube/Composer/EccubePluginInstallerPlugin.php @@ -0,0 +1,28 @@ +getInstallationManager()->addInstaller($installer); + } + public function deactivate(Composer $composer, IOInterface $io) + { + $installer = new PluginInstaller($io, $composer, self::TYPE); + $composer->getInstallationManager()->addInstaller($installer); + } + public function uninstall(Composer $composer, IOInterface $io) + { + } +} diff --git a/vendor/ec-cube/plugin-installer/src/Eccube/Composer/PluginInstaller.php b/vendor/ec-cube/plugin-installer/src/Eccube/Composer/PluginInstaller.php new file mode 100644 index 0000000..7f69635 --- /dev/null +++ b/vendor/ec-cube/plugin-installer/src/Eccube/Composer/PluginInstaller.php @@ -0,0 +1,165 @@ +getExtra(); + if (!isset($extra['code'])) { + throw new \RuntimeException('`extra.code` not found in '.$package->getName().'/composer.json'); + } + return "app/Plugin/".$extra['code']; + } + + /** + * {@inheritDoc} + */ + public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target) + { + $Promise = parent::update($repo, $initial, $target); + $this->addPluginIdToComposerJson($target); + + return $Promise; + } + + /** + * {@inheritDoc} + */ + public function install(InstalledRepositoryInterface $repo, PackageInterface $package) + { + if (!isset($GLOBALS['kernel'])) { + $message = 'You can not install the EC-CUBE plugin via `composer` command.'.PHP_EOL + .'Please use the `bin/console eccube:composer:require '.$package->getName().'` instead.'; + throw new \RuntimeException($message); + } + + /** @var Kernel $kernel */ + $kernel = $GLOBALS['kernel']; + $container = $kernel->getContainer(); + + $extra = $package->getExtra(); + $source = $extra['id']; + $code = $extra['code']; + $version = $package->getPrettyVersion(); + + $pluginRepository = $container->get('Eccube\Repository\PluginRepository'); + $Plugin = $pluginRepository->findOneBy([ + 'source' => $source, + 'code' => $code, + 'version' => $version + ]); + + // レコードがある場合はcomposer.jsonの更新のみ行う. + if ($Plugin) { + $Promise = parent::install($repo, $package); + + $this->addPluginIdToComposerJson($package); + + return $Promise; + } + + try { + + $Promise = parent::install($repo, $package); + + $this->addPluginIdToComposerJson($package); + + /** @var PluginService $pluginService */ + $pluginService = $container->get(PluginService::class); + $config = $pluginService->readConfig($this->getInstallPath($package)); + $Plugin = $pluginService->registerPlugin($config, $config['source']); + + return $Promise; + } catch (\Exception $e) { + + // 更新されたcomposer.jsonを戻す + parent::uninstall($repo, $package); + $fileName = $kernel->getProjectDir().DIRECTORY_SEPARATOR.'composer.json'; + $contents = file_get_contents($fileName); + $json = new JsonManipulator($contents); + $json->removeSubNode('require', $package->getPrettyName()); + file_put_contents($fileName, $json->getContents()); + + throw $e; + } + } + + private function addPluginIdToComposerJson(PackageInterface $package) + { + $extra = $package->getExtra(); + $id = @$extra['id']; + $composerPath = $this->getInstallPath($package).DIRECTORY_SEPARATOR.'composer.json'; + if (file_exists($composerPath)) { + $composerJson = json_decode(file_get_contents($composerPath), true); + $composerJson['extra']['id'] = $id; + file_put_contents($composerPath, json_encode($composerJson)); + } + } + + /** + * {@inheritDoc} + */ + public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $package) + { + if (!isset($GLOBALS['kernel'])) { + $message = 'You can not uninstall the EC-CUBE plugin via `composer` command.'.PHP_EOL + .'Please use the `bin/console eccube:composer:remove '.$package->getName().'` instead.'; + throw new \RuntimeException($message); + } + + $kernel = $GLOBALS['kernel']; + $container = $kernel->getContainer(); + + $extra = $package->getExtra(); + $code = $extra['code']; + + $pluginRepository = $container->get('Eccube\Repository\PluginRepository'); + $pluginService = $container->get('Eccube\Service\PluginService'); + + // 他のプラグインから依存されている場合はアンインストールできない + $enabledPlugins = $pluginRepository->findBy(['enabled' => Constant::ENABLED]); + foreach ($enabledPlugins as $p) { + if ($p->getCode() !== $code) { + $dir = 'app/Plugin/'.$p->getCode(); + $jsonText = @file_get_contents($dir.'/composer.json'); + if ($jsonText) { + $json = json_decode($jsonText, true); + if (array_key_exists('require', $json) + // see https://www.php.net/manual/ja/function.array-key-exists.php#92717 + && (in_array(strtolower('ec-cube/'.$code), array_map('strtolower', array_keys($json['require']))))) { + throw new \RuntimeException('このプラグインに依存しているプラグインがあるため削除できません。'.$p->getCode()); + } + } + } + } + + // 無効化していないとアンインストールできない + $id = @$extra['id']; + if ($id) { + $Plugin = $pluginRepository->findOneBy(['source' => $id]); + if ($Plugin && $Plugin->isEnabled()) { + throw new \RuntimeException('プラグインを無効化してください。'.$code); + } + if ($Plugin) { + $pluginService->uninstall($Plugin); + } + } + + return parent::uninstall($repo, $package); + } +}