self::TOTP, 'OTPHP\HOTP' => self::HOTP, ]; /** * model's array form. * * @var array */ protected $fillable = [ // 'service', // 'account', // 'otp_type', // 'digits', // 'secret', // 'algorithm', // 'counter', // 'period', // 'icon' ]; /** * The table associated with the model. * * @var string */ protected $table = 'twofaccounts'; /** * The accessors to append to the model's array form. * * @var array */ public $appends = []; /** * The model's default values for attributes. * * @var array */ protected $attributes = [ 'digits' => 6, 'algorithm' => self::SHA1, ]; /** * The attributes that should be hidden for arrays. * * @var array */ protected $hidden = []; /** * The attributes that should be cast. * * @var array */ protected $casts = []; /** * The event map for the model. * * @var array */ protected $dispatchesEvents = [ 'deleted' => TwoFAccountDeleted::class, ]; /** * Override The "booting" method of the model * * @return void */ protected static function boot() { parent::boot(); static::saving(function ($twofaccount) { if (!$twofaccount->legacy_uri) $twofaccount->legacy_uri = $twofaccount->getURI(); if ($twofaccount->otp_type == TwoFAccount::TOTP && !$twofaccount->period) $twofaccount->period = TwoFAccount::DEFAULT_PERIOD; if ($twofaccount->otp_type == TwoFAccount::HOTP && !$twofaccount->counter) $twofaccount->counter = TwoFAccount::DEFAULT_COUNTER; }); // static::deleted(function ($model) { // Log::info(sprintf('TwoFAccount #%d deleted', $model->id)); // }); } /** * Fill the model with an array of attributes. * * @param array $attributes * @return $this * * @throws \Illuminate\Database\Eloquent\MassAssignmentException */ // public function fill(array $attributes) // { // parent::fill($attributes); // if ($this->otp_type == self::TOTP && !$this->period) $this->period = self::DEFAULT_PERIOD; // if ($this->otp_type == self::HOTP && !$this->counter) $this->counter = self::DEFAULT_COUNTER; // return $this; // } /** * Settings for @spatie/eloquent-sortable package * * @var array */ public $sortable = [ 'order_column_name' => 'order_column', 'sort_when_creating' => true, ]; /** * The OTP generator. * Instanciated as null to keep the model light * * @var */ protected $generator = null; /** * Get legacy_uri attribute * * @param string $value * @return string */ public function getLegacyUriAttribute($value) { return $this->decryptOrReturn($value); } /** * Set legacy_uri attribute * * @param string $value * @return void */ public function setLegacyUriAttribute($value) { // Encrypt if needed $this->attributes['legacy_uri'] = $this->encryptOrReturn($value); } /** * Get account attribute * * @param string $value * @return string */ public function getAccountAttribute($value) { return $this->decryptOrReturn($value); } /** * Set account attribute * * @param string $value * @return void */ public function setAccountAttribute($value) { // Encrypt when needed $this->attributes['account'] = $this->encryptOrReturn($value); } /** * Get secret attribute * * @param string $value * @return string */ public function getSecretAttribute($value) { return $this->decryptOrReturn($value); } /** * Set secret attribute * * @param string $value * @return void */ public function setSecretAttribute($value) { // Encrypt when needed $this->attributes['secret'] = $this->encryptOrReturn($value); } /** * Set digits attribute * * @param string $value * @return void */ public function setDigitsAttribute($value) { $this->attributes['digits'] = !$value ? 6 : $value; } /** * Set algorithm attribute * * @param string $value * @return void */ public function setAlgorithmAttribute($value) { $this->attributes['algorithm'] = !$value ? self::SHA1 : $value; } /** * Set period attribute * * @param string $value * @return void */ public function setPeriodAttribute($value) { $this->attributes['period'] = !$value && $this->otp_type === self::TOTP ? self::DEFAULT_PERIOD : $value; } /** * Set counter attribute * * @param string $value * @return void */ public function setCounterAttribute($value) { $this->attributes['counter'] = is_null($value) && $this->otp_type === self::HOTP ? self::DEFAULT_COUNTER : $value; } /** * Returns a One-Time Password with its parameters * * @throws InvalidSecretException The secret is not a valid base32 encoded string * @throws UndecipherableException The secret cannot be deciphered */ public function getOTP() : TotpDto|HotpDto { Log::info(sprintf('OTP requested for TwoFAccount #%s', $this->id)); // Early exit if the model has an undecipherable secret if (strtolower($this->secret) === __('errors.indecipherable')) { Log::error('Secret cannot be deciphered, OTP generation aborted'); throw new UndecipherableException(); } $this->initGenerator(); try { if ( $this->otp_type === self::TOTP || $this->otp_type === self::STEAM_TOTP ) { $OtpDto = new TotpDto(); $OtpDto->otp_type = $this->otp_type; $OtpDto->generated_at = time(); $OtpDto->password = $this->otp_type === self::TOTP ? $this->generator->at($OtpDto->generated_at) : SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret))); $OtpDto->period = $this->period; } else if ( $this->otp_type === self::HOTP ) { $OtpDto = new HotpDto(); $OtpDto->otp_type = $this->otp_type; $counter = $this->generator->getCounter(); $OtpDto->password = $this->generator->at($counter); $OtpDto->counter = $this->counter = $counter + 1; } else throw new UnsupportedOtpTypeException(); Log::info(sprintf('New OTP generated for TwoFAccount #%s', $this->id)); return $OtpDto; } catch (\Exception|\Throwable $ex) { Log::error('An error occured, OTP generation aborted'); // Currently a secret issue is the only possible exception thrown by OTPHP for this stack // so it is Ok to send the corresponding 2FAuth exception. // If the generator package change it could be necessary to throw a more generic exception. throw new InvalidSecretException($ex->getMessage()); } } /** * Fill the model using an array of OTP parameters. * Missing parameters will be set with default values * * @return $this */ public function fillWithOtpParameters(array $parameters) { $this->otp_type = Arr::get($parameters, 'otp_type'); $this->account = Arr::get($parameters, 'account'); $this->service = Arr::get($parameters, 'service'); $this->icon = Arr::get($parameters, 'icon'); $this->secret = Arr::get($parameters, 'secret'); $this->algorithm = Arr::get($parameters, 'algorithm', self::SHA1); $this->digits = Arr::get($parameters, 'digits', self::DEFAULT_DIGITS); $this->period = Arr::get($parameters, 'period', $this->otp_type == self::TOTP ? self::DEFAULT_PERIOD : null); $this->counter = Arr::get($parameters, 'counter', $this->otp_type == self::HOTP ? self::DEFAULT_COUNTER : null); $this->initGenerator(); if ($this->otp_type === self::STEAM_TOTP || strtolower($this->service) === 'steam') { $this->enforceAsSteam(); } Log::info(sprintf('TwoFAccount filled with OTP parameters')); return $this; } /** * Fill the model by parsing an otpauth URI * * @return $this */ public function fillWithURI(string $uri, bool $isSteamTotp = false) { // First we instanciate the OTP generator try { $this->generator = Factory::loadFromProvisioningUri($uri); } catch (\Assert\AssertionFailedException|\Assert\InvalidArgumentException|\Exception|\Throwable $ex) { throw ValidationException::withMessages([ 'uri' => __('validation.custom.uri.regex', ['attribute' => 'uri']) ]); } // As loadFromProvisioningUri() accept URI without label (nor account nor service) we check // that the account is set if ( ! $this->generator->getLabel() ) { Log::error('URI passed to fillWithURI() must contain a label'); throw ValidationException::withMessages([ 'label' => __('validation.custom.label.required') ]); } $this->otp_type = $this->getGeneratorOtpType(); $this->account = $this->generator->getLabel(); $this->secret = $this->generator->getSecret(); $this->service = $this->generator->getIssuer(); $this->algorithm = $this->generator->getDigest(); $this->digits = $this->generator->getDigits(); $this->period = $this->generator->hasParameter('period') ? $this->generator->getParameter('period') : null; $this->counter = $this->generator->hasParameter('counter') ? $this->generator->getParameter('counter') : null; $this->legacy_uri = $uri; if ($isSteamTotp || strtolower($this->service) === 'steam') { $this->enforceAsSteam(); } else if ($this->generator->hasParameter('image')) { $this->icon = $this->storeImageAsIcon($this->generator->getParameter('image')); } Log::info(sprintf('TwoFAccount filled with an URI')); return $this; } /** * Sets model attributes to STEAM values */ private function enforceAsSteam() { $this->otp_type = self::STEAM_TOTP; $this->digits = 5; $this->algorithm = self::SHA1; $this->period = 30; $this->icon = $this->storeImageAsIcon('https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Steam_icon_logo.svg/langfr-320px-Steam_icon_logo.svg.png'); } /** * Returns the OTP type of the instanciated OTP generator */ private function getGeneratorOtpType() { return Arr::get($this->generatorClassMap, $this->generator::class); } /** * Returns an otpauth URI built with model attribute values */ public function getURI() : string { $this->initGenerator(); return $this->generator->getProvisioningUri(); } /** * Instanciates the OTP generator with model attribute values */ private function initGenerator() { try { switch ($this->otp_type) { case self::TOTP: $this->generator = TOTP::create( $this->secret, $this->period ?: self::DEFAULT_PERIOD, $this->algorithm ?: self::DEFAULT_ALGORITHM, $this->digits ?: self::DEFAULT_DIGITS ); break; case self::STEAM_TOTP: $this->generator = TOTP::create($this->secret, 30, self::SHA1, 5); break; case self::HOTP: $this->generator = HOTP::create( $this->secret, $this->counter ?: self::DEFAULT_COUNTER, $this->algorithm ?: self::DEFAULT_ALGORITHM, $this->digits ?: self::DEFAULT_DIGITS ); break; default: throw new UnsupportedOtpTypeException(); break; } if ($this->service) $this->generator->setIssuer($this->service); if ($this->account) $this->generator->setLabel($this->account); } catch (UnsupportedOtpTypeException $exception) { Log::error(sprintf('%s is not an OTP type supported by the current generator', $this->otp_type)); throw $exception; } catch (\Exception|\Throwable $exception) { throw new InvalidOtpParameterException($exception->getMessage()); } } /** * Gets the image resource pointed by the image url and store it as an icon * * @return string|null The filename of the stored icon or null if the operation fails */ private function storeImageAsIcon(string $url) { try { $remoteImageURL = $url; $path_parts = pathinfo($remoteImageURL); $newFilename = Str::random(40) . '.' . $path_parts['extension']; $imageFile = self::IMAGELINK_STORAGE_PATH . $newFilename; $iconFile = self::ICON_STORAGE_PATH . $newFilename; Storage::disk('local')->put($imageFile, file_get_contents($remoteImageURL)); if ( in_array(Storage::mimeType($imageFile), ['image/png', 'image/jpeg', 'image/webp', 'image/bmp']) && getimagesize(storage_path() . '/app/' . $imageFile) ) { // Should be a valid image Storage::move($imageFile, $iconFile); Log::info(sprintf('Icon file %s stored', $newFilename)); } else { // @codeCoverageIgnoreStart Storage::delete($imageFile); throw new \Exception; // @codeCoverageIgnoreEnd } return $newFilename; } // @codeCoverageIgnoreStart catch (\Assert\AssertionFailedException|\Assert\InvalidArgumentException|\Exception|\Throwable $ex) { return null; } // @codeCoverageIgnoreEnd } /** * Returns an acceptable value */ private function decryptOrReturn($value) { // Decipher when needed if ( SettingService::get('useEncryption') ) { try { return Crypt::decryptString($value); } catch (Exception $ex) { return __('errors.indecipherable'); } } else { return $value; } } /** * Encrypt a value */ private function encryptOrReturn($value) { // should be replaced by laravel 8 attribute encryption casting return SettingService::get('useEncryption') ? Crypt::encryptString($value) : $value; } }