Add an email registration policy feature - Closes #250

This commit is contained in:
Bubka 2024-02-29 13:42:28 +01:00
parent fd5520c1cf
commit 3eed7c8f5b
12 changed files with 320 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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