Browse Source

Refactor and finalize the Import feature for G.Auth, Aegis & Plain Text

Bubka 2 years ago
parent
commit
e79ae0a3ed
30 changed files with 1134 additions and 230 deletions
  1. 0 55
      app/Api/v1/Controllers/ImportController.php
  2. 24 0
      app/Api/v1/Controllers/TwoFAccountController.php
  3. 2 1
      app/Api/v1/Requests/TwoFAccountImportRequest.php
  4. 0 16
      app/Contracts/MigrationService.php
  5. 14 0
      app/Exceptions/EncryptedMigrationException.php
  6. 12 2
      app/Exceptions/Handler.php
  7. 0 14
      app/Exceptions/InvalidGoogleAuthMigration.php
  8. 14 0
      app/Exceptions/InvalidMigrationDataException.php
  9. 14 0
      app/Exceptions/UnsupportedMigrationException.php
  10. 114 0
      app/Factories/MigratorFactory.php
  11. 16 0
      app/Factories/MigratorFactoryInterface.php
  12. 16 33
      app/Providers/MigrationServiceProvider.php
  13. 6 0
      app/Providers/TwoFAuthServiceProvider.php
  14. 71 5
      app/Services/Migrators/AegisMigrator.php
  15. 6 6
      app/Services/Migrators/GoogleAuthMigrator.php
  16. 6 28
      app/Services/Migrators/Migrator.php
  17. 46 5
      app/Services/Migrators/PlainTextMigrator.php
  18. 61 0
      app/Services/TwoFAccountService.php
  19. 0 1
      config/app.php
  20. 11 0
      resources/js/views/Start.vue
  21. 135 23
      resources/js/views/twofaccounts/Import.vue
  22. 3 1
      resources/lang/en/errors.php
  23. 7 2
      resources/lang/en/twofaccounts.php
  24. 1 3
      routes/api/v1.php
  25. 221 11
      tests/Api/v1/Controllers/TwoFAccountControllerTest.php
  26. 6 18
      tests/Api/v1/Requests/TwoFAccountImportRequestTest.php
  27. 178 0
      tests/Classes/LocalFileFactory.php
  28. 141 0
      tests/Classes/OtpTestData.php
  29. 5 5
      tests/Feature/Services/TwoFAccountServiceTest.php
  30. 4 1
      tests/Unit/Exceptions/HandlerTest.php

+ 0 - 55
app/Api/v1/Controllers/ImportController.php

@@ -1,55 +0,0 @@
-<?php
-
-namespace App\Api\v1\Controllers;
-
-use App\Api\v1\Requests\TwoFAccountImportRequest;
-use App\Api\v1\Resources\TwoFAccountCollection;
-use App\Contracts\MigrationService;
-use App\Http\Controllers\Controller;
-
-class ImportController extends Controller
-{
-    /**
-     * @var $migrator The Migration service
-     */
-    protected $migrator;
-
-
-    /**
-     * Constructor
-     */
-    public function __construct(MigrationService $migrationService)
-    {
-        $this->migrator = $migrationService;
-    }
-
-
-    /**
-     * Convert Google Auth data to a TwoFAccounts collection
-     *
-     * @param  \App\Api\v1\Requests\TwoFAccountImportRequest  $request
-     * @return \App\Api\v1\Resources\TwoFAccountCollection
-     */
-    public function googleAuth(TwoFAccountImportRequest $request)
-    { 
-        $request->merge(['withSecret' => true]);
-        $twofaccounts = $this->migrator->migrate($request->uri);
-
-        return new TwoFAccountCollection($twofaccounts);
-    }
-
-
-    /**
-     * Convert Aegis data to a TwoFAccounts collection
-     *
-     * @param  \App\Api\v1\Requests\TwoFAccountImportRequest  $request
-     * @return \App\Api\v1\Resources\TwoFAccountCollection
-     */
-    public function aegis(TwoFAccountImportRequest $request)
-    { 
-        $request->merge(['withSecret' => true]);
-        $twofaccounts = $this->migrator->migrate($request->uri);
-
-        return new TwoFAccountCollection($twofaccounts);
-    }
-}

+ 24 - 0
app/Api/v1/Controllers/TwoFAccountController.php

@@ -102,6 +102,30 @@ class TwoFAccountController extends Controller
     }
 
 
+    /**
+     * Convert a migration resource to a valid TwoFAccounts collection
+     *
+     * @param  \App\Api\v1\Requests\TwoFAccountImportRequest  $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function migrate(TwoFAccountImportRequest $request)
+    {
+        $request->merge(['withSecret' => true]);
+        $validated = $request->validated();
+
+        if (Arr::has($validated, 'file')) {
+            $migrationResource = $request->file('file');
+            
+            return $migrationResource instanceof \Illuminate\Http\UploadedFile
+                ? new TwoFAccountCollection(TwoFAccounts::migrate($migrationResource->get()))
+                : response()->json(['message' => __('errors.file_upload_failed')], 500);
+        }
+        else {
+            return new TwoFAccountCollection(TwoFAccounts::migrate($request->payload));
+        }
+    }
+
+
     /**
      * Save 2FA accounts order
      *

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

@@ -25,7 +25,8 @@ class TwoFAccountImportRequest extends FormRequest
     public function rules()
     {
         return [
-            'uri' => 'required|string|regex:/^otpauth-migration:\/\/offline\?data=/i',
+            'payload' => 'required_without:file|string',
+            'file' => 'required_without:payload|mimes:txt,json,csv',
         ];
     }
 }

+ 0 - 16
app/Contracts/MigrationService.php

@@ -1,16 +0,0 @@
-<?php
-
-namespace App\Contracts;
-
-use \Illuminate\Support\Collection;
-
-interface MigrationService
-{
-    /**
-     * Convert migration data to a 2FAccounts collection.
-     *
-     * @param  mixed  $migrationPayload
-     * @return \Illuminate\Support\Collection The converted accounts
-     */
-    public function migrate(mixed $migrationPayload) : Collection;
-}

+ 14 - 0
app/Exceptions/EncryptedMigrationException.php

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

+ 12 - 2
app/Exceptions/Handler.php

@@ -60,9 +60,19 @@ class Handler extends ExceptionHandler
                 'message' => $exception->getMessage()], 400);
         });
 
