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
+
+ {% else %}
+
TOTP is disabled
+
+ {% 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