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 @@
+
+
+
+
+
+
+ {{ $t('twofaccounts.twofauth_export_format_sub') }}
+
+
+
+ {{ $t('twofaccounts.twofauth_export_format_desc') }}
+ {{ $t('twofaccounts.twofauth_export_format_url') }}
+
+ {{ $t('twofaccounts.twofauth_export_schema') }}
+
+
+
+
+
+
+ {{ $t('twofaccounts.otpauth_export_format_sub') }}
+
+
+ {{ $t('twofaccounts.otpauth_export_format_desc') }}
+
+
+
+
\ 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',
+ ]],
+ ];
+ }
+}