Add MultifactorAuthenticationForm
authorTim Düsterhus <duesterhus@woltlab.com>
Fri, 6 Nov 2020 08:38:27 +0000 (09:38 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Mon, 16 Nov 2020 16:26:03 +0000 (17:26 +0100)
com.woltlab.wcf/objectType.xml
com.woltlab.wcf/page.xml
com.woltlab.wcf/templates/multifactorAuthentication.tpl [new file with mode: 0644]
wcfsetup/install/files/lib/acp/form/LoginForm.class.php
wcfsetup/install/files/lib/form/LoginForm.class.php
wcfsetup/install/files/lib/form/MultifactorAuthenticationForm.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

index 3108650d25514f6dd18ff43e23564779f972a1d7..08aa82aec0da4e4537da07b9d8d066a8bfb336dd 100644 (file)
                        <name>com.woltlab.wcf.multifactor.backup</name>
                        <definitionname>com.woltlab.wcf.multifactor</definitionname>
                        <icon>sticky-note</icon>
+                       <priority>15</priority> <!-- TODO: Testing purposes only -->
                        <classname>wcf\system\user\multifactor\BackupMultifactorMethod</classname>
                </type>
                <type>
                        <name>com.woltlab.wcf.multifactor.totp</name>
                        <definitionname>com.woltlab.wcf.multifactor</definitionname>
                        <icon>mobile</icon>
+                       <priority>10</priority>
                        <classname>wcf\system\user\multifactor\TotpMultifactorMethod</classname>
                </type>
                <!-- /multi factor -->
                        <conditionobject>com.woltlab.wcf.page</conditionobject>
                </type>
                <!-- /deprecated -->
-</import>
+       </import>
        <delete>
                <type name="com.woltlab.wcf.like.user">
                        <definitionname>com.woltlab.wcf.rebuildData</definitionname>
index 0cd257e6f413d079d151fdbce7a8a9f6856761d9..46d1750fb617c3412b27fa86087a2c0b6cfcca38 100644 (file)
@@ -838,6 +838,17 @@ E-Mail: [E-Mail-Adresse der verantwortlichen Stelle]</p><p><br></p><p>Verantwort
                        <excludeFromLandingPage>1</excludeFromLandingPage>
                        <requireObjectID>1</requireObjectID>
                </page>
+               <page identifier="com.woltlab.wcf.MultifactorAuthentication">
+                       <pageType>system</pageType>
+                       <controller>wcf\form\MultifactorAuthenticationForm</controller>
+                       <name language="en">Multifactor Authentication</name>
+                       <name language="de">Mehrfaktor-Authentifizierung</name>
+                       <hasFixedParent>1</hasFixedParent>
+                       <parent>com.woltlab.wcf.Login</parent>
+                       <excludeFromLandingPage>1</excludeFromLandingPage>
+                       <availableDuringOfflineMode>1</availableDuringOfflineMode>
+                       <requireObjectID>1</requireObjectID>
+               </page>
        </import>
        <delete>
                <page identifier="com.woltlab.wcf.Mail"/>
diff --git a/com.woltlab.wcf/templates/multifactorAuthentication.tpl b/com.woltlab.wcf/templates/multifactorAuthentication.tpl
new file mode 100644 (file)
index 0000000..2570189
--- /dev/null
@@ -0,0 +1,25 @@
+{capture assign='sidebarLeft'}
+<section class="box">
+               <h2 class="boxTitle">{lang}wcf.user.security.multifactor.methods{/lang}</h2>
+               
+               <div class="boxContent">
+                       <nav>
+                               <ol class="boxMenu">
+                                       {foreach from=$methods key='_setupId' item='method'}
+                                               <li{if $setupId == $_setupId} class="active"{/if}>
+                                                       <a class="boxMenuLink" href="{link controller='MultifactorAuthentication' id=$_setupId}{/link}"><span class="boxMenuLinkTitle">{lang}wcf.user.security.multifactor.{$method->objectType}{/lang}</span></a>
+                                               </li>
+                                       {/foreach}
+                               </ol>
+                       </nav>
+               </div>
+       </section>
+{/capture}
+
+{include file='header' __disableAds=true __sidebarLeftHasMenu=true}
+
+{$user->username}
+
+{@$form->getHtml()}
+
+{include file='footer' __disableAds=true}
index 4baeb41c8adf664c3a78786f8c25416cde2429e0..208535a6496fdaf73196110de281eca94c530e57 100755 (executable)
@@ -5,9 +5,11 @@ use wcf\data\user\authentication\failure\UserAuthenticationFailureAction;
 use wcf\data\user\User;
 use wcf\data\user\UserProfile;
 use wcf\form\AbstractCaptchaForm;
+use wcf\form\MultifactorAuthenticationForm;
 use wcf\system\application\ApplicationHandler;
 use wcf\system\exception\NamedUserException;
 use wcf\system\exception\UserInputException;
+use wcf\system\request\LinkHandler;
 use wcf\system\request\RequestHandler;
 use wcf\system\request\RouteHandler;
 use wcf\system\user\authentication\EmailUserAuthentication;
@@ -200,16 +202,20 @@ class LoginForm extends AbstractCaptchaForm {
                parent::save();
                
                // change user
-               WCF::getSession()->changeUser($this->user);
+               $needsMultifactor = WCF::getSession()->changeUserAfterMultifactor($this->user);
                $this->saved();
                
-               $this->performRedirect();
+               $this->performRedirect($needsMultifactor);
        }
        
        /**
         * Performs the redirect after successful authentication.
         */
-       protected function performRedirect() {
+       protected function performRedirect(bool $needsMultifactor = false) {
+               if ($needsMultifactor) {
+                       $this->url = LinkHandler::getInstance()->getControllerLink(MultifactorAuthenticationForm::class);
+               }
+               
                if (!empty($this->url)) {
                        HeaderUtil::redirect($this->url);
                }
index f1ec31c455956005c29ff196effd9d57c7c26c72..d2b035d66589b284f2a596202a9b0c16728a775c 100644 (file)
@@ -24,14 +24,14 @@ class LoginForm extends \wcf\acp\form\LoginForm {
                if (FORCE_LOGIN) WCF::getSession()->unregister('__wsc_forceLoginRedirect');
                
                // change user
-               WCF::getSession()->changeUser($this->user);
+               $needsMultifactor = WCF::getSession()->changeUserAfterMultifactor($this->user);
                
                $this->saved();
                
                // redirect to url
                WCF::getTPL()->assign('__hideUserMenu', true);
                
-               $this->performRedirect();
+               $this->performRedirect($needsMultifactor);
        }
        
        /**
@@ -53,11 +53,11 @@ class LoginForm extends \wcf\acp\form\LoginForm {
        /**
         * @inheritDoc
         */
-       protected function performRedirect() {
+       protected function performRedirect(bool $needsMultifactor = false) {
                if (empty($this->url) || mb_stripos($this->url, '?login/') !== false || mb_stripos($this->url, '/login/') !== false) {
                        $this->url = LinkHandler::getInstance()->getLink();
                }
                
-               parent::performRedirect();
+               parent::performRedirect($needsMultifactor);
        }
 }
diff --git a/wcfsetup/install/files/lib/form/MultifactorAuthenticationForm.class.php b/wcfsetup/install/files/lib/form/MultifactorAuthenticationForm.class.php
new file mode 100644 (file)
index 0000000..1bd63a6
--- /dev/null
@@ -0,0 +1,152 @@
+<?php
+namespace wcf\form;
+use wcf\data\object\type\ObjectType;
+use wcf\data\user\User;
+use wcf\form\AbstractFormBuilderForm;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\request\LinkHandler;
+use wcf\system\user\multifactor\IMultifactorMethod;
+use wcf\system\WCF;
+
+/**
+ * Represents the multifactor authentication 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 MultifactorAuthenticationForm extends AbstractFormBuilderForm {
+       const AVAILABLE_DURING_OFFLINE_MODE = true;
+       
+       /**
+        * @inheritDoc
+        */
+       public $formAction = 'authenticate';
+       
+       /**
+        * @var User
+        */
+       private $user;
+       
+       /**
+        * @var ObjectType[]
+        */
+       private $methods;
+       
+       /**
+        * @var ObjectType
+        */
+       private $method;
+       
+       /**
+        * @var IMultifactorMethod
+        */
+       private $processor;
+       
+       /**
+        * @var int
+        */
+       private $setupId;
+       
+       /**
+        * @inheritDoc
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               $userId = WCF::getSession()->getVar('__changeUserAfterMultifactor__');
+               if (!$userId) {
+                       throw new PermissionDeniedException();
+               }
+               $this->user = new User($userId);
+               if (!$this->user->userID) {
+                       throw new PermissionDeniedException();
+               }
+               
+               $this->methods = $this->user->getEnabledMultifactorMethods();
+               
+               if (empty($this->methods)) {
+                       throw new \LogicException('Unreachable');
+               }
+               
+               uasort($this->methods, function (ObjectType $a, ObjectType $b) {
+                       return $b->priority <=> $a->priority;
+               });
+               
+               $this->setupId = array_keys($this->methods)[0];
+               if (isset($_GET['id'])) {
+                       $this->setupId = $_GET['id'];
+               }
+               
+               if (!isset($this->methods[$this->setupId])) {
+                       throw new IllegalLinkException();
+               }
+               
+               $this->method = $this->methods[$this->setupId];
+               assert($this->method->getDefinition()->definitionName === 'com.woltlab.wcf.multifactor');
+               
+               $this->processor = $this->method->getProcessor();
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function createForm() {
+               parent::createForm();
+               
+               $this->processor->createAuthenticationForm($this->form, $this->setupId);
+       }
+       
+       public function save() {
+               AbstractForm::save();
+               
+               WCF::getDB()->beginTransaction();
+               
+               $this->returnData = $this->processor->processAuthenticationForm($this->form, $this->setupId);
+               
+               WCF::getDB()->commitTransaction();
+               
+               WCF::getSession()->changeUser($this->user);
+               
+               $this->saved();
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function saved() {
+               AbstractForm::saved();
+               
+               $this->form->cleanup();
+               $this->buildForm();
+               
+               // TODO: Proper success message and hiding of the form.
+               $this->form->showSuccessMessage(true);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function setFormAction() {
+               $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, [
+                       'id' => $this->setupId,
+               ]));
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function assignVariables() {
+               parent::assignVariables();
+               
+               WCF::getTPL()->assign([
+                       'method' => $this->method,
+                       'methods' => $this->methods,
+                       'user' => $this->user,
+                       'setupId' => $this->setupId,
+               ]);
+       }
+}
index a783a9b9b1e182179594cc5c21123a7cc3f7e040..316d12d85262956b11502e813fcc553fe0b1a0a3 100644 (file)
@@ -2,6 +2,7 @@
 namespace wcf\system\user\multifactor;
 use wcf\system\form\builder\container\FormContainer;
 use wcf\system\form\builder\field\ButtonFormField;
+use wcf\system\form\builder\field\TextFormField;
 use wcf\system\form\builder\field\validation\FormFieldValidationError;
 use wcf\system\form\builder\field\validation\FormFieldValidator;
 use wcf\system\form\builder\IFormDocument;
@@ -171,4 +172,88 @@ class BackupMultifactorMethod implements IMultifactorMethod {
                
                return $codes;
        }
+       
+       /**
+        * Returns a code from $codes matching the $userCode. `null` is returned if
+        * no matching code could be found.
+        */
+       private function findValidCode(string $userCode, array $codes): ?array {
+               $manager = PasswordAlgorithmManager::getInstance();
+               
+               $result = null;
+               foreach ($codes as $code) {
+                       [$algorithmName, $hash] = explode(':', $code['code']);
+                       $algorithm = $manager->getAlgorithmFromName($algorithmName);
+                       
+                       // The use of `&` is intentional to disable the shortcutting logic.
+                       if ($algorithm->verify($userCode, $hash) & $code['useTime'] === null) {
+                               $result = $code;
+                       }
+               }
+               
+               return $result;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function createAuthenticationForm(IFormDocument $form, int $setupId): void {
+               $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);
+               
+               $form->appendChildren([
+                       TextFormField::create('code')
+                               ->label('wcf.user.security.multifactor.backup.code')
+                               ->autoFocus()
+                               ->required()
+                               ->addValidator(new FormFieldValidator('code', function (TextFormField $field) use ($codes) {
+                                       $userCode = preg_replace('/\s+/', '', $field->getValue());
+                                       
+                                       if ($this->findValidCode($userCode, $codes) === null) {
+                                               $field->addValidationError(new FormFieldValidationError('invalid'));
+                                       }
+                               })),
+               ]);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function processAuthenticationForm(IFormDocument $form, int $setupId): void {
+               $userCode = \preg_replace('/\s+/', '', $form->getData()['data']['code']);
+               
+               $sql = "SELECT  *
+                       FROM    wcf".WCF_N."_user_multifactor_backup
+                       WHERE   setupID = ?
+                       FOR UPDATE";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([$setupId]);
+               $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
+               
+               $usedCode = $this->findValidCode($userCode, $codes);
+               
+               if ($usedCode === null) {
+                       throw new \RuntimeException('Unable to find a valid code.');
+               }
+               
+               $sql = "UPDATE  wcf".WCF_N."_user_multifactor_backup
+                       SET     useTime = ?
+                       WHERE           setupID = ?
+                               AND     identifier = ?
+                               AND     useTime IS NULL";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([
+                       TIME_NOW,
+                       $setupId,
+                       $usedCode['identifier'],
+               ]);
+               
+               if ($statement->getAffectedRows() !== 1) {
+                       throw new \RuntimeException('Unable to invalidate the code.');
+               }
+       }
 }
index b68bebb573757d047dc2933b2a89c1c147e7c5c0..6a456d585772a5c26faa44fa45780120b1e1fb4c 100644 (file)
@@ -28,7 +28,7 @@ interface IMultifactorMethod {
         * 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
+        * state is reached. Specifically the multifactor method MUST be usable after this method
         * finishes successfully.
         * 
         * An example of an invalid state could be the removal of all multifactor devices.
@@ -39,4 +39,26 @@ interface IMultifactorMethod {
         * @return      mixed   Opaque data that will be passed as `$returnData` in createManagementForm().
         */
        public function processManagementForm(IFormDocument $form, int $setupId);
+       
+       /**
+        * Populates the form to authenticate a user with this method.
+        */
+       public function createAuthenticationForm(IFormDocument $form, int $setupId): void;
+       
+       /**
+        * Updates the database information based on the data received in the authentication form.
+        * 
+        * This method will be run within a database transaction.
+        * 
+        * This method MUST revalidate the information received from the form in a transaction safe way
+        * to prevent concurrent use of the same authentication credentials.
+        * 
+        * An example of such transaction safe use would be invalidating a code by deleting a database row,
+        * checking the number of affected rows and bailing out if the value is not exactly `1`.
+        * 
+        * This method MUST throw an Exception if the database state does not match the expected state.
+        * 
+        * @throws \RuntimeException
+        */
+       public function processAuthenticationForm(IFormDocument $form, int $setupId): void;
 }
index 9ac83165f017540ab00065739e95f73493b7930d..a12a3cef8a4f5fe464fc7d1213a088b2541dc79e 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace wcf\system\user\multifactor;
 use ParagonIE\ConstantTime\Hex;
+use wcf\system\exception\NotImplementedException;
 use wcf\system\form\builder\container\FormContainer;
 use wcf\system\form\builder\field\TextFormField;
 use wcf\system\form\builder\field\validation\FormFieldValidationError;
@@ -67,6 +68,9 @@ class TotpMultifactorMethod implements IMultifactorMethod {
                $form->appendChild($newDeviceContainer);
        }
        
+       /**
+        * @inheritDoc
+        */
        public function processManagementForm(IFormDocument $form, int $setupId): void {
                $formData = $form->getData();
 
@@ -81,4 +85,18 @@ class TotpMultifactorMethod implements IMultifactorMethod {
                        TIME_NOW,
                ]);
        }
+       
+       /**
+        * @inheritDoc
+        */
+       public function createAuthenticationForm(IFormDocument $form, int $setupId): void {
+               throw new NotImplementedException('TODO');
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function processAuthenticationForm(IFormDocument $form, int $setupId): void {
+               throw new NotImplementedException('TODO');
+       }
 }