mirror of
https://github.com/Bubka/2FAuth.git
synced 2025-02-02 11:39:19 +01:00
Add ability to export data as otpauth URIs - Closes #386
This commit is contained in:
parent
63a700da44
commit
3e2a80b816
@ -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();
|
||||
|
||||
|
33
app/Api/v1/Requests/TwoFAccountExportRequest.php
Normal file
33
app/Api/v1/Requests/TwoFAccountExportRequest.php
Normal 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',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
];
|
||||
|
@ -17,6 +17,7 @@
|
||||
* @property int|null $period
|
||||
* @property int|null $counter
|
||||
* @property string $legacy_uri
|
||||
* @method string getURI()
|
||||
*/
|
||||
class TwoFAccountExportResource extends JsonResource
|
||||
{
|
||||
@ -28,7 +29,11 @@ class TwoFAccountExportResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request)
|
||||
{
|
||||
return [
|
||||
return $request->has('otpauth') && $request->boolean('otpauth')
|
||||
? [
|
||||
'uri' => urldecode($this->getURI()),
|
||||
]
|
||||
: [
|
||||
'otp_type' => $this->otp_type,
|
||||
'account' => $this->account,
|
||||
'service' => $this->service,
|
||||
|
@ -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)
|
||||
|
||||
@ -48,6 +49,7 @@ public function index()
|
||||
'urls' => collect([
|
||||
'installDocUrl' => $installDocUrl,
|
||||
'ssoDocUrl' => $ssoDocUrl,
|
||||
'exportSchemaUrl' => $exportSchemaUrl,
|
||||
]),
|
||||
'defaultPreferences' => $defaultPreferences,
|
||||
'subdirectory' => $subdir,
|
||||
|
@ -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',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
@ -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
resources/js/components/ExportButtons.vue
Normal file
39
resources/js/components/ExportButtons.vue
Normal 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>
|
4
resources/js/services/twofaccountService.js
vendored
4
resources/js/services/twofaccountService.js
vendored
@ -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 = {}) {
|
||||
|
17
resources/js/stores/twofaccounts.js
vendored
17
resources/js/stores/twofaccounts.js
vendored
@ -163,12 +163,25 @@ export const useTwofaccounts = defineStore({
|
||||
/**
|
||||
* Exports selected accounts to a json file
|
||||
*/
|
||||
export() {
|
||||
twofaccountService.export(this.selectedIds.join(), {responseType: 'blob'})
|
||||
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");
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -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>
|
||||
|
@ -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' => [
|
||||
|
@ -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()
|
||||
{
|
||||
|
126
tests/Api/v1/Requests/TwoFAccountExportRequestTest.php
Normal file
126
tests/Api/v1/Requests/TwoFAccountExportRequestTest.php
Normal 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',
|
||||
]],
|
||||
];
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user