Add support for 2FAS Auth export and fix some issues with migrators

This commit is contained in:
Bubka 2022-10-11 11:20:07 +02:00
parent f867bd3fc5
commit ed19b482cd
11 changed files with 207 additions and 11 deletions

View File

@ -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;
}
}

View File

@ -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');

View File

@ -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();
});

View File

@ -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();

View File

@ -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();

View File

@ -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, '=');
}
}

View File

@ -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();

View File

@ -0,0 +1,121 @@
<?php
namespace App\Services\Migrators;
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 TwoFASMigrator extends Migrator
{
// {
// "groups":
// [
// {
// "id": "C2F69014-C4C7-4EEC-9225-D24E750F77FD",
// "name": "Test",
// "isExpanded": true
// }
// ],
// "schemaVersion": 2,
// "appOrigin": "ios",
// "appVersionCode": 32001,
// "services":
// [
// {
// "secret": "NRTWO2DLNJUGO23KM5UA",
// "badge":
// {
// "color": "Default"
// },
// "order":
// {
// "position": 0
// },
// "otp":
// {
// "account": "My account",
// "digits": 6,
// "counter": 0,
// "period": 30,
// "algorithm": "SHA1",
// "tokenType": "TOTP"
// },
// "updatedAt": 1657529936000,
// "type": "ManuallyAdded",
// "name": "My Service",
// "icon":
// {
// "selected": "Brand",
// "brand":
// {
// "id": "ManuallyAdded"
// },
// "label":
// {
// "text": "OW",
// "backgroundColor": "LightBlue"
// }
// }
// }
// ],
// "appVersionName": "3.20.1"
// }
/**
* Convert migration data to a TwoFAccounts collection.
*
* @param mixed $migrationPayload
* @return \Illuminate\Support\Collection The converted accounts
*/
public function migrate(mixed $migrationPayload) : Collection
{
$json = json_decode(htmlspecialchars_decode($migrationPayload), true);
if (is_null($json) || Arr::has($json, 'services') == false) {
Log::error('Aegis JSON migration data cannot be read');
throw new InvalidMigrationDataException('2FAS Auth');
}
foreach ($json['services'] as $key => $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);
}
}

View File

@ -26,7 +26,7 @@
<!-- 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">
<input aria-hidden="true" tabindex="-1" class="file-input" type="file" accept="text/plain,application/json,text/csv,.2fas" v-on:change="submitFile" ref="fileInput">
{{ $t('twofaccounts.import.upload_a_file') }}
</label>
<field-error :form="uploadForm" field="file" />
@ -54,6 +54,12 @@
<span class="tag is-black">{{ $t('twofaccounts.import.plain_text') }}</span>
</div>
</div>
<div class="control">
<div class="tags has-addons">
<span class="tag is-dark">2FAS Auth</span>
<span class="tag is-black">JSON</span>
</div>
</div>
</div>
</div>
<div v-else>

View File

@ -40,6 +40,7 @@ return [
'auth_proxy_failed' => 'Proxy authentication failed',
'auth_proxy_failed_legend' => '2Fauth is configured to run behind an authentication proxy but your proxy does not return the expected header. Check your configuration and try again.',
'invalid_x_migration' => 'Invalid or unreadable :appname data',
'invalid_2fa_data' => 'Invalid 2FA data',
'unsupported_migration' => 'Data do not match any supported format',
'unsupported_otp_type' => 'Unsupported OTP type',
'encrypted_migration' => 'Unreadable, the data seem encrypted',

View File

@ -137,7 +137,7 @@ return [
'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_formats_for_file_upload' => 'Accepted: Plain text, json, 2fas',
'supported_migration_formats' => 'Supported migration formats',
'qr_code' => 'QR code',
'plain_text' => 'Plain text',
@ -152,6 +152,8 @@ return [
'discard_this_account' => 'Discard this account',
'generate_a_test_password' => 'Generate a test pasword',
'possible_duplicate' => 'An account with the exact same data already exists',
'invalid_account' => '- invalid account -',
'invalid_service' => '- invalid service -',
],
];