--- /dev/null
+<?php
+namespace wcf\system\user\multifactor;
+use wcf\system\background\BackgroundQueueHandler;
+use wcf\system\email\SimpleEmail;
+use wcf\system\flood\FloodControl;
+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;
+use wcf\system\form\builder\TemplateFormNode;
+use wcf\system\WCF;
+
+/**
+ * Implementation of one time codes sent via email.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\System\User\Multifactor
+ * @since 5.4
+ */
+class EmailMultifactorMethod implements IMultifactorMethod {
+ private const LIFETIME = 10 * 60;
+ private const REFRESH_AFTER = 2 * 60;
+
+ private const LENGTH = 8;
+
+ private const USER_ATTEMPTS_PER_TEN_MINUTES = 5;
+
+ /**
+ * Returns an empty string.
+ */
+ public function getStatusText(Setup $setup): string {
+ return '';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function createManagementForm(IFormDocument $form, ?Setup $setup, $returnData = null): void {
+ $form->addDefaultButton(false);
+ $form->successMessage('wcf.user.security.multifactor.email.success');
+
+ if ($setup) {
+ $statusContainer = FormContainer::create('enabledContainer')
+ ->label('wcf.user.security.multifactor.email.enabled')
+ ->appendChildren([
+ TemplateFormNode::create('enabled')
+ ->templateName('multifactorManageEmail'),
+ ]);
+ $form->appendChild($statusContainer);
+ }
+ else {
+ $generateContainer = FormContainer::create('enableContainer')
+ ->label('wcf.user.security.multifactor.email.enable')
+ ->appendChildren([
+ ButtonFormField::create('enable')
+ ->buttonLabel('wcf.user.security.multifactor.email.enable')
+ ->objectProperty('action')
+ ->value('enable')
+ ->addValidator(new FormFieldValidator('enable', function (ButtonFormField $field) {
+ if ($field->getValue() === null) {
+ $field->addValidationError(new FormFieldValidationError('unreachable', 'unreachable'));
+ }
+ })),
+ ]);
+ $form->appendChild($generateContainer);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function processManagementForm(IFormDocument $form, Setup $setup): void {
+ $formData = $form->getData();
+ \assert($formData['action'] === 'enable');
+ }
+
+ /**
+ * 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 {
+ $result = null;
+ foreach ($codes as $code) {
+ if (hash_equals($code['code'], $userCode)) {
+ $result = $code;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Sends the email containing the one time code.
+ */
+ private function sendEmail(Setup $setup, string $code): void {
+ $email = new SimpleEmail();
+ $email->setRecipient($setup->getUser());
+
+ $email->setSubject(
+ WCF::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.email.subject')
+ );
+ $email->setHtmlMessage(
+ WCF::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.email.body.html', [
+ 'code' => $code,
+ ])
+ );
+ $email->setMessage(
+ WCF::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.email.body.plain', [
+ 'code' => $code,
+ ])
+ );
+
+ $jobs = $email->getEmail()->getJobs();
+ foreach ($jobs as $job) {
+ BackgroundQueueHandler::getInstance()->performJob($job);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function createAuthenticationForm(IFormDocument $form, Setup $setup): void {
+ $sql = "SELECT code, createTime
+ FROM wcf".WCF_N."_user_multifactor_email
+ WHERE setupID = ?
+ AND createTime > ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([
+ $setup->getId(),
+ (\TIME_NOW - self::LIFETIME),
+ ]);
+ $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
+
+ $lastCode = 0;
+ foreach ($codes as $code) {
+ $lastCode = max($lastCode, $code['createTime']);
+ }
+
+ if ($lastCode < (\TIME_NOW - self::REFRESH_AFTER)) {
+ assert(self::LENGTH <= 9, "Code does not fit into a 32-bit integer.");
+
+ $code = \random_int(
+ 10 ** (self::LENGTH - 1),
+ (10 ** self::LENGTH) - 1
+ );
+ $sql = "INSERT INTO wcf".WCF_N."_user_multifactor_email
+ (setupID, code, createTime)
+ VALUES (?, ?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([
+ $setup->getId(),
+ $code,
+ \TIME_NOW,
+ ]);
+
+ $this->sendEmail($setup, $code);
+ $lastCode = \TIME_NOW;
+ }
+
+ $address = $setup->getUser()->email;
+ $atSign = strrpos($address, '@');
+ $emailDomain = substr($address, $atSign + 1);
+
+ $form->appendChildren([
+ TextFormField::create('code')
+ ->label('wcf.user.security.multifactor.email.code')
+ ->description('wcf.user.security.multifactor.email.code.description', [
+ 'emailDomain' => $emailDomain,
+ 'lastCode' => $lastCode,
+ ])
+ ->autoFocus()
+ ->required()
+ ->addValidator(new FormFieldValidator('code', function (TextFormField $field) use ($codes, $setup) {
+ FloodControl::getInstance()->registerUserContent('com.woltlab.wcf.multifactor.email', $setup->getId());
+ $attempts = FloodControl::getInstance()->countUserContent('com.woltlab.wcf.multifactor.email', $setup->getId(), new \DateInterval('PT10M'));
+ if ($attempts['count'] > self::USER_ATTEMPTS_PER_TEN_MINUTES) {
+ $field->value('');
+ $field->addValidationError(new FormFieldValidationError(
+ 'flood',
+ 'wcf.user.security.multifactor.email.error.flood',
+ $attempts
+ ));
+ return;
+ }
+
+ $userCode = $field->getValue();
+
+ if ($this->findValidCode($userCode, $codes) === null) {
+ $field->value('');
+ $field->addValidationError(new FormFieldValidationError('invalid'));
+ }
+ })),
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function processAuthenticationForm(IFormDocument $form, Setup $setup): void {
+ $userCode = $form->getData()['data']['code'];
+
+ $sql = "SELECT code
+ FROM wcf".WCF_N."_user_multifactor_email
+ WHERE setupID = ?
+ AND createTime > ?
+ FOR UPDATE";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([
+ $setup->getId(),
+ (\TIME_NOW - self::LIFETIME),
+ ]);
+ $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
+
+ $usedCode = $this->findValidCode($userCode, $codes);
+
+ if ($usedCode === null) {
+ throw new \RuntimeException('Unable to find a valid code.');
+ }
+
+ $sql = "DELETE FROM wcf".WCF_N."_user_multifactor_email
+ WHERE setupID = ?
+ AND createTime > ?
+ AND code = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([
+ $setup->getId(),
+ (\TIME_NOW - self::LIFETIME),
+ $usedCode['code'],
+ ]);
+
+ if ($statement->getAffectedRows() !== 1) {
+ throw new \RuntimeException('Unable to invalidate the code.');
+ }
+ }
+
+ /**
+ * Deletes expired codes.
+ */
+ public static function prune(): void {
+ $sql = "DELETE FROM wcf".WCF_N."_user_multifactor_email
+ WHERE createTime < ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([
+ (\TIME_NOW - self::LIFETIME),
+ ]);
+ }
+}
<item name="wcf.user.security.multifactor.initialBackup"><![CDATA[<p>Die Mehrfaktor-Authentifizierung ist ab sofort für {if LANGUAGE_USE_INFORMAL_VARIANT}dein{else}Ihr{/if} Benutzerkonto aktiv. {if LANGUAGE_USE_INFORMAL_VARIANT}Du wirst{else}Sie werden{/if} von nun an bei jedem Login den zusätzlichen Faktor benötigen.</p>\r
<p>Zusätzlich wurden Backup-Codes generiert, mit denen der Zugriff wiederhergestellt werden kann, falls der zusätzliche Faktor unbrauchbar wird.</p>\r
<p>Bitte {if LANGUAGE_USE_INFORMAL_VARIANT}Notiere oder speichere dir{else}Notieren oder speichern Sie{/if} sich die unterhalb angezeigten Notfall-Codes. Ein möglicher Aufbewahrungsort könnte ein Blatt Papier in einem Aktenordner sein.</p>]]></item>
+ <item name="wcf.user.security.multifactor.com.woltlab.wcf.multifactor.email"><![CDATA[Einmalcode über E-Mail]]></item>
+ <item name="wcf.user.security.multifactor.com.woltlab.wcf.multifactor.email.manage"><![CDATA[Einmalcode über E-Mail]]></item>
+ <item name="wcf.user.security.multifactor.email.enabled"><![CDATA[E-Mails aktiviert]]></item>
+ <item name="wcf.user.security.multifactor.email.enabled.description"><![CDATA[<p>Die Mehrfaktor-Authentifizierung über E-Mail ist aktiv. {if LANGUAGE_USE_INFORMAL_VARIANT}Du erhältst{else}Sie erhalten{/if} bei jedem Login eine E-Mail mit einem einmal gültigen Code.</p>
+<p>{if LANGUAGE_USE_INFORMAL_VARIANT}Verwende{else}Verwenden Sie{/if} bitte die <a href="{link controller='AccountSecurity'}{/link}">Übersicht in der Benutzerkonto-Sicherheit</a>, wenn {if LANGUAGE_USE_INFORMAL_VARIANT}du{else}Sie{/if} die Mehrfaktor-Authentifizierung deaktivieren {if LANGUAGE_USE_INFORMAL_VARIANT}möchtest{else}möchten{/if}.</p>]]></item>
+ <item name="wcf.user.security.multifactor.email.enable"><![CDATA[Bestätigung über E-Mail aktivieren]]></item>
+ <item name="wcf.user.security.multifactor.email.success"><![CDATA[Die Bestätigung via E-Mail wurde erfolgreich aktiviert.]]></item>
+ <item name="wcf.user.security.multifactor.email.code"><![CDATA[Einmalcode]]></item>
+ <item name="wcf.user.security.multifactor.email.code.description"><![CDATA[Der Einmalcode wurde um <strong>{$lastCode|date:'H:i:s'}</strong> an {if LANGUAGE_USE_INFORMAL_VARIANT}deine{else}Ihre{/if} E-Mail-Adresse bei <strong>{$emailDomain}</strong> gesendet.]]></item>
+ <item name="wcf.user.security.multifactor.email.subject"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Dein{else}Ihr{/if} Einmalcode for {@PAGE_TITLE|language}]]></item>
+ <item name="wcf.user.security.multifactor.email.body.html"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Dein{else}Ihr{/if} Einmalcode lautet: <pre>{$code}</pre>]]></item>
+ <item name="wcf.user.security.multifactor.email.body.plain"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Dein{else}Ihr{/if} Einmalcode lautet: {$code}]]></item>
</category>
<category name="wcf.user.trophy">
<item name="wcf.user.trophy.trophyPoints"><![CDATA[Trophäen]]></item>