]> git.ipfire.org Git - ipfire.org.git/blobdiff - src/backend/accounts.py
people: Show number of accounts created this week/month
[ipfire.org.git] / src / backend / accounts.py
index c310cf5300f8de86b9525016c2fb3890cf87470f..8dcb051576a964ca0fee53ae35a55ed169dfd1da 100644 (file)
@@ -3,14 +3,16 @@
 
 import base64
 import datetime
+import hashlib
 import hmac
+import iso3166
 import json
 import ldap
 import ldap.modlist
 import logging
 import os
 import phonenumbers
-import sshpubkeys
+import re
 import time
 import tornado.httpclient
 import urllib.parse
@@ -25,10 +27,158 @@ from .misc import Object
 # Set the client keytab name
 os.environ["KRB5_CLIENT_KTNAME"] = "/etc/ipfire.org/ldap.keytab"
 
+class LDAPObject(Object):
+       def init(self, dn, attrs=None):
+               self.dn = dn
+
+               self.attributes = attrs or {}
+
+       def __eq__(self, other):
+               if isinstance(other, self.__class__):
+                       return self.dn == other.dn
+
+       @property
+       def ldap(self):
+               return self.accounts.ldap
+
+       def _exists(self, key):
+               try:
+                       self.attributes[key]
+               except KeyError:
+                       return False
+
+               return True
+
+       def _get(self, key):
+               for value in self.attributes.get(key, []):
+                       yield value
+
+       def _get_bytes(self, key, default=None):
+               for value in self._get(key):
+                       return value
+
+               return default
+
+       def _get_strings(self, key):
+               for value in self._get(key):
+                       yield value.decode()
+
+       def _get_string(self, key, default=None):
+               for value in self._get_strings(key):
+                       return value
+
+               return default
+
+       def _get_phone_numbers(self, key):
+               for value in self._get_strings(key):
+                       yield phonenumbers.parse(value, None)
+
+       def _get_timestamp(self, key):
+               value = self._get_string(key)
+
+               # Parse the timestamp value and returns a datetime object
+               if value:
+                       return datetime.datetime.strptime(value, "%Y%m%d%H%M%SZ")
+
+       def _modify(self, modlist):
+               logging.debug("Modifying %s: %s" % (self.dn, modlist))
+
+               # Authenticate before performing any write operations
+               self.accounts._authenticate()
+
+               # Run modify operation
+               self.ldap.modify_s(self.dn, modlist)
+
+               # Clear cache
+               self._clear_cache()
+
+       def _clear_cache(self):
+               """
+                       Clears cache
+               """
+               pass
+
+       def _set(self, key, values):
+               current = self._get(key)
+
+               # Don't do anything if nothing has changed
+               if list(current) == values:
+                       return
+
+               # Remove all old values and add all new ones
+               modlist = []
+
+               if self._exists(key):
+                       modlist.append((ldap.MOD_DELETE, key, None))
+
+               # Add new values
+               if values:
+                       modlist.append((ldap.MOD_ADD, key, values))
+
+               # Run modify operation
+               self._modify(modlist)
+
+               # Update cache
+               self.attributes.update({ key : values })
+
+       def _set_bytes(self, key, values):
+               return self._set(key, values)
+
+       def _set_strings(self, key, values):
+               return self._set(key, [e.encode() for e in values if e])
+
+       def _set_string(self, key, value):
+               return self._set_strings(key, [value,])
+
+       def _add(self, key, values):
+               modlist = [
+                       (ldap.MOD_ADD, key, values),
+               ]
+
+               self._modify(modlist)
+
+       def _add_strings(self, key, values):
+               return self._add(key, [e.encode() for e in values])
+
+       def _add_string(self, key, value):
+               return self._add_strings(key, [value,])
+
+       def _delete(self, key, values):
+               modlist = [
+                       (ldap.MOD_DELETE, key, values),
+               ]
+
+               self._modify(modlist)
+
+       def _delete_strings(self, key, values):
+               return self._delete(key, [e.encode() for e in values])
+
+       def _delete_string(self, key, value):
+               return self._delete_strings(key, [value,])
+
+       @property
+       def objectclasses(self):
+               return self._get_strings("objectClass")
+
+       @staticmethod
+       def _parse_date(s):
+               return datetime.datetime.strptime(s.decode(), "%Y%m%d%H%M%SZ")
+
+
 class Accounts(Object):
        def init(self):
                self.search_base = self.settings.get("ldap_search_base")
 
