Support disabling the multi-factor authentication
authorTim Düsterhus <duesterhus@woltlab.com>
Fri, 20 Nov 2020 14:25:47 +0000 (15:25 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Fri, 20 Nov 2020 14:30:42 +0000 (15:30 +0100)
com.woltlab.wcf/page.xml
com.woltlab.wcf/templates/__multifactorDisableExplanation.tpl [new file with mode: 0644]
com.woltlab.wcf/templates/accountSecurity.tpl
com.woltlab.wcf/templates/multifactorDisable.tpl [new file with mode: 0644]
com.woltlab.wcf/templates/multifactorManage.tpl
wcfsetup/install/files/lib/form/MultifactorDisableForm.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/form/MultifactorManageForm.class.php
wcfsetup/install/files/lib/system/user/multifactor/Setup.class.php
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml

index e6b3932403ab1e58beb83d9b180b593bb816d462..7f7d66e625d254c2e5e2a04851eef38c0d6cb6e7 100644 (file)
@@ -831,8 +831,8 @@ E-Mail: [E-Mail-Adresse der verantwortlichen Stelle]</p><p><br></p><p>Verantwort
                <page identifier="com.woltlab.wcf.MultifactorManage">
                        <pageType>system</pageType>
                        <controller>wcf\form\MultifactorManageForm</controller>
-                       <name language="de">Mehrfaktor-Authentifizierung einrichten</name>
                        <name language="en">Manage Multi-Factor Authentication</name>
+                       <name language="de">Mehrfaktor-Authentifizierung einrichten</name>
                        <hasFixedParent>1</hasFixedParent>
                        <parent>com.woltlab.wcf.AccountSecurity</parent>
                        <excludeFromLandingPage>1</excludeFromLandingPage>
@@ -849,6 +849,23 @@ E-Mail: [E-Mail-Adresse der verantwortlichen Stelle]</p><p><br></p><p>Verantwort
                        <availableDuringOfflineMode>1</availableDuringOfflineMode>
                        <requireObjectID>1</requireObjectID>
                </page>
+               <page identifier="com.woltlab.wcf.MultifactorDisable">
+                       <pageType>system</pageType>
+                       <controller>wcf\form\MultifactorDisableForm</controller>
+                       <name language="en">Disable Multi-Factor Authentication</name>
+                       <name language="de">Mehrfaktor-Authentifizierung deaktivieren</name>
+                       <hasFixedParent>1</hasFixedParent>
+                       <parent>com.woltlab.wcf.AccountSecurity</parent>
+                       <excludeFromLandingPage>1</excludeFromLandingPage>
+                       <availableDuringOfflineMode>1</availableDuringOfflineMode>
+                       <requireObjectID>1</requireObjectID>
+                       <content language="en">
+                               <title>Disable Multi-Factor Authentication</title>
+                       </content>
+                       <content language="de">
+                               <title>Mehrfaktor-Authentifizierung deaktivieren</title>
+                       </content>
+               </page>
        </import>
        <delete>
                <page identifier="com.woltlab.wcf.Mail"/>
diff --git a/com.woltlab.wcf/templates/__multifactorDisableExplanation.tpl b/com.woltlab.wcf/templates/__multifactorDisableExplanation.tpl
new file mode 100644 (file)
index 0000000..a6c516e
--- /dev/null
@@ -0,0 +1 @@
+{lang}wcf.user.security.multifactor.disable.explanation{/lang}
index 8e2c030cf199f5faec6a3205a8a88540251a531a..4f079fb8c8b739231b2c69319b4e5325db54c32a 100644 (file)
                                                </div>
                                                
                                                <div class="accountSecurityButtons">
-                                                       <a class="small button" href="{link controller='MultifactorManage' id=$method->objectTypeID}{/link}">
-                                                               {lang}wcf.user.security.multifactor.{if $enabledMultifactorMethods[$method->objectTypeID]|isset}manage{else}setup{/if}{/lang}
-                                                       </a>
+                                                       {if $enabledMultifactorMethods[$method->objectTypeID]|isset}
+                                                               {if $method->objectType !== 'com.woltlab.wcf.multifactor.backup'}
+                                                                       <a class="small button" href="{link controller='MultifactorDisable' object=$enabledMultifactorMethods[$method->objectTypeID]}{/link}">
+                                                                               {lang}wcf.user.security.multifactor.disable{/lang}
+                                                                       </a>
+                                                               {/if}
+                                                               
+                                                               <a class="small button buttonPrimary" href="{link controller='MultifactorManage' object=$method}{/link}">
+                                                                       {lang}wcf.user.security.multifactor.manage{/lang}
+                                                               </a>
+                                                       {else}
+                                                               <a class="small button buttonPrimary" href="{link controller='MultifactorManage' object=$method}{/link}">
+                                                                       {lang}wcf.user.security.multifactor.setup{/lang}
+                                                               </a>
+                                                       {/if}
                                                </div>
                                        </div>
                                </li>
diff --git a/com.woltlab.wcf/templates/multifactorDisable.tpl b/com.woltlab.wcf/templates/multifactorDisable.tpl
new file mode 100644 (file)
index 0000000..9b9926b
--- /dev/null
@@ -0,0 +1,7 @@
+{include file='userMenuSidebar'}
+
+{include file='header' __disableAds=true __sidebarLeftHasMenu=true}
+
+{@$form->getHtml()}
+
+{include file='footer' __disableAds=true}
index b1c9d6aab7ff03b73f518c17d961371f4b448a34..ada92d312264afa11f6bd546203015536a43fceb 100644 (file)
@@ -5,7 +5,6 @@
 
 {include file='header' __disableAds=true __sidebarLeftHasMenu=true}
 
-
 {if $backupForm}
        {if $form->showsSuccessMessage()}
                <p class="success">
diff --git a/wcfsetup/install/files/lib/form/MultifactorDisableForm.class.php b/wcfsetup/install/files/lib/form/MultifactorDisableForm.class.php
new file mode 100644 (file)
index 0000000..8f4a6c4
--- /dev/null
@@ -0,0 +1,231 @@
+<?php
+namespace wcf\form;
+use wcf\data\object\type\ObjectType;
+use wcf\data\user\UserEditor;
+use wcf\page\AccountSecurityPage;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\form\builder\TemplateFormNode;
+use wcf\system\menu\user\UserMenu;
+use wcf\system\request\LinkHandler;
+use wcf\system\user\multifactor\Setup;
+use wcf\system\WCF;
+use wcf\util\HeaderUtil;
+
+/**
+ * Represents the multi-factor disable form.
+ *
+ * @author     Tim Duesterhus
+ * @copyright  2001-2020 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\Form
+ * @since      5.4
+ */
+class MultifactorDisableForm extends AbstractFormBuilderForm {
+       /**
+        * @inheritDoc
+        */
+       public $loginRequired = true;
+       
+       /**
+        * @var ObjectType
+        */
+       private $method;
+       
+       /**
+        * @var Setup
+        */
+       private $setup;
+       
+       /**
+        * @var Setup[]
+        */
+       private $setups;
+       
+       /**
+        * @inheritDoc
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (!isset($_GET['id'])) {
+                       throw new IllegalLinkException();
+               }
+               
+               $this->setups = Setup::getAllForUser(WCF::getUser());
+               
+               if (empty($this->setups)) {
+                       throw new IllegalLinkException();
+               }
+               
+               if (!isset($this->setups[$_GET['id']])) {
+                       throw new IllegalLinkException();
+               }
+               
+               $this->setup = $this->setups[$_GET['id']];
+               $this->method = $this->setup->getObjectType();
+               \assert($this->method->getDefinition()->definitionName === 'com.woltlab.wcf.multifactor');
+               
+               // Backup codes may not be disabled.
+               if ($this->method->objectType === 'com.woltlab.wcf.multifactor.backup') {
+                       throw new PermissionDeniedException();
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function createForm() {
+               parent::createForm();
+               
+               $this->form->appendChildren([
+                       TemplateFormNode::create('explanation')
+                               ->templateName('__multifactorDisableExplanation')
+                               ->variables([
+                                       'remaining' =>  $this->setupsWithoutDisableRequest(
+                                               $this->setupsWithoutBackupCodes($this->setups)
+                                       ),
+                                       'setup' => $this->setup,
+                               ]),
+                       BooleanFormField::create('confirm')
+                               ->label('wcf.user.security.multifactor.disable.confirm', [
+                                       'remaining' =>  $this->setupsWithoutDisableRequest(
+                                               $this->setupsWithoutBackupCodes($this->setups)
+                                       ),
+                                       'setup' => $this->setup,
+                               ])
+                               ->addValidator(new FormFieldValidator('confirm', function(BooleanFormField $formField) {
+                                       if (!$formField->getValue()) {
+                                               $formField->addValidationError(
+                                                       new FormFieldValidationError(
+                                                               'required',
+                                                               'wcf.user.security.multifactor.disable.confirm.required'
+                                                       )
+                                               );
+                                       }
+                               })),
+               ]);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function save() {
+               AbstractForm::save();
+               
+               WCF::getDB()->beginTransaction();
+               
+               $this->form->successMessage('wcf.user.security.multifactor.disable.success', [
+                       'setup' => $this->setup,
+               ]);
+               $this->setup->delete();
+               
+               $setups = Setup::getAllForUser(WCF::getUser());
+               $remaining = $this->setupsWithoutBackupCodes($setups);
+               
+               if (empty($remaining)) {
+                       foreach ($setups as $setup) {
+                               $setup->delete();
+                       }
+                       $this->disableMultifactorAuth();
+                       $this->form->successMessage('wcf.user.security.multifactor.disable.success.full');
+               }
+               
+               WCF::getDB()->commitTransaction();
+               
+               $this->saved();
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function saved() {
+               AbstractForm::saved();
+               
+               HeaderUtil::delayedRedirect(
+                       LinkHandler::getInstance()
+                               ->getControllerLink(AccountSecurityPage::class),
+                       $this->form->getSuccessMessage()
+               );
+               exit;
+       }
+       
+       /**
+        * Returns the active setups without the backup codes.
+        * 
+        * @param Setup[] $setups
+        * @return Setup[]
+        */
+       protected function setupsWithoutBackupCodes(array $setups): array {
+               return array_filter($setups, function (Setup $setup) {
+                       return $setup->getObjectType()->objectType !== 'com.woltlab.wcf.multifactor.backup';
+               });
+       }
+       
+       /**
+        * Returns the active setups without the setup that is going to be disabled.
+        * 
+        * @param Setup[] $setups
+        * @return Setup[]
+        */
+       protected function setupsWithoutDisableRequest(array $setups): array {
+               return array_filter($setups, function (Setup $setup) {
+                       return $setup->getId() !== $this->setup->getId();
+               });
+       }
+       
+       /**
+        * Disables multifactor authentication for the user.
+        */
+       protected function disableMultifactorAuth(): void {
+               // This method intentionally does not use UserAction to prevent
+               // events from firing.
+               //
+               // This method is being run from within a transaction to ensure
+               // a consistent database state in case any part of the MFA setup
+               // fails. Event listeners could run complex logic, including
+               // queries that modify the database state, possibly leading to
+               // a very large transaction and much more surface area for
+               // unexpected failures.
+               //
+               // Use the saved@MultifactorDisableForm event if you need to run
+               // logic in response to a user disabling MFA.
+               $editor = new UserEditor(WCF::getUser());
+               $editor->update([
+                       'multifactorActive' => 0,
+               ]);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function setFormAction() {
+               $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, [
+                       'object' => $this->setup,
+               ]));
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function assignVariables() {
+               parent::assignVariables();
+               
+               WCF::getTPL()->assign([
+                       'method' => $this->method,
+                       'setups' => $this->setups,
+               ]);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function show() {
+               UserMenu::getInstance()->setActiveMenuItem('wcf.user.menu.profile.security');
+               
+               parent::show();
+       }
+}
index a9592aa91cbf291ef1136af23aaeeb1c82ffd33e..1dad21c3638f1a12846d517e532f9d57b84cf771 100644 (file)
@@ -98,6 +98,9 @@ class MultifactorManageForm extends AbstractFormBuilderForm {
                $this->processor->createManagementForm($this->form, $this->setup, $this->returnData);
        }
        
+       /**
+        * @inheritDoc
+        */
        public function save() {
                AbstractForm::save();
                
@@ -221,7 +224,7 @@ class MultifactorManageForm extends AbstractFormBuilderForm {
         */
        protected function setFormAction() {
                $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, [
-                       'id' => $this->method->objectTypeID,
+                       'object' => $this->method,
                ]));
        }
        
index 7548bfbe47237c7714aae0971718fd45aad57047..1fd920740c8f01dbea82a67c3722db09340ad83f 100644 (file)
@@ -22,6 +22,8 @@ final class Setup implements IIDObject {
         */
        private $row;
        
+       private $isDeleted = false;
+       
        private function __construct(array $row) {
                $this->row = $row;
        }
@@ -30,6 +32,10 @@ final class Setup implements IIDObject {
         * Returns the setup ID.
         */
        public function getId(): int {
+               if ($this->isDeleted) {
+                       throw new \BadMethodCallException('The Setup is deleted.');
+               }
+               
                return $this->row['setupID'];
        }
        
@@ -37,6 +43,10 @@ final class Setup implements IIDObject {
         * @see Setup::getId()
         */
        public function getObjectID(): int {
+               if ($this->isDeleted) {
+                       throw new \BadMethodCallException('The Setup is deleted.');
+               }
+               
                return $this->getId();
        }
        
@@ -44,6 +54,10 @@ final class Setup implements IIDObject {
         * Returns the object type.
         */
        public function getObjectType(): ObjectType {
+               if ($this->isDeleted) {
+                       throw new \BadMethodCallException('The Setup is deleted.');
+               }
+               
                return ObjectTypeCache::getInstance()->getObjectType($this->row['objectTypeID']);
        }
        
@@ -51,6 +65,10 @@ final class Setup implements IIDObject {
         * Returns the user.
         */
        public function getUser(): User {
+               if ($this->isDeleted) {
+                       throw new \BadMethodCallException('The Setup is deleted.');
+               }
+               
                return UserRuntimeCache::getInstance()->getObject($this->row['userID']);
        }
        
@@ -58,6 +76,10 @@ final class Setup implements IIDObject {
         * Locks the database record for this setup, preventing concurrent changes, and returns itself.
         */
        public function lock(): self {
+               if ($this->isDeleted) {
+                       throw new \BadMethodCallException('The Setup is deleted.');
+               }
+               
                $sql = "SELECT  setupId
                        FROM    wcf".WCF_N."_user_multifactor
                        WHERE   setupId = ?
@@ -73,6 +95,19 @@ final class Setup implements IIDObject {
                return $this;
        }
        
+       /**
+        * Deletes the setup.
+        */
+       public function delete(): void {
+               $sql = "DELETE FROM     wcf".WCF_N."_user_multifactor
+                       WHERE           setupId = ?";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([
+                       $this->getId(),
+               ]);
+               $this->isDeleted = true;
+       }
+       
        /**
         * Returns an existing setup for the given objectType and user or null if none was found.
         */
index fe46550090fdf99a4652ad6914157f1baec8070b..994acdc89dd528ecbd83931dda2bbf5063d5d62b 100644 (file)
@@ -4904,6 +4904,22 @@ Die E-Mail-Adresse des neuen Benutzers lautet: {@$user->email}
                <item name="wcf.user.security.multifactor.com.woltlab.wcf.multifactor.email.description"><![CDATA[<p class="small">{if LANGUAGE_USE_INFORMAL_VARIANT}Du erhältst bei jedem Login einen Einmalcode an deine E-Mail-Adresse.{else}Sie erhalten bei jedem Login einen Einmalcode an Ihre E-Mail-Adresse.{/if}</p>]]></item>
                <item name="wcf.user.security.multifactor.description"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Schütze dein Benutzerkonto{else}Schützen Sie Ihr Benutzerkonto{/if}, indem bei jedem Login eine zusätzliche Authentifizierung mit Hilfe eines zweiten Faktors erforderlich ist.]]></item>
                <item name="wcf.user.security.activeSessions.description"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Du bist{else}Sie sind{/if} derzeit in den aufgelisteten Webbrowsern eingeloggt. {if LANGUAGE_USE_INFORMAL_VARIANT}Beende Sitzungen, die du nicht mehr benötigst oder nicht erkennst.{else}Beenden Sie Sitzungen, die Sie nicht mehr benötigen oder nicht erkennen.{/if} {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst dein Kennwort{else}Sie können Ihr Kennwort{/if} in der <a href="{link controller='AccountManagement'}{/link}">Account-Verwaltung</a> ändern.]]></item>
+               <item name="wcf.user.security.multifactor.disable"><![CDATA[Deaktivieren]]></item>
+               <item name="wcf.user.security.multifactor.disable.explanation"><![CDATA[<p>Mit dem Absenden dieses Formulars {if LANGUAGE_USE_INFORMAL_VARIANT}deaktivierst du{else}deaktivieren Sie{/if} das Verfahren <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> zur Nutzung mit der Mehrfaktor-Authentifizierung. {if LANGUAGE_USE_INFORMAL_VARIANT}Du wirst{else}Sie werden{/if} dieses Verfahren anschließend nicht mehr nutzen können, um {if LANGUAGE_USE_INFORMAL_VARIANT}dich{else}sich{/if} zu authentifizieren.</p>
+{if !$remaining|empty}
+<p>Nach Deaktivierung von <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> {plural value=$remaining|count 1='steht folgendes' other='stehen folgende'} Verfahren weiterhin zur Mehrfaktor-Authentifizierung zur Verfügung.</p>
+<ul class="nativeList">
+{foreach from=$remaining item='method'}
+<li><a href="{link controller='MultifactorManage' object=$method->getObjectType()}{/link}">{lang}wcf.user.security.multifactor.{$method->getObjectType()->objectType}{/lang}</a></li>
+{/foreach}
+</ul>
+{else}
+<p>Die Deaktivierung des Verfahrens wird die Mehrfaktor-Authentifizierung für {if LANGUAGE_USE_INFORMAL_VARIANT}dein{else}Ihr{/if} Benutzerkonto vollständig deaktivieren, da es das einzige aktive Verfahren ist.</p>
+{/if}]]></item>
+               <item name="wcf.user.security.multifactor.disable.confirm"><![CDATA[{if $remaining|empty}Mehrfaktor-Authentifizierung vollständig deaktivieren{else}<strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> deaktivieren{/if}]]></item>
+               <item name="wcf.user.security.multifactor.disable.confirm.required"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Bitte bestätige, dass du die Hinweise gelesen hast und mit der Deaktivierung fortfahren möchtest.{else}Bitte bestätigen Sie, dass Sie die Hinweise gelesen haben und mit der Deaktivierung fortfahren möchten.{/if}]]></item>
+               <item name="wcf.user.security.multifactor.disable.success"><![CDATA[Das Verfahren <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> wurde erfolgreich deaktiviert.]]></item>
+               <item name="wcf.user.security.multifactor.disable.success.full"><![CDATA[Die Mehrfaktor-Authentifizierung wurde erfolgreich deaktiviert.]]></item>
        </category>
        <category name="wcf.user.trophy">
                <item name="wcf.user.trophy.trophyPoints"><![CDATA[Trophäen]]></item>
index 208a50ceb31de5e307bfcacd0646952d022593ab..f440faba8fc89ef20774d27182aaeadec160ceba 100644 (file)
@@ -4901,6 +4901,22 @@ Open the link below to access the user profile:
                <item name="wcf.user.security.multifactor.com.woltlab.wcf.multifactor.email.description"><![CDATA[<p class="small">You will receive a one time code via email after logging in.</p>]]></item>
                <item name="wcf.user.security.multifactor.description"><![CDATA[Protect your account by requiring authentication with a second factor for every login.]]></item>
                <item name="wcf.user.security.activeSessions.description"><![CDATA[You are currently logged into your account in the listed web browsers. Revoke sessions you no longer need or that you do not recognize. You can change your password within the <a href="{link controller='AccountManagement'}{/link}">Account Management Form</a>.]]></item>
+               <item name="wcf.user.security.multifactor.disable"><![CDATA[Disable]]></item>
+               <item name="wcf.user.security.multifactor.disable.explanation"><![CDATA[<p>By submitting this form the <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> method for multi-factor authentication will be disabled. You will no longer be able to use this method to authenticate yourself.</p>
+{if !$remaining|empty}
+<p>After disabling <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> the following methods will still be available for multi-factor authentication.</p>
+<ul class="nativeList">
+{foreach from=$remaining item='method'}
+<li><a href="{link controller='MultifactorManage' object=$method->getObjectType()}{/link}">{lang}wcf.user.security.multifactor.{$method->getObjectType()->objectType}{/lang}</a></li>
+{/foreach}
+</ul>
+{else}
+<p>Disabling <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> will fully disable the use of multi-factor authentication for your account as it is the only active method.</p>
+{/if}]]></item>
+               <item name="wcf.user.security.multifactor.disable.confirm"><![CDATA[{if $remaining|empty}Completely Disable Multi-Factor Authentication{else}Disable <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong>{/if}]]></item>
+               <item name="wcf.user.security.multifactor.disable.confirm.required"><![CDATA[Please confirm that you read the explanation and that you would like to proceed.]]></item>
+               <item name="wcf.user.security.multifactor.disable.success"><![CDATA[The <strong>{lang}wcf.user.security.multifactor.{$setup->getObjectType()->objectType}{/lang}</strong> method has successfully been disabled.]]></item>
+               <item name="wcf.user.security.multifactor.disable.success.full"><![CDATA[The multi-factor authentication has successfully been disabled.]]></item>
        </category>
        <category name="wcf.user.trophy">
                <item name="wcf.user.trophy.trophyPoints"><![CDATA[Trophies]]></item>