From 783fc82fc9943eb8e6cce649be66cd11082eecc6 Mon Sep 17 00:00:00 2001 From: Bubka <858858+Bubka@users.noreply.github.com> Date: Thu, 12 Nov 2020 00:09:33 +0100 Subject: [PATCH] Rebuild TwoFAccount model on top of an OTPHP object --- app/TwoFAccount.php | 339 ++++++++++++++++++++++++++++------- resources/lang/en/errors.php | 3 + 2 files changed, 275 insertions(+), 67 deletions(-) diff --git a/app/TwoFAccount.php b/app/TwoFAccount.php index 7dbd00ad..cb8587ba 100644 --- a/app/TwoFAccount.php +++ b/app/TwoFAccount.php @@ -3,6 +3,7 @@ namespace App; use Exception; +use OTPHP\TOTP; use OTPHP\HOTP; use OTPHP\Factory; use App\Classes\Options; @@ -39,7 +40,7 @@ class TwoFAccount extends Model implements Sortable * * @var array */ - protected $appends = ['otpType', 'counter', 'isConsistent']; + protected $appends = ['isConsistent', 'otpType', 'secret', 'algorithm', 'digits', 'totpPeriod', 'hotpCounter', 'imageLink']; /** @@ -47,7 +48,15 @@ class TwoFAccount extends Model implements Sortable * * @var array */ - protected $hidden = ['uri']; + protected $hidden = ['uri', 'secret', 'algorithm']; + + + /** + * An OTP object from package Spomky-Labs/otphp + * + * @var OTPHP/TOTP || OTPHP/HOTP + */ + protected $otp; /** @@ -58,6 +67,10 @@ class TwoFAccount extends Model implements Sortable protected static function boot() { parent::boot(); + + static::retrieved(function ($model) { + $model->populateFromUri(); + }); static::deleted(function ($model) { Storage::delete('public/icons/' . $model->icon); @@ -124,7 +137,6 @@ class TwoFAccount extends Model implements Sortable */ public function setIconAttribute($value) { - if( !Storage::exists('public/icons/' . $value) && \App::environment('testing') == false ) { $this->attributes['icon'] = ''; @@ -134,46 +146,28 @@ class TwoFAccount extends Model implements Sortable $this->attributes['icon'] = $value; } } - - + /** - * Get the account OTP type. - * - * @return string - */ - public function getOtpTypeAttribute() + * Get decyphered uri + * + * @param string $value + * @return string + */ + public function getUriAttribute($value) { - switch (substr( $this->uri, 0, 15 )) { - - case "otpauth://totp/" : - return 'totp'; - break; - - case "otpauth://hotp/" : - return 'hotp'; - break; - - default: - return null; + if( Options::get('useEncryption') ) + { + try { + return Crypt::decryptString($value); + } + catch (Exception $e) { + return '*encrypted*'; + } } - } - - /** - * Get the account counter in case of HOTP. - * - * @return integer - */ - public function getCounterAttribute() - { - - if( $this->otpType === 'hotp' ) { - $otp = Factory::loadFromProvisioningUri($this->uri); - - return $otp->getCounter(); + else { + return $value; } - - return null; } @@ -188,13 +182,14 @@ class TwoFAccount extends Model implements Sortable $this->attributes['uri'] = Options::get('useEncryption') ? Crypt::encryptString($value) : $value; } + /** - * Get decyphered uri + * Get decyphered account * * @param string $value * @return string */ - public function getUriAttribute($value) + public function getAccountAttribute($value) { if( Options::get('useEncryption') ) { @@ -222,39 +217,249 @@ class TwoFAccount extends Model implements Sortable $this->attributes['account'] = Options::get('useEncryption') ? Crypt::encryptString($value) : $value; } - /** - * Get decyphered account - * - * @param string $value - * @return string - */ - public function getAccountAttribute($value) - { - if( Options::get('useEncryption') ) - { - try { - return Crypt::decryptString($value); - } - catch (Exception $e) { - return '*encrypted*'; - } - } - else { - return $value; - } - } - /** - * Null empty icon resource has gone + * Get IsConsistent attribute * - * @param string $value - * @return string + * @return bool * */ - public function getIsConsistentAttribute($value) + public function getIsConsistentAttribute() { return $this->uri === '*encrypted*' || $this->account === '*encrypted*' ? false : true; } -} + + /** + * Populate some attributes of the model from an uri + * + * @param $foreignUri an URI to parse + * @return Boolean wether or not the URI provided a valid OTP resource + */ + public function populateFromUri(String $foreignUri = null) : bool + { + // No uri to parse + if( !$this->uri && !$foreignUri ) { + return false; + } + + // The foreign uri is used in first place. This parameter is passed + // when we need a TwoFAccount new object, for example after a qrcode upload + // or for a preview + $uri = $foreignUri ? $foreignUri : $this->uri; + + try { + + $this->otp = Factory::loadFromProvisioningUri($uri); + + // Account and service values are already recorded in the db so we set them + // only when the uri used is a foreign uri, otherwise it would override + // the db values + if( $foreignUri ) { + + if(!$this->otp->getIssuer()) { + $this->otp->setIssuer($this->otp->getLabel()); + $this->otp->setLabel(''); + } + + $this->service = $this->otp->getIssuer(); + $this->account = $this->otp->getLabel(); + $this->uri = $foreignUri; + } + + return true; + } + catch (\Exception $e) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'qrcode' => __('errors.response.no_valid_otp') + ]); + } + + } + + + /** + * Populate attributes with direct values + * @param Array|array $attrib All attributes to be set + */ + public function populate(Array $attrib = []) + { + // The Type and Secret attributes are mandatory + // All other attributes have default value set by OTPHP + + if( strcasecmp($attrib['otpType'], 'totp') == 0 && strcasecmp($attrib['otpType'], 'hotp') == 0 ) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'otpType' => __('errors.not_a_supported_otp_type') + ]); + } + + if( !$attrib['secret'] ) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'secret' => __('errors.cannot_create_otp_without_secret') + ]); + } + + try { + // Create an OTP object using our secret but with default parameters + $secret = $attrib['secretIsBase32Encoded'] === 1 ? $attrib['secret'] : Encoding::base32EncodeUpper($attrib['secret']); + + $this->otp = strtolower($attrib['otpType']) === 'totp' ? TOTP::create($secret) : HOTP::create($secret); + + // and we change parameters if needed + if ($attrib['service']) { + $this->service = $attrib['service']; + $this->otp->setIssuer( $attrib['service'] ); + } + if ($attrib['account']) { + $this->account = $attrib['account']; + $this->otp->setLabel( $attrib['account'] ); + } + if ($attrib['icon']) { $this->account = $attrib['icon']; } + if ($attrib['digits'] > 0) { $this->otp->setParameter( 'digits', (int) $attrib['digits'] ); } + if ($attrib['algorithm']) { $this->otp->setParameter( 'digest', $attrib['algorithm'] ); } + if ($attrib['totpPeriod'] && $attrib['otpType'] !== 'totp') { $this->otp->setParameter( 'period', (int) $attrib['totpPeriod'] ); } + if ($attrib['hotpCounter'] && $attrib['otpType'] !== 'hotp') { $this->otp->setParameter( 'counter', (int) $attrib['hotpCounter'] ); } + if ($attrib['imageLink']) { $this->otp->setParameter( 'image', $attrib['imageLink'] ); } + + // We can now generate a fresh URI + $this->uri = $this->otp->getProvisioningUri(); + + } + catch (\Exception $e) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'qrcode' => __('errors.cannot_create_otp_without_parameters') + ]); + } + + } + + + /** + * Update the uri attribute using the OTP object + * @return void + */ + private function refreshUri() : void + { + $this->uri = urldecode($this->otp->getProvisioningUri()); + } + + + /** + * Generate a token which is valid at the current time (now) + * @return string The generated token + */ + public function token() : string + { + return $this->otpType === 'totp' ? $this->otp->now() : $this->otp->at($this->otp->getCounter()); + } + + + /** + * Increment the hotp counter by 1 + * @return string The generated token + */ + public function increaseHotpCounter() : void + { + if( $this->otpType === 'hotp' ) { + $this->hotpCounter = $this->hotpCounter + 1; + $this->refreshUri(); + } + } + + + /** + * get OTP Type attribute + * + * @return string + * + */ + public function getOtpTypeAttribute() + { + return get_class($this->otp) === 'OTPHP\TOTP' ? 'totp' : 'hotp'; + } + + + /** + * get Secret attribute + * + * @return string + * + */ + public function getSecretAttribute() + { + return $this->otp->getSecret(); + } + + + /** + * get algorithm attribute + * + * @return string + * + */ + public function getAlgorithmAttribute() + { + return $this->otp->getDigest(); // default is SHA1 + } + + + /** + * get Digits attribute + * + * @return string + * + */ + public function getDigitsAttribute() + { + return $this->otp->getDigits(); // Default is 6 + } + + + /** + * get TOTP Period attribute + * + * @return string + * + */ + public function getTotpPeriodAttribute() + { + return $this->otpType === 'totp' ? $this->otp->getPeriod() : null; // Default is 30 + } + + + /** + * get HOTP counter attribute + * + * @return string + * + */ + public function getHotpCounterAttribute() + { + return $this->otpType === 'hotp' ? $this->otp->getCounter() : null; // Default is 0 + } + + + /** + * set HOTP counter attribute + * + * @return string + * + */ + public function setHotpCounterAttribute($value) + { + $this->otp->setParameter( 'counter', $this->otp->getcounter() + 1 ); + } + + + /** + * get Image parameter attribute + * + * @return string + * + */ + public function getImageLinkAttribute() + { + return $this->otp->hasParameter('image') ? $this->otp->getParameter('image') : null; + } + +} \ No newline at end of file diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index e8f0c4c7..989cfe0e 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -20,10 +20,13 @@ return [ 'refresh' => 'Refresh', 'response' => [ 'no_valid_otp' => 'No valid OTP resource in this QR code', + '' ], 'something_wrong_with_server' => 'Something is wrong with your server', 'Unable_to_decrypt_uri' => 'Unable to decrypt uri', 'not_a_supported_otp_type' => 'This OTP format is not currently supported', + 'cannot_create_otp_without_secret' => 'Cannot create an OTP without a secret', + 'cannot_create_otp_without_parameters' => 'Cannot create an OTP with those parameters', 'wrong_current_password' => 'Wrong current password, nothing has changed', 'error_during_encryption' => 'Encryption failed, your database remains unprotected.', 'error_during_decryption' => 'Decryption failed, your database is still protected. This is mainly caused by an integrity issue of encrypted data for one or more accounts.',