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

This commit is contained in:
Bubka 2022-10-07 18:58:48 +02:00
parent 1610cf3738
commit e79ae0a3ed
30 changed files with 1134 additions and 230 deletions

View File

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

View File

@ -102,6 +102,30 @@ public function update(TwoFAccountUpdateRequest $request, TwoFAccount $twofaccou
}
/**
* 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
*

View File

@ -25,7 +25,8 @@ public function authorize()
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',
];
}
}

View File

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

View File

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

View File

@ -60,9 +60,19 @@ public function register()
'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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
@ -19,19 +18,18 @@ class MigrationServiceProvider extends ServiceProvider implements DeferrableProv
*/
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);
case 'import.aegis':
return $this->app->get(AegisMigrator::class);
$this->app->singleton(GoogleAuthMigrator::class, function () {
return new GoogleAuthMigrator();
});
default:
return $this->app->get(PlainTextMigrator::class);
}
$this->app->singleton(AegisMigrator::class, function () {
return new AegisMigrator();
});
$this->app->singleton(PlainTextMigrator::class, function () {
return new PlainTextMigrator();
});
}
@ -44,19 +42,4 @@ public function boot()
{
//
}
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [
GoogleAuthMigrator::class,
AegisMigrator::class,
PlainTextMigrator::class,
];
}
}

View File

@ -5,6 +5,8 @@
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();
});

View File

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

View File

@ -4,19 +4,19 @@
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 @@ public function migrate(mixed $migrationPayload) : Collection
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 @@ public function migrate(mixed $migrationPayload) : Collection
}
}
return self::markAsDuplicate(collect($twofaccounts));
return collect($twofaccounts);
}
}

View File

@ -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
* Convert migration data to a 2FAccounts collection.
*
* @param \Illuminate\Support\Collection $twofaccounts
* @return \Illuminate\Support\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;
}

View File

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

View File

@ -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 @@ public static function withdraw($ids) : void
}
/**
* 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 @@ public static function delete($ids) : int
}
/**
* 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
*

View File

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

View File

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

View File

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

View File

@ -39,8 +39,10 @@
'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'
];

View File

@ -134,8 +134,13 @@
'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',

View File

@ -29,6 +29,7 @@
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::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');

View File

@ -10,6 +10,7 @@
use App\Models\TwoFAccount;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\Classes\LocalFile;
/**
@ -457,11 +458,11 @@ public function test_update_twofaccount_with_invalid_data_returns_validation_err
/**
* @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 @@ public function test_import_valid_gauth_data_returns_success_with_consistent_res
/**
* @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 @@ public function test_import_with_invalid_uri_returns_validation_error()
/**
* @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 @@ public function test_import_gauth_data_with_duplicates_returns_negative_ids()
]);
$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 @@ public function test_import_gauth_data_with_duplicates_returns_negative_ids()
/**
* @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 @@ public function test_import_invalid_gauth_data_returns_bad_request()
}
/**
* @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
*/

View File

@ -45,7 +45,7 @@ public function provideValidData() : array
{
return [
[[
'uri' => 'otpauth-migration://offline?data=AEoATACEAEYASAA'
'payload' => 'otpauth-migration://offline?data=AEoATACEAEYASAA'
]],
];
}
@ -68,29 +68,17 @@ public function provideInvalidData() : array
{
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
]]
];
}

View File

@ -3,6 +3,7 @@
namespace Tests\Classes;
use Illuminate\Http\Testing\File;
use Tests\Classes\OtpTestData;
class LocalFileFactory {
@ -48,4 +49,181 @@ public function invalidQrcode()
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());
}));
}
}

View File

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

View File

@ -192,7 +192,7 @@ public function test_delete_single_id()
*/
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 @@ public function test_convert_migration_from_gauth_returns_flagged_duplicates()
$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 @@ public function test_convert_migration_from_gauth_returns_flagged_duplicates()
/**
* @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);
}
}

View File

@ -59,11 +59,14 @@ public function provideExceptionsforBadRequest() : array
'\App\Exceptions\DbEncryptionException'
],
[
'\App\Exceptions\InvalidGoogleAuthMigration'
'\App\Exceptions\InvalidMigrationDataException'
],
[
'\App\Exceptions\UndecipherableException'
],
[
'\App\Exceptions\UnsupportedMigrationException'
],
[
'\App\Exceptions\UnsupportedOtpTypeException'
],