From 17137b9885da59e3c62d0ead77814de68e92cafe Mon Sep 17 00:00:00 2001 From: Bubka <858858+Bubka@users.noreply.github.com> Date: Wed, 14 Dec 2022 22:20:03 +0100 Subject: [PATCH] Add support for 2FAuth json migration --- .../Resources/TwoFAccountExportCollection.php | 9 +- app/Factories/MigratorFactory.php | 34 ++++- app/Services/Migrators/TwoFAuthMigrator.php | 131 ++++++++++++++++++ resources/js/views/twofaccounts/Import.vue | 6 + 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 app/Services/Migrators/TwoFAuthMigrator.php diff --git a/app/Api/v1/Resources/TwoFAccountExportCollection.php b/app/Api/v1/Resources/TwoFAccountExportCollection.php index 3ce03ebc..67407cb0 100644 --- a/app/Api/v1/Resources/TwoFAccountExportCollection.php +++ b/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 + * @return array */ public function toArray($request) { - return $this->collection; + return [ + 'app' => '2fauth_v' . config('2fauth.version'), + 'schema' => 1, + 'datetime' => now(), + 'data' => $this->collection, + ]; } } diff --git a/app/Factories/MigratorFactory.php b/app/Factories/MigratorFactory.php index 1027b704..4a72fb8f 100644 --- a/app/Factories/MigratorFactory.php +++ b/app/Factories/MigratorFactory.php @@ -9,6 +9,7 @@ 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 @@ private function isPlainText(string $migrationPayload) : bool )->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 * diff --git a/app/Services/Migrators/TwoFAuthMigrator.php b/app/Services/Migrators/TwoFAuthMigrator.php new file mode 100644 index 00000000..b0b9379d --- /dev/null +++ b/app/Services/Migrators/TwoFAuthMigrator.php @@ -0,0 +1,131 @@ + 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); + } +} diff --git a/resources/js/views/twofaccounts/Import.vue b/resources/js/views/twofaccounts/Import.vue index 32e1b28e..c124fdea 100644 --- a/resources/js/views/twofaccounts/Import.vue +++ b/resources/js/views/twofaccounts/Import.vue @@ -41,6 +41,12 @@
{{ $t('twofaccounts.import.supported_migration_formats') }}
+
+
+ 2FAuth + JSON +
+
Google Auth