From afbc4e211aba607fed2005c7670027dc754c4a19 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Tim=20D=C3=BCsterhus?= Date: Tue, 17 Nov 2020 15:19:55 +0100 Subject: [PATCH] Add EmailMultifactorMethod --- com.woltlab.wcf/objectType.xml | 11 + .../templates/multifactorManageEmail.tpl | 1 + .../cronjob/DailyCleanUpCronjob.class.php | 2 + .../EmailMultifactorMethod.class.php | 251 ++++++++++++++++++ wcfsetup/install/lang/de.xml | 12 + wcfsetup/install/lang/en.xml | 12 + wcfsetup/setup/db/install.sql | 12 + 7 files changed, 301 insertions(+) create mode 100644 com.woltlab.wcf/templates/multifactorManageEmail.tpl create mode 100644 wcfsetup/install/files/lib/system/user/multifactor/EmailMultifactorMethod.class.php diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index c080b52037..4e7f955338 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -1743,6 +1743,17 @@ com.woltlab.wcf.multifactor.totp com.woltlab.wcf.floodControl + + com.woltlab.wcf.multifactor.email + com.woltlab.wcf.multifactor + envelope + 5 + wcf\system\user\multifactor\EmailMultifactorMethod + + + com.woltlab.wcf.multifactor.email + com.woltlab.wcf.floodControl + diff --git a/com.woltlab.wcf/templates/multifactorManageEmail.tpl b/com.woltlab.wcf/templates/multifactorManageEmail.tpl new file mode 100644 index 0000000000..156cd27bd6 --- /dev/null +++ b/com.woltlab.wcf/templates/multifactorManageEmail.tpl @@ -0,0 +1 @@ +{lang}wcf.user.security.multifactor.email.enabled.description{/lang} diff --git a/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php b/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php index 7fbaca55c9..7a926b4a5e 100644 --- a/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php +++ b/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php @@ -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 index 0000000000..21054dc293 --- /dev/null +++ b/wcfsetup/install/files/lib/system/user/multifactor/EmailMultifactorMethod.class.php @@ -0,0 +1,251 @@ + + * @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), + ]); + } +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index dc8ca7d056..cfc9c3af01 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4875,6 +4875,18 @@ Die E-Mail-Adresse des neuen Benutzers lautet: {@$user->email} 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.

Zusätzlich wurden Backup-Codes generiert, mit denen der Zugriff wiederhergestellt werden kann, falls der zusätzliche Faktor unbrauchbar wird.

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.

]]>
+ + + + 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.

+

{if LANGUAGE_USE_INFORMAL_VARIANT}Verwende{else}Verwenden Sie{/if} bitte die Übersicht in der Benutzerkonto-Sicherheit, 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}.

]]>
+ + + + {$lastCode|date:'H:i:s'} an {if LANGUAGE_USE_INFORMAL_VARIANT}deine{else}Ihre{/if} E-Mail-Adresse bei {$emailDomain} gesendet.]]> + + {$code}]]> + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 14a6e40df3..47a8624f49 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4872,6 +4872,18 @@ Open the link below to access the user profile: 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.

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.

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.

]]>
+ + + + Multi-factor authentication via email is enabled for your account. You will receive a one time code via email whenever you login.

+

Use the Overview in Account Security if you want to disable multi-factor authentication.

]]>
+ + + + {$lastCode|date:'g:i:s a'} to your email address at {$emailDomain}.]]> + + {$code}]]> +
diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index a1ef45d6ba..ae24cafc92 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -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; -- 2.20.1