Browse Source

Refactoring - Move OTPHP logic to TwoFAccount model

Bubka 3 years ago
parent
commit
720eb16750

+ 1 - 1
app/Api/v1/Controllers/QrCodeController.php

@@ -44,7 +44,7 @@ class QrCodeController extends Controller
      */
     public function show(TwoFAccount $twofaccount)
     {
-        $uri = $this->twofaccountService->getURI($twofaccount);
+        $uri = $twofaccount->getURI();
 
         return response()->json(['qrcode' => $this->qrcodeService->encode($uri)], 200);
     }

+ 30 - 27
app/Api/v1/Controllers/TwoFAccountController.php

@@ -87,10 +87,15 @@ class TwoFAccountController extends Controller
         //     -> We use the parameters array to define the account
 
         $validated = $request->validated();
+        $twofaccount = new TwoFAccount;
 
-        $twofaccount = Arr::has($validated, 'uri')
-            ? $this->twofaccountService->createFromUri($validated['uri'])
-            : $this->twofaccountService->createFromParameters($validated);
+        if (Arr::has($validated, 'uri')) {
+            $twofaccount->fillWithURI($validated['uri'], Arr::get($validated, 'custom_otp') === TwoFAccount::STEAM_TOTP);
+        }
+        else {
+            $twofaccount->fillWithOtpParameters($validated);
+        }
+        $twofaccount->save();
 
         // Possible group association
         $this->groupService->assign($twofaccount->id);
@@ -113,7 +118,8 @@ class TwoFAccountController extends Controller
     {
         $validated = $request->validated();
 
-        $this->twofaccountService->update($twofaccount, $validated);
+        $twofaccount->fillWithOtpParameters($validated);
+        $twofaccount->save();
 
         return (new TwoFAccountReadResource($twofaccount))
                 ->response()
@@ -161,7 +167,8 @@ class TwoFAccountController extends Controller
      */
     public function preview(TwoFAccountUriRequest $request)
     {
-        $twofaccount = $this->twofaccountService->createFromUri($request->uri, false);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI($request->uri, $request->custom_otp === TwoFAccount::STEAM_TOTP);
 
         return new TwoFAccountStoreResource($twofaccount);
     }
@@ -179,38 +186,34 @@ class TwoFAccountController extends Controller
         $inputs = $request->all();
 
         // The request input is the ID of an existing account
-        if ( $id ) {
-            try {
-                $otp = $this->twofaccountService->getOTP((int) $id);
-            }
-            catch (UndecipherableException $ex) {
-                return response()->json([
-                    'message' => __('errors.cannot_decipher_secret')
-                ], 400);
-            }
+        if ($id) {
+            $twofaccount = TwoFAccount::findOrFail((int) $id);
         }
 
         // The request input is an uri
-        else if ( count($inputs) === 1 && $request->has('uri') ) {
-            $validatedData = $request->validate((new TwoFAccountUriRequest)->rules());
-            $otp = $this->twofaccountService->getOTP($validatedData['uri']);
-        }
-        
-        // return bad request if uri is provided with any other input
-        else if ( count($inputs) > 1 && $request->has('uri')) {
-            return response()->json([
-                'message' => 'bad request',
-                'reason' => ['uri' => __('validation.single', ['attribute' => 'uri'])]
-            ], 400);
+        else if ( $request->has('uri') ) {
+            // return 404 if uri is provided with any parameter other than otp_type
+            if ((count($inputs) == 2 && $request->missing('custom_otp')) || count($inputs) > 2) {
+                return response()->json([
+                    'message' => 'bad request',
+                    'reason' => ['uri' => __('validation.onlyCustomOtpWithUri')]
+                ], 400);
+            }
+            else {
+                $validatedData = $request->validate((new TwoFAccountUriRequest)->rules());
+                $twofaccount = new TwoFAccount;
+                $twofaccount->fillWithURI($validatedData['uri'], Arr::get($validatedData, 'custom_otp') === TwoFAccount::STEAM_TOTP);
+            }
         }
 
         // The request inputs should define an account
         else {
             $validatedData = $request->validate((new TwoFAccountStoreRequest)->rules());
-            $otp = $this->twofaccountService->getOTP($validatedData);
+            $twofaccount = new TwoFAccount();
+            $twofaccount->fillWithOtpParameters($validatedData);
         }
 
-        return response()->json($otp, 200);
+        return response()->json($twofaccount->getOTP(), 200);
     }
 
 

+ 2 - 1
app/Api/v1/Requests/TwoFAccountUriRequest.php

@@ -25,7 +25,8 @@ class TwoFAccountUriRequest extends FormRequest
     public function rules()
     {
         return [
-            'uri' => 'required|string|regex:/^otpauth:\/\/[h,t]otp\//i',
+            'uri'        => 'required|string|regex:/^otpauth:\/\/[h,t]otp\//i',
+            'custom_otp' => 'string|in:steamtotp',
         ];
     }
 }

+ 10 - 0
app/Exceptions/Handler.php

@@ -65,6 +65,16 @@ class Handler extends ExceptionHandler
                 'message' => __('errors.invalid_google_auth_migration')], 400);
         });
 
