mirror of
https://github.com/Bubka/2FAuth.git
synced 2025-03-27 06:46:45 +01:00
Add an email registration policy feature - Closes #250
This commit is contained in:
parent
fd5520c1cf
commit
3eed7c8f5b
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
@ -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',
|
||||
];
|
||||
|
42
app/Rules/ComplyWithEmailRestrictionPolicy.php
Normal file
42
app/Rules/ComplyWithEmailRestrictionPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
app/Rules/IsValideEmailList.php
Normal file
29
app/Rules/IsValideEmailList.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@ -74,6 +74,7 @@
|
||||
'latestRelease' => false,
|
||||
'disableRegistration' => false,
|
||||
'enableSso' => true,
|
||||
'restrictRegistration' => false,
|
||||
],
|
||||
|
||||
/*
|
||||
|
2
resources/js/icons.js
vendored
2
resources/js/icons.js
vendored
@ -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
resources/js/services/appSettingService.js
vendored
15
resources/js/services/appSettingService.js
vendored
@ -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 })
|
||||
},
|
||||
}
|
@ -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">
|
||||
|
@ -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',
|
||||
|
@ -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.',
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user