]> git.ipfire.org Git - ipfire.org.git/blobdiff - src/backend/accounts.py
accounts: Drop username check from StopForumSpam
[ipfire.org.git] / src / backend / accounts.py
index 2a88cab0bc507e78c103031725823d68b7de2be5..ee7fca0870e4d3652c173e26f4a08ec19d1b9ae9 100644 (file)
@@ -3,7 +3,9 @@
 
 import base64
 import datetime
+import hashlib
 import hmac
+import iso3166
 import json
 import ldap
 import ldap.modlist
@@ -22,16 +24,165 @@ from . import util
 from .decorators import *
 from .misc import Object
 
+INT_MAX = (2**31) - 1
+
 # 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))")
+               accounts = self._search("(objectClass=person)")
 
                return iter(sorted(accounts))
 
@@ -43,9 +194,15 @@ class Accounts(Object):
                logging.debug("Connecting to LDAP server: %s" % ldap_uri)
 
                # Connect to the LDAP server
-               return ldap.ldapobject.ReconnectLDAPObject(ldap_uri,
+               connection = ldap.ldapobject.ReconnectLDAPObject(ldap_uri,
+                       trace_level=2 if self.backend.debug else 0,
                        retry_max=10, retry_delay=3)
 
+               # Set maximum timeout for operations
+               connection.set_option(ldap.OPT_TIMEOUT, 10)
+
+               return connection
+
        def _authenticate(self):
                # Authenticate against LDAP server using Kerberos
                self.ldap.sasl_gssapi_bind_s()
@@ -71,6 +228,11 @@ class Accounts(Object):
 
                return results
 
+       def _count(self, query):
+               res = self._query(query, attrlist=["dn"], limit=INT_MAX)
+
+               return len(res)
+
        def _search(self, query, attrlist=None, limit=0):
                accounts = []
                for dn, attrs in self._query(query, attrlist=["dn"], limit=limit):
@@ -100,22 +262,19 @@ class Accounts(Object):
 
                return Account(self.backend, dn, attrs)
 
+       @staticmethod
+       def _format_date(t):
+               return t.strftime("%Y%m%d%H%M%SZ")
+
        def get_created_after(self, ts):
-               t = ts.strftime("%Y%m%d%H%M%SZ")
+               return self._search("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
 
-               return self._search("(&(objectClass=person)(createTimestamp>=%s))" % t)
+       def count_created_after(self, ts):
+               return self._count("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
 
        def search(self, query):
-               # Search for exact matches
-               accounts = self._search(
-                       "(&(objectClass=person)(|(uid=%s)(mail=%s)(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
-                       % (query, query, query, query, query, query))
-
-               # Find accounts by name
-               if not accounts:
-                       for account in self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)))" % (query, query)):
-                               if not account in accounts:
-                                       accounts.append(account)
+               accounts = self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)(displayName=*%s*)(mail=*%s*)))" \
+                       % (query, query, query, query))
 
                return sorted(accounts)
 
@@ -126,12 +285,8 @@ class Accounts(Object):
                        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)
+               m = re.match(r"^[a-z_][a-z0-9_-]{3,31}$", uid)
                if m:
                        return True
 
@@ -150,6 +305,39 @@ class Accounts(Object):
                # Account with uid does not exist, yet
                return False
 
+       def mail_is_valid(self, mail):
+               username, delim, domain = mail.partition("@")
+
+               # There must be an @ and a domain part
+               if not domain:
+                       return False
+
+               # The domain cannot end on a dot
+               if domain.endswith("."):
+                       return False
+
+               # The domain should at least have one dot to fully qualified
+               if not "." in domain:
+                       return False
+
+               # Looks like a valid email address
+               return True
+
+       def mail_is_blacklisted(self, mail):
+               username, delim, domain = mail.partition("@")
+
+               if domain:
+                       return self.domain_is_blacklisted(domain)
+
+       def domain_is_blacklisted(self, domain):
+               res = self.db.get("SELECT TRUE AS found FROM blacklisted_domains \
+                       WHERE domain = %s", domain)
+
+               if res and res.found:
+                       return True
+
+               return False
+
        def get_by_uid(self, uid):
                return self._search_one("(&(objectClass=person)(uid=%s))" % uid)
 
@@ -179,12 +367,17 @@ class Accounts(Object):
                        "(&(objectClass=inetOrgPerson)(|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
                        % (number, number, number, number))
 
-       @tornado.gen.coroutine
-       def check_spam(self, uid, email, address):
-               sfs = StopForumSpam(self.backend, uid, email, address)
+       @property
+       def pending_registrations(self):
+               res = self.db.get("SELECT COUNT(*) AS c FROM account_activations")
+
+               return res.c or 0
+
+       async def check_spam(self, email, address):
+               sfs = StopForumSpam(self.backend, email, address)
 
                # Get spam score
-               score = yield sfs.check()
+               score = await sfs.check()
 
                return score >= 50
 
@@ -210,6 +403,14 @@ class Accounts(Object):
                if self.uid_exists(uid):
                        raise ValueError("UID exists: %s" % uid)
 
+               # Check if the email address is valid
+               if not self.mail_is_valid(email):
+                       raise ValueError("Email is invalid: %s" % email)
+
+               # Check if the email address is blacklisted
+               if self.mail_is_blacklisted(email):
+                       raise ValueError("Email is blacklisted: %s" % email)
+
                # Generate a random activation code
                activation_code = util.random_string(36)
 
@@ -244,10 +445,18 @@ class Accounts(Object):
                        first_name=res.first_name, last_name=res.last_name,
                        country_code=res.country_code)
 
+               # Non-EU users do not need to consent to promo emails
+               if account.country_code and not account.country_code in countries.EU_COUNTRIES:
+                       account.consents_to_promotional_emails = True
+
                # 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):
@@ -329,6 +538,9 @@ class Accounts(Object):
                # Cleanup expired account activations
                self.db.execute("DELETE FROM account_activations WHERE expires_at <= NOW()")
 
+               # Cleanup expired account password resets
+               self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()")
+
        # Discourse
 
        def decode_discourse_payload(self, payload, signature):
@@ -365,14 +577,20 @@ class Accounts(Object):
 
                return h.hexdigest()
 
+       @property
+       def countries(self):
+               ret = {}
+
+               for country in iso3166.countries:
+                       count = self._count("(&(objectClass=person)(st=%s))" % country.alpha2)
 
-class Account(Object):
-       def __init__(self, backend, dn, attrs=None):
-               Object.__init__(self, backend)
-               self.dn = dn
+                       if count:
+                               ret[country] = count
+
+               return ret
 
-               self.attributes = attrs or {}
 
+class Account(LDAPObject):
        def __str__(self):
                if self.nickname:
                        return self.nickname
@@ -382,127 +600,14 @@ class Account(Object):
        def __repr__(self):
                return "<%s %s>" % (self.__class__.__name__, self.dn)
 
-       def __eq__(self, other):
-               if isinstance(other, self.__class__):
-                       return self.dn == other.dn
-
        def __lt__(self, other):
                if isinstance(other, self.__class__):
                        return self.name < other.name
 
-       @property
-       def ldap(self):
-               return self.accounts.ldap
-
-       def _exists(self, key):
-               try:
-                       self.attributes[key]
-               except KeyError:
-                       return False
-
-               return True
-
-       def _get(self, key):
-               for value in self.attributes.get(key, []):
-                       yield value
-
-       def _get_bytes(self, key, default=None):
-               for value in self._get(key):
-                       return value
-
-               return default
-
-       def _get_strings(self, key):
-               for value in self._get(key):
-                       yield value.decode()
-
-       def _get_string(self, key, default=None):
-               for value in self._get_strings(key):
-                       return value
-
-               return default
-
-       def _get_phone_numbers(self, key):
-               for value in self._get_strings(key):
-                       yield phonenumbers.parse(value, None)
-
-       def _get_timestamp(self, key):
-               value = self._get_string(key)
-
-               # Parse the timestamp value and returns a datetime object
-               if value:
-                       return datetime.datetime.strptime(value, "%Y%m%d%H%M%SZ")
-
-       def _modify(self, modlist):
-               logging.debug("Modifying %s: %s" % (self.dn, modlist))
-
-               # Authenticate before performing any write operations
-               self.accounts._authenticate()
-
-               # Run modify operation
-               self.ldap.modify_s(self.dn, modlist)
-
+       def _clear_cache(self):
                # Delete cached attributes
                self.memcache.delete("accounts:%s:attrs" % self.dn)
 
