+
+
{lang}wcf.user.security.multifactor.{$method->objectType}{/lang}
@@ -26,6 +26,12 @@
{$method->getProcessor()->getStatusText($__wcf->user)}
+
+
{/foreach}
diff --git a/com.woltlab.wcf/templates/multifactorManage.tpl b/com.woltlab.wcf/templates/multifactorManage.tpl
new file mode 100644
index 0000000000..ad34602b3d
--- /dev/null
+++ b/com.woltlab.wcf/templates/multifactorManage.tpl
@@ -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
index 0000000000..1fff904d7a
--- /dev/null
+++ b/com.woltlab.wcf/templates/multifactorManageBackup.tpl
@@ -0,0 +1,8 @@
+
+{foreach from=$codes item='code'}
+-
+ {foreach from=$code[chunks] item='chunk'}{$chunk}{/foreach}
+ {if $code[useTime]}({$code[useTime]|plainTime}){/if}
+
+{/foreach}
+
diff --git a/wcfsetup/install/files/lib/form/MultifactorManageForm.class.php b/wcfsetup/install/files/lib/form/MultifactorManageForm.class.php
new file mode 100644
index 0000000000..36e83a4fd7
--- /dev/null
+++ b/wcfsetup/install/files/lib/form/MultifactorManageForm.class.php
@@ -0,0 +1,179 @@
+
+ * @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();
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/user/multifactor/BackupMultifactorMethod.class.php b/wcfsetup/install/files/lib/system/user/multifactor/BackupMultifactorMethod.class.php
index ad4f32f215..ac3fd59627 100644
--- a/wcfsetup/install/files/lib/system/user/multifactor/BackupMultifactorMethod.class.php
+++ b/wcfsetup/install/files/lib/system/user/multifactor/BackupMultifactorMethod.class.php
@@ -1,6 +1,16 @@
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;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/user/multifactor/IMultifactorMethod.class.php b/wcfsetup/install/files/lib/system/user/multifactor/IMultifactorMethod.class.php
index 33ec715ccd..22abe11556 100644
--- a/wcfsetup/install/files/lib/system/user/multifactor/IMultifactorMethod.class.php
+++ b/wcfsetup/install/files/lib/system/user/multifactor/IMultifactorMethod.class.php
@@ -1,6 +1,7 @@