From 764b687904ca11e846c51263953f37e9122cf4cf Mon Sep 17 00:00:00 2001 From: Bubka <858858+Bubka@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:20:26 +0100 Subject: [PATCH] Add custom defaults & locking to user preferences - Closes #413 --- .env.testing | 12 +++ app/Api/v1/Controllers/UserController.php | 14 ++- app/Helpers/Helpers.php | 18 ++++ app/Http/Controllers/SinglePageController.php | 2 + app/Models/User.php | 2 +- bootstrap/app.php | 7 +- config/2fauth.php | 86 ++++++++++++------- .../components/formElements/FormCheckbox.vue | 9 +- .../js/components/formElements/FormField.vue | 9 +- .../components/formElements/FormLockField.vue | 9 +- .../formElements/FormPasswordField.vue | 7 +- .../js/components/formElements/FormSelect.vue | 9 +- .../components/formElements/FormTextarea.vue | 9 +- .../js/components/formElements/FormToggle.vue | 9 +- resources/js/stores/appSettings.js | 5 +- resources/js/stores/user.js | 10 +++ resources/js/views/settings/Options.vue | 59 +++++++------ resources/lang/en/settings.php | 13 +-- resources/lang/en/twofaccounts.php | 2 +- resources/views/landing.blade.php | 1 + .../Controllers/Auth/UserControllerTest.php | 39 ++++++++- tests/Unit/HelpersTest.php | 38 ++++++++ vite.version.js | 2 +- 23 files changed, 271 insertions(+), 100 deletions(-) diff --git a/.env.testing b/.env.testing index 86b3b38f..cdc7dd91 100644 --- a/.env.testing +++ b/.env.testing @@ -23,6 +23,18 @@ LOG_CHANNEL=stack DB_CONNECTION=testing DB_DATABASE=:memory: +# valid overriden default +USERPREF_DEFAULT__THEME=light + +# valid locked user preference +USERPREF_LOCKED__THEME=true + +# invalid locked user preference +USERPREF_LOCKED__DISPLAY_MODE= + +# malformed locked user preference +USERPREF_LOCKED___FORMAT_PASSWORD=false + # DB_CONNECTION=mysql # DB_HOST=localhost # DB_PORT=3306 diff --git a/app/Api/v1/Controllers/UserController.php b/app/Api/v1/Controllers/UserController.php index cc6841c6..2b335d67 100644 --- a/app/Api/v1/Controllers/UserController.php +++ b/app/Api/v1/Controllers/UserController.php @@ -33,8 +33,9 @@ class UserController extends Controller $preferences->each(function (mixed $item, string $key) use ($jsonPrefs) { $jsonPrefs->push([ - 'key' => $key, - 'value' => $item, + 'key' => $key, + 'value' => $item, + 'locked' => in_array($key, config('2fauth.lockedPreferences')), ]); }); @@ -53,8 +54,9 @@ class UserController extends Controller } return response()->json([ - 'key' => $preferenceName, - 'value' => $request->user()->preferences[$preferenceName], + 'key' => $preferenceName, + 'value' => $request->user()->preferences[$preferenceName], + 'locked' => in_array($preferenceName, config('2fauth.lockedPreferences')), ], 200); } @@ -69,6 +71,10 @@ class UserController extends Controller abort(404); } + if (in_array($preferenceName, config('2fauth.lockedPreferences'))) { + abort(403); + } + $validated = $request->validated(); $request->user()['preferences->' . $preferenceName] = $validated['value']; diff --git a/app/Helpers/Helpers.php b/app/Helpers/Helpers.php index fc5c5d0a..77bd5325 100644 --- a/app/Helpers/Helpers.php +++ b/app/Helpers/Helpers.php @@ -49,4 +49,22 @@ class Helpers { return Str::random($length) . '.' . $extension; } + + /** + * Defines preferences locked for change. + * This helper is only intended to be called from the 2FAuth config file. + */ + public static function lockedPreferences(array $preferences) : array + { + foreach ($preferences as $key => $value) { + $_key = $key === 'revealDottedOTP' ? 'revealDottedOtp' : $key; + $isLocked = envUnlessEmpty(Str::of($_key)->snake('_')->upper()->prepend('USERPREF_LOCKED__')->toString(), false); + + if ($isLocked) { + $lockedPreferences[] = $key; + } + } + + return $lockedPreferences ?? []; + } } diff --git a/app/Http/Controllers/SinglePageController.php b/app/Http/Controllers/SinglePageController.php index b5e499f0..209cff97 100644 --- a/app/Http/Controllers/SinglePageController.php +++ b/app/Http/Controllers/SinglePageController.php @@ -38,6 +38,7 @@ class SinglePageController extends Controller $proxyLogoutUrl = config('2fauth.config.proxyLogoutUrl') ? config('2fauth.config.proxyLogoutUrl') : false; $subdir = config('2fauth.config.appSubdirectory') ? '/' . config('2fauth.config.appSubdirectory') : ''; $defaultPreferences = collect(config('2fauth.preferences')); /** @phpstan-ignore-line */ + $lockedPreferences = collect(config('2fauth.lockedPreferences')); /** @phpstan-ignore-line */ $isDemoApp = config('2fauth.config.isDemoApp') ? 'true' : 'false'; $isTestingApp = config('2fauth.config.isTestingApp') ? 'true' : 'false'; $lang = App::getLocale(); @@ -69,6 +70,7 @@ class SinglePageController extends Controller 'exportSchemaUrl' => $exportSchemaUrl, ]), 'defaultPreferences' => $defaultPreferences, + 'lockedPreferences' => $lockedPreferences, 'subdirectory' => $subdir, 'isDemoApp' => $isDemoApp, 'isTestingApp' => $isTestingApp, diff --git a/app/Models/User.php b/app/Models/User.php index 47a97dfa..9af631ca 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -212,7 +212,7 @@ class User extends Authenticatable implements HasLocalePreference, WebAuthnAuthe public function getPreferencesAttribute($value) { $preferences = collect(config('2fauth.preferences'))->merge(json_decode($value)); /** @phpstan-ignore-line */ - + return $preferences; } diff --git a/bootstrap/app.php b/bootstrap/app.php index 29eaef8c..8695be52 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,15 +13,12 @@ if (! function_exists('envUnlessEmpty')) { /** - * @param string $key - * @param null $default - * * @return mixed|null */ - function envUnlessEmpty(string $key, $default = null) + function envUnlessEmpty(string $key, string|int|bool|float|null $default = null) { $result = env($key, $default); - if (is_string($result) && '' === $result) { + if ('' === $result) { $result = $default; } diff --git a/config/2fauth.php b/config/2fauth.php index 2644cbd0..8c013070 100644 --- a/config/2fauth.php +++ b/config/2fauth.php @@ -1,5 +1,47 @@ envUnlessEmpty('USERPREF_DEFAULT__SHOW_OTP_AS_DOT', false), + 'showNextOtp' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_NEXT_OTP', false), + 'revealDottedOTP' => envUnlessEmpty('USERPREF_DEFAULT__REVEAL_DOTTED_OTP', false), + 'closeOtpOnCopy' => envUnlessEmpty('USERPREF_DEFAULT__CLOSE_OTP_ON_COPY', false), + 'copyOtpOnDisplay' => envUnlessEmpty('USERPREF_DEFAULT__COPY_OTP_ON_DISPLAY', false), + 'clearSearchOnCopy' => envUnlessEmpty('USERPREF_DEFAULT__CLEAR_SEARCH_ON_COPY', false), + 'useBasicQrcodeReader' => envUnlessEmpty('USERPREF_DEFAULT__USE_BASIC_QRCODE_READER', false), + 'displayMode' => envUnlessEmpty('USERPREF_DEFAULT__DISPLAY_MODE', 'list'), + 'showAccountsIcons' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_ACCOUNTS_ICONS', true), + 'kickUserAfter' => envUnlessEmpty('USERPREF_DEFAULT__KICK_USER_AFTER', 15), + 'activeGroup' => 0, + 'rememberActiveGroup' => envUnlessEmpty('USERPREF_DEFAULT__REMEMBER_ACTIVE_GROUP', true), + 'viewDefaultGroupOnCopy' => envUnlessEmpty('USERPREF_DEFAULT__VIEW_DEFAULT_GROUP_ON_COPY', false), + 'defaultGroup' => 0, + 'defaultCaptureMode' => envUnlessEmpty('USERPREF_DEFAULT__DEFAULT_CAPTURE_MODE', 'livescan'), + 'useDirectCapture' => envUnlessEmpty('USERPREF_DEFAULT__USE_DIRECT_CAPTURE', false), + 'useWebauthnOnly' => envUnlessEmpty('USERPREF_DEFAULT__USE_WEBAUTHN_ONLY', false), + 'getOfficialIcons' => envUnlessEmpty('USERPREF_DEFAULT__GET_OFFICIAL_ICONS', true), + 'theme' => envUnlessEmpty('USERPREF_DEFAULT__THEME', 'system'), + 'formatPassword' => envUnlessEmpty('USERPREF_DEFAULT__FORMAT_PASSWORD', true), + 'formatPasswordBy' => envUnlessEmpty('USERPREF_DEFAULT__FORMAT_PASSWORD_BY', 0.5), + 'lang' => envUnlessEmpty('USERPREF_DEFAULT__LANG', 'browser'), + 'getOtpOnRequest' => envUnlessEmpty('USERPREF_DEFAULT__GET_OTP_ON_REQUEST', true), + 'notifyOnNewAuthDevice' => envUnlessEmpty('USERPREF_DEFAULT__NOTIFY_ON_NEW_AUTH_DEVICE', false), + 'notifyOnFailedLogin' => envUnlessEmpty('USERPREF_DEFAULT__NOTIFY_ON_FAILED_LOGIN', false), + 'timezone' => envUnlessEmpty('USERPREF_DEFAULT__TIMEZONE', 'UTC'), + 'sortCaseSensitive' => envUnlessEmpty('USERPREF_DEFAULT__SORT_CASE_SENSITIVE', false), + 'autoCloseTimeout' => envUnlessEmpty('USERPREF_DEFAULT__AUTO_CLOSE_TIMEOUT', 2), + 'AutoSaveQrcodedAccount' => envUnlessEmpty('USERPREF_DEFAULT__AUTO_SAVE_QRCODED_ACCOUNT', false), + 'showEmailInFooter' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_EMAIL_IN_FOOTER', true), +]; + +$nonLockablePreferences = [ + 'activeGroup', + 'defaultGroup', + 'useWebauthnOnly', +]; + return [ /* @@ -107,37 +149,17 @@ return [ | */ - 'preferences' => [ - 'showOtpAsDot' => false, - 'showNextOtp' => false, - 'revealDottedOTP' => false, - 'closeOtpOnCopy' => false, - 'copyOtpOnDisplay' => false, - 'clearSearchOnCopy' => false, - 'useBasicQrcodeReader' => false, - 'displayMode' => 'list', - 'showAccountsIcons' => true, - 'kickUserAfter' => 15, - 'activeGroup' => 0, - 'rememberActiveGroup' => true, - 'viewDefaultGroupOnCopy' => false, - 'defaultGroup' => 0, - 'defaultCaptureMode' => 'livescan', - 'useDirectCapture' => false, - 'useWebauthnOnly' => false, - 'getOfficialIcons' => true, - 'theme' => 'system', - 'formatPassword' => true, - 'formatPasswordBy' => 0.5, - 'lang' => 'browser', - 'getOtpOnRequest' => true, - 'notifyOnNewAuthDevice' => false, - 'notifyOnFailedLogin' => false, - 'timezone' => env('APP_TIMEZONE', 'UTC'), - 'sortCaseSensitive' => false, - 'autoCloseTimeout' => 2, - 'AutoSaveQrcodedAccount' => false, - 'showEmailInFooter' => true, - ], + 'preferences' => $preferences, + + + /* + |-------------------------------------------------------------------------- + | List of user preferences locked against user customization + | These settings cannot be overloaded and persisted by each user + |-------------------------------------------------------------------------- + | + */ + + 'lockedPreferences' => Helpers::lockedPreferences(Arr::except($preferences, $nonLockablePreferences)), ]; diff --git a/resources/js/components/formElements/FormCheckbox.vue b/resources/js/components/formElements/FormCheckbox.vue index 14d9d7b0..f33f80b0 100644 --- a/resources/js/components/formElements/FormCheckbox.vue +++ b/resources/js/components/formElements/FormCheckbox.vue @@ -26,6 +26,7 @@ }, isIndented: Boolean, isDisabled: Boolean, + isLocked: Boolean, }) const emit = defineEmits(['update:modelValue']) @@ -49,12 +50,14 @@