Przeglądaj źródła

Add ability to export data as otpauth URIs - Closes #386

Bubka 8 miesięcy temu
rodzic
commit
3e2a80b816

+ 2 - 1
app/Api/v1/Controllers/TwoFAccountController.php

@@ -4,6 +4,7 @@ namespace App\Api\v1\Controllers;
 
 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 @@ class TwoFAccountController extends Controller
      *
      * @return TwoFAccountExportCollection|\Illuminate\Http\JsonResponse
      */
-    public function export(TwoFAccountBatchRequest $request)
+    public function export(TwoFAccountExportRequest $request)
     {
         $validated = $request->validated();
 

+ 33 - 0
app/Api/v1/Requests/TwoFAccountExportRequest.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Api\v1\Requests;
+
+use Illuminate\Support\Facades\Auth;
+
+class TwoFAccountExportRequest extends TwoFAccountBatchRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return Auth::check();
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return array_merge(
+            parent::rules(),
+            [
+                'otpauth' => 'sometimes|required|boolean',
+            ],
+        );
+    }
+}

+ 1 - 1
app/Api/v1/Resources/TwoFAccountExportCollection.php

@@ -23,7 +23,7 @@ class TwoFAccountExportCollection extends ResourceCollection
     {
         return [
             'app'      => '2fauth_v' . config('2fauth.version'),
-            'schema'   => 1,
+            'schema'   => $this->when($request->missing('otpauth') || ! $request->boolean('otpauth'), 1),
             'datetime' => now(),
             'data'     => $this->collection,
         ];

+ 19 - 14
app/Api/v1/Resources/TwoFAccountExportResource.php

@@ -17,6 +17,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
  * @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,
+            ];
     }
 }

+ 4 - 2
app/Http/Controllers/SinglePageController.php

@@ -31,6 +31,7 @@ class SinglePageController extends Controller
         $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 @@ class SinglePageController extends Controller
                 'subdirectory' => $subdir,
             ])->toJson(),
             'urls' => collect([
-                'installDocUrl' => $installDocUrl,
-                'ssoDocUrl'     => $ssoDocUrl,
+                'installDocUrl'   => $installDocUrl,
+                'ssoDocUrl'       => $ssoDocUrl,
+                'exportSchemaUrl' => $exportSchemaUrl,
             ]),
             'defaultPreferences' => $defaultPreferences,
             'subdirectory'       => $subdir,

+ 1 - 0
config/2fauth.php

@@ -14,6 +14,7 @@ return [
     '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',
 
     /*
     |--------------------------------------------------------------------------

+ 1 - 1
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') }}
             </button>
         </p>

+ 39 - 0
resources/js/components/ExportButtons.vue

@@ -0,0 +1,39 @@
+<script setup>
+    import { UseColorMode  } from '@vueuse/components'
+
+    const router = useRouter()
+    const emit = defineEmits(['export-twofauth-format', 'export-otpauth-format'])
+    const $2fauth = inject('2fauth')
+
+</script>
+
+<template>
+    <div class="block">
+        <UseColorMode v-slot="{ mode }">
+            <p class="has-text-weight-bold has-text-grey">
+                {{ $t('twofaccounts.twofauth_export_format_sub') }}
+            </p>
+        </UseColorMode>
+        <p class="is-size-7-mobile">
+            {{ $t('twofaccounts.twofauth_export_format_desc') }}
+            {{ $t('twofaccounts.twofauth_export_format_url') }}
+            <a id="lnkExportSchemaUrl" class="is-link" tabindex="0" :href="$2fauth.urls.exportSchemaUrl" target="_blank">
+                {{ $t('twofaccounts.twofauth_export_schema') }}
+            </a>
+        </p>
+        <button id="btnExport2FAuth" class="button is-link is-rounded is-focus my-3" @click="$emit('export-twofauth-format')" :title="$t('twofaccounts.export_selected_to_json')">
+            {{ $t('twofaccounts.twofauth_export_format') }}
+        </button>
+    </div>
+    <div class="block">
+        <p class="has-text-weight-bold has-text-grey">
+            {{ $t('twofaccounts.otpauth_export_format_sub') }}
+        </p>
+        <p class="is-size-7-mobile">
+            {{ $t('twofaccounts.otpauth_export_format_desc') }}
+        </p>
+        <button id="btnExportOtpauth" class="button is-link is-rounded is-focus my-3" @click="$emit('export-otpauth-format')" :title="$t('twofaccounts.export_selected_to_otpauth_uri')">
+            {{ $t('twofaccounts.otpauth_export_format') }}
+        </button>
+    </div>
+</template>

+ 2 - 2
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 = {}) {

+ 19 - 6
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");
+                })
+            }
         },
 
         /**

+ 11 - 2
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 @@
                 </div>
             </div>
         </div>
-        <!-- modal -->
+        <!-- export modal -->
+        <Modal v-model="showExportFormatSelector" :isFullHeight="true">
+            <ExportButtons
+                @export-twofauth-format="twofaccounts.export()"
+                @export-otpauth-format="twofaccounts.export('otpauth')">
+            </ExportButtons>
+        </Modal>
+        <!-- otp modal -->
         <Modal v-model="showOtpInModal">
             <OtpDisplay
                 ref="otpDisplay"
@@ -467,7 +476,7 @@
                     :areDisabled="twofaccounts.hasNoneSelected"
                     @move-button-clicked="showDestinationGroupSelector = true"
                     @delete-button-clicked="deleteAccounts"
-                    @export-button-clicked="twofaccounts.export()">
+                    @export-button-clicked="showExportFormatSelector = true">
                 </ActionButtons>
             </VueFooter>
         </div>

+ 11 - 1
resources/lang/en/twofaccounts.php

@@ -28,7 +28,17 @@ return [
     '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' => [

+ 40 - 1
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 @@ class TwoFAccountControllerTest extends FeatureTestCase
             ->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()
     {

+ 126 - 0
tests/Api/v1/Requests/TwoFAccountExportRequestTest.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace Tests\Api\v1\Requests;
+
+use App\Api\v1\Requests\TwoFAccountExportRequest;
+use Illuminate\Foundation\Testing\WithoutMiddleware;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Validator;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use Tests\TestCase;
+
+/**
+ * TwoFAccountExportRequestTest test class
+ */
+#[CoversClass(TwoFAccountExportRequest::class)]
+class TwoFAccountExportRequestTest extends TestCase
+{
+    use WithoutMiddleware;
+
+    #[Test]
+    public function test_user_is_authorized()
+    {
+        Auth::shouldReceive('check')
+            ->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',
+            ]],
+        ];
+    }
+}