-        $this->renderable(function (InvalidGoogleAuthMigration $exception, $request) {
+        $this->renderable(function (InvalidMigrationDataException $exception, $request) {
             return response()->json([
-                'message' => __('errors.invalid_google_auth_migration')], 400);
+                'message' => __('errors.invalid_x_migration', ['appname' => $exception->getMessage()])], 400);
+        });
+
+        $this->renderable(function (UnsupportedMigrationException $exception, $request) {
+            return response()->json([
+                'message' => __('errors.unsupported_migration')], 400);
+        });
+
+        $this->renderable(function (EncryptedMigrationException $exception, $request) {
+            return response()->json([
+                'message' => __('errors.encrypted_migration')], 400);
         });
 
         $this->renderable(function (UndecipherableException $exception, $request) {

+ 0 - 14
app/Exceptions/InvalidGoogleAuthMigration.php

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

+ 14 - 0
app/Exceptions/InvalidMigrationDataException.php

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

+ 14 - 0
app/Exceptions/UnsupportedMigrationException.php

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

+ 114 - 0
app/Factories/MigratorFactory.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Factories;
+
+use App\Services\Migrators\GoogleAuthMigrator;
+use App\Services\Migrators\AegisMigrator;
+use App\Services\Migrators\Migrator;
+use App\Services\Migrators\PlainTextMigrator;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Validator;
+use App\Exceptions\UnsupportedMigrationException;
+use App\Exceptions\EncryptedMigrationException;
+
+class MigratorFactory implements MigratorFactoryInterface
+{
+    /**
+     * Infer the type of migrator needed from a payload and create the migrator
+     * 
+     * @param string $migrationPayload The migration payload used to infer the migrator type
+     * @return Migrator
+     */
+    public function create($migrationPayload) : Migrator
+    {
+        if ($this->isAegisJSON($migrationPayload)) {
+            return App::make(AegisMigrator::class);
+        }
+        else if ($this->isGoogleAuth($migrationPayload)) {
+            return App::make(GoogleAuthMigrator::class);
+        }
+        else if ($this->isPlainText($migrationPayload)) {
+            return App::make(PlainTextMigrator::class);
+        }
+        else throw new UnsupportedMigrationException();
+
+    }
+
+
+    /**
+     * 
+     */
+    private function isGoogleAuth($migrationPayload) : bool
+    {
+        // - Google Auth migration URI : a string starting with otpauth-migration://offline?data= on a single line
+
+        $lines = preg_split('~\R~', $migrationPayload, -1 , PREG_SPLIT_NO_EMPTY);
+
+        if (!$lines || count($lines) != 1)
+            return false;
+
+        return preg_match('/^otpauth-migration:\/\/offline\?data=.+$/', $lines[0]) == 1;
+    }
+
+
+    /**
+     * 
+     */
+    private function isPlainText($migrationPayload) : bool
+    {
+        // - Plain text : one or more otpauth URIs (otpauth://[t|h]otp/...), one per line
+
+        return Validator::make(
+            preg_split('~\R~', $migrationPayload, -1 , PREG_SPLIT_NO_EMPTY),
+            [
+                '*' => 'regex:/^otpauth:\/\/[h,t]otp\//i',
+            ]
+        )->passes();
+    }
+
+
+    /**
+     * 
+     */
+    private function isAegisJSON($migrationPayload) : mixed
+    {
+        // - Aegis JSON : is a JSON object with the key db.entries full of objects like
+        //      {
+        //          "type": "totp",
+        //          "uuid": "5be1c189-240d-5fe1-930b-a78xb669zd86",
+        //          "name": "John DOE",
+        //          "issuer": "Facebook",
+        //          "note": "",
+        //          "icon": null,
+        //          "info": {
+        //              "secret": "A4GRFTVVRBGY7UIW",
+        //              "algo": "SHA1",
+        //              "digits": 6,
+        //              "period": 30
+        //          }
+        //      }
+
+        $json = json_decode($migrationPayload, true);
+
+        if (Arr::has($json, 'db')) {
+            if (is_string($json['db']) && is_array(Arr::get($json, 'header.slots'))) {
+                throw new EncryptedMigrationException();
+            }
+            else {
+                return count(Validator::validate(
+                    $json,
+                    [
+                        'db.entries.*.type' => 'required',
+                        'db.entries.*.name' => 'required',
+                        'db.entries.*.issuer' => 'required',
+                        'db.entries.*.info' => 'required'
+                    ]
+                ));
+            }
+        }
+
+        return false;
+    }
+
+}

+ 16 - 0
app/Factories/MigratorFactoryInterface.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Factories;
+
+use App\Services\Migrators\Migrator;
+
+interface MigratorFactoryInterface
+{
+    /**
+     * Infer the type of migrator needed from a payload and create the migrator
+     * 
+     * @param string $migrationPayload The migration payload used to infer the migrator type
+     * @return Migrator
+     */
+    public function create(string $migrationPayload) : Migrator;
+}

+ 16 - 33
app/Providers/MigrationServiceProvider.php

@@ -2,15 +2,14 @@
 
 namespace App\Providers;
 
-use App\Api\v1\Controllers\ImportController;
-use App\Contracts\MigrationService;
+use App\Factories\MigratorFactory;
+use App\Factories\MigratorFactoryInterface;
 use App\Services\Migrators\GoogleAuthMigrator;
 use App\Services\Migrators\AegisMigrator;
 use App\Services\Migrators\PlainTextMigrator;
 use Illuminate\Support\ServiceProvider;
-use Illuminate\Contracts\Support\DeferrableProvider;
 
-class MigrationServiceProvider extends ServiceProvider implements DeferrableProvider
+class MigrationServiceProvider extends ServiceProvider
 {
     /**
      * Register services.
@@ -18,21 +17,20 @@ class MigrationServiceProvider extends ServiceProvider implements DeferrableProv
      * @return void
      */
     public function register()
-    {            
-        $this->app->when(ImportController::class)
-            ->needs(MigrationService::class)
-            ->give(function () {
-                switch (request()->route()->getName()) {
-                    case 'import.googleAuth':
-                        return $this->app->get(GoogleAuthMigrator::class);
+    {
+        $this->app->bind(MigratorFactoryInterface::class, MigratorFactory::class);
+        
+        $this->app->singleton(GoogleAuthMigrator::class, function () {
+            return new GoogleAuthMigrator();
+        });
+
+        $this->app->singleton(AegisMigrator::class, function () {
+            return new AegisMigrator();
+        });
 
-                    case 'import.aegis':
-                        return $this->app->get(AegisMigrator::class);
-                    
-                    default:
-                        return $this->app->get(PlainTextMigrator::class);
-                }
-            });
+        $this->app->singleton(PlainTextMigrator::class, function () {
+            return new PlainTextMigrator();
+        });
     }
 
     /**
@@ -44,19 +42,4 @@ class MigrationServiceProvider extends ServiceProvider implements DeferrableProv
     {
         //
     }
-
-
-    /**
-     * Get the services provided by the provider.
-     *
-     * @return array
-     */
-    public function provides()
-    {
-        return [
-            GoogleAuthMigrator::class,
-            AegisMigrator::class,
-            PlainTextMigrator::class,
-        ];
-    }
 }

+ 6 - 0
app/Providers/TwoFAuthServiceProvider.php

@@ -5,6 +5,8 @@ namespace App\Providers;
 use App\Services\LogoService;
 use App\Services\SettingService;
 use App\Services\ReleaseRadarService;
+use App\Services\TwoFAccountService;
+use App\Factories\MigratorFactoryInterface;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Contracts\Support\DeferrableProvider;
 
@@ -17,6 +19,10 @@ class TwoFAuthServiceProvider extends ServiceProvider implements DeferrableProvi
      */
     public function register()
     {
+        $this->app->singleton(TwoFAccountService::class, function ($app) {
+            return new TwoFAccountService($app->make(MigratorFactoryInterface::class));
+        });
+
         $this->app->singleton(SettingService::class, function () {
             return new SettingService();
         });

+ 71 - 5
app/Services/Migrators/AegisMigrator.php

@@ -2,19 +2,85 @@
 
 namespace App\Services\Migrators;
 
-use App\Contracts\MigrationService;
-use \Illuminate\Support\Collection;
+use App\Services\Migrators\Migrator;
+use Illuminate\Support\Collection;
+use App\Models\TwoFAccount;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Arr;
+use App\Exceptions\InvalidMigrationDataException;
 
-class AegisMigrator implements MigrationService
+class AegisMigrator extends Migrator
 {
+    // Typical JSON structure of an Aegis export
+    //
+    // {
+    //     "type": "totp",
+    //     "uuid": "5be1c189-240d-5fe1-930b-a78xb669zd86",
+    //     "name": "John DOE",
+    //     "issuer": "Facebook",
+    //     "note": "",
+    //     "icon": "PHN2ZyB4bWxucz0ia[...]0KPC9zdmc+DQo=",
+    //     "icon_mime": "image\/svg+xml",
+    //     "info": {
+    //         "secret": "A4GRFTVVRBGY7UIW",
+    //         "algo": "SHA1",
+    //         "digits": 6,
+    //         "period": 30,
+    //         "counter": 30
+    //     }
+    // }
+
+
     /**
-     * Convert migration data to a 2FAccounts collection.
+     * Convert migration data to a TwoFAccounts collection.
      *
      * @param  mixed  $migrationPayload
      * @return \Illuminate\Support\Collection The converted accounts
      */
     public function migrate(mixed $migrationPayload) : Collection
     {
-        return Collect(['collected from aegisMigrator']);
+        $json = json_decode(htmlspecialchars_decode($migrationPayload), true);
+
+        if (is_null($json) || Arr::has($json, 'db.entries') == false) {
+            Log::error('Aegis JSON migration data cannot be read');
+            throw new InvalidMigrationDataException('Aegis');
+        }
+
+        foreach ($json['db']['entries'] as $key => $otp_parameters) {
+            // Storage::put('file.jpg', $contents);
+            $parameters = array();
+            $parameters['otp_type']     = $otp_parameters['type'] == 'steam' ? TwoFAccount::STEAM_TOTP : $otp_parameters['type'];
+            $parameters['service']      = $otp_parameters['issuer'];
+            $parameters['account']      = $otp_parameters['name'];
+            $parameters['secret']       = $otp_parameters['info']['secret'];
+            $parameters['algorithm']    = $otp_parameters['info']['algo'];
+            $parameters['digits']       = $otp_parameters['info']['digits'];
+            $parameters['counter']      = $otp_parameters['info']['counter'] ?? null;
+            $parameters['period']       = $otp_parameters['info']['period'] ?? null;
+
+            try {
+               $twofaccounts[$key] = new TwoFAccount;
+               $twofaccounts[$key]->fillWithOtpParameters($parameters);
+            }
+            catch (\Exception $exception) {
+
+                Log::error(sprintf('Cannot instanciate a TwoFAccount object with OTP parameters from imported item #%s', $key));
+                Log::error($exception->getMessage());
+
+                // 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  = $otp_parameters['type'];
+                // Only basic fields are filled to limit the risk of another exception.
+                $fakeAccount->account   = $otp_parameters['name'];
+                $fakeAccount->service   = $otp_parameters['issuer'];
+                // The secret field is used to pass the error, not very clean but will do the job for now.
+                $fakeAccount->secret    = $exception->getMessage();
+
+                $twofaccounts[$key] = $fakeAccount;
+            }
+        }
+
+        return collect($twofaccounts);
     }
 }

+ 6 - 6
app/Services/Migrators/GoogleAuthMigrator.php

@@ -4,19 +4,19 @@ namespace App\Services\Migrators;
 
 use Exception;
 use App\Models\TwoFAccount;
-use App\Contracts\MigrationService;
-use \Illuminate\Support\Collection;
+use App\Services\Migrators\Migrator;
+use Illuminate\Support\Collection;
 use ParagonIE\ConstantTime\Base32;
 use App\Protobuf\GAuthValueMapping;
 use App\Protobuf\GoogleAuth\Payload;
 use App\Protobuf\GoogleAuth\Payload\OtpType;
 use App\Protobuf\GoogleAuth\Payload\Algorithm;
 use App\Protobuf\GoogleAuth\Payload\DigitCount;
-use App\Exceptions\InvalidGoogleAuthMigration;
+use App\Exceptions\InvalidMigrationDataException;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 
-class GoogleAuthMigrator extends Migrator implements MigrationService
+class GoogleAuthMigrator extends Migrator
 {
 
     /**
@@ -38,7 +38,7 @@ class GoogleAuthMigrator extends Migrator implements MigrationService
             Log::error("Protobuf failed to get OTP parameters from provided migration URI");
             Log::error($ex->getMessage());
 
-            throw new InvalidGoogleAuthMigration();
+            throw new InvalidMigrationDataException('Google Authenticator');
         }
 
         $twofaccounts = array();
@@ -78,6 +78,6 @@ class GoogleAuthMigrator extends Migrator implements MigrationService
             }
         }
 
-        return self::markAsDuplicate(collect($twofaccounts));
+        return collect($twofaccounts);
     }
 }

+ 6 - 28
app/Services/Migrators/Migrator.php

@@ -2,38 +2,16 @@
 
 namespace App\Services\Migrators;
 
-use App\Models\TwoFAccount;
-use \Illuminate\Support\Collection;
+use Illuminate\Support\Collection;
 
 abstract class Migrator
 {
-
     /**
-     * Return the given collection with items marked as Duplicates (using id=-1) if a similar record exists in database
-     * 
-     * @param \Illuminate\Support\Collection $twofaccounts
-     * @return \Illuminate\Support\Collection
+     * Convert migration data to a 2FAccounts collection.
+     *
+     * @param  mixed  $migrationPayload
+     * @return \Illuminate\Support\Collection The converted accounts
      */
-    protected static function markAsDuplicate(Collection $twofaccounts) : Collection
-    {
-        $storage = TwoFAccount::all();
-
-        $twofaccounts = $twofaccounts->map(function ($twofaccount, $key) use ($storage) {
-            if ($storage->contains(function ($value, $key) use ($twofaccount) {
-                return $value->secret == $twofaccount->secret
-                    && $value->service == $twofaccount->service
-                    && $value->account == $twofaccount->account
-                    && $value->otp_type == $twofaccount->otp_type
-                    && $value->digits == $twofaccount->digits
-                    && $value->algorithm == $twofaccount->algorithm;
-            })) {
-                $twofaccount->id = -1;
-            }
-
-            return $twofaccount;
-        });
-
-        return $twofaccounts;
-    }
+    abstract protected function migrate(mixed $migrationPayload) : Collection;
 
 }

+ 46 - 5
app/Services/Migrators/PlainTextMigrator.php

@@ -2,19 +2,60 @@
 
 namespace App\Services\Migrators;
 
-use App\Contracts\MigrationService;
-use \Illuminate\Support\Collection;
+use App\Services\Migrators\Migrator;
+use Illuminate\Support\Collection;
+use App\Models\TwoFAccount;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Str;
+use App\Exceptions\InvalidMigrationDataException;
 
-class PlainTextMigrator implements MigrationService
+class PlainTextMigrator extends Migrator
 {
+
     /**
-     * Convert migration data to a 2FAccounts collection.
+     * Convert migration data to a TwoFAccounts collection.
      *
      * @param  mixed  $migrationPayload
      * @return \Illuminate\Support\Collection The converted accounts
      */
     public function migrate(mixed $migrationPayload) : Collection
     {
-        return Collect(['collected from plainTextMigrator']);
+        $otpauthURIs = preg_split('~\R~', $migrationPayload);
+        $otpauthURIs = Arr::where($otpauthURIs, function ($value, $key) {
+            return Str::startsWith($value, ['otpauth://totp/', 'otpauth://hotp/']);
+        });
+
+        if (count($otpauthURIs) < 1) {
+            Log::error('No valid OtpAuth URI found in the migration');
+            throw new InvalidMigrationDataException('migration');
+        }
+
+        foreach ($otpauthURIs as $key => $uri) {
+
+            try {
+               $twofaccounts[$key] = new TwoFAccount;
+               $twofaccounts[$key]->fillWithURI($uri);
+            }
+            catch (\Exception $exception) {
+
+                Log::error(sprintf('Cannot instanciate a TwoFAccount object with OTP parameters from imported item #%s', $key));
+                Log::error($exception->getMessage());
+
+                // 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  = substr($uri, 10, 4);
+                // Only basic fields are filled to limit the risk of another exception.
+                $fakeAccount->account   = '## invalid OTP data ##';
+                $fakeAccount->service   = filter_input(INPUT_GET, 'issuer', FILTER_SANITIZE_ENCODED) ?? '-';
+                // The secret field is used to pass the error, not very clean but will do the job for now.
+                $fakeAccount->secret    = $exception->getMessage();
+
+                $twofaccounts[$key] = $fakeAccount;
+            }
+        }
+
+        return collect($twofaccounts);
     }
 }

+ 61 - 0
app/Services/TwoFAccountService.php

@@ -3,10 +3,26 @@
 namespace App\Services;
 
 use App\Models\TwoFAccount;
+use App\Factories\MigratorFactoryInterface;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Collection;
 
 class TwoFAccountService
 {
+    /**
+     * @var $migrator The Migration service
+     */
+    protected $migratorFactory;
+
+
+    /**
+     * Constructor
+     */
+    public function __construct(MigratorFactoryInterface $migratorFactory)
+    {
+        $this->migratorFactory = $migratorFactory;
+    }
+
 
     /**
      * Withdraw one or more twofaccounts from their group
@@ -31,6 +47,22 @@ class TwoFAccountService
     }
 
 
+    /**
+     * Convert a migration payload to a set of TwoFAccount objects
+     * 
+     * @param string $migrationUri migration uri provided by Google Authenticator export feature
+     * 
+     * @return \Illuminate\Support\Collection The converted accounts
+     */
+    public function migrate($migrationPayload) : Collection
+    {
+        $migrator = $this->migratorFactory->create($migrationPayload);
+        $twofaccounts = $migrator->migrate($migrationPayload);
+
+        return self::markAsDuplicate($twofaccounts);
+    }
+
+
     /**
      * Delete one or more twofaccounts
      * 
@@ -50,6 +82,35 @@ class TwoFAccountService
     }
 
 
+    /**
+     * Return the given collection with items marked as Duplicates (using id=-1) if a similar record exists in database
+     * 
+     * @param \Illuminate\Support\Collection $twofaccounts
+     * @return \Illuminate\Support\Collection
+     */
+    private static function markAsDuplicate(Collection $twofaccounts) : Collection
+    {
+        $storage = TwoFAccount::all();
+
+        $twofaccounts = $twofaccounts->map(function ($twofaccount, $key) use ($storage) {
+            if ($storage->contains(function ($value, $key) use ($twofaccount) {
+                return $value->secret == $twofaccount->secret
+                    && $value->service == $twofaccount->service
+                    && $value->account == $twofaccount->account
+                    && $value->otp_type == $twofaccount->otp_type
+                    && $value->digits == $twofaccount->digits
+                    && $value->algorithm == $twofaccount->algorithm;
+            })) {
+                $twofaccount->id = -1;
+            }
+
+            return $twofaccount;
+        });
+
+        return $twofaccounts;
+    }
+
+
     /**
      * Explode a comma separated list of IDs to an array of IDs
      * 

+ 0 - 1
config/app.php

@@ -176,7 +176,6 @@ return [
         App\Providers\RouteServiceProvider::class,
         App\Providers\TwoFAuthServiceProvider::class,
         App\Providers\MigrationServiceProvider::class,
-
     ],
 
     /*

+ 11 - 0
resources/js/views/Start.vue

@@ -37,6 +37,12 @@
                         {{ $t('twofaccounts.forms.use_advanced_form') }}
                     </router-link>
                 </div>
+                <!-- link to import view -->
+                <div v-if="showImportButton" class="block has-text-link">
+                    <router-link class="button is-link is-outlined is-rounded" :to="{ name: 'importAccounts' }" >
+                        {{ $t('twofaccounts.import.import') }}
+                    </router-link>
+                </div>
             </div>
         </div>
         <!-- Footer -->
@@ -74,6 +80,7 @@
             return {
                 accountCount: null,
                 form: new Form(),
+                alternativeMethod: null,
             }
         },
 
@@ -82,6 +89,10 @@
                 type: Boolean,
                 default: true
             },
+            showImportButton: {
+                type: Boolean,
+                default: true
+            },
             returnToView: {
                 type: String,
                 default: 'accounts'

+ 135 - 23
resources/js/views/twofaccounts/Import.vue

@@ -5,17 +5,58 @@
                 <h1 class="title has-text-grey-dark">
                     {{ $t('twofaccounts.import.import') }}
                 </h1>
-                <div class="is-size-7-mobile" v-html="$t('twofaccounts.import.import_legend')">
-                </div>
-                <div class="mt-3 mb-6">
-                    <router-link class="is-link" :to="{ name: 'start', params: {showAdvancedFormButton: false, returnToView: 'importAccounts'} }">
-                        <span class="tag is-black">
-                            <font-awesome-icon :icon="['fas', 'qrcode']" size="lg" class="mr-1" />{{ $t('twofaccounts.import.use_the_gauth_qr_code') }}
-                        </span>
-                    </router-link>
-                </div>
                 <div>
-                    <div v-if="exportedAccounts.length > 0">
+                    <div v-if="exportedAccounts.length == 0">
+                        <div class="block is-size-7-mobile" v-html="$t('twofaccounts.import.import_legend')"></div>
+                        <!-- scan button that launch camera stream -->
+                        <div class="block">
+                            <button tabindex="0" class="button is-link is-rounded" @click="capture()">
+                                {{ $t('twofaccounts.forms.scan_qrcode') }}
+                            </button>
+                        </div>
+                        <!-- upload a qr code (with basic file field and backend decoding) -->
+                        <div class="block">
+                            <label role="button" tabindex="0" class="button is-link is-rounded is-outlined" ref="qrcodeInputLabel"  @keyup.enter="$refs.qrcodeInputLabel.click()">
+                                <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="submitQrCode" ref="qrcodeInput">
+                                {{ $t('twofaccounts.forms.upload_qrcode') }}
+                            </label>
+                            <field-error :form="form" field="qrcode" />
+                            <p class="help">{{ $t('twofaccounts.import.supported_formats_for_qrcode_upload') }}</p>
+                        </div>
+                        <!-- upload a file -->
+                        <div class="block">
+                            <label role="button" tabindex="0" class="button is-link is-rounded is-outlined" ref="fileInputLabel" @keyup.enter="$refs.fileInputLabel.click()">
+                                <input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="text/plain,application/json,text/csv" v-on:change="submitFile" ref="fileInput">
+                                {{ $t('twofaccounts.import.upload_a_file') }}
+                            </label>
+                            <field-error :form="uploadForm" field="file" />
+                            <p class="help">{{ $t('twofaccounts.import.supported_formats_for_file_upload') }}</p>
+                        </div>
+
+                        <!-- Supported migration resources -->
+                        <h5 class="title is-5 mb-3">{{ $t('twofaccounts.import.supported_migration_formats') }}</h5>
+                        <div class="field is-grouped is-grouped-multiline pt-0">
+                            <div class="control">
+                                <div class="tags has-addons">
+                                <span class="tag is-dark">Google Auth</span>
+                                <span class="tag is-black">{{ $t('twofaccounts.import.qr_code') }}</span>
+                                </div>
+                            </div>
+                            <div class="control">
+                                <div class="tags has-addons">
+                                <span class="tag is-dark">Aegis Auth</span>
+                                <span class="tag is-black">JSON</span>
+                                </div>
+                            </div>
+                            <div class="control">
+                                <div class="tags has-addons">
+                                <span class="tag is-dark">Aegis Auth</span>
+                                <span class="tag is-black">{{ $t('twofaccounts.import.plain_text') }}</span>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                    <div v-else>
                         <div v-for="(account, index) in exportedAccounts" :key="account.name" class="group-item has-text-light is-size-5 is-size-6-mobile">
                             <div class="is-flex is-justify-content-space-between">
                                 <!-- Account name -->
@@ -108,7 +149,7 @@
     export default {
         data() {
             return {
-                migrationUri: '',
+                migrationPayload: '',
                 exportedAccounts: [],
                 isFetching: false,
                 form: new Form({
@@ -125,6 +166,7 @@
                     image: '',
                     qrcode: null,
                 }),
+                uploadForm: new Form(),
                 ShowTwofaccountInModal : false,
             }
         },
@@ -143,18 +185,7 @@
             // A migration URI is provided as route parameter, we extract the accounts from the URI and
             // list them in the view
             if( this.$route.params.migrationUri ) {
-                this.migrationUri = this.$route.params.migrationUri
-                this.isFetching = true
-                
-                await this.axios.post('/api/v1/import/google-auth', { uri: this.migrationUri }).then(response => {
-                    response.data.forEach((data) => {
-                        data.imported = -1;
-                        this.exportedAccounts.push(data)
-                    })
-                });
-
-                this.$notify({type: 'is-success', text: this.$t('twofaccounts.import.x_valid_accounts_found', { count: this.importableCount }) })
-                this.isFetching = false
+                this.migrate(this.$route.params.migrationUri)
             }
 
             this.$on('modalClose', function() {
@@ -169,6 +200,28 @@
 
         methods: {
 
+            /**
+             * Post the migration payload
+             */
+            async migrate(migrationPayload) {
+                this.migrationPayload = migrationPayload
+                this.isFetching = true
+
+                await this.axios.post('/api/v1/twofaccounts/migration', {payload: this.migrationPayload}, {returnError: true}).then(response => {
+                    response.data.forEach((data) => {
+                        data.imported = -1;
+                        this.exportedAccounts.push(data)
+                    })
+
+                    this.notifyValidAccountFound()
+                })
+                .catch(error => {
+                    this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
+                });
+
+                this.isFetching = false
+            },
+
             /**
              * Remove all duplicates from the accounts list
              */
@@ -258,6 +311,65 @@
                 this.form.counter = twofaccount.otp_type === 'hotp' ? twofaccount.counter : null
                 this.form.period = twofaccount.otp_type === 'totp' ? twofaccount.period : null
             },
+
+            /**
+             * Upload the submitted file to the backend for parsing
+             */
+            submitFile() {
+                this.isFetching = true
+
+                let filedata = new FormData();
+                filedata.append('file', this.$refs.fileInput.files[0]);
+
+                this.uploadForm.upload('/api/v1/twofaccounts/migration', filedata, {returnError: true}).then(response => {
+                    response.data.forEach((data) => {
+                        data.imported = -1;
+                        this.exportedAccounts.push(data)
+                    })
+
+                    this.notifyValidAccountFound()
+                })
+                .catch(error => {
+                    if( error.response.status !== 422 ) {
+                        this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
+                    }
+                });
+                
+                this.isFetching = false
+            },
+
+            /**
+             * Upload the submitted QR code file to the backend for decoding
+             */
+            submitQrCode() {
+
+                let imgdata = new FormData();
+                imgdata.append('qrcode', this.$refs.qrcodeInput.files[0]);
+
+                this.form.upload('/api/v1/qrcode/decode', imgdata, {returnError: true}).then(response => {
+                    this.migrate(response.data.data)
+                })
+                .catch(error => {
+                    if( error.response.status !== 422 ) {
+                        this.$notify({type: 'is-danger', text: this.$t(error.response.data.message) })
+                    }
+                });
+            },
+
+
+            /**
+             * Push user to the dedicated capture view for live scan
+             */
+            capture() {
+                this.$router.push({ name: 'capture' });
+            },
+
+            /**
+             * Notify that valid account(s) have been found for import
+             */
+            notifyValidAccountFound() {
+                this.$notify({type: 'is-success', text: this.$t('twofaccounts.import.x_valid_accounts_found', { count: this.importableCount }) })
+            }
         }
     }
 

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

@@ -39,8 +39,10 @@ return [
     'user_deletion_failed' => 'User account deletion failed, no data have been deleted',
     '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',
+    'invalid_x_migration' => 'Invalid or unreadable :appname data',
+    'unsupported_migration' => 'Data do not match any supported format',
     'unsupported_otp_type' => 'Unsupported OTP type',
+    'encrypted_migration' => 'Unreadable, the data seem encrypted',
     'no_logo_found_for_x' => 'No logo available for {service}',
     'file_upload_failed' => 'File upload failed'
 ];

+ 7 - 2
resources/lang/en/twofaccounts.php

@@ -134,8 +134,13 @@ return [
     'import' => [
         'import' => 'Import',
         'to_import' => 'Import',
-        'import_legend' => 'Import your Google Authenticator accounts.',
-        'use_the_gauth_qr_code' => 'Load a G-Auth QR code',
+        'import_legend' => '2FAuth can import data from various 2FA apps.<br />Use the Export feature of these apps to get a migration resource (a QR code or a file) and submit it using your preferred method below.',
+        'upload_a_file' => 'Upload a file',
+        'supported_formats_for_qrcode_upload' => 'Accepted: jpg, jpeg, png, bmp, gif, svg, or webp',
+        'supported_formats_for_file_upload' => 'Accepted: Plain text, json, csv',
+        'supported_migration_formats' => 'Supported migration formats',
+        'qr_code' => 'QR code',
+        'plain_text' => 'Plain text',
         'issuer' => 'Issuer',
         'imported' => 'Imported',
         'failure' => 'Failure',

+ 1 - 3
routes/api/v1.php

@@ -29,6 +29,7 @@ Route::group(['middleware' => 'auth:api-guard'], function () {
     Route::delete('twofaccounts', 'TwoFAccountController@batchDestroy')->name('twofaccounts.batchDestroy');
     Route::patch('twofaccounts/withdraw', 'TwoFAccountController@withdraw')->name('twofaccounts.withdraw');
     Route::post('twofaccounts/reorder', 'TwoFAccountController@reorder')->name('twofaccounts.reorder');
+    Route::post('twofaccounts/migration', 'TwoFAccountController@migrate')->name('twofaccounts.migrate');
     Route::post('twofaccounts/preview', 'TwoFAccountController@preview')->name('twofaccounts.preview');
     Route::get('twofaccounts/{twofaccount}/qrcode', 'QrCodeController@show')->name('twofaccounts.show.qrcode');
     Route::get('twofaccounts/count', 'TwoFAccountController@count')->name('twofaccounts.count');
@@ -36,9 +37,6 @@ Route::group(['middleware' => 'auth:api-guard'], function () {
     Route::post('twofaccounts/otp', 'TwoFAccountController@otp')->name('twofaccounts.otp');
     Route::apiResource('twofaccounts', 'TwoFAccountController');
 
-    Route::post('import/google-auth', 'ImportController@googleAuth')->name('import.googleAuth');
-    Route::post('import/aegis', 'ImportController@aegis')->name('import.aegis');
-
     Route::get('groups/{group}/twofaccounts', 'GroupController@accounts')->name('groups.show.twofaccounts');
     Route::post('groups/{group}/assign', 'GroupController@assignAccounts')->name('groups.assign.twofaccounts');
     Route::apiResource('groups', 'GroupController');

+ 221 - 11
tests/Api/v1/Controllers/TwoFAccountControllerTest.php

@@ -10,6 +10,7 @@ use Tests\Classes\OtpTestData;
 use App\Models\TwoFAccount;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
+use Tests\Classes\LocalFile;
 
 
 /**
@@ -457,11 +458,11 @@ class TwoFAccountControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function test_import_valid_gauth_data_returns_success_with_consistent_resources()
+    public function test_import_valid_gauth_payload_returns_success_with_consistent_resources()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts/import', [
-                'uri' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
+            ->json('POST', '/api/v1/twofaccounts/migration', [
+                'payload' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertOk()
             ->assertJsonCount(2, $key = null)
@@ -493,10 +494,10 @@ class TwoFAccountControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function test_import_with_invalid_uri_returns_validation_error()
+    public function test_import_with_invalid_gauth_payload_returns_validation_error()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts', [
+            ->json('POST', '/api/v1/twofaccounts/migration', [
                 'uri' => OtpTestData::INVALID_GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertStatus(422);
@@ -506,7 +507,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function test_import_gauth_data_with_duplicates_returns_negative_ids()
+    public function test_import_gauth_payload_with_duplicates_returns_negative_ids()
     {
         $twofaccount = TwoFAccount::factory()->create([
             'otp_type' => 'totp',
@@ -521,8 +522,8 @@ class TwoFAccountControllerTest extends FeatureTestCase
         ]);
 
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts/import', [
-                'uri' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
+            ->json('POST', '/api/v1/twofaccounts/migration', [
+                'payload' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertOk()
             ->assertJsonFragment([
@@ -536,11 +537,11 @@ class TwoFAccountControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function test_import_invalid_gauth_data_returns_bad_request()
+    public function test_import_invalid_gauth_payload_returns_bad_request()
     {
         $response = $this->actingAs($this->user, 'api-guard')
-            ->json('POST', '/api/v1/twofaccounts/import', [
-                'uri' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
+            ->json('POST', '/api/v1/twofaccounts/migration', [
+                'payload' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
             ])
             ->assertStatus(400)
             ->assertJsonStructure([
@@ -549,6 +550,215 @@ class TwoFAccountControllerTest extends FeatureTestCase
     }
 
 
+    /**
+     * @test
+     */
+    public function test_import_valid_aegis_json_file_returns_success()
+    {
+        $file = LocalFile::fake()->validAegisJsonFile();
+
+        $response = $this->withHeaders(['Content-Type' => 'multipart/form-data'])
+            ->actingAs($this->user, 'api-guard')
+            ->json('POST', '/api/v1/twofaccounts/migration', [
+                'file' => $file,
+            ])
+            ->assertOk()
+            ->assertJsonCount(5, $key = null)
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::SERVICE . '_totp',
+                'account'   => OtpTestData::ACCOUNT . '_totp',
+                'otp_type'  => 'totp',
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_DEFAULT,
+                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+                'period'    => OtpTestData::PERIOD_DEFAULT,
+                'counter'   => null
+            ])
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::SERVICE . '_totp_custom',
+                'account'   => OtpTestData::ACCOUNT . '_totp_custom',
+                'otp_type'  => 'totp',
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_CUSTOM,
+                'algorithm' => OtpTestData::ALGORITHM_CUSTOM,
+                'period'    => OtpTestData::PERIOD_CUSTOM,
+                'counter'   => null
+            ])
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::SERVICE . '_hotp',
+                'account'   => OtpTestData::ACCOUNT . '_hotp',
+                'otp_type'  => 'hotp',
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_DEFAULT,
+                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+                'period'    => null,
+                'counter'   => OtpTestData::COUNTER_DEFAULT
+            ])
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::SERVICE . '_hotp_custom',
+                'account'   => OtpTestData::ACCOUNT . '_hotp_custom',
+                'otp_type'  => 'totp',
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_CUSTOM,
+                'algorithm' => OtpTestData::ALGORITHM_CUSTOM,
+                'period'    => null,
+                'counter'   => OtpTestData::COUNTER_CUSTOM,
+            ])
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::STEAM,
+                'account'   => OtpTestData::ACCOUNT . '_steam',
+                'otp_type'  => 'steamtotp',
+                'secret'    => OtpTestData::STEAM_SECRET,
+                'digits'    => OtpTestData::DIGITS_STEAM,
+                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+                'period'    => OtpTestData::PERIOD_DEFAULT,
+                'counter'   => null
+            ]);
+    }
+
+
+    /**
+     * @test
+     * 
+     * @dataProvider invalidAegisJsonFileProvider
+     */
+    public function test_import_invalid_aegis_json_file_returns_bad_request($file)
+    {
+        $response = $this->withHeaders(['Content-Type' => 'multipart/form-data'])
+            ->actingAs($this->user, 'api-guard')
+            ->json('POST', '/api/v1/twofaccounts/migration', [
+                'file' => $file,
+            ])
+            ->assertStatus(400);
+    }
+
+
+    /**
+     * Provide invalid Aegis JSON files for import tests
+     */
+    public function invalidAegisJsonFileProvider()
+    {
+        return [
+            'validPlainTextFile' => [
+                LocalFile::fake()->encryptedAegisJsonFile()
+            ],
+            'validPlainTextFileWithNewLines' => [
+                LocalFile::fake()->invalidAegisJsonFile()
+            ],
+        ];
+    }
+
+
+    /**
+     * @test
+     * 
+     * @dataProvider validPlainTextFileProvider
+     */
+    public function test_import_valid_plain_text_file_returns_success($file)
+    {
+        $response = $this->withHeaders(['Content-Type' => 'multipart/form-data'])
+            ->actingAs($this->user, 'api-guard')
+            ->json('POST', '/api/v1/twofaccounts/migration', [
+                'file' => $file,
+            ])
+            ->assertOk()
+            ->assertJsonCount(3, $key = null)
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::SERVICE,
+                'account'   => OtpTestData::ACCOUNT,
+                'otp_type'  => 'totp',
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_CUSTOM,
+                'algorithm' => OtpTestData::ALGORITHM_CUSTOM,
+                'period'    => OtpTestData::PERIOD_CUSTOM,
+                'counter'   => null
+            ])
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::SERVICE,
+                'account'   => OtpTestData::ACCOUNT,
+                'otp_type'  => 'hotp',
+                'secret'    => OtpTestData::SECRET,
+                'digits'    => OtpTestData::DIGITS_CUSTOM,
+                'algorithm' => OtpTestData::ALGORITHM_CUSTOM,
+                'period'    => null,
+                'counter'   => OtpTestData::COUNTER_CUSTOM
+            ])
+            ->assertJsonFragment([
+                'id'        => 0,
+                'service'   => OtpTestData::STEAM,
+                'account'   => OtpTestData::ACCOUNT,
+                'otp_type'  => 'steamtotp',
+                'secret'    => OtpTestData::STEAM_SECRET,
+                'digits'    => OtpTestData::DIGITS_STEAM,
+                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
+                'period'    => OtpTestData::PERIOD_DEFAULT,
+                'counter'   => null
+            ]);
+    }
+
+
+    /**
+     * Provide valid Plain Text files for import tests
+     */
+    public function validPlainTextFileProvider()
+    {
+        return [
+            'validPlainTextFile' => [
+                LocalFile::fake()->validPlainTextFile()
+            ],
+            'validPlainTextFileWithNewLines' => [
+                LocalFile::fake()->validPlainTextFileWithNewLines()
+            ],
+        ];
+    }
+
+
+    /**
+     * @test
+     * 
+     * @dataProvider invalidPlainTextFileProvider
+     */
+    public function test_import_invalid_plain_text_file_returns_bad_request($file)
+    {
+
+        $response = $this->withHeaders(['Content-Type' => 'multipart/form-data'])
+            ->actingAs($this->user, 'api-guard')
+            ->json('POST', '/api/v1/twofaccounts/migration', [
+                'file' => $file,
+            ])
+            ->assertStatus(400);
+    }
+
+
+    /**
+     * Provide invalid Plain Text files for import tests
+     */
+    public function invalidPlainTextFileProvider()
+    {
+        return [
+            'validPlainTextFile' => [
+                LocalFile::fake()->invalidPlainTextFileEmpty()
+            ],
+            'validPlainTextFileWithNewLines' => [
+                LocalFile::fake()->invalidPlainTextFileNoUri()
+            ],
+            'validPlainTextFileWithNewLines' => [
+                LocalFile::fake()->invalidPlainTextFileWithInvalidUri()
+            ],
+            'validPlainTextFileWithNewLines' => [
+                LocalFile::fake()->invalidPlainTextFileWithInvalidLine()
+            ],
+        ];
+    }
+
+
     /**
      * @test
      */

