浏览代码

Add an email registration policy feature - Closes #250

Bubka 1 年之前
父节点
当前提交
3eed7c8f5b

+ 11 - 2
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 @@ class SettingUpdateRequest extends FormRequest
      */
     public function rules()
     {
-        return [
-            'value' => 'required',
+        $rule = [
+            'value' => [
+                'required',
+            ]
         ];
+
+        if ($this->route()->parameter('settingName') == 'restrictList') {
+            $rule['value'][] = new IsValideEmailList;
+        }
+
+        return $rule;
     }
 }

+ 10 - 2
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 @@ class UserStoreRequest extends FormRequest
     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',
         ];
     }

+ 2 - 0
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 @@ class UserUpdateRequest extends FormRequest
                 'email',
                 'max:191',
                 Rule::unique('users')->ignore($this->user()->id),
+                new ComplyWithEmailRestrictionPolicy,
             ],
             'password' => 'required',
         ];

+ 42 - 0
app/Rules/ComplyWithEmailRestrictionPolicy.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Rules;
+
+use App\Facades\Settings;
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class ComplyWithEmailRestrictionPolicy implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        $list  = Settings::get('restrictList');
+        $regex = Settings::get('restrictRule');
+        
+        $validatesFilter = true;
+        $validatesRegex  = true;
+
+        if (Settings::get('restrictRegistration') == true) {
+            if ($list && ! in_array($value, explode('|', $list))) {
+                $validatesFilter = false;
+            }
+            if ($regex && ! preg_match('/' . $regex . '/', $value)) {
+                $validatesRegex = false;
+            }
+
+            if ($list && $regex) {
+                if (! $validatesFilter && ! $validatesRegex) {
+                    $fail('validation.custom.email.ComplyWithEmailRestrictionPolicy')->translate();
+                }
+            }
+            else {
+                if (! $validatesFilter || ! $validatesRegex) {
+                    $fail('validation.custom.email.ComplyWithEmailRestrictionPolicy')->translate();
+                }
+            }
+        }
+    }
+}

+ 29 - 0
app/Rules/IsValideEmailList.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+use Illuminate\Support\Facades\Validator;
+
+class IsValideEmailList implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        $emails = explode('|', $value);
+
+        $pass = Validator::make(
+            $emails,
+            [
+                '*' => 'email',
+            ]
+        )->passes();
+
+        if (! $pass) {
+            $fail('validation.custom.email.IsValidEmailList')->translate();
+        }
+    }
+}

+ 1 - 0
config/2fauth.php

@@ -74,6 +74,7 @@ return [
         'latestRelease' => false,
         'disableRegistration' => false,
         'enableSso' => true,
+        'restrictRegistration' => false,
     ],
 
     /*

+ 2 - 0
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

+ 15 - 0
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 })
+    },
 }

+ 77 - 5
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 @@
                 <form>
                     <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.general') }}</h4>
                     <!-- Check for update -->
-                    <FormCheckbox v-model="appSettings.checkForUpdate" @update:model-value="val => saveSetting('checkForUpdate', val)" fieldName="checkForUpdate" label="commons.check_for_update" help="commons.check_for_update_help" />
+                    <FormCheckbox v-model="_settings.checkForUpdate" @update:model-value="val => saveSetting('checkForUpdate', val)" fieldName="checkForUpdate" label="commons.check_for_update" help="commons.check_for_update_help" />
                     <VersionChecker />
                     <div class="field">
                         <!-- <h5 class="title is-5">{{ $t('settings.security') }}</h5> -->
@@ -86,12 +152,18 @@
                     </div>                   
                     <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.security') }}</h4>
                     <!-- protect db -->
-                    <FormCheckbox v-model="appSettings.useEncryption" @update:model-value="val => saveSetting('useEncryption', val)" fieldName="useEncryption" label="admin.forms.use_encryption.label" help="admin.forms.use_encryption.help" />
+                    <FormCheckbox v-model="_settings.useEncryption" @update:model-value="val => saveSetting('useEncryption', val)" fieldName="useEncryption" label="admin.forms.use_encryption.label" help="admin.forms.use_encryption.help" />
                     <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.registrations') }}</h4>
+                    <!-- restrict registration -->
+                    <FormCheckbox v-model="_settings.restrictRegistration" @update:model-value="val => saveSetting('restrictRegistration', val)" fieldName="restrictRegistration" :isDisabled="appSettings.disableRegistration" label="admin.forms.restrict_registration.label" help="admin.forms.restrict_registration.help" />
+                        <!-- restrict list -->
+                        <FormField v-model="_settings.restrictList" @change:model-value="val => saveOrDeleteSetting('restrictList', val)" :fieldError="fieldErrors.restrictList" fieldName="restrictList" :isDisabled="!appSettings.restrictRegistration || appSettings.disableRegistration" label="admin.forms.restrict_list.label" help="admin.forms.restrict_list.help" :isIndented="true" />
+                        <!-- restrict rule -->
+                        <FormField v-model="_settings.restrictRule" @change:model-value="val => saveOrDeleteSetting('restrictRule', val)" :fieldError="fieldErrors.restrictRule" fieldName="restrictRule" :isDisabled="!appSettings.restrictRegistration || appSettings.disableRegistration" label="admin.forms.restrict_rule.label" help="admin.forms.restrict_rule.help" :isIndented="true" leftIcon="slash" rightIcon="slash" />
                     <!-- disable registration -->
-                    <FormCheckbox v-model="appSettings.disableRegistration" @update:model-value="val => saveSetting('disableRegistration', val)" fieldName="disableRegistration" label="admin.forms.disable_registration.label" help="admin.forms.disable_registration.help" />
+                    <FormCheckbox v-model="_settings.disableRegistration" @update:model-value="val => saveSetting('disableRegistration', val)" fieldName="disableRegistration" label="admin.forms.disable_registration.label" help="admin.forms.disable_registration.help" />
                     <!-- disable SSO registration -->
-                    <FormCheckbox v-model="appSettings.enableSso" @update:model-value="val => saveSetting('enableSso', val)" fieldName="enableSso" label="admin.forms.enable_sso.label" help="admin.forms.enable_sso.help" />
+                    <FormCheckbox v-model="_settings.enableSso" @update:model-value="val => saveSetting('enableSso', val)" fieldName="enableSso" label="admin.forms.enable_sso.label" help="admin.forms.enable_sso.help" />
                 </form>
                 <h4 class="title is-4 pt-5 has-text-grey-light">{{ $t('commons.environment') }}</h4>
                 <div v-if="infos" class="about-debug box is-family-monospace is-size-7">

+ 13 - 1
resources/lang/en/admin.php

@@ -65,9 +65,21 @@ return [
     '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',

+ 2 - 0
resources/lang/en/validation.php

@@ -170,6 +170,8 @@ return [
         ],
         '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.',

+ 116 - 0
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 @@ class RegisterControllerTest extends FeatureTestCase
         ])
             ->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);
+    }
 }