# coding: utf-8 from django.db import models, IntegrityError from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes import generic from django.contrib.auth.models import User as DUser, UserManager, Group from django.utils.translation import ugettext as _, ugettext_lazy from django.utils.crypto import constant_time_compare from django.conf import settings from modoboa.lib import parameters, events from modoboa.lib.exceptions import PermDeniedException from modoboa.lib.sysutils import exec_cmd, exec_as_vuser from modoboa.lib.emailutils import split_mailbox from modoboa.extensions import exts_pool from exceptions import * import os, sys import pwd import re import crypt, hashlib, string, base64 from random import Random try: from modoboa.lib.ldaputils import * ldap_available = True except ImportError: ldap_available = False class User(DUser): """Proxy for the ``User`` model. It overloads the way passwords are stored into the database. The main reason to change this mechanism is to ensure the compatibility with the way Dovecot stores passwords. It also adds new attributes and methods. """ class Meta: proxy = True ordering = ["username"] password_expr = re.compile(r'\{(\w+)\}(.+)') def __init__(self, *args, **kwargs): """Constructor A little hack to increase the maximum length for the username field. """ max_length = 254 DUser._meta.get_field('username').max_length = max_length DUser._meta.get_field('username').validators[0].limit_value = 254 super(User, self).__init__(*args, **kwargs) def __unicode__(self): return u"%s %s" % (self.first_name, self.last_name) def delete(self, fromuser, keep_mb_dir, *args, **kwargs): """Custom delete method To check permissions properly, we need to make a distinction between 2 cases: * If the user owns a mailbox, the check is made on that object (useful for domain admins) * Otherwise, the check is made on the user """ from modoboa.lib.permissions import \ get_object_owner, grant_access_to_object, ungrant_access_to_object if fromuser == self: raise AdminError(_("You can't delete your own account")) if not fromuser.can_access(self): raise PermDeniedException if self.has_mailbox: mb = self.mailbox_set.all()[0] if not fromuser.can_access(mb): raise PermDeniedException mb.delete(keepdir=keep_mb_dir) owner = get_object_owner(self) for ooentry in self.objectaccess_set.filter(is_owner=True): if ooentry.content_object is not None: grant_access_to_object(owner, ooentry.content_object, True) events.raiseEvent("AccountDeleted", self) ungrant_access_to_object(self) super(User, self).delete(*args, **kwargs) @staticmethod def get_content_type(): """An uggly hack to retrieve the appropriate content_type! The explanation is available here: https://code.djangoproject.com/ticket/11154 Quickly, the content_types framework does not retrieve the appropriate content type for proxy models, it retrieves the one of the first parent that is not a proxy. """ if not hasattr(User, "ct"): User.ct = ContentType.objects.get(app_label="admin", model="user") return User.ct def _crypt_password(self, raw_value): scheme = parameters.get_admin("PASSWORD_SCHEME") if scheme == "crypt": salt = ''.join(Random().sample(string.letters + string.digits, 2)) result = crypt.crypt(raw_value, salt) elif scheme == "md5": obj = hashlib.md5(raw_value) result = obj.hexdigest() elif scheme == "sha256": obj = hashlib.sha256(raw_value) result = base64.b64encode(obj.digest()) else: scheme = "plain" result = raw_value return "{%s}%s" % (scheme.upper(), result) def set_password(self, raw_value, curvalue=None): """Password update Update the current mailbox's password with the given clear value. This value is encrypted according to the defined method before it is saved. :param raw_value: the new password's value :param curvalue: the current password (for LDAP authentication) """ if parameters.get_admin("AUTHENTICATION_TYPE") == "local": self.password = self._crypt_password(raw_value) return if not ldap_available: raise AdminError(_("Failed to update password: LDAP module not installed")) ab = LDAPAuthBackend() try: ab.update_user_password(self.username, curvalue, raw_value) except LDAPException, e: raise AdminError(_("Failed to update password: %s" % str(e))) def check_password(self, raw_value): m = self.password_expr.match(self.password) if m is None: return False scheme = m.group(1).lower() val2 = m.group(2) if scheme == "crypt": val1 = crypt.crypt(raw_value, m.group(2)) elif scheme == "md5": val1 = hashlib.md5(raw_value).hexdigest() elif scheme == "sha256": val1 = base64.b64encode(hashlib.sha256(raw_value).digest()) else: val1 = raw_value return constant_time_compare(val1, val2) @property def has_mailbox(self): return len(self.mailbox_set.all()) != 0 @property def fullname(self): if self.first_name != "": return "%s %s" % (self.first_name, self.last_name) return self.username @property def identity(self): return self.username @property def name_or_rcpt(self): if self.first_name != "": return "%s %s" % (self.first_name, self.last_name) return "----" @property def group(self): if self.is_superuser: return "SuperAdmins" try: return self.groups.all()[0].name except IndexError: return "?" def belongs_to_group(self, name): """Simple shortcut to check if this user is a member of a specific group. :param name: the group's name :return: a boolean """ try: self.groups.get(name=name) except Group.DoesNotExist: return False return True def get_identities(self): from modoboa.lib.permissions import get_content_type userct = get_content_type(self) alct = get_content_type(Alias) return self.objectaccess_set.filter(content_type__in=[userct, alct]) def get_domains(self): """Return the domains belonging to this user The result is a ``QuerySet`` object, so this function can be used to fill ``ModelChoiceField`` objects. """ if self.is_superuser: return Domain.objects.all() return Domain.objects.filter(owners__user=self) def get_domaliases(self): """Return the domain aliases that belong to this user The result will contain the domain aliases defined for each domain that user can see. :return: a list of ``DomainAlias`` objects """ if self.is_superuser: return DomainAlias.objects.all() domains = self.get_domains() domaliases = [] for dom in domains: domaliases += dom.domainalias_set.all() return domaliases def get_mailboxes(self): """Return the mailboxes that belong to this user The result will contain the mailboxes defined for each domain that user can see. :return: a list of ``Mailbox`` objects """ if self.is_superuser: return Mailbox.objects.all() mboxes = [] domains = self.get_domains() for dom in domains: mboxes += dom.mailbox_set.all() return mboxes def get_mbaliases(self): """Return the mailbox aliases that belong to this user The result will contain the mailbox aliases defined for each domain that user can see. :return: a list of ``Alias`` objects """ if self.is_superuser: return Alias.objects.all() domains = self.get_domains() mbaliases = [] for dom in domains: mbaliases += dom.alias_set.all() return mbaliases def is_owner(self, obj): """Tell is the user is the unique owner of this object :param obj: an object inheriting from ``models.Model`` :return: a boolean """ from modoboa.lib.permissions import get_content_type ct = get_content_type(obj) try: ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id) except ObjectAccess.DoesNotExist: return False return ooentry.is_owner def can_access(self, obj): """Check if the user can access a specific object This function is recursive : if the given user hasn't got direct access to this object and if he has got access other ``User`` objects, we check if one of those users owns the object. :param obj: a admin object :return: a boolean """ from modoboa.lib.permissions import get_content_type if self.is_superuser: return True ct = get_content_type(obj) try: ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id) except ObjectAccess.DoesNotExist: pass else: return True if ct.model == "user": return False ct = self.get_content_type() qs = self.objectaccess_set.filter(content_type=ct) for ooentry in qs.all(): if ooentry.content_object.is_owner(obj): return True return False class ObjectAccess(models.Model): user = models.ForeignKey(User) content_type = models.ForeignKey(ContentType) object_id = models.PositiveIntegerField() content_object = generic.GenericForeignKey('content_type', 'object_id') is_owner = models.BooleanField(default=False) class Meta: unique_together = (("user", "content_type", "object_id"),) def __unicode__(self): return "%s => %s (%s)" % (self.user, self.content_object, self.content_type) class ObjectDates(models.Model): """Dates recording for admin objects This table keeps creation and last modification dates for Domains, domain aliases, mailboxes and aliases objects. """ creation = models.DateTimeField(auto_now_add=True) last_modification = models.DateTimeField(auto_now=True) @staticmethod def set_for_object(obj): """Initialize or update dates for a given object. :param obj: an admin object (Domain, Mailbox, etc) """ try: dates = getattr(obj, "dates") except ObjectDates.DoesNotExist: dates = ObjectDates() dates.save() obj.dates = dates class DatesAware(models.Model): """Abstract model to support dates Inherit from this model to automatically add the "dates" feature to another model. It defines the appropriate field and handles saves. """ dates = models.ForeignKey(ObjectDates) class Meta: abstract = True def save(self, *args, **kwargs): ObjectDates.set_for_object(self) super(DatesAware, self).save(*args, **kwargs) @property def creation(self): return self.dates.creation @property def last_modification(self): return self.dates.last_modification class Domain(DatesAware): name = models.CharField(ugettext_lazy('name'), max_length=100, unique=True, help_text=ugettext_lazy("The domain name")) quota = models.IntegerField(help_text=ugettext_lazy("Default quota in MB applied to mailboxes")) enabled = models.BooleanField(ugettext_lazy('enabled'), help_text=ugettext_lazy("Check to activate this domain")) owners = generic.GenericRelation(ObjectAccess) class Meta: permissions = ( ("view_domain", "View domain"), ("view_domains", "View domains"), ) ordering = ["name"] @property def domainalias_count(self): return len(self.domainalias_set.all()) @property def mailbox_count(self): return len(self.mailbox_set.all()) @property def mbalias_count(self): return len(self.alias_set.all()) @property def admins(self): return [oa.user for oa in self.owners.filter(user__is_superuser=False)] def create_dir(self): if parameters.get_admin("CREATE_DIRECTORIES") == "yes": path = "%s/%s" % (parameters.get_admin("STORAGE_PATH"), self.name) return exec_as_vuser("mkdir -p %s" % path) return True def rename_dir(self, oldname): if parameters.get_admin("CREATE_DIRECTORIES") == "yes": stpath = parameters.get_admin("STORAGE_PATH") return exec_as_vuser("mv %s/%s %s/%s" \ % (stpath, oldname, stpath, self.name)) return True def delete_dir(self): if parameters.get_admin("CREATE_DIRECTORIES") == "yes": return exec_as_vuser("rm -r %s/%s" \ % (parameters.get_admin("STORAGE_PATH"), self.name)) return True def delete(self, keepdir=False): from modoboa.lib.permissions import ungrant_access_to_object, ungrant_access_to_objects if len(self.domainalias_set.all()): events.raiseEvent("DomainAliasDeleted", self.domainalias_set.all()) ungrant_access_to_objects(self.domainalias_set.all()) if len(self.mailbox_set.all()): events.raiseEvent("DeleteMailbox", self.mailbox_set.all()) ungrant_access_to_objects(self.mailbox_set.all()) if len(self.alias_set.all()): events.raiseEvent("MailboxAliasDelete", self.alias_set.all()) ungrant_access_to_objects(self.alias_set.all()) events.raiseEvent("DeleteDomain", self) ungrant_access_to_object(self) super(Domain, self).delete() if not keepdir: self.delete_dir() def save(self, *args, **kwargs): if not self.create_dir(): raise AdminError(_("Failed to initialise domain, check permissions")) super(Domain, self).save(*args, **kwargs) def __str__(self): return self.name def create_from_csv(self, user, row): if len(row) < 3: raise AdminError(_("Invalid line")) self.name = row[1].strip() self.quota = int(row[2].strip()) self.enabled = True self.save() class DomainAlias(DatesAware): name = models.CharField(ugettext_lazy("name"), max_length=100, unique=True, help_text=ugettext_lazy("The alias name")) target = models.ForeignKey(Domain, verbose_name=ugettext_lazy('target'), help_text=ugettext_lazy("The domain this alias points to")) enabled = models.BooleanField( ugettext_lazy('enabled'), help_text=ugettext_lazy("Check to activate this alias") ) class Meta: permissions = ( ("view_domaliases", "View domain aliases"), ) def __unicode__(self): return self.name def delete(self): from modoboa.lib.permissions import ungrant_access_to_object events.raiseEvent("DomainAliasDeleted", self) ungrant_access_to_object(self) super(DomainAlias, self).delete() class Mailbox(DatesAware): address = models.CharField( ugettext_lazy('address'), max_length=100, help_text=ugettext_lazy("Mailbox address (without the @domain.tld part)") ) quota = models.IntegerField() uid = models.IntegerField() gid = models.IntegerField() path = models.CharField(max_length=255) domain = models.ForeignKey(Domain) user = models.ForeignKey(User) class Meta: permissions = ( ("view_mailboxes", "View mailboxes"), ) def __init__(self, *args, **kwargs): super(Mailbox, self).__init__(*args, **kwargs) self.mbtype = parameters.get_admin("MAILBOX_TYPE") self.mdirroot = parameters.get_admin("MAILDIR_ROOT") if not self.mdirroot.endswith('/'): self.mdirroot += '/' def __str__(self): return self.full_address @property def full_address(self): return "%s@%s" % (self.address, self.domain.name) @property def enabled(self): return self.user.is_active @property def full_path(self): path = os.path.join(parameters.get_admin("STORAGE_PATH"), self.domain.name, self.address) return os.path.abspath(path) @property def alias_count(self): return len(self.alias_set.all()) def create_dir(self): if parameters.get_admin("CREATE_DIRECTORIES") == "yes": relpath = "%s/%s" % (self.domain.name, self.address) abspath = os.path.join(parameters.get_admin("STORAGE_PATH"), relpath) if self.mbtype == "mbox": self.path = "%s/Inbox" % self.address else: self.path = "%s/" % self.address if os.path.exists(abspath): return True if not exec_as_vuser("mkdir -p %s" % abspath): return False if self.mbtype == "mbox": template = ["Inbox", "Drafts", "Sent", "Trash", "Junk"] for f in template: exec_as_vuser("touch %s/%s" % (abspath, f)) else: template = [".Drafts/", ".Sent/", ".Trash/", ".Junk/"] for dir in template: for sdir in ["cur", "new", "tmp"]: exec_as_vuser("mkdir -p %s/%s/%s/%s" \ % (abspath, self.mdirroot, dir, sdir)) return True def rename_dir(self, newdomain, newaddress): if parameters.get_admin("CREATE_DIRECTORIES") == "yes": if self.domain.name == newdomain and newaddress == self.address: return True oldpath = "%s/%s" % (parameters.get_admin("STORAGE_PATH"), self.domain.name) newpath = "%s/%s" % (parameters.get_admin("STORAGE_PATH"), newdomain) oldpath = os.path.join(oldpath, self.address) newpath = os.path.join(newpath, newaddress) code = exec_as_vuser("mv %s %s" % (oldpath, newpath)) if code: self.path = "%s/" % newaddress return code return True def delete_dir(self): if parameters.get_admin("CREATE_DIRECTORIES") == "yes": return exec_as_vuser("rm -r %s/%s/%s" \ % (parameters.get_admin("STORAGE_PATH"), self.domain.name, self.address)) return True def set_ownership(self): # If we ever had numerical uid, don't resolve it v_uid = parameters.get_admin("VIRTUAL_UID"); if v_uid.isdigit(): self.uid = v_uid else: try: self.uid = pwd.getpwnam(v_uid).pw_uid except KeyError: raise AdminError(_("%s is not a valid uid/username" % v_uid)) # Just the same for gid v_gid = parameters.get_admin("VIRTUAL_GID") if v_gid.isdigit(): self.gid = v_gid else: try: self.gid = pwd.getpwnam(v_gid).pw_gid except KeyError: raise AdminError(_("%s is not a valid gid/groupname" % v_gid)) def save(self, *args, **kwargs): if not self.create_dir(): raise AdminError(_("Failed to initialise mailbox, check permissions")) try: user = getattr(self, "user") except User.DoesNotExist: user = User() user.email = "%s@%s" % (self.address, self.domain.name) if kwargs.has_key("username"): user.username = kwargs["username"] else: user.username = user.email if kwargs.has_key("enabled"): user.is_active = kwargs["enabled"] if kwargs.has_key("name"): try: fname, lname = kwargs["name"].split() except ValueError: fname = kwargs["name"] lname = "" user.first_name = fname user.last_name = lname if kwargs.has_key("password"): user.set_password(kwargs["password"]) try: user.save() except IntegrityError, e: raise AdminError(_("Account '%s' already exists" % user.username)) self.user = user if len(user.groups.all()) == 0: user.groups.add(Group.objects.get(name="SimpleUsers")) user.save() self.set_ownership() if kwargs.has_key("quota") and kwargs["quota"] is not None \ and int(kwargs["quota"]) < self.domain.quota: self.quota = kwargs["quota"] else: self.quota = self.domain.quota for kw in ["username", "name", "enabled", "password", "quota"]: try: del kwargs[kw] except KeyError: pass super(Mailbox, self).save(*args, **kwargs) def save_from_user(self, localpart, domain, user, quota=None): """Simple save method called for automatic creations :param localpart: the mailbox :param domain: the associated Domain object :param user: the associated User object """ self.address = localpart self.domain = domain self.user = user if not self.create_dir(): raise AdminError(_("Failed to initialise mailbox, check permissions")) self.set_ownership() self.quota = self.domain.quota if quota is None else quota super(Mailbox, self).save() def create_from_csv(self, user, line): """Create a new mailbox from a CSV file entry The expected order is the following:: [loginname, password, first name, last name, address] :param line: a list containing the expected information """ if len(line) < 6: raise AdminError(_("Invalid line")) mailbox, domname = split_mailbox(line[5].strip()) self.address = mailbox try: self.domain = Domain.objects.get(name=domname) except Domain.DoesNotExist: raise AdminError(_("Cannot import this mailbox because the associated domain does not exist")) if not user.can_access(self.domain): raise PermDeniedException name = "%s %s" % (line[3].strip(), line[4].strip()) self.save(username=line[1].strip(), name=name, password=line[2].strip(), enabled=True) def delete(self, keepdir=False): from modoboa.lib.permissions import ungrant_access_to_object events.raiseEvent("DeleteMailbox", self) ungrant_access_to_object(self) for alias in self.alias_set.all(): alias.mboxes.remove(self) if len(alias.mboxes.all()) == 0: alias.delete() super(Mailbox, self).delete() if not keepdir: self.delete_dir() class Alias(DatesAware): address = models.CharField( ugettext_lazy('address'), max_length=254, help_text=ugettext_lazy("The alias address (without the domain part). For a 'catch-all' address, just enter an * character.") ) domain = models.ForeignKey(Domain) mboxes = models.ManyToManyField( Mailbox, verbose_name=ugettext_lazy('mailboxes'), help_text=ugettext_lazy("The mailboxes this alias points to") ) extmboxes = models.TextField(blank=True) enabled = models.BooleanField(ugettext_lazy('enabled'), help_text=ugettext_lazy("Check to activate this alias")) class Meta: permissions = ( ("view_aliases", "View aliases"), ) unique_together = (("address", "domain"),) ordering = ["domain__name", "address"] @property def full_address(self): return "%s@%s" % (self.address, self.domain.name) @property def identity(self): return self.full_address @property def name_or_rcpt(self): rcpts = self.get_recipients() if len(rcpts) > 1: return "%s, ..." % rcpts[0] return rcpts[0] def save(self, int_rcpts, ext_rcpts, *args, **kwargs): if len(ext_rcpts): self.extmboxes = ",".join(ext_rcpts) else: self.extmboxes = "" super(Alias, self).save(*args, **kwargs) curmboxes = self.mboxes.all() for t in int_rcpts: if not t in curmboxes: self.mboxes.add(t) for t in curmboxes: if not t in int_rcpts: self.mboxes.remove(t) def delete(self): from modoboa.lib.permissions import ungrant_access_to_object events.raiseEvent("MailboxAliasDeleted", self) ungrant_access_to_object(self) super(Alias, self).delete() def get_recipients(self): """Return the recipients list Internal and external addresses are mixed into a single list. """ result = map(lambda mb: mb.full_address, self.mboxes.all()) if self.extmboxes != "": result += self.extmboxes.split(',') return result def ui_disabled(self, user): if user.is_superuser: return False for mb in self.mboxes.all(): if not user.is_owner(mb.domain): return True return False def create_from_csv(self, user, row): if len(row) < 4: raise AdminError(_("Invalid line")) localpart, domname = split_mailbox(row[1].strip()) try: domain = Domain.objects.get(name=domname) except Domain.DoesNotExist: raise AdminError(_("Domain does not exist")) if not user.can_access(domain): raise PermDeniedException self.address = localpart self.domain = domain self.enabled = True int_rcpts = [] ext_rcpts = [] for rcpt in row[2:]: rcpt = rcpt.strip() localpart, domname = split_mailbox(rcpt) try: Domain.objects.get(name=domname) except Domain.DoesNotExist: ext_rcpts += [rcpt] continue try: int_rcpts += [Mailbox.objects.get(address=localpart, domain__name=domname)] except Mailbox.DoesNotExist: raise AdminError(_("Mailbox %s does not exist" % rcpt)) self.save(int_rcpts, ext_rcpts) class Extension(models.Model): name = models.CharField(max_length=150) enabled = models.BooleanField( ugettext_lazy('enabled'), help_text=ugettext_lazy("Check to enable this extension") ) def __init__(self, *args, **kwargs): super(Extension, self).__init__(*args, **kwargs) def __get_ext_instance(self): if not self.name: return None if hasattr(self, "instance") and self.instance: return self.instance = exts_pool.get_extension(self.name) if self.instance: self.__get_ext_dir() def __get_ext_dir(self): modname = self.instance.__module__ path = os.path.realpath(sys.modules[modname].__file__) self.extdir = os.path.dirname(path) def on(self): self.enabled = True self.save() self.__get_ext_instance() self.instance.load() self.instance.init() if self.instance.needs_media: path = os.path.join(settings.MEDIA_ROOT, self.name) exec_cmd("mkdir %s" % path) scriptdir = "%s/%s" % (self.extdir, "scripts") if os.path.exists(scriptdir): targetdir = os.path.join(settings.MODOBOA_DIR, "scripts") exec_cmd("ln -s %s %s/%s" % (scriptdir, targetdir, self.name)) events.raiseEvent("ExtEnabled", self) def off(self): self.__get_ext_instance() self.instance.destroy() self.enabled = False self.save() if self.instance.needs_media: path = os.path.join(settings.MEDIA_ROOT, self.name) exec_cmd("rm -r %s" % path) scriptdir = "%s/%s" % (self.extdir, "scripts") if os.path.exists(scriptdir): target = "%s/scripts/%s" % (settings.MODOBOA_DIR, self.name) exec_cmd("rm %s" % target) events.raiseEvent("ExtDisabled", self) def populate_callback(sender, user=None, **kwargs): """Populate signal callback If the LDAP authentication backend is in use, this callback will be called each time a new user authenticates succesfuly to Modoboa. This function is in charge of creating the mailbox associated to the provided ``User`` object. :param sender: ?? :param user: a ``User`` instance """ if parameters.get_admin("AUTHENTICATION_TYPE") != "ldap": return if user is None: return localpart, domname = split_mailbox(user.username) try: domain = Domain.objects.get(name=domname) except Domain.DoesNotExist: domain = Domain() domain.name = domname domain.enabled = True domain.quota = 0 domain.save() try: mb = Mailbox.objects.get(domain=domain, address=localpart) except Mailbox.DoesNotExist: mb = Mailbox() mb.save_from_user(localpart, domain, user) try: from django_auth_ldap.backend import populate_user except ImportError, inst: pass else: populate_user.connect(populate_callback, dispatch_uid="myuid")