--- /dev/null
+<input type="text" name="{@$field->getPrefixedId()}" value="{$field->getSignedValue()}">
+<kbd>{$field->getEncodedValue()}</kbd>
<?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).
* 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,
+ ]);
}
}
--- /dev/null
+<?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';
+ }
+}
--- /dev/null
+<?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';
+ }
+}
--- /dev/null
+<?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;
+ }
+}
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 (
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;