From 7488e1bc2427d0a936197623f9e5050461295a71 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 14:03:32 -0400 Subject: [PATCH 01/13] Restore front-end MFA support --- src/auth/methods/AuthMethodInterface.php | 7 +++++++ src/auth/methods/BaseAuthMethod.php | 8 ++++++++ src/auth/methods/RecoveryCodes.php | 5 +++-- src/auth/methods/TOTP.php | 15 ++++++++++++++- src/controllers/AuthController.php | 1 - .../auth/methods/RecoveryCodes/form.twig | 9 ++++++++- .../_components/auth/methods/TOTP/form.twig | 9 ++++++++- 7 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/auth/methods/AuthMethodInterface.php b/src/auth/methods/AuthMethodInterface.php index 3083fe1ab4c..ccf7ff973e4 100644 --- a/src/auth/methods/AuthMethodInterface.php +++ b/src/auth/methods/AuthMethodInterface.php @@ -66,6 +66,13 @@ public function isActive(): bool; */ public function getSetupHtml(string $containerId): string; + /** + * Returns the data needed to render the authentication method’s setup form. + * + * @return array + */ + public function getSetupData(): array; + /** * Returns the HTML for the authentication method’s authentication form. * diff --git a/src/auth/methods/BaseAuthMethod.php b/src/auth/methods/BaseAuthMethod.php index 2fefad88130..8c60538eed6 100644 --- a/src/auth/methods/BaseAuthMethod.php +++ b/src/auth/methods/BaseAuthMethod.php @@ -23,6 +23,14 @@ abstract class BaseAuthMethod extends Component implements AuthMethodInterface */ protected User $user; + /** + * @inheritdoc + */ + public function getSetupData(): array + { + return []; + } + /** * @inheritdoc */ diff --git a/src/auth/methods/RecoveryCodes.php b/src/auth/methods/RecoveryCodes.php index ccdb695538c..d1eb470b0c2 100644 --- a/src/auth/methods/RecoveryCodes.php +++ b/src/auth/methods/RecoveryCodes.php @@ -13,6 +13,7 @@ use craft\helpers\Json; use craft\records\RecoveryCodes as RecoveryCodesRecord; use craft\web\assets\recoverycodes\RecoveryCodesAsset; +use craft\web\View; use DateTime; use PragmaRX\Recovery\Recovery; use yii\base\InvalidArgumentException; @@ -62,7 +63,7 @@ public function getSetupHtml(string $containerId): string new Craft.RecoveryCodesSetup($containerId); JS, [$containerId]); - return $view->renderTemplate('_components/auth/methods/RecoveryCodes/setup.twig'); + return $view->renderTemplate('_components/auth/methods/RecoveryCodes/setup.twig', $this->getSetupData(), View::TEMPLATE_MODE_CP); } /** @@ -72,7 +73,7 @@ public function getAuthFormHtml(): string { $view = Craft::$app->getView(); $view->registerAssetBundle(RecoveryCodesAsset::class); - return $view->renderTemplate('_components/auth/methods/RecoveryCodes/form.twig'); + return $view->renderTemplate('_components/auth/methods/RecoveryCodes/form.twig', [], View::TEMPLATE_MODE_CP); } /** diff --git a/src/auth/methods/TOTP.php b/src/auth/methods/TOTP.php index 8fe50a1fd84..dca22d159f5 100644 --- a/src/auth/methods/TOTP.php +++ b/src/auth/methods/TOTP.php @@ -73,6 +73,19 @@ public function isActive(): bool return self::secretFromDb($this->user->id) !== null; } + /** + * @inheritdoc + */ + public function getSetupData(): array + { + $secret = $this->secret(); + + return [ + 'secret' => trim($secret), + 'qrCode' => $this->generateQrCode($secret), + ]; + } + /** * @inheritdoc */ @@ -108,7 +121,7 @@ public function getAuthFormHtml(): string { $view = Craft::$app->getView(); $view->registerAssetBundle(TotpAsset::class); - return $view->renderTemplate('_components/auth/methods/TOTP/form.twig'); + return $view->renderTemplate('_components/auth/methods/TOTP/form.twig', [], View::TEMPLATE_MODE_CP); } /** diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 62b9cbbda2c..0e7ab4040bf 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -105,7 +105,6 @@ public function actionMethodListingHtml(): ?Response */ public function actionRemoveMethod(): ?Response { - $this->requireCpRequest(); $this->requirePostRequest(); $this->requireElevatedSession(); diff --git a/src/templates/_components/auth/methods/RecoveryCodes/form.twig b/src/templates/_components/auth/methods/RecoveryCodes/form.twig index d6a257da86f..dfdd1f40b41 100644 --- a/src/templates/_components/auth/methods/RecoveryCodes/form.twig +++ b/src/templates/_components/auth/methods/RecoveryCodes/form.twig @@ -2,7 +2,13 @@ {% set formId = formId ?? "recovery-codes-form-#{random()}" %} -
+ + {{ actionInput('auth/verify-recovery-code') }} + + {% if craft.app.config.general.enableCsrfProtection %} + {{ csrfInput() }} + {% endif %} + {% embed '_includes/forms/field.twig' with { fieldClass: 'first', label: 'Recovery Code'|t('app'), @@ -12,6 +18,7 @@
{{ forms.text({ class: 'code auth-recovery-code', + name: 'code', maxlength: 13, }) }} {{ forms.submitButton({ diff --git a/src/templates/_components/auth/methods/TOTP/form.twig b/src/templates/_components/auth/methods/TOTP/form.twig index fa7375c3119..91e9f9dcb03 100644 --- a/src/templates/_components/auth/methods/TOTP/form.twig +++ b/src/templates/_components/auth/methods/TOTP/form.twig @@ -2,7 +2,13 @@ {% set formId = formId ?? "totp-form-#{random()}" %} - + + {{ actionInput('auth/verify-totp') }} + + {% if craft.app.config.general.enableCsrfProtection %} + {{ csrfInput() }} + {% endif %} + {% embed '_includes/forms/field.twig' with { fieldClass: 'first', label: 'Verification Code'|t('app'), @@ -14,6 +20,7 @@ {{ forms.text({ class: 'code auth-totp-code', id: 'verification-code', + name: 'code', autocomplete: 'one-time-code', maxlength: 6, }) }} From 67f25f867ab5954059c5658d34b4c42a0ccd0baf Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 14:12:12 -0400 Subject: [PATCH 02/13] Trim front-end MFA port --- src/auth/methods/RecoveryCodes.php | 5 ++--- src/auth/methods/TOTP.php | 2 +- .../_components/auth/methods/RecoveryCodes/form.twig | 9 +-------- src/templates/_components/auth/methods/TOTP/form.twig | 9 +-------- 4 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/auth/methods/RecoveryCodes.php b/src/auth/methods/RecoveryCodes.php index d1eb470b0c2..ccdb695538c 100644 --- a/src/auth/methods/RecoveryCodes.php +++ b/src/auth/methods/RecoveryCodes.php @@ -13,7 +13,6 @@ use craft\helpers\Json; use craft\records\RecoveryCodes as RecoveryCodesRecord; use craft\web\assets\recoverycodes\RecoveryCodesAsset; -use craft\web\View; use DateTime; use PragmaRX\Recovery\Recovery; use yii\base\InvalidArgumentException; @@ -63,7 +62,7 @@ public function getSetupHtml(string $containerId): string new Craft.RecoveryCodesSetup($containerId); JS, [$containerId]); - return $view->renderTemplate('_components/auth/methods/RecoveryCodes/setup.twig', $this->getSetupData(), View::TEMPLATE_MODE_CP); + return $view->renderTemplate('_components/auth/methods/RecoveryCodes/setup.twig'); } /** @@ -73,7 +72,7 @@ public function getAuthFormHtml(): string { $view = Craft::$app->getView(); $view->registerAssetBundle(RecoveryCodesAsset::class); - return $view->renderTemplate('_components/auth/methods/RecoveryCodes/form.twig', [], View::TEMPLATE_MODE_CP); + return $view->renderTemplate('_components/auth/methods/RecoveryCodes/form.twig'); } /** diff --git a/src/auth/methods/TOTP.php b/src/auth/methods/TOTP.php index dca22d159f5..6c3656500b1 100644 --- a/src/auth/methods/TOTP.php +++ b/src/auth/methods/TOTP.php @@ -121,7 +121,7 @@ public function getAuthFormHtml(): string { $view = Craft::$app->getView(); $view->registerAssetBundle(TotpAsset::class); - return $view->renderTemplate('_components/auth/methods/TOTP/form.twig', [], View::TEMPLATE_MODE_CP); + return $view->renderTemplate('_components/auth/methods/TOTP/form.twig'); } /** diff --git a/src/templates/_components/auth/methods/RecoveryCodes/form.twig b/src/templates/_components/auth/methods/RecoveryCodes/form.twig index dfdd1f40b41..d6a257da86f 100644 --- a/src/templates/_components/auth/methods/RecoveryCodes/form.twig +++ b/src/templates/_components/auth/methods/RecoveryCodes/form.twig @@ -2,13 +2,7 @@ {% set formId = formId ?? "recovery-codes-form-#{random()}" %} - - {{ actionInput('auth/verify-recovery-code') }} - - {% if craft.app.config.general.enableCsrfProtection %} - {{ csrfInput() }} - {% endif %} - + {% embed '_includes/forms/field.twig' with { fieldClass: 'first', label: 'Recovery Code'|t('app'), @@ -18,7 +12,6 @@
{{ forms.text({ class: 'code auth-recovery-code', - name: 'code', maxlength: 13, }) }} {{ forms.submitButton({ diff --git a/src/templates/_components/auth/methods/TOTP/form.twig b/src/templates/_components/auth/methods/TOTP/form.twig index 91e9f9dcb03..fa7375c3119 100644 --- a/src/templates/_components/auth/methods/TOTP/form.twig +++ b/src/templates/_components/auth/methods/TOTP/form.twig @@ -2,13 +2,7 @@ {% set formId = formId ?? "totp-form-#{random()}" %} - - {{ actionInput('auth/verify-totp') }} - - {% if craft.app.config.general.enableCsrfProtection %} - {{ csrfInput() }} - {% endif %} - + {% embed '_includes/forms/field.twig' with { fieldClass: 'first', label: 'Verification Code'|t('app'), @@ -20,7 +14,6 @@ {{ forms.text({ class: 'code auth-totp-code', id: 'verification-code', - name: 'code', autocomplete: 'one-time-code', maxlength: 6, }) }} From 0adfd51a56b139434c61dad9c67b3422b8b71bce Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 14:26:07 -0400 Subject: [PATCH 03/13] Add auth method data endpoints --- src/controllers/AuthController.php | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 0e7ab4040bf..65ae5d557b3 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -8,6 +8,7 @@ namespace craft\controllers; use Craft; +use craft\auth\methods\AuthMethodInterface; use craft\auth\methods\RecoveryCodes; use craft\auth\methods\TOTP; use craft\helpers\Html; @@ -96,6 +97,40 @@ public function actionMethodListingHtml(): ?Response ]); } + /** + * Returns the data for available authentication method listings. + * + * @return Response + */ + public function actionMethodListingData(): Response + { + $this->requireAcceptsJson(); + + return $this->asJson([ + 'methods' => array_map( + fn(AuthMethodInterface $method) => $this->authMethodData($method), + Craft::$app->getAuth()->getAvailableMethods(), + ), + ]); + } + + /** + * Returns the data needed to set up an authentication method. + * + * @return Response + */ + public function actionMethodSetupData(): Response + { + $this->requirePostRequest(); + $this->requireAcceptsJson(); + $this->requireElevatedSession(); + + $class = $this->request->getRequiredBodyParam('method'); + $method = Craft::$app->getAuth()->getMethod($class); + + return $this->asJson($method->getSetupData()); + } + /** * Remove auth type setup (for 2FA or Passkeys) from the database * @@ -262,6 +297,17 @@ private function passkeyTableHtml(): string ]); } + private function authMethodData(AuthMethodInterface $method): array + { + return [ + 'class' => $method::class, + 'name' => $method::displayName(), + 'description' => $method::description(), + 'actionMenuItems' => $method->getActionMenuItems(), + 'isActive' => $method->isActive(), + ]; + } + /** * Generates new recovery codes. * From ae8184d8781a00217f1c5329c8e777a83bf52342 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 14:31:46 -0400 Subject: [PATCH 04/13] Rename auth method data actions --- src/controllers/AuthController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 65ae5d557b3..19ddfb8d5d0 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -98,11 +98,11 @@ public function actionMethodListingHtml(): ?Response } /** - * Returns the data for available authentication method listings. + * Returns the data for available authentication methods. * * @return Response */ - public function actionMethodListingData(): Response + public function actionGetMethods(): Response { $this->requireAcceptsJson(); @@ -119,7 +119,7 @@ public function actionMethodListingData(): Response * * @return Response */ - public function actionMethodSetupData(): Response + public function actionMethodData(): Response { $this->requirePostRequest(); $this->requireAcceptsJson(); From 6db3aa24c36d819cfc1dda21f53cb00952e74015 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 14:36:21 -0400 Subject: [PATCH 05/13] Match auth setup data action name --- src/controllers/AuthController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 19ddfb8d5d0..ff3eeb48071 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -119,7 +119,7 @@ public function actionGetMethods(): Response * * @return Response */ - public function actionMethodData(): Response + public function actionMethodSetupData(): Response { $this->requirePostRequest(); $this->requireAcceptsJson(); From c2526173ec1c91bca68c24651543b949279f7d15 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:18:34 -0400 Subject: [PATCH 06/13] Avoid requiring setup data on auth methods --- src/auth/methods/AuthMethodInterface.php | 7 ------- src/auth/methods/BaseAuthMethod.php | 4 +++- src/controllers/AuthController.php | 4 +++- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/auth/methods/AuthMethodInterface.php b/src/auth/methods/AuthMethodInterface.php index ccf7ff973e4..3083fe1ab4c 100644 --- a/src/auth/methods/AuthMethodInterface.php +++ b/src/auth/methods/AuthMethodInterface.php @@ -66,13 +66,6 @@ public function isActive(): bool; */ public function getSetupHtml(string $containerId): string; - /** - * Returns the data needed to render the authentication method’s setup form. - * - * @return array - */ - public function getSetupData(): array; - /** * Returns the HTML for the authentication method’s authentication form. * diff --git a/src/auth/methods/BaseAuthMethod.php b/src/auth/methods/BaseAuthMethod.php index 8c60538eed6..228ee3856ad 100644 --- a/src/auth/methods/BaseAuthMethod.php +++ b/src/auth/methods/BaseAuthMethod.php @@ -24,7 +24,9 @@ abstract class BaseAuthMethod extends Component implements AuthMethodInterface protected User $user; /** - * @inheritdoc + * Returns the data needed to render the authentication method’s setup form. + * + * @return array */ public function getSetupData(): array { diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index ff3eeb48071..28e10546549 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -128,7 +128,9 @@ public function actionMethodSetupData(): Response $class = $this->request->getRequiredBodyParam('method'); $method = Craft::$app->getAuth()->getMethod($class); - return $this->asJson($method->getSetupData()); + return $this->asJson( + is_callable([$method, 'getSetupData']) ? $method->getSetupData() : [], + ); } /** From ad8cfd42b6a51137baa6d1e04c6fa12af3587bb2 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:22:07 -0400 Subject: [PATCH 07/13] Clarify available auth methods action --- src/controllers/AuthController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 28e10546549..d57ac3603fd 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -102,7 +102,7 @@ public function actionMethodListingHtml(): ?Response * * @return Response */ - public function actionGetMethods(): Response + public function actionGetAvailableMethods(): Response { $this->requireAcceptsJson(); From d98bba7f2f92f0e9663345143786847b20b9d042 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:23:26 -0400 Subject: [PATCH 08/13] Simplify auth setup action name --- src/controllers/AuthController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index d57ac3603fd..18a6568f80b 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -119,7 +119,7 @@ public function actionGetAvailableMethods(): Response * * @return Response */ - public function actionMethodSetupData(): Response + public function actionSetup(): Response { $this->requirePostRequest(); $this->requireAcceptsJson(); From 2a17097d6817c8b1d53a18522958756912341527 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:27:13 -0400 Subject: [PATCH 09/13] Rename auth setup data action --- src/controllers/AuthController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 18a6568f80b..ab1d74a3cc1 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -119,7 +119,7 @@ public function actionGetAvailableMethods(): Response * * @return Response */ - public function actionSetup(): Response + public function actionGetSetupData(): Response { $this->requirePostRequest(); $this->requireAcceptsJson(); From 405c4eb4f7b7a5237540eff771a775e626756226 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:33:18 -0400 Subject: [PATCH 10/13] Limit auth setup data to base methods --- src/auth/methods/BaseAuthMethod.php | 3 +++ src/controllers/AuthController.php | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/auth/methods/BaseAuthMethod.php b/src/auth/methods/BaseAuthMethod.php index 228ee3856ad..009d3f6a8a5 100644 --- a/src/auth/methods/BaseAuthMethod.php +++ b/src/auth/methods/BaseAuthMethod.php @@ -26,6 +26,9 @@ abstract class BaseAuthMethod extends Component implements AuthMethodInterface /** * Returns the data needed to render the authentication method’s setup form. * + * This should be part of AuthMethodInterface, but remains here for 5.x BC + * with auth methods that implement the interface directly. + * * @return array */ public function getSetupData(): array diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index ab1d74a3cc1..d84ac801096 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -9,6 +9,7 @@ use Craft; use craft\auth\methods\AuthMethodInterface; +use craft\auth\methods\BaseAuthMethod; use craft\auth\methods\RecoveryCodes; use craft\auth\methods\TOTP; use craft\helpers\Html; @@ -128,9 +129,7 @@ public function actionGetSetupData(): Response $class = $this->request->getRequiredBodyParam('method'); $method = Craft::$app->getAuth()->getMethod($class); - return $this->asJson( - is_callable([$method, 'getSetupData']) ? $method->getSetupData() : [], - ); + return $this->asJson($method instanceof BaseAuthMethod ? $method->getSetupData() : []); } /** From 1b9d0c952e0a4d976710689248bd835da4da5e87 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:43:12 -0400 Subject: [PATCH 11/13] Use consistent TOTP setup secret --- src/auth/methods/TOTP.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth/methods/TOTP.php b/src/auth/methods/TOTP.php index 6c3656500b1..cbed63af9aa 100644 --- a/src/auth/methods/TOTP.php +++ b/src/auth/methods/TOTP.php @@ -78,10 +78,10 @@ public function isActive(): bool */ public function getSetupData(): array { - $secret = $this->secret(); + $secret = trim($this->secret()); return [ - 'secret' => trim($secret), + 'secret' => $secret, 'qrCode' => $this->generateQrCode($secret), ]; } From 56f2735969e4f0a170dcaa48f65389e79f9f5bff Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:49:41 -0400 Subject: [PATCH 12/13] Require POST for auth methods data --- src/controllers/AuthController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index d84ac801096..a0a56f21ae9 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -105,6 +105,7 @@ public function actionMethodListingHtml(): ?Response */ public function actionGetAvailableMethods(): Response { + $this->requirePostRequest(); $this->requireAcceptsJson(); return $this->asJson([ From 5ab77e97db4297baa84f3ef1eccccbe17e329357 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 30 Jun 2026 15:52:19 -0400 Subject: [PATCH 13/13] Handle invalid auth method requests --- src/controllers/AuthController.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index a0a56f21ae9..59fb3e07ead 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -17,6 +17,7 @@ use craft\i18n\Locale; use craft\web\Controller; use craft\web\View; +use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; use yii\web\Response; @@ -128,7 +129,11 @@ public function actionGetSetupData(): Response $this->requireElevatedSession(); $class = $this->request->getRequiredBodyParam('method'); - $method = Craft::$app->getAuth()->getMethod($class); + try { + $method = Craft::$app->getAuth()->getMethod($class); + } catch (InvalidArgumentException $e) { + return $this->asFailure($e->getMessage()); + } return $this->asJson($method instanceof BaseAuthMethod ? $method->getSetupData() : []); } @@ -143,12 +148,18 @@ public function actionGetSetupData(): Response public function actionRemoveMethod(): ?Response { $this->requirePostRequest(); + $this->requireAcceptsJson(); $this->requireElevatedSession(); $methodClass = $this->request->getRequiredBodyParam('method'); $auth = Craft::$app->getAuth(); - $method = $auth->getMethod($methodClass); + try { + $method = $auth->getMethod($methodClass); + } catch (InvalidArgumentException $e) { + return $this->asFailure($e->getMessage()); + } + $method->remove(); // if that was the last non-Recovery Codes method, remove Recovery Codes too