From 3e2a80b816c07c67abbef27e74eb180f063a1ebf Mon Sep 17 00:00:00 2001 From: Bubka <858858+Bubka@users.noreply.github.com> Date: Fri, 8 Nov 2024 08:41:11 +0100 Subject: [PATCH] Add ability to export data as otpauth URIs - Closes #386 --- .../v1/Controllers/TwoFAccountController.php | 3 +- .../v1/Requests/TwoFAccountExportRequest.php | 33 +++++ .../Resources/TwoFAccountExportCollection.php | 2 +- .../Resources/TwoFAccountExportResource.php | 33 +++-- app/Http/Controllers/SinglePageController.php | 6 +- config/2fauth.php | 1 + resources/js/components/ActionButtons.vue | 2 +- resources/js/components/ExportButtons.vue | 39 ++++++ resources/js/services/twofaccountService.js | 4 +- resources/js/stores/twofaccounts.js | 25 +++- resources/js/views/twofaccounts/Accounts.vue | 13 +- resources/lang/en/twofaccounts.php | 12 +- .../Controllers/TwoFAccountControllerTest.php | 41 +++++- .../Requests/TwoFAccountExportRequestTest.php | 126 ++++++++++++++++++ 14 files changed, 309 insertions(+), 31 deletions(-) create mode 100644 app/Api/v1/Requests/TwoFAccountExportRequest.php create mode 100644 resources/js/components/ExportButtons.vue create mode 100644 tests/Api/v1/Requests/TwoFAccountExportRequestTest.php diff --git a/app/Api/v1/Controllers/TwoFAccountController.php b/app/Api/v1/Controllers/TwoFAccountController.php index 94572030..8f2bd1e2 100644 --- a/app/Api/v1/Controllers/TwoFAccountController.php +++ b/app/Api/v1/Controllers/TwoFAccountController.php @@ -4,6 +4,7 @@ use App\Api\v1\Requests\TwoFAccountBatchRequest; use App\Api\v1\Requests\TwoFAccountDynamicRequest; +use App\Api\v1\Requests\TwoFAccountExportRequest; use App\Api\v1\Requests\TwoFAccountImportRequest; use App\Api\v1\Requests\TwoFAccountIndexRequest; use App\Api\v1\Requests\TwoFAccountReorderRequest; @@ -184,7 +185,7 @@ public function preview(TwoFAccountUriRequest $request) * * @return TwoFAccountExportCollection|\Illuminate\Http\JsonResponse */ - public function export(TwoFAccountBatchRequest $request) + public function export(TwoFAccountExportRequest $request) { $validated = $request->validated(); diff --git a/app/Api/v1/Requests/TwoFAccountExportRequest.php b/app/Api/v1/Requests/TwoFAccountExportRequest.php new file mode 100644 index 00000000..abecc854 --- /dev/null +++ b/app/Api/v1/Requests/TwoFAccountExportRequest.php @@ -0,0 +1,33 @@ + 'sometimes|required|boolean', + ], + ); + } +} diff --git a/app/Api/v1/Resources/TwoFAccountExportCollection.php b/app/Api/v1/Resources/TwoFAccountExportCollection.php index 67407cb0..9f3f9df7 100644 --- a/app/Api/v1/Resources/TwoFAccountExportCollection.php +++ b/app/Api/v1/Resources/TwoFAccountExportCollection.php @@ -23,7 +23,7 @@ public function toArray($request) { return [ 'app' => '2fauth_v' . config('2fauth.version'), - 'schema' => 1, + 'schema' => $this->when($request->missing('otpauth') || ! $request->boolean('otpauth'), 1), 'datetime' => now(), 'data' => $this->collection, ]; diff --git a/app/Api/v1/Resources/TwoFAccountExportResource.php b/app/Api/v1/Resources/TwoFAccountExportResource.php index bf1cb145..e7d76876 100644 --- a/app/Api/v1/Resources/TwoFAccountExportResource.php +++ b/app/Api/v1/Resources/TwoFAccountExportResource.php @@ -17,6 +17,7 @@ * @property int|null $period * @property int|null $counter * @property string $legacy_uri + * @method string getURI() */ class TwoFAccountExportResource extends JsonResource { @@ -28,19 +29,23 @@ class TwoFAccountExportResource extends JsonResource */ public function toArray($request) { - return [ - 'otp_type' => $this->otp_type, - 'account' => $this->account, - 'service' => $this->service, - 'icon' => $this->icon, - 'icon_mime' => $this->icon && IconStore::exists($this->icon) ? IconStore::mimeType($this->icon) : null, - 'icon_file' => $this->icon && IconStore::exists($this->icon) ? base64_encode(IconStore::get($this->icon)) : null, - 'secret' => $this->secret, - 'digits' => (int) $this->digits, - 'algorithm' => $this->algorithm, - 'period' => is_null($this->period) ? null : (int) $this->period, - 'counter' => is_null($this->counter) ? null : (int) $this->counter, - 'legacy_uri' => $this->legacy_uri, - ]; + return $request->has('otpauth') && $request->boolean('otpauth') + ? [ + 'uri' => urldecode($this->getURI()), + ] + : [ + 'otp_type' => $this->otp_type, + 'account' => $this->account, + 'service' => $this->service, + 'icon' => $this->icon, + 'icon_mime' => $this->icon && IconStore::exists($this->icon) ? IconStore::mimeType($this->icon) : null, + 'icon_file' => $this->icon && IconStore::exists($this->icon) ? base64_encode(IconStore::get($this->icon)) : null, + 'secret' => $this->secret, + 'digits' => (int) $this->digits, + 'algorithm' => $this->algorithm, + 'period' => is_null($this->period) ? null : (int) $this->period, + 'counter' => is_null($this->counter) ? null : (int) $this->counter, + 'legacy_uri' => $this->legacy_uri, + ]; } } diff --git a/app/Http/Controllers/SinglePageController.php b/app/Http/Controllers/SinglePageController.php index 86108ca6..b1c4d01d 100644 --- a/app/Http/Controllers/SinglePageController.php +++ b/app/Http/Controllers/SinglePageController.php @@ -31,6 +31,7 @@ public function index() $githubAuth = config('services.github.client_secret') ? true : false; $installDocUrl = config('2fauth.installDocUrl'); $ssoDocUrl = config('2fauth.ssoDocUrl'); + $exportSchemaUrl = config('2fauth.exportSchemaUrl'); // if (Auth::user()->preferences) @@ -46,8 +47,9 @@ public function index() 'subdirectory' => $subdir, ])->toJson(), 'urls' => collect([ - 'installDocUrl' => $installDocUrl, - 'ssoDocUrl' => $ssoDocUrl, + 'installDocUrl' => $installDocUrl, + 'ssoDocUrl' => $ssoDocUrl, + 'exportSchemaUrl' => $exportSchemaUrl, ]), 'defaultPreferences' => $defaultPreferences, 'subdirectory' => $subdir, diff --git a/config/2fauth.php b/config/2fauth.php index b8c4cf36..bb2fa0f5 100644 --- a/config/2fauth.php +++ b/config/2fauth.php @@ -14,6 +14,7 @@ 'latestReleaseUrl' => 'https://api.github.com/repos/Bubka/2FAuth/releases/latest', 'installDocUrl' => 'https://docs.2fauth.app/getting-started/installation/self-hosted-server/', 'ssoDocUrl' => 'https://docs.2fauth.app/security/authentication/sso/', + 'exportSchemaUrl' => 'https://docs.2fauth.app/usage/migration/#export-schema', /* |-------------------------------------------------------------------------- diff --git a/resources/js/components/ActionButtons.vue b/resources/js/components/ActionButtons.vue index 1e46ec8f..102359cf 100644 --- a/resources/js/components/ActionButtons.vue +++ b/resources/js/components/ActionButtons.vue @@ -71,7 +71,7 @@ :disabled='areDisabled' class="button is-rounded" :class="[{ 'is-outlined': mode == 'dark' || areDisabled }, areDisabled ? 'is-dark': 'is-link']" @click="$emit('export-button-clicked')" - :title="$t('twofaccounts.export_selected_to_json')" > + :title="$t('twofaccounts.export_selected_accounts')" > {{ $t('commons.export') }}

diff --git a/resources/js/components/ExportButtons.vue b/resources/js/components/ExportButtons.vue new file mode 100644 index 00000000..ee728fa4 --- /dev/null +++ b/resources/js/components/ExportButtons.vue @@ -0,0 +1,39 @@ + + + \ No newline at end of file diff --git a/resources/js/services/twofaccountService.js b/resources/js/services/twofaccountService.js index 2a2a9eb6..60b86a2b 100644 --- a/resources/js/services/twofaccountService.js +++ b/resources/js/services/twofaccountService.js @@ -55,8 +55,8 @@ export default { return apiClient.delete('/twofaccounts?ids=' + ids, { ...config }) }, - export(ids, config = {}) { - return apiClient.get('/twofaccounts/export?ids=' + ids, { ...config }) + export(ids, otpauthFormat, config = {}) { + return apiClient.get('/twofaccounts/export?ids=' + ids + (otpauthFormat ? '&otpauth=1' : ''), { ...config }) }, getQrcode(id, config = {}) { diff --git a/resources/js/stores/twofaccounts.js b/resources/js/stores/twofaccounts.js index ed26143e..bcae61e2 100644 --- a/resources/js/stores/twofaccounts.js +++ b/resources/js/stores/twofaccounts.js @@ -163,12 +163,25 @@ export const useTwofaccounts = defineStore({ /** * Exports selected accounts to a json file */ - export() { - twofaccountService.export(this.selectedIds.join(), {responseType: 'blob'}) - .then((response) => { - var blob = new Blob([response.data], {type: "application/json;charset=utf-8"}); - saveAs.saveAs(blob, "2fauth_export.json"); - }) + export(format = '2fauth') { + if (format == 'otpauth') { + twofaccountService.export(this.selectedIds.join(), true) + .then((response) => { + let uris = [] + response.data.data.forEach(account => { + uris.push(account.uri) + }); + var blob = new Blob([uris.join('\n')], {type: "text/plain;charset=utf-8"}); + saveAs.saveAs(blob, "2fauth_export_otpauth.txt"); + }) + } + else { + twofaccountService.export(this.selectedIds.join(), false, {responseType: 'blob'}) + .then((response) => { + var blob = new Blob([response.data], {type: "application/json;charset=utf-8"}); + saveAs.saveAs(blob, "2fauth_export.json"); + }) + } }, /** diff --git a/resources/js/views/twofaccounts/Accounts.vue b/resources/js/views/twofaccounts/Accounts.vue index fe51110b..d9051b18 100644 --- a/resources/js/views/twofaccounts/Accounts.vue +++ b/resources/js/views/twofaccounts/Accounts.vue @@ -7,6 +7,7 @@ import Toolbar from '@/components/Toolbar.vue' import OtpDisplay from '@/components/OtpDisplay.vue' import ActionButtons from '@/components/ActionButtons.vue' + import ExportButtons from '@/components/ExportButtons.vue' import Dots from '@/components/Dots.vue' import { UseColorMode } from '@vueuse/components' import { useUserStore } from '@/stores/user' @@ -29,6 +30,7 @@ const groups = useGroups() const showOtpInModal = ref(false) + const showExportFormatSelector = ref(false) const showGroupSwitch = ref(false) const showDestinationGroupSelector = ref(false) const isDragging = ref(false) @@ -357,7 +359,14 @@ - + + + + + + + @export-button-clicked="showExportFormatSelector = true"> diff --git a/resources/lang/en/twofaccounts.php b/resources/lang/en/twofaccounts.php index 92ddb567..2efea4a1 100644 --- a/resources/lang/en/twofaccounts.php +++ b/resources/lang/en/twofaccounts.php @@ -28,7 +28,17 @@ 'account_updated' => 'Account successfully updated', 'accounts_deleted' => 'Account(s) successfully deleted', 'accounts_moved' => 'Account(s) successfully moved', - 'export_selected_to_json' => 'Download a json export of selected accounts', + 'export_selected_accounts' => 'Export selected accounts', + 'export_selected_to_json' => 'Export accounts using the 2FAuth json format', + 'export_selected_to_otpauth_uri' => 'Export accounts to plain text otpauth URIs', + 'twofauth_export_format' => '2FAuth format', + 'twofauth_export_format_sub' => 'Export data using the 2FAuth json schema', + 'twofauth_export_format_desc' => 'You should prefer this option if you need to create a backup that can be restored. This format takes care of the icons.', + 'twofauth_export_format_url' => 'The schema definition is described here:', + 'twofauth_export_schema' => '2FAuth export schema', + 'otpauth_export_format' => 'otpauth URIs', + 'otpauth_export_format_sub' => 'Export data as a list of otpauth URIs', + 'otpauth_export_format_desc' => 'otpauth URI is the most common format used to exchange 2FA data, for example in the form of a QR code when you enable 2FA on a web site. Select this if you want to switch from 2FAuth.', 'reveal' => 'reveal', 'forms' => [ 'service' => [ diff --git a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php index a91a08ad..51bc17e4 100644 --- a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php +++ b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php @@ -166,7 +166,18 @@ class TwoFAccountControllerTest extends FeatureTestCase 'period', 'counter', 'legacy_uri', - ], ], + ], + ], + ]; + + private const VALID_EXPORT_AS_URIS_STRUTURE = [ + 'app', + 'datetime', + 'data' => [ + '*' => [ + 'uri', + ], + ], ]; private const JSON_FRAGMENTS_FOR_CUSTOM_TOTP = [ @@ -1221,6 +1232,34 @@ public function test_export_returns_json_migration_resource() ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP); } + #[Test] + public function test_export_returns_plain_text_with_otpauth_uris() + { + $this->twofaccountA = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP); + $this->twofaccountB = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP); + + $this->actingAs($this->user, 'api-guard') + ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountA->id . ',' . $this->twofaccountB->id . '&otpauth=1') + ->assertOk() + ->assertJsonStructure(self::VALID_EXPORT_AS_URIS_STRUTURE) + ->assertJsonFragment(['uri' => $this->twofaccountA->getURI()]) + ->assertJsonFragment(['uri' => $this->twofaccountB->getURI()]); + } + + #[Test] + public function test_export_returns_json_migration_resource_when_otpauth_param_is_off() + { + $this->twofaccountA = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP); + $this->twofaccountB = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP); + + $this->actingAs($this->user, 'api-guard') + ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountA->id . ',' . $this->twofaccountB->id . '&otpauth=0') + ->assertOk() + ->assertJsonStructure(self::VALID_EXPORT_STRUTURE) + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP) + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP); + } + #[Test] public function test_export_too_many_ids_returns_bad_request() { diff --git a/tests/Api/v1/Requests/TwoFAccountExportRequestTest.php b/tests/Api/v1/Requests/TwoFAccountExportRequestTest.php new file mode 100644 index 00000000..9730f418 --- /dev/null +++ b/tests/Api/v1/Requests/TwoFAccountExportRequestTest.php @@ -0,0 +1,126 @@ +once() + ->andReturn(true); + + $request = new TwoFAccountExportRequest; + + $this->assertTrue($request->authorize()); + } + + #[Test] + #[DataProvider('provideValidData')] + public function test_valid_data(array $data) : void + { + $request = new TwoFAccountExportRequest; + $validator = Validator::make($data, $request->rules()); + + $this->assertFalse($validator->fails()); + } + + /** + * Provide Valid data for validation test + */ + public static function provideValidData() : array + { + return [ + [[ + 'ids' => '1', + 'otpauth' => '1', + ]], + [[ + 'ids' => '1', + 'otpauth' => 1, + ]], + [[ + 'ids' => '1', + 'otpauth' => true, + ]], + [[ + 'ids' => '1', + ]], + [[ + 'ids' => '1', + 'otpauth' => '0', + ]], + [[ + 'ids' => '1', + 'otpauth' => 0, + ]], + [[ + 'ids' => '1', + 'otpauth' => false, + ]], + ]; + } + + #[Test] + #[DataProvider('provideInvalidData')] + public function test_invalid_data(array $data) : void + { + $request = new TwoFAccountExportRequest; + $validator = Validator::make($data, $request->rules()); + + $this->assertTrue($validator->fails()); + } + + /** + * Provide invalid data for validation test + */ + public static function provideInvalidData() : array + { + return [ + [[ + 'ids' => '1', + 'otpauth' => null, + ]], + [[ + 'ids' => '1', + 'otpauth' => '', + ]], + [[ + 'ids' => '1', + 'otpauth' => 2, + ]], + [[ + 'ids' => '1', + 'otpauth' => 'string', + ]], + [[ + 'ids' => '1', + 'otpauth' => 0.1, + ]], + [[ + 'ids' => '1', + 'otpauth' => '01/01/2020', + ]], + [[ + 'ids' => '1', + 'otpauth' => '01', + ]], + ]; + } +}