<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>
<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>
<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}
--- /dev/null
+{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}
--- /dev/null
+<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>
--- /dev/null
+<?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();
+ }
+}
<?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.
* @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.
*/
// 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;
+ }
}
<?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.
* 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);
}
<?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).
// 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");
+ }
}
.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;
+ }
+ }
+}
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 (
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;