+ 6 - 18
tests/Api/v1/Requests/TwoFAccountImportRequestTest.php

@@ -45,7 +45,7 @@ class TwoFAccountImportRequestTest extends TestCase
     {
         return [
             [[
-                'uri' => 'otpauth-migration://offline?data=AEoATACEAEYASAA'
+                'payload' => 'otpauth-migration://offline?data=AEoATACEAEYASAA'
             ]],
         ];
     }
@@ -68,29 +68,17 @@ class TwoFAccountImportRequestTest extends TestCase
     {
         return [
             [[
-                'uri' => null // required
+                'payload' => null // required
             ]],
             [[
-                'uri' => '' // required
+                'payload' => '' // required
             ]],
             [[
-                'uri' => true // string
+                'payload' => true // string
             ]],
             [[
-                'uri' => 8 // string
-            ]],
-            [[
-                'uri' => 'otpXauth-migration://offline?data=fYmlzIAEoATACEAEYASAA' // regex
-            ]],
-            [[
-                'uri' => 'otpauth-migration:/offline?data=fYmlzIAEoATACEAEYASAA' // regex
-            ]],
-            [[
-                'uri' => 'otpauth-migration://offlinedata=fYmlzIAEoATACEAEYASAA' // regex
-            ]],
-            [[
-                'uri' => 'otpauth-migration://offline?dat=fYmlzIAEoATACEAEYASAA' // regex
-            ]],
+                'payload' => 8 // string
+            ]]
         ];
     }
 

