]> git.ipfire.org Git - ipfire.org.git/blobdiff - src/backend/accounts.py
people: Encourage people to upload an avatar
[ipfire.org.git] / src / backend / accounts.py
index 1287e31d368eeea97c0ae15c1d94c1958099bc30..44952a34e75a11b8cbef96e80cda15c878b0e7ec 100644 (file)
@@ -1,21 +1,34 @@
 #!/usr/bin/python
 # encoding: utf-8
 
-import PIL
-import PIL.ImageOps
-import io
+import base64
+import datetime
+import hmac
+import json
 import ldap
 import ldap.modlist
 import logging
+import os
 import phonenumbers
+import re
+import time
+import tornado.httpclient
 import urllib.parse
 import urllib.request
+import zxcvbn
 
+from . import countries
 from . import util
 from .decorators import *
 from .misc import Object
 
+# Set the client keytab name
+os.environ["KRB5_CLIENT_KTNAME"] = "/etc/ipfire.org/ldap.keytab"
+
 class Accounts(Object):
+       def init(self):
+               self.search_base = self.settings.get("ldap_search_base")
+
        def __iter__(self):
                # Only return developers (group with ID 1000)
                accounts = self._search("(&(objectClass=posixAccount)(gidNumber=1000))")
@@ -26,70 +39,122 @@ class Accounts(Object):
        def ldap(self):
                # Connect to LDAP server
                ldap_uri = self.settings.get("ldap_uri")
-               conn = ldap.initialize(ldap_uri)
 
-               # Bind with username and password
-               bind_dn = self.settings.get("ldap_bind_dn")
-               if bind_dn:
-                       bind_pw = self.settings.get("ldap_bind_pw", "")
-                       conn.simple_bind(bind_dn, bind_pw)
+               logging.debug("Connecting to LDAP server: %s" % ldap_uri)
 
-               return conn
+               # Connect to the LDAP server
+               return ldap.ldapobject.ReconnectLDAPObject(ldap_uri,
+                       retry_max=10, retry_delay=3)
 
-       def _query(self, query, attrlist=None, limit=0):
-               logging.debug("Performing LDAP query: %s" % query)
+       def _authenticate(self):
+               # Authenticate against LDAP server using Kerberos
+               self.ldap.sasl_gssapi_bind_s()
 
-               search_base = self.settings.get("ldap_search_base")
+       def test_ldap(self):
+               logging.info("Testing LDAP connection...")
 
-               try:
-                       results = self.ldap.search_ext_s(search_base, ldap.SCOPE_SUBTREE,
-                               query, attrlist=attrlist, sizelimit=limit)
-               except:
-                       # Close current connection
-                       self.ldap.close()
-                       del self.ldap
+               self._authenticate()
+
+               logging.info("Successfully authenticated as %s" % self.ldap.whoami_s())
 
-                       raise
+       def _query(self, query, attrlist=None, limit=0, search_base=None):
+               logging.debug("Performing LDAP query (%s): %s" \
+                       % (search_base or self.search_base, query))
+
+               t = time.time()
+
+               results = self.ldap.search_ext_s(search_base or self.search_base,
+                       ldap.SCOPE_SUBTREE, query, attrlist=attrlist, sizelimit=limit)
+
+               # Log time it took to perform the query
+               logging.debug("Query took %.2fms" % ((time.time() - t) * 1000.0))
 
                return results
 
        def _search(self, query, attrlist=None, limit=0):
                accounts = []
-
-               for dn, attrs in self._query(query, attrlist=attrlist, limit=limit):
-                       account = Account(self.backend, dn, attrs)
+               for dn, attrs in self._query(query, attrlist=["dn"], limit=limit):
+                       account = self.get_by_dn(dn)
                        accounts.append(account)
 
                return accounts
 
+       def _get_attrs(self, dn):
+               """
+                       Fetches all attributes for the given distinguished name
+               """
+               results = self._query("(objectClass=*)", search_base=dn, limit=1,
+                       attrlist=("*", "createTimestamp", "modifyTimestamp"))
+
+               for dn, attrs in results:
+                       return attrs
+
+       def get_by_dn(self, dn):
+               attrs = self.memcache.get("accounts:%s:attrs" % dn)
+               if attrs is None:
+                       attrs = self._get_attrs(dn)
+                       assert attrs, dn
+
+                       # Cache all attributes for 5 min
+                       self.memcache.set("accounts:%s:attrs" % dn, attrs, 300)
+
+               return Account(self.backend, dn, attrs)
+
+       def get_created_after(self, ts):
+               t = ts.strftime("%Y%m%d%H%M%SZ")
+
+               return self._search("(&(objectClass=person)(createTimestamp>=%s))" % t)
+
        def search(self, query):
                # Search for exact matches
