diff --git a/code_examples/totp/_misc/totp-append-ConfigProvider.php b/code_examples/totp/_misc/totp-append-ConfigProvider.php new file mode 100644 index 0000000..44c4354 --- /dev/null +++ b/code_examples/totp/_misc/totp-append-ConfigProvider.php @@ -0,0 +1,11 @@ +GetEnableTotpFormHandler::class => AttributedServiceFactory::class, +PostEnableTotpHandler::class => AttributedServiceFactory::class, +GetDisableTotpFormHandler::class => AttributedServiceFactory::class, +PostDisableTotpHandler::class => AttributedServiceFactory::class, +PostValidateTotpHandler::class => AttributedServiceFactory::class, +GetTotpHandler::class => AttributedServiceFactory::class, +GetRecoveryFormHandler::class => AttributedServiceFactory::class, +PostValidateRecoveryHandler::class => AttributedServiceFactory::class, + +TotpForm::class => ElementFactory::class, +RecoveryForm::class => ElementFactory::class, diff --git a/code_examples/totp/_misc/totp-append-Pipeline.php b/code_examples/totp/_misc/totp-append-Pipeline.php new file mode 100644 index 0000000..ce07b19 --- /dev/null +++ b/code_examples/totp/_misc/totp-append-Pipeline.php @@ -0,0 +1,2 @@ +$app->pipe(TotpMiddleware::class); +$app->pipe(CancelUrlMiddleware::class); diff --git a/code_examples/totp/_misc/totp-append-routes.php b/code_examples/totp/_misc/totp-append-routes.php new file mode 100644 index 0000000..f3f0f4b --- /dev/null +++ b/code_examples/totp/_misc/totp-append-routes.php @@ -0,0 +1,8 @@ +->get('/enable-totp-form', GetEnableTotpFormHandler::class, 'admin::enable-totp-form') +->post('/enable-totp', PostEnableTotpHandler::class, 'admin::enable-totp') +->get('/disable-totp-form', GetDisableTotpFormHandler::class, 'admin::disable-totp-form') +->post('/disable-totp', PostDisableTotpHandler::class, 'admin::disable-totp') +->get('/validate-totp-form', GetTotpHandler::class, 'admin::validate-totp-form') +->post('/validate-totp', PostValidateTotpHandler::class, 'admin::validate-totp') +->get('/recovery-form', GetRecoveryFormHandler::class, 'admin::recovery-form') +->post('/validate-recovery', PostValidateRecoveryHandler::class, 'admin::validate-recovery') diff --git a/code_examples/totp/_misc/totp-append-view-account.html.twig b/code_examples/totp/_misc/totp-append-view-account.html.twig new file mode 100644 index 0000000..c25efd6 --- /dev/null +++ b/code_examples/totp/_misc/totp-append-view-account.html.twig @@ -0,0 +1,20 @@ +
+
TOTP Setup
+ {% if isTotpEnabled %} +
TOTP is enabled
+
+ + Disable TOTP + +
+ {% else %} +
TOTP is disabled
+
+ + Enable TOTP + +
+ {% endif %} +
diff --git a/code_examples/totp/config/autoload/totp.global.php b/code_examples/totp/config/autoload/totp.global.php new file mode 100644 index 0000000..3383c8a --- /dev/null +++ b/code_examples/totp/config/autoload/totp.global.php @@ -0,0 +1,28 @@ + [ + 'identity_class_map' => [ + AdminIdentity::class => Admin::class, + ], + 'totp_required_routes' => [ + 'page::components' => true, + ], + 'options' => [ + // Time step in seconds + 'period' => 30, + // Number of digits in the TOTP code + 'digits' => 6, + // Hashing algorithm used to generate the code + 'algorithm' => 'sha1', + ], + 'provision_uri_config' => [ + 'issuer' => 'DK-Admin', + ], + ], +]; diff --git a/code_examples/totp/src/Admin/src/Form/RecoveryForm.php b/code_examples/totp/src/Admin/src/Form/RecoveryForm.php new file mode 100644 index 0000000..9bdac91 --- /dev/null +++ b/code_examples/totp/src/Admin/src/Form/RecoveryForm.php @@ -0,0 +1,77 @@ + + */ +class RecoveryForm extends AbstractForm +{ + /** + * @param array $options + * @throws ExceptionInterface + */ + #[Inject( + RouterInterface::class, + )] + public function __construct(?string $name = null, array $options = []) + { + parent::__construct($name, $options); + + $this->init(); + + $this->setAttribute('id', 'recovery-form'); + $this->setAttribute('method', 'post'); + + $this->inputFilter = new RecoveryInputFilter(); + $this->inputFilter->init(); + } + + /** + * @throws ExceptionInterface + */ + public function init(): void + { + $this->add( + (new Text('recoveryCode')) + ->setLabel('Recovery Code') + ->setAttribute('class', 'form-control') + ->setAttribute('minlength', 11) + ->setAttribute('maxlength', 11) + ->setAttribute('pattern', '[A-Za-z0-9]{5}-[A-Za-z0-9]{5}') + ->setAttribute('required', true) + ->setAttribute('autofocus', true) + ); + + $this->add( + (new Csrf('recoveryCsrf')) + ->setOptions([ + 'csrf_options' => [ + 'timeout' => 3600, + 'session' => new Container(), + ], + ]) + ->setAttribute('required', true) + ); + + $this->add( + (new Submit('submit')) + ->setAttribute('type', 'submit') + ->setAttribute('value', 'Verify Code') + ->setAttribute('class', 'btn btn-primary mt-2') + ); + } +} diff --git a/code_examples/totp/src/Admin/src/Form/TotpForm.php b/code_examples/totp/src/Admin/src/Form/TotpForm.php new file mode 100644 index 0000000..e381768 --- /dev/null +++ b/code_examples/totp/src/Admin/src/Form/TotpForm.php @@ -0,0 +1,77 @@ + + */ +class TotpForm extends AbstractForm +{ + /** + * @param array $options + * @throws ExceptionInterface + */ + #[Inject( + RouterInterface::class, + )] + public function __construct(?string $name = null, array $options = []) + { + parent::__construct($name, $options); + + $this->init(); + + $this->setAttribute('id', 'enable-totp-form'); + $this->setAttribute('method', 'post'); + $this->setAttribute('title', 'TOTP Authentication Setup'); + + $this->inputFilter = new TotpInputFilter(); + $this->inputFilter->init(); + } + + /** + * @throws ExceptionInterface + */ + public function init(): void + { + $this->add( + (new Text('code')) + ->setLabel('Authentication Code') + ->setAttribute('class', 'form-control') + ->setAttribute('maxlength', 6) + ->setAttribute('pattern', '\d{6}') + ->setAttribute('required', true) + ->setAttribute('autofocus', true) + ); + + $this->add( + (new Csrf('totpCsrf')) + ->setOptions([ + 'csrf_options' => [ + 'timeout' => 3600, + 'session' => new Container(), + ], + ]) + ->setAttribute('required', true) + ); + + $this->add( + (new Submit('submit')) + ->setAttribute('type', 'submit') + ->setAttribute('value', 'Verify Code') + ->setAttribute('class', 'btn btn-primary mt-2') + ); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/GetDisableTotpFormHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/GetDisableTotpFormHandler.php new file mode 100644 index 0000000..dd0d846 --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/GetDisableTotpFormHandler.php @@ -0,0 +1,47 @@ +totpForm + ->setAttribute('action', $this->router->generateUri('admin::disable-totp')); + + return new HtmlResponse( + $this->template->render('admin::validate-totp-form', [ + 'totpForm' => $this->totpForm->prepare(), + 'cancelUrl' => $this->router->generateUri('admin::edit-account'), + 'error' => null, + ]) + ); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/GetEnableTotpFormHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/GetEnableTotpFormHandler.php new file mode 100644 index 0000000..61c1259 --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/GetEnableTotpFormHandler.php @@ -0,0 +1,93 @@ +authenticationService->getStorage()->read(); + + if ( + empty($storage->pendingSecret) || + empty($storage->secretTimestamp) || + (time() - $storage->secretTimestamp) > self::SECRET_MAX_AGE + ) { + $storage->pendingSecret = $this->totpService->generateSecretBase32(); + $storage->secretTimestamp = time(); + $this->authenticationService->getStorage()->write($storage); + } + + $uri = $this->totpService->getProvisioningUri( + $storage->getIdentity(), + $this->provisioningUri['issuer'], + $storage->pendingSecret + ); + + $qrSvg = $this->totpService->generateInlineSvgQr($uri); + $storage->totp_verified = false; + + if (isset($storage->recovery_auth) && $storage->recovery_auth) { + $this->totpForm->setAttribute('title', 'Reconfigure Two-Factor Authentication'); + } + + $this->totpForm->setAttribute('action', $this->router->generateUri('admin::enable-totp')); + + return new HtmlResponse( + $this->template->render('admin::validate-totp-form', [ + 'qrSvg' => $qrSvg, + 'cancelUrl' => $request->getAttribute('cancelUrl'), + 'totpForm' => $this->totpForm->prepare(), + 'error' => null, + ]) + ); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/GetRecoveryFormHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/GetRecoveryFormHandler.php new file mode 100644 index 0000000..c95cf68 --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/GetRecoveryFormHandler.php @@ -0,0 +1,43 @@ +recoveryForm + ->setAttribute('action', $this->router->generateUri('admin::validate-recovery')); + + return new HtmlResponse( + $this->template->render('admin::recovery-form', [ + 'recoveryForm' => $this->recoveryForm, + 'cancelUrl' => $request->getAttribute('cancelUrl'), + ]) + ); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/GetTotpHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/GetTotpHandler.php new file mode 100644 index 0000000..6a89c3c --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/GetTotpHandler.php @@ -0,0 +1,44 @@ +totpForm + ->setAttribute('action', $this->router->generateUri('admin::validate-totp')); + + return new HtmlResponse( + $this->template->render('admin::validate-totp-form', [ + 'totpForm' => $this->totpForm->prepare(), + 'cancelUrl' => $request->getAttribute('cancelUrl'), + 'error' => null, + ]) + ); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/PostDisableTotpHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/PostDisableTotpHandler.php new file mode 100644 index 0000000..ddfa1b0 --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/PostDisableTotpHandler.php @@ -0,0 +1,78 @@ +adminService->findAdmin($this->authenticationService->getIdentity()->getId()); + } catch (NotFoundException $exception) { + $this->messenger->addError($exception->getMessage()); + + return new EmptyResponse(StatusCodeInterface::STATUS_NOT_FOUND); + } + + $parsedBody = $request->getParsedBody(); + $storage = $this->authenticationService->getStorage()->read(); + + if (! is_array($parsedBody) || ! isset($parsedBody['code'])) { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + return new RedirectResponse( + $this->router->generateUri('admin::validate-totp-form') + ); + } + + $code = (string) $parsedBody['code']; + + if ($this->totpService->verifyCode((string) $admin->getTotpSecret(), $code)) { + $admin->disableTotp(); + $this->adminService->getAdminRepository()->saveResource($admin); + $storage->totp_verified = false; + return new RedirectResponse($this->router->generateUri('admin::edit-account')); + } else { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + } + + return new RedirectResponse($this->router->generateUri('admin::disable-totp-form')); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/PostEnableTotpHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/PostEnableTotpHandler.php new file mode 100644 index 0000000..dd9652b --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/PostEnableTotpHandler.php @@ -0,0 +1,105 @@ +adminService->findAdmin($this->authenticationService->getIdentity()->getId()); + } catch (NotFoundException $exception) { + $this->messenger->addError($exception->getMessage()); + + return new EmptyResponse(StatusCodeInterface::STATUS_NOT_FOUND); + } + + $parsedBody = $request->getParsedBody(); + $storage = $this->authenticationService->getStorage()->read(); + + if (! is_array($parsedBody) || ! isset($parsedBody['code'])) { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + return new RedirectResponse( + $this->router->generateUri('admin::validate-totp-form') + ); + } + + $code = (string) $parsedBody['code']; + + if ($admin->isTotpEnabled()) { + $pendingSecret = $admin->getTotpSecret(); + } else { + $pendingSecret = $storage->pendingSecret; + } + + if ($this->totpService->verifyCode($pendingSecret, $code)) { + $recoveryCodes = $this->totpService->generateRecoveryCodes(); + $admin->enableTotp($pendingSecret); + + $hashedRecoveryCodes = $this->totpService->hashRecoveryCodes($recoveryCodes); + $admin->setRecoveryCodes($hashedRecoveryCodes); + + $this->adminService->getAdminRepository()->saveResource($admin); + $storage->totp_verified = true; + if (isset($storage->recovery_auth)) { + unset($storage->recovery_auth); + } + + return new HtmlResponse($this->template->render('admin::list-recovery-codes', [ + 'totpForm' => $this->form, + 'plainCodes' => $recoveryCodes, + ])); + } else { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + } + + return new RedirectResponse($this->router->generateUri('admin::enable-totp-form')); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/PostValidateRecoveryHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/PostValidateRecoveryHandler.php new file mode 100644 index 0000000..f50075c --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/PostValidateRecoveryHandler.php @@ -0,0 +1,82 @@ +adminService->findAdmin($this->authenticationService->getIdentity()->getId()); + } catch (NotFoundException $exception) { + $this->messenger->addError($exception->getMessage()); + + return new EmptyResponse(StatusCodeInterface::STATUS_NOT_FOUND); + } + + $parsedBody = $request->getParsedBody(); + $storage = $this->authenticationService->getStorage()->read(); + + if (! is_array($parsedBody) || ! isset($parsedBody['recoveryCode'])) { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + return new RedirectResponse( + $this->router->generateUri('admin::recovery-form') + ); + } + + $recoveryCode = (string) $parsedBody['recoveryCode']; + $hashedCodes = $admin->getRecoveryCodes() ?? []; + + if ($this->totpService->validateRecoveryCode($recoveryCode, $hashedCodes)) { + $admin->setRecoveryCodes(array_values($hashedCodes)); + $admin->disableTotp(); + $storage->recovery_auth = true; + + $this->adminService->getAdminRepository()->saveResource($admin); + return new RedirectResponse($this->router->generateUri('admin::enable-totp-form')); + } else { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + } + + return new RedirectResponse($this->router->generateUri('admin::validate-totp-form')); + } +} diff --git a/code_examples/totp/src/Admin/src/Handler/Account/PostValidateTotpHandler.php b/code_examples/totp/src/Admin/src/Handler/Account/PostValidateTotpHandler.php new file mode 100644 index 0000000..6b03745 --- /dev/null +++ b/code_examples/totp/src/Admin/src/Handler/Account/PostValidateTotpHandler.php @@ -0,0 +1,78 @@ +adminService->findAdmin($this->authenticationService->getIdentity()->getId()); + } catch (NotFoundException $exception) { + $this->messenger->addError($exception->getMessage()); + + return new EmptyResponse(StatusCodeInterface::STATUS_NOT_FOUND); + } + + $parsedBody = $request->getParsedBody(); + $storage = $this->authenticationService->getStorage()->read(); + + if (! is_array($parsedBody) || ! isset($parsedBody['code'])) { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + return new RedirectResponse( + $this->router->generateUri('admin::validate-totp-form') + ); + } + + $code = (string) $parsedBody['code']; + + if ($this->totpService->verifyCode((string) $admin->getTotpSecret(), $code)) { + $this->adminService->getAdminRepository()->saveResource($admin); + $storage->totp_verified = true; + + return new RedirectResponse($this->router->generateUri($storage->route ?? 'dashboard::view-dashboard')); + } else { + $this->messenger->addError(Message::VALIDATOR_INVALID_CODE); + } + + return new RedirectResponse($this->router->generateUri('admin::validate-totp-form')); + } +} diff --git a/code_examples/totp/src/Admin/templates/admin/recovery-form.html.twig b/code_examples/totp/src/Admin/templates/admin/recovery-form.html.twig new file mode 100644 index 0000000..7a7a293 --- /dev/null +++ b/code_examples/totp/src/Admin/templates/admin/recovery-form.html.twig @@ -0,0 +1,29 @@ + + + + + + +{{ messagesPartial('partial::notifications', {dismissible: true}) }} +
+
+
+

Recovery code

+ + {{ form().openTag(recoveryForm)|raw }} + + {{ formElement(recoveryForm.get('recoveryCode')) }} + {{ formElement(recoveryForm.get('recoveryCsrf')) }} + +
+ {{ formElement(recoveryForm.get('submit')) }} + Cancel +
+ + {{ form().closeTag()|raw }} +
+
+
+ + + diff --git a/code_examples/totp/src/Admin/templates/admin/validate-totp-form.html.twig b/code_examples/totp/src/Admin/templates/admin/validate-totp-form.html.twig new file mode 100644 index 0000000..366e4dd --- /dev/null +++ b/code_examples/totp/src/Admin/templates/admin/validate-totp-form.html.twig @@ -0,0 +1,44 @@ + + + + + + +{{ messagesPartial('partial::notifications', {dismissible: true}) }} +
+
+
+

{{ totpForm.getAttribute('title') }}

+ + {% if qrSvg is defined %} +
+ {{ qrSvg|raw }} +
+ +

+ Scan the QR code with your authenticator app and enter the 6-digit code below. +

+ {% endif %} + + {{ form().openTag(totpForm)|raw }} + + {{ formElement(totpForm.get('code')) }} + {{ formElement(totpForm.get('totpCsrf')) }} + +
+ {{ formElement(totpForm.get('submit')) }} + + {% if qrSvg is not defined %} + Recovery + {% endif %} + + Cancel +
+ + {{ form().closeTag()|raw }} +
+
+
+ + + diff --git a/code_examples/totp/src/App/src/Middleware/CancelUrlMiddleware.php b/code_examples/totp/src/App/src/Middleware/CancelUrlMiddleware.php new file mode 100644 index 0000000..8c0649c --- /dev/null +++ b/code_examples/totp/src/App/src/Middleware/CancelUrlMiddleware.php @@ -0,0 +1,53 @@ +router->generateUri('admin::login-admin-form'); + + $storage = $this->authService->getStorage()->read(); + + if (isset($storage->recovery_auth) || isset($storage->totp_verified)) { + $referer = $request->getHeaderLine('Referer'); + + if (str_contains($referer, $this->router->generateUri('admin::recovery-form'))) { + $cancelUrl = $this->router->generateUri('admin::edit-account-form'); + } else { + $cancelUrl = $referer; + } + } + + return $handler->handle( + $request->withAttribute('cancelUrl', $cancelUrl) + ); + } +} diff --git a/code_examples/totp/src/App/src/Middleware/TotpMiddleware.php b/code_examples/totp/src/App/src/Middleware/TotpMiddleware.php new file mode 100644 index 0000000..3ce1d34 --- /dev/null +++ b/code_examples/totp/src/App/src/Middleware/TotpMiddleware.php @@ -0,0 +1,110 @@ + */ + private array $routes; + + /** + * @param array $config + */ + public function __construct( + protected AuthenticationService $authService, + protected EntityManagerInterface $entityManager, + protected RouterInterface $router, + protected array $config + ) { + $this->routes = $config['dot_totp']['totp_required_routes'] ?? []; + } + + /** + * @throws OptimisticLockException + * @throws ORMException + * @throws ExceptionInterface + */ + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): responseInterface|RedirectResponse { + $storage = $this->authService->getStorage()->read(); + /** @var AdminIdentity $identity */ + $identity = $this->authService->getIdentity(); + $routeResult = $request->getAttribute(RouteResult::class); + $routeName = $routeResult->getMatchedRouteName(); + + $excludedRoutes = [ + 'admin::validate-totp-form', + 'admin::validate-totp', + 'admin::enable-totp-form', + 'admin::logout-admin', + 'admin::recovery-form', + 'admin::validate-recovery', + 'admin::login-admin-form', + ]; + + if ( + in_array($routeName, $excludedRoutes, true) || + (isset($storage->totp_verified) && $storage->totp_verified) + ) { + return $handler->handle($request); + } + + if ($identity instanceof UserInterface) { + $type = $identity::class; + $map = $this->config['dot_totp']['identity_class_map']; + + if (! isset($map[$type])) { + throw new RuntimeException("No identity_class configured for type $type"); + } + + /** @var class-string $entityClass */ + $entityClass = $map[$type]; + /** @var Admin|null $entity */ + $entity = $this->entityManager->find($entityClass, $identity->id); + + if ($entity === null) { + throw new RuntimeException("Entity of type $entityClass with ID {$identity->id} not found"); + } + + if (! isset($storage->totp_verified) && $entity->isTotpEnabled()) { + return new RedirectResponse($this->router->generateUri('admin::validate-totp-form')); + } + + if (! isset($this->routes[$routeName]) || ! $this->routes[$routeName]) { + return $handler->handle($request); + } + + $storage->route = $routeName; + if ($entity->isTotpEnabled()) { + return new RedirectResponse($this->router->generateUri('admin::validate-totp-form')); + } else { + return new RedirectResponse($this->router->generateUri('admin::enable-totp-form')); + } + } + + return $handler->handle($request); + } +} diff --git a/code_examples/totp/src/Core/src/App/src/Entity/TotpTrait.php b/code_examples/totp/src/Core/src/App/src/Entity/TotpTrait.php new file mode 100644 index 0000000..989be2a --- /dev/null +++ b/code_examples/totp/src/Core/src/App/src/Entity/TotpTrait.php @@ -0,0 +1,64 @@ + false])] + protected bool $totpEnabled = false; + + /** @var string[]|null */ + #[ORM\Column(name: 'recovery_codes', type: Types::JSON, nullable: true)] + protected ?array $recoveryCodes = null; + + public function enableTotp(string $secret): self + { + $this->totpSecret = $secret; + $this->totpEnabled = true; + + return $this; + } + + public function disableTotp(): self + { + $this->totpSecret = null; + $this->totpEnabled = false; + $this->recoveryCodes = null; + + return $this; + } + + public function isTotpEnabled(): bool + { + return $this->totpEnabled; + } + + public function getTotpSecret(): ?string + { + return $this->totpSecret; + } + + /** + * @param string[]|null $recoveryCodes + */ + public function setRecoveryCodes(?array $recoveryCodes = null): void + { + $this->recoveryCodes = $recoveryCodes; + } + + /** + * @return string[]|null + */ + public function getRecoveryCodes(): ?array + { + return $this->recoveryCodes; + } +}