+        $this->renderable(function (UndecipherableException $exception, $request) {
+            return response()->json([
+                'message' => __('errors.cannot_decipher_secret')], 400);
+        });
+
+        $this->renderable(function (UnsupportedOtpTypeException $exception, $request) {
+            return response()->json([
+                'message' => __('errors.unsupported_otp_type')], 400);
+        });
+
         $this->renderable(function (\Illuminate\Auth\AuthenticationException $exception, $request) {
             if ($exception->guards() === ['reverse-proxy-guard']) {
                 return response()->json([

+ 1 - 1
app/Exceptions/InvalidGoogleAuthMigration.php

@@ -5,7 +5,7 @@ namespace App\Exceptions;
 use Exception;
 
 /**
- * Class UndecipherableException.
+ * Class InvalidGoogleAuthMigration.
  *
  * @codeCoverageIgnore
  */

+ 14 - 0
app/Exceptions/UnsupportedOtpTypeException.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Exceptions;
+
+use Exception;
+
+/**
+ * Class NotImplementedException.
+ *
+ * @codeCoverageIgnore
+ */
+class UnsupportedOtpTypeException extends Exception
+{
+}

+ 9 - 0
app/Models/Dto/HotpDto.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace App\Models\Dto;
+
+class HotpDto extends OtpDto
+{
+    /* @var integer */
+    public int $counter;
+}

+ 12 - 0
app/Models/Dto/OtpDto.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Models\Dto;
+
+class OtpDto
+{
+    /* @var integer */
+    public string $password;
+
+    /* @var integer */
+    public string $otp_type;
+}

+ 12 - 0
app/Models/Dto/TotpDto.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Models\Dto;
+
+class TotpDto extends OtpDto
+{
+    /* @var integer */
+    public int $generated_at;
+
+    /* @var integer */
+    public int $period;
+}

+ 398 - 1
app/Models/TwoFAccount.php

@@ -3,27 +3,77 @@
 namespace App\Models;
 
 use Exception;
+use App\Models\Dto\TotpDto;
+use App\Models\Dto\HotpDto;
 use App\Events\TwoFAccountDeleted;
+use App\Exceptions\InvalidSecretException;
+use App\Exceptions\InvalidOtpParameterException;
+use App\Exceptions\UnsupportedOtpTypeException;
+use App\Exceptions\UndecipherableException;
+use Illuminate\Validation\ValidationException;
 use Facades\App\Services\SettingService;
 use Spatie\EloquentSortable\Sortable;
 use Spatie\EloquentSortable\SortableTrait;
+use OTPHP\TOTP;
+use OTPHP\HOTP;
+use OTPHP\Factory;
+use SteamTotp\SteamTotp;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Str;
+use Illuminate\Support\Arr;
 use Illuminate\Support\Facades\Crypt;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use ParagonIE\ConstantTime\Base32;
 
 class TwoFAccount extends Model implements Sortable
 {
 
     use SortableTrait, HasFactory;
 
+    const TOTP       = 'totp';
+    const HOTP       = 'hotp';
+    const STEAM_TOTP = 'steamtotp';
+
+    const SHA1       = 'sha1';
+    const MD5        = 'md5';
+    const SHA256     = 'sha256';
+    const SHA512     = 'sha512';
+    
+    const DEFAULT_PERIOD = 30;
+    const DEFAULT_COUNTER = 0;
+    const DEFAULT_DIGITS = 6;
+    const DEFAULT_ALGORITHM = self::SHA1;
+
+    private const IMAGELINK_STORAGE_PATH = 'imagesLink/';
+    private const ICON_STORAGE_PATH      = 'public/icons/';
+
+
+    /**
+     * List of OTP types supported by 2FAuth
+     */
+    private array $generatorClassMap = [
+        'OTPHP\TOTP' => self::TOTP,
+        'OTPHP\HOTP' => self::HOTP,
+    ];
 
     /**
      * model's array form.
      *
      * @var array
      */
-    protected $fillable = [];
+    protected $fillable = [
+        // 'service',
+        // 'account',
+        // 'otp_type',
+        // 'digits',
+        // 'secret',
+        // 'algorithm',
+        // 'counter',
+        // 'period',
+        // 'icon'
+    ];
 
 
     /**
@@ -40,6 +90,17 @@ class TwoFAccount extends Model implements Sortable
      * @var array
      */
     public $appends = [];
+    
+    
+    /**
+    * The model's default values for attributes.
+    *
+    * @var array
+    */
+    protected $attributes = [
+        'digits' => 6,
+        'algorithm' => self::SHA1,
+    ];
 
 
     /**
@@ -77,11 +138,35 @@ class TwoFAccount extends Model implements Sortable
     {
         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
@@ -94,6 +179,15 @@ class TwoFAccount extends Model implements Sortable
     ];
 
 
+    /**
+     * The OTP generator.
+     * Instanciated as null to keep the model light
+     *
+     * @var
+     */
+    protected $generator = null;
+
+
     /**
      * Get legacy_uri attribute
      *
@@ -166,6 +260,309 @@ class TwoFAccount extends Model implements Sortable
     }
 
 
+    /**
+     * 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, bool $isSteamTotp = false)
+    {
+        $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 ($isSteamTotp) {
+            $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) {
+            $this->enforceAsSteam();
+        }
+
+        if ( $this->generator->hasParameter('image') ) {
+            $this->icon     = $this->storeTokenImageAsIcon();
+        }
+
+        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;
+    }
+
+
+    /**
+     * 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 generator image parameter and store it as an icon
+     * 
+     * @return string|null The filename of the stored icon or null if the operation fails
+     */
+    private function storeTokenImageAsIcon()
+    {
+        try {
+            $remoteImageURL = $this->generator->getParameter('image');
+            $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
      */

+ 8 - 6
app/Protobuf/GAuthValueMapping.php

@@ -4,14 +4,16 @@
 
 namespace App\Protobuf;
 
+use App\Models\TwoFAccount;
+
 class GAuthValueMapping
 {
     const ALGORITHM = [
         'ALGORITHM_UNSPECIFIED' => '',
-        'ALGORITHM_SHA1' => 'sha1',
-        'ALGORITHM_SHA256' => 'sha256',
-        'ALGORITHM_SHA512' => 'sha512',
-        'ALGORITHM_MD5' => 'md5'
+        'ALGORITHM_SHA1' => TwoFAccount::SHA1,
+        'ALGORITHM_SHA256' => TwoFAccount::SHA256,
+        'ALGORITHM_SHA512' => TwoFAccount::SHA512,
+        'ALGORITHM_MD5' => TwoFAccount::MD5
     ];
 
     const DIGIT_COUNT = [
@@ -22,8 +24,8 @@ class GAuthValueMapping
 
     const OTP_TYPE = [
         'OTP_TYPE_UNSPECIFIED' => '',
-        'OTP_TYPE_HOTP' => 'hotp',
-        'OTP_TYPE_TOTP' => 'totp'
+        'OTP_TYPE_HOTP' => TwoFAccount::HOTP,
+        'OTP_TYPE_TOTP' => TwoFAccount::TOTP
     ];
 
     private function __construct() {}

+ 0 - 21
app/Services/Dto/OtpDto.php

@@ -1,21 +0,0 @@
-<?php
-
-namespace App\Services\Dto;
-
-class OtpDto
-{
-    /* @var integer */
-    public string $password;
-
-    /* @var integer */
-    public string $otp_type;
-
-    /* @var integer */
-    public ?int $generated_at;
-
-    /* @var integer */
-    public ?int $period;
-
-    /* @var integer */
-    public ?int $counter;
-}

+ 0 - 33
app/Services/Dto/TwoFAccountDto.php

@@ -1,33 +0,0 @@
-<?php
-
-namespace App\Services\Dto;
-
-class TwoFAccountDto
-{
-    /* @var string */
-    public string $otp_type;
-
-    /* @var string */
-    public string $account = '';
-
-    /* @var string */
-    public ?string $service = null;
-
-    /* @var string */
-    public ?string $icon = null;
-
-    /* @var string */
-    public ?string $secret = null;
-
-    /* @var string */
-    public ?string $algorithm = 'sha1';
-
-    /* @var integer */
-    public ?int $digits = 6;
-
-    /* @var integer */
-    public ?int $period = 30;
-
-    /* @var integer */
-    public ?int $counter = 0;
-}

+ 5 - 411
app/Services/TwoFAccountService.php

@@ -3,22 +3,11 @@
 namespace App\Services;
 
 use App\Models\TwoFAccount;
-use App\Exceptions\InvalidSecretException;
-use App\Exceptions\InvalidOtpParameterException;
-use App\Exceptions\UndecipherableException;
 use App\Exceptions\InvalidGoogleAuthMigration;
-use App\Services\Dto\OtpDto;
-use App\Services\Dto\TwoFAccountDto;
 use Exception;
-use OTPHP\TOTP;
-use OTPHP\HOTP;
-use OTPHP\Factory;
 use Illuminate\Support\Str;
-use Illuminate\Support\Arr;
 use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Log;
-use Illuminate\Validation\ValidationException;
 use ParagonIE\ConstantTime\Base32;
 use App\Protobuf\GAuthValueMapping;
 use App\Protobuf\GoogleAuth\Payload;
@@ -28,172 +17,6 @@ use App\Protobuf\GoogleAuth\Payload\DigitCount;
 
 class TwoFAccountService
 {
-    /**
-     * 
-     */
-    private $token;
-
-    /**
-     * 
-     */
-    private array $supportedOtpTypes = [
-        "OTPHP\TOTP" => "totp",
-        "OTPHP\HOTP" => "hotp"
-    ];
-
-    private const IMAGELINK_STORAGE_PATH = 'imagesLink/';
-    private const ICON_STORAGE_PATH = 'public/icons/';
-    
-
-    public function __construct()
-    {
-        //$this->token = $otpType === TOTP::create($secret) : HOTP::create($secret);
-    }
-
-
-    /**
-     * Creates an account using an otpauth URI
-     * 
-     * @param string $uri
-     * @param bool $saveToDB Whether or not the created account should be saved to DB
-     * 
-     * @return \App\Models\TwoFAccount The created account
-     */
-    public function createFromUri(string $uri, bool $saveToDB = true ) : TwoFAccount
-    {
-        // Instanciate the token
-        $this->initTokenWith($uri);
-
-        // Create the account
-        $twofaccount = new TwoFAccount;
-        $twofaccount->legacy_uri = $uri;
-        $this->fillWithToken($twofaccount);
-        
-        if ( $saveToDB ) {
-            $twofaccount->save();
-
-            Log::info(sprintf('TwoFAccount #%d created (from URI)', $twofaccount->id));
-        }
-
-        return $twofaccount;
-    }
-
-
-    /**
-     * Creates an account using a list of parameters
-     * 
-     * @param array $data
-     * @param bool $saveToDB Whether or not the created account should be saved to DB
-     * 
-     * @return \App\Models\TwoFAccount The created account
-     */
-    public function createFromParameters(array $data, bool $saveToDB = true) : TwoFAccount
-    {
-        // Instanciate the token
-        $this->initTokenWith($data);
-
-        // Create and fill the account
-        $twofaccount = new TwoFAccount;
-        $twofaccount->legacy_uri = $this->token->getProvisioningUri();
-        $twofaccount->icon = Arr::get($data, 'icon', null);
-        $this->fillWithToken($twofaccount);
-        
-        if ( $saveToDB ) {
-            $twofaccount->save();
-
-            Log::info(sprintf('TwoFAccount #%d created (from parameters)', $twofaccount->id));
-        }
-
-        return $twofaccount;
-    }
-
-
-    /**
-     * Updates an account using a list of parameters
-     * 
-     * @param \App\Models\TwoFAccount $twofaccount The account
-     * @param array $data The parameters
-     * 
-     * @return \App\Models\TwoFAccount The updated account
-     */
-    public function update(TwoFAccount $twofaccount, array $data) : TwoFAccount
-    {
-        // Instanciate the token
-        $this->initTokenWith($data);
-
-        $this->fillWithToken($twofaccount);
-        $twofaccount->icon = Arr::get($data, 'icon', null);
-        $twofaccount->save();
-
-        Log::info(sprintf('TwoFAccount #%d updated', $twofaccount->id));
-
-        return $twofaccount;
-    }
-
-
-    /**
-     * Returns a One-Time Password (with its parameters) for the specified account
-     * 
-     * @param \App\Models\TwoFAccount|TwoFAccountDto|int|string $data Data defining an account
-     * 
-     * @return OtpDto an OTP DTO
-     * 
-     * @throws InvalidSecretException The secret is not a valid base32 encoded string
-     * @throws UndecipherableException The secret cannot be deciphered
-     */
-    public function getOTP($data) : OtpDto
-    {
-        $this->initTokenWith($data);
-        $OtpDto = new OtpDto();
-
-        // Early exit if the model returned an undecipherable secret
-        if (strtolower($this->token->getSecret()) === __('errors.indecipherable')) {
-            Log::error('Secret cannot be deciphered, OTP generation aborted');
-
-            throw new UndecipherableException();
-        }
-        
-        try {
-            if ( $this->tokenOtpType() === 'totp' ) {
-
-                $OtpDto->generated_at   = time();
-                $OtpDto->otp_type       = 'totp';
-                $OtpDto->password       = $this->token->at($OtpDto->generated_at);
-                $OtpDto->period      = $this->token->getParameter('period');
-            }
-            else if ( $this->tokenOtpType() === 'hotp' ) {
-
-                $counter = $this->token->getCounter();
-                $OtpDto->otp_type   = 'hotp';
-                $OtpDto->password   = $this->token->at($counter);
-                $OtpDto->counter    = $counter + 1;
-            }
-        }
-        catch (\Assert\AssertionFailedException|\Assert\InvalidArgumentException|\Exception|\Throwable $ex) {
-            // 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 token package change it could be necessary to throw a more generic exception.
-            throw new InvalidSecretException($ex->getMessage());
-        }
-
-        Log::info(sprintf('New %s generated', $OtpDto->otp_type));
-
-        return $OtpDto;
-    }
-
-
-    /**
-     * Returns a generated otpauth URI for the specified account
-     * 
-     * @param \App\Models\TwoFAccount|TwoFAccountDto|int $data Data defining an account
-     */
-    public function getURI($data) : string
-    {
-        $this->initTokenWith($data);
-
-        return $this->token->getProvisioningUri();
-    }
-
 
     /**
      * Withdraw one or more twofaccounts from their group
@@ -268,10 +91,11 @@ class TwoFAccountService
                 $parameters['secret']       = Base32::encodeUpper($otp_parameters->getSecret());
                 $parameters['algorithm']    = GAuthValueMapping::ALGORITHM[Algorithm::name($otp_parameters->getAlgorithm())];
                 $parameters['digits']       = GAuthValueMapping::DIGIT_COUNT[DigitCount::name($otp_parameters->getDigits())];
-                $parameters['counter']      = $otp_parameters->getCounter();
-                // $parameters['period']       = $otp_parameters->getPeriod();
+                $parameters['counter']      = $parameters['otp_type'] === TwoFAccount::HOTP ? $otp_parameters->getCounter() : null;
+                $parameters['period']       = $parameters['otp_type'] === TwoFAccount::TOTP ? $otp_parameters->getPeriod() : null;
 
-                $twofaccounts[$key] = $this->createFromParameters($parameters, false);
+                $twofaccounts[$key] = new TwoFAccount;
+                $twofaccounts[$key]->fillWithOtpParameters($parameters);
              }
              catch (Exception $exception) {
 
@@ -281,7 +105,7 @@ class TwoFAccountService
                 // The token failed to generate a valid account so we create a fake account to be returned.
                 $fakeAccount = new TwoFAccount();
                 $fakeAccount->id = -2;
-                $fakeAccount->otp_type  = 'totp';
+                $fakeAccount->otp_type  = $fakeAccount::TOTP;
                 // Only basic fields are filled to limit the risk of another exception.
                 $fakeAccount->account   = $otp_parameters->getName();
                 $fakeAccount->service   = $otp_parameters->getIssuer();
@@ -297,11 +121,6 @@ class TwoFAccountService
     }
 
 
-// ########################################################################################################################
-// ########################################################################################################################
-// ########################################################################################################################
-// ########################################################################################################################
-
     /**
      * 
      */
@@ -318,231 +137,6 @@ class TwoFAccountService
         return $ids;
     }
 
-    /**
-     * Inits the Token
-     */
-    private function initTokenWith($data) : void
-    {
-        // init with a TwoFAccount instance
-        if ( is_object($data) && get_class($data) === 'App\Models\TwoFAccount' ) {
-            $this->initTokenWithTwoFAccount($data);
-        }
-        // init with a TwoFAccountDto instance
-        else if ( is_object($data) && get_class($data) === 'App\Services\Dto\TwoFAccountDto' ) {
-            $this->initTokenWithParameters($data);
-        }
-        // init with an account ID
-        else if ( is_integer($data) ) {
-            // we should have an ID
-            $twofaccount = TwoFAccount::findOrFail($data);
-            $this->initTokenWithTwoFAccount($twofaccount);
-        }
-        // init with an array of property
-        else if( is_array($data) ) {
-            $dto = $this->mapArrayToDto($data);
-            $this->initTokenWithParameters($dto);
-        }
-        // or with a string that should be an otpauth URI
-        else {
-            $this->initTokenWithUri($data);
-        }
-    }
-
-
-    /**
-     * Maps array items to a TwoFAccountDto instance
-     * 
-     * @param array $array The array to map
-     * 
-     * @returns TwoFAccountDto
-     */
-    private function mapArrayToDto($array) : TwoFAccountDto
-    {
-        $dto = new TwoFAccountDto();
-
-        try {
-            foreach ($array as $key => $value) {
-                $dto->$key = ! Arr::has($array, $key) ?: $value;
-            }
-        }
-        catch (\TypeError $ex) {
-            throw new InvalidOtpParameterException($ex->getMessage());
-        }
-
-        return $dto;
-    }
-
-
-
-    /**
-     * Instanciates the token with a TwoFAccount
-     * 
-     * @param \App\Models\TwoFAccount $twofaccount
-     * 
-     * @param bool $usingUri Whether or not the token should be fed with the account uri
-     */
-    private function initTokenWithTwoFAccount(TwoFAccount $twofaccount) : void
-    {
-        $dto = new TwoFAccountDto();
-
-        $dto->otp_type              = $twofaccount->otp_type;
-        $dto->account               = $twofaccount->account;
-        $dto->service               = $twofaccount->service;
-        $dto->icon                  = $twofaccount->icon;
-        $dto->secret                = $twofaccount->secret;
-        $dto->algorithm             = $twofaccount->algorithm;
-        $dto->digits                = $twofaccount->digits;
-
-        if ( $twofaccount->period ) $dto->period    = $twofaccount->period;
-        if ( $twofaccount->counter ) $dto->counter  = $twofaccount->counter;
-
-        $this->initTokenWithParameters($dto);
-    }
-
-
-    /**
-     * Instanciates the token object by parsing an otpauth URI
-     * 
-     * @throws ValidationException The URI is not a valid otpauth URI
-     */
-    private function initTokenWithUri(string $uri) : void
-    {
-        try {
-            $this->token = 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->token->getLabel() ) {
-            Log::error('URI passed to initTokenWithUri() must contain a label');
-
-            throw ValidationException::withMessages([
-                'label' => __('validation.custom.label.required')
-            ]);
-        }
-    }
-
-
-    /**
-     * Instanciates the token object by passing a list of parameters
-     * 
-     * @throws ValidationException otp type not supported
-     * @throws InvalidOtpParameterException invalid otp parameters
-     */
-    private function initTokenWithParameters(TwoFAccountDto $dto) : void
-    {
-        // Check OTP type again to ensure the upcoming OTPHP instanciation
-        if ( ! in_array($dto->otp_type, $this->supportedOtpTypes, true) ) {
-            Log::error(sprintf('%s is not an OTP type supported by the current token', $dto->otp_type));
-
-            throw ValidationException::withMessages([
-                'otp_type' => __('validation.custom.otp_type.in', ['attribute' => 'otp type'])
-            ]);
-        }
-
-        try {
-            if ( $dto->otp_type === 'totp' ) {
-                $this->token = TOTP::create(
-                    $dto->secret
-                );
-
-                if ($dto->period) $this->token->setParameter('period', $dto->period);
-            }
-            else if ( $dto->otp_type === 'hotp' ) {
-                $this->token = HOTP::create(
-                    $dto->secret
-                );
-
-                if ($dto->counter) $this->token->setParameter('counter', $dto->counter);
-            }
-
-            if ($dto->algorithm) $this->token->setParameter('algorithm', $dto->algorithm);
-            if ($dto->digits) $this->token->setParameter('digits', $dto->digits);
-            // if ($dto->epoch) $this->token->setParameter('epoch', $dto->epoch);
-            if ($dto->service) $this->token->setIssuer($dto->service);
-            if ($dto->account) $this->token->setLabel($dto->account);
-        }
-        catch (\Assert\AssertionFailedException|\Assert\InvalidArgumentException|\Exception|\Throwable $ex) {
-            throw new InvalidOtpParameterException($ex->getMessage());
-        }
-        
-    }
-
-
-    /**
-     * Fills a TwoFAccount with token's parameters
-     */
-    private function fillWithToken(TwoFAccount &$twofaccount) : void
-    {
-        $twofaccount->otp_type      = $this->tokenOtpType();
-        $twofaccount->account       = $this->token->getLabel();
-        $twofaccount->secret        = $this->token->getSecret();
-        $twofaccount->service       = $this->token->getIssuer();
-        $twofaccount->algorithm     = $this->token->getDigest();
-        $twofaccount->digits        = $this->token->getDigits();
-        $twofaccount->period        = $this->token->hasParameter('period') ? $this->token->getParameter('period') : null;
-        $twofaccount->counter       = $this->token->hasParameter('counter') ? $this->token->getParameter('counter') : null;
-
-        if ( $this->token->hasParameter('image') ) {
-            $twofaccount->icon      = $this->storeTokenImageAsIcon();
-        }
-    }
-
-
-    /**
-     * Returns the otp_type that matchs the token instance class
-     */
-    private function tokenOtpType() : string
-    {
-        return $this->supportedOtpTypes[get_class($this->token)];
-    }
-
-
-    /**
-     * Gets the image resource pointed by the token image parameter and store it as an icon
-     * 
-     * @return string|null The filename of the stored icon or null if the operation fails
-     */
-    private function storeTokenImageAsIcon()
-    {
-        try {
-            $remoteImageURL = $this->token->getParameter('image');
-            $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
-    }
-
 
     /**
      * Return the given collection with items marked as Duplicates (using id=-1) if a similar record exists in database

+ 1 - 0
composer.json

@@ -22,6 +22,7 @@
         "ext-xml": "*",
         "chillerlan/php-qrcode": "^4.3",
         "darkghosthunter/larapass": "^3.0.2",
+        "doctormckay/steam-totp": "^1.0",
         "doctrine/dbal": "^3.2",
         "fruitcake/laravel-cors": "^2.0",
         "google/protobuf": "^3.21",

+ 1 - 0
resources/lang/en/errors.php

@@ -40,4 +40,5 @@ return [
     'auth_proxy_failed' => 'Proxy authentication failed',
     'auth_proxy_failed_legend' => '2Fauth is configured to run behind an authentication proxy but your proxy does not return the expected header. Check your configuration and try again.',
     'invalid_google_auth_migration' => 'Invalid or unreadable Google Authenticator data',
+    'unsupported_otp_type' => 'Unsupported OTP type',
 ];

+ 1 - 0
resources/lang/en/validation.php

@@ -128,6 +128,7 @@ return [
     'uuid' => 'The :attribute must be a valid UUID.',
 
     'single' => 'When using :attribute it must be the only parameter in this request body',
+    'onlyCustomOtpWithUri' => 'The uri parameter must be provided alone or only in combination with the \'custom_otp\' parameter',
 
     /*
     |--------------------------------------------------------------------------

+ 104 - 154
tests/Api/v1/Controllers/TwoFAccountControllerTest.php

@@ -5,6 +5,7 @@ namespace Tests\Api\v1\Controllers;
 use App\Models\User;
 use App\Models\Group;
 use Tests\FeatureTestCase;
+use Tests\Classes\OtpTestData;
 use App\Models\TwoFAccount;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
@@ -28,25 +29,8 @@ class TwoFAccountControllerTest extends FeatureTestCase
     */
     protected $group;
 
-    private const ACCOUNT = 'account';
-    private const SERVICE = 'service';
-    private const SECRET = 'A4GRFHVVRBGY7UIW';
-    private const ALGORITHM_DEFAULT = 'sha1';
-    private const ALGORITHM_CUSTOM = 'sha256';
-    private const DIGITS_DEFAULT = 6;
-    private const DIGITS_CUSTOM = 7;
-    private const PERIOD_DEFAULT = 30;
-    private const PERIOD_CUSTOM = 40;
-    private const COUNTER_DEFAULT = 0;
-    private const COUNTER_CUSTOM = 5;
-    private const IMAGE = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
-    private const ICON = 'test.png';
-    private const TOTP_FULL_CUSTOM_URI = 'otpauth://totp/'.self::SERVICE.':'.self::ACCOUNT.'?secret='.self::SECRET.'&issuer='.self::SERVICE.'&digits='.self::DIGITS_CUSTOM.'&period='.self::PERIOD_CUSTOM.'&algorithm='.self::ALGORITHM_CUSTOM.'&image='.self::IMAGE;
-    private const HOTP_FULL_CUSTOM_URI = 'otpauth://hotp/'.self::SERVICE.':'.self::ACCOUNT.'?secret='.self::SECRET.'&issuer='.self::SERVICE.'&digits='.self::DIGITS_CUSTOM.'&counter='.self::COUNTER_CUSTOM.'&algorithm='.self::ALGORITHM_CUSTOM.'&image='.self::IMAGE;
-    private const TOTP_SHORT_URI = 'otpauth://totp/'.self::ACCOUNT.'?secret='.self::SECRET;
-    private const HOTP_SHORT_URI = 'otpauth://hotp/'.self::ACCOUNT.'?secret='.self::SECRET;
-    private const TOTP_URI_WITH_UNREACHABLE_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=https%3A%2F%2Fen.opensuse.org%2Fimage.png';
-    private const INVALID_OTPAUTH_URI = 'otpauth://Xotp/'.self::ACCOUNT.'?secret='.self::SECRET;
+
+
     private const VALID_RESOURCE_STRUCTURE_WITHOUT_SECRET = [
         'id',
         'group_id',
@@ -83,86 +67,52 @@ class TwoFAccountControllerTest extends FeatureTestCase
         'password',
         'counter',
     ];
-    private const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
-        'icon'      => self::ICON,
-        'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
-        'period'    => self::PERIOD_CUSTOM,
-        'counter'   => null,
-    ];
-    private const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP = [
-        'account'   => self::ACCOUNT,
-        'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
-    ];
     private const JSON_FRAGMENTS_FOR_CUSTOM_TOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
+        'service'   => OtpTestData::SERVICE,
+        'account'   => OtpTestData::ACCOUNT,
         'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
-        'period'    => self::PERIOD_CUSTOM,
+        'secret'    => OtpTestData::SECRET,
+        'digits'    => OtpTestData::DIGITS_CUSTOM,
+        'algorithm' => OtpTestData::ALGORITHM_CUSTOM,
+        'period'    => OtpTestData::PERIOD_CUSTOM,
         'counter'   => null,
     ];
     private const JSON_FRAGMENTS_FOR_DEFAULT_TOTP = [
         'service'   => null,
-        'account'   => self::ACCOUNT,
+        'account'   => OtpTestData::ACCOUNT,
         'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_DEFAULT,
-        'algorithm' => self::ALGORITHM_DEFAULT,
-        'period'    => self::PERIOD_DEFAULT,
+        'secret'    => OtpTestData::SECRET,
+        'digits'    => OtpTestData::DIGITS_DEFAULT,
+        'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+        'period'    => OtpTestData::PERIOD_DEFAULT,
         'counter'   => null,
     ];
-    private const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
-        'icon'      => self::ICON,
-        'otp_type'  => 'hotp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
-        'period'    => null,
-        'counter'   => self::COUNTER_CUSTOM,
-    ];
-    private const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP = [
-        'account'   => self::ACCOUNT,
-        'otp_type'  => 'hotp',
-        'secret'    => self::SECRET,
-    ];
     private const JSON_FRAGMENTS_FOR_CUSTOM_HOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
+        'service'   => OtpTestData::SERVICE,
+        'account'   => OtpTestData::ACCOUNT,
         'otp_type'  => 'hotp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
+        'secret'    => OtpTestData::SECRET,
+        'digits'    => OtpTestData::DIGITS_CUSTOM,
+        'algorithm' => OtpTestData::ALGORITHM_CUSTOM,
         'period'    => null,
-        'counter'   => self::COUNTER_CUSTOM,
+        'counter'   => OtpTestData::COUNTER_CUSTOM,
     ];
     private const JSON_FRAGMENTS_FOR_DEFAULT_HOTP = [
         'service' => null,
-        'account'   => self::ACCOUNT,
+        'account'   => OtpTestData::ACCOUNT,
         'otp_type'  => 'hotp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_DEFAULT,
-        'algorithm' => self::ALGORITHM_DEFAULT,
+        'secret'    => OtpTestData::SECRET,
+        'digits'    => OtpTestData::DIGITS_DEFAULT,
+        'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
         'period'    => null,
-        'counter'   => self::COUNTER_DEFAULT,
+        'counter'   => OtpTestData::COUNTER_DEFAULT,
     ];
     private const ARRAY_OF_INVALID_PARAMETERS = [
         'account'   => null,
         'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
+        'secret'    => OtpTestData::SECRET,
     ];
-    private const GOOGLE_AUTH_MIGRATION_URI = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY2UgASgBMAIKLAoKBw0SnrWITY/RFhILYWNjb3VudF9iaXMaC3NlcnZpY2VfYmlzIAEoATACEAEYASAA';
-    private const INVALID_GOOGLE_AUTH_MIGRATION_URI = 'otpauthmigration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY2UgASgBMAIKLAoKBw0SnrWITY/RFhILYWNjb3VudF9iaXMaC3NlcnZpY2VfYmlzIAEoATACEAEYASAA';
-    private const GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY';
+
 
 
     /**
@@ -301,28 +251,28 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         return [
             [[
-                'uri' => self::TOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::TOTP_FULL_CUSTOM_URI,
             ]],
             [[
-                'uri' => self::TOTP_SHORT_URI,
+                'uri' => OtpTestData::TOTP_SHORT_URI,
             ]],
             [
-                self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP
+                OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP
             ],
             [
-                self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP
+                OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP
             ],
             [[
-                'uri' => self::HOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::HOTP_FULL_CUSTOM_URI,
             ]],
             [[
-                'uri' => self::HOTP_SHORT_URI,
+                'uri' => OtpTestData::HOTP_SHORT_URI,
             ]],
             [
-                self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP
+                OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP
             ],
             [
-                self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP
+                OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP
             ],
         ];
     }
@@ -335,7 +285,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::TOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::TOTP_FULL_CUSTOM_URI,
             ])
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_TOTP);
     }
@@ -348,7 +298,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::TOTP_SHORT_URI,
+                'uri' => OtpTestData::TOTP_SHORT_URI,
             ])
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP);
     }
@@ -360,7 +310,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function test_store_totp_using_fully_custom_parameters_returns_consistent_resource()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts', self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
+            ->json('POST', '/api/v1/twofaccounts', OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_TOTP);
     }
 
@@ -371,7 +321,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function test_store_totp_using_minimum_parameters_returns_consistent_resource()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts', self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP)
+            ->json('POST', '/api/v1/twofaccounts', OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP)
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP);
     }
 
@@ -383,7 +333,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::HOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::HOTP_FULL_CUSTOM_URI,
             ])
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_HOTP);
     }
@@ -396,7 +346,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::HOTP_SHORT_URI,
+                'uri' => OtpTestData::HOTP_SHORT_URI,
             ])
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP);
     }
@@ -408,7 +358,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function test_store_hotp_using_fully_custom_parameters_returns_consistent_resource()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts', self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP)
+            ->json('POST', '/api/v1/twofaccounts', OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP)
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_HOTP);
     }
 
@@ -419,7 +369,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function test_store_hotp_using_minimum_parameters_returns_consistent_resource()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts', self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP)
+            ->json('POST', '/api/v1/twofaccounts', OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP)
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP);
     }
 
@@ -431,7 +381,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::INVALID_OTPAUTH_URI,
+                'uri' => OtpTestData::INVALID_OTPAUTH_URI,
             ])
             ->assertStatus(422);
     }
@@ -448,7 +398,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::TOTP_SHORT_URI,
+                'uri' => OtpTestData::TOTP_SHORT_URI,
             ])
             ->assertJsonFragment([
                 'group_id' => $this->group->id
@@ -470,7 +420,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::TOTP_SHORT_URI,
+                'uri' => OtpTestData::TOTP_SHORT_URI,
             ])
             ->assertJsonFragment([
                 'group_id' => $this->group->id
@@ -490,7 +440,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::TOTP_SHORT_URI,
+                'uri' => OtpTestData::TOTP_SHORT_URI,
             ])
             ->assertJsonFragment([
                 'group_id' => null
@@ -510,7 +460,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::TOTP_SHORT_URI,
+                'uri' => OtpTestData::TOTP_SHORT_URI,
             ])
             ->assertJsonFragment([
                 'group_id' => null
@@ -526,7 +476,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
         $twofaccount = TwoFAccount::factory()->create();
 
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('PUT', '/api/v1/twofaccounts/' . $twofaccount->id, self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
+            ->json('PUT', '/api/v1/twofaccounts/' . $twofaccount->id, OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
             ->assertOk()
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_TOTP);
     }
@@ -540,7 +490,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
         $twofaccount = TwoFAccount::factory()->create();
 
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('PUT', '/api/v1/twofaccounts/' . $twofaccount->id, self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP)
+            ->json('PUT', '/api/v1/twofaccounts/' . $twofaccount->id, OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP)
             ->assertOk()
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_HOTP);
     }
@@ -552,7 +502,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function test_update_missing_twofaccount_returns_not_found()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('PUT', '/api/v1/twofaccounts/1000', self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
+            ->json('PUT', '/api/v1/twofaccounts/1000', OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
             ->assertNotFound();
     }
 
@@ -577,30 +527,30 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/import', [
-                'uri' => self::GOOGLE_AUTH_MIGRATION_URI,
+                'uri' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertOk()
             ->assertJsonCount(2, $key = null)
             ->assertJsonFragment([
                 'id'        => 0,
-                'service'   => self::SERVICE,
-                'account'   => self::ACCOUNT,
+                'service'   => OtpTestData::SERVICE,
+                'account'   => OtpTestData::ACCOUNT,
                 'otp_type'  => 'totp',
-                'secret'    => self::SECRET,
-                'digits'    => self::DIGITS_DEFAULT,
-                'algorithm' => self::ALGORITHM_DEFAULT,
-                'period'    => self::PERIOD_DEFAULT,
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_DEFAULT,
+                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+                'period'    => OtpTestData::PERIOD_DEFAULT,
                 'counter'   => null
             ])
             ->assertJsonFragment([
                 'id'        => 0,
-                'service'   => self::SERVICE . '_bis',
-                'account'   => self::ACCOUNT . '_bis',
+                'service'   => OtpTestData::SERVICE . '_bis',
+                'account'   => OtpTestData::ACCOUNT . '_bis',
                 'otp_type'  => 'totp',
-                'secret'    => self::SECRET,
-                'digits'    => self::DIGITS_DEFAULT,
-                'algorithm' => self::ALGORITHM_DEFAULT,
-                'period'    => self::PERIOD_DEFAULT,
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_DEFAULT,
+                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+                'period'    => OtpTestData::PERIOD_DEFAULT,
                 'counter'   => null
             ]);
     }
@@ -613,7 +563,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts', [
-                'uri' => self::INVALID_GOOGLE_AUTH_MIGRATION_URI,
+                'uri' => OtpTestData::INVALID_GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertStatus(422);
     }
@@ -626,25 +576,25 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $twofaccount = TwoFAccount::factory()->create([
             'otp_type' => 'totp',
-            'account' => self::ACCOUNT,
-            'service' => self::SERVICE,
-            'secret' => self::SECRET,
-            'algorithm' => self::ALGORITHM_DEFAULT,
-            'digits' => self::DIGITS_DEFAULT,
-            'period' => self::PERIOD_DEFAULT,
-            'legacy_uri' => self::TOTP_SHORT_URI,
+            'account' => OtpTestData::ACCOUNT,
+            'service' => OtpTestData::SERVICE,
+            'secret' => OtpTestData::SECRET,
+            'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+            'digits' => OtpTestData::DIGITS_DEFAULT,
+            'period' => OtpTestData::PERIOD_DEFAULT,
+            'legacy_uri' => OtpTestData::TOTP_SHORT_URI,
             'icon' => '',
         ]);
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/import', [
-                'uri' => self::GOOGLE_AUTH_MIGRATION_URI,
+                'uri' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertOk()
             ->assertJsonFragment([
                 'id'        => -1,
-                'service'   => self::SERVICE,
-                'account'   => self::ACCOUNT,
+                'service'   => OtpTestData::SERVICE,
+                'account'   => OtpTestData::ACCOUNT,
             ]);
     }
 
@@ -656,7 +606,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/import', [
-                'uri' => self::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
+                'uri' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
             ])
             ->assertStatus(400)
             ->assertJsonStructure([
@@ -703,7 +653,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/preview', [
-                'uri' => self::TOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::TOTP_FULL_CUSTOM_URI,
             ])
             ->assertOk()
             ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_CUSTOM_TOTP);
@@ -717,7 +667,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/preview', [
-                'uri' => self::INVALID_OTPAUTH_URI,
+                'uri' => OtpTestData::INVALID_OTPAUTH_URI,
             ])
             ->assertStatus(422);
     }
@@ -730,7 +680,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/preview', [
-                'uri' => self::TOTP_URI_WITH_UNREACHABLE_IMAGE,
+                'uri' => OtpTestData::TOTP_URI_WITH_UNREACHABLE_IMAGE,
             ])
             ->assertOk()
             ->assertJsonFragment([
@@ -746,13 +696,13 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $twofaccount = TwoFAccount::factory()->create([
             'otp_type' => 'totp',
-            'account' => self::ACCOUNT,
-            'service' => self::SERVICE,
-            'secret' => self::SECRET,
-            'algorithm' => self::ALGORITHM_DEFAULT,
-            'digits' => self::DIGITS_DEFAULT,
-            'period' => self::PERIOD_DEFAULT,
-            'legacy_uri' => self::TOTP_SHORT_URI,
+            'account' => OtpTestData::ACCOUNT,
+            'service' => OtpTestData::SERVICE,
+            'secret' => OtpTestData::SECRET,
+            'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+            'digits' => OtpTestData::DIGITS_DEFAULT,
+            'period' => OtpTestData::PERIOD_DEFAULT,
+            'legacy_uri' => OtpTestData::TOTP_SHORT_URI,
             'icon' => '',
         ]);
 
@@ -762,7 +712,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
             ->assertJsonStructure(self::VALID_OTP_RESOURCE_STRUCTURE_FOR_TOTP)
             ->assertJsonFragment([
                 'otp_type' => 'totp',
-                'period' => self::PERIOD_DEFAULT,
+                'period' => OtpTestData::PERIOD_DEFAULT,
             ]);
     }
 
@@ -774,13 +724,13 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/otp', [
-                'uri' => self::TOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::TOTP_FULL_CUSTOM_URI,
             ])
             ->assertOk()
             ->assertJsonStructure(self::VALID_OTP_RESOURCE_STRUCTURE_FOR_TOTP)
             ->assertJsonFragment([
                 'otp_type' => 'totp',
-                'period' => self::PERIOD_CUSTOM,
+                'period' => OtpTestData::PERIOD_CUSTOM,
             ]);
     }
 
@@ -791,12 +741,12 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function test_get_otp_by_posting_totp_parameters_returns_consistent_resource()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts/otp', self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
+            ->json('POST', '/api/v1/twofaccounts/otp', OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)
             ->assertOk()
             ->assertJsonStructure(self::VALID_OTP_RESOURCE_STRUCTURE_FOR_TOTP)
             ->assertJsonFragment([
                 'otp_type' => 'totp',
-                'period' => self::PERIOD_CUSTOM,
+                'period' => OtpTestData::PERIOD_CUSTOM,
             ]);
     }
 
@@ -808,13 +758,13 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $twofaccount = TwoFAccount::factory()->create([
             'otp_type' => 'hotp',
-            'account' => self::ACCOUNT,
-            'service' => self::SERVICE,
-            'secret' => self::SECRET,
-            'algorithm' => self::ALGORITHM_DEFAULT,
-            'digits' => self::DIGITS_DEFAULT,
+            'account' => OtpTestData::ACCOUNT,
+            'service' => OtpTestData::SERVICE,
+            'secret' => OtpTestData::SECRET,
+            'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+            'digits' => OtpTestData::DIGITS_DEFAULT,
             'period' => null,
-            'legacy_uri' => self::HOTP_SHORT_URI,
+            'legacy_uri' => OtpTestData::HOTP_SHORT_URI,
             'icon' => '',
         ]);
 
@@ -824,7 +774,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
             ->assertJsonStructure(self::VALID_OTP_RESOURCE_STRUCTURE_FOR_HOTP)
             ->assertJsonFragment([
                 'otp_type' => 'hotp',
-                'counter' => self::COUNTER_DEFAULT + 1,
+                'counter' => OtpTestData::COUNTER_DEFAULT + 1,
             ]);
     }
 
@@ -836,13 +786,13 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/otp', [
-                'uri' => self::HOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::HOTP_FULL_CUSTOM_URI,
             ])
             ->assertOk()
             ->assertJsonStructure(self::VALID_OTP_RESOURCE_STRUCTURE_FOR_HOTP)
             ->assertJsonFragment([
                 'otp_type' => 'hotp',
-                'counter' => self::COUNTER_CUSTOM + 1,
+                'counter' => OtpTestData::COUNTER_CUSTOM + 1,
             ]);
     }
 
@@ -853,12 +803,12 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function test_get_otp_by_posting_hotp_parameters_returns_consistent_resource()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts/otp', self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP)
+            ->json('POST', '/api/v1/twofaccounts/otp', OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP)
             ->assertOk()
             ->assertJsonStructure(self::VALID_OTP_RESOURCE_STRUCTURE_FOR_HOTP)
             ->assertJsonFragment([
                 'otp_type' => 'hotp',
-                'counter' => self::COUNTER_CUSTOM + 1,
+                'counter' => OtpTestData::COUNTER_CUSTOM + 1,
             ]);
     }
 
@@ -870,7 +820,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/otp', [
-                'uri' => self::HOTP_FULL_CUSTOM_URI,
+                'uri' => OtpTestData::HOTP_FULL_CUSTOM_URI,
                 'key' => 'value',
             ])
             ->assertStatus(400)
@@ -924,7 +874,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/otp', [
-                'uri' => self::INVALID_OTPAUTH_URI,
+                'uri' => OtpTestData::INVALID_OTPAUTH_URI,
             ])
             ->assertStatus(422);
     }

+ 16 - 0
tests/Api/v1/Requests/TwoFAccountUriRequestTest.php

@@ -50,6 +50,10 @@ class TwoFAccountUriRequestTest extends TestCase
             [[
                 'uri' => 'otpauth://hotp/test@test.com?secret=A4GRFHZVRBGY7UIW&issuer=test'
             ]],
+            [[
+                'uri' => 'otpauth://totp/test@test.com?secret=A4GRFHZVRBGY7UIW&issuer=test',
+                'custom_otp' => 'steamtotp'
+            ]],
         ];
     }
 
@@ -85,6 +89,18 @@ class TwoFAccountUriRequestTest extends TestCase
             [[
                 'uri' => 'otpXauth://totp/test@test.com?secret=A4GRFHZVRBGY7UIW&issuer=test' // regex
             ]],
+            [[
+                'uri' => 'otpauth://totp/test@test.com?secret=A4GRFHZVRBGY7UIW&issuer=test',
+                'custom_otp' => 'notSteam' // not in
+            ]],
+            [[
+                'uri' => 'otpauth://totp/test@test.com?secret=A4GRFHZVRBGY7UIW&issuer=test',
+                'custom_otp' => 0 // string
+            ]],
+            [[
+                'uri' => 'otpauth://totp/test@test.com?secret=A4GRFHZVRBGY7UIW&issuer=test',
+                'custom_otp' => true // string
+            ]],
         ];
     }
 

+ 73 - 0
tests/Classes/OtpTestData.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Tests\Classes;
+
+class OtpTestData
+{
+    const ACCOUNT = 'account';
+    const SERVICE = 'service';
+    const SECRET = 'A4GRFHVVRBGY7UIW';
+    const ALGORITHM_DEFAULT = 'sha1';
+    const ALGORITHM_CUSTOM = 'sha256';
+    const DIGITS_DEFAULT = 6;
+    const DIGITS_CUSTOM = 7;
+    const PERIOD_DEFAULT = 30;
+    const PERIOD_CUSTOM = 40;
+    const COUNTER_DEFAULT = 0;
+    const COUNTER_CUSTOM = 5;
+    const IMAGE = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
+    const ICON = 'test.png';
+    const TOTP_FULL_CUSTOM_URI = 'otpauth://totp/'.self::SERVICE.':'.self::ACCOUNT.'?secret='.self::SECRET.'&issuer='.self::SERVICE.'&digits='.self::DIGITS_CUSTOM.'&period='.self::PERIOD_CUSTOM.'&algorithm='.self::ALGORITHM_CUSTOM.'&image='.self::IMAGE;
+    const HOTP_FULL_CUSTOM_URI = 'otpauth://hotp/'.self::SERVICE.':'.self::ACCOUNT.'?secret='.self::SECRET.'&issuer='.self::SERVICE.'&digits='.self::DIGITS_CUSTOM.'&counter='.self::COUNTER_CUSTOM.'&algorithm='.self::ALGORITHM_CUSTOM.'&image='.self::IMAGE;
+    const TOTP_SHORT_URI = 'otpauth://totp/'.self::ACCOUNT.'?secret='.self::SECRET;
+    const HOTP_SHORT_URI = 'otpauth://hotp/'.self::ACCOUNT.'?secret='.self::SECRET;
+    const TOTP_URI_WITH_UNREACHABLE_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=https%3A%2F%2Fen.opensuse.org%2Fimage.png';
+    const INVALID_OTPAUTH_URI = 'otpauth://Xotp/'.self::ACCOUNT.'?secret='.self::SECRET;
+
+    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP = [
+        'service'   => self::SERVICE,
+        'account'   => self::ACCOUNT,
+        'icon'      => self::ICON,
+        'otp_type'  => 'totp',
+        'secret'    => self::SECRET,
+        'digits'    => self::DIGITS_CUSTOM,
+        'algorithm' => self::ALGORITHM_CUSTOM,
+        'period'    => self::PERIOD_CUSTOM,
+        'counter'   => null,
+    ];
+    const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP = [
+        'account'   => self::ACCOUNT,
+        'otp_type'  => 'totp',
+        'secret'    => self::SECRET,
+    ];
+    const ARRAY_OF_PARAMETERS_FOR_UNSUPPORTED_OTP_TYPE = [
+        'account'   => self::ACCOUNT,
+        'otp_type'  => 'Xotp',
+        'secret'    => self::SECRET,
+    ];
+    const ARRAY_OF_INVALID_PARAMETERS_FOR_TOTP = [
+        'account'   => self::ACCOUNT,
+        'otp_type'  => 'totp',
+        'secret'    => 0,
+    ];
+    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP = [
+        'service'   => self::SERVICE,
+        'account'   => self::ACCOUNT,
+        'icon'      => self::ICON,
+        'otp_type'  => 'hotp',
+        'secret'    => self::SECRET,
+        'digits'    => self::DIGITS_CUSTOM,
+        'algorithm' => self::ALGORITHM_CUSTOM,
+        'period'    => null,
+        'counter'   => self::COUNTER_CUSTOM,
+    ];
+    const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP = [
+        'account'   => self::ACCOUNT,
+        'otp_type'  => 'hotp',
+        'secret'    => self::SECRET,
+    ];
+
+    const GOOGLE_AUTH_MIGRATION_URI = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY2UgASgBMAIKLAoKBw0SnrWITY/RFhILYWNjb3VudF9iaXMaC3NlcnZpY2VfYmlzIAEoATACEAEYASAA';
+    const INVALID_GOOGLE_AUTH_MIGRATION_URI = 'otpauthmigration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY2UgASgBMAIKLAoKBw0SnrWITY/RFhILYWNjb3VudF9iaXMaC3NlcnZpY2VfYmlzIAEoATACEAEYASAA';
+    const GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY';
+}

+ 510 - 0
tests/Feature/Models/TwoFAccountModelTest.php

@@ -0,0 +1,510 @@
+<?php
+
+namespace Tests\Feature\Models;
+
+use App\Models\TwoFAccount;
+use Tests\FeatureTestCase;
+use Tests\Classes\OtpTestData;
+
+/**
+ * @covers \App\Models\TwoFAccount
+ */
+class TwoFAccountModelTest extends FeatureTestCase
+{
+    /**
+     * App\Models\TwoFAccount $customTotpTwofaccount
+     */
+    protected $customTotpTwofaccount;
+
+
+    /**
+     * App\Models\TwoFAccount $customTotpTwofaccount
+     */
+    protected $customHotpTwofaccount;
+
+
+    /**
+     * @test
+     */
+    public function setUp() : void
+    {
+        parent::setUp();
+
+        // $this->twofaccountService = $this->app->make('App\Services\TwoFAccountService');
+
+        $this->customTotpTwofaccount = new TwoFAccount;
+        $this->customTotpTwofaccount->legacy_uri = OtpTestData::TOTP_FULL_CUSTOM_URI;
+        $this->customTotpTwofaccount->service = OtpTestData::SERVICE;
+        $this->customTotpTwofaccount->account = OtpTestData::ACCOUNT;
+        $this->customTotpTwofaccount->icon = OtpTestData::ICON;
+        $this->customTotpTwofaccount->otp_type = 'totp';
+        $this->customTotpTwofaccount->secret = OtpTestData::SECRET;
+        $this->customTotpTwofaccount->digits = OtpTestData::DIGITS_CUSTOM;
+        $this->customTotpTwofaccount->algorithm = OtpTestData::ALGORITHM_CUSTOM;
+        $this->customTotpTwofaccount->period = OtpTestData::PERIOD_CUSTOM;
+        $this->customTotpTwofaccount->counter = null;
+        $this->customTotpTwofaccount->save();
+
+        $this->customHotpTwofaccount = new TwoFAccount;
+        $this->customHotpTwofaccount->legacy_uri = OtpTestData::HOTP_FULL_CUSTOM_URI;
+        $this->customHotpTwofaccount->service = OtpTestData::SERVICE;
+        $this->customHotpTwofaccount->account = OtpTestData::ACCOUNT;
+        $this->customHotpTwofaccount->icon = OtpTestData::ICON;
+        $this->customHotpTwofaccount->otp_type = 'hotp';
+        $this->customHotpTwofaccount->secret = OtpTestData::SECRET;
+        $this->customHotpTwofaccount->digits = OtpTestData::DIGITS_CUSTOM;
+        $this->customHotpTwofaccount->algorithm = OtpTestData::ALGORITHM_CUSTOM;
+        $this->customHotpTwofaccount->period = null;
+        $this->customHotpTwofaccount->counter = OtpTestData::COUNTER_CUSTOM;
+        $this->customHotpTwofaccount->save();
+
+
+        // $this->group = new Group;
+        // $this->group->name = 'MyGroup';
+        // $this->group->save();
+    }
+
+
+    /**
+    * @test
+    */
+    public function test_fill_with_custom_totp_uri_returns_correct_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI(OtpTestData::TOTP_FULL_CUSTOM_URI);
+
+        $this->assertEquals('totp', $twofaccount->otp_type);
+        $this->assertEquals(OtpTestData::TOTP_FULL_CUSTOM_URI, $twofaccount->legacy_uri);
+        $this->assertEquals(OtpTestData::SERVICE, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_CUSTOM, $twofaccount->digits);
+        $this->assertEquals(OtpTestData::PERIOD_CUSTOM, $twofaccount->period);
+        $this->assertEquals(null, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_CUSTOM, $twofaccount->algorithm);
+        $this->assertStringEndsWith('.png',$twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_fill_with_basic_totp_uri_returns_default_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI(OtpTestData::TOTP_SHORT_URI);
+
+        $this->assertEquals('totp', $twofaccount->otp_type);
+        $this->assertEquals(OtpTestData::TOTP_SHORT_URI, $twofaccount->legacy_uri);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(null, $twofaccount->service);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccount->digits);
+        $this->assertEquals(OtpTestData::PERIOD_DEFAULT, $twofaccount->period);
+        $this->assertEquals(null, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccount->algorithm);
+        $this->assertEquals(null, $twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_fill_with_custom_hotp_uri_returns_correct_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI(OtpTestData::HOTP_FULL_CUSTOM_URI);
+
+        $this->assertEquals('hotp', $twofaccount->otp_type);
+        $this->assertEquals(OtpTestData::HOTP_FULL_CUSTOM_URI, $twofaccount->legacy_uri);
+        $this->assertEquals(OtpTestData::SERVICE, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_CUSTOM, $twofaccount->digits);
+        $this->assertEquals(null, $twofaccount->period);
+        $this->assertEquals(OtpTestData::COUNTER_CUSTOM, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_CUSTOM, $twofaccount->algorithm);
+        $this->assertStringEndsWith('.png',$twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_fill_with_basic_hotp_uri_returns_default_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI(OtpTestData::HOTP_SHORT_URI);
+
+        $this->assertEquals('hotp', $twofaccount->otp_type);
+        $this->assertEquals(OtpTestData::HOTP_SHORT_URI, $twofaccount->legacy_uri);
+        $this->assertEquals(null, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccount->digits);
+        $this->assertEquals(null, $twofaccount->period);
+        $this->assertEquals(OtpTestData::COUNTER_DEFAULT, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccount->algorithm);
+        $this->assertEquals(null, $twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_filled_with_uri_persists_correct_values_to_db()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI(OtpTestData::TOTP_SHORT_URI);
+        $twofaccount->save();
+
+        $this->assertDatabaseHas('twofaccounts', [
+            'otp_type'      => 'totp',
+            'legacy_uri'    => OtpTestData::TOTP_SHORT_URI,
+            'service'       => null,
+            'account'       => OtpTestData::ACCOUNT,
+            'secret'        => OtpTestData::SECRET,
+            'digits'        => OtpTestData::DIGITS_DEFAULT,
+            'period'        => OtpTestData::PERIOD_DEFAULT,
+            'counter'       => null,
+            'algorithm'     => OtpTestData::ALGORITHM_DEFAULT,
+            'icon'          => null,
+        ]);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_fill_with_invalid_uri_returns_ValidationException()
+    {
+        $this->expectException(\Illuminate\Validation\ValidationException::class);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI(OtpTestData::INVALID_OTPAUTH_URI);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_fill_with_uri_without_label_returns_ValidationException()
+    {
+        $this->expectException(\Illuminate\Validation\ValidationException::class);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI('otpauth://totp/?secret='.OtpTestData::SECRET);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_create_custom_totp_from_parameters_returns_correct_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP);
+
+        $this->assertEquals('totp', $twofaccount->otp_type);
+        $this->assertEquals(OtpTestData::SERVICE, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_CUSTOM, $twofaccount->digits);
+        $this->assertEquals(OtpTestData::PERIOD_CUSTOM, $twofaccount->period);
+        $this->assertEquals(null, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_CUSTOM, $twofaccount->algorithm);
+        $this->assertStringEndsWith('.png',$twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_create_basic_totp_from_parameters_returns_correct_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
+
+        $this->assertEquals('totp', $twofaccount->otp_type);
+        $this->assertEquals(null, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccount->digits);
+        $this->assertEquals(OtpTestData::PERIOD_DEFAULT, $twofaccount->period);
+        $this->assertEquals(null, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccount->algorithm);
+        $this->assertEquals(null, $twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_create_custom_hotp_from_parameters_returns_correct_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP);
+
+        $this->assertEquals('hotp', $twofaccount->otp_type);
+        $this->assertEquals(OtpTestData::SERVICE, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_CUSTOM, $twofaccount->digits);
+        $this->assertEquals(null, $twofaccount->period);
+        $this->assertEquals(OtpTestData::COUNTER_CUSTOM, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_CUSTOM, $twofaccount->algorithm);
+        $this->assertStringEndsWith('.png',$twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_create_basic_hotp_from_parameters_returns_correct_value()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP);
+
+        $this->assertEquals('hotp', $twofaccount->otp_type);
+        $this->assertEquals(null, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccount->digits);
+        $this->assertEquals(null, $twofaccount->period);
+        $this->assertEquals(OtpTestData::COUNTER_DEFAULT, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccount->algorithm);
+        $this->assertEquals(null, $twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_create_from_parameters_persists_correct_values_to_db()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
+        $twofaccount->save();
+
+        $this->assertDatabaseHas('twofaccounts', [
+            'otp_type'      => 'totp',
+            'legacy_uri'    => OtpTestData::TOTP_SHORT_URI,
+            'service'       => null,
+            'account'       => OtpTestData::ACCOUNT,
+            'secret'        => OtpTestData::SECRET,
+            'digits'        => OtpTestData::DIGITS_DEFAULT,
+            'period'        => OtpTestData::PERIOD_DEFAULT,
+            'counter'       => null,
+            'algorithm'     => OtpTestData::ALGORITHM_DEFAULT,
+            'icon'          => null,
+        ]);
+    }
+
+    
+    /**
+     * @test
+     */
+    public function test_create_from_unsupported_parameters_returns_unsupportedOtpTypeException()
+    {
+        $this->expectException(\App\Exceptions\UnsupportedOtpTypeException::class);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_PARAMETERS_FOR_UNSUPPORTED_OTP_TYPE);
+    }
+
+    
+    /**
+     * @test
+     */
+    public function test_create_from_invalid_parameters_type_returns_InvalidOtpParameterException()
+    {
+        $this->expectException(\App\Exceptions\InvalidOtpParameterException::class);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters([
+            'account'   => OtpTestData::ACCOUNT,
+            'otp_type'  => 'totp',
+            'digits' => 'notsupported',
+        ]);
+    }
+
+    
+    /**
+     * @test
+     */
+    public function test_create_from_invalid_parameters_returns_InvalidOtpParameterException()
+    {
+        $this->expectException(\App\Exceptions\InvalidOtpParameterException::class);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters([
+            'account'   => OtpTestData::ACCOUNT,
+            'otp_type'  => 'totp',
+            'algorithm' => 'notsupported',
+        ]);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_update_totp_returns_updated_model()
+    {
+        $twofaccount = $this->customTotpTwofaccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
+
+        $this->assertEquals('totp', $twofaccount->otp_type);
+        $this->assertEquals(null, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccount->digits);
+        $this->assertEquals(OtpTestData::PERIOD_DEFAULT, $twofaccount->period);
+        $this->assertEquals(null, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccount->algorithm);
+        $this->assertEquals(null, $twofaccount->counter);
+        $this->assertEquals(null, $twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_update_hotp_returns_updated_model()
+    {
+        $twofaccount = $this->customTotpTwofaccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP);
+
+        $this->assertEquals('hotp', $twofaccount->otp_type);
+        $this->assertEquals(null, $twofaccount->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccount->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccount->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccount->digits);
+        $this->assertEquals(null, $twofaccount->period);
+        $this->assertEquals(OtpTestData::COUNTER_DEFAULT, $twofaccount->counter);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccount->algorithm);
+        $this->assertEquals(null, $twofaccount->counter);
+        $this->assertEquals(null, $twofaccount->icon);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_update_totp_persists_updated_model()
+    {
+        $twofaccount = $this->customTotpTwofaccount;
+        $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
+        $twofaccount->save();
+
+        $this->assertDatabaseHas('twofaccounts', [
+            'otp_type'      => 'totp',
+            'service'       => null,
+            'account'       => OtpTestData::ACCOUNT,
+            'secret'        => OtpTestData::SECRET,
+            'digits'        => OtpTestData::DIGITS_DEFAULT,
+            'period'        => OtpTestData::PERIOD_DEFAULT,
+            'counter'       => null,
+            'algorithm'     => OtpTestData::ALGORITHM_DEFAULT,
+            'icon'          => null,
+        ]);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_getOTP_for_totp_returns_the_same_password()
+    {
+        $twofaccount = new TwoFAccount;
+
+        $otp_from_model = $this->customTotpTwofaccount->getOTP();
+        $otp_from_uri = $twofaccount->fillWithURI(OtpTestData::TOTP_FULL_CUSTOM_URI)->getOTP();
+
+        if ($otp_from_model->generated_at === $otp_from_uri->generated_at) {
+            $this->assertEquals($otp_from_model, $otp_from_uri);
+        }
+
+        $otp_from_model = $this->customTotpTwofaccount->getOTP();
+        $otp_from_parameters = $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP)->getOTP();
+
+        if ($otp_from_model->generated_at === $otp_from_parameters->generated_at) {
+            $this->assertEquals($otp_from_model, $otp_from_parameters);
+        }
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_getOTP_for_hotp_returns_the_same_password()
+    {
+        $twofaccount = new TwoFAccount;
+
+        $otp_from_model = $this->customHotpTwofaccount->getOTP();
+        $otp_from_uri = $twofaccount->fillWithURI(OtpTestData::HOTP_FULL_CUSTOM_URI)->getOTP();
+
+        $this->assertEquals($otp_from_model, $otp_from_uri);
+
+        $otp_from_parameters = $twofaccount->fillWithOtpParameters(OtpTestData::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP)->getOTP();
+
+        $this->assertEquals($otp_from_model, $otp_from_parameters);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_getOTP_for_totp_with_invalid_secret_returns_InvalidSecretException()
+    {
+        $twofaccount = new TwoFAccount;
+
+        $this->expectException(\App\Exceptions\InvalidSecretException::class);
+        $otp_from_uri = $twofaccount->fillWithURI('otpauth://totp/'.OtpTestData::ACCOUNT.'?secret=0')->getOTP();
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_getOTP_for_totp_with_undecipherable_secret_returns_UndecipherableException()
+    {
+        $twofaccount = new TwoFAccount;
+
+        $this->expectException(\App\Exceptions\UndecipherableException::class);
+        $otp_from_uri = $twofaccount->fillWithOtpParameters([
+            'account'   => OtpTestData::ACCOUNT,
+            'otp_type'  => 'totp',
+            'secret'    => __('errors.indecipherable'),
+        ])->getOTP();
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_getURI_for_custom_totp_model_returns_uri()
+    {
+        $uri = $this->customTotpTwofaccount->getURI();
+        
+        $this->assertStringContainsString('otpauth://totp/', $uri);
+        $this->assertStringContainsString(OtpTestData::SERVICE, $uri);
+        $this->assertStringContainsString(OtpTestData::ACCOUNT, $uri);
+        $this->assertStringContainsString('secret='.OtpTestData::SECRET, $uri);
+        $this->assertStringContainsString('digits='.OtpTestData::DIGITS_CUSTOM, $uri);
+        $this->assertStringContainsString('period='.OtpTestData::PERIOD_CUSTOM, $uri);
+        $this->assertStringContainsString('algorithm='.OtpTestData::ALGORITHM_CUSTOM, $uri);
+    }
+
+
+    /**
+     * @test
+     */
+    public function test_getURI_for_custom_hotp_model_returns_uri()
+    {
+        $uri = $this->customHotpTwofaccount->getURI();
+        
+        $this->assertStringContainsString('otpauth://hotp/', $uri);
+        $this->assertStringContainsString(OtpTestData::SERVICE, $uri);
+        $this->assertStringContainsString(OtpTestData::ACCOUNT, $uri);
+        $this->assertStringContainsString('secret='.OtpTestData::SECRET, $uri);
+        $this->assertStringContainsString('digits='.OtpTestData::DIGITS_CUSTOM, $uri);
+        $this->assertStringContainsString('counter='.OtpTestData::COUNTER_CUSTOM, $uri);
+        $this->assertStringContainsString('algorithm='.OtpTestData::ALGORITHM_CUSTOM, $uri);
+    }
+
+}

+ 46 - 611
tests/Feature/Services/TwoFAccountServiceTest.php

@@ -5,7 +5,7 @@ namespace Tests\Feature\Services;
 use App\Models\Group;
 use App\Models\TwoFAccount;
 use Tests\FeatureTestCase;
-use Illuminate\Support\Facades\DB;
+use Tests\Classes\OtpTestData;
 
 
 /**
@@ -36,70 +36,6 @@ class TwoFAccountServiceTest extends FeatureTestCase
      */
     protected $customHotpTwofaccount;
 
-    private const ACCOUNT = 'account';
-    private const SERVICE = 'service';
-    private const SECRET = 'A4GRFHVVRBGY7UIW';
-    private const ALGORITHM_DEFAULT = 'sha1';
-    private const ALGORITHM_CUSTOM = 'sha256';
-    private const DIGITS_DEFAULT = 6;
-    private const DIGITS_CUSTOM = 7;
-    private const PERIOD_DEFAULT = 30;
-    private const PERIOD_CUSTOM = 40;
-    private const COUNTER_DEFAULT = 0;
-    private const COUNTER_CUSTOM = 5;
-    private const IMAGE = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
-    private const ICON = 'test.png';
-    private const TOTP_FULL_CUSTOM_URI = 'otpauth://totp/'.self::SERVICE.':'.self::ACCOUNT.'?secret='.self::SECRET.'&issuer='.self::SERVICE.'&digits='.self::DIGITS_CUSTOM.'&period='.self::PERIOD_CUSTOM.'&algorithm='.self::ALGORITHM_CUSTOM.'&image='.self::IMAGE;
-    private const HOTP_FULL_CUSTOM_URI = 'otpauth://hotp/'.self::SERVICE.':'.self::ACCOUNT.'?secret='.self::SECRET.'&issuer='.self::SERVICE.'&digits='.self::DIGITS_CUSTOM.'&counter='.self::COUNTER_CUSTOM.'&algorithm='.self::ALGORITHM_CUSTOM.'&image='.self::IMAGE;
-    private const TOTP_SHORT_URI = 'otpauth://totp/'.self::ACCOUNT.'?secret='.self::SECRET;
-    private const HOTP_SHORT_URI = 'otpauth://hotp/'.self::ACCOUNT.'?secret='.self::SECRET;
-    private const TOTP_URI_WITH_UNREACHABLE_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=https%3A%2F%2Fen.opensuse.org%2Fimage.png';
-    private const INVALID_OTPAUTH_URI = 'otpauth://Xotp/'.self::ACCOUNT.'?secret='.self::SECRET;
-    private const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
-        'icon'      => self::ICON,
-        'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
-        'period'    => self::PERIOD_CUSTOM,
-        'counter'   => null,
-    ];
-    private const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP = [
-        'account'   => self::ACCOUNT,
-        'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
-    ];
-    private const ARRAY_OF_PARAMETERS_FOR_UNSUPPORTED_OTP_TYPE = [
-        'account'   => self::ACCOUNT,
-        'otp_type'  => 'Xotp',
-        'secret'    => self::SECRET,
-    ];
-    private const ARRAY_OF_INVALID_PARAMETERS_FOR_TOTP = [
-        'account'   => self::ACCOUNT,
-        'otp_type'  => 'totp',
-        'secret'    => 0,
-    ];
-    private const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
-        'icon'      => self::ICON,
-        'otp_type'  => 'hotp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
-        'period'    => null,
-        'counter'   => self::COUNTER_CUSTOM,
-    ];
-    private const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP = [
-        'account'   => self::ACCOUNT,
-        'otp_type'  => 'hotp',
-        'secret'    => self::SECRET,
-    ];
-    private const GOOGLE_AUTH_MIGRATION_URI = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY2UgASgBMAIKLAoKBw0SnrWITY/RFhILYWNjb3VudF9iaXMaC3NlcnZpY2VfYmlzIAEoATACEAEYASAA';
-    private const GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY';
-
 
     /**
      * @test
@@ -111,29 +47,29 @@ class TwoFAccountServiceTest extends FeatureTestCase
         $this->twofaccountService = $this->app->make('App\Services\TwoFAccountService');
 
         $this->customTotpTwofaccount = new TwoFAccount;
-        $this->customTotpTwofaccount->legacy_uri = self::TOTP_FULL_CUSTOM_URI;
-        $this->customTotpTwofaccount->service = self::SERVICE;
-        $this->customTotpTwofaccount->account = self::ACCOUNT;
-        $this->customTotpTwofaccount->icon = self::ICON;
+        $this->customTotpTwofaccount->legacy_uri = OtpTestData::TOTP_FULL_CUSTOM_URI;
+        $this->customTotpTwofaccount->service = OtpTestData::SERVICE;
+        $this->customTotpTwofaccount->account = OtpTestData::ACCOUNT;
+        $this->customTotpTwofaccount->icon = OtpTestData::ICON;
         $this->customTotpTwofaccount->otp_type = 'totp';
-        $this->customTotpTwofaccount->secret = self::SECRET;
-        $this->customTotpTwofaccount->digits = self::DIGITS_CUSTOM;
-        $this->customTotpTwofaccount->algorithm = self::ALGORITHM_CUSTOM;
-        $this->customTotpTwofaccount->period = self::PERIOD_CUSTOM;
+        $this->customTotpTwofaccount->secret = OtpTestData::SECRET;
+        $this->customTotpTwofaccount->digits = OtpTestData::DIGITS_CUSTOM;
+        $this->customTotpTwofaccount->algorithm = OtpTestData::ALGORITHM_CUSTOM;
+        $this->customTotpTwofaccount->period = OtpTestData::PERIOD_CUSTOM;
         $this->customTotpTwofaccount->counter = null;
         $this->customTotpTwofaccount->save();
 
         $this->customHotpTwofaccount = new TwoFAccount;
-        $this->customHotpTwofaccount->legacy_uri = self::HOTP_FULL_CUSTOM_URI;
-        $this->customHotpTwofaccount->service = self::SERVICE;
-        $this->customHotpTwofaccount->account = self::ACCOUNT;
-        $this->customHotpTwofaccount->icon = self::ICON;
+        $this->customHotpTwofaccount->legacy_uri = OtpTestData::HOTP_FULL_CUSTOM_URI;
+        $this->customHotpTwofaccount->service = OtpTestData::SERVICE;
+        $this->customHotpTwofaccount->account = OtpTestData::ACCOUNT;
+        $this->customHotpTwofaccount->icon = OtpTestData::ICON;
         $this->customHotpTwofaccount->otp_type = 'hotp';
-        $this->customHotpTwofaccount->secret = self::SECRET;
-        $this->customHotpTwofaccount->digits = self::DIGITS_CUSTOM;
-        $this->customHotpTwofaccount->algorithm = self::ALGORITHM_CUSTOM;
+        $this->customHotpTwofaccount->secret = OtpTestData::SECRET;
+        $this->customHotpTwofaccount->digits = OtpTestData::DIGITS_CUSTOM;
+        $this->customHotpTwofaccount->algorithm = OtpTestData::ALGORITHM_CUSTOM;
         $this->customHotpTwofaccount->period = null;
-        $this->customHotpTwofaccount->counter = self::COUNTER_CUSTOM;
+        $this->customHotpTwofaccount->counter = OtpTestData::COUNTER_CUSTOM;
         $this->customHotpTwofaccount->save();
 
 
@@ -143,510 +79,6 @@ class TwoFAccountServiceTest extends FeatureTestCase
     }
 
 
-    /**
-     * @test
-     */
-    public function test_create_custom_totp_from_uri_returns_correct_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromUri(self::TOTP_FULL_CUSTOM_URI);
-
-        $this->assertEquals('totp', $twofaccount->otp_type);
-        $this->assertEquals(self::TOTP_FULL_CUSTOM_URI, $twofaccount->legacy_uri);
-        $this->assertEquals(self::SERVICE, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_CUSTOM, $twofaccount->digits);
-        $this->assertEquals(self::PERIOD_CUSTOM, $twofaccount->period);
-        $this->assertEquals(null, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_CUSTOM, $twofaccount->algorithm);
-        $this->assertStringEndsWith('.png',$twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_basic_totp_from_uri_returns_default_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromUri(self::TOTP_SHORT_URI);
-
-        $this->assertEquals('totp', $twofaccount->otp_type);
-        $this->assertEquals(self::TOTP_SHORT_URI, $twofaccount->legacy_uri);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(null, $twofaccount->service);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccount->digits);
-        $this->assertEquals(self::PERIOD_DEFAULT, $twofaccount->period);
-        $this->assertEquals(null, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccount->algorithm);
-        $this->assertEquals(null, $twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_custom_hotp_from_uri_returns_correct_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromUri(self::HOTP_FULL_CUSTOM_URI);
-
-        $this->assertEquals('hotp', $twofaccount->otp_type);
-        $this->assertEquals(self::HOTP_FULL_CUSTOM_URI, $twofaccount->legacy_uri);
-        $this->assertEquals(self::SERVICE, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_CUSTOM, $twofaccount->digits);
-        $this->assertEquals(null, $twofaccount->period);
-        $this->assertEquals(self::COUNTER_CUSTOM, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_CUSTOM, $twofaccount->algorithm);
-        $this->assertStringEndsWith('.png',$twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_basic_hotp_from_uri_returns_default_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromUri(self::HOTP_SHORT_URI);
-
-        $this->assertEquals('hotp', $twofaccount->otp_type);
-        $this->assertEquals(self::HOTP_SHORT_URI, $twofaccount->legacy_uri);
-        $this->assertEquals(null, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccount->digits);
-        $this->assertEquals(null, $twofaccount->period);
-        $this->assertEquals(self::COUNTER_DEFAULT, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccount->algorithm);
-        $this->assertEquals(null, $twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_from_uri_persists_to_db()
-    {
-        $twofaccount = $this->twofaccountService->createFromUri(self::TOTP_SHORT_URI);
-
-        $this->assertDatabaseHas('twofaccounts', [
-            'otp_type'      => 'totp',
-            'legacy_uri'    => self::TOTP_SHORT_URI,
-            'service'       => null,
-            'account'       => self::ACCOUNT,
-            'secret'        => self::SECRET,
-            'digits'        => self::DIGITS_DEFAULT,
-            'period'        => self::PERIOD_DEFAULT,
-            'counter'       => null,
-            'algorithm'     => self::ALGORITHM_DEFAULT,
-            'icon'          => null,
-        ]);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_from_uri_does_not_persist_to_db()
-    {
-        $twofaccount = $this->twofaccountService->createFromUri(self::TOTP_SHORT_URI, false);
-
-        $this->assertDatabaseMissing('twofaccounts', [
-            'otp_type'      => 'totp',
-            'legacy_uri'    => self::TOTP_SHORT_URI,
-            'service'       => null,
-            'account'       => self::ACCOUNT,
-            'secret'        => self::SECRET,
-            'digits'        => self::DIGITS_DEFAULT,
-            'period'        => self::PERIOD_DEFAULT,
-            'counter'       => null,
-            'algorithm'     => self::ALGORITHM_DEFAULT,
-            'icon'          => null,
-        ]);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_from_invalid_uri_returns_ValidationException()
-    {
-        $this->expectException(\Illuminate\Validation\ValidationException::class);
-        $twofaccount = $this->twofaccountService->createFromUri(self::INVALID_OTPAUTH_URI);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_from_uri_without_label_returns_ValidationException()
-    {
-        $this->expectException(\Illuminate\Validation\ValidationException::class);
-        $twofaccount = $this->twofaccountService->createFromUri('otpauth://totp/?secret='.self::SECRET);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_custom_totp_from_parameters_returns_correct_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromParameters(self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP);
-
-        $this->assertEquals('totp', $twofaccount->otp_type);
-        $this->assertEquals(self::SERVICE, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_CUSTOM, $twofaccount->digits);
-        $this->assertEquals(self::PERIOD_CUSTOM, $twofaccount->period);
-        $this->assertEquals(null, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_CUSTOM, $twofaccount->algorithm);
-        $this->assertStringEndsWith('.png',$twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_basic_totp_from_parameters_returns_correct_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromParameters(self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
-
-        $this->assertEquals('totp', $twofaccount->otp_type);
-        $this->assertEquals(null, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccount->digits);
-        $this->assertEquals(self::PERIOD_DEFAULT, $twofaccount->period);
-        $this->assertEquals(null, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccount->algorithm);
-        $this->assertEquals(null, $twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_custom_hotp_from_parameters_returns_correct_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromParameters(self::ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP);
-
-        $this->assertEquals('hotp', $twofaccount->otp_type);
-        $this->assertEquals(self::SERVICE, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_CUSTOM, $twofaccount->digits);
-        $this->assertEquals(null, $twofaccount->period);
-        $this->assertEquals(self::COUNTER_CUSTOM, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_CUSTOM, $twofaccount->algorithm);
-        $this->assertStringEndsWith('.png',$twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_basic_hotp_from_parameters_returns_correct_value()
-    {
-        $twofaccount = $this->twofaccountService->createFromParameters(self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP);
-
-        $this->assertEquals('hotp', $twofaccount->otp_type);
-        $this->assertEquals(null, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccount->digits);
-        $this->assertEquals(null, $twofaccount->period);
-        $this->assertEquals(self::COUNTER_DEFAULT, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccount->algorithm);
-        $this->assertEquals(null, $twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_from_parameters_persists_to_db()
-    {
-        $twofaccount = $this->twofaccountService->createFromParameters(self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
-
-        $this->assertDatabaseHas('twofaccounts', [
-            'otp_type'      => 'totp',
-            'legacy_uri'    => self::TOTP_SHORT_URI,
-            'service'       => null,
-            'account'       => self::ACCOUNT,
-            'secret'        => self::SECRET,
-            'digits'        => self::DIGITS_DEFAULT,
-            'period'        => self::PERIOD_DEFAULT,
-            'counter'       => null,
-            'algorithm'     => self::ALGORITHM_DEFAULT,
-            'icon'          => null,
-        ]);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_create_from_parameters_does_not_persist_to_db()
-    {
-        $twofaccount = $this->twofaccountService->createFromParameters(self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP, false);
-
-        $this->assertDatabaseMissing('twofaccounts', [
-            'otp_type'      => 'totp',
-            'legacy_uri'    => self::TOTP_SHORT_URI,
-            'service'       => null,
-            'account'       => self::ACCOUNT,
-            'secret'        => self::SECRET,
-            'digits'        => self::DIGITS_DEFAULT,
-            'period'        => self::PERIOD_DEFAULT,
-            'counter'       => null,
-            'algorithm'     => self::ALGORITHM_DEFAULT,
-            'icon'          => null,
-        ]);
-    }
-
-    
-    /**
-     * @test
-     */
-    public function test_create_from_unsupported_parameters_returns_ValidationException()
-    {
-        $this->expectException(\Illuminate\Validation\ValidationException::class);
-        $twofaccount = $this->twofaccountService->createFromParameters(self::ARRAY_OF_PARAMETERS_FOR_UNSUPPORTED_OTP_TYPE);
-    }
-
-    
-    /**
-     * @test
-     */
-    public function test_create_from_invalid_parameters_type_returns_InvalidOtpParameterException()
-    {
-        $this->expectException(\App\Exceptions\InvalidOtpParameterException::class);
-        $twofaccount = $this->twofaccountService->createFromParameters([
-            'account'   => self::ACCOUNT,
-            'otp_type'  => 'totp',
-            'digits' => 'notsupported',
-        ]);
-    }
-
-    
-    /**
-     * @test
-     */
-    public function test_create_from_invalid_parameters_returns_InvalidOtpParameterException()
-    {
-        $this->expectException(\App\Exceptions\InvalidOtpParameterException::class);
-        $twofaccount = $this->twofaccountService->createFromParameters([
-            'account'   => self::ACCOUNT,
-            'otp_type'  => 'totp',
-            'algorithm' => 'notsupported',
-        ]);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_update_totp_returns_updated_model()
-    {
-        $twofaccount = $this->twofaccountService->update($this->customTotpTwofaccount, self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
-
-        $this->assertEquals('totp', $twofaccount->otp_type);
-        $this->assertEquals(null, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccount->digits);
-        $this->assertEquals(self::PERIOD_DEFAULT, $twofaccount->period);
-        $this->assertEquals(null, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccount->algorithm);
-        $this->assertEquals(null, $twofaccount->counter);
-        $this->assertEquals(null, $twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_update_hotp_returns_updated_model()
-    {
-        $twofaccount = $this->twofaccountService->update($this->customTotpTwofaccount, self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP);
-
-        $this->assertEquals('hotp', $twofaccount->otp_type);
-        $this->assertEquals(null, $twofaccount->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccount->account);
-        $this->assertEquals(self::SECRET, $twofaccount->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccount->digits);
-        $this->assertEquals(null, $twofaccount->period);
-        $this->assertEquals(self::COUNTER_DEFAULT, $twofaccount->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccount->algorithm);
-        $this->assertEquals(null, $twofaccount->counter);
-        $this->assertEquals(null, $twofaccount->icon);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_update_totp_persists_updated_model()
-    {
-        $twofaccount = $this->twofaccountService->update($this->customTotpTwofaccount, self::ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP);
-
-        $this->assertDatabaseHas('twofaccounts', [
-            'otp_type'      => 'totp',
-            'service'       => null,
-            'account'       => self::ACCOUNT,
-            'secret'        => self::SECRET,
-            'digits'        => self::DIGITS_DEFAULT,
-            'period'        => self::PERIOD_DEFAULT,
-            'counter'       => null,
-            'algorithm'     => self::ALGORITHM_DEFAULT,
-            'icon'          => null,
-        ]);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getOTP_for_totp_returns_the_same_password()
-    {
-        $otp_from_model = $this->twofaccountService->getOTP($this->customTotpTwofaccount);
-        $otp_from_id = $this->twofaccountService->getOTP($this->customTotpTwofaccount->id);
-        $otp_from_uri = $this->twofaccountService->getOTP(self::TOTP_FULL_CUSTOM_URI);
-
-        // Those assertions may fail if the 3 previous assignments are not done at the same exact timestamp
-        $this->assertEquals($otp_from_model, $otp_from_id);
-        $this->assertEquals($otp_from_model, $otp_from_uri);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getOTP_for_hotp_returns_the_same_password()
-    {
-        $otp_from_model = $this->twofaccountService->getOTP($this->customHotpTwofaccount);
-        $otp_from_id = $this->twofaccountService->getOTP($this->customHotpTwofaccount->id);
-        $otp_from_uri = $this->twofaccountService->getOTP(self::HOTP_FULL_CUSTOM_URI);
-
-        // Those assertions may fail if the 3 previous assignments are not done at the same exact timestamp
-        $this->assertEquals($otp_from_model, $otp_from_id);
-        $this->assertEquals($otp_from_model, $otp_from_uri);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getOTP_for_totp_with_invalid_secret_returns_InvalidSecretException()
-    {
-        $this->expectException(\App\Exceptions\InvalidSecretException::class);
-        $otp_from_uri = $this->twofaccountService->getOTP('otpauth://totp/'.self::ACCOUNT.'?secret=0');
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getOTP_for_totp_with_undecipherable_secret_returns_UndecipherableException()
-    {
-        $this->expectException(\App\Exceptions\UndecipherableException::class);
-        $otp_from_uri = $this->twofaccountService->getOTP([
-            'account'   => self::ACCOUNT,
-            'otp_type'  => 'totp',
-            'secret'    => __('errors.indecipherable'),
-        ]);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getURI_for_custom_totp_model_returns_uri()
-    {
-        $uri = $this->twofaccountService->getURI($this->customTotpTwofaccount);
-        
-        $this->assertStringContainsString('otpauth://totp/', $uri);
-        $this->assertStringContainsString(self::SERVICE, $uri);
-        $this->assertStringContainsString(self::ACCOUNT, $uri);
-        $this->assertStringContainsString('secret='.self::SECRET, $uri);
-        $this->assertStringContainsString('digits='.self::DIGITS_CUSTOM, $uri);
-        $this->assertStringContainsString('period='.self::PERIOD_CUSTOM, $uri);
-        $this->assertStringContainsString('algorithm='.self::ALGORITHM_CUSTOM, $uri);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getURI_for_custom_totp_model_id_returns_uri()
-    {
-        $uri = $this->twofaccountService->getURI($this->customTotpTwofaccount->id);
-        
-        $this->assertStringContainsString('otpauth://totp/', $uri);
-        $this->assertStringContainsString(self::SERVICE, $uri);
-        $this->assertStringContainsString(self::ACCOUNT, $uri);
-        $this->assertStringContainsString('secret='.self::SECRET, $uri);
-        $this->assertStringContainsString('digits='.self::DIGITS_CUSTOM, $uri);
-        $this->assertStringContainsString('period='.self::PERIOD_CUSTOM, $uri);
-        $this->assertStringContainsString('algorithm='.self::ALGORITHM_CUSTOM, $uri);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getURI_for_custom_hotp_model_returns_uri()
-    {
-        $uri = $this->twofaccountService->getURI($this->customHotpTwofaccount);
-        
-        $this->assertStringContainsString('otpauth://hotp/', $uri);
-        $this->assertStringContainsString(self::SERVICE, $uri);
-        $this->assertStringContainsString(self::ACCOUNT, $uri);
-        $this->assertStringContainsString('secret='.self::SECRET, $uri);
-        $this->assertStringContainsString('digits='.self::DIGITS_CUSTOM, $uri);
-        $this->assertStringContainsString('counter='.self::COUNTER_CUSTOM, $uri);
-        $this->assertStringContainsString('algorithm='.self::ALGORITHM_CUSTOM, $uri);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getURI_for_custom_hotp_model_id_returns_uri()
-    {
-        $uri = $this->twofaccountService->getURI($this->customHotpTwofaccount->id);
-        
-        $this->assertStringContainsString('otpauth://hotp/', $uri);
-        $this->assertStringContainsString(self::SERVICE, $uri);
-        $this->assertStringContainsString(self::ACCOUNT, $uri);
-        $this->assertStringContainsString('secret='.self::SECRET, $uri);
-        $this->assertStringContainsString('digits='.self::DIGITS_CUSTOM, $uri);
-        $this->assertStringContainsString('counter='.self::COUNTER_CUSTOM, $uri);
-        $this->assertStringContainsString('algorithm='.self::ALGORITHM_CUSTOM, $uri);
-    }
-
-
-    /**
-     * @test
-     */
-    public function test_getURI_for_totp_dto_returns_uri()
-    {
-        $dto = new \App\Services\Dto\TwoFAccountDto;
-
-        $dto->otp_type = 'totp';
-        $dto->account = self::ACCOUNT;
-        $dto->secret = self::SECRET;
-
-        $uri = $this->twofaccountService->getURI($dto);
-        
-        $this->assertStringContainsString('otpauth://totp/', $uri);
-        $this->assertStringContainsString(self::ACCOUNT, $uri);
-        $this->assertStringContainsString('secret='.self::SECRET, $uri);
-    }
-
-
     /**
      * @test
      */
@@ -767,27 +199,27 @@ class TwoFAccountServiceTest extends FeatureTestCase
      */
     public function test_convert_migration_from_gauth_returns_correct_accounts()
     {        
-        $twofaccounts = $this->twofaccountService->convertMigrationFromGA(self::GOOGLE_AUTH_MIGRATION_URI);
+        $twofaccounts = $this->twofaccountService->convertMigrationFromGA(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
 
         $this->assertCount(2, $twofaccounts);
 
         $this->assertEquals('totp', $twofaccounts->first()->otp_type);
-        $this->assertEquals(self::SERVICE, $twofaccounts->first()->service);
-        $this->assertEquals(self::ACCOUNT, $twofaccounts->first()->account);
-        $this->assertEquals(self::SECRET, $twofaccounts->first()->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccounts->first()->digits);
-        $this->assertEquals(self::PERIOD_DEFAULT, $twofaccounts->first()->period);
+        $this->assertEquals(OtpTestData::SERVICE, $twofaccounts->first()->service);
+        $this->assertEquals(OtpTestData::ACCOUNT, $twofaccounts->first()->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccounts->first()->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccounts->first()->digits);
+        $this->assertEquals(OtpTestData::PERIOD_DEFAULT, $twofaccounts->first()->period);
         $this->assertEquals(null, $twofaccounts->first()->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccounts->first()->algorithm);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccounts->first()->algorithm);
 
         $this->assertEquals('totp', $twofaccounts->last()->otp_type);
-        $this->assertEquals(self::SERVICE.'_bis', $twofaccounts->last()->service);
-        $this->assertEquals(self::ACCOUNT.'_bis', $twofaccounts->last()->account);
-        $this->assertEquals(self::SECRET, $twofaccounts->last()->secret);
-        $this->assertEquals(self::DIGITS_DEFAULT, $twofaccounts->last()->digits);
-        $this->assertEquals(self::PERIOD_DEFAULT, $twofaccounts->last()->period);
+        $this->assertEquals(OtpTestData::SERVICE.'_bis', $twofaccounts->last()->service);
+        $this->assertEquals(OtpTestData::ACCOUNT.'_bis', $twofaccounts->last()->account);
+        $this->assertEquals(OtpTestData::SECRET, $twofaccounts->last()->secret);
+        $this->assertEquals(OtpTestData::DIGITS_DEFAULT, $twofaccounts->last()->digits);
+        $this->assertEquals(OtpTestData::PERIOD_DEFAULT, $twofaccounts->last()->period);
         $this->assertEquals(null, $twofaccounts->last()->counter);
-        $this->assertEquals(self::ALGORITHM_DEFAULT, $twofaccounts->last()->algorithm);
+        $this->assertEquals(OtpTestData::ALGORITHM_DEFAULT, $twofaccounts->last()->algorithm);
     }
 
 
@@ -797,22 +229,25 @@ class TwoFAccountServiceTest extends FeatureTestCase
     public function test_convert_migration_from_gauth_returns_flagged_duplicates()
     {
         $parameters = [
-            'service'   => self::SERVICE,
-            'account'   => self::ACCOUNT,
-            'icon'      => self::ICON,
+            'service'   => OtpTestData::SERVICE,
+            'account'   => OtpTestData::ACCOUNT,
+            'icon'      => OtpTestData::ICON,
             'otp_type'  => 'totp',
-            'secret'    => self::SECRET,
-            'digits'    => self::DIGITS_DEFAULT,
-            'algorithm' => self::ALGORITHM_DEFAULT,
-            'period'    => self::PERIOD_DEFAULT,
+            'secret'    => OtpTestData::SECRET,
+            'digits'    => OtpTestData::DIGITS_DEFAULT,
+            'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+            'period'    => OtpTestData::PERIOD_DEFAULT,
         ];
-        $twofaccount = $this->twofaccountService->createFromParameters($parameters);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters($parameters)->save();
+
+        $parameters['service'] = OtpTestData::SERVICE.'_bis';
+        $parameters['account'] = OtpTestData::ACCOUNT.'_bis';
 
-        $parameters['service'] = self::SERVICE.'_bis';
-        $parameters['account'] = self::ACCOUNT.'_bis';
-        $twofaccount = $this->twofaccountService->createFromParameters($parameters);
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithOtpParameters($parameters)->save();
 
-        $twofaccounts = $this->twofaccountService->convertMigrationFromGA(self::GOOGLE_AUTH_MIGRATION_URI);
+        $twofaccounts = $this->twofaccountService->convertMigrationFromGA(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
 
         $this->assertEquals(-1, $twofaccounts->first()->id);
         $this->assertEquals(-1, $twofaccounts->last()->id);
@@ -825,7 +260,7 @@ class TwoFAccountServiceTest extends FeatureTestCase
     public function test_convert_invalid_migration_from_gauth_returns_InvalidGoogleAuthMigration_excpetion()
     {
         $this->expectException(\App\Exceptions\InvalidGoogleAuthMigration::class);
-        $twofaccounts = $this->twofaccountService->convertMigrationFromGA(self::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA);
+        $twofaccounts = $this->twofaccountService->convertMigrationFromGA(OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA);
     }
 
 }

+ 32 - 0
tests/Unit/Exceptions/HandlerTest.php

@@ -61,6 +61,12 @@ class HandlerTest extends TestCase
             [
                 '\App\Exceptions\InvalidGoogleAuthMigration'
             ],
+            [
+                '\App\Exceptions\UndecipherableException'
+            ],
+            [
+                '\App\Exceptions\UnsupportedOtpTypeException'
+            ],
         ];
     }
 
@@ -103,4 +109,30 @@ class HandlerTest extends TestCase
             ],
         ];
     }
+
+    /**
+    * @test
+    */
+    public function test_authenticationException_returns_proxyAuthRequired_json_response_with_proxy_guard()
+    {
+        $request = $this->createMock(Request::class);
+        $instance = new Handler($this->createMock(Container::class));
+        $class = new \ReflectionClass(Handler::class);
+
+        $method = $class->getMethod('render');
+        $method->setAccessible(true);
+
+        $mockException = $this->createMock(\Illuminate\Auth\AuthenticationException::class);
+        $mockException->method("guards")->willReturn(['reverse-proxy-guard']);
+
+        $response = $method->invokeArgs($instance, [$request, $mockException]);
+
+        $this->assertInstanceOf(JsonResponse::class, $response);
+
+        $response = \Illuminate\Testing\TestResponse::fromBaseResponse($response);
+        $response->assertStatus(407)
+            ->assertJsonStructure([
+                'message'
+            ]);
+    }
 }

+ 11 - 1
tests/Unit/TwoFAccountModelTest.php

@@ -22,7 +22,17 @@ class TwoFAccountModelTest extends ModelTestCase
     {
         $this->runConfigurationAssertions(
             new TwoFAccount(),
-            [],
+            [
+                'service',
+                'account',
+                'otp_type',
+                'digits',
+                'secret',
+                'algorithm',
+                'counter',
+                'period',
+                'icon'
+            ],
             [],
             ['*'],
             [],