+       def __len__(self):
+               count = self.memcache.get("accounts:count")
+
+               if count is None:
+                       count = self._count("(objectClass=person)")
+
+                       self.memcache.set("accounts:count", count, 300)
+
+               return count
+
        def __iter__(self):
                # Only return developers (group with ID 1000)
                accounts = self._search("(&(objectClass=posixAccount)(gidNumber=1000))")
@@ -71,6 +221,11 @@ class Accounts(Object):
 
                return results
 
+       def _count(self, query):
+               res = self._query(query, attrlist=["dn"])
+
+               return len(res)
+
        def _search(self, query, attrlist=None, limit=0):
                accounts = []
                for dn, attrs in self._query(query, attrlist=["dn"], limit=limit):
@@ -100,22 +255,19 @@ class Accounts(Object):
 
                return Account(self.backend, dn, attrs)
 
+       @staticmethod
+       def _format_date(t):
+               return t.strftime("%Y%m%d%H%M%SZ")
+
        def get_created_after(self, ts):
-               t = ts.strftime("%Y%m%d%H%M%SZ")
+               return self._search("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
 
-               return self._search("(&(objectClass=person)(createTimestamp>=%s))" % t)
+       def count_created_after(self, ts):
+               return self._count("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
 
        def search(self, query):
-               # Search for exact matches
-               accounts = self._search(
-                       "(&(objectClass=person)(|(uid=%s)(mail=%s)(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
-                       % (query, query, query, query, query, query))
-
-               # Find accounts by name
-               if not accounts:
-                       for account in self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)))" % (query, query)):
-                               if not account in accounts:
-                                       accounts.append(account)
+               accounts = self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)(displayName=*%s*)(mail=*%s*)))" \
+                       % (query, query, query, query))
 
                return sorted(accounts)
 
@@ -125,6 +277,14 @@ class Accounts(Object):
                for result in results:
                        return result
 
+       def uid_is_valid(self, uid):
+               # https://unix.stackexchange.com/questions/157426/what-is-the-regex-to-validate-linux-users
+               m = re.match(r"^[a-z_][a-z0-9_-]{3,31}$", uid)
+               if m:
+                       return True
+
+               return False
+
        def uid_exists(self, uid):
                if self.get_by_uid(uid):
                        return True
@@ -167,12 +327,11 @@ class Accounts(Object):
                        "(&(objectClass=inetOrgPerson)(|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
                        % (number, number, number, number))
 
-       @tornado.gen.coroutine
-       def check_spam(self, uid, email, address):
+       async def check_spam(self, uid, email, address):
                sfs = StopForumSpam(self.backend, uid, email, address)
 
                # Get spam score
-               score = yield sfs.check()
+               score = await sfs.check()
 
                return score >= 50
 
@@ -190,6 +349,10 @@ class Accounts(Object):
                # Convert all uids to lowercase
                uid = uid.lower()
 
+               # Check if UID is valid
+               if not self.uid_is_valid(uid):
+                       raise ValueError("UID is invalid: %s" % uid)
+
                # Check if UID is unique
                if self.uid_exists(uid):
                        raise ValueError("UID exists: %s" % uid)
@@ -228,9 +391,21 @@ class Accounts(Object):
                        first_name=res.first_name, last_name=res.last_name,
                        country_code=res.country_code)
 
+               # Non-EU users do not need to consent to promo emails
+               if account.country_code and not account.country_code in countries.EU_COUNTRIES:
+                       account.consents_to_promotional_emails = True
+
+               # Invite newly registered users to newsletter
+               self.backend.messages.send_template(
+                       "newsletter/subscribe", address="%s <%s>" % (account, account.email))
+
                # Send email about account registration
                self.backend.messages.send_template("people/messages/new-account",
-                       recipients=["admin@ipfire.org"], account=account)
+                       recipients=["moderators@ipfire.org"], account=account)
+
+               # Launch drip campaigns
+               for campaign in ("signup", "christmas"):
+                       self.backend.campaigns.launch(campaign, account)
 
                return account
 
@@ -270,8 +445,10 @@ class Accounts(Object):
        # Session stuff
 
        def create_session(self, account, host):
-               res = self.db.get("INSERT INTO sessions(host, uid) VALUES(%s, %s) \
-                       RETURNING session_id, time_expires", host, account.uid)
+               session_id = util.random_string(64)
+
+               res = self.db.get("INSERT INTO sessions(host, uid, session_id) VALUES(%s, %s, %s) \
+                       RETURNING session_id, time_expires", host, account.uid, session_id)
 
                # Session could not be created
                if not res:
@@ -311,6 +488,9 @@ class Accounts(Object):
                # Cleanup expired account activations
                self.db.execute("DELETE FROM account_activations WHERE expires_at <= NOW()")
 
+               # Cleanup expired account password resets
+               self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()")
+
        # Discourse
 
        def decode_discourse_payload(self, payload, signature):
@@ -347,14 +527,20 @@ class Accounts(Object):
 
                return h.hexdigest()
 
+       @property
+       def countries(self):
+               ret = {}
 
-class Account(Object):
-       def __init__(self, backend, dn, attrs=None):
-               Object.__init__(self, backend)
-               self.dn = dn
+               for country in iso3166.countries:
+                       count = self._count("(&(objectClass=person)(st=%s))" % country.alpha2)
+
+                       if count:
+                               ret[country] = count
+
+               return ret
 
-               self.attributes = attrs or {}
 
+class Account(LDAPObject):
        def __str__(self):
                if self.nickname:
                        return self.nickname
@@ -364,127 +550,14 @@ class Account(Object):
        def __repr__(self):
                return "<%s %s>" % (self.__class__.__name__, self.dn)
 
-       def __eq__(self, other):
-               if isinstance(other, self.__class__):
-                       return self.dn == other.dn
-
        def __lt__(self, other):
                if isinstance(other, self.__class__):
                        return self.name < other.name
 
-       @property
-       def ldap(self):
-               return self.accounts.ldap
-
-       def _exists(self, key):
-               try:
-                       self.attributes[key]
-               except KeyError:
-                       return False
-
-               return True
-
-       def _get(self, key):
-               for value in self.attributes.get(key, []):
-                       yield value
-
-       def _get_bytes(self, key, default=None):
-               for value in self._get(key):
-                       return value
-
-               return default
-
-       def _get_strings(self, key):
-               for value in self._get(key):
-                       yield value.decode()
-
-       def _get_string(self, key, default=None):
-               for value in self._get_strings(key):
-                       return value
-
-               return default
-
-       def _get_phone_numbers(self, key):
-               for value in self._get_strings(key):
-                       yield phonenumbers.parse(value, None)
-
-       def _get_timestamp(self, key):
-               value = self._get_string(key)
-
-               # Parse the timestamp value and returns a datetime object
-               if value:
-                       return datetime.datetime.strptime(value, "%Y%m%d%H%M%SZ")
-
-       def _modify(self, modlist):
-               logging.debug("Modifying %s: %s" % (self.dn, modlist))
-
-               # Authenticate before performing any write operations
-               self.accounts._authenticate()
-
-               # Run modify operation
-               self.ldap.modify_s(self.dn, modlist)
-
+       def _clear_cache(self):
                # Delete cached attributes
                self.memcache.delete("accounts:%s:attrs" % self.dn)
 
