Add MultifactorManageForm
authorTim Düsterhus <duesterhus@woltlab.com>
Thu, 5 Nov 2020 09:59:31 +0000 (10:59 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Mon, 16 Nov 2020 16:25:04 +0000 (17:25 +0100)
com.woltlab.wcf/page.xml
com.woltlab.wcf/templates/accountSecurity.tpl
com.woltlab.wcf/templates/multifactorManage.tpl [new file with mode: 0644]
com.woltlab.wcf/templates/multifactorManageBackup.tpl [new file with mode: 0644]
wcfsetup/install/files/lib/form/MultifactorManageForm.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/multifactor/BackupMultifactorMethod.class.php
wcfsetup/install/files/lib/system/user/multifactor/IMultifactorMethod.class.php
wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php
wcfsetup/install/files/style/ui/accountSecurity.scss
wcfsetup/setup/db/install.sql

index 6c9fe24f8574eba38e6c8018ae26b9bc9fbd30f9..0cd257e6f413d079d151fdbce7a8a9f6856761d9 100644 (file)
                        <name language="de">Dashboard</name>
                        <name language="en">Dashboard</name>
                        <allowSpidersToIndex>1</allowSpidersToIndex>
-
                        <content>
                                <title>Dashboard</title>
                                <customURL>dashboard</customURL>
                        <name language="en">Cookie Policy</name>
                        <availableDuringOfflineMode>1</availableDuringOfflineMode>
                        <allowSpidersToIndex>0</allowSpidersToIndex>
-                       
                        <content language="en">
                                <title>Cookie Policy</title>
                                <content><![CDATA[<p>This website uses cookies required to operate and use this site. Below you will find an explanation on our cookie usage.</p>
                        <name language="en">Privacy Policy</name>
                        <availableDuringOfflineMode>1</availableDuringOfflineMode>
                        <allowSpidersToIndex>0</allowSpidersToIndex>
-                       
                        <content language="en">
                                <title>Privacy Policy</title>
                                <content><![CDATA[<h2>1. An overview of data protection</h2>
@@ -831,8 +828,18 @@ E-Mail: [E-Mail-Adresse der verantwortlichen Stelle]</p><p><br></p><p>Verantwort
                                <customURL>datenschutzerklaerung</customURL>
                        </content>
                </page>
+               <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 Multifactor Authentication</name>
+                       <hasFixedParent>1</hasFixedParent>
+                       <parent>com.woltlab.wcf.AccountSecurity</parent>
+                       <excludeFromLandingPage>1</excludeFromLandingPage>
+                       <requireObjectID>1</requireObjectID>
+               </page>
        </import>
        <delete>
-               <page identifier="com.woltlab.wcf.Mail" />
+               <page identifier="com.woltlab.wcf.Mail"/>
        </delete>
 </data>
index ffd304234a70031df11e9c8ea20489197c3f21b3..9684b731ae5a105b3003adcfbaa955279b35122e 100644 (file)
@@ -12,8 +12,8 @@
                                        <span class="icon icon64 fa-{if $method->icon}{$method->icon}{else}lock{/if}"></span>
                                </div>
                                
-                               <div>
-                                       <div class="containerHeadline">
+                               <div class="accountSecurityContainer">
+                                       <div class="containerHeadline accountSecurityInformation">
                                                <h3>
                                                        {lang}wcf.user.security.multifactor.{$method->objectType}{/lang}
                                                        
                                                
                                                {$method->getProcessor()->getStatusText($__wcf->user)}
                                        </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>
+                                       </div>
                                </div>
                        </li>
                {/foreach}
diff --git a/com.woltlab.wcf/templates/multifactorManage.tpl b/com.woltlab.wcf/templates/multifactorManage.tpl
new file mode 100644 (file)
index 0000000..ad34602
--- /dev/null
@@ -0,0 +1,10 @@
+{capture assign='pageTitle'}{lang}wcf.user.security.multifactor.{$method->objectType}.manage{/lang}{/capture}
+{capture assign='contentTitle'}{lang}wcf.user.security.multifactor.{$method->objectType}.manage{/lang}{/capture}
+
+{include file='userMenuSidebar'}
+
+{include file='header' __disableAds=true __sidebarLeftHasMenu=true}
+
+{@$form->getHtml()}
+
+{include file='footer' __disableAds=true}
diff --git a/com.woltlab.wcf/templates/multifactorManageBackup.tpl b/com.woltlab.wcf/templates/multifactorManageBackup.tpl
new file mode 100644 (file)
index 0000000..1fff904
--- /dev/null
@@ -0,0 +1,8 @@
+<ol class="nativeList multifactorBackupCodes">
+{foreach from=$codes item='code'}
+<li>
+       <span class="multifactorBackupCode{if $code[useTime]} used{/if}">{foreach from=$code[chunks] item='chunk'}<span class="chunk">{$chunk}</span>{/foreach}</span>
+       {if $code[useTime]}({$code[useTime]|plainTime}){/if}
+</li>
+{/foreach}
+</ol>
diff --git a/wcfsetup/install/files/lib/form/MultifactorManageForm.class.php b/wcfsetup/install/files/lib/form/MultifactorManageForm.class.php
new file mode 100644 (file)
index 0000000..36e83a4
--- /dev/null
@@ -0,0 +1,179 @@
+<?php
+namespace wcf\form;
+use wcf\data\object\type\ObjectType;
+use wcf\data\object\type\ObjectTypeCache;
+use wcf\form\AbstractFormBuilderForm;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\menu\user\UserMenu;
+use wcf\system\request\LinkHandler;
+use wcf\system\user\multifactor\IMultifactorMethod;
+use wcf\system\WCF;
+
+/**
+ * Represents the multifactor setup 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 MultifactorManageForm extends AbstractFormBuilderForm {
+       /**
+        * @inheritDoc
+        */
+       public $loginRequired = true;
+       
+       /**
+        * @inheritDoc
+        */
+       public $formAction = 'setup';
+       
+       /**
+        * @var ObjectType
+        */
+       private $method;
+       
+       /**
+        * @var IMultifactorMethod
+        */
+       private $processor;
+       
+       /**
+        * @var int
+        */
+       private $setupId;
+       
+       /**
+        * @var mixed
+        */
+       private $returnData;
+       
+       /**
+        * @inheritDoc
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (!isset($_GET['id'])) {
+                       throw new IllegalLinkException();
+               }
+               
+               $objectType = ObjectTypeCache::getInstance()->getObjectType(intval($_GET['id']));
+               
+               if (!$objectType) {
+                       throw new IllegalLinkException();
+               }
+               if ($objectType->getDefinition()->definitionName !== 'com.woltlab.wcf.multifactor') {
+                       throw new IllegalLinkException();
+               }
+               
+               $this->method = $objectType;
+               $this->processor = $this->method->getProcessor();
+               
+               $sql = "SELECT  setupID
+                       FROM    wcf".WCF_N."_user_multifactor
+                       WHERE   userID = ?
+                               AND objectTypeID = ?";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([
+                       WCF::getUser()->userID,
+                       $this->method->objectTypeID,
+               ]);
+               $this->setupId = $statement->fetchSingleColumn();
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function createForm() {
+               parent::createForm();
+               
+               $this->processor->createManagementForm($this->form, $this->setupId, $this->returnData);
+       }
+       
+       public function save() {
+               AbstractForm::save();
+
+               WCF::getDB()->beginTransaction();
+               
+               /** @var int|null $setupId */
+               $setupId = null;
+               if ($this->setupId) {
+                       $sql = "SELECT  setupId
+                               FROM    wcf".WCF_N."_user_multifactor
+                               WHERE   setupId = ?
+                               FOR UPDATE";
+                       $statement = WCF::getDB()->prepareStatement($sql);
+                       $statement->execute([
+                               $this->setupId,
+                       ]);
+                       
+                       $setupId = intval($statement->fetchSingleColumn());
+               }
+               else {
+                       $sql = "INSERT INTO     wcf".WCF_N."_user_multifactor
+                                               (userID, objectTypeID)
+                               VALUES          (?, ?)";
+                       $statement = WCF::getDB()->prepareStatement($sql);
+                       $statement->execute([
+                               WCF::getUser()->userID,
+                               $this->method->objectTypeID,
+                       ]);
+                       
+                       $setupId = intval(WCF::getDB()->getInsertID("wcf".WCF_N."_user_multifactor", 'setupID'));
+               }
+               
+               if (!$setupId) {
+                       throw new \RuntimeException("Multifactor setup disappeared");
+               }
+               
+               $this->returnData = $this->processor->processManagementForm($this->form, $setupId);
+               
+               $this->setupId = $setupId;
+               WCF::getDB()->commitTransaction();
+               
+               $this->saved();
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function saved() {
+               AbstractForm::saved();
+               
+               $this->form->cleanup();
+               $this->buildForm();
+               
+               $this->form->showSuccessMessage(true);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function setFormAction() {
+               $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, [
+                       'id' => $this->method->objectTypeID,
+               ]));
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function assignVariables() {
+               parent::assignVariables();
+               
+               WCF::getTPL()->assign([
+                       'method' => $this->method,
+               ]);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function show() {
+               UserMenu::getInstance()->setActiveMenuItem('wcf.user.menu.profile.security');
+               
+               parent::show();
+       }
+}
index ad4f32f21517d9302e8cba91507b13c794b42206..ac3fd59627d47e255cfc308b872158ab497783d0 100644 (file)
@@ -1,6 +1,16 @@
 <?php
 namespace wcf\system\user\multifactor;
 use wcf\data\user\User;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\field\ButtonFormField;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\form\builder\IFormDocument;
+use wcf\system\form\builder\TemplateFormNode;
+use wcf\system\user\authentication\password\algorithm\Bcrypt;
+use wcf\system\user\authentication\password\IPasswordAlgorithm;
+use wcf\system\user\authentication\password\PasswordAlgorithmManager;
+use wcf\system\WCF;
 
 /**
  * Implementation of random backup codes.
@@ -12,6 +22,18 @@ use wcf\data\user\User;
  * @since      5.4
  */
 class BackupMultifactorMethod implements IMultifactorMethod {
+       /**
+        * @var IPasswordAlgorithm
+        */
+       private $algorithm;
+       
+       private const CHUNKS = 4;
+       private const CHUNK_LENGTH = 5;
+       
+       public function __construct() {
+               $this->algorithm = new Bcrypt();
+       }
+       
        /**
         * Returns the number of remaining codes.
         */
@@ -19,4 +41,128 @@ class BackupMultifactorMethod implements IMultifactorMethod {
                // TODO: Return a proper text.
                return random_int(10000, 99999)." codes remaining";
        }
+       
+       /**
+        * @inheritDoc
+        */
+       public function createManagementForm(IFormDocument $form, ?int $setupId, $returnData = null): void {
+               $form->addDefaultButton(false);
+               $form->successMessage('wcf.user.security.multifactor.backup.success');
+               
+               if ($setupId) {
+                       $sql = "SELECT  *
+                               FROM    wcf".WCF_N."_user_multifactor_backup
+                               WHERE   setupID = ?";
+                       $statement = WCF::getDB()->prepareStatement($sql);
+                       $statement->execute([$setupId]);
+                       
+                       $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
+                       
+                       $codes = array_map(function ($code) use ($returnData) {
+                               if (isset($returnData[$code['identifier']])) {
+                                       $code['chunks'] = str_split($returnData[$code['identifier']], self::CHUNK_LENGTH);
+                               }
+                               else {
+                                       $code['chunks'] = [
+                                               $code['identifier'],
+                                       ];
+                                       
+                                       while (\count($code['chunks']) < self::CHUNKS) {
+                                               $code['chunks'][] = \str_repeat('x', self::CHUNK_LENGTH);
+                                       }
+                               }
+                               
+                               return $code;
+                       }, $codes);
+                       
+                       $statusContainer = FormContainer::create('existingCodesContainer')
+                               ->label('wcf.user.security.multifactor.backup.existingCodes')
+                               ->appendChildren([
+                                       TemplateFormNode::create('existingCodes')
+                                               ->templateName('multifactorManageBackup')
+                                               ->variables([
+                                                       'codes' => $codes,
+                                               ]),
+                               ]);
+                       $form->appendChild($statusContainer);
+               
+                       $regenerateContainer = FormContainer::create('regenerateCodesContainer')
+                               ->label('wcf.user.security.multifactor.backup.regenerateCodes')
+                               ->appendChildren([
+                                       ButtonFormField::create('regenerateCodes')
+                                               ->buttonLabel('wcf.user.security.multifactor.backup.regenerateCodes')
+                                               ->objectProperty('action')
+                                               ->value('regenerateCodes')
+                                               ->addValidator(new FormFieldValidator('regenerateCodes', function (ButtonFormField $field) {
+                                                       if ($field->getValue() === null) {
+                                                               $field->addValidationError(new FormFieldValidationError('unreachable', 'unreachable'));
+                                                       }
+                                               })),
+                               ]);
+                       $form->appendChild($regenerateContainer);
+               }
+               else {
+                       $generateContainer = FormContainer::create('generateCodesContainer')
+                               ->label('wcf.user.security.multifactor.backup.generateCodes')
+                               ->appendChildren([
+                                       ButtonFormField::create('generateCodes')
+                                               ->buttonLabel('wcf.user.security.multifactor.backup.generateCodes')
+                                               ->objectProperty('action')
+                                               ->value('generateCodes')
+                                               ->addValidator(new FormFieldValidator('generateCodes', function (ButtonFormField $field) {
+                                                       if ($field->getValue() === null) {
+                                                               $field->addValidationError(new FormFieldValidationError('unreachable', 'unreachable'));
+                                                       }
+                                               })),
+                               ]);
+                       $form->appendChild($generateContainer);
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function processManagementForm(IFormDocument $form, int $setupId): array {
+               $formData = $form->getData();
+               assert($formData['action'] === 'generateCodes' || $formData['action'] === 'regenerateCodes');
+               
+               $sql = "DELETE FROM     wcf".WCF_N."_user_multifactor_backup
+                       WHERE           setupID = ?";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([$setupId]);
+               
+               $codes = [];
+               for ($i = 0; $i < 10; $i++) {
+                       $chunks = [];
+                       for ($part = 0; $part < self::CHUNKS; $part++) {
+                               $chunks[] = \random_int(
+                                       pow(10, self::CHUNK_LENGTH - 1),
+                                       pow(10, self::CHUNK_LENGTH) - 1
+                               );
+                       }
+                       
+                       $identifier = $chunks[0];
+                       if (isset($codes[$identifier])) {
+                               continue;
+                       }
+                       
+                       $codes[$identifier] = implode('', $chunks);
+               }
+               
+               $sql = "INSERT INTO     wcf".WCF_N."_user_multifactor_backup
+                                       (setupID, identifier, code, createTime)
+                       VALUES          (?, ?, ?, ?)";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $algorithName = PasswordAlgorithmManager::getInstance()->getNameFromAlgorithm($this->algorithm);
+               foreach ($codes as $identifier => $code) {
+                       $statement->execute([
+                               $setupId,
+                               $identifier,
+                               $algorithName.':'.$this->algorithm->hash($code),
+                               TIME_NOW,
+                       ]);
+               }
+               
+               return $codes;
+       }
 }
index 33ec715ccd21b403b9dad925759b653fa6647290..22abe11556e08750ff345118e185edae4ee1b29e 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace wcf\system\user\multifactor;
 use wcf\data\user\User;
+use wcf\system\form\builder\IFormDocument;
 
 /**
  * Handles multifactor authentication for a specific authentication method.
@@ -18,4 +19,25 @@ interface IMultifactorMethod {
         * An example text could be: "5 backup codes remaining".
         */
        public function getStatusText(User $user): string;
+       
+       /**
+        * Populates the form to set-up and manage this method.
+        */
+       public function createManagementForm(IFormDocument $form, ?int $setupId, $returnData = null): void;
+       
+       /**
+        * Updates the database information based on the data received in the management form.
+        * 
+        * This method will be run within a database transaction and must ensure that a valid database
+        * state is reached. Specifically the multifcaator method MUST be usable after this method
+        * finishes successfully.
+        * 
+        * An example of an invalid state could be the removal of all multifactor devices.
+        * 
+        * It is recommended that this method double checks the state of the database to prevent TOCTOU
+        * issues with the validation performed by the form fields and the actual database update.
+        * 
+        * @return      mixed   Opaque data that will be passed as `$returnData` in createManagementForm().
+        */
+       public function processManagementForm(IFormDocument $form, int $setupId);
 }
index 6c16f7f80703ce427c4ddfd020f65700d0cc6089..7cdafd988608a32a3084a31f55ad41cf5d3d5d28 100644 (file)
@@ -1,6 +1,8 @@
 <?php
 namespace wcf\system\user\multifactor;
 use wcf\data\user\User;
+use wcf\system\exception\NotImplementedException;
+use wcf\system\form\builder\IFormDocument;
 
 /**
  * Implementation of the Time-based One-time Password Algorithm (RFC 6238).
@@ -19,4 +21,15 @@ class TotpMultifactorMethod implements IMultifactorMethod {
                // TODO: Return a proper text.
                return random_int(10000, 99999)." devices configured";
        }
+       
+       /**
+        * @inheritDoc
+        */
+       public function createManagementForm(IFormDocument $form, ?int $setupId, $returnData = null): void {
+               throw new NotImplementedException("TODO");
+       }
+       
+       public function processManagementForm(IFormDocument $form, int $setupId): void {
+               throw new NotImplementedException("TODO");
+       }
 }
index a3c7be6a263ed7ef5d52048d8eda465adb460506..9cf57e45ee2b44bf27bbca330ca0bbeadde7fb5d 100644 (file)
@@ -6,3 +6,17 @@
 .accountSecurityInformation {
        flex: 1 1 auto;
 }
+
+.multifactorBackupCode {
+       font-family: monospace;
+       &.used {
+               text-decoration: line-through;
+       }
+       .chunk {
+               margin-left: 5px;
+               &:first-child {
+                       margin-left: 0;
+                       font-weight: 600;
+               }
+       }
+}
index 1dbbcb2a9f131814651ad25573c6709e1666090a..aee45a93d47e422f65cd44ee76706111cde2b8a6 100644 (file)
@@ -1683,6 +1683,17 @@ CREATE TABLE wcf1_user_multifactor (
        UNIQUE KEY (userID, objectTypeID)
 );
 
+DROP TABLE IF EXISTS wcf1_user_multifactor_backup;
+CREATE TABLE wcf1_user_multifactor_backup (
+       setupID INT(10) NOT NULL,
+       identifier VARCHAR(255) NOT NULL,
+       code VARCHAR(255) NOT NULL,
+       createTime INT(10) NOT NULL,
+       useTime INT(10) DEFAULT NULL,
+       
+       UNIQUE KEY (setupID, identifier)
+);
+
 -- notifications
 DROP TABLE IF EXISTS wcf1_user_notification;
 CREATE TABLE wcf1_user_notification (
@@ -2175,6 +2186,8 @@ ALTER TABLE wcf1_user_authentication_failure ADD FOREIGN KEY (userID) REFERENCES
 ALTER TABLE wcf1_user_multifactor ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE CASCADE;
 ALTER TABLE wcf1_user_multifactor ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE;
 
+ALTER TABLE wcf1_user_multifactor_backup ADD FOREIGN KEY (setupID) REFERENCES wcf1_user_multifactor (setupID) ON DELETE CASCADE;
+
 ALTER TABLE wcf1_user_object_watch ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE CASCADE;
 ALTER TABLE wcf1_user_object_watch ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE;