]> 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 a1b6ee212a590c4b3c577b9bf955fb4831af709b..8dcb051576a964ca0fee53ae35a55ed169dfd1da 100644 (file)
 #!/usr/bin/python
 # encoding: utf-8
 
-import PIL
-import PIL.ImageOps
+import base64
 import datetime
-import io
+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
 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 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))")
@@ -29,61 +189,101 @@ 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
-                       del self.ldap
+               self._authenticate()
+
+               logging.info("Successfully authenticated as %s" % self.ldap.whoami_s())
+
+       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)
 
-                       raise
+               # Log time it took to perform the query
+               logging.debug("Query took %.2fms" % ((time.time() - t) * 1000.0))
 
                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=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 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))
+       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
 
-               # 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)
+       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)
+
+       @staticmethod
+       def _format_date(t):
+               return t.strftime("%Y%m%d%H%M%SZ")
+
+       def get_created_after(self, ts):
+               return self._search("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
+
+       def count_created_after(self, ts):
+               return self._count("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
+
+       def search(self, query):
+               accounts = self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)(displayName=*%s*)(mail=*%s*)))" \
+                       % (query, query, query, query))
 
                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
 
-               if result:
-                       return result[0]
+       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):
@@ -104,8 +304,6 @@ class Accounts(Object):
        def get_by_mail(self, mail):
                return self._search_one("(&(objectClass=inetOrgPerson)(mail=%s))" % mail)
 
-       find = get_by_uid
-
        def find_account(self, s):
                account = self.get_by_uid(s)
                if account:
@@ -114,51 +312,143 @@ 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=inetOrgPerson) \
-                       (|(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))
 
+       async def check_spam(self, uid, email, address):
+               sfs = StopForumSpam(self.backend, uid, email, address)
+
+               # Get spam score
+               score = await 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 create(self, uid, email, first_name, last_name):
+       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.get_by_uid(uid):
+               if self.uid_exists(uid):
                        raise ValueError("UID exists: %s" % uid)
 
-               activation_code = util.random_string(24)
+               # 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)
+
+               # 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=["moderators@ipfire.org"], account=account)
+
+               # Launch drip campaigns
+               for campaign in ("signup", "christmas"):
+                       self.backend.campaigns.launch(campaign, 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"],
-                       "userPassword" : activation_code.encode(),
                        "mail"         : email.encode(),
 
                        # Name
-                       "cn"           : b"%s %s" % (first_name.encode(), last_name.encode()),
+                       "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.ldap.add_s("uid=%s,ou=People,dc=mcfly,dc=local" % uid, ldap.modlist.addModlist(account))
+               self.accounts._authenticate()
+               self.ldap.add_s(dn, ldap.modlist.addModlist(account))
 
-               # TODO Send email with activation code
-               pass
+               # Fetch the account
+               account = self.get_by_dn(dn)
 
-       # Session stuff
+               # Optionally set country code
+               if country_code:
+                       account.country_code = country_code
 
-       def _cleanup_expired_sessions(self):
-               self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
+               # 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:
@@ -173,7 +463,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)
@@ -191,128 +480,131 @@ class Accounts(Object):
                        WHERE session_id = %s AND host = %s", session_id, host)
 
                return self.get_by_uid(res.uid)
-               
-
-class Account(Object):
-       def __init__(self, backend, dn, attrs=None):
-               Object.__init__(self, backend)
-               self.dn = dn
-
-               self.attributes = attrs or {}
-
-       def __str__(self):
-               return self.name
 
-       def __repr__(self):
-               return "<%s %s>" % (self.__class__.__name__, self.dn)
+       def cleanup(self):
+               # Cleanup expired sessions
+               self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
 
-       def __eq__(self, other):
-               if isinstance(other, self.__class__):
-                       return self.dn == other.dn
+               # Cleanup expired account activations
+               self.db.execute("DELETE FROM account_activations WHERE expires_at <= NOW()")
 