-       def _set(self, key, values):
-               current = self._get(key)
-
-               # Don't do anything if nothing has changed
-               if list(current) == values:
-                       return
-
-               # Remove all old values and add all new ones
-               modlist = []
-
-               if self._exists(key):
-                       modlist.append((ldap.MOD_DELETE, key, None))
-
-               # Add new values
-               if values:
-                       modlist.append((ldap.MOD_ADD, key, values))
-
-               # Run modify operation
-               self._modify(modlist)
-
-               # Update cache
-               self.attributes.update({ key : values })
-
-       def _set_bytes(self, key, values):
-               return self._set(key, values)
-
-       def _set_strings(self, key, values):
-               return self._set(key, [e.encode() for e in values if e])
-
-       def _set_string(self, key, value):
-               return self._set_strings(key, [value,])
-
-       def _add(self, key, values):
-               modlist = [
-                       (ldap.MOD_ADD, key, values),
-               ]
-
-               self._modify(modlist)
-
-       def _add_strings(self, key, values):
-               return self._add(key, [e.encode() for e in values])
-
-       def _add_string(self, key, value):
-               return self._add_strings(key, [value,])
-
-       def _delete(self, key, values):
-               modlist = [
-                       (ldap.MOD_DELETE, key, values),
-               ]
-
-               self._modify(modlist)
-
-       def _delete_strings(self, key, values):
-               return self._delete(key, [e.encode() for e in values])
-
-       def _delete_string(self, key, value):
-               return self._delete_strings(key, [value,])
-
        @lazy_property
        def kerberos_attributes(self):
                res = self.backend.accounts._query(
@@ -503,10 +576,6 @@ class Account(Object):
 
                return {}
 
-       @staticmethod
-       def _parse_date(s):
-               return datetime.datetime.strptime(s.decode(), "%Y%m%d%H%M%SZ")
-
        @property
        def last_successful_authentication(self):
                try:
@@ -584,11 +653,37 @@ class Account(Object):
                        self.first_name, self.last_name,
                ))
 
+       def request_password_reset(self, address=None):
+               reset_code = util.random_string(64)
+
+               self.db.execute("INSERT INTO account_password_resets(uid, reset_code, address) \
+                       VALUES(%s, %s, %s)", self.uid, reset_code, address)
+
+               # Send a password reset email
+               self.backend.messages.send_template("auth/messages/password-reset",
+                       recipients=[self.email], priority=100, account=self, reset_code=reset_code)
+
+       def reset_password(self, reset_code, new_password):
+               # Delete the reset token
+               res = self.db.query("DELETE FROM account_password_resets \
+                       WHERE uid = %s AND reset_code = %s AND expires_at >= NOW() \
+                       RETURNING *", self.uid, reset_code)
+
+               # The reset code was invalid
+               if not res:
+                       raise ValueError("Invalid password reset token for %s: %s" % (self, reset_code))
+
+               # Perform password change
+               return self.passwd(new_password)
+
        def is_admin(self):
-               return "sudo" in self.groups
+               return self.is_member_of_group("sudo")
 
        def is_staff(self):
-               return "staff" in self.groups
+               return self.is_member_of_group("staff")
+
+       def is_moderator(self):
+               return self.is_member_of_group("moderators")
 
        def has_shell(self):
                return "posixAccount" in self.classes
@@ -660,30 +755,16 @@ class Account(Object):
 
        @lazy_property
        def groups(self):
-               groups = self.memcache.get("accounts:%s:groups" % self.dn)
-               if groups:
-                       return groups
-
-               # Fetch groups from LDAP
-               groups = self._get_groups()
+               return self.backend.groups._get_groups("(| \
+                       (&(objectClass=groupOfNames)(member=%s)) \
+                       (&(objectClass=posixGroup)(memberUid=%s)) \
+               )" % (self.dn, self.uid))
 
-               # Cache groups for 5 min
-               self.memcache.set("accounts:%s:groups" % self.dn, groups, 300)
-
-               return groups
-
-       def _get_groups(self):
-               groups = []
-
-               res = self.accounts._query("(&(objectClass=posixGroup) \
-                       (memberUid=%s))" % self.uid, ["cn"])
-
-               for dn, attrs in res:
-                       cns = attrs.get("cn")
-                       if cns:
-                               groups.append(cns[0].decode())
-
-               return groups
+       def is_member_of_group(self, gid):
+               """
+                       Returns True if this account is a member of this group
+               """
+               return gid in (g.gid for g in self.groups)
 
        # Created/Modified at
 
@@ -759,6 +840,10 @@ class Account(Object):
        def email(self):
                return self._get_string("mail")
 
+       @property
+       def email_to(self):
+               return "%s <%s>" % (self, self.email)
+
        # Mail Routing Address
 
        def get_mail_routing_address(self):
@@ -789,6 +874,10 @@ class Account(Object):
        def sip_url(self):
                return "%s@ipfire.org" % self.sip_id
 
+       @lazy_property
+       def agent_status(self):
+               return self.backend.talk.freeswitch.get_agent_status(self)
+
        def uses_sip_forwarding(self):
                if self.sip_routing_address:
                        return True
@@ -933,11 +1022,33 @@ class Account(Object):
 
                return ret
 
+       # Description
+
+       def get_description(self):
+               return self._get_string("description")
+
+       def set_description(self, description):
+               self._set_string("description", description)
+
+       description = property(get_description, set_description)
+
+       # Avatar
+
+       def has_avatar(self):
+               has_avatar = self.memcache.get("accounts:%s:has-avatar" % self.uid)
+               if has_avatar is None:
+                       has_avatar = True if self.get_avatar() else False
+
+                       # Cache avatar status for up to 24 hours
+                       self.memcache.set("accounts:%s:has-avatar" % self.uid, has_avatar, 3600 * 24)
+
+               return has_avatar
+
        def avatar_url(self, size=None):
-               url = "https://people.ipfire.org/users/%s.jpg" % self.uid
+               url = "https://people.ipfire.org/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
 
                if size:
-                       url += "?size=%s" % size
+                       url += "&size=%s" % size
 
                return url
 
@@ -965,90 +1076,52 @@ class Account(Object):
 
                return avatar
 
-       def upload_avatar(self, avatar):
-               self._set("jpegPhoto", avatar)
-
-       # SSH Keys
-
-       @lazy_property
-       def ssh_keys(self):
-               ret = []
-
-               for key in self._get_strings("sshPublicKey"):
-                       s = sshpubkeys.SSHKey()
-
-                       try:
-                               s.parse(key)
-                       except (sshpubkeys.InvalidKeyError, NotImplementedError) as e:
-                               logging.warning("Could not parse SSH key %s: %s" % (key, e))
-                               continue
-
-                       ret.append(s)
-
-               return ret
-
-       def get_ssh_key_by_hash_sha256(self, hash_sha256):
-               for key in self.ssh_keys:
-                       if not key.hash_sha256() == hash_sha256:
-                               continue
-
-                       return key
-
-       def add_ssh_key(self, key):
-               k = sshpubkeys.SSHKey()
-
-               # Try to parse the key
-               k.parse(key)
-
-               # Check for types and sufficient sizes
-               if k.key_type == b"ssh-rsa":
-                       if k.bits < 4096:
-                               raise sshpubkeys.TooShortKeyError("RSA keys cannot be smaller than 4096 bits")
+       @property
+       def avatar_hash(self):
+               hash = self.memcache.get("accounts:%s:avatar-hash" % self.dn)
+               if not hash:
+                       h = hashlib.new("md5")
+                       h.update(self.get_avatar() or b"")
+                       hash = h.hexdigest()[:7]
 
-               elif k.key_type == b"ssh-dss":
-                       raise sshpubkeys.InvalidKeyError("DSA keys are not supported")
+                       self.memcache.set("accounts:%s:avatar-hash" % self.dn, hash, 86400)
 
-               # Ignore any duplicates
-               if key in (k.keydata for k in self.ssh_keys):
-                       logging.debug("SSH Key has already been added for %s: %s" % (self, key))
-                       return
+               return hash
 
-               # Prepare transaction
-               modlist = []
+       def upload_avatar(self, avatar):
+               self._set("jpegPhoto", avatar)
 
-               # Add object class if user is not in it, yet
-               if not "ldapPublicKey" in self.classes:
-                       modlist.append((ldap.MOD_ADD, "objectClass", b"ldapPublicKey"))
+               # Delete cached avatar status
+               self.memcache.delete("accounts:%s:has-avatar" % self.dn)
 
-               # Add key
-               modlist.append((ldap.MOD_ADD, "sshPublicKey", key.encode()))
+               # Delete avatar hash
+               self.memcache.delete("accounts:%s:avatar-hash" % self.dn)
 
-               # Save key to LDAP
-               self._modify(modlist)
+       # Consent to promotional emails
 
-               # Append to cache
-               self.ssh_keys.append(k)
+       def get_consents_to_promotional_emails(self):
+               return self.is_member_of_group("promotional-consent")
 
-       def delete_ssh_key(self, key):
-               if not key in (k.keydata for k in self.ssh_keys):
-                       return
+       def set_contents_to_promotional_emails(self, value):
+               group = self.backend.groups.get_by_gid("promotional-consent")
+               assert group, "Could not find group: promotional-consent"
 
-               # Delete key from LDAP
-               if len(self.ssh_keys) > 1:
-                       self._delete_string("sshPublicKey", key)
+               if value is True:
+                       group.add_member(self)
                else:
-                       self._modify([
-                               (ldap.MOD_DELETE, "objectClass", b"ldapPublicKey"),
-                               (ldap.MOD_DELETE, "sshPublicKey", key.encode()),
-                       ])
+                       group.del_member(self)
+
+       consents_to_promotional_emails = property(
+               get_consents_to_promotional_emails,
+               set_contents_to_promotional_emails,
+       )
 
 
 class StopForumSpam(Object):
        def init(self, uid, email, address):
                self.uid, self.email, self.address = uid, email, address
 
-       @tornado.gen.coroutine
-       def send_request(self, **kwargs):
+       async def send_request(self, **kwargs):
                arguments = {
                        "json" : "1",
                }
@@ -1060,14 +1133,13 @@ class StopForumSpam(Object):
                request.body = urllib.parse.urlencode(arguments)
 
                # Send the request
-               response = yield self.backend.http_client.fetch(request)
+               response = await self.backend.http_client.fetch(request)
 
                # Decode the JSON response
                return json.loads(response.body.decode())
 
-       @tornado.gen.coroutine
-       def check_address(self):
-               response = yield self.send_request(ip=self.address)
+       async def check_address(self):
+               response = await self.send_request(ip=self.address)
 
                try:
                        confidence = response["ip"]["confidence"]
@@ -1078,9 +1150,8 @@ class StopForumSpam(Object):
 
                return confidence
 
-       @tornado.gen.coroutine
-       def check_username(self):
-               response = yield self.send_request(username=self.uid)
+       async def check_username(self):
+               response = await self.send_request(username=self.uid)
 
                try:
                        confidence = response["username"]["confidence"]
@@ -1091,9 +1162,8 @@ class StopForumSpam(Object):
 
                return confidence
 
-       @tornado.gen.coroutine
-       def check_email(self):
-               response = yield self.send_request(email=self.email)
+       async def check_email(self):
+               response = await self.send_request(email=self.email)
 
                try:
                        confidence = response["email"]["confidence"]
@@ -1104,8 +1174,7 @@ class StopForumSpam(Object):
 
                return confidence
 
-       @tornado.gen.coroutine
-       def check(self, threshold=95):
+       async def check(self, threshold=95):
                """
                        This function tries to detect if we have a spammer.
 
@@ -1113,15 +1182,172 @@ class StopForumSpam(Object):
                        address and username and if those are on the database, we
                        will send the email address as well.
                """
-               confidences = yield [self.check_address(), self.check_username()]
+               confidences = [await self.check_address(), await self.check_username()]
 
                if any((c < threshold for c in confidences)):
-                       confidences += yield [self.check_email()]
+                       confidences.append(await self.check_email())
 
                # Build a score based on the lowest confidence
                return 100 - min(confidences)
 
 
