<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>
<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"/>
--- /dev/null
+{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}
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;
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);
}
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);
}
/**
/**
* @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);
}
}
--- /dev/null
+<?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,
+ ]);
+ }
+}
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;
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.');
+ }
+ }
}
* 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.
* @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;
}
<?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;
$form->appendChild($newDeviceContainer);
}
+ /**
+ * @inheritDoc
+ */
public function processManagementForm(IFormDocument $form, int $setupId): void {
$formData = $form->getData();
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');
+ }
}