From b15436bfdbc15b1607a4fbd56d62d775a04805e3 Mon Sep 17 00:00:00 2001
From: =?utf8?q?Tim=20D=C3=BCsterhus?=
Date: Fri, 6 Nov 2020 09:38:27 +0100
Subject: [PATCH] Add MultifactorAuthenticationForm
---
com.woltlab.wcf/objectType.xml | 4 +-
com.woltlab.wcf/page.xml | 11 ++
.../templates/multifactorAuthentication.tpl | 25 +++
.../files/lib/acp/form/LoginForm.class.php | 12 +-
.../files/lib/form/LoginForm.class.php | 8 +-
.../MultifactorAuthenticationForm.class.php | 152 ++++++++++++++++++
.../BackupMultifactorMethod.class.php | 85 ++++++++++
.../multifactor/IMultifactorMethod.class.php | 24 ++-
.../TotpMultifactorMethod.class.php | 18 +++
9 files changed, 330 insertions(+), 9 deletions(-)
create mode 100644 com.woltlab.wcf/templates/multifactorAuthentication.tpl
create mode 100644 wcfsetup/install/files/lib/form/MultifactorAuthenticationForm.class.php
diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml
index 3108650d25..08aa82aec0 100644
--- a/com.woltlab.wcf/objectType.xml
+++ b/com.woltlab.wcf/objectType.xml
@@ -1725,12 +1725,14 @@
com.woltlab.wcf.multifactor.backup
com.woltlab.wcf.multifactor
sticky-note
+ 15
wcf\system\user\multifactor\BackupMultifactorMethod
com.woltlab.wcf.multifactor.totp
com.woltlab.wcf.multifactor
mobile
+ 10
wcf\system\user\multifactor\TotpMultifactorMethod
@@ -1748,7 +1750,7 @@
com.woltlab.wcf.page
-
+
com.woltlab.wcf.rebuildData
diff --git a/com.woltlab.wcf/page.xml b/com.woltlab.wcf/page.xml
index 0cd257e6f4..46d1750fb6 100644
--- a/com.woltlab.wcf/page.xml
+++ b/com.woltlab.wcf/page.xml
@@ -838,6 +838,17 @@ E-Mail: [E-Mail-Adresse der verantwortlichen Stelle]
Verantwort
1
1
+
+ system
+ wcf\form\MultifactorAuthenticationForm
+ Multifactor Authentication
+ Mehrfaktor-Authentifizierung
+ 1
+ com.woltlab.wcf.Login
+ 1
+ 1
+ 1
+
diff --git a/com.woltlab.wcf/templates/multifactorAuthentication.tpl b/com.woltlab.wcf/templates/multifactorAuthentication.tpl
new file mode 100644
index 0000000000..2570189b5f
--- /dev/null
+++ b/com.woltlab.wcf/templates/multifactorAuthentication.tpl
@@ -0,0 +1,25 @@
+{capture assign='sidebarLeft'}
+
+ {lang}wcf.user.security.multifactor.methods{/lang}
+
+
+
+
+
+{/capture}
+
+{include file='header' __disableAds=true __sidebarLeftHasMenu=true}
+
+{$user->username}
+
+{@$form->getHtml()}
+
+{include file='footer' __disableAds=true}
diff --git a/wcfsetup/install/files/lib/acp/form/LoginForm.class.php b/wcfsetup/install/files/lib/acp/form/LoginForm.class.php
index 4baeb41c8a..208535a649 100755
--- a/wcfsetup/install/files/lib/acp/form/LoginForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/LoginForm.class.php
@@ -5,9 +5,11 @@ use wcf\data\user\authentication\failure\UserAuthenticationFailureAction;
use wcf\data\user\User;
use wcf\data\user\UserProfile;
use wcf\form\AbstractCaptchaForm;
+use wcf\form\MultifactorAuthenticationForm;
use wcf\system\application\ApplicationHandler;
use wcf\system\exception\NamedUserException;
use wcf\system\exception\UserInputException;
+use wcf\system\request\LinkHandler;
use wcf\system\request\RequestHandler;
use wcf\system\request\RouteHandler;
use wcf\system\user\authentication\EmailUserAuthentication;
@@ -200,16 +202,20 @@ class LoginForm extends AbstractCaptchaForm {
parent::save();
// change user
- WCF::getSession()->changeUser($this->user);
+ $needsMultifactor = WCF::getSession()->changeUserAfterMultifactor($this->user);
$this->saved();
- $this->performRedirect();
+ $this->performRedirect($needsMultifactor);
}
/**
* Performs the redirect after successful authentication.
*/
- protected function performRedirect() {
+ protected function performRedirect(bool $needsMultifactor = false) {
+ if ($needsMultifactor) {
+ $this->url = LinkHandler::getInstance()->getControllerLink(MultifactorAuthenticationForm::class);
+ }
+
if (!empty($this->url)) {
HeaderUtil::redirect($this->url);
}
diff --git a/wcfsetup/install/files/lib/form/LoginForm.class.php b/wcfsetup/install/files/lib/form/LoginForm.class.php
index f1ec31c455..d2b035d665 100644
--- a/wcfsetup/install/files/lib/form/LoginForm.class.php
+++ b/wcfsetup/install/files/lib/form/LoginForm.class.php
@@ -24,14 +24,14 @@ class LoginForm extends \wcf\acp\form\LoginForm {
if (FORCE_LOGIN) WCF::getSession()->unregister('__wsc_forceLoginRedirect');
// change user
- WCF::getSession()->changeUser($this->user);
+ $needsMultifactor = WCF::getSession()->changeUserAfterMultifactor($this->user);
$this->saved();
// redirect to url
WCF::getTPL()->assign('__hideUserMenu', true);
- $this->performRedirect();
+ $this->performRedirect($needsMultifactor);
}
/**
@@ -53,11 +53,11 @@ class LoginForm extends \wcf\acp\form\LoginForm {
/**
* @inheritDoc
*/
- protected function performRedirect() {
+ protected function performRedirect(bool $needsMultifactor = false) {
if (empty($this->url) || mb_stripos($this->url, '?login/') !== false || mb_stripos($this->url, '/login/') !== false) {
$this->url = LinkHandler::getInstance()->getLink();
}
- parent::performRedirect();
+ parent::performRedirect($needsMultifactor);
}
}
diff --git a/wcfsetup/install/files/lib/form/MultifactorAuthenticationForm.class.php b/wcfsetup/install/files/lib/form/MultifactorAuthenticationForm.class.php
new file mode 100644
index 0000000000..1bd63a6959
--- /dev/null
+++ b/wcfsetup/install/files/lib/form/MultifactorAuthenticationForm.class.php
@@ -0,0 +1,152 @@
+
+ * @package WoltLabSuite\Core\Form
+ * @since 5.4
+ */
+class MultifactorAuthenticationForm extends AbstractFormBuilderForm {
+ const AVAILABLE_DURING_OFFLINE_MODE = true;
+
+ /**
+ * @inheritDoc
+ */
+ public $formAction = 'authenticate';
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var ObjectType[]
+ */
+ private $methods;
+
+ /**
+ * @var ObjectType
+ */
+ private $method;
+
+ /**
+ * @var IMultifactorMethod
+ */
+ private $processor;
+
+ /**
+ * @var int
+ */
+ private $setupId;
+
+ /**
+ * @inheritDoc
+ */
+ public function readParameters() {
+ parent::readParameters();
+
+ $userId = WCF::getSession()->getVar('__changeUserAfterMultifactor__');
+ if (!$userId) {
+ throw new PermissionDeniedException();
+ }
+ $this->user = new User($userId);
+ if (!$this->user->userID) {
+ throw new PermissionDeniedException();
+ }
+
+ $this->methods = $this->user->getEnabledMultifactorMethods();
+
+ if (empty($this->methods)) {
+ throw new \LogicException('Unreachable');
+ }
+
+ uasort($this->methods, function (ObjectType $a, ObjectType $b) {
+ return $b->priority <=> $a->priority;
+ });
+
+ $this->setupId = array_keys($this->methods)[0];
+ if (isset($_GET['id'])) {
+ $this->setupId = $_GET['id'];
+ }
+
+ if (!isset($this->methods[$this->setupId])) {
+ throw new IllegalLinkException();
+ }
+
+ $this->method = $this->methods[$this->setupId];
+ assert($this->method->getDefinition()->definitionName === 'com.woltlab.wcf.multifactor');
+
+ $this->processor = $this->method->getProcessor();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function createForm() {
+ parent::createForm();
+
+ $this->processor->createAuthenticationForm($this->form, $this->setupId);
+ }
+
+ public function save() {
+ AbstractForm::save();
+
+ WCF::getDB()->beginTransaction();
+
+ $this->returnData = $this->processor->processAuthenticationForm($this->form, $this->setupId);
+
+ WCF::getDB()->commitTransaction();
+
+ WCF::getSession()->changeUser($this->user);
+
+ $this->saved();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function saved() {
+ AbstractForm::saved();
+
+ $this->form->cleanup();
+ $this->buildForm();
+
+ // TODO: Proper success message and hiding of the form.
+ $this->form->showSuccessMessage(true);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function setFormAction() {
+ $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, [
+ 'id' => $this->setupId,
+ ]));
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([
+ 'method' => $this->method,
+ 'methods' => $this->methods,
+ 'user' => $this->user,
+ 'setupId' => $this->setupId,
+ ]);
+ }
+}
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 a783a9b9b1..316d12d852 100644
--- a/wcfsetup/install/files/lib/system/user/multifactor/BackupMultifactorMethod.class.php
+++ b/wcfsetup/install/files/lib/system/user/multifactor/BackupMultifactorMethod.class.php
@@ -2,6 +2,7 @@
namespace wcf\system\user\multifactor;
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;
@@ -171,4 +172,88 @@ class BackupMultifactorMethod implements IMultifactorMethod {
return $codes;
}
+
+ /**
+ * 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 {
+ $manager = PasswordAlgorithmManager::getInstance();
+
+ $result = null;
+ foreach ($codes as $code) {
+ [$algorithmName, $hash] = explode(':', $code['code']);
+ $algorithm = $manager->getAlgorithmFromName($algorithmName);
+
+ // The use of `&` is intentional to disable the shortcutting logic.
+ if ($algorithm->verify($userCode, $hash) & $code['useTime'] === null) {
+ $result = $code;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function createAuthenticationForm(IFormDocument $form, int $setupId): void {
+ $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);
+
+ $form->appendChildren([
+ TextFormField::create('code')
+ ->label('wcf.user.security.multifactor.backup.code')
+ ->autoFocus()
+ ->required()
+ ->addValidator(new FormFieldValidator('code', function (TextFormField $field) use ($codes) {
+ $userCode = preg_replace('/\s+/', '', $field->getValue());
+
+ if ($this->findValidCode($userCode, $codes) === null) {
+ $field->addValidationError(new FormFieldValidationError('invalid'));
+ }
+ })),
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function processAuthenticationForm(IFormDocument $form, int $setupId): void {
+ $userCode = \preg_replace('/\s+/', '', $form->getData()['data']['code']);
+
+ $sql = "SELECT *
+ FROM wcf".WCF_N."_user_multifactor_backup
+ WHERE setupID = ?
+ FOR UPDATE";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([$setupId]);
+ $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
+
+ $usedCode = $this->findValidCode($userCode, $codes);
+
+ if ($usedCode === null) {
+ throw new \RuntimeException('Unable to find a valid code.');
+ }
+
+ $sql = "UPDATE wcf".WCF_N."_user_multifactor_backup
+ SET useTime = ?
+ WHERE setupID = ?
+ AND identifier = ?
+ AND useTime IS NULL";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([
+ TIME_NOW,
+ $setupId,
+ $usedCode['identifier'],
+ ]);
+
+ if ($statement->getAffectedRows() !== 1) {
+ throw new \RuntimeException('Unable to invalidate the code.');
+ }
+ }
}
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 b68bebb573..6a456d5857 100644
--- a/wcfsetup/install/files/lib/system/user/multifactor/IMultifactorMethod.class.php
+++ b/wcfsetup/install/files/lib/system/user/multifactor/IMultifactorMethod.class.php
@@ -28,7 +28,7 @@ interface IMultifactorMethod {
* 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
+ * state is reached. Specifically the multifactor method MUST be usable after this method
* finishes successfully.
*
* An example of an invalid state could be the removal of all multifactor devices.
@@ -39,4 +39,26 @@ interface IMultifactorMethod {
* @return mixed Opaque data that will be passed as `$returnData` in createManagementForm().
*/
public function processManagementForm(IFormDocument $form, int $setupId);
+
+ /**
+ * Populates the form to authenticate a user with this method.
+ */
+ public function createAuthenticationForm(IFormDocument $form, int $setupId): void;
+
+ /**
+ * Updates the database information based on the data received in the authentication form.
+ *
+ * This method will be run within a database transaction.
+ *
+ * This method MUST revalidate the information received from the form in a transaction safe way
+ * to prevent concurrent use of the same authentication credentials.
+ *
+ * An example of such transaction safe use would be invalidating a code by deleting a database row,
+ * checking the number of affected rows and bailing out if the value is not exactly `1`.
+ *
+ * This method MUST throw an Exception if the database state does not match the expected state.
+ *
+ * @throws \RuntimeException
+ */
+ public function processAuthenticationForm(IFormDocument $form, int $setupId): void;
}
diff --git a/wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php b/wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php
index 9ac83165f0..a12a3cef8a 100644
--- a/wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php
+++ b/wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php
@@ -1,6 +1,7 @@
appendChild($newDeviceContainer);
}
+ /**
+ * @inheritDoc
+ */
public function processManagementForm(IFormDocument $form, int $setupId): void {
$formData = $form->getData();
@@ -81,4 +85,18 @@ class TotpMultifactorMethod implements IMultifactorMethod {
TIME_NOW,
]);
}
+
+ /**
+ * @inheritDoc
+ */
+ public function createAuthenticationForm(IFormDocument $form, int $setupId): void {
+ throw new NotImplementedException('TODO');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function processAuthenticationForm(IFormDocument $form, int $setupId): void {
+ throw new NotImplementedException('TODO');
+ }
}
--
2.20.1