-       def _set(self, key, values):
-               current = self._get(key)
-
-               # Don't do anything if nothing has changed
-               if list(current) == values:
-                       return
-
-               # Remove all old values and add all new ones
-               modlist = []
-
-               if self._exists(key):
-                       modlist.append((ldap.MOD_DELETE, key, None))
-
-               # Add new values
-               if values:
-                       modlist.append((ldap.MOD_ADD, key, values))
-
-               # Run modify operation
-               self._modify(modlist)
-
-               # Update cache
-               self.attributes.update({ key : values })
-
-       def _set_bytes(self, key, values):
-               return self._set(key, values)
-
-       def _set_strings(self, key, values):
-               return self._set(key, [e.encode() for e in values if e])
-
-       def _set_string(self, key, value):
-               return self._set_strings(key, [value,])
-
-       def _add(self, key, values):
-               modlist = [
-                       (ldap.MOD_ADD, key, values),
-               ]
-
-               self._modify(modlist)
-
-       def _add_strings(self, key, values):
-               return self._add(key, [e.encode() for e in values])
-
-       def _add_string(self, key, value):
-               return self._add_strings(key, [value,])
-
-       def _delete(self, key, values):
-               modlist = [
-                       (ldap.MOD_DELETE, key, values),
-               ]
-
-               self._modify(modlist)
-
-       def _delete_strings(self, key, values):
-               return self._delete(key, [e.encode() for e in values])
-
-       def _delete_string(self, key, value):
-               return self._delete_strings(key, [value,])
-
        @lazy_property
        def kerberos_attributes(self):
                res = self.backend.accounts._query(
@@ -521,10 +626,6 @@ class Account(Object):
 
                return {}
 
-       @staticmethod
-       def _parse_date(s):
-               return datetime.datetime.strptime(s.decode(), "%Y%m%d%H%M%SZ")
-
        @property
        def last_successful_authentication(self):
                try:
@@ -602,6 +703,29 @@ 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 self.is_member_of_group("sudo")
 
@@ -766,6 +890,10 @@ class Account(Object):
        def email(self):
                return self._get_string("mail")
 
+       @property
+       def email_to(self):
+               return "%s <%s>" % (self, self.email)
+
        # Mail Routing Address
 
        def get_mail_routing_address(self):
@@ -796,6 +924,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
@@ -963,10 +1095,10 @@ class Account(Object):
                return has_avatar
 
        def avatar_url(self, size=None):
-               url = "https://people.ipfire.org/users/%s.jpg" % self.uid
+               url = "https://people.ipfire.org/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
 
                if size:
-                       url += "?size=%s" % size
+                       url += "&size=%s" % size
 
                return url
 
@@ -994,19 +1126,52 @@ class Account(Object):
 
                return avatar
 
+       @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]
+
+                       self.memcache.set("accounts:%s:avatar-hash" % self.dn, hash, 86400)
+
+               return hash
+
        def upload_avatar(self, avatar):
                self._set("jpegPhoto", avatar)
 
                # Delete cached avatar status
-               self.memcache.delete("accounts:%s:has-avatar" % self.uid)
+               self.memcache.delete("accounts:%s:has-avatar" % self.dn)
+
+               # Delete avatar hash
+               self.memcache.delete("accounts:%s:avatar-hash" % self.dn)
+
+       # Consent to promotional emails
+
+       def get_consents_to_promotional_emails(self):
+               return self.is_member_of_group("promotional-consent")
+
+       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"
+
+               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
+       def init(self, email, address):
+               self.email, self.address = email, address
 
-       @tornado.gen.coroutine
-       def send_request(self, **kwargs):
+       async def send_request(self, **kwargs):
                arguments = {
                        "json" : "1",
                }
@@ -1014,18 +1179,18 @@ class StopForumSpam(Object):
 
                # Create request
                request = tornado.httpclient.HTTPRequest(
-                       "https://api.stopforumspam.org/api", method="POST")
+                       "https://api.stopforumspam.org/api", method="POST",
+                       connect_timeout=2, request_timeout=5)
                request.body = urllib.parse.urlencode(arguments)
 
                # Send the request
-               response = yield self.backend.http_client.fetch(request)
+               response = await self.backend.http_client.fetch(request)
 
                # Decode the JSON response
                return json.loads(response.body.decode())
 
-       @tornado.gen.coroutine
-       def check_address(self):
-               response = yield self.send_request(ip=self.address)
+       async def check_address(self):
+               response = await self.send_request(ip=self.address)
 
                try:
                        confidence = response["ip"]["confidence"]
@@ -1036,22 +1201,8 @@ class StopForumSpam(Object):
 
                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)
+       async def check_email(self):
+               response = await self.send_request(email=self.email)
 
                try:
                        confidence = response["email"]["confidence"]
@@ -1062,8 +1213,7 @@ class StopForumSpam(Object):
 
                return confidence
 
-       @tornado.gen.coroutine
-       def check(self, threshold=95):
+       async def check(self, threshold=95):
                """
                        This function tries to detect if we have a spammer.
 
@@ -1071,10 +1221,7 @@ class StopForumSpam(Object):
                        address and username and if those are on the database, we
                        will send the email address as well.
                """
-               confidences = yield [self.check_address(), self.check_username()]
-
-               if any((c < threshold for c in confidences)):
-                       confidences += yield [self.check_email()]
+               confidences = [await self.check_address(), await self.check_email()]
 
                # Build a score based on the lowest confidence
                return 100 - min(confidences)
@@ -1139,12 +1286,7 @@ class Groups(Object):
                )
 
 
-class Group(Object):
-       def init(self, dn, attrs=None):
-               self.dn = dn
-
-               self.attributes = attrs or {}
-
+class Group(LDAPObject):
        def __repr__(self):
                if self.description:
                        return "<%s %s (%s)>" % (
@@ -1158,10 +1300,6 @@ class Group(Object):
        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)
@@ -1187,49 +1325,65 @@ class Group(Object):
 
        @property
        def gid(self):
-               try:
-                       gid = self.attributes["cn"][0]
-               except KeyError:
-                       return None
-
-               return gid.decode()
+               return self._get_string("cn")
 
        @property
        def description(self):
-               try:
-                       description = self.attributes["description"][0]
-               except KeyError:
-                       return None
-
-               return description.decode()
+               return self._get_string("description")
 
        @property
        def email(self):
-               try:
-                       email = self.attributes["mail"][0]
-               except KeyError:
-                       return None
-
-               return email.decode()
+               return self._get_string("mail")
 
        @lazy_property
        def members(self):
                members = []
 
                # Get all members by DN
-               for dn in self.attributes.get("member", []):
-                       member = self.backend.accounts.get_by_dn(dn.decode())
+               for dn in self._get_strings("member"):
+                       member = self.backend.accounts.get_by_dn(dn)
                        if member:
                                members.append(member)
 
-               # Get all meembers by UID
-               for uid in self.attributes.get("memberUid", []):
-                       member = self.backend.accounts.get_by_uid(uid.decode())
+               # Get all members by UID
+               for uid in self._get_strings("memberUid"):
+                       member = self.backend.accounts.get_by_uid(uid)
                        if member:
                                members.append(member)
 
                return sorted(members)
 
+       def add_member(self, account):
+               """
+                       Adds a member to this group
+               """
+               # Do nothing if this user is already in the group
+               if account.is_member_of_group(self.gid):
+                       return
+
+               if "posixGroup" in self.objectclasses:
+                       self._add_string("memberUid", account.uid)
+               else:
+                       self._add_string("member", account.dn)
+
+               # Append to cached list of members
+               self.members.append(account)
+               self.members.sort()
+
+       def del_member(self, account):
+               """
+                       Removes a member from a group
+               """
+               # Do nothing if this user is not in the group
+               if not account.is_member_of_group(self.gid):
+                       return
+
+               if "posixGroup" in self.objectclasses:
+                       self._delete_string("memberUid", account.uid)
+               else:
+                       self._delete_string("member", account.dn)
+
+
 if __name__ == "__main__":
        a = Accounts()