Add EmailMultifactorMethod
authorTim Düsterhus <duesterhus@woltlab.com>
Tue, 17 Nov 2020 14:19:55 +0000 (15:19 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Wed, 18 Nov 2020 08:55:51 +0000 (09:55 +0100)
com.woltlab.wcf/objectType.xml
com.woltlab.wcf/templates/multifactorManageEmail.tpl [new file with mode: 0644]
wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php
wcfsetup/install/files/lib/system/user/multifactor/EmailMultifactorMethod.class.php [new file with mode: 0644]
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml
wcfsetup/setup/db/install.sql

index c080b520370b5806fa33c1ab85be3d607820c68c..4e7f955338069075e990a5407a947406e14a031d 100644 (file)
                        <name>com.woltlab.wcf.multifactor.totp</name>
                        <definitionname>com.woltlab.wcf.floodControl</definitionname>
                </type>
+               <type>
+                       <name>com.woltlab.wcf.multifactor.email</name>
+                       <definitionname>com.woltlab.wcf.multifactor</definitionname>
+                       <icon>envelope</icon>
+                       <priority>5</priority>
+                       <classname>wcf\system\user\multifactor\EmailMultifactorMethod</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.multifactor.email</name>
+                       <definitionname>com.woltlab.wcf.floodControl</definitionname>
+               </type>
                <!-- /multi factor -->
                <!-- deprecated -->
                <type>
diff --git a/com.woltlab.wcf/templates/multifactorManageEmail.tpl b/com.woltlab.wcf/templates/multifactorManageEmail.tpl
new file mode 100644 (file)
index 0000000..156cd27
--- /dev/null
@@ -0,0 +1 @@
+{lang}wcf.user.security.multifactor.email.enabled.description{/lang}
index 7fbaca55c9e491705a2cf3e40ddfd1b716175151..7a926b4a5edf87ee429fb1a4ec26cac9d6e6e580 100644 (file)
@@ -3,6 +3,7 @@ namespace wcf\system\cronjob;
 use wcf\data\cronjob\Cronjob;
 use wcf\data\object\type\ObjectTypeCache;
 use wcf\system\flood\FloodControl;
+use wcf\system\user\multifactor\EmailMultifactorMethod;
 use wcf\system\visitTracker\VisitTracker;
 use wcf\system\WCF;
 use wcf\util\FileUtil;
@@ -216,5 +217,6 @@ class DailyCleanUpCronjob extends AbstractCronjob {
                }
                
                FloodControl::getInstance()->prune();
+               EmailMultifactorMethod::prune();
        }
 }
diff --git a/wcfsetup/install/files/lib/system/user/multifactor/EmailMultifactorMethod.class.php b/wcfsetup/install/files/lib/system/user/multifactor/EmailMultifactorMethod.class.php
new file mode 100644 (file)
index 0000000..21054dc
--- /dev/null
@@ -0,0 +1,251 @@
+<?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),
+               ]);
+       }
+}
index dc8ca7d0561f8786ec6451f7a95216d703a03703..cfc9c3af01ba46540136adc26dc55eec7b9bcc3b 100644 (file)
@@ -4875,6 +4875,18 @@ Die E-Mail-Adresse des neuen Benutzers lautet: {@$user->email}
                <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>
index 14a6e40df32013422c5d9e450edc61f8432a7f28..47a8624f4938893ff2ce19a9016202ad296329f4 100644 (file)
@@ -4872,6 +4872,18 @@ Open the link below to access the user profile:
                <item name="wcf.user.security.multifactor.initialBackup"><![CDATA[<p>The multi-factor authentication is enabled for your account starting now. Going forward you will need to have your second factor handy for every login.</p>\r
 <p>In addition we generated emergency codes for you. They will allow you to gain access to your account in case your second factor becomes unavailable.</p>\r
 <p>Please carefully note or save the emergency codes shown below. An example of a secure storage could be a piece of paper within a filing cabinet.</p>]]></item>
+               <item name="wcf.user.security.multifactor.com.woltlab.wcf.multifactor.email"><![CDATA[Code via Email]]></item>
+               <item name="wcf.user.security.multifactor.com.woltlab.wcf.multifactor.email.manage"><![CDATA[Code via Email]]></item>
+               <item name="wcf.user.security.multifactor.email.enabled"><![CDATA[Emails Enabled]]></item>
+               <item name="wcf.user.security.multifactor.email.enabled.description"><![CDATA[<p>Multi-factor authentication via email is enabled for your account. You will receive a one time code via email whenever you login.</p>
+<p>Use the <a href="{link controller='AccountSecurity'}{/link}">Overview in Account Security</a> if you want to disable multi-factor authentication.</p>]]></item>
+               <item name="wcf.user.security.multifactor.email.enable"><![CDATA[Enable confirmation via email]]></item>
+               <item name="wcf.user.security.multifactor.email.success"><![CDATA[The confirmation via email has successfully been enabled.]]></item>
+               <item name="wcf.user.security.multifactor.email.code"><![CDATA[One Time Code]]></item>
+               <item name="wcf.user.security.multifactor.email.code.description"><![CDATA[The one time code was sent at <strong>{$lastCode|date:'g:i:s a'}</strong> to your email address at <strong>{$emailDomain}</strong>.]]></item>
+               <item name="wcf.user.security.multifactor.email.subject"><![CDATA[Your One Time Code for {@PAGE_TITLE|language}]]></item>
+               <item name="wcf.user.security.multifactor.email.body.html"><![CDATA[Your one time code is: <pre>{$code}</pre>]]></item>
+               <item name="wcf.user.security.multifactor.email.body.plain"><![CDATA[Your one time code is: {$code}]]></item>
        </category>
        <category name="wcf.user.trophy">
                <item name="wcf.user.trophy.trophyPoints"><![CDATA[Trophies]]></item>
index a1ef45d6ba353807c3e53de14e08008a3d932157..ae24cafc92fdeb4322e4a87bbdf88eb6db9a54ba 100644 (file)
@@ -1695,6 +1695,15 @@ CREATE TABLE wcf1_user_multifactor_backup (
        UNIQUE KEY (setupID, identifier)
 );
 
+DROP TABLE IF EXISTS wcf1_user_multifactor_email;
+CREATE TABLE wcf1_user_multifactor_email (
+       setupID INT(10) NOT NULL,
+       code VARCHAR(255) NOT NULL,
+       createTime INT(10) NOT NULL,
+       
+       UNIQUE KEY (setupID, code)
+);
+
 DROP TABLE IF EXISTS wcf1_user_multifactor_totp;
 CREATE TABLE wcf1_user_multifactor_totp (
        setupID INT(10) NOT NULL,
@@ -2201,6 +2210,9 @@ ALTER TABLE wcf1_user_multifactor ADD FOREIGN KEY (userID) REFERENCES wcf1_user
 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_multifactor_email ADD FOREIGN KEY (setupID) REFERENCES wcf1_user_multifactor (setupID) ON DELETE CASCADE;
+
 ALTER TABLE wcf1_user_multifactor_totp 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;