diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ac93bd90a..3af13a0f5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,7 @@ - - - + diff --git a/frontend/src/api/account.js b/frontend/src/api/account.js index 65f2bd5a7..a3c2f17a5 100644 --- a/frontend/src/api/account.js +++ b/frontend/src/api/account.js @@ -5,5 +5,9 @@ const resource = 'account' export default { getMe () { return repository.get(`/${resource}/me/`) + }, + verifyTFACode (code) { + const payload = { code } + return repository.post(`${resource}/tfa/verify/`, payload) } } diff --git a/frontend/src/api/repository.js b/frontend/src/api/repository.js index b355c27f1..3c14f1bbd 100644 --- a/frontend/src/api/repository.js +++ b/frontend/src/api/repository.js @@ -25,13 +25,17 @@ _axios.interceptors.response.use( return response }, function (error) { - if (error.response.status !== 401) { + if (error.response.status === 418) { + router.push({ name: 'TwoFA' }) + return Promise.reject(error) + } + if (error.response.status !== 401 || router.currentRoute.path === '/login/') { return Promise.reject(error) } const refreshToken = Cookies.get('refreshToken') if (error.config.url.endsWith('/token/refresh/') || !refreshToken) { store.dispatch('auth/logout') - router.push('/login/') + router.push({ name: 'Login' }) return Promise.reject(error) } return _axios.post('/token/refresh/', { refresh: refreshToken }).then(resp => { diff --git a/frontend/src/components/domains/DomainCreationForm.vue b/frontend/src/components/domains/DomainCreationForm.vue index c3fc6a23a..1084f9a0a 100644 --- a/frontend/src/components/domains/DomainCreationForm.vue +++ b/frontend/src/components/domains/DomainCreationForm.vue @@ -20,6 +20,28 @@ + + + + + {{ item.key }} + {{ item.value|yesno }} + + + + {{ domain.domain_admin.password }} + + mdi-clipboard-multiple-outline + + + + + + + {{ item.key }} + {{ item.value|yesno }} + + @@ -80,6 +102,7 @@ export default { if (this.createAdmin) { result[3].items.push({ key: this.$gettext('Administrator name'), value: this.domain.domain_admin.username }) result[3].items.push({ + name: 'with_random_password', key: this.$gettext('Random password'), value: this.domain.domain_admin.with_random_password, type: 'yesno' @@ -117,6 +140,11 @@ export default { this.initDomain() }, methods: { + copyPassword () { + navigator.clipboard.writeText(this.domain.domain_admin.password).then(() => { + bus.$emit('notification', { msg: this.$gettext('Password copied to clipboard') }) + }) + }, initDomain () { this.domain = { name: '', @@ -154,6 +182,7 @@ export default { delete data.domain_admin } this.$store.dispatch('domains/createDomain', data).then(resp => { + this.$router.push({ name: 'DomainDetail', params: { id: resp.data.pk } }) bus.$emit('notification', { msg: this.$gettext('Domain created') }) this.close() }) @@ -161,3 +190,9 @@ export default { } } + + diff --git a/frontend/src/components/domains/DomainOptionsForm.vue b/frontend/src/components/domains/DomainOptionsForm.vue index fdc448edb..7129bd4fd 100644 --- a/frontend/src/components/domains/DomainOptionsForm.vue +++ b/frontend/src/components/domains/DomainOptionsForm.vue @@ -23,6 +23,7 @@ :label="'Random password' | translate" :disabled="!createAdmin" :hint="'Generate a random password for the administrator.' | translate" + @change="updatePassword" persistent-hint /> diff --git a/frontend/src/components/logs/LogList.vue b/frontend/src/components/logs/LogList.vue deleted file mode 100644 index 80e946fd4..000000000 --- a/frontend/src/components/logs/LogList.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - - - Logs - - - - - - - {{ props.item.date_created }} - - - {{ props.item.level }} - - - {{ props.item.level }} - - - {{ props.item.level }} - - - {{ props.item.logger }} - {{ props.item.message }} - - - - - - - diff --git a/frontend/src/components/parameters/ParametersForm.vue b/frontend/src/components/parameters/ParametersForm.vue index 465407196..6b7261e17 100644 --- a/frontend/src/components/parameters/ParametersForm.vue +++ b/frontend/src/components/parameters/ParametersForm.vue @@ -1,22 +1,28 @@ - - - - - {{ element.label }} - - - - - - - - + + + {{ title }} + + + + + {{ element.label }} + + + + + + + + + {{ param.label }} + + - - - - - - - - - mdi-content-save - - - + + + + + + + + + mdi-content-save + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 855650e31..4ff2f90dc 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -4,11 +4,18 @@ import VueRouter from 'vue-router' import store from '@/store' import ParametersForm from '@/components/parameters/ParametersForm' -import LogList from '@/components/logs/LogList' Vue.use(VueRouter) const routes = [ + { + path: '/', + name: 'Dashboard', + component: () => import(/* webpackChunkName: "dashboard" */ '../views/Dashboard.vue'), + meta: { + requiresAuth: true + } + }, { path: '/login', name: 'Login', @@ -17,28 +24,36 @@ const routes = [ // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ '../views/Login.vue') }, + { + path: '/twofa', + name: 'TwoFA', + component: () => import('../views/TwoFA.vue') + }, { path: '/domains', name: 'DomainList', - component: () => import('../views/Domains.vue'), + component: () => import('../views/domains/Domains.vue'), meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'] } }, { path: '/domains/:id', name: 'DomainDetail', - component: () => import('../views/Domain.vue'), + component: () => import('../views/domains/Domain.vue'), meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'] } }, { path: '/domains/:id/edit', name: 'DomainEdit', - component: () => import('../views/DomainEdit.vue'), + component: () => import('../views/domains/DomainEdit.vue'), meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'] } }, { @@ -46,7 +61,8 @@ const routes = [ name: 'Identities', component: () => import('../views/identities/Identities.vue'), meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'] } }, { @@ -54,7 +70,8 @@ const routes = [ name: 'AccountEdit', component: () => import('../views/identities/AccountEdit.vue'), meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'] } }, { @@ -62,7 +79,8 @@ const routes = [ name: 'AliasEdit', component: () => import('../views/identities/AliasEdit.vue'), meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'] } }, { @@ -70,15 +88,17 @@ const routes = [ name: 'ParametersEdit', component: ParametersForm, meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['SuperAdmins'] } }, { path: '/logs/', - name: 'LogList', - component: LogList, + name: 'AuditTrail', + component: () => import('../views/logs/AuditTrail.vue'), meta: { - requiresAuth: true + requiresAuth: true, + allowedRoles: ['SuperAdmins'] } } ] @@ -93,8 +113,14 @@ router.beforeEach((to, from, next) => { if (to.matched.some(record => record.meta.requiresAuth)) { store.dispatch('auth/initialize').then(() => { if (!store.getters['auth/isAuthenticated']) { - next('/login/') + next({ name: 'Login' }) } else { + if (to.meta.allowedRoles !== undefined) { + if (to.meta.allowedRoles.indexOf(store.getters['auth/authUser'].role) === -1) { + next({ name: 'Dashboard' }) + return + } + } next() } }) diff --git a/frontend/src/store/modules/domains.js b/frontend/src/store/modules/domains.js index 5ffd3781a..82ed70a1d 100644 --- a/frontend/src/store/modules/domains.js +++ b/frontend/src/store/modules/domains.js @@ -40,8 +40,13 @@ const actions = { }) }, createDomain ({ commit }, data) { - return domainApi.createDomain(data).then(response => { - commit(ADD_DOMAIN, { domain: response.data }) + return new Promise((resolve, reject) => { + domainApi.createDomain(data).then(response => { + commit(ADD_DOMAIN, { domain: response.data }) + resolve(response) + }).catch(error => { + reject(error) + }) }) }, updateDomain ({ commit }, data) { diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 000000000..5bbd1678b --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,7 @@ + + + + Welcome to Modoboa + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 310d6ab4e..a58591ab6 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,58 +1,73 @@ - - - - - Modoboa - - - - - - - - - - - Connect - - - - - + + + + + + Identification + + + Username + + + + Password + + + Forgot password? + + + + + Connect + + + + + + diff --git a/frontend/src/views/TwoFA.vue b/frontend/src/views/TwoFA.vue new file mode 100644 index 000000000..3c304b8e3 --- /dev/null +++ b/frontend/src/views/TwoFA.vue @@ -0,0 +1,68 @@ + + + + + + + Verification + + + Two-factor authentication code + + + + + + Verify code + + + + + + + diff --git a/frontend/src/views/Domain.vue b/frontend/src/views/domains/Domain.vue similarity index 97% rename from frontend/src/views/Domain.vue rename to frontend/src/views/domains/Domain.vue index 775057f4d..c48a6cbb3 100644 --- a/frontend/src/views/Domain.vue +++ b/frontend/src/views/domains/Domain.vue @@ -57,7 +57,7 @@ export default { }, data () { return { - domain: {}, + domain: { pk: this.$route.params.id }, showEditForm: false, showAdminList: false } diff --git a/frontend/src/views/DomainEdit.vue b/frontend/src/views/domains/DomainEdit.vue similarity index 100% rename from frontend/src/views/DomainEdit.vue rename to frontend/src/views/domains/DomainEdit.vue diff --git a/frontend/src/views/Domains.vue b/frontend/src/views/domains/Domains.vue similarity index 100% rename from frontend/src/views/Domains.vue rename to frontend/src/views/domains/Domains.vue diff --git a/frontend/src/views/identities/Identities.vue b/frontend/src/views/identities/Identities.vue index 88d79fd63..91628a81b 100644 --- a/frontend/src/views/identities/Identities.vue +++ b/frontend/src/views/identities/Identities.vue @@ -79,3 +79,9 @@ export default { } } + + diff --git a/frontend/src/views/logs/AuditTrail.vue b/frontend/src/views/logs/AuditTrail.vue new file mode 100644 index 000000000..08ba34ac1 --- /dev/null +++ b/frontend/src/views/logs/AuditTrail.vue @@ -0,0 +1,24 @@ + + + + Audit trail + + + + + + + + diff --git a/modoboa/admin/api/v1/serializers.py b/modoboa/admin/api/v1/serializers.py index a299d05a7..22159d87c 100644 --- a/modoboa/admin/api/v1/serializers.py +++ b/modoboa/admin/api/v1/serializers.py @@ -194,7 +194,7 @@ class AccountSerializer(serializers.ModelSerializer): fields = ( "pk", "username", "first_name", "last_name", "is_active", "master_user", "mailbox", "role", "language", "phone_number", - "secondary_email", "domains", + "secondary_email", "domains" ) def __init__(self, *args, **kwargs): diff --git a/modoboa/admin/api/v2/serializers.py b/modoboa/admin/api/v2/serializers.py index 297fe6051..b1fe670fc 100644 --- a/modoboa/admin/api/v2/serializers.py +++ b/modoboa/admin/api/v2/serializers.py @@ -1,5 +1,7 @@ """Admin API v2 serializers.""" +import os + from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ @@ -13,6 +15,7 @@ from modoboa.core import models as core_models, signals as core_signals from modoboa.lib import exceptions as lib_exceptions from modoboa.lib import fields as lib_fields from modoboa.lib import validators, web_utils +from modoboa.lib.sysutils import exec_cmd from modoboa.parameters import tools as param_tools from ... import constants, lib, models @@ -22,7 +25,7 @@ class CreateDomainAdminSerializer(serializers.Serializer): """Sub serializer for domain administrator creation.""" username = serializers.CharField() - with_random_password = serializers.BooleanField(default=False) + password = serializers.CharField(required=False) with_mailbox = serializers.BooleanField(default=False) with_aliases = serializers.BooleanField(default=False) @@ -57,9 +60,8 @@ class DomainSerializer(v1_serializers.DomainSerializer): core_signals.can_create_object.send( self.__class__, context=user, klass=models.Mailbox) da = core_models.User(username=username, email=username, is_active=True) - if domain_admin["with_random_password"]: - password = lib.make_password() - else: + password = domain_admin.get("password") + if password is None: password = param_tools.get_global_parameter( "default_password", app="core") da.set_password(password) @@ -132,6 +134,48 @@ class AdminGlobalParametersSerializer(serializers.Serializer): auto_create_domain_and_mailbox = serializers.BooleanField(default=True) create_alias_on_mbox_rename = serializers.BooleanField(default=False) + def validate_default_domain_quota(self, value): + """Ensure quota is a positive integer.""" + if value < 0: + raise serializers.ValidationError( + _("Must be a positive integer") + ) + return value + + def validate_default_mailbox_quota(self, value): + """Ensure quota is a positive integer.""" + if value < 0: + raise serializers.ValidationError( + _("Must be a positive integer") + ) + return value + + def validate_dkim_keys_storage_dir(self, value): + """Check that directory exists.""" + if value: + if not os.path.isdir(value): + raise serializers.ValidationError( + _("Directory not found.") + ) + code, output = exec_cmd("which openssl") + if code: + raise serializers.ValidationError( + _("openssl not found, please make sure it is installed.") + ) + return value + + def validate(self, data): + """Check MX options.""" + condition = ( + data.get("enable_mx_checks") and + data.get("domains_must_have_authorized_mx") and + not data.get("valid_mxs")) + if condition: + raise serializers.ValidationError({ + "valid_mxs": _("Define at least one authorized network / address") + }) + return data + class DomainAdminSerializer(serializers.ModelSerializer): """Serializer used for administrator related routes.""" @@ -184,8 +228,9 @@ class MailboxSerializer(serializers.ModelSerializer): def validate(self, data): """Check if quota is required.""" + method = self.context["request"].method if not data.get("use_domain_quota", False): - if "quota" not in data: + if "quota" not in data and method != "PATCH": raise serializers.ValidationError({ "quota": _("This field is required") }) diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index 4881222be..10ad2d391 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -137,7 +137,7 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filterset_class = AccountFilterSet def get_serializer_class(self): - if self.action in ["create", "validate"]: + if self.action in ["create", "validate", "update", "partial_update"]: return serializers.WritableAccountSerializer if self.action == "delete": return serializers.DeleteAccountSerializer diff --git a/modoboa/core/api/v2/serializers.py b/modoboa/core/api/v2/serializers.py index 846d458f8..b1659e65d 100644 --- a/modoboa/core/api/v2/serializers.py +++ b/modoboa/core/api/v2/serializers.py @@ -5,12 +5,14 @@ from django.utils.translation import ugettext_lazy, ugettext as _ from django.contrib.auth import password_validation +import django_otp from rest_framework import serializers from modoboa.lib import fields as lib_fields from ... import constants from ... import models +from ... import sms_backends class CoreGlobalParametersSerializer(serializers.Serializer): @@ -34,16 +36,39 @@ class CoreGlobalParametersSerializer(serializers.Serializer): default="sha512crypt" ) rounds_number = serializers.IntegerField(default=70000) + update_scheme = serializers.BooleanField(default=True) default_password = serializers.CharField(default="password") random_password_length = serializers.IntegerField(min_value=8, default=8) + update_password_url = serializers.URLField(required=False) + password_recovery_msg = serializers.CharField( + required=False, allow_blank=True) + sms_password_recovery = serializers.BooleanField(default=False) + sms_provider = serializers.ChoiceField( + choices=constants.SMS_BACKENDS, required=False) # LDAP settings ldap_server_address = serializers.CharField(default="localhost") ldap_server_port = serializers.IntegerField(default=389) + ldap_enable_secondary_server = serializers.BooleanField(default=False) + ldap_secondary_server_address = serializers.CharField(required=False) + ldap_secondary_server_port = serializers.IntegerField( + default=389, required=False) ldap_secured = serializers.ChoiceField( choices=constants.LDAP_SECURE_MODES, default="none" ) + ldap_is_active_directory = serializers.BooleanField(default=False) + ldap_admin_groups = serializers.CharField( + default="", required=False, allow_blank=True) + ldap_group_type = serializers.ChoiceField( + default="posixgroup", + choices=constants.LDAP_GROUP_TYPES + ) + ldap_groups_search_base = serializers.CharField( + default="", required=False, allow_blank=True) + ldap_password_attribute = serializers.CharField(default="userPassword") + + # LDAP auth settings ldap_auth_method = serializers.ChoiceField( choices=constants.LDAP_AUTH_METHODS, default="searchbind", @@ -59,16 +84,24 @@ class CoreGlobalParametersSerializer(serializers.Serializer): allow_blank=True) ldap_user_dn_template = serializers.CharField( default="", required=False, allow_blank=True) - ldap_password_attribute = serializers.CharField(default="userPassword") - ldap_is_active_directory = serializers.BooleanField(default=False) - ldap_admin_groups = serializers.CharField( - default="", required=False, allow_blank=True) - ldap_group_type = serializers.ChoiceField( - default="posixgroup", - choices=constants.LDAP_GROUP_TYPES + + # LDAP sync settings + ldap_sync_bind_dn = serializers.CharField(required=False) + ldap_sync_bind_password = serializers.CharField(required=False) + ldap_enable_sync = serializers.BooleanField(default=False) + ldap_sync_delete_remote_account = serializers.BooleanField(default=False) + ldap_sync_account_dn_template = serializers.CharField( + required=False, allow_blank=True) + ldap_enable_import = serializers.BooleanField(default=False) + ldap_import_search_base = serializers.CharField(required=False) + ldap_import_search_filter = serializers.CharField( + default="(cn=*)", required=False + ) + ldap_import_username_attr = serializers.CharField(default="cn") + ldap_dovecot_sync = serializers.BooleanField(default=False) + ldap_dovecot_conf_file = serializers.CharField( + default="/etc/dovecot/dovecot-modoboa.conf", required=False ) - ldap_groups_search_base = serializers.CharField( - default="", required=False, allow_blank=True) # Dashboard settings rss_feed_url = serializers.URLField(allow_blank=True) @@ -81,6 +114,8 @@ class CoreGlobalParametersSerializer(serializers.Serializer): # API settings enable_api_communication = serializers.BooleanField(default=True) check_new_versions = serializers.BooleanField(default=True) + send_new_versions_email = serializers.BooleanField(default=False) + new_versions_email_rcpt = lib_fields.DRFEmailFieldUTF8(required=False) send_statistics = serializers.BooleanField(default=True) # Misc settings @@ -98,6 +133,20 @@ class CoreGlobalParametersSerializer(serializers.Serializer): raise serializers.ValidationError(_("Invalid syntax")) return value + def validate_ldap_sync_account_dn_template(self, value): + try: + value % {"user": "toto"} + except (KeyError, ValueError): + raise serializers.ValidationError(_("Invalid syntax")) + return value + + def validate_ldap_search_filter(self, value): + try: + value % {"user": "toto"} + except (KeyError, ValueError, TypeError): + raise serializers.ValidationError(_("Invalid syntax")) + return value + def validate_rounds_number(self, value): if value < 1000 or value > 999999999: raise serializers.ValidationError(_("Invalid rounds number")) @@ -114,13 +163,24 @@ class CoreGlobalParametersSerializer(serializers.Serializer): Depending on 'ldap_auth_method' value, we check for different required parameters. """ + errors = {} + if data["sms_password_recovery"]: + provider = data.get("sms_provider") + if provider: + sms_settings = sms_backends.get_backend_settings(provider) + if sms_settings: + for name in sms_settings.keys(): + if not data.get(name): + errors[name] = _("This field is required") + else: + errors["sms_provider"] = _("This field is required") + if data["authentication_type"] != "ldap": return data if data["ldap_auth_method"] == "searchbind": required_fields = ["ldap_search_base", "ldap_search_filter"] else: required_fields = ["ldap_user_dn_template"] - errors = {} for f in required_fields: if data.get(f, u"") == u"": errors[f] = _("This field is required") @@ -140,3 +200,15 @@ class LogSerializer(serializers.ModelSerializer): def get_date_created(self, log) -> str: return formats.date_format(log.date_created, "SHORT_DATETIME_FORMAT") + + +class VerifyTFACodeSerializer(serializers.Serializer): + """Serializer used to verify 2FA code validity.""" + + code = serializers.CharField() + + def validate_code(self, value): + device = django_otp.match_token(self.context["user"], value) + if device is None: + raise serializers.ValidationError(_("This code is invalid")) + return device diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index f96600feb..314357bdb 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -1,11 +1,14 @@ """Core API v2 viewsets.""" +from drf_spectacular.utils import extend_schema from rest_framework import permissions, response, viewsets from rest_framework.decorators import action +from rest_framework_simplejwt.tokens import RefreshToken from modoboa.admin.api.v1 import serializers as admin_v1_serializers from modoboa.core.api.v1 import viewsets as core_v1_viewsets +from ... import constants from ... import models from . import serializers @@ -14,11 +17,26 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet): """Account viewset.""" @action(methods=["get"], detail=False) + @extend_schema(responses=admin_v1_serializers.AccountSerializer) def me(self, request): """Return information about connected user.""" serializer = admin_v1_serializers.AccountSerializer(request.user) return response.Response(serializer.data) + @action(methods=["post"], detail=False, url_path="tfa/verify") + def tfa_verify_code(self, request): + """Verify given code validity.""" + serializer = serializers.VerifyTFACodeSerializer( + data=request.data, context={"user": request.user}) + serializer.is_valid(raise_exception=True) + refresh = RefreshToken.for_user(request.user) + refresh[constants.TFA_DEVICE_TOKEN_KEY] = ( + serializer.validated_data["code"].persistent_id) + return response.Response({ + "refresh": str(refresh), + "access": str(refresh.access_token) + }) + class LogViewSet(viewsets.ReadOnlyModelViewSet): """Log viewset.""" diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index f4bc99eb2..b43917392 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -737,6 +737,13 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([ "inclusive." ) }), + ("update_scheme", { + "label": ugettext_lazy("Update password scheme at login"), + "help_text": ugettext_lazy( + "Update user password at login to use the default " + "password scheme" + ) + }), ("default_password", { "label": ugettext_lazy("Default password"), "help_text": ugettext_lazy( @@ -746,12 +753,41 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([ "label": ugettext_lazy("Random password length"), "help_text": ugettext_lazy( "Length of randomly generated passwords.") - }) + }), + ("update_password_url", { + "label": ugettext_lazy("Update password service URL"), + "help_text": ugettext_lazy( + "The URL of an external page where users will be able" + " to update their password. It applies only to non local" + " users, ie. those automatically created after a successful" + " external authentication (LDAP, SMTP)." + ) + }), + ("password_recovery_msg", { + "label": ugettext_lazy("Password recovery announcement"), + "help_text": ugettext_lazy( + "A temporary message that will be displayed on the " + "reset password page." + ) + }), + ("sms_password_recovery", { + "label": ugettext_lazy("Enable password recovery by SMS"), + "help_text": ugettext_lazy( + "Enable password recovery by SMS for users who filled " + "a phone number." + ) + }), + ("sms_provider", { + "label": ugettext_lazy("SMS provider"), + "display": "sms_password_recovery=true", + "help_text": ugettext_lazy( + "Choose a provider to send password recovery SMS" + ) + }), ]) }), ("ldap", { "label": _("LDAP"), - "display": "authentication_type=ldap", "params": collections.OrderedDict([ ("ldap_server_address", { "label": ugettext_lazy("Server address"), @@ -763,61 +799,32 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([ "help_text": ugettext_lazy( "The TCP port number used by the LDAP server") }), + ("ldap_enable_secondary_server", { + "label": ugettext_lazy("Enable secondary server (fallback)"), + "help_text": ugettext_lazy( + "Enable a secondary LDAP server which will be used " + "if the primary one fails" + ) + }), + ("ldap_secondary_server_address", { + "label": ugettext_lazy("Secondary server address"), + "display": "ldap_enable_secondary_server=true", + "help_text": ugettext_lazy( + "The IP address or the DNS name of the seondary LDAP server" + ) + }), + ("ldap_secondary_server_port", { + "label": ugettext_lazy("Secondary server port"), + "display": "ldap_enable_secondary_server=true", + "help_text": ugettext_lazy( + "The TCP port number used by the LDAP secondary server" + ) + }), ("ldap_secured", { "label": ugettext_lazy("Use a secured connection"), "help_text": ugettext_lazy( "Use an SSL/STARTTLS connection to access the LDAP server") }), - ("ldap_auth_method", { - "label": ugettext_lazy("Authentication method"), - "help_text": ugettext_lazy( - "Choose the authentication method to use"), - }), - ("ldap_bind_dn", { - "label": ugettext_lazy("Bind DN"), - "help_text": ugettext_lazy( - "The distinguished name to use when binding to the LDAP " - "server. Leave empty for an anonymous bind" - ), - "display": "ldap_auth_method=searchbind" - }), - ("ldap_bind_password", { - "label": ugettext_lazy("Bind password"), - "help_text": ugettext_lazy( - "The password to use when binding to the LDAP server " - "(with 'Bind DN')" - ), - "display": "ldap_auth_method=searchbind" - }), - ("ldap_search_base", { - "label": ugettext_lazy("Users search base"), - "help_text": ugettext_lazy( - "The distinguished name of the search base used to find " - "users" - ), - "display": "ldap_auth_method=searchbind" - }), - ("ldap_search_filter", { - "label": ugettext_lazy("Search filter"), - "help_text": ugettext_lazy( - "An optional filter string (e.g. '(objectClass=person)'). " - "In order to be valid, it must be enclosed in parentheses." - ), - "display": "ldap_auth_method=searchbind" - }), - ("ldap_user_dn_template", { - "label": ugettext_lazy("User DN template"), - "help_text": ugettext_lazy( - "The template used to construct a user's DN. It should " - "contain one placeholder (ie. %(user)s)" - ), - "display": "ldap_auth_method=directbind" - }), - ("ldap_password_attribute", { - "label": ugettext_lazy("Password attribute"), - "help_text": ugettext_lazy( - "The attribute used to store user passwords"), - }), ("ldap_is_active_directory", { "label": ugettext_lazy("Active Directory"), "help_text": ugettext_lazy( @@ -843,7 +850,154 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([ "The distinguished name of the search base used to find " "groups" ) - }) + }), + ("ldap_password_attribute", { + "label": ugettext_lazy("Password attribute"), + "help_text": ugettext_lazy( + "The attribute used to store user passwords"), + }), + ("ldap_auth_sep", { + "label": ugettext_lazy("LDAP authentication settings"), + "display": "authentication_type=ldap", + "separator": True + }), + ("ldap_auth_method", { + "label": ugettext_lazy("Authentication method"), + "display": "authentication_type=ldap", + "help_text": ugettext_lazy( + "Choose the authentication method to use"), + }), + ("ldap_bind_dn", { + "label": ugettext_lazy("Bind DN"), + "help_text": ugettext_lazy( + "The distinguished name to use when binding to the LDAP " + "server. Leave empty for an anonymous bind" + ), + "display": "authentication_type=ldap&ldap_auth_method=searchbind" + }), + ("ldap_bind_password", { + "label": ugettext_lazy("Bind password"), + "help_text": ugettext_lazy( + "The password to use when binding to the LDAP server " + "(with 'Bind DN')" + ), + "display": "authentication_type=ldap&ldap_auth_method=searchbind", + "password": True + }), + ("ldap_search_base", { + "label": ugettext_lazy("Users search base"), + "help_text": ugettext_lazy( + "The distinguished name of the search base used to find " + "users" + ), + "display": "authentication_type=ldap&ldap_auth_method=searchbind" + }), + ("ldap_search_filter", { + "label": ugettext_lazy("Search filter"), + "help_text": ugettext_lazy( + "An optional filter string (e.g. '(objectClass=person)'). " + "In order to be valid, it must be enclosed in parentheses." + ), + "display": "authentication_type=ldap&ldap_auth_method=searchbind" + }), + ("ldap_user_dn_template", { + "label": ugettext_lazy("User DN template"), + "help_text": ugettext_lazy( + "The template used to construct a user's DN. It should " + "contain one placeholder (ie. %(user)s)" + ), + "display": "authentication_type=ldap&ldap_auth_method=directbind" + }), + ("ldap_sync_sep", { + "label": ugettext_lazy("LDAP synchronization settings"), + "separator": True + }), + ("ldap_sync_bind_dn", { + "label": ugettext_lazy("Bind DN"), + "help_text": ugettext_lazy( + "The distinguished name to use when binding to the LDAP server. " + "Leave empty for an anonymous bind" + ) + }), + ("ldap_sync_bind_password", { + "label": ugettext_lazy("Bind password"), + "help_text": ugettext_lazy( + "The password to use when binding to the LDAP server " + "(with 'Bind DN')" + ), + "password": True + }), + ("ldap_enable_sync", { + "label": ugettext_lazy("Enable export to LDAP"), + "help_text": ugettext_lazy( + "Enable automatic synchronization between local database " + "and LDAP directory" + ) + }), + ("ldap_sync_delete_remote_account", { + "label": ugettext_lazy( + "Delete remote LDAP account when local account is deleted"), + "help_text": ugettext_lazy( + "Delete remote LDAP account when local account is deleted, " + "otherwise it will be disabled." + ), + "display": "ldap_enable_sync=true" + }), + ("ldap_sync_account_dn_template", { + "label": ugettext_lazy("Account DN template"), + "help_text": ugettext_lazy( + "The template used to construct an account's DN. It should " + "contain one placeholder (ie. %(user)s)" + ), + "display": "ldap_enable_sync=true" + }), + ("ldap_enable_import", { + "label": ugettext_lazy("Enable import from LDAP"), + "help_text": ugettext_lazy( + "Enable account synchronization from LDAP directory to " + "local database" + ) + }), + ("ldap_import_search_base", { + "label": ugettext_lazy("Users search base"), + "help_text": ugettext_lazy( + "The distinguished name of the search base used to find " + "users" + ), + "display": "ldap_enable_import=true" + }), + ("ldap_import_search_filter", { + "label": ugettext_lazy("Search filter"), + "help_text": ugettext_lazy( + "An optional filter string (e.g. '(objectClass=person)'). " + "In order to be valid, it must be enclosed in parentheses." + ), + "display": "ldap_enable_import=true" + }), + ("ldap_import_username_attr", { + "label": ugettext_lazy("Username attribute"), + "help_text": ugettext_lazy( + "The name of the LDAP attribute where the username can be " + "found." + ), + "display": "ldap_enable_import=true" + }), + ("ldap_dovecot_sync", { + "label": ugettext_lazy("Enable Dovecot LDAP sync"), + "help_text": ugettext_lazy( + "LDAP authentication settings will be applied to Dovecot " + "configuration." + ) + }), + ("ldap_dovecot_conf_file", { + "label": ugettext_lazy("Dovecot LDAP config file"), + "help_text": ugettext_lazy( + "Location of the configuration file which contains " + "Dovecot LDAP settings." + ), + "display": "ldap_dovecot_sync=true" + }), + ]) }), ("dashboard", { @@ -889,6 +1043,21 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([ "help_text": ugettext_lazy( "Automatically checks if a newer version is available") }), + ("send_new_versions_email", { + "label": ugettext_lazy( + "Send an email when new versions are found"), + "display": "check_new_versions=true", + "help_text": ugettext_lazy( + "Send an email to notify admins about new versions" + ) + }), + ("new_versions_email_rcpt", { + "label": ugettext_lazy("Recipient"), + "display": "check_new_versions=true", + "help_text": ugettext_lazy( + "Recipient of new versions notification emails." + ) + }), ("send_statistics", { "label": ugettext_lazy("Send statistics"), "display": "enable_api_communication=true", diff --git a/modoboa/core/constants.py b/modoboa/core/constants.py index 6b9fc6daf..52623b465 100644 --- a/modoboa/core/constants.py +++ b/modoboa/core/constants.py @@ -110,3 +110,5 @@ SMS_BACKENDS = [ if settings.DEBUG: SMS_BACKENDS.insert(1, ("dummy", ugettext_lazy("Dummy"))) + +TFA_DEVICE_TOKEN_KEY = "otp_device_id" diff --git a/modoboa/core/drf_authentication.py b/modoboa/core/drf_authentication.py new file mode 100644 index 000000000..af643b649 --- /dev/null +++ b/modoboa/core/drf_authentication.py @@ -0,0 +1,46 @@ +"""Custom authentication for DRF.""" + +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ + +from django_otp.models import Device +from rest_framework import status +from rest_framework.exceptions import APIException +from rest_framework_simplejwt.authentication import JWTAuthentication + +from . import constants + + +class NotVerified(APIException): + status_code = status.HTTP_418_IM_A_TEAPOT + default_detail = _('Missing 2FA verification.') + default_code = 'not_verified' + + +class JWTAuthenticationWith2FA(JWTAuthentication): + """Add 2FA support.""" + + def verify_user(self, request, user): + header = self.get_header(request) + raw_token = self.get_raw_token(header) + validated_token = self.get_validated_token(raw_token) + url_exceptions = ( + reverse("v2:account-tfa-verify-code"), + ) + if request.path in url_exceptions: + return + if constants.TFA_DEVICE_TOKEN_KEY in validated_token: + device_id = validated_token[constants.TFA_DEVICE_TOKEN_KEY] + device = Device.from_persistent_id(device_id) + if device is not None and device.user_id == user.pk: + return + raise NotVerified + + def authenticate(self, request): + result = super().authenticate(request) + if result is None: + return None + user, token = result + if user.tfa_enabled: + self.verify_user(request, user) + return user, token diff --git a/modoboa/parameters/tools.py b/modoboa/parameters/tools.py index b3bc4fa37..fb7409e73 100644 --- a/modoboa/parameters/tools.py +++ b/modoboa/parameters/tools.py @@ -120,19 +120,25 @@ class Registry(object): "parameters": [] } for name, config in list(sconfig["params"].items()): - field = serializer.fields[name] data = { "name": name, "label": config["label"], "help_text": config.get("help_text", ""), "display": config.get("display", ""), - "widget": field.__class__.__name__, } - if data["widget"] == "ChoiceField": - data["choices"] = [ - {"text": value, "value": key} - for key, value in field.choices.items() - ] + if config.get("separator"): + data["widget"] = "SeparatorField" + else: + field = serializer.fields[name] + if config.get("password"): + data["widget"] = "PasswordField" + else: + data["widget"] = field.__class__.__name__ + if data["widget"] == "ChoiceField": + data["choices"] = [ + {"text": value, "value": key} + for key, value in field.choices.items() + ] item["parameters"].append(data) result.append(item) return result