-               accounts = self._search("(&(objectClass=posixAccount) \
-                       (|(uid=%s)(mail=%s)(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
+               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=posixAccount)(|(cn=*%s*)(uid=*%s*)))" % (query, query)):
+                       for account in self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)))" % (query, query)):
                                if not account in accounts:
                                        accounts.append(account)
 
                return sorted(accounts)
 
        def _search_one(self, query):
-               result = self._search(query, limit=1)
-               assert len(result) <= 1
+               results = self._search(query, limit=1)
+
+               for result in results:
+                       return result
+
+       def uid_is_valid(self, uid):
+               # UID must be at least four characters
+               if len(uid) <= 4:
+                       return False
+
+               # https://unix.stackexchange.com/questions/157426/what-is-the-regex-to-validate-linux-users
+               m = re.match(r"^[a-z_]([a-z0-9_-]{0,31}|[a-z0-9_-]{0,30}\$)$", uid)
+               if m:
+                       return True
+
+               return False
 
-               if result:
-                       return result[0]
+       def uid_exists(self, uid):
+               if self.get_by_uid(uid):
+                       return True
+
+               res = self.db.get("SELECT 1 FROM account_activations \
+                       WHERE uid = %s AND expires_at > NOW()", uid)
+
+               if res:
+                       return True
+
+               # Account with uid does not exist, yet
+               return False
 
        def get_by_uid(self, uid):
-               return self._search_one("(&(objectClass=posixAccount)(uid=%s))" % uid)
+               return self._search_one("(&(objectClass=person)(uid=%s))" % uid)
 
        def get_by_mail(self, mail):
-               return self._search_one("(&(objectClass=posixAccount)(mail=%s))" % mail)
-
-       find = get_by_uid
+               return self._search_one("(&(objectClass=inetOrgPerson)(mail=%s))" % mail)
 
        def find_account(self, s):
                account = self.get_by_uid(s)
@@ -99,24 +164,132 @@ class Accounts(Object):
                return self.get_by_mail(s)
 
        def get_by_sip_id(self, sip_id):
-               return self._search_one("(|(&(objectClass=sipUser)(sipAuthenticationUser=%s)) \
-                       (&(objectClass=sipRoutingObject)(sipLocalAddress=%s)))" % (sip_id, sip_id))
+               if not sip_id:
+                       return
+
+               return self._search_one(
+                       "(|(&(objectClass=sipUser)(sipAuthenticationUser=%s))(&(objectClass=sipRoutingObject)(sipLocalAddress=%s)))" \
+                       % (sip_id, sip_id))
 
        def get_by_phone_number(self, number):
