diff --git a/app/Api/v1/Requests/SettingUpdateRequest.php b/app/Api/v1/Requests/SettingUpdateRequest.php index a4f618f1..5f5b6d5c 100644 --- a/app/Api/v1/Requests/SettingUpdateRequest.php +++ b/app/Api/v1/Requests/SettingUpdateRequest.php @@ -2,6 +2,7 @@ namespace App\Api\v1\Requests; +use App\Rules\IsValideEmailList; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; @@ -24,8 +25,16 @@ public function authorize() */ public function rules() { - return [ - 'value' => 'required', + $rule = [ + 'value' => [ + 'required', + ] ]; + + if ($this->route()->parameter('settingName') == 'restrictList') { + $rule['value'][] = new IsValideEmailList; + } + + return $rule; } } diff --git a/app/Http/Requests/UserStoreRequest.php b/app/Http/Requests/UserStoreRequest.php index 16d6969f..5f6a09dd 100644 --- a/app/Http/Requests/UserStoreRequest.php +++ b/app/Http/Requests/UserStoreRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\ComplyWithEmailRestrictionPolicy; use Illuminate\Foundation\Http\FormRequest; class UserStoreRequest extends FormRequest @@ -24,8 +25,15 @@ public function authorize() public function rules() { return [ - 'name' => 'unique:App\Models\User,name|required|string|max:191', - 'email' => 'unique:App\Models\User,email|required|string|email|max:191', + 'name' => 'unique:App\Models\User,name|required|string|max:191', + 'email' => [ + 'unique:App\Models\User,email', + 'required', + 'string', + 'email', + 'max:191', + new ComplyWithEmailRestrictionPolicy, + ], 'password' => 'required|string|min:8|confirmed', ]; } diff --git a/app/Http/Requests/UserUpdateRequest.php b/app/Http/Requests/UserUpdateRequest.php index 6aa8da26..dc9e92c5 100644 --- a/app/Http/Requests/UserUpdateRequest.php +++ b/app/Http/Requests/UserUpdateRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\ComplyWithEmailRestrictionPolicy; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; @@ -37,6 +38,7 @@ public function rules() 'email', 'max:191', Rule::unique('users')->ignore($this->user()->id), + new ComplyWithEmailRestrictionPolicy, ], 'password' => 'required', ]; diff --git a/app/Rules/ComplyWithEmailRestrictionPolicy.php b/app/Rules/ComplyWithEmailRestrictionPolicy.php new file mode 100644 index 00000000..12bb8ee6 --- /dev/null +++ b/app/Rules/ComplyWithEmailRestrictionPolicy.php @@ -0,0 +1,42 @@ +translate(); + } + } + else { + if (! $validatesFilter || ! $validatesRegex) { + $fail('validation.custom.email.ComplyWithEmailRestrictionPolicy')->translate(); + } + } + } + } +} diff --git a/app/Rules/IsValideEmailList.php b/app/Rules/IsValideEmailList.php new file mode 100644 index 00000000..55ae6050 --- /dev/null +++ b/app/Rules/IsValideEmailList.php @@ -0,0 +1,29 @@ + 'email', + ] + )->passes(); + + if (! $pass) { + $fail('validation.custom.email.IsValidEmailList')->translate(); + } + } +} diff --git a/config/2fauth.php b/config/2fauth.php index 9afb9bd0..2b84131e 100644 --- a/config/2fauth.php +++ b/config/2fauth.php @@ -74,6 +74,7 @@ 'latestRelease' => false, 'disableRegistration' => false, 'enableSso' => true, + 'restrictRegistration' => false, ], /* diff --git a/resources/js/icons.js b/resources/js/icons.js index 7711642c..4a1fa8d0 100644 --- a/resources/js/icons.js +++ b/resources/js/icons.js @@ -46,6 +46,7 @@ import { faFileLines, faVideoSlash, faChevronRight, + faSlash, } from '@fortawesome/free-solid-svg-icons' import { @@ -107,6 +108,7 @@ library.add( faChevronRight, faOpenid, faPaperPlane, + faSlash, ); export default FontAwesomeIcon \ No newline at end of file diff --git a/resources/js/services/appSettingService.js b/resources/js/services/appSettingService.js index 2905554d..e7743a98 100644 --- a/resources/js/services/appSettingService.js +++ b/resources/js/services/appSettingService.js @@ -3,6 +3,14 @@ import { httpClientFactory } from '@/services/httpClientFactory' const apiClient = httpClientFactory('api') export default { + /** + * + * @returns + */ + get(config = {}) { + return apiClient.get('/settings', { ...config }) + }, + /** * * @returns @@ -11,4 +19,11 @@ export default { return apiClient.put('/settings/' + name, { value: value }) }, + /** + * + * @returns + */ + delete(name, config = {}) { + return apiClient.delete('/settings/' + name, { ...config }) + }, } \ No newline at end of file diff --git a/resources/js/views/admin/AppSetup.vue b/resources/js/views/admin/AppSetup.vue index 3d9187a0..16ca23d3 100644 --- a/resources/js/views/admin/AppSetup.vue +++ b/resources/js/views/admin/AppSetup.vue @@ -17,6 +17,19 @@ const infos = ref() const listInfos = ref(null) const isSendingTestEmail = ref(false) + const fieldErrors = ref({ + restrictList: null, + restrictRule: null, + }) + const _settings = ref({ + checkForUpdate: appSettings.checkForUpdate, + useEncryption: appSettings.useEncryption, + restrictRegistration: appSettings.restrictRegistration, + restrictList: appSettings.restrictList, + restrictRule: appSettings.restrictRule, + disableRegistration: appSettings.disableRegistration, + enableSso: appSettings.enableSso, + }) /** * Saves a setting on the backend @@ -24,9 +37,46 @@ * @param {any} value */ function saveSetting(setting, value) { + fieldErrors.value[setting] = null + appSettingService.update(setting, value).then(response => { + appSettings[setting] = value useNotifyStore().success({ type: 'is-success', text: trans('settings.forms.setting_saved') }) }) + .catch(error => { + if( error.response.status === 422 ) { + fieldErrors.value[setting] = error.response.data.message + } + else { + notify.error(error); + } + }) + } + + /** + * Saves a setting on the backend + * @param {string} preference + * @param {any} value + */ + function saveOrDeleteSetting(setting, value) { + if (value == '') { + fieldErrors.value[setting] = null + + appSettingService.delete(setting, { returnError: true }).then(response => { + appSettings[setting] = '' + useNotifyStore().success({ type: 'is-success', text: trans('settings.forms.setting_saved') }) + }) + .catch(error => { + // appSettings[setting] = oldValue + + if( error.response.status !== 404 ) { + notify.error(error); + } + }) + } + else { + saveSetting(setting, value) + } } /** @@ -47,7 +97,23 @@ } }) - onMounted(() => { + onMounted(async () => { + appSettingService.get({ returnError: true }) + .then(response => { + // we reset those two because they are not registered on server side + // in order to be able to set them to blank + _settings.value.restrictList = '' + _settings.value.restrictRule = '' + + response.data.forEach(setting => { + appSettings[setting.key] = setting.value + _settings.value[setting.key] = setting.value + }) + }) + .catch(error => { + notify.alert({ text: trans('errors.data_cannot_be_refreshed_from_server') }) + }) + systemService.getSystemInfos({returnError: true}).then(response => { infos.value = response.data.common }) @@ -66,7 +132,7 @@

{{ $t('settings.general') }}

- +
@@ -86,12 +152,18 @@

{{ $t('settings.security') }}

- +

{{ $t('admin.registrations') }}

+ + + + + + - + - +

{{ $t('commons.environment') }}

diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 9c753b94..f5f43afb 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -65,9 +65,21 @@ 'security_devices_succesfully_revoked' => 'User\'s security devices successfully revoked', 'forms' => [ 'use_encryption' => [ - 'label' => 'Protect sensible data', + 'label' => 'Protect sensitive data', 'help' => 'Sensitive data, the 2FA secrets and emails, are stored encrypted in database. Be sure to backup the APP_KEY value of your .env file (or the whole file) as it serves as key encryption. There is no way to decypher encrypted data without this key.', ], + 'restrict_registration' => [ + 'label' => 'Restrict registration', + 'help' => 'Make registration only available to a limited range of email addresses. Both rules can be used simultaneously.', + ], + 'restrict_list' => [ + 'label' => 'Filtering list', + 'help' => 'Emails in this list will be allowed to register. Separate addresses with a pipe ("|")', + ], + 'restrict_rule' => [ + 'label' => 'Filtering rule', + 'help' => 'Emails matching this regular expression will be allowed to register', + ], 'disable_registration' => [ 'label' => 'Disable registration', 'help' => 'Prevent new user registration. This affects SSO as well, so new SSO users won\'t be able to sign on', diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index d56d0324..b49f6f93 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -170,6 +170,8 @@ ], 'email' => [ 'exists' => 'No account found using this email.', + 'ComplyWithEmailRestrictionPolicy' => 'This email address does not comply with the registration policy', + 'IsValidEmailList' => 'All emails must be valid and separated with a pipe' ], 'secret' => [ 'isBase32Encoded' => 'The :attribute must be a base32 encoded string.', diff --git a/tests/Feature/Http/Auth/RegisterControllerTest.php b/tests/Feature/Http/Auth/RegisterControllerTest.php index 8d36c989..a4e3d49c 100644 --- a/tests/Feature/Http/Auth/RegisterControllerTest.php +++ b/tests/Feature/Http/Auth/RegisterControllerTest.php @@ -21,8 +21,16 @@ class RegisterControllerTest extends FeatureTestCase private const EMAIL = 'johndoe@example.org'; + private const EMAIL_NOT_IN_FILTERING_LIST = 'jane@example.org'; + + private const EMAIL_EXCLUDED_BY_FILTERING_RULE = 'johndoe@anywhere.org'; + private const PASSWORD = 'password'; + private const EMAIL_FILTERING_LIST = 'johndoe@example.org|johndoe@test.org|johndoe@anywhere.org'; + + private const EMAIL_FILTERING_RULE = '^[A-Za-z0-9._%+-]+@example\.org'; + /** * @test */ @@ -147,4 +155,112 @@ public function test_register_is_forbidden_when_registration_is_disabled() ]) ->assertStatus(403); } + + /** + * @test + */ + public function test_register_succeeds_when_email_is_in_restricted_list() + { + Settings::set('restrictRegistration', true); + Settings::set('restrictList', self::EMAIL_FILTERING_LIST); + Settings::set('restrictRule', ''); + + $this->json('POST', '/user', [ + 'name' => self::USERNAME, + 'email' => self::EMAIL, + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + ]) + ->assertStatus(201); + } + + /** + * @test + */ + public function test_register_fails_when_email_is_not_in_restricted_list() + { + Settings::set('restrictRegistration', true); + Settings::set('restrictList', self::EMAIL_FILTERING_LIST); + Settings::set('restrictRule', ''); + + $this->json('POST', '/user', [ + 'name' => self::USERNAME, + 'email' => self::EMAIL_NOT_IN_FILTERING_LIST, + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + ]) + ->assertStatus(422); + } + + /** + * @test + */ + public function test_register_succeeds_when_email_matchs_filtering_rule() + { + Settings::set('restrictRegistration', true); + Settings::set('restrictList', ''); + Settings::set('restrictRule', self::EMAIL_FILTERING_RULE); + + $this->json('POST', '/user', [ + 'name' => self::USERNAME, + 'email' => self::EMAIL, + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + ]) + ->assertStatus(201); + } + + /** + * @test + */ + public function test_register_fails_when_email_does_not_match_filtering_rule() + { + Settings::set('restrictRegistration', true); + Settings::set('restrictList', ''); + Settings::set('restrictRule', self::EMAIL_FILTERING_RULE); + + $this->json('POST', '/user', [ + 'name' => self::USERNAME, + 'email' => self::EMAIL_EXCLUDED_BY_FILTERING_RULE, + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + ]) + ->assertStatus(422); + } + + /** + * @test + */ + public function test_register_succeeds_when_email_is_allowed_by_list_over_regex() + { + Settings::set('restrictRegistration', true); + Settings::set('restrictList', self::EMAIL_FILTERING_LIST); + Settings::set('restrictRule', self::EMAIL_FILTERING_RULE); + + $this->json('POST', '/user', [ + 'name' => self::USERNAME, + 'email' => self::EMAIL_EXCLUDED_BY_FILTERING_RULE, + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + ]) + ->assertStatus(201); + } + + /** + * @test + */ + public function test_register_succeeds_when_email_is_allowed_by_regex_over_list() + { + Settings::set('restrictRegistration', true); + Settings::set('restrictList', self::EMAIL_FILTERING_LIST); + Settings::set('restrictRule', self::EMAIL_FILTERING_RULE); + + $this->json('POST', '/user', [ + 'name' => self::USERNAME, + 'email' => self::EMAIL_NOT_IN_FILTERING_LIST, + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + ]) + ->assertStatus(201); + } }