-       def __lt__(self, other):
-               if isinstance(other, self.__class__):
-                       return self.name < other.name
+               # Cleanup expired account password resets
+               self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()")
 
-       @property
-       def ldap(self):
-               return self.accounts.ldap
+       # Discourse
 
-       def _exists(self, key):
-               try:
-                       self.attributes[key]
-               except KeyError:
-                       return False
+       def decode_discourse_payload(self, payload, signature):
+               # Check signature
+               calculated_signature = self.sign_discourse_payload(payload)
 
-               return True
+               if not hmac.compare_digest(signature, calculated_signature):
+                       raise ValueError("Invalid signature: %s" % signature)
 
-       def _get(self, key):
-               for value in self.attributes.get(key, []):
-                       yield value
+               # Decode the query string
+               qs = base64.b64decode(payload).decode()
 
-       def _get_bytes(self, key, default=None):
-               for value in self._get(key):
-                       return value
+               # Parse the query string
+               data = {}
+               for key, val in urllib.parse.parse_qsl(qs):
+                       data[key] = val
 
-               return default
+               return data
 
-       def _get_strings(self, key):
-               for value in self._get(key):
-                       yield value.decode()
+       def encode_discourse_payload(self, **args):
+               # Encode the arguments into an URL-formatted string
+               qs = urllib.parse.urlencode(args).encode()
 
-       def _get_string(self, key, default=None):
-               for value in self._get_strings(key):
-                       return value
+               # Encode into base64
+               return base64.b64encode(qs).decode()
 
-               return default
+       def sign_discourse_payload(self, payload, secret=None):
+               if secret is None:
+                       secret = self.settings.get("discourse_sso_secret")
 
-       def _get_phone_numbers(self, key):
-               for value in self._get_strings(key):
-                       yield phonenumbers.parse(value, None)
+               # Calculate a HMAC using SHA256
+               h = hmac.new(secret.encode(),
+                       msg=payload.encode(), digestmod="sha256")
 
-       def _modify(self, modlist):
-               logging.debug("Modifying %s: %s" % (self.dn, modlist))
+               return h.hexdigest()
 
-               # Run modify operation
-               self.ldap.modify_s(self.dn, modlist)
+       @property
+       def countries(self):
+               ret = {}
 
-       def _set(self, key, values):
-               current = self._get(key)
+               for country in iso3166.countries:
+                       count = self._count("(&(objectClass=person)(st=%s))" % country.alpha2)
 
-               # Don't do anything if nothing has changed
-               if list(current) == values:
-                       return
+                       if count:
+                               ret[country] = count
 
-               # Remove all old values and add all new ones
-               modlist = []
+               return ret
 
-               if self._exists(key):
-                       modlist.append((ldap.MOD_DELETE, key, None))
 
-               # Add new values
-               if values:
-                       modlist.append((ldap.MOD_ADD, key, values))
+class Account(LDAPObject):
+       def __str__(self):
+               if self.nickname:
+                       return self.nickname
 
-               # Run modify operation
-               self._modify(modlist)
+               return self.name
 
-               # Update cache
-               self.attributes.update({ key : values })
+       def __repr__(self):
+               return "<%s %s>" % (self.__class__.__name__, self.dn)
 
-       def _set_bytes(self, key, values):
-               return self._set(key, values)
+       def __lt__(self, other):
+               if isinstance(other, self.__class__):
+                       return self.name < other.name
 
-       def _set_strings(self, key, values):
-               return self._set(key, [e.encode() for e in values if e])
+       def _clear_cache(self):
+               # Delete cached attributes
+               self.memcache.delete("accounts:%s:attrs" % self.dn)
 
-       def _set_string(self, key, value):
-               return self._set_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)
 
-       def _add(self, key, values):
-               modlist = [
-                       (ldap.MOD_ADD, key, values),
-               ]
+               for dn, attrs in res:
+                       return { key : attrs[key][0] for key in attrs }
 
-               self._modify(modlist)
+               return {}
 
-       def _add_strings(self, key, values):
-               return self._add(key, [e.encode() for e in values])
+       @property
+       def last_successful_authentication(self):
+               try:
+                       s = self.kerberos_attributes["krbLastSuccessfulAuth"]
+               except KeyError:
+                       return None
 
-       def _add_string(self, key, value):
-               return self._add_strings(key, [value,])
+               return self._parse_date(s)
 
-       def _delete(self, key, values):
-               modlist = [
-                       (ldap.MOD_DELETE, key, values),
-               ]
+       @property
+       def last_failed_authentication(self):
+               try:
+                       s = self.kerberos_attributes["krbLastFailedAuth"]
+               except KeyError:
+                       return None
 
-               self._modify(modlist)
+               return self._parse_date(s)
 
-       def _delete_strings(self, key, values):
-               return self._delete(key, [e.encode() for e in values])
+       @property
+       def failed_login_count(self):
+               try:
+                       count = self.kerberos_attributes["krbLoginFailedCount"].decode()
+               except KeyError:
+                       return 0
 
-       def _delete_string(self, key, value):
-               return self._delete_strings(key, [value,])
+               try:
+                       return int(count)
+               except ValueError:
+                       return 0
 
        def passwd(self, password):
                """
@@ -323,6 +615,7 @@ class Account(Object):
                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):
@@ -360,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 "wheel" 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
@@ -398,6 +717,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):
@@ -426,52 +755,94 @@ class Account(Object):
 
        @lazy_property
        def groups(self):
-               groups = []
+               return self.backend.groups._get_groups("(| \
+                       (&(objectClass=groupOfNames)(member=%s)) \
+                       (&(objectClass=posixGroup)(memberUid=%s)) \
+               )" % (self.dn, self.uid))
 
-               res = self.accounts._query("(&(objectClass=posixGroup) \
-                       (memberUid=%s))" % self.uid, ["cn"])
+       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)
 
-               for dn, attrs in res:
-                       cns = attrs.get("cn")
-                       if cns:
-                               groups.append(cns[0].decode())
+       # Created/Modified at
 
-               return groups
+       @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")
 
-               return []
+       def set_street(self, street):
+               self._set_string("street", street)
 
-       def set_address(self, address):
-               data = ", ".join(address.splitlines())
+       street = property(get_street, set_street)
 
-               self._set_bytes("homePostalAddress", data.encode())
+       def get_city(self):
+               return self._get_string("l") or ""
 
-       address = property(get_address, set_address)
+       def set_city(self, city):
+               self._set_string("l", city)
+
+       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")
+
+       @property
+       def email_to(self):
+               return "%s <%s>" % (self, self.email)
 
        # Mail Routing Address
 
@@ -503,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
@@ -524,29 +899,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
 
@@ -634,125 +1022,330 @@ class Account(Object):
 
                return ret
 
-       def avatar_url(self, size=None):
-               if self.backend.debug:
-                       hostname = "http://people.dev.ipfire.org"
-               else:
-                       hostname = "https://people.ipfire.org"
+       # 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)
 
-               url = "%s/users/%s.jpg" % (hostname, self.uid)
+               return has_avatar
+
+       def avatar_url(self, size=None):
+               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
 
        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
 
-               # Resize the image to the desired resolution (and make it square)
-               thumbnail = PIL.ImageOps.fit(image, (size, size), PIL.Image.ANTIALIAS)
+       @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]
 
-               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)
+                       self.memcache.set("accounts:%s:avatar-hash" % self.dn, hash, 86400)
 
-                       return f.getvalue()
+               return hash
 
        def upload_avatar(self, avatar):
                self._set("jpegPhoto", avatar)
 
-       # SSH Keys
+               # Delete cached avatar status
+               self.memcache.delete("accounts:%s:has-avatar" % self.dn)
 
-       @lazy_property
-       def ssh_keys(self):
-               ret = []
+               # Delete avatar hash
+               self.memcache.delete("accounts:%s:avatar-hash" % self.dn)
 
-               for key in self._get_strings("sshPublicKey"):
-                       s = sshpubkeys.SSHKey()
+       # Consent to promotional emails
 
-                       try:
-                               s.parse(key)
-                       except (sshpubkeys.InvalidKeyError, NotImplementedError) as e:
-                               logging.warning("Could not parse SSH key %s: %s" % (key, e))
-                               continue
+       def get_consents_to_promotional_emails(self):
+               return self.is_member_of_group("promotional-consent")
 
-                       ret.append(s)
+       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"
 
-               return ret
+               if value is True:
+                       group.add_member(self)
+               else:
+                       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
+
+       async 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 = await self.backend.http_client.fetch(request)
+
+               # Decode the JSON response
+               return json.loads(response.body.decode())
+
+       async def check_address(self):
+               response = await 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
+
+       async def check_username(self):
+               response = await 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
+
+       async def check_email(self):
+               response = await 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
+
+       async 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 = [await self.check_address(), await self.check_username()]
+
+               if any((c < threshold for c in confidences)):
+                       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)
 
-       def get_ssh_key_by_hash_sha256(self, hash_sha256):
-               for key in self.ssh_keys:
-                       if not key.hash_sha256() == hash_sha256:
+               groups = []
+               for dn, attrs in res:
+                       # Skip any hidden groups
+                       if dn in self.hidden_groups:
                                continue
 
-                       return key
+                       g = Group(self.backend, dn, attrs)
+                       groups.append(g)
 
-       def add_ssh_key(self, key):
-               k = sshpubkeys.SSHKey()
+               return sorted(groups)
 
-               # Try to parse the key
-               k.parse(key)
+       def _get_group(self, query, **kwargs):
+               kwargs.update({
+                       "limit" : 1,
+               })
 
-               # 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")
+               groups = self._get_groups(query, **kwargs)
+               if groups:
+                       return groups[0]
 
-               elif k.key_type == b"ssh-dss":
-                       raise sshpubkeys.InvalidKeyError("DSA keys are not supported")
+       def get_all(self):
+               return self._get_groups(
+                       "(|(objectClass=posixGroup)(objectClass=groupOfNames))",
+               )
 
-               # 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
+       def get_by_gid(self, gid):
+               return self._get_group(
+                       "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % gid,
+               )
 
-               # Prepare transaction
-               modlist = []
 
-               # Add object class if user is not in it, yet
-               if not "ldapPublicKey" in self.classes:
-                       modlist.append((ldap.MOD_ADD, "objectClass", b"ldapPublicKey"))
+class Group(LDAPObject):
+       def __repr__(self):
+               if self.description:
+                       return "<%s %s (%s)>" % (
+                               self.__class__.__name__,
+                               self.gid,
+                               self.description,
+                       )
 
-               # Add key
-               modlist.append((ldap.MOD_ADD, "sshPublicKey", key.encode()))
+               return "<%s %s>" % (self.__class__.__name__, self.gid)
 
-               # Save key to LDAP
-               self._modify(modlist)
+       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)
 
-               # Append to cache
-               self.ssh_keys.append(k)
+               # 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)
 
-       def delete_ssh_key(self, key):
-               if not key in (k.keydata for k in self.ssh_keys):
+               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
 
-               # Delete key from LDAP
-               if len(self.ssh_keys) > 1:
-                       self._delete_string("sshPublicKey", key)
+               if "posixGroup" in self.objectclasses:
+                       self._add_string("memberUid", account.uid)
                else:
-                       self._modify([
-                               (ldap.MOD_DELETE, "objectClass", b"ldapPublicKey"),
-                               (ldap.MOD_DELETE, "sshPublicKey", key.encode()),
-                       ])
+                       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__":