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

This commit is contained in:
Bubka 2024-11-08 08:41:11 +01:00
parent 63a700da44
commit 3e2a80b816
14 changed files with 309 additions and 31 deletions

View File

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

View File

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

View File

@ -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,
];

View File

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

View File

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

View File

@ -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',
/*
|--------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

@ -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");
})
}
},
/**

View File

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

View File

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

View File

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

View File

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