Added random password generator. (#1225)

* Added random password generator.

see #1219

* Fixed unit test.

see #1219

* Fixed typo.

see #1219

* Increased coverage.

see #1219
This commit is contained in:
Antoine Nguyen
2017-09-07 18:18:43 +02:00
committed by GitHub
parent f4aea6b6e0
commit 0dd55069bc
12 changed files with 200 additions and 41 deletions
+39 -23
View File
@@ -25,6 +25,7 @@ from modoboa.lib.validators import validate_utf8_email
from modoboa.lib.web_utils import render_to_json_response
from modoboa.parameters import tools as param_tools
from .. import lib
from .. import models
from .. import signals
@@ -45,8 +46,17 @@ class AccountFormGeneral(forms.ModelForm):
choices=[("", ugettext_lazy("Choose"))],
help_text=ugettext_lazy("What level of permission this user will have")
)
random_password = forms.BooleanField(
label=ugettext_lazy("Random password"),
help_text=ugettext_lazy(
"Generate a random password. If you're updating this account and "
"check this box, a new password will be generated."
),
required=False
)
password1 = forms.CharField(
label=ugettext_lazy("Password"), widget=forms.widgets.PasswordInput
label=ugettext_lazy("Password"), widget=forms.widgets.PasswordInput,
required=False
)
password2 = forms.CharField(
@@ -54,7 +64,8 @@ class AccountFormGeneral(forms.ModelForm):
widget=forms.widgets.PasswordInput,
help_text=ugettext_lazy(
"Enter the same password as above, for verification."
)
),
required=False
)
class Meta:
@@ -71,8 +82,9 @@ class AccountFormGeneral(forms.ModelForm):
super(AccountFormGeneral, self).__init__(*args, **kwargs)
self.fields = OrderedDict(
(key, self.fields[key]) for key in
["role", "username", "first_name", "last_name", "password1",
"password2", "master_user", "is_active"]
["role", "username", "first_name", "last_name",
"random_password", "password1", "password2",
"master_user", "is_active"]
)
self.user = user
condition = (
@@ -103,21 +115,15 @@ class AccountFormGeneral(forms.ModelForm):
)
if domain_disabled:
self.fields["is_active"].widget.attrs["disabled"] = "disabled"
if args:
empty_password = (
args[0].get("password1", "") == "" and
args[0].get("password2", "") == "")
if empty_password:
self.fields["password1"].required = False
self.fields["password2"].required = False
if domain_disabled:
del self.fields["is_active"]
if args and domain_disabled:
del self.fields["is_active"]
self.fields["role"].initial = self.instance.role
condition = (
not self.instance.is_local and
param_tools.get_global_parameter(
"ldap_auth_method", app="core") == "directbind")
if condition:
del self.fields["random_password"]
del self.fields["password1"]
del self.fields["password2"]
@@ -151,16 +157,6 @@ class AccountFormGeneral(forms.ModelForm):
validate_utf8_email(uname)
return uname
def clean_password2(self):
password1 = self.cleaned_data.get("password1", "")
password2 = self.cleaned_data["password2"]
if password1 != password2:
raise forms.ValidationError(
_("The two password fields didn't match."))
if password2 != "":
password_validation.validate_password(password2, self.instance)
return password2
def clean(self):
"""Check master user mode."""
super(AccountFormGeneral, self).clean()
@@ -175,6 +171,26 @@ class AccountFormGeneral(forms.ModelForm):
"master_user",
_("Only super administrators are allowed for this mode")
)
random_password = self.cleaned_data.get("random_password")
if random_password:
self.cleaned_data["password2"] = lib.make_password()
elif "random_password" in self.fields and not random_password:
password1 = self.cleaned_data.get("password1", "")
password2 = self.cleaned_data.get("password2", "")
empty_password = password1 == "" and password2 == ""
if not self.instance.pk or not empty_password:
if not password1:
self.add_error("password1", _("This field is required."))
if not password2:
self.add_error("password2", _("This field is required."))
if not self.errors:
if password1 != password2:
self.add_error(
"password2",
_("The two password fields didn't match."))
else:
password_validation.validate_password(
password2, self.instance)
return self.cleaned_data
def save(self, commit=True):
+20 -6
View File
@@ -20,8 +20,9 @@ from modoboa.lib.form_utils import (
from modoboa.lib.web_utils import render_to_json_response
from modoboa.parameters import tools as param_tools
from .. import lib
from .. import signals
from ..lib import check_if_domain_exists, domain_has_authorized_mx
from ..models import (
Domain, DomainAlias, Mailbox, Alias
)
@@ -85,7 +86,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
def clean_name(self):
"""Check unicity and more."""
name = self.cleaned_data["name"]
label = check_if_domain_exists(
label = lib.check_if_domain_exists(
name, [(DomainAlias, _('domain alias'))])
if label is not None:
raise forms.ValidationError(
@@ -95,7 +96,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
param_tools.get_global_parameter("domains_must_have_authorized_mx")
)
if domains_must_have_authorized_mx and not self.user.is_superuser:
if not domain_has_authorized_mx(name):
if not lib.domain_has_authorized_mx(name):
raise forms.ValidationError(
_("No authorized MX record found for this domain"))
@@ -145,7 +146,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
)
del cleaned_data[k]
continue
label = check_if_domain_exists(
label = lib.check_if_domain_exists(
cleaned_data[k], [(Domain, _("domain"))])
if label is not None:
self.add_error(
@@ -212,6 +213,15 @@ class DomainFormOptions(forms.Form):
required=False
)
random_password = YesNoField(
label=ugettext_lazy("Random password"),
initial=False,
help_text=ugettext_lazy(
"Generate a random password for the administrator."
),
required=False
)
with_mailbox = YesNoField(
label=ugettext_lazy("With a mailbox"),
initial=True,
@@ -274,8 +284,12 @@ class DomainFormOptions(forms.Form):
core_signals.can_create_object.send(
self.__class__, context=user, klass=Mailbox)
da = User(username=username, email=username, is_active=True)
da.set_password(
param_tools.get_global_parameter("default_password", app="core"))
if self.cleaned_data["random_password"]:
password = lib.make_password()
else:
password = param_tools.get_global_parameter(
"default_password", app="core")
da.set_password(password)
da.save()
da.role = "DomainAdmins"
da.post_create(user)
+12
View File
@@ -7,7 +7,9 @@ from __future__ import unicode_literals
from functools import wraps
import ipaddress
from itertools import chain
import random
import socket
import string
import dns.resolver
@@ -206,3 +208,13 @@ def domain_has_authorized_mx(name):
if mx_ip_addr in subnet:
return True
return False
def make_password():
"""Create a random password."""
length = int(
param_tools.get_global_parameter("random_password_length", app="core")
)
return "".join(
random.SystemRandom().choice(
string.ascii_letters + string.digits) for _ in range(length))
+22 -12
View File
@@ -189,11 +189,13 @@ class AccountPasswordSerializer(serializers.ModelSerializer):
class WritableAccountSerializer(AccountSerializer):
"""Serializer to create account."""
random_password = serializers.BooleanField(default=False)
role = serializers.ChoiceField(choices=core_constants.ROLES)
password = serializers.CharField(required=False)
class Meta(AccountSerializer.Meta):
fields = AccountSerializer.Meta.fields + (
"password", )
"password", "random_password")
def __init__(self, *args, **kwargs):
"""Adapt fields to current user."""
@@ -206,16 +208,6 @@ class WritableAccountSerializer(AccountSerializer):
choices=permissions.get_account_roles(user))
self.fields["domains"] = serializers.ListField(
child=serializers.CharField(), allow_empty=False, required=False)
if request.method == "PUT":
self.fields["password"].required = False
def validate_password(self, value):
"""Check password constraints."""
try:
password_validation.validate_password(value, self.instance)
except ValidationError as exc:
raise serializers.ValidationError(exc.messages[0])
return value
def validate(self, data):
"""Check constraints."""
@@ -235,6 +227,19 @@ class WritableAccountSerializer(AccountSerializer):
raise serializers.ValidationError({
"username": _("Must be equal to mailbox full_address")
})
if not data["random_password"]:
password = data.get("password")
if password:
try:
password_validation.validate_password(
data["password"], self.instance)
except ValidationError as exc:
raise serializers.ValidationError({
"password": exc.messages[0]})
elif not self.instance:
raise serializers.ValidationError({
"password": _("This field is required.")
})
domain_names = data.get("domains")
if not domain_names:
return data
@@ -296,8 +301,13 @@ class WritableAccountSerializer(AccountSerializer):
mailbox_data = validated_data.pop("mailbox", None)
role = validated_data.pop("role")
domains = validated_data.pop("domains", [])
random_password = validated_data.pop("random_password", False)
user = core_models.User(**validated_data)
user.set_password(validated_data["password"])
if random_password:
password = lib.make_password()
else:
password = validated_data.pop("password")
user.set_password(password)
user.save(creator=creator)
if mailbox_data:
self._create_mailbox(creator, user, mailbox_data)
+19
View File
@@ -468,6 +468,21 @@ Identities.prototype = {
}
},
/**
* Hide or show password inputs depending on value.
*
* @param {bool} value - visibility flag
*/
togglePasswordInputs: function(value) {
if (value) {
$('#id_password1').parents(".form-group").show();
$('#id_password2').parents(".form-group").show();
} else {
$('#id_password1').parents(".form-group").hide();
$('#id_password2').parents(".form-group").hide();
}
},
generalform_init: function(notrigger) {
$("#id_role").change($.proxy(function(e) {
var $this = $(e.target);
@@ -479,6 +494,10 @@ Identities.prototype = {
this.normal_mode(value);
}
}, this));
$('#id_random_password').click($.proxy(function (e) {
var $this = $(e.target);
this.togglePasswordInputs(!$this.prop('checked'));
}, this));
this.toggle_master_user($("#id_role").val());
if (notrigger !== undefined && notrigger) {
return;
@@ -4,6 +4,7 @@
{% render_field form.username %}
{% render_field form.first_name %}
{% render_field form.last_name %}
{% render_field form.random_password %}
{% render_field form.password1 %}
{% render_field form.password2 %}
{% if form.master_user %}{% render_field form.master_user %}{% endif %}
@@ -3,6 +3,7 @@
{% if form.fields %}
{% render_field form.create_dom_admin %}
{% render_field form.dom_admin_username appended_text="@" %}
{% render_field form.random_password %}
{% render_field form.with_mailbox %}
{% render_field form.create_aliases %}
{% else %}
+51
View File
@@ -107,6 +107,57 @@ class AccountTestCase(ModoTestCase):
alias__internal=True).exists()
)
def test_password_constraints(self):
"""Check password constraints."""
values = {
"username": "tester@test.com",
"first_name": "Tester", "last_name": "Toto",
"password1": "", "password2": "",
"role": "SimpleUsers",
"quota_act": True, "is_active": True, "email": "tester@test.com",
"stepid": "step2"
}
resp = self.ajax_post(reverse("admin:account_add"), values, 400)
self.assertEqual(
resp["form_errors"]["password1"][0],
"This field is required.")
values["password1"] = "Toto1234"
values["password2"] = "Toto12345"
resp = self.ajax_post(reverse("admin:account_add"), values, 400)
self.assertEqual(
resp["form_errors"]["password2"][0],
"The two password fields didn't match.")
def test_random_password(self):
"""Try to create an account with a random password."""
values = {
"username": "tester@test.com",
"first_name": "Tester", "last_name": "Toto",
"random_password": True, "role": "SimpleUsers",
"quota_act": True, "is_active": True, "email": "tester@test.com",
"stepid": "step2"
}
self.ajax_post(reverse("admin:account_add"), values)
account = User.objects.get(username=values["username"])
password = account.password
values["language"] = "en"
# Since 'random_password' is still True, a new password should
# be generated
self.ajax_post(
reverse("admin:account_change", args=[account.pk]), values
)
account.refresh_from_db()
self.assertNotEqual(password, account.password)
password = account.password
values["random_password"] = False
self.ajax_post(
reverse("admin:account_change", args=[account.pk]), values
)
account.refresh_from_db()
self.assertEqual(password, account.password)
def test_delete_default_superadmin(self):
"""Delete default superadmin."""
sadmin2 = core_factories.UserFactory(
+14
View File
@@ -285,7 +285,21 @@ class AccountAPITestCase(ModoAPITestCase):
data = copy.deepcopy(self.ACCOUNT_DATA)
data["username"] = "fromapi_ééé@test.com"
data["mailbox"]["full_address"] = data["username"]
del data["password"]
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 400)
data["password"] = "Toto1234"
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
def test_create_account_with_random_password(self):
"""Try to create a new account with random password."""
url = reverse("api:account-list")
data = dict(self.ACCOUNT_DATA)
data["random_password"] = True
del data["password"]
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
+12
View File
@@ -87,6 +87,18 @@ class DomainTestCase(ModoTestCase):
Alias.objects.filter(
address="postmaster@pouet2.com", internal=False).exists())
def test_create_with_template_and_random_password(self):
"""Test domain creation with administrator and random password."""
values = {
"name": "pouet.com", "quota": 1000, "default_mailbox_quota": 100,
"create_dom_admin": True, "dom_admin_username": "toto",
"with_mailbox": True, "create_aliases": True, "type": "domain",
"stepid": "step3", "random_password": True
}
self.ajax_post(reverse("admin:domain_add"), values)
self.assertFalse(
self.client.login(username="admin@pouet.com", password="password"))
def test_create_with_template_and_no_mailbox(self):
"""Test domain creation with administrator but no mailbox."""
values = {
+8
View File
@@ -86,6 +86,14 @@ class GeneralParametersForm(param_forms.AdminParametersForm):
"Default password for automatically created accounts.")
)
random_password_length = forms.IntegerField(
label=ugettext_lazy("Random password length"),
min_value=8,
initial=8,
help_text=ugettext_lazy(
"Length of randomly generated passwords.")
)
# LDAP specific settings
ldap_sep = SeparatorField(label=ugettext_lazy("LDAP settings"))
+1
View File
@@ -158,6 +158,7 @@ class SettingsTestCase(ModoTestCase):
"core-hide_features_widget": "False",
"core-inactive_account_threshold": "30",
"admin-domains_must_have_authorized_mx": False,
"core-random_password_length": "8"
}
def test_get_settings(self):