-               return self._search_one("(&(objectClass=posixAccount) \
-                       (|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
+               if not number:
+                       return
+
+               return self._search_one(
+                       "(&(objectClass=inetOrgPerson)(|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
                        % (number, number, number, number))
 
-       # Session stuff
+       @tornado.gen.coroutine
+       def check_spam(self, uid, email, address):
+               sfs = StopForumSpam(self.backend, uid, email, address)
 
-       def _cleanup_expired_sessions(self):
-               self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
+               # Get spam score
+               score = yield sfs.check()
+
+               return score >= 50
+
+       def auth(self, username, password):
+               # Find account
+               account = self.backend.accounts.find_account(username)
+
+               # Check credentials
+               if account and account.check_password(password):
+                       return account
+
+       # Registration
+
+       def register(self, uid, email, first_name, last_name, country_code=None):
+               # 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)
+
+               # Generate a random activation code
+               activation_code = util.random_string(36)
+
+               # Create an entry in our database until the user
+               # has activated the account
+               self.db.execute("INSERT INTO account_activations(uid, activation_code, \
+                       email, first_name, last_name, country_code) VALUES(%s, %s, %s, %s, %s, %s)",
+                       uid, activation_code, email, first_name, last_name, country_code)
+
+               # Send an account activation email
+               self.backend.messages.send_template("auth/messages/register",
+                       recipients=[email], priority=100, uid=uid,
+                       activation_code=activation_code, email=email,
+                       first_name=first_name, last_name=last_name)
+
+       def activate(self, uid, activation_code):
+               res = self.db.get("DELETE FROM account_activations \
+                       WHERE uid = %s AND activation_code = %s AND expires_at > NOW() \
+                       RETURNING *", uid, activation_code)
+
+               # Return nothing when account was not found
+               if not res:
+                       return
+
+               # Return the account if it has already been created
+               account = self.get_by_uid(uid)
+               if account:
+                       return account
+
+               # Create a new account on the LDAP database
+               account = self.create(uid, res.email,
+                       first_name=res.first_name, last_name=res.last_name,
+                       country_code=res.country_code)
+
+               # Send email about account registration
+               self.backend.messages.send_template("people/messages/new-account",
+                       recipients=["moderators@ipfire.org"], account=account)
+
+               return account
+
+       def create(self, uid, email, first_name, last_name, country_code=None):
+               cn = "%s %s" % (first_name, last_name)
+
+               # Account Parameters
+               account = {
+                       "objectClass"  : [b"top", b"person", b"inetOrgPerson"],
+                       "mail"         : email.encode(),
+
+                       # Name
+                       "cn"           : cn.encode(),
+                       "sn"           : last_name.encode(),
+                       "givenName"    : first_name.encode(),
+               }
+
+               logging.info("Creating new account: %s: %s" % (uid, account))
+
+               # Create DN
+               dn = "uid=%s,ou=People,dc=ipfire,dc=org" % uid
+
+               # Create account on LDAP
+               self.accounts._authenticate()
+               self.ldap.add_s(dn, ldap.modlist.addModlist(account))
+
+               # Fetch the account
+               account = self.get_by_dn(dn)
+
+               # Optionally set country code
+               if country_code:
+                       account.country_code = country_code
+
+               # Return account
+               return account
+
+       # Session stuff
 
        def create_session(self, account, host):
-               self._cleanup_expired_sessions()
+               session_id = util.random_string(64)
 
-               res = self.db.get("INSERT INTO sessions(host, uid) VALUES(%s, %s) \
-                       RETURNING session_id, time_expires", host, account.uid)
+               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:
@@ -131,7 +304,6 @@ class Accounts(Object):
 
                self.db.execute("DELETE FROM sessions \
                        WHERE session_id = %s AND host = %s", session_id, host)
-               self._cleanup_expired_sessions()
 
        def get_by_session(self, session_id, host):
                logging.debug("Looking up session %s" % session_id)
@@ -149,7 +321,50 @@ class Accounts(Object):
                        WHERE session_id = %s AND host = %s", session_id, host)
 
                return self.get_by_uid(res.uid)
-               
+
+       def cleanup(self):
+               # Cleanup expired sessions
+               self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
+
+               # Cleanup expired account activations
+               self.db.execute("DELETE FROM account_activations WHERE expires_at <= NOW()")
+
+       # Discourse
+
+       def decode_discourse_payload(self, payload, signature):
+               # Check signature
+               calculated_signature = self.sign_discourse_payload(payload)
+
+               if not hmac.compare_digest(signature, calculated_signature):
+                       raise ValueError("Invalid signature: %s" % signature)
+
+               # Decode the query string
+               qs = base64.b64decode(payload).decode()
+
+               # Parse the query string
+               data = {}
+               for key, val in urllib.parse.parse_qsl(qs):
+                       data[key] = val
+
+               return data
+
+       def encode_discourse_payload(self, **args):
+               # Encode the arguments into an URL-formatted string
+               qs = urllib.parse.urlencode(args).encode()
+
+               # Encode into base64
+               return base64.b64encode(qs).decode()
+
+       def sign_discourse_payload(self, payload, secret=None):
+               if secret is None:
+                       secret = self.settings.get("discourse_sso_secret")
+
+               # Calculate a HMAC using SHA256
+               h = hmac.new(secret.encode(),
+                       msg=payload.encode(), digestmod="sha256")
+
+               return h.hexdigest()
+
 
 class Account(Object):
        def __init__(self, backend, dn, attrs=None):
@@ -159,6 +374,9 @@ class Account(Object):
                self.attributes = attrs or {}
 
        def __str__(self):
+               if self.nickname:
+                       return self.nickname
+
                return self.name
 
        def __repr__(self):
@@ -208,12 +426,25 @@ class Account(Object):
                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)
 
+               # Delete cached attributes
+               self.memcache.delete("accounts:%s:attrs" % self.dn)
+
        def _set(self, key, values):
                current = self._get(key)
 
@@ -228,7 +459,8 @@ class Account(Object):
                        modlist.append((ldap.MOD_DELETE, key, None))
 
                # Add new values
-               modlist.append((ldap.MOD_ADD, key, values))
+               if values:
+                       modlist.append((ldap.MOD_ADD, key, values))
 
                # Run modify operation
                self._modify(modlist)
@@ -240,11 +472,101 @@ class Account(Object):
                return self._set(key, values)
 
        def _set_strings(self, key, values):
-               return self._set(key, [e.encode() for e in 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(
+                       "(&(objectClass=krbPrincipal)(krbPrincipalName=%s@IPFIRE.ORG))" % self.uid,
+                       attrlist=[
+                               "krbLastSuccessfulAuth",
+                               "krbLastPasswordChange",
+                               "krbLastFailedAuth",
+                               "krbLoginFailedCount",
+                       ],
+                       limit=1,
+                       search_base="cn=krb5,%s" % self.backend.accounts.search_base)
+
+               for dn, attrs in res:
+                       return { key : attrs[key][0] for key in attrs }
+
+               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:
+                       s = self.kerberos_attributes["krbLastSuccessfulAuth"]
+               except KeyError:
+                       return None
+
+               return self._parse_date(s)
+
+       @property
+       def last_failed_authentication(self):
+               try:
+                       s = self.kerberos_attributes["krbLastFailedAuth"]
+               except KeyError:
+                       return None
+
+               return self._parse_date(s)
+
+       @property
+       def failed_login_count(self):
+               try:
+                       count = self.kerberos_attributes["krbLoginFailedCount"].decode()
+               except KeyError:
+                       return 0
+
+               try:
+                       return int(count)
+               except ValueError:
+                       return 0
+
+       def passwd(self, password):
+               """
+                       Sets a new password
+               """
+               # The new password must have a score of 3 or better
+               quality = self.check_password_quality(password)
+               if quality["score"] < 3:
+                       raise ValueError("Password too weak")
+
+               self.accounts._authenticate()
+               self.ldap.passwd_s(self.dn, None, password)
+
        def check_password(self, password):
                """
                        Bind to the server with given credentials and return
@@ -252,23 +574,51 @@ class Account(Object):
 
                        Raises exceptions from the server on any other errors.
                """
+               if not password:
+                       return
 
                logging.debug("Checking credentials for %s" % self.dn)
+
+               # Create a new LDAP connection
+               ldap_uri = self.backend.settings.get("ldap_uri")
+               conn = ldap.initialize(ldap_uri)
+
                try:
-                       self.ldap.simple_bind_s(self.dn, password.encode("utf-8"))
+                       conn.simple_bind_s(self.dn, password.encode("utf-8"))
                except ldap.INVALID_CREDENTIALS:
-                       logging.debug("Account credentials are invalid.")
+                       logging.debug("Account credentials are invalid for %s" % self)
                        return False
 
-               logging.debug("Successfully authenticated.")
+               logging.info("Successfully authenticated %s" % self)
+
                return True
 
+       def check_password_quality(self, password):
+               """
+                       Passwords are passed through zxcvbn to make sure
+                       that they are strong enough.
+               """
+               return zxcvbn.zxcvbn(password, user_inputs=(
+                       self.first_name, self.last_name,
+               ))
+
        def is_admin(self):
-               return "wheel" in self.groups
+               return self.is_member_of_group("sudo")
+
+       def is_staff(self):
+               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
+
+       def has_mail(self):
+               return "postfixMailUser" in self.classes
 
-       def is_talk_enabled(self):
-               return "sipUser" in self.classes or "sipRoutingObject" in self.classes \
-                       or self.telephone_numbers or self.address
+       def has_sip(self):
+               return "sipUser" in self.classes or "sipRoutingObject" in self.classes
 
        def can_be_managed_by(self, account):
                """
@@ -293,6 +643,16 @@ class Account(Object):
        def name(self):
                return self._get_string("cn")
 
+       # Nickname
+
+       def get_nickname(self):
+               return self._get_string("displayName")
+
+       def set_nickname(self, nickname):
+               self._set_string("displayName", nickname)
+
+       nickname = property(get_nickname, set_nickname)
+
        # First Name
 
        def get_first_name(self):
@@ -321,10 +681,23 @@ class Account(Object):
 
        @lazy_property
        def groups(self):
+               groups = self.memcache.get("accounts:%s:groups" % self.dn)
+               if not groups:
+                       # Fetch groups from LDAP
+                       groups = self._get_groups()
+
+                       # Cache groups for 5 min
+                       self.memcache.set("accounts:%s:groups" % self.dn, groups, 300)
+
+               return sorted((Group(self.backend, gid) for gid in groups))
+
+       def _get_groups(self):
                groups = []
 
-               res = self.accounts._query("(&(objectClass=posixGroup) \
-                       (memberUid=%s))" % self.uid, ["cn"])
+               res = self.accounts._query("(| \
+                       (&(objectClass=groupOfNames)(member=%s)) \
+                       (&(objectClass=posixGroup)(memberUid=%s)) \
+               )" % (self.dn, self.uid), ["cn"])
 
                for dn, attrs in res:
                        cns = attrs.get("cn")
@@ -333,40 +706,85 @@ class Account(Object):
 
                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
+
+       @property
+       def created_at(self):
+               return self._get_timestamp("createTimestamp")
+
+       @property
+       def modified_at(self):
+               return self._get_timestamp("modifyTimestamp")
+
        # Address
 
-       def get_address(self):
-               address = self._get_string("homePostalAddress")
+       @property
+       def address(self):
+               address = []
+
+               if self.street:
+                       address += self.street.splitlines()
 
-               if address:
-                       return (line.strip() for line in address.split(","))
+               if self.postal_code and self.city:
+                       if self.country_code in ("AT", "DE"):
+                               address.append("%s %s" % (self.postal_code, self.city))
+                       else:
+                               address.append("%s, %s" % (self.city, self.postal_code))
+               else:
+                       address.append(self.city or self.postal_code)
+
+               if self.country_name:
+                       address.append(self.country_name)
+
+               return address
+
+       def get_street(self):
+               return self._get_string("street") or self._get_string("homePostalAddress")
+
+       def set_street(self, street):
+               self._set_string("street", street)
 
-               return []
+       street = property(get_street, set_street)
 
-       def set_address(self, address):
-               data = ", ".join(address.splitlines())
+       def get_city(self):
+               return self._get_string("l") or ""
 
-               self._set_bytes("homePostalAddress", data.encode())
+       def set_city(self, city):
+               self._set_string("l", city)
 
-       address = property(get_address, set_address)
+       city = property(get_city, set_city)
+
+       def get_postal_code(self):
+               return self._get_string("postalCode") or ""
+
+       def set_postal_code(self, postal_code):
+               self._set_string("postalCode", postal_code)
+
+       postal_code = property(get_postal_code, set_postal_code)
+
+       # XXX This should be c
+       def get_country_code(self):
+               return self._get_string("st")
+
+       def set_country_code(self, country_code):
+               self._set_string("st", country_code)
+
+       country_code = property(get_country_code, set_country_code)
+
+       @property
+       def country_name(self):
+               if self.country_code:
+                       return countries.get_name(self.country_code)
 
        @property
        def email(self):
-               name = self.name.lower()
-               name = name.replace(" ", ".")
-               name = name.replace("Ä", "Ae")
-               name = name.replace("Ö", "Oe")
-               name = name.replace("Ü", "Ue")
-               name = name.replace("ä", "ae")
-               name = name.replace("ö", "oe")
-               name = name.replace("ü", "ue")
-
-               for mail in self.attributes.get("mail", []):
-                       if mail.decode().startswith("%s@ipfire.org" % name):
-                               return mail
-
-               # If everything else fails, we will go with the UID
-               return "%s@ipfire.org" % self.uid
+               return self._get_string("mail")
 
        # Mail Routing Address
 
@@ -374,7 +792,7 @@ class Account(Object):
                return self._get_string("mailRoutingAddress", None)
 
        def set_mail_routing_address(self, address):
-               self._set_string("mailRoutingAddress", address)
+               self._set_string("mailRoutingAddress", address or None)
 
        mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
 
@@ -419,29 +837,42 @@ class Account(Object):
                        return
 
                if address:
-                       modlist = [
-                               # This is no longer a SIP user any more
-                               (ldap.MOD_DELETE, "objectClass", b"sipUser"),
-                               (ldap.MOD_DELETE, "sipAuthenticationUser", None),
-                               (ldap.MOD_DELETE, "sipPassword", None),
-
-                               (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
-                               (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
-                               (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
-                       ]
+                       # This is no longer a SIP user any more
+                       try:
+                               self._modify([
+                                       (ldap.MOD_DELETE, "objectClass", b"sipUser"),
+                                       (ldap.MOD_DELETE, "sipAuthenticationUser", None),
+                                       (ldap.MOD_DELETE, "sipPassword", None),
+                               ])
+                       except ldap.NO_SUCH_ATTRIBUTE:
+                               pass
+
+                       # Set new routing object
+                       try:
+                               self._modify([
+                                       (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
+                                       (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
+                                       (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
+                               ])
+
+                       # If this is a change, we cannot add this again
+                       except ldap.TYPE_OR_VALUE_EXISTS:
+                               self._set_string("sipRoutingAddress", address)
                else:
-                       modlist = [
-                               (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
-                               (ldap.MOD_DELETE, "sipLocalAddress", None),
-                               (ldap.MOD_DELETE, "sipRoutingAddress", None),
-
+                       try:
+                               self._modify([
+                                       (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
+                                       (ldap.MOD_DELETE, "sipLocalAddress", None),
+                                       (ldap.MOD_DELETE, "sipRoutingAddress", None),
+                               ])
+                       except ldap.NO_SUCH_ATTRIBUTE:
+                               pass
+
+                       self._modify([
                                (ldap.MOD_ADD, "objectClass", b"sipUser"),
                                (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
                                (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
-                       ]
-
-               # Run modification
-               self._modify(modlist)
+                       ])
 
                # XXX Cache is invalid here
 
@@ -458,11 +889,28 @@ class Account(Object):
 
                return sip_registrations
 
+       @lazy_property
+       def sip_channels(self):
+               return self.backend.talk.freeswitch.get_sip_channels(self)
+
        def get_cdr(self, date=None, limit=None):
                return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit)
 
        # Phone Numbers
 
+       @lazy_property
+       def phone_number(self):
+               """
+                       Returns the IPFire phone number
+               """
+               if self.sip_id:
+                       return phonenumbers.parse("+4923636035%s" % self.sip_id)
+
+       @lazy_property
+       def fax_number(self):
+               if self.sip_id:
+                       return phonenumbers.parse("+49236360359%s" % self.sip_id)
+
        def get_phone_numbers(self):
                ret = []
 
@@ -500,15 +948,30 @@ class Account(Object):
 
        @property
        def _all_telephone_numbers(self):
-               return [ self.sip_id, ] + list(self.phone_numbers)
+               ret = [ self.sip_id, ]
 
-       def avatar_url(self, size=None):
-               if self.backend.debug:
-                       hostname = "http://people.dev.ipfire.org"
-               else:
-                       hostname = "https://people.ipfire.org"
+               if self.phone_number:
+                       s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
+                       ret.append(s)
 
-               url = "%s/users/%s.jpg" % (hostname, self.uid)
+               for number in self.phone_numbers:
+                       s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
+                       ret.append(s)
+
+               return ret
+
+       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
 
                if size:
                        url += "?size=%s" % size
@@ -516,37 +979,174 @@ class Account(Object):
                return url
 
        def get_avatar(self, size=None):
-               avatar = self._get_bytes("jpegPhoto")
-               if not avatar:
+               photo = self._get_bytes("jpegPhoto")
+
+               # Exit if no avatar is available
+               if not photo:
                        return
 
-               if not size:
+               # Return the raw image if no size was requested
+               if size is None:
+                       return photo
+
+               # Try to retrieve something from the cache
+               avatar = self.memcache.get("accounts:%s:avatar:%s" % (self.dn, size))
+               if avatar:
                        return avatar
 
-               return self._resize_avatar(avatar, size)
+               # Generate a new thumbnail
+               avatar = util.generate_thumbnail(photo, size, square=True)
 
-       def _resize_avatar(self, image, size):
-               image = PIL.Image.open(io.BytesIO(image))
+               # Save to cache for 15m
+               self.memcache.set("accounts:%s:avatar:%s" % (self.dn, size), avatar, 900)
 
-               # Convert RGBA images into RGB because JPEG doesn't support alpha-channels
-               if image.mode == "RGBA":
-                       image = image.convert("RGB")
+               return avatar
+
+       def upload_avatar(self, avatar):
+               self._set("jpegPhoto", avatar)
 
-               # Resize the image to the desired resolution (and make it square)
-               thumbnail = PIL.ImageOps.fit(image, (size, size), PIL.Image.ANTIALIAS)
+               # Delete cached avatar status
+               self.memcache.delete("accounts:%s:has-avatar" % self.uid)
 
-               with io.BytesIO() as f:
-                       # If writing out the image does not work with optimization,
-                       # we try to write it out without any optimization.
-                       try:
-                               thumbnail.save(f, "JPEG", optimize=True, quality=98)
-                       except:
-                               thumbnail.save(f, "JPEG", quality=98)
 
-                       return f.getvalue()
+class StopForumSpam(Object):
+       def init(self, uid, email, address):
+               self.uid, self.email, self.address = uid, email, address
 
-       def upload_avatar(self, avatar):
-               self._set("jpegPhoto", avatar)
+       @tornado.gen.coroutine
+       def send_request(self, **kwargs):
+               arguments = {
+                       "json" : "1",
+               }
+               arguments.update(kwargs)
+
+               # Create request
+               request = tornado.httpclient.HTTPRequest(
+                       "https://api.stopforumspam.org/api", method="POST")
+               request.body = urllib.parse.urlencode(arguments)
+
+               # Send the request
+               response = yield 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)
+
+               try:
+                       confidence = response["ip"]["confidence"]
+               except KeyError:
+                       confidence = 100
+
+               logging.debug("Confidence for %s: %s" % (self.address, confidence))
+
+               return confidence
+
+       @tornado.gen.coroutine
+       def check_username(self):
+               response = yield self.send_request(username=self.uid)
+
+               try:
+                       confidence = response["username"]["confidence"]
+               except KeyError:
+                       confidence = 100
+
+               logging.debug("Confidence for %s: %s" % (self.uid, confidence))
+
+               return confidence
+
+       @tornado.gen.coroutine
+       def check_email(self):
+               response = yield self.send_request(email=self.email)
+
+               try:
+                       confidence = response["email"]["confidence"]
+               except KeyError:
+                       confidence = 100
+
+               logging.debug("Confidence for %s: %s" % (self.email, confidence))
+
+               return confidence
+
+       @tornado.gen.coroutine
+       def check(self, threshold=95):
+               """
+                       This function tries to detect if we have a spammer.
+
+                       To honour the privacy of our users, we only send the IP
+                       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()]
+
+               if any((c < threshold for c in confidences)):
+                       confidences += yield [self.check_email()]
+
+               # Build a score based on the lowest confidence
+               return 100 - min(confidences)
+
+
+
+class Groups(Object):
+       @property
+       def search_base(self):
+               return "ou=Group,%s" % self.backend.accounts.search_base
+
+
+class Group(Object):
+       def init(self, gid):
+               self.gid = gid
+
+       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 __eq__(self, other):
+               if isinstance(other, self.__class__):
+                       return self.gid == other.gid
+
+       def __lt__(self, other):
+               if isinstance(other, self.__class__):
+                       return (self.description or self.gid) < (other.description or other.gid)
+
+       @lazy_property
+       def attributes(self):
+               attrs = self.memcache.get("groups:%s:attrs" % self.gid)
+               if not attrs:
+                       attrs = self._get_attrs()
+
+                       # Cache this for 5 mins
+                       self.memcache.set("groups:%s:attrs" % self.gid, attrs, 300)
+
+               return attrs
+
+       def _get_attrs(self):
+               res = self.backend.accounts._query(
+                       "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % self.gid,
+                       search_base=self.backend.groups.search_base, limit=1)
+
+               for dn, attrs in res:
+                       return attrs
+
+       @property
+       def description(self):
+               try:
+                       description = self.attributes["description"][0]
+               except KeyError:
+                       return None
+
+               return description.decode()
 
 
 if __name__ == "__main__":