import base64
import datetime
+import hashlib
import hmac
+import iso3166
import json
import ldap
import ldap.modlist
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))
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()
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):
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)
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
# 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)
"(&(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
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)
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):
# 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):
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
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(
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:
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")
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):
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
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
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",
}
# 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"]
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"]
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.
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)
)
-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)>" % (
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)
@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()