Bläddra i källkod

Add support for 2FAuth json migration

Bubka 2 år sedan
förälder
incheckning
17137b9885

+ 7 - 2
app/Api/v1/Resources/TwoFAccountExportCollection.php

@@ -17,10 +17,15 @@ class TwoFAccountExportCollection extends ResourceCollection
      * Transform the resource collection into an array.
      *
      * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Support\Collection<int|string, TwoFAccountExportResource>
+     * @return array
      */
     public function toArray($request)
     {
-        return $this->collection;
+        return [
+            'app'      => '2fauth_v' . config('2fauth.version'),
+            'schema'   => 1,
+            'datetime' => now(),
+            'data'     => $this->collection,
+        ];
     }
 }

+ 33 - 1
app/Factories/MigratorFactory.php

@@ -9,6 +9,7 @@ use App\Services\Migrators\GoogleAuthMigrator;
 use App\Services\Migrators\Migrator;
 use App\Services\Migrators\PlainTextMigrator;
 use App\Services\Migrators\TwoFASMigrator;
+use App\Services\Migrators\TwoFAuthMigrator;
 use Illuminate\Support\Arr;
 use Illuminate\Support\Facades\App;
 use Illuminate\Support\Facades\Validator;
@@ -23,7 +24,9 @@ class MigratorFactory implements MigratorFactoryInterface
      */
     public function create(string $migrationPayload) : Migrator
     {
-        if ($this->isAegisJSON($migrationPayload)) {
+        if ($this->isTwoFAuthJSON($migrationPayload)) {
+            return App::make(TwoFAuthMigrator::class);
+        } elseif ($this->isAegisJSON($migrationPayload)) {
             return App::make(AegisMigrator::class);
         } elseif ($this->is2FASv2($migrationPayload)) {
             return App::make(TwoFASMigrator::class);
@@ -73,6 +76,35 @@ class MigratorFactory implements MigratorFactoryInterface
         )->passes();
     }
 
+    /**
+     * Determine if a payload comes from 2FAuth in JSON format
+     *
+     * @param  string  $migrationPayload The payload to analyse
+     * @return bool
+     */
+    private function isTwoFAuthJSON(string $migrationPayload) : bool
+    {
+        $json = json_decode($migrationPayload, true);
+
+        if (Arr::has($json, 'schema') && (strpos(Arr::get($json, 'app'), '2fauth_') === 0)) {
+            return count(Validator::validate(
+                $json,
+                [
+                    'data.*.otp_type'  => 'required',
+                    'data.*.service'   => 'required',
+                    'data.*.account'   => 'required',
+                    'data.*.secret'    => 'required',
+                    'data.*.digits'    => 'required',
+                    'data.*.algorithm' => 'required',
+                    'data.*.period'    => 'present',
+                    'data.*.counter'   => 'present',
+                ]
+            )) > 0;
+        }
+
+        return false;
+    }
+
     /**
      * Determine if a payload comes from Aegis Authenticator in JSON format
      *

+ 131 - 0
app/Services/Migrators/TwoFAuthMigrator.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace App\Services\Migrators;
+
+use App\Exceptions\InvalidMigrationDataException;
+use App\Models\TwoFAccount;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Log;
+
+class TwoFAuthMigrator extends Migrator
+{
+    // {
+    //     "app": "2fauth_v3.4.1",
+    //     "schema": 1,
+    //     "datetime": "2022-12-14T14:53:06.173939Z",
+    //     "data":
+    //     [
+    //         {
+    //             "otp_type": "totp",
+    //             "account": "cwxcwxc",
+    //             "service": "wcxwxcwx",
+    //             "icon": null,
+    //             "icon_mime": null,
+    //             "icon_file": null,
+    //             "secret": "EEEE====",
+    //             "digits": 6,
+    //             "algorithm": "sha1",
+    //             "period": 30,
+    //             "counter": null,
+    //             "legacy_uri": "otpauth://totp/wcxwxcwx%3Acwxcwxc?issuer=wcxwxcwx&secret=EEEE"
+    //         }
+    //     ]
+    // }
+
+    /**
+     * Convert migration data to a TwoFAccounts collection.
+     *
+     * @param  mixed  $migrationPayload
+     * @return \Illuminate\Support\Collection<int|string, \App\Models\TwoFAccount> The converted accounts
+     */
+    public function migrate(mixed $migrationPayload) : Collection
+    {
+        $json = json_decode(htmlspecialchars_decode($migrationPayload), true);
+
+        if (is_null($json)) {
+            Log::error('2FAuth JSON migration data cannot be read');
+            throw new InvalidMigrationDataException('2FAS Auth');
+        }
+
+        $twofaccounts = [];
+
+        foreach ($json['data'] as $key => $otp_parameters) {
+            $parameters               = [];
+            $parameters['otp_type']   = $otp_parameters['otp_type'];
+            $parameters['service']    = $otp_parameters['service'];
+            $parameters['account']    = $otp_parameters['account'];
+            $parameters['secret']     = $this->padToValidBase32Secret($otp_parameters['secret']);
+            $parameters['algorithm']  = $otp_parameters['algorithm'];
+            $parameters['digits']     = $otp_parameters['digits'];
+            $parameters['legacy_uri'] = $otp_parameters['legacy_uri'];
+            $parameters['counter']    = strtolower($parameters['otp_type']) === 'hotp' && $otp_parameters['counter'] > 0
+                ? $otp_parameters['counter']
+                : null;
+            $parameters['period'] = strtolower($parameters['otp_type']) === 'totp' && $otp_parameters['period'] > 0
+                ? $otp_parameters['period']
+                : null;
+
+            try {
+                if (Arr::has($otp_parameters, 'icon_file') && Arr::has($otp_parameters, 'icon_mime')) {
+                    switch ($otp_parameters['icon_mime']) {
+                        case 'image/svg+xml':
+                            $parameters['iconExt'] = 'svg';
+                            break;
+
+                        case 'image/png':
+                            $parameters['iconExt'] = 'png';
+                            break;
+
+                        case 'image/jpeg':
+                            $parameters['iconExt'] = 'jpg';
+                            break;
+
+                        case 'image/bmp':
+                            $parameters['iconExt'] = 'bmp';
+                            break;
+
+                        case 'image/x-ms-bmp':
+                            $parameters['iconExt'] = 'bmp';
+                            break;
+
+                        case 'image/webp':
+                            $parameters['iconExt'] = 'webp';
+                            break;
+
+                        default:
+                            throw new \Exception();
+                    }
+                    $parameters['icon_file'] = base64_decode($otp_parameters['icon_file']);
+                }
+            } catch (\Exception) {
+                // we do nothing
+            }
+
+            try {
+                $twofaccounts[$key] = new TwoFAccount;
+                $twofaccounts[$key]->fillWithOtpParameters($parameters);
+                if (Arr::has($parameters, 'iconExt')) {
+                    $twofaccounts[$key]->setIcon($parameters['icon_file'], $parameters['iconExt']);
+                }
+            } catch (\Exception $exception) {
+                Log::error(sprintf('Cannot instanciate a TwoFAccount object with 2FAS imported item #%s', $key));
+                Log::debug($exception->getMessage());
+
+                // The token failed to generate a valid account so we create a fake account to be returned.
+                $fakeAccount           = new TwoFAccount();
+                $fakeAccount->id       = TwoFAccount::FAKE_ID;
+                $fakeAccount->otp_type = $otp_parameters['otp']['tokenType'] ?? TwoFAccount::TOTP;
+                // Only basic fields are filled to limit the risk of another exception.
+                $fakeAccount->account = $otp_parameters['otp']['account'] ?? __('twofaccounts.import.invalid_account');
+                $fakeAccount->service = $otp_parameters['name'] ?? __('twofaccounts.import.invalid_service');
+                // 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 - 0
resources/js/views/twofaccounts/Import.vue

@@ -41,6 +41,12 @@
                 <!-- Supported migration resources -->
                 <h5 class="title is-5 mb-3 has-text-grey-dark">{{ $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">2FAuth</span>
+                        <span class="tag is-black">JSON</span>
+                        </div>
+                    </div>
                     <div class="control">
                         <div class="tags has-addons">
                         <span class="tag is-dark">Google Auth</span>