diff --git a/app/Factories/MigratorFactory.php b/app/Factories/MigratorFactory.php index 998e282b..08055840 100644 --- a/app/Factories/MigratorFactory.php +++ b/app/Factories/MigratorFactory.php @@ -6,6 +6,7 @@ use App\Services\Migrators\GoogleAuthMigrator; use App\Services\Migrators\AegisMigrator; use App\Services\Migrators\Migrator; use App\Services\Migrators\PlainTextMigrator; +use App\Services\Migrators\TwoFASMigrator; use Illuminate\Support\Facades\App; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; @@ -25,6 +26,9 @@ class MigratorFactory implements MigratorFactoryInterface if ($this->isAegisJSON($migrationPayload)) { return App::make(AegisMigrator::class); } + else if ($this->is2FASv2($migrationPayload)) { + return App::make(TwoFASMigrator::class); + } else if ($this->isGoogleAuth($migrationPayload)) { return App::make(GoogleAuthMigrator::class); } @@ -111,4 +115,52 @@ class MigratorFactory implements MigratorFactoryInterface return false; } + + /** + * + */ + private function is2FASv2($migrationPayload) : mixed + { + // - 2FAS JSON : is a JSON object with the key 'schemaVersion' == 2 and a key 'services' full of objects like + // { + // "secret": "A4GRFTVVRBGY7UIW", + // ... + // "otp": + // { + // "account": "John DOE", + // "digits": 6, + // "counter": 0, + // "period": 30, + // "algorithm": "SHA1", + // "tokenType": "TOTP" + // }, + // "type": "ManuallyAdded", + // "name": "Facebook", + // "icon": + // { + // ... + // } + // } + + $json = json_decode($migrationPayload, true); + + if (Arr::get($json, 'schemaVersion') == 2 && (Arr::has($json, 'services') || Arr::has($json, 'servicesEncrypted'))) { + if (Arr::has($json, 'servicesEncrypted')) { + throw new EncryptedMigrationException(); + } + else { + return count(Validator::validate( + $json, + [ + 'services.*.secret' => 'required', + 'services.*.name' => 'required', + 'services.*.otp' => 'required' + ] + )); + } + } + + return false; + } + } diff --git a/app/Models/TwoFAccount.php b/app/Models/TwoFAccount.php index c84e76dc..07e6c6bb 100644 --- a/app/Models/TwoFAccount.php +++ b/app/Models/TwoFAccount.php @@ -365,7 +365,7 @@ class TwoFAccount extends Model implements Sortable */ public function fillWithOtpParameters(array $parameters, bool $skipIconFetching = false) { - $this->otp_type = Arr::get($parameters, 'otp_type'); + $this->otp_type = strtolower(Arr::get($parameters, 'otp_type')); $this->account = Arr::get($parameters, 'account'); $this->service = Arr::get($parameters, 'service'); $this->icon = Arr::get($parameters, 'icon'); diff --git a/app/Providers/MigrationServiceProvider.php b/app/Providers/MigrationServiceProvider.php index 11589163..caf133de 100644 --- a/app/Providers/MigrationServiceProvider.php +++ b/app/Providers/MigrationServiceProvider.php @@ -7,6 +7,7 @@ use App\Factories\MigratorFactoryInterface; use App\Services\Migrators\GoogleAuthMigrator; use App\Services\Migrators\AegisMigrator; use App\Services\Migrators\PlainTextMigrator; +use App\Services\Migrators\TwoFASMigrator; use Illuminate\Support\ServiceProvider; class MigrationServiceProvider extends ServiceProvider @@ -28,6 +29,10 @@ class MigrationServiceProvider extends ServiceProvider return new AegisMigrator(); }); + $this->app->singleton(TwoFASMigrator::class, function () { + return new TwoFASMigrator(); + }); + $this->app->singleton(PlainTextMigrator::class, function () { return new PlainTextMigrator(); }); diff --git a/app/Services/Migrators/AegisMigrator.php b/app/Services/Migrators/AegisMigrator.php index 650b84f3..04c2c222 100644 --- a/app/Services/Migrators/AegisMigrator.php +++ b/app/Services/Migrators/AegisMigrator.php @@ -55,7 +55,7 @@ class AegisMigrator extends Migrator $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['secret'] = $this->padToValidBase32Secret($otp_parameters['info']['secret']); $parameters['algorithm'] = $otp_parameters['info']['algo']; $parameters['digits'] = $otp_parameters['info']['digits']; $parameters['counter'] = $otp_parameters['info']['counter'] ?? null; @@ -108,10 +108,10 @@ class AegisMigrator extends Migrator // 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['type']; + $fakeAccount->otp_type = $otp_parameters['type'] ?? TwoFAccount::TOTP; // Only basic fields are filled to limit the risk of another exception. - $fakeAccount->account = $otp_parameters['name']; - $fakeAccount->service = $otp_parameters['issuer']; + $fakeAccount->account = $otp_parameters['name'] ?? __('twofaccounts.import.invalid_account'); + $fakeAccount->service = $otp_parameters['issuer'] ?? __('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(); diff --git a/app/Services/Migrators/GoogleAuthMigrator.php b/app/Services/Migrators/GoogleAuthMigrator.php index 8ba63da6..3e6c38f5 100644 --- a/app/Services/Migrators/GoogleAuthMigrator.php +++ b/app/Services/Migrators/GoogleAuthMigrator.php @@ -69,8 +69,8 @@ class GoogleAuthMigrator extends Migrator $fakeAccount->id = -2; $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(); + $fakeAccount->account = $otp_parameters->getName() ?? __('twofaccounts.import.invalid_account'); + $fakeAccount->service = $otp_parameters->getIssuer() ?? __('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(); diff --git a/app/Services/Migrators/Migrator.php b/app/Services/Migrators/Migrator.php index b89cb956..553fe281 100644 --- a/app/Services/Migrators/Migrator.php +++ b/app/Services/Migrators/Migrator.php @@ -14,4 +14,13 @@ abstract class Migrator */ abstract protected function migrate(mixed $migrationPayload) : Collection; + + /** + * Pad a string to 8 chars min + */ + protected function padToValidBase32Secret(string $string) + { + return str_pad($string, 8, '='); + } + } diff --git a/app/Services/Migrators/PlainTextMigrator.php b/app/Services/Migrators/PlainTextMigrator.php index 4a47ca74..2f100400 100644 --- a/app/Services/Migrators/PlainTextMigrator.php +++ b/app/Services/Migrators/PlainTextMigrator.php @@ -47,8 +47,8 @@ class PlainTextMigrator extends Migrator $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) ?? '-'; + $fakeAccount->account = __('twofaccounts.import.invalid_account'); + $fakeAccount->service = filter_input(INPUT_GET, 'issuer', FILTER_SANITIZE_ENCODED) ?? __('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(); diff --git a/app/Services/Migrators/TwoFASMigrator.php b/app/Services/Migrators/TwoFASMigrator.php new file mode 100644 index 00000000..11a0c531 --- /dev/null +++ b/app/Services/Migrators/TwoFASMigrator.php @@ -0,0 +1,121 @@ + $otp_parameters) { + + $parameters = array(); + $parameters['otp_type'] = $otp_parameters['otp']['tokenType']; + $parameters['service'] = $otp_parameters['name']; + $parameters['account'] = $otp_parameters['otp']['account'] ?? $parameters['service']; + $parameters['secret'] = $this->padToValidBase32Secret($otp_parameters['secret']); + $parameters['algorithm'] = $otp_parameters['otp']['algorithm']; + $parameters['digits'] = $otp_parameters['otp']['digits']; + $parameters['counter'] = $otp_parameters['otp']['counter'] ?? null; + $parameters['period'] = $otp_parameters['otp']['period'] ?? null; + + try { + $twofaccounts[$key] = new TwoFAccount; + $twofaccounts[$key]->fillWithOtpParameters($parameters); + } + catch (\Exception $exception) { + + Log::error(sprintf('Cannot instanciate a TwoFAccount object with 2FAS 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 = 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 c1ecff69..98a9f422 100644 --- a/resources/js/views/twofaccounts/Import.vue +++ b/resources/js/views/twofaccounts/Import.vue @@ -26,7 +26,7 @@