+ 178 - 0
tests/Classes/LocalFileFactory.php

@@ -3,6 +3,7 @@
 namespace Tests\Classes;
 
 use Illuminate\Http\Testing\File;
+use Tests\Classes\OtpTestData;
 
 class LocalFileFactory {
 
@@ -48,4 +49,181 @@ class LocalFileFactory {
             fwrite($temp, ob_get_clean());
         }));
     }
+
+
+    /**
+     * Create a new local valid Aegis JSON file.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function validAegisJsonFile()
+    {
+        return new File('validAegisMigration.json', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo OtpTestData::AEGIS_JSON_MIGRATION_PAYLOAD;
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local invalid Aegis JSON file.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function invalidAegisJsonFile()
+    {
+        return new File('invalidAegisMigration.json', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo OtpTestData::INVALID_AEGIS_JSON_MIGRATION_PAYLOAD;
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local encrypted Aegis JSON file.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function encryptedAegisJsonFile()
+    {
+        return new File('encryptedAegisJsonFile.txt', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo OtpTestData::ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD;
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local valid Plain Text file.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function validPlainTextFile()
+    {
+        return new File('validPlainTextFile.txt', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo OtpTestData::TOTP_FULL_CUSTOM_URI;
+            echo PHP_EOL;
+            echo OtpTestData::HOTP_FULL_CUSTOM_URI;
+            echo PHP_EOL;
+            echo OtpTestData::STEAM_TOTP_URI;
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local valid Plain Text file with new lines.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function validPlainTextFileWithNewLines()
+    {
+        return new File('validPlainTextFileWithNewLines.txt', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo PHP_EOL;
+            echo OtpTestData::TOTP_FULL_CUSTOM_URI;
+            echo PHP_EOL;
+            echo PHP_EOL;
+            echo OtpTestData::HOTP_FULL_CUSTOM_URI;
+            echo PHP_EOL;
+            echo PHP_EOL;
+            echo OtpTestData::STEAM_TOTP_URI;
+            echo PHP_EOL;
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local invalid Plain Text file with no URI.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function invalidPlainTextFileNoUri()
+    {
+        return new File('invalidPlainTextFileNoUri.txt', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo 'lorem ipsum';
+            echo PHP_EOL;
+            echo 'lorem ipsum';
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local invalid Plain Text file with invalid line.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function invalidPlainTextFileWithInvalidLine()
+    {
+        return new File('invalidPlainTextFileWithInvalidLine.txt', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo OtpTestData::TOTP_FULL_CUSTOM_URI;
+            echo PHP_EOL;
+            echo 'lorem ipsum';
+            echo PHP_EOL;
+            echo OtpTestData::HOTP_FULL_CUSTOM_URI;
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local invalid Plain Text file with invalid URI.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function invalidPlainTextFileWithInvalidUri()
+    {
+        return new File('invalidPlainTextFileWithInvalidUri.txt', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo OtpTestData::TOTP_FULL_CUSTOM_URI;
+            echo PHP_EOL;
+            echo OtpTestData::INVALID_OTPAUTH_URI;
+            echo PHP_EOL;
+            echo OtpTestData::HOTP_FULL_CUSTOM_URI;
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+
+    /**
+     * Create a new local empty Plain Text file.
+     *
+     * @return \Illuminate\Http\Testing\File
+     */
+    public function invalidPlainTextFileEmpty()
+    {
+        return new File('invalidPlainTextFileEmpty.txt', tap(tmpfile(), function ($temp) {
+            ob_start();
+
+            echo '';
+
+            fwrite($temp, ob_get_clean());
+        }));
+    }
+
+    
 }

+ 141 - 0
tests/Classes/OtpTestData.php

@@ -84,4 +84,145 @@ class OtpTestData
     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';
+
+    const AEGIS_JSON_MIGRATION_PAYLOAD = '
+    {
+        "version": 1,
+        "header": {
+            "slots": null,
+            "params": null
+        },
+        "db": {
+            "version": 2,
+            "entries": [
+                {
+                    "type": "totp",
+                    "uuid": "5be1b189-260d-4fe1-930a-a78cb669dd86",
+                    "name": "'.self::ACCOUNT.'_totp",
+                    "issuer": "'.self::SERVICE.'_totp",
+                    "note": "",
+                    "icon": null,
+                    "info": {
+                        "secret": "'.self::SECRET.'",
+                        "algo": "'.self::ALGORITHM_DEFAULT.'",
+                        "digits": '.self::DIGITS_DEFAULT.',
+                        "period": '.self::PERIOD_DEFAULT.'
+                    }
+                },
+                {
+                    "type": "totp",
+                    "uuid": "fb2ebd05-9d71-4b2e-9d4e-b7f8d2942bfb",
+                    "name": "'.self::ACCOUNT.'_totp_custom",
+                    "issuer": "'.self::SERVICE.'_totp_custom",
+                    "note": "",
+                    "icon": null,
+                    "info": {
+                        "secret": "'.self::SECRET.'",
+                        "algo": "'.self::ALGORITHM_CUSTOM.'",
+                        "digits": '.self::DIGITS_CUSTOM.',
+                        "period": '.self::PERIOD_CUSTOM.'
+                    }
+                },
+                {
+                    "type": "hotp",
+                    "uuid": "90a2af2e-2857-4515-bb18-52c4fa823f6f",
+                    "name": "'.self::ACCOUNT.'_hotp",
+                    "issuer": "'.self::SERVICE.'_hotp",
+                    "note": "",
+                    "icon": null,
+                    "info": {
+                        "secret": "'.self::SECRET.'",
+                        "algo": "'.self::ALGORITHM_DEFAULT.'",
+                        "digits": '.self::DIGITS_DEFAULT.',
+                        "counter": '.self::COUNTER_DEFAULT.'
+                    }
+                },
+                {
+                    "type": "hotp",
+                    "uuid": "e1b3f683-d5fe-4126-b616-8c8abd8ad97c",
+                    "name": "'.self::ACCOUNT.'_hotp_custom",
+                    "issuer": "'.self::SERVICE.'_hotp_custom",
+                    "note": "",
+                    "icon": null,
+                    "info": {
+                        "secret": "'.self::SECRET.'",
+                        "algo": "'.self::ALGORITHM_CUSTOM.'",
+                        "digits": '.self::DIGITS_CUSTOM.',
+                        "counter": '.self::COUNTER_CUSTOM.'
+                    }
+                },
+                {
+                    "type": "steamtotp",
+                    "uuid": "9fb06143-421d-46e1-a7e9-4aafe44b0e72",
+                    "name": "'.self::ACCOUNT.'_steam",
+                    "issuer": "'.self::STEAM.'",
+                    "note": "",
+                    "icon": "null",
+                    "info": {
+                        "secret": "'.self::STEAM_SECRET.'",
+                        "algo": "'.self::ALGORITHM_DEFAULT.'",
+                        "digits": '.self::DIGITS_STEAM.',
+                        "period": '.self::PERIOD_DEFAULT.'
+                    }
+                }
+            ]
+        }
+    }';
+
+    const INVALID_AEGIS_JSON_MIGRATION_PAYLOAD = '
+    {
+        "version": 1,
+        "header": {
+            "slots": null,
+            "params": null
+        },
+        "db": {
+            "version": 2,
+            "thisIsNotTheCorrectKeyName": [
+                {
+                    "type": "totp",
+                    "uuid": "5be1b189-260d-4fe1-930a-a78cb669dd86",
+                    "name": "'.self::ACCOUNT.'",
+                    "issuer": "'.self::SERVICE.'",
+                    "note": "",
+                    "icon": null,
+                    "info": {
+                        "secret": "'.self::SECRET.'",
+                        "algo": "'.self::ALGORITHM_DEFAULT.'",
+                        "digits": '.self::DIGITS_DEFAULT.',
+                        "period": '.self::PERIOD_DEFAULT.'
+                    }
+                }
+            ]
+        }
+    }';
+
+    const ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD = '
+    {
+        "version": 1,
+        "header": {
+            "slots": [
+                {
+                    "type": 1,
+                    "uuid": "1f447956-c71c-4be4-8192-97197dc67df7",
+                    "key": "d742967686cae462c5732023a72d59245d8q7c5c93a66aeb2q2a350bb8b6a7ae",
+                    "key_params": {
+                        "nonce": "77a8ff6d84265efd2a3ed9b7",
+                        "tag": "cc13fb4a5baz3fd27bc97f5eacaa00d0"
+                    },
+                    "n": 32768,
+                    "r": 8,
+                    "p": 1,
+                    "salt": "1c245b2696b948dt040c30c538aeb6f9620b054d9ff182f33dd4b285b67bed51",
+                    "repaired": true
+                }
+            ],
+            "params": {
+                "nonce": "f31675d9966d2z588bd07788",
+                "tag": "ad5729fa135dc6d6sw87e0c955932661"
+            }
+        },
+        "db": "1rX0ajzsxNbhN2hvnNCMBNooLlzqwz\/LMT3bNEIJjPH+zIvIbA6GVVPHLpna+yvjxLPKVkt1OQig=="
+    }';
+
 }

+ 5 - 5
tests/Feature/Services/TwoFAccountServiceTest.php

@@ -192,7 +192,7 @@ class TwoFAccountServiceTest extends FeatureTestCase
      */
     public function test_convert_migration_from_gauth_returns_correct_accounts()
     {        
-        $twofaccounts = TwoFAccounts::convertMigrationFromGA(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
+        $twofaccounts = TwoFAccounts::migrate(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
 
         $this->assertCount(2, $twofaccounts);
 
@@ -240,7 +240,7 @@ class TwoFAccountServiceTest extends FeatureTestCase
         $twofaccount = new TwoFAccount;
         $twofaccount->fillWithOtpParameters($parameters)->save();
 
-        $twofaccounts = TwoFAccounts::convertMigrationFromGA(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
+        $twofaccounts = TwoFAccounts::migrate(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
 
         $this->assertEquals(-1, $twofaccounts->first()->id);
         $this->assertEquals(-1, $twofaccounts->last()->id);
@@ -250,10 +250,10 @@ class TwoFAccountServiceTest extends FeatureTestCase
     /**
      * @test
      */
-    public function test_convert_invalid_migration_from_gauth_returns_InvalidGoogleAuthMigration_excpetion()
+    public function test_convert_invalid_migration_from_gauth_returns_InvalidMigrationData_exception()
     {
-        $this->expectException(\App\Exceptions\InvalidGoogleAuthMigration::class);
-        $twofaccounts = TwoFAccounts::convertMigrationFromGA(OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA);
+        $this->expectException(\App\Exceptions\InvalidMigrationDataException::class);
+        $twofaccounts = TwoFAccounts::migrate(OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA);
     }
 
 }

+ 4 - 1
tests/Unit/Exceptions/HandlerTest.php

@@ -59,11 +59,14 @@ class HandlerTest extends TestCase
                 '\App\Exceptions\DbEncryptionException'
             ],
             [
-                '\App\Exceptions\InvalidGoogleAuthMigration'
+                '\App\Exceptions\InvalidMigrationDataException'
             ],
             [
                 '\App\Exceptions\UndecipherableException'
             ],
+            [
+                '\App\Exceptions\UnsupportedMigrationException'
+            ],
             [
                 '\App\Exceptions\UnsupportedOtpTypeException'
             ],