Add support for adding devices to TotpMultifactorMethod
authorTim Düsterhus <duesterhus@woltlab.com>
Thu, 5 Nov 2020 13:42:58 +0000 (14:42 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Mon, 16 Nov 2020 16:25:16 +0000 (17:25 +0100)
com.woltlab.wcf/templates/__totpSecretField.tpl [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php
wcfsetup/install/files/lib/system/user/multifactor/totp/CodeFormField.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/multifactor/totp/SecretFormField.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/user/multifactor/totp/Totp.class.php [new file with mode: 0644]
wcfsetup/setup/db/install.sql

diff --git a/com.woltlab.wcf/templates/__totpSecretField.tpl b/com.woltlab.wcf/templates/__totpSecretField.tpl
new file mode 100644 (file)
index 0000000..d44f22f
--- /dev/null
@@ -0,0 +1,2 @@
+<input type="text" name="{@$field->getPrefixedId()}" value="{$field->getSignedValue()}">
+<kbd>{$field->getEncodedValue()}</kbd>
index 0d5f5bb3f85af61e8cb6085de730d7c8353e4c8a..9ac83165f017540ab00065739e95f73493b7930d 100644 (file)
@@ -1,7 +1,14 @@
 <?php
 namespace wcf\system\user\multifactor;
-use wcf\system\exception\NotImplementedException;
+use ParagonIE\ConstantTime\Hex;
+use wcf\system\form\builder\container\FormContainer;
+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\user\multifactor\totp\CodeFormField;
+use wcf\system\user\multifactor\totp\SecretFormField;
+use wcf\system\WCF;
 
 /**
  * Implementation of the Time-based One-time Password Algorithm (RFC 6238).
@@ -17,18 +24,61 @@ class TotpMultifactorMethod implements IMultifactorMethod {
         * Returns the number of devices the user set up.
         */
        public function getStatusText(int $setupId): string {
-               // TODO: Return a proper text.
-               return random_int(10000, 99999)." devices configured";
+               $sql = "SELECT  COUNT(*)
+                       FROM    wcf".WCF_N."_user_multifactor_totp
+                       WHERE   setupID = ?";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([$setupId]);
+               
+               // TODO: Language item
+               return $statement->fetchSingleColumn()." devices configured";
        }
        
        /**
         * @inheritDoc
         */
        public function createManagementForm(IFormDocument $form, ?int $setupId, $returnData = null): void {
-               throw new NotImplementedException("TODO");
+               if ($setupId) {
+                       
+               }
+               
+               $newDeviceContainer = FormContainer::create('newDevice')
+                       ->label('wcf.user.security.multifactor.totp.newDevice')
+                       ->appendChildren([
+                               SecretFormField::create(),
+                               CodeFormField::create()
+                                       ->label('wcf.user.security.multifactor.totp.code')
+                                       ->required()
+                                       ->addValidator(new FormFieldValidator('totpSecretValid', function (CodeFormField $field) {
+                                               /** @var SecretFormField $secret */
+                                               $secret = $field->getDocument()->getNodeById('secret');
+                                               $totp = $secret->getTotp();
+                                               
+                                               $minCounter = 0;
+                                               if (!$totp->validateTotpCode($field->getValue(), $minCounter, new \DateTime())) {
+                                                       $field->addValidationError(new FormFieldValidationError('invalid'));
+                                               }
+                                               $field->minCounter($minCounter);
+                                       })),
+                               TextFormField::create('deviceName')
+                                       ->label('wcf.user.security.multifactor.totp.deviceName')
+                                       ->placeholder('wcf.user.security.multifactor.totp.deviceName.placeholder'),
+                       ]);
+               $form->appendChild($newDeviceContainer);
        }
        
        public function processManagementForm(IFormDocument $form, int $setupId): void {
-               throw new NotImplementedException("TODO");
+               $formData = $form->getData();
+
+               $sql = "INSERT INTO wcf".WCF_N."_user_multifactor_totp (setupID, deviceID, deviceName, secret, minCounter, createTime) VALUES (?, ?, ?, ?, ?, ?)";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute([
+                       $setupId,
+                       Hex::encode(\random_bytes(16)),
+                       $formData['data']['deviceName'],
+                       $formData['data']['secret'],
+                       $formData['data']['code']['minCounter'],
+                       TIME_NOW,
+               ]);
        }
 }
diff --git a/wcfsetup/install/files/lib/system/user/multifactor/totp/CodeFormField.class.php b/wcfsetup/install/files/lib/system/user/multifactor/totp/CodeFormField.class.php
new file mode 100644 (file)
index 0000000..5427c4a
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+namespace wcf\system\user\multifactor\totp;
+use wcf\system\form\builder\field\TDefaultIdFormField;
+use wcf\system\form\builder\field\TextFormField;
+
+/**
+ * Handles the input of a TOTP code.
+ *
+ * @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\Totp
+ * @since      5.4
+ */
+class CodeFormField extends TextFormField {
+       use TDefaultIdFormField;
+       
+       /**
+        * @var ?int
+        */
+       protected $minCounter;
+       
+       public function __construct() {
+               $this->minimumLength(Totp::CODE_LENGTH);
+               $this->maximumLength(Totp::CODE_LENGTH);
+       }
+       
+       /**
+        * Used to carry the minCounter value along.
+        */
+       public function minCounter(int $minCounter): self {
+               $this->minCounter = $minCounter;
+               
+               return $this;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getSaveValue(): array {
+               if ($this->minCounter === null) {
+                       throw new \BadMethodCallException('No minCounter was set. Did you validate this field?');
+               }
+               
+               return [
+                       'value' => $this->getValue(),
+                       'minCounter' => $this->minCounter,
+               ];
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected static function getDefaultId(): string {
+               return 'code';
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/user/multifactor/totp/SecretFormField.class.php b/wcfsetup/install/files/lib/system/user/multifactor/totp/SecretFormField.class.php
new file mode 100644 (file)
index 0000000..c935f50
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+namespace wcf\system\user\multifactor\totp;
+use ParagonIE\ConstantTime\Base32;
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\form\builder\field\TDefaultIdFormField;
+use wcf\util\CryptoUtil;
+
+/**
+ * Shows the TOTP secret as a QR code.
+ *
+ * @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\Totp
+ * @since      5.4
+ */
+class SecretFormField extends AbstractFormField {
+       use TDefaultIdFormField;
+       
+       /**
+        * @inheritDoc
+        */
+       protected $templateName = '__totpSecretField';
+       
+       public function __construct() {
+               $this->value(Totp::generateSecret());
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function readValue(): self {
+               if ($this->getDocument()->hasRequestData($this->getPrefixedId())) {
+                       $value = CryptoUtil::getValueFromSignedString($this->getDocument()->getRequestData($this->getPrefixedId()));
+                       
+                       if ($value !== null) {
+                               $this->value = $value;
+                       }
+               }
+               
+               return $this;
+       }
+       
+       /**
+        * Returns the encoded value for use within the QR code.
+        */
+       public function getEncodedValue(): string {
+               return Base32::encodeUpperUnpadded($this->getValue());
+       }
+       
+       /**
+        * Returns the signed value for use within the hidden input.
+        */
+       public function getSignedValue(): string {
+               return CryptoUtil::createSignedString($this->getValue());
+       }
+       
+       /**
+        * Returns a Totp handler for the field's secret.
+        */
+       public function getTotp(): Totp {
+               return new Totp($this->getValue());
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected static function getDefaultId(): string {
+               return 'secret';
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/user/multifactor/totp/Totp.class.php b/wcfsetup/install/files/lib/system/user/multifactor/totp/Totp.class.php
new file mode 100644 (file)
index 0000000..c884cd4
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+namespace wcf\system\user\multifactor\totp;
+
+/**
+ * Implementation of the Time-based One-time Password Algorithm (RFC 6238).
+ *
+ * @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
+ */
+final class Totp {
+       /**
+        * The number of digits of the resulting code.
+        */
+       public const CODE_LENGTH = 6;
+       
+       /**
+        * The number of seconds after which the internal counter increases.
+        */
+       private const TIME_STEP = 30;
+       
+       /**
+        * The number of additional time steps allowed into each direction.
+        * 
+        * `2` into each direction allows a total of 5`` codes in total.
+        */
+       private const LEEWAY = 2;
+       
+       /**
+        * @var string
+        */
+       private $secret;
+       
+       public function __construct(string $secret) {
+               $this->secret = $secret;
+       }
+       
+       /**
+        * Returns a random secret.
+        */
+       public static function generateSecret(): string {
+               return \random_bytes(16);
+       }
+       
+       /**
+        * Generates the HOTP code for the given counter.
+        */
+       private function generateHotpCode(int $counter): string {
+               $hash = \hash_hmac('sha1', \pack('J', $counter), $this->secret, true);
+               $offset = \ord($hash[\mb_strlen($hash, '8bit') - 1]) & 0xf;
+               $binary =
+                       ((\ord($hash[$offset + 0]) & 0x7f) << 24) |
+                       ((\ord($hash[$offset + 1]) & 0xff) << 16) |
+                       ((\ord($hash[$offset + 2]) & 0xff) << 8) |
+                       ((\ord($hash[$offset + 3]) & 0xff) << 0);
+               
+               $otp = \str_pad($binary % \pow(10, self::CODE_LENGTH), self::CODE_LENGTH, "0", \STR_PAD_LEFT);
+               
+               return $otp;
+       }
+       
+       /**
+        * Generates the TOTP code for the given timestamp.
+        */
+       public function generateTotpCode(\DateTime $time): string {
+               $counter = \intval($time->getTimestamp() / self::TIME_STEP);
+               
+               return $this->generateHotpCode($counter);
+       }
+       
+       /**
+        * Validates the given userCode against the given minimum counter and time.
+        * 
+        * If this method returns `true` the $minCounter value will be updated to the counter that
+        * was used for verification. You MUST store the updated $minCounter to prevent code re-use.
+        */
+       public function validateTotpCode(string $userCode, int &$minCounter, \DateTime $time): bool {
+               $counter = intval($time->getTimestamp() / self::TIME_STEP);
+               
+               for ($offset = -self::LEEWAY; $offset < self::LEEWAY; $offset++) {
+                       $possibleCode = $this->generateHotpCode($counter + $offset);
+                       
+                       if (\hash_equals($possibleCode, $userCode)) {
+                               // Check for possible code re-use.
+                               if ($counter + $offset > $minCounter) {
+                                       $minCounter = $counter + $offset;
+                                       return true;
+                               }
+                               
+                               return false;
+                       }
+               }
+               
+               return false;
+       }
+}
index aee45a93d47e422f65cd44ee76706111cde2b8a6..aeafdc53f78ad09ed383887fdd6a8fa14c2e39ce 100644 (file)
@@ -1694,6 +1694,19 @@ CREATE TABLE wcf1_user_multifactor_backup (
        UNIQUE KEY (setupID, identifier)
 );
 
+DROP TABLE IF EXISTS wcf1_user_multifactor_totp;
+CREATE TABLE wcf1_user_multifactor_totp (
+       setupID INT(10) NOT NULL,
+       deviceID VARCHAR(255) NOT NULL,
+       deviceName VARCHAR(255) NOT NULL,
+       secret VARBINARY(255) NOT NULL,
+       minCounter INT(10) NOT NULL,
+       createTime INT(10) NOT NULL,
+       useTime INT(10) DEFAULT NULL,
+       
+       UNIQUE KEY (setupID, deviceID)
+);
+
 -- notifications
 DROP TABLE IF EXISTS wcf1_user_notification;
 CREATE TABLE wcf1_user_notification (
@@ -2187,6 +2200,7 @@ 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_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;
 ALTER TABLE wcf1_user_object_watch ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE;