mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Adding support for SHA256-CRYPT and SHA512-CRYPT.
fixes #450 fixes #493
This commit is contained in:
@@ -46,15 +46,26 @@ class GeneralParametersForm(parameters.AdminParametersForm):
|
||||
|
||||
password_scheme = forms.ChoiceField(
|
||||
label=ugettext_lazy("Default password scheme"),
|
||||
choices=[("crypt", "crypt"),
|
||||
("md5", "md5"),
|
||||
("md5crypt", "md5crypt"),
|
||||
("sha256", "sha256"),
|
||||
("plain", "plain")],
|
||||
initial="md5crypt",
|
||||
choices=[("sha512crypt", "sha512crypt"),
|
||||
("sha256crypt", "sha256crypt"),
|
||||
("md5crypt", ugettext_lazy("md5crypt (weak)")),
|
||||
("sha256", ugettext_lazy("sha256 (weak)")),
|
||||
("md5", ugettext_lazy("md5 (weak)")),
|
||||
("crypt", ugettext_lazy("crypt (weak)")),
|
||||
("plain", ugettext_lazy("plain (weak)"))],
|
||||
initial="sha512crypt",
|
||||
help_text=ugettext_lazy("Scheme used to crypt mailbox passwords")
|
||||
)
|
||||
|
||||
rounds_number = forms.IntegerField(
|
||||
label=ugettext_lazy("Rounds"),
|
||||
initial=70000,
|
||||
help_text=ugettext_lazy(
|
||||
"Number of rounds to use (only used by sha256crypt and "
|
||||
"sha512crypt). Must be between 1000 and 999999999, inclusive."
|
||||
)
|
||||
)
|
||||
|
||||
secret_key = forms.CharField(
|
||||
label=ugettext_lazy("Secret key"),
|
||||
initial=random_key(),
|
||||
@@ -173,7 +184,7 @@ class GeneralParametersForm(parameters.AdminParametersForm):
|
||||
"ldap_search_filter": "ldap_auth_method=searchbind",
|
||||
"ldap_user_dn_template": "ldap_auth_method=directbind",
|
||||
"ldap_password_attribute": "authentication_type=ldap",
|
||||
"ldap_is_active_directory": "authentication_type=ldap"
|
||||
"ldap_is_active_directory": "authentication_type=ldap",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -195,6 +206,12 @@ class GeneralParametersForm(parameters.AdminParametersForm):
|
||||
raise forms.ValidationError(_("Invalid syntax"))
|
||||
return tpl
|
||||
|
||||
def clean_rounds_number(self):
|
||||
value = self.cleaned_data["rounds_number"]
|
||||
if value < 1000 or value > 999999999:
|
||||
raise forms.ValidationError(_("Invalid rounds number"))
|
||||
return value
|
||||
|
||||
def clean(self):
|
||||
"""Custom validation method
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import DataMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(DataMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
"Write your forwards methods here."
|
||||
for user in orm['core.User'].objects.all():
|
||||
if user.password.startswith('$1$'):
|
||||
user.password = '{MD5-CRYPT}%s' % user.password
|
||||
user.save()
|
||||
|
||||
def backwards(self, orm):
|
||||
"Write your backwards methods here."
|
||||
for user in orm['core.User'].objects.all():
|
||||
if user.password.startswith('{MD5-CRYPT}'):
|
||||
user.password = user.password.replace('{MD5-CRYPT}', '')
|
||||
user.save()
|
||||
|
||||
models = {
|
||||
u'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
u'auth.permission': {
|
||||
'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
u'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
u'core.extension': {
|
||||
'Meta': {'object_name': 'Extension'},
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '150'})
|
||||
},
|
||||
u'core.log': {
|
||||
'Meta': {'object_name': 'Log'},
|
||||
'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'level': ('django.db.models.fields.CharField', [], {'max_length': '15'}),
|
||||
'logger': ('django.db.models.fields.CharField', [], {'max_length': '30'}),
|
||||
'message': ('django.db.models.fields.CharField', [], {'max_length': '255'})
|
||||
},
|
||||
u'core.objectaccess': {
|
||||
'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'},
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"})
|
||||
},
|
||||
u'core.user': {
|
||||
'Meta': {'ordering': "['username']", 'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_local': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['core']
|
||||
symmetrical = True
|
||||
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
|
||||
# Changing field 'User.password'
|
||||
db.alter_column(u'core_user', 'password', self.gf('django.db.models.fields.CharField')(max_length=256))
|
||||
|
||||
def backwards(self, orm):
|
||||
|
||||
# Changing field 'User.password'
|
||||
db.alter_column(u'core_user', 'password', self.gf('django.db.models.fields.CharField')(max_length=128))
|
||||
|
||||
models = {
|
||||
u'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
u'auth.permission': {
|
||||
'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
u'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
u'core.extension': {
|
||||
'Meta': {'object_name': 'Extension'},
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '150'})
|
||||
},
|
||||
u'core.log': {
|
||||
'Meta': {'object_name': 'Log'},
|
||||
'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'level': ('django.db.models.fields.CharField', [], {'max_length': '15'}),
|
||||
'logger': ('django.db.models.fields.CharField', [], {'max_length': '30'}),
|
||||
'message': ('django.db.models.fields.CharField', [], {'max_length': '255'})
|
||||
},
|
||||
u'core.objectaccess': {
|
||||
'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'},
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"})
|
||||
},
|
||||
u'core.user': {
|
||||
'Meta': {'ordering': "['username']", 'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_local': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['core']
|
||||
+48
-55
@@ -1,29 +1,26 @@
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import crypt
|
||||
import base64
|
||||
import logging
|
||||
from random import Random
|
||||
import reversion
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes import generic
|
||||
from django.contrib.auth.models import (
|
||||
UserManager, Group, AbstractBaseUser, PermissionsMixin
|
||||
UserManager, Group, PermissionsMixin
|
||||
)
|
||||
from modoboa.lib import events, md5crypt, parameters
|
||||
from django.contrib.auth.hashers import make_password, is_password_usable
|
||||
from modoboa.lib import events, parameters
|
||||
from modoboa.lib.exceptions import (
|
||||
PermDeniedException, InternalError, BadRequest
|
||||
)
|
||||
from modoboa.lib.sysutils import exec_cmd
|
||||
from modoboa.core.extensions import exts_pool
|
||||
from modoboa.core.password_hashers import get_password_hasher
|
||||
|
||||
try:
|
||||
from modoboa.lib.ldaputils import LDAPAuthBackend
|
||||
@@ -32,7 +29,7 @@ except ImportError:
|
||||
ldap_available = False
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
class User(PermissionsMixin):
|
||||
"""Custom User model.
|
||||
|
||||
It overloads the way passwords are stored into the database. The
|
||||
@@ -49,6 +46,10 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
is_active = models.BooleanField(default=True)
|
||||
date_joined = models.DateTimeField(default=timezone.now)
|
||||
is_local = models.BooleanField(default=True)
|
||||
password = models.CharField(ugettext_lazy('password'), max_length=256)
|
||||
last_login = models.DateTimeField(
|
||||
ugettext_lazy('last login'), default=timezone.now
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
@@ -58,7 +59,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
class Meta:
|
||||
ordering = ["username"]
|
||||
|
||||
password_expr = re.compile(r'(\{(\w+)\}|(\$1\$))(.+)')
|
||||
password_expr = re.compile(r'\{([\w\-]+)\}(.+)')
|
||||
|
||||
def delete(self, fromuser, *args, **kwargs):
|
||||
"""Custom delete method
|
||||
@@ -95,35 +96,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
scheme = parameters.get_admin("PASSWORD_SCHEME")
|
||||
if type(raw_value) is unicode:
|
||||
raw_value = raw_value.encode("utf-8")
|
||||
if scheme == "crypt":
|
||||
salt = "".join(Random().sample(string.letters + string.digits, 2))
|
||||
result = crypt.crypt(raw_value, salt)
|
||||
prefix = "{CRYPT}"
|
||||
elif scheme == "md5":
|
||||
obj = hashlib.md5(raw_value)
|
||||
result = obj.hexdigest()
|
||||
prefix = "{MD5}"
|
||||
# The md5crypt scheme is the only supported method that has both:
|
||||
# (a) a salt ("crypt" has this too),
|
||||
# (b) supports passwords lengths of more than 8 characters (all except
|
||||
# "crypt").
|
||||
elif scheme == "md5crypt":
|
||||
# The salt may vary from 12 to 48 bits. (Using all six bytes here
|
||||
# with a subset of characters means we get only 35 random bits.)
|
||||
import string
|
||||
salt = "".join(Random().sample(string.letters + string.digits, 6))
|
||||
result = md5crypt(raw_value, salt)
|
||||
prefix = "" # the result already has $1$ prepended to it
|
||||
# to signify what this is
|
||||
elif scheme == "sha256":
|
||||
obj = hashlib.sha256(raw_value)
|
||||
result = base64.b64encode(obj.digest())
|
||||
prefix = "{SHA256}"
|
||||
else:
|
||||
scheme = "plain"
|
||||
result = raw_value
|
||||
prefix = "{PLAIN}"
|
||||
return "%s%s" % (prefix, result)
|
||||
return get_password_hasher(scheme.upper())().encrypt(raw_value)
|
||||
|
||||
def set_password(self, raw_value, curvalue=None):
|
||||
"""Password update
|
||||
@@ -150,26 +123,46 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
)
|
||||
|
||||
def check_password(self, raw_value):
|
||||
m = self.password_expr.match(self.password)
|
||||
if m is None:
|
||||
match = self.password_expr.match(self.password)
|
||||
if match is None:
|
||||
return False
|
||||
if type(raw_value) is unicode:
|
||||
raw_value = raw_value.encode("utf-8")
|
||||
scheme = (m.group(2) or m.group(3)).lower()
|
||||
val2 = m.group(4)
|
||||
if scheme == u"crypt":
|
||||
val1 = crypt.crypt(raw_value, val2)
|
||||
elif scheme == u"md5":
|
||||
val1 = hashlib.md5(raw_value).hexdigest()
|
||||
elif scheme == u"sha256":
|
||||
val1 = base64.b64encode(hashlib.sha256(raw_value).digest())
|
||||
elif scheme == u"$1$": # md5crypt
|
||||
salt, hashed = val2.split('$')
|
||||
val1 = md5crypt(raw_value, str(salt))
|
||||
val2 = self.password # re-add scheme for comparison below
|
||||
else:
|
||||
val1 = raw_value
|
||||
return constant_time_compare(val1, val2)
|
||||
scheme = match.group(1)
|
||||
val2 = match.group(2)
|
||||
hasher = get_password_hasher(scheme)
|
||||
return hasher().verify(raw_value, val2)
|
||||
|
||||
def get_username(self):
|
||||
"Return the identifying username for this User"
|
||||
return getattr(self, self.USERNAME_FIELD)
|
||||
|
||||
def __str__(self):
|
||||
return self.get_username()
|
||||
|
||||
def natural_key(self):
|
||||
return (self.get_username(),)
|
||||
|
||||
def is_anonymous(self):
|
||||
"""
|
||||
Always returns False. This is a way of comparing User objects to
|
||||
anonymous users.
|
||||
"""
|
||||
return False
|
||||
|
||||
def is_authenticated(self):
|
||||
"""
|
||||
Always return True. This is a way to tell if the user has been
|
||||
authenticated in templates.
|
||||
"""
|
||||
return True
|
||||
|
||||
def set_unusable_password(self):
|
||||
# Sets a value that will never be a valid hash
|
||||
self.password = make_password(None)
|
||||
|
||||
def has_usable_password(self):
|
||||
return is_password_usable(self.password)
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Password hashers for Modoboa.
|
||||
"""
|
||||
from modoboa.core.password_hashers.base import (
|
||||
PLAINHasher, CRYPTHasher, MD5Hasher, SHA256Hasher
|
||||
)
|
||||
from modoboa.core.password_hashers.advanced import (
|
||||
MD5CRYPTHasher, SHA256CRYPTHasher, SHA512CRYPTHasher
|
||||
)
|
||||
|
||||
|
||||
def get_password_hasher(scheme):
|
||||
"""Retrieve the hasher corresponding to :keyword:`scheme`.
|
||||
|
||||
If no class is found, `PLAINHasher` is returned.
|
||||
|
||||
:param str scheme: a valid scheme name
|
||||
:rtype: PasswordHasher sub class
|
||||
:return: The hasher class
|
||||
"""
|
||||
try:
|
||||
scheme = scheme.replace('-', '')
|
||||
hasher = globals()['%sHasher' % scheme]
|
||||
except AttributeError:
|
||||
hasher = PLAINHasher
|
||||
return hasher
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Advanced (ie. stronger) password hashers.
|
||||
|
||||
This module relies on `passlib` to provide more secure hashers.
|
||||
"""
|
||||
from passlib.hash import md5_crypt, sha256_crypt, sha512_crypt
|
||||
from modoboa.core.password_hashers.base import PasswordHasher
|
||||
from modoboa.lib import parameters
|
||||
|
||||
|
||||
class MD5CRYPTHasher(PasswordHasher):
|
||||
"""
|
||||
MD5-CRYPT password hasher.
|
||||
|
||||
This scheme can't be considered as secure anymore.
|
||||
"""
|
||||
@property
|
||||
def scheme(self):
|
||||
return '{MD5-CRYPT}'
|
||||
|
||||
def _encrypt(self, clearvalue, salt=None):
|
||||
return md5_crypt.encrypt(clearvalue)
|
||||
|
||||
def verify(self, clearvalue, hashed_value):
|
||||
return md5_crypt.verify(clearvalue, hashed_value)
|
||||
|
||||
|
||||
class SHA256CRYPTHasher(PasswordHasher):
|
||||
"""
|
||||
SHA256-CRYPT password hasher.
|
||||
|
||||
Supports rounds and is a good compromise between security and
|
||||
performance.
|
||||
"""
|
||||
@property
|
||||
def scheme(self):
|
||||
return '{SHA256-CRYPT}'
|
||||
|
||||
def _encrypt(self, clearvalue, salt=None):
|
||||
rounds = int(parameters.get_admin('ROUNDS_NUMBER'))
|
||||
return sha256_crypt.encrypt(clearvalue, rounds=rounds)
|
||||
|
||||
def verify(self, clearvalue, hashed_value):
|
||||
return sha256_crypt.verify(clearvalue, hashed_value)
|
||||
|
||||
|
||||
class SHA512CRYPTHasher(PasswordHasher):
|
||||
"""
|
||||
SHA512-CRYPT password hasher.
|
||||
|
||||
Supports rounds and is the strongest scheme provided by
|
||||
Modoboa. Requires more resource than SHA256-CRYPT.
|
||||
"""
|
||||
@property
|
||||
def scheme(self):
|
||||
return '{SHA512-CRYPT}'
|
||||
|
||||
def _encrypt(self, clearvalue, salt=None):
|
||||
rounds = int(parameters.get_admin('ROUNDS_NUMBER'))
|
||||
return sha512_crypt.encrypt(clearvalue, rounds=rounds)
|
||||
|
||||
def verify(self, clearvalue, hashed_value):
|
||||
return sha512_crypt.verify(clearvalue, hashed_value)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Base password hashers.
|
||||
|
||||
Contains weak hashers (the original ones) available with Modoboa.
|
||||
"""
|
||||
import crypt
|
||||
import hashlib
|
||||
import base64
|
||||
import string
|
||||
from random import Random
|
||||
from django.utils.crypto import constant_time_compare
|
||||
|
||||
|
||||
class PasswordHasher(object):
|
||||
"""
|
||||
Base class of all hashers.
|
||||
"""
|
||||
def encrypt(self, clearvalue):
|
||||
"""Encrypt a password.
|
||||
|
||||
The format used to store passwords is the same than dovecot's one::
|
||||
|
||||
{SCHEME}<hash>
|
||||
|
||||
<hash> may differ according to the used scheme.
|
||||
|
||||
:param str clearvalue: clear password
|
||||
:rtype: str
|
||||
:return: encrypted password
|
||||
"""
|
||||
return '%s%s' % (self.scheme, self._encrypt(clearvalue))
|
||||
|
||||
def verify(self, clearvalue, hashed_value):
|
||||
"""Verify a password against a hashed value.
|
||||
|
||||
:param str clearvalue: clear password
|
||||
:param str hashed_value: password previously hashed
|
||||
:return: True if passwords match, False otherwise
|
||||
"""
|
||||
return constant_time_compare(
|
||||
self._encrypt(clearvalue, hashed_value),
|
||||
hashed_value
|
||||
)
|
||||
|
||||
|
||||
class PLAINHasher(PasswordHasher):
|
||||
"""
|
||||
Plain (ie. clear) password hasher.
|
||||
"""
|
||||
@property
|
||||
def scheme(self):
|
||||
return '{PLAIN}'
|
||||
|
||||
def _encrypt(self, clearvalue, salt=None):
|
||||
return clearvalue
|
||||
|
||||
|
||||
class CRYPTHasher(PasswordHasher):
|
||||
"""
|
||||
crypt password hasher.
|
||||
|
||||
Uses python `crypt` standard module.
|
||||
"""
|
||||
@property
|
||||
def scheme(self):
|
||||
return '{CRYPT}'
|
||||
|
||||
def _encrypt(self, clearvalue, salt=None):
|
||||
if salt is None:
|
||||
salt = "".join(Random().sample(string.letters + string.digits, 2))
|
||||
return crypt.crypt(clearvalue, salt)
|
||||
|
||||
|
||||
class MD5Hasher(PasswordHasher):
|
||||
"""
|
||||
MD5 password hasher.
|
||||
|
||||
Uses python `hashlib` standard module.
|
||||
"""
|
||||
@property
|
||||
def scheme(self):
|
||||
return '{MD5}'
|
||||
|
||||
def _encrypt(self, clearvalue, salt=None):
|
||||
obj = hashlib.md5(clearvalue)
|
||||
return obj.hexdigest()
|
||||
|
||||
|
||||
class SHA256Hasher(PasswordHasher):
|
||||
"""
|
||||
SHA256 password hasher.
|
||||
|
||||
Uses python `hashlib` and `base64` standard modules.
|
||||
"""
|
||||
@property
|
||||
def scheme(self):
|
||||
return '{SHA256}'
|
||||
|
||||
def _encrypt(self, clearvalue, salt=None):
|
||||
return base64.b64encode(hashlib.sha256(clearvalue).digest())
|
||||
@@ -19,9 +19,9 @@ class ProfileTestCase(ModoTestCase):
|
||||
* The default admin changes his password (no associated Mailbox)
|
||||
* A normal user changes his password
|
||||
"""
|
||||
self.check_ajax_post(reverse("modoboa.core.views.user.profile"),
|
||||
{"oldpassword": "password",
|
||||
"newpassword": "titi", "confirmation": "titi"})
|
||||
self.ajax_post(reverse("modoboa.core.views.user.profile"),
|
||||
{"oldpassword": "password",
|
||||
"newpassword": "titi", "confirmation": "titi"})
|
||||
self.clt.logout()
|
||||
|
||||
self.assertEqual(
|
||||
@@ -31,12 +31,12 @@ class ProfileTestCase(ModoTestCase):
|
||||
self.clt.login(username="user@test.com", password="toto"), True
|
||||
)
|
||||
|
||||
self.check_ajax_post(
|
||||
self.ajax_post(
|
||||
reverse("modoboa.core.views.user.profile"),
|
||||
{"oldpassword": "toto",
|
||||
"newpassword": "tutu", "confirmation": "tutu"}
|
||||
)
|
||||
self.clt.logout()
|
||||
self.assertEqual(
|
||||
self.clt.login(username="user@test.com", password="tutu"), True
|
||||
self.assertTrue(
|
||||
self.clt.login(username="user@test.com", password="tutu")
|
||||
)
|
||||
|
||||
@@ -4,9 +4,10 @@ from .account import AccountTestCase, PermissionsTestCase
|
||||
from .alias import AliasTestCase
|
||||
from .import_ import ImportTestCase
|
||||
from .export import ExportTestCase
|
||||
from .password_schemes import PasswordSchemesTestCase
|
||||
|
||||
__all__ = [
|
||||
'DomainTestCase', 'DomainAliasTestCase', 'AccountTestCase',
|
||||
'PermissionsTestCase', 'AliasTestCase', 'ImportTestCase',
|
||||
'ExportTestCase'
|
||||
'ExportTestCase', 'PasswordSchemesTestCase'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from django.core.urlresolvers import reverse
|
||||
from modoboa.core import load_core_settings
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.lib import parameters
|
||||
from modoboa.extensions.admin import factories
|
||||
|
||||
|
||||
class PasswordSchemesTestCase(ModoTestCase):
|
||||
fixtures = ['initial_users.json']
|
||||
|
||||
def setUp(self):
|
||||
super(PasswordSchemesTestCase, self).setUp()
|
||||
factories.populate_database()
|
||||
load_core_settings()
|
||||
|
||||
def _create_account(self):
|
||||
values = dict(
|
||||
username="tester@test.com", first_name="Tester", last_name="Toto",
|
||||
password1="toto", password2="toto", role="SimpleUsers",
|
||||
quota_act=True,
|
||||
is_active=True, email="tester@test.com", stepid='step2'
|
||||
)
|
||||
self.ajax_post(
|
||||
reverse("modoboa.extensions.admin.views.identity.newaccount"),
|
||||
values
|
||||
)
|
||||
|
||||
def _test_scheme(self, name, startpattern):
|
||||
parameters.save_admin('PASSWORD_SCHEME', name, app='core')
|
||||
self._create_account()
|
||||
account = User.objects.get(username='tester@test.com')
|
||||
self.assertTrue(account.password.startswith(startpattern))
|
||||
self.assertTrue(account.check_password('toto'))
|
||||
|
||||
def test_sha512crypt_scheme(self):
|
||||
self._test_scheme('sha512crypt', '{SHA512-CRYPT}')
|
||||
|
||||
def test_sha256crypt_scheme(self):
|
||||
self._test_scheme('sha256crypt', '{SHA256-CRYPT}')
|
||||
|
||||
def test_md5crypt_scheme(self):
|
||||
self._test_scheme('md5crypt', '{MD5-CRYPT}')
|
||||
|
||||
def test_sha256_scheme(self):
|
||||
self._test_scheme('sha256', '{SHA256}')
|
||||
|
||||
def test_md5_scheme(self):
|
||||
self._test_scheme('md5', '{MD5}')
|
||||
|
||||
def test_crypt(self):
|
||||
self._test_scheme('crypt', '{CRYPT}')
|
||||
|
||||
def test_plain(self):
|
||||
self._test_scheme('plain', '{PLAIN}')
|
||||
@@ -1,79 +0,0 @@
|
||||
# coding: utf-8
|
||||
import hashlib
|
||||
|
||||
def md5crypt(password, salt, magic='$1$'):
|
||||
"""Taken from:
|
||||
http://code.activestate.com/recipes/325204-passwd-file-compatible-1-md5-crypt/
|
||||
|
||||
Based on FreeBSD src/lib/libcrypt/crypt.c 1.2
|
||||
http://www.freebsd.org/cgi/cvsweb.cgi/~checkout~/src/lib/libcrypt/crypt.c?rev=1.2&content-type=text/plain
|
||||
|
||||
Original license:
|
||||
* "THE BEER-WARE LICENSE" (Revision 42):
|
||||
* <phk@login.dknet.dk> wrote this file. As long as you retain this notice you
|
||||
* can do whatever you want with this stuff. If we meet some day, and you think
|
||||
* this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp
|
||||
|
||||
"A port of Poul-Henning Kamp's MD5 password hash routine, as initially
|
||||
found in FreeBSD 2. It is also used in Cisco routers, Apache htpasswd files,
|
||||
and other places that you find "$1$" at the beginning of password hashes."
|
||||
by Mark Johnston
|
||||
"""
|
||||
# The password first, since that is what is most unknown
|
||||
# Then our magic string. Then the raw salt.
|
||||
m = hashlib.md5()
|
||||
m.update(password + magic + salt)
|
||||
|
||||
# /* Then just as many characters of the MD5(pw,salt,pw) */
|
||||
mixin = hashlib.md5(password + salt + password).digest()
|
||||
for i in range(0, len(password)):
|
||||
m.update(mixin[i % 16])
|
||||
|
||||
# Then something really weird... Also really broken, as far as I
|
||||
# can tell. -m
|
||||
i = len(password)
|
||||
while i:
|
||||
if i & 1:
|
||||
m.update('\x00')
|
||||
else:
|
||||
m.update(password[0])
|
||||
i >>= 1
|
||||
|
||||
final = m.digest()
|
||||
|
||||
# And now, just to make sure things don't run too fast
|
||||
for i in range(1000):
|
||||
m2 = hashlib.md5()
|
||||
if i & 1:
|
||||
m2.update(password)
|
||||
else:
|
||||
m2.update(final)
|
||||
|
||||
if i % 3:
|
||||
m2.update(salt)
|
||||
|
||||
if i % 7:
|
||||
m2.update(password)
|
||||
|
||||
if i & 1:
|
||||
m2.update(final)
|
||||
else:
|
||||
m2.update(password)
|
||||
|
||||
final = m2.digest()
|
||||
|
||||
# This is the bit that uses to64() in the original code.
|
||||
|
||||
itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
rearranged = ''
|
||||
for a, b, c in ((0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)):
|
||||
v = ord(final[a]) << 16 | ord(final[b]) << 8 | ord(final[c])
|
||||
for i in range(4):
|
||||
rearranged += itoa64[v & 0x3f]; v >>= 6
|
||||
|
||||
v = ord(final[11])
|
||||
for i in range(2):
|
||||
rearranged += itoa64[v & 0x3f]; v >>= 6
|
||||
|
||||
return magic + salt + '$' + rearranged
|
||||
|
||||
@@ -9,3 +9,4 @@ chardet
|
||||
argparse
|
||||
rfc6266
|
||||
factory-boy
|
||||
passlib
|
||||
|
||||
@@ -20,7 +20,7 @@ class DeployTest(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
path = os.path.join(self.workdir, self.projname)
|
||||
code, output = exec_cmd("python manage.py test lib admin limits postfix_relay_domains", cwd=path)
|
||||
code, output = exec_cmd("python manage.py test core lib admin limits postfix_relay_domains", cwd=path)
|
||||
print output
|
||||
self.assertEqual(code, 0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user