+class Groups(Object):
+       hidden_groups = (
+               "cn=LDAP Read Only,ou=Group,dc=ipfire,dc=org",
+               "cn=LDAP Read Write,ou=Group,dc=ipfire,dc=org",
+
+               # Everyone is a member of people
+               "cn=people,ou=Group,dc=ipfire,dc=org",
+       )
+
+       @property
+       def search_base(self):
+               return "ou=Group,%s" % self.backend.accounts.search_base
+
+       def _query(self, *args, **kwargs):
+               kwargs.update({
+                       "search_base" : self.backend.groups.search_base,
+               })
+
+               return self.backend.accounts._query(*args, **kwargs)
+
+       def __iter__(self):
+               groups = self.get_all()
+
+               return iter(groups)
+
+       def _get_groups(self, query, **kwargs):
+               res = self._query(query, **kwargs)
+
+               groups = []
+               for dn, attrs in res:
+                       # Skip any hidden groups
+                       if dn in self.hidden_groups:
+                               continue
+
+                       g = Group(self.backend, dn, attrs)
+                       groups.append(g)
+
+               return sorted(groups)
+
+       def _get_group(self, query, **kwargs):
+               kwargs.update({
+                       "limit" : 1,
+               })
+
+               groups = self._get_groups(query, **kwargs)
+               if groups:
+                       return groups[0]
+
+       def get_all(self):
+               return self._get_groups(
+                       "(|(objectClass=posixGroup)(objectClass=groupOfNames))",
+               )
+
+       def get_by_gid(self, gid):
+               return self._get_group(
+                       "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % gid,
+               )
+
+
+class Group(LDAPObject):
+       def __repr__(self):
+               if self.description:
+                       return "<%s %s (%s)>" % (
+                               self.__class__.__name__,
+                               self.gid,
+                               self.description,
+                       )
+
+               return "<%s %s>" % (self.__class__.__name__, self.gid)
+
+       def __str__(self):
+               return self.description or self.gid
+
+       def __lt__(self, other):
+               if isinstance(other, self.__class__):
+                       return (self.description or self.gid) < (other.description or other.gid)
+
+       def __bool__(self):
+               return True
+
+       def __len__(self):
+               """
+                       Returns the number of members in this group
+               """
+               l = 0
+
+               for attr in ("member", "memberUid"):
+                       a = self.attributes.get(attr, None)
+                       if a:
+                               l += len(a)
+
+               return l
+
+       def __iter__(self):
+               return iter(self.members)
+
+       @property
+       def gid(self):
+               return self._get_string("cn")
+
+       @property
+       def description(self):
+               return self._get_string("description")
+
+       @property
+       def email(self):
+               return self._get_string("mail")
+
+       @lazy_property
+       def members(self):
+               members = []
+
+               # Get all members by DN
+               for dn in self._get_strings("member"):
+                       member = self.backend.accounts.get_by_dn(dn)
+                       if member:
+                               members.append(member)
+
+               # Get all members by UID
+               for uid in self._get_strings("memberUid"):
+                       member = self.backend.accounts.get_by_uid(uid)
+                       if member:
+                               members.append(member)
+
+               return sorted(members)
+
+       def add_member(self, account):
+               """
+                       Adds a member to this group
+               """
+               # Do nothing if this user is already in the group
+               if account.is_member_of_group(self.gid):
+                       return
+
+               if "posixGroup" in self.objectclasses:
+                       self._add_string("memberUid", account.uid)
+               else:
+                       self._add_string("member", account.dn)
+
+               # Append to cached list of members
+               self.members.append(account)
+               self.members.sort()
+
+       def del_member(self, account):
+               """
+                       Removes a member from a group
+               """
+               # Do nothing if this user is not in the group
+               if not account.is_member_of_group(self.gid):
+                       return
+
+               if "posixGroup" in self.objectclasses:
+                       self._delete_string("memberUid", account.uid)
+               else:
+                       self._delete_string("member", account.dn)
+
+
 if __name__ == "__main__":
        a = Accounts()