From b4a0d5c680d57ce7bd9d43ed01167aa1037443d3 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Tim=20D=C3=BCsterhus?= Date: Thu, 5 Nov 2020 14:42:58 +0100 Subject: [PATCH] Add support for adding devices to TotpMultifactorMethod --- .../templates/__totpSecretField.tpl | 2 + .../TotpMultifactorMethod.class.php | 60 +++++++++++- .../multifactor/totp/CodeFormField.class.php | 57 +++++++++++ .../totp/SecretFormField.class.php | 71 ++++++++++++++ .../user/multifactor/totp/Totp.class.php | 98 +++++++++++++++++++ wcfsetup/setup/db/install.sql | 14 +++ 6 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 com.woltlab.wcf/templates/__totpSecretField.tpl create mode 100644 wcfsetup/install/files/lib/system/user/multifactor/totp/CodeFormField.class.php create mode 100644 wcfsetup/install/files/lib/system/user/multifactor/totp/SecretFormField.class.php create mode 100644 wcfsetup/install/files/lib/system/user/multifactor/totp/Totp.class.php diff --git a/com.woltlab.wcf/templates/__totpSecretField.tpl b/com.woltlab.wcf/templates/__totpSecretField.tpl new file mode 100644 index 0000000000..d44f22f315 --- /dev/null +++ b/com.woltlab.wcf/templates/__totpSecretField.tpl @@ -0,0 +1,2 @@ + +{$field->getEncodedValue()} 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 0d5f5bb3f8..9ac83165f0 100644 --- a/wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php +++ b/wcfsetup/install/files/lib/system/user/multifactor/TotpMultifactorMethod.class.php @@ -1,7 +1,14 @@ 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 index 0000000000..5427c4a94c --- /dev/null +++ b/wcfsetup/install/files/lib/system/user/multifactor/totp/CodeFormField.class.php @@ -0,0 +1,57 @@ + + * @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 index 0000000000..c935f50698 --- /dev/null +++ b/wcfsetup/install/files/lib/system/user/multifactor/totp/SecretFormField.class.php @@ -0,0 +1,71 @@ + + * @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 index 0000000000..c884cd494e --- /dev/null +++ b/wcfsetup/install/files/lib/system/user/multifactor/totp/Totp.class.php @@ -0,0 +1,98 @@ + + * @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; + } +} diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index aee45a93d4..aeafdc53f7 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -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; -- 2.20.1