]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/accounts.py
accounts: Use more than one letter from nicknames for avatars
[ipfire.org.git] / src / backend / accounts.py
1 #!/usr/bin/python
2 # encoding: utf-8
3
4 import asyncio
5 import base64
6 import datetime
7 import hashlib
8 import hmac
9 import iso3166
10 import json
11 import kerberos
12 import ldap
13 import ldap.modlist
14 import logging
15 import os
16 import phonenumbers
17 import re
18 import socket
19 import sys
20 import time
21 import tornado.httpclient
22 import urllib.parse
23 import urllib.request
24 import zxcvbn
25
26 from . import countries
27 from . import util
28 from .decorators import *
29 from .misc import Object
30
31 # Set the client keytab name
32 os.environ["KRB5_CLIENT_KTNAME"] = "/etc/ipfire.org/ldap.keytab"
33
34 FQDN = socket.gethostname()
35
36 class LDAPObject(Object):
37 def init(self, dn, attrs=None):
38 self.dn = dn
39
40 self.attributes = attrs or {}
41
42 def __eq__(self, other):
43 if isinstance(other, self.__class__):
44 return self.dn == other.dn
45
46 return NotImplemented
47
48 @property
49 def ldap(self):
50 return self.accounts.ldap
51
52 def _exists(self, key):
53 try:
54 self.attributes[key]
55 except KeyError:
56 return False
57
58 return True
59
60 def _get(self, key):
61 for value in self.attributes.get(key, []):
62 yield value
63
64 def _get_bytes(self, key, default=None):
65 for value in self._get(key):
66 return value
67
68 return default
69
70 def _get_strings(self, key):
71 for value in self._get(key):
72 yield value.decode()
73
74 def _get_string(self, key, default=None):
75 for value in self._get_strings(key):
76 return value
77
78 return default
79
80 def _get_phone_numbers(self, key):
81 for value in self._get_strings(key):
82 yield phonenumbers.parse(value, None)
83
84 def _get_timestamp(self, key):
85 value = self._get_string(key)
86
87 # Parse the timestamp value and returns a datetime object
88 if value:
89 return datetime.datetime.strptime(value, "%Y%m%d%H%M%SZ")
90
91 def _modify(self, modlist):
92 logging.debug("Modifying %s: %s" % (self.dn, modlist))
93
94 # Authenticate before performing any write operations
95 self.accounts._authenticate()
96
97 # Run modify operation
98 self.ldap.modify_s(self.dn, modlist)
99
100 def _set(self, key, values):
101 current = self._get(key)
102
103 # Don't do anything if nothing has changed
104 if list(current) == values:
105 return
106
107 # Remove all old values and add all new ones
108 modlist = []
109
110 if self._exists(key):
111 modlist.append((ldap.MOD_DELETE, key, None))
112
113 # Add new values
114 if values:
115 modlist.append((ldap.MOD_ADD, key, values))
116
117 # Run modify operation
118 self._modify(modlist)
119
120 # Update cache
121 self.attributes.update({ key : values })
122
123 def _set_bytes(self, key, values):
124 return self._set(key, values)
125
126 def _set_strings(self, key, values):
127 return self._set(key, [e.encode() for e in values if e])
128
129 def _set_string(self, key, value):
130 return self._set_strings(key, [value,])
131
132 def _add(self, key, values):
133 modlist = [
134 (ldap.MOD_ADD, key, values),
135 ]
136
137 self._modify(modlist)
138
139 def _add_strings(self, key, values):
140 return self._add(key, [e.encode() for e in values])
141
142 def _add_string(self, key, value):
143 return self._add_strings(key, [value,])
144
145 def _delete(self, key, values):
146 modlist = [
147 (ldap.MOD_DELETE, key, values),
148 ]
149
150 self._modify(modlist)
151
152 def _delete_strings(self, key, values):
153 return self._delete(key, [e.encode() for e in values])
154
155 def _delete_string(self, key, value):
156 return self._delete_strings(key, [value,])
157
158 def _delete_dn(self, dn):
159 logging.debug("Deleting %s" % dn)
160
161 # Authenticate before performing any delete operations
162 self.accounts._authenticate()
163
164 # Run delete operation
165 self.ldap.delete_s(dn)
166
167 @property
168 def objectclasses(self):
169 return self._get_strings("objectClass")
170
171 @staticmethod
172 def _parse_date(s):
173 return datetime.datetime.strptime(s.decode(), "%Y%m%d%H%M%SZ")
174
175
176 class Accounts(Object):
177 def init(self):
178 self.search_base = self.settings.get("ldap_search_base")
179
180 def __len__(self):
181 return self._count("(objectClass=person)")
182
183 def __iter__(self):
184 accounts = self._search("(objectClass=person)")
185
186 return iter(sorted(accounts))
187
188 @lazy_property
189 def ldap(self):
190 # Connect to LDAP server
191 ldap_uri = self.settings.get("ldap_uri")
192
193 logging.debug("Connecting to LDAP server: %s" % ldap_uri)
194
195 # Connect to the LDAP server
196 connection = ldap.ldapobject.ReconnectLDAPObject(ldap_uri,
197 trace_level=2 if self.backend.debug else 0,
198 retry_max=sys.maxsize, retry_delay=3)
199
200 # Set maximum timeout for operations
201 connection.set_option(ldap.OPT_TIMEOUT, 10)
202
203 return connection
204
205 def _authenticate(self):
206 # Authenticate against LDAP server using Kerberos
207 self.ldap.sasl_gssapi_bind_s()
208
209 async def test_ldap(self):
210 logging.info("Testing LDAP connection...")
211
212 self._authenticate()
213
214 logging.info("Successfully authenticated as %s" % self.ldap.whoami_s())
215
216 def _query(self, query, attrlist=None, limit=0, search_base=None):
217 logging.debug("Performing LDAP query (%s): %s" \
218 % (search_base or self.search_base, query))
219
220 t = time.time()
221
222 # Ask for up to 512 results being returned at a time
223 page_control = ldap.controls.SimplePagedResultsControl(True, size=512, cookie="")
224
225 results = []
226 pages = 0
227
228 # Perform the search
229 while True:
230 response = self.ldap.search_ext(search_base or self.search_base,
231 ldap.SCOPE_SUBTREE, query, attrlist=attrlist, sizelimit=limit,
232 serverctrls=[page_control],
233 )
234
235 # Fetch all results
236 type, data, rmsgid, serverctrls = self.ldap.result3(response)
237
238 # Append to local copy
239 results += data
240 pages += 1
241
242 controls = [c for c in serverctrls
243 if c.controlType == ldap.controls.SimplePagedResultsControl.controlType]
244
245 if not controls:
246 break
247
248 # Set the cookie for more results
249 page_control.cookie = controls[0].cookie
250
251 # There are no more results
252 if not page_control.cookie:
253 break
254
255 # Log time it took to perform the query
256 logging.debug("Query took %.2fms (%s page(s))" % ((time.time() - t) * 1000.0, pages))
257
258 return results
259
260 def _count(self, query):
261 res = self._query(query, attrlist=["dn"])
262
263 return len(res)
264
265 def _search(self, query, attrlist=None, limit=0):
266 accounts = []
267 for dn, attrs in self._query(query, attrlist=["dn"], limit=limit):
268 account = self.get_by_dn(dn)
269 accounts.append(account)
270
271 return accounts
272
273 def _get_attrs(self, dn):
274 """
275 Fetches all attributes for the given distinguished name
276 """
277 results = self._query("(objectClass=*)", search_base=dn, limit=1,
278 attrlist=("*", "createTimestamp", "modifyTimestamp"))
279
280 for dn, attrs in results:
281 return attrs
282
283 def get_by_dn(self, dn):
284 attrs = self._get_attrs(dn)
285
286 return Account(self.backend, dn, attrs)
287
288 @staticmethod
289 def _format_date(t):
290 return t.strftime("%Y%m%d%H%M%SZ")
291
292 def get_recently_registered(self, limit=None):
293 # Check the last two weeks
294 t = datetime.datetime.utcnow() - datetime.timedelta(days=14)
295
296 # Fetch all accounts created after t
297 accounts = self.get_created_after(t)
298
299 # Order by creation date and put latest first
300 accounts.sort(key=lambda a: a.created_at, reverse=True)
301
302 # Cap at the limit
303 if accounts and limit:
304 accounts = accounts[:limit]
305
306 return accounts
307
308 def get_created_after(self, ts):
309 return self._search("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
310
311 def count_created_after(self, ts):
312 return self._count("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
313
314 def search(self, query):
315 # Try finding an exact match
316 account = self._search_one(
317 "(&"
318 "(objectClass=person)"
319 "(|"
320 "(uid=%s)"
321 "(mail=%s)"
322 "(mailAlternateAddress=%s)"
323 ")"
324 ")" % (query, query, query))
325 if account:
326 return [account]
327
328 # Otherwise search for a substring match
329 accounts = self._search(
330 "(&"
331 "(objectClass=person)"
332 "(|"
333 "(cn=*%s*)"
334 "(uid=*%s*)"
335 "(displayName=*%s*)"
336 "(mail=*%s*)"
337 ")"
338 ")" % (query, query, query, query))
339
340 return sorted(accounts)
341
342 def _search_one(self, query):
343 results = self._search(query, limit=1)
344
345 for result in results:
346 return result
347
348 def uid_is_valid(self, uid):
349 # https://unix.stackexchange.com/questions/157426/what-is-the-regex-to-validate-linux-users
350 m = re.match(r"^[a-z_][a-z0-9_-]{3,31}$", uid)
351 if m:
352 return True
353
354 return False
355
356 def uid_exists(self, uid):
357 if self.get_by_uid(uid):
358 return True
359
360 res = self.db.get("SELECT 1 FROM account_activations \
361 WHERE uid = %s AND expires_at > NOW()", uid)
362
363 if res:
364 return True
365
366 # Account with uid does not exist, yet
367 return False
368
369 def mail_is_valid(self, mail):
370 username, delim, domain = mail.partition("@")
371
372 # There must be an @ and a domain part
373 if not domain:
374 return False
375
376 # The domain cannot end on a dot
377 if domain.endswith("."):
378 return False
379
380 # The domain should at least have one dot to fully qualified
381 if not "." in domain:
382 return False
383
384 # Looks like a valid email address
385 return True
386
387 def mail_is_blacklisted(self, mail):
388 username, delim, domain = mail.partition("@")
389
390 if domain:
391 return self.domain_is_blacklisted(domain)
392
393 def domain_is_blacklisted(self, domain):
394 res = self.db.get("SELECT TRUE AS found FROM blacklisted_domains \
395 WHERE domain = %s OR %s LIKE '%%.' || domain", domain, domain)
396
397 if res and res.found:
398 return True
399
400 return False
401
402 def get_by_uid(self, uid):
403 return self._search_one("(&(objectClass=person)(uid=%s))" % uid)
404
405 def get_by_mail(self, mail):
406 return self._search_one("(&(objectClass=inetOrgPerson)(mail=%s))" % mail)
407
408 def find_account(self, s):
409 account = self.get_by_uid(s)
410 if account:
411 return account
412
413 return self.get_by_mail(s)
414
415 def get_by_sip_id(self, sip_id):
416 if not sip_id:
417 return
418
419 return self._search_one(
420 "(|(&(objectClass=sipUser)(sipAuthenticationUser=%s))(&(objectClass=sipRoutingObject)(sipLocalAddress=%s)))" \
421 % (sip_id, sip_id))
422
423 def get_by_phone_number(self, number):
424 if not number:
425 return
426
427 return self._search_one(
428 "(&(objectClass=inetOrgPerson)(|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
429 % (number, number, number, number))
430
431 @property
432 def pending_registrations(self):
433 res = self.db.get("SELECT COUNT(*) AS c FROM account_activations")
434
435 return res.c or 0
436
437 def auth(self, username, password):
438 # Find account
439 account = self.backend.accounts.find_account(username)
440
441 # Check credentials
442 if account and account.check_password(password):
443 return account
444
445 # Join
446
447 def join(self, uid, email, first_name, last_name, country_code=None):
448 # Convert all uids to lowercase
449 uid = uid.lower()
450
451 # Check if UID is valid
452 if not self.uid_is_valid(uid):
453 raise ValueError("UID is invalid: %s" % uid)
454
455 # Check if UID is unique
456 if self.uid_exists(uid):
457 raise ValueError("UID exists: %s" % uid)
458
459 # Check if the email address is valid
460 if not self.mail_is_valid(email):
461 raise ValueError("Email is invalid: %s" % email)
462
463 # Check if the email address is blacklisted
464 if self.mail_is_blacklisted(email):
465 raise ValueError("Email is blacklisted: %s" % email)
466
467 # Generate a random activation code
468 activation_code = util.random_string(36)
469
470 # Create an entry in our database until the user
471 # has activated the account
472 self.db.execute("INSERT INTO account_activations(uid, activation_code, \
473 email, first_name, last_name, country_code) VALUES(%s, %s, %s, %s, %s, %s)",
474 uid, activation_code, email, first_name, last_name, country_code)
475
476 # Send an account activation email
477 self.backend.messages.send_template("auth/messages/join",
478 priority=100, uid=uid, activation_code=activation_code, email=email,
479 first_name=first_name, last_name=last_name)
480
481 def activate(self, uid, activation_code):
482 res = self.db.get("DELETE FROM account_activations \
483 WHERE uid = %s AND activation_code = %s AND expires_at > NOW() \
484 RETURNING *", uid, activation_code)
485
486 # Return nothing when account was not found
487 if not res:
488 return
489
490 # Return the account if it has already been created
491 account = self.get_by_uid(uid)
492 if account:
493 return account
494
495 # Create a new account on the LDAP database
496 account = self.create(uid, res.email,
497 first_name=res.first_name, last_name=res.last_name,
498 country_code=res.country_code)
499
500 # Non-EU users do not need to consent to promo emails
501 if account.country_code and not account.country_code in countries.EU_COUNTRIES:
502 account.consents_to_promotional_emails = True
503
504 # Send email about account registration
505 self.backend.messages.send_template("people/messages/new-account",
506 account=account)
507
508 # Launch drip campaigns
509 for campaign in ("signup", "christmas"):
510 self.backend.campaigns.launch(campaign, account)
511
512 return account
513
514 def create(self, uid, email, first_name, last_name, country_code=None):
515 cn = "%s %s" % (first_name, last_name)
516
517 # Account Parameters
518 account = {
519 "objectClass" : [b"top", b"person", b"inetOrgPerson"],
520 "mail" : email.encode(),
521
522 # Name
523 "cn" : cn.encode(),
524 "sn" : last_name.encode(),
525 "givenName" : first_name.encode(),
526 }
527
528 logging.info("Creating new account: %s: %s" % (uid, account))
529
530 # Create DN
531 dn = "uid=%s,ou=People,dc=ipfire,dc=org" % uid
532
533 # Create account on LDAP
534 self.accounts._authenticate()
535 self.ldap.add_s(dn, ldap.modlist.addModlist(account))
536
537 # Fetch the account
538 account = self.get_by_dn(dn)
539
540 # Optionally set country code
541 if country_code:
542 account.country_code = country_code
543
544 # Return account
545 return account
546
547 # Session stuff
548
549 def create_session(self, account, host):
550 session_id = util.random_string(64)
551
552 res = self.db.get("INSERT INTO sessions(host, uid, session_id) VALUES(%s, %s, %s) \
553 RETURNING session_id, time_expires", host, account.uid, session_id)
554
555 # Session could not be created
556 if not res:
557 return None, None
558
559 logging.info("Created session %s for %s which expires %s" \
560 % (res.session_id, account, res.time_expires))
561 return res.session_id, res.time_expires
562
563 def destroy_session(self, session_id, host):
564 logging.info("Destroying session %s" % session_id)
565
566 self.db.execute("DELETE FROM sessions \
567 WHERE session_id = %s AND host = %s", session_id, host)
568
569 def get_by_session(self, session_id, host):
570 logging.debug("Looking up session %s" % session_id)
571
572 res = self.db.get("SELECT uid FROM sessions WHERE session_id = %s \
573 AND host = %s AND NOW() BETWEEN time_created AND time_expires",
574 session_id, host)
575
576 # Session does not exist or has expired
577 if not res:
578 return
579
580 # Update the session expiration time
581 self.db.execute("UPDATE sessions SET time_expires = NOW() + INTERVAL '14 days' \
582 WHERE session_id = %s AND host = %s", session_id, host)
583
584 return self.get_by_uid(res.uid)
585
586 def cleanup(self):
587 # Cleanup expired sessions
588 self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
589
590 # Cleanup expired account activations
591 self.db.execute("DELETE FROM account_activations WHERE expires_at <= NOW()")
592
593 # Cleanup expired account password resets
594 self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()")
595
596 async def _delete(self, *args, **kwargs):
597 """
598 Deletes given users
599 """
600 # Who is deleting?
601 who = self.get_by_uid("ms")
602
603 for uid in args:
604 account = self.get_by_uid(uid)
605
606 # Delete the account
607 with self.db.transaction():
608 await account.delete(who)
609
610 # Discourse
611
612 def decode_discourse_payload(self, payload, signature):
613 # Check signature
614 calculated_signature = self.sign_discourse_payload(payload)
615
616 if not hmac.compare_digest(signature, calculated_signature):
617 raise ValueError("Invalid signature: %s" % signature)
618
619 # Decode the query string
620 qs = base64.b64decode(payload).decode()
621
622 # Parse the query string
623 data = {}
624 for key, val in urllib.parse.parse_qsl(qs):
625 data[key] = val
626
627 return data
628
629 def encode_discourse_payload(self, **args):
630 # Encode the arguments into an URL-formatted string
631 qs = urllib.parse.urlencode(args).encode()
632
633 # Encode into base64
634 return base64.b64encode(qs).decode()
635
636 def sign_discourse_payload(self, payload, secret=None):
637 if secret is None:
638 secret = self.settings.get("discourse_sso_secret")
639
640 # Calculate a HMAC using SHA256
641 h = hmac.new(secret.encode(),
642 msg=payload.encode(), digestmod="sha256")
643
644 return h.hexdigest()
645
646 @property
647 def countries(self):
648 ret = {}
649
650 for country in iso3166.countries:
651 count = self._count("(&(objectClass=person)(c=%s))" % country.alpha2)
652
653 if count:
654 ret[country] = count
655
656 return ret
657
658 async def get_all_emails(self):
659 # Returns all email addresses
660 for dn, attrs in self._query("(objectClass=person)", attrlist=("mail",)):
661 mails = attrs.get("mail", None)
662 if not mails:
663 continue
664
665 for mail in mails:
666 print(mail.decode())
667
668
669 class Account(LDAPObject):
670 def __str__(self):
671 if self.nickname:
672 return self.nickname
673
674 return self.name
675
676 def __repr__(self):
677 return "<%s %s>" % (self.__class__.__name__, self.dn)
678
679 def __lt__(self, other):
680 if isinstance(other, self.__class__):
681 return self.name < other.name
682
683 return NotImplemented
684
685 @property
686 def kerberos_principal_dn(self):
687 return "krbPrincipalName=%s@IPFIRE.ORG,cn=IPFIRE.ORG,cn=krb5,dc=ipfire,dc=org" % self.uid
688
689 @lazy_property
690 def kerberos_attributes(self):
691 res = self.backend.accounts._query(
692 "(&(objectClass=krbPrincipal)(krbPrincipalName=%s@IPFIRE.ORG))" % self.uid,
693 attrlist=[
694 "krbLastSuccessfulAuth",
695 "krbLastPasswordChange",
696 "krbLastFailedAuth",
697 "krbLoginFailedCount",
698 ],
699 limit=1,
700 search_base="cn=krb5,%s" % self.backend.accounts.search_base)
701
702 for dn, attrs in res:
703 return { key : attrs[key][0] for key in attrs }
704
705 return {}
706
707 @property
708 def last_successful_authentication(self):
709 try:
710 s = self.kerberos_attributes["krbLastSuccessfulAuth"]
711 except KeyError:
712 return None
713
714 return self._parse_date(s)
715
716 @property
717 def last_failed_authentication(self):
718 try:
719 s = self.kerberos_attributes["krbLastFailedAuth"]
720 except KeyError:
721 return None
722
723 return self._parse_date(s)
724
725 @property
726 def failed_login_count(self):
727 try:
728 count = self.kerberos_attributes["krbLoginFailedCount"].decode()
729 except KeyError:
730 return 0
731
732 try:
733 return int(count)
734 except ValueError:
735 return 0
736
737 def passwd(self, password):
738 """
739 Sets a new password
740 """
741 # The new password must have a score of 3 or better
742 quality = self.check_password_quality(password)
743 if quality["score"] < 3:
744 raise ValueError("Password too weak")
745
746 self.accounts._authenticate()
747 self.ldap.passwd_s(self.dn, None, password)
748
749 def check_password(self, password):
750 """
751 Bind to the server with given credentials and return
752 true if password is corrent and false if not.
753
754 Raises exceptions from the server on any other errors.
755 """
756 if not password:
757 return
758
759 logging.debug("Checking credentials for %s" % self.dn)
760
761 # Check the credentials against the Kerberos database
762 try:
763 kerberos.checkPassword(self.uid, password, "host/%s" % FQDN, "IPFIRE.ORG")
764
765 # Catch any authentication errors
766 except kerberos.BasicAuthError as e:
767 logging.debug("Could not authenticate %s: %s" % (self.uid, e))
768
769 return False
770
771 # Otherwise return True
772 else:
773 logging.info("Successfully authenticated %s" % self)
774
775 return True
776
777 def check_password_quality(self, password):
778 """
779 Passwords are passed through zxcvbn to make sure
780 that they are strong enough.
781 """
782 return zxcvbn.zxcvbn(password, user_inputs=(
783 self.first_name, self.last_name,
784 ))
785
786 def request_password_reset(self, address=None):
787 reset_code = util.random_string(64)
788
789 self.db.execute("INSERT INTO account_password_resets(uid, reset_code, address) \
790 VALUES(%s, %s, %s)", self.uid, reset_code, address)
791
792 # Send a password reset email
793 self.backend.messages.send_template("auth/messages/password-reset",
794 priority=100, account=self, reset_code=reset_code)
795
796 def reset_password(self, reset_code, new_password):
797 # Delete the reset token
798 res = self.db.query("DELETE FROM account_password_resets \
799 WHERE uid = %s AND reset_code = %s AND expires_at >= NOW() \
800 RETURNING *", self.uid, reset_code)
801
802 # The reset code was invalid
803 if not res:
804 raise ValueError("Invalid password reset token for %s: %s" % (self, reset_code))
805
806 # Perform password change
807 return self.passwd(new_password)
808
809 def is_admin(self):
810 return self.is_member_of_group("sudo")
811
812 def is_staff(self):
813 return self.is_member_of_group("staff")
814
815 def is_moderator(self):
816 return self.is_member_of_group("moderators")
817
818 def has_shell(self):
819 return "posixAccount" in self.classes
820
821 def has_mail(self):
822 return "postfixMailUser" in self.classes
823
824 def has_sip(self):
825 return "sipUser" in self.classes or "sipRoutingObject" in self.classes
826
827 def is_blog_author(self):
828 return self.is_member_of_group("blog-authors")
829
830 def is_lwl(self):
831 return self.is_member_of_group("lwl-staff")
832
833 def can_be_managed_by(self, account):
834 """
835 Returns True if account is allowed to manage this account
836 """
837 # Admins can manage all accounts
838 if account.is_admin():
839 return True
840
841 # Users can manage themselves
842 return self == account
843
844 @property
845 def classes(self):
846 return self._get_strings("objectClass")
847
848 @property
849 def uid(self):
850 return self._get_string("uid")
851
852 @property
853 def name(self):
854 return self._get_string("cn")
855
856 # Delete
857
858 async def delete(self, user):
859 """
860 Deletes this user
861 """
862 # Check if this user can be deleted
863 if not self.can_be_deleted_by(user):
864 raise RuntimeError("Cannot delete user %s" % self)
865
866 logging.info("Deleting user %s" % self)
867
868 async with asyncio.TaskGroup() as tasks:
869 t = datetime.datetime.now()
870
871 # Disable this account on Bugzilla
872 tasks.create_task(
873 self._disable_on_bugzilla("Deleted by %s, %s" % (user, t)),
874 )
875
876 # XXX Delete on Discourse
877
878 # Delete on LDAP
879 self._delete()
880
881 def can_be_deleted_by(self, user):
882 """
883 Return True if the user can be deleted by user
884 """
885 # Check permissions
886 if not self.can_be_managed_by(user):
887 return False
888
889 # Cannot delete shell users
890 if self.has_shell():
891 return False
892
893 # Looks okay
894 return True
895
896 def _delete(self):
897 """
898 Deletes this object from LDAP
899 """
900 # Delete the Kerberos Principal
901 self._delete_dn(self.kerberos_principal_dn)
902
903 # Delete this object
904 self._delete_dn(self.dn)
905
906 # Nickname
907
908 def get_nickname(self):
909 return self._get_string("displayName")
910
911 def set_nickname(self, nickname):
912 self._set_string("displayName", nickname)
913
914 nickname = property(get_nickname, set_nickname)
915
916 # First Name
917
918 def get_first_name(self):
919 return self._get_string("givenName")
920
921 def set_first_name(self, first_name):
922 self._set_string("givenName", first_name)
923
924 # Update Common Name
925 self._set_string("cn", "%s %s" % (first_name, self.last_name))
926
927 first_name = property(get_first_name, set_first_name)
928
929 # Last Name
930
931 def get_last_name(self):
932 return self._get_string("sn")
933
934 def set_last_name(self, last_name):
935 self._set_string("sn", last_name)
936
937 # Update Common Name
938 self._set_string("cn", "%s %s" % (self.first_name, last_name))
939
940 last_name = property(get_last_name, set_last_name)
941
942 @lazy_property
943 def groups(self):
944 return self.backend.groups._get_groups("(| \
945 (&(objectClass=groupOfNames)(member=%s)) \
946 (&(objectClass=posixGroup)(memberUid=%s)) \
947 )" % (self.dn, self.uid))
948
949 def is_member_of_group(self, gid):
950 """
951 Returns True if this account is a member of this group
952 """
953 return gid in (g.gid for g in self.groups)
954
955 # Created/Modified at
956
957 @property
958 def created_at(self):
959 return self._get_timestamp("createTimestamp")
960
961 @property
962 def modified_at(self):
963 return self._get_timestamp("modifyTimestamp")
964
965 # Address
966
967 @property
968 def address(self):
969 address = []
970
971 if self.street:
972 address += self.street.splitlines()
973
974 if self.postal_code and self.city:
975 if self.country_code in ("AT", "DE"):
976 address.append("%s %s" % (self.postal_code, self.city))
977 else:
978 address.append("%s, %s" % (self.city, self.postal_code))
979 else:
980 address.append(self.city or self.postal_code)
981
982 if self.country_name:
983 address.append(self.country_name)
984
985 return [line for line in address if line]
986
987 def get_street(self):
988 return self._get_string("street") or self._get_string("homePostalAddress")
989
990 def set_street(self, street):
991 self._set_string("street", street)
992
993 street = property(get_street, set_street)
994
995 def get_city(self):
996 return self._get_string("l") or ""
997
998 def set_city(self, city):
999 self._set_string("l", city)
1000
1001 city = property(get_city, set_city)
1002
1003 def get_postal_code(self):
1004 return self._get_string("postalCode") or ""
1005
1006 def set_postal_code(self, postal_code):
1007 self._set_string("postalCode", postal_code)
1008
1009 postal_code = property(get_postal_code, set_postal_code)
1010
1011 def get_state(self):
1012 return self._get_string("st")
1013
1014 def set_state(self, state):
1015 self._set_string("st", state)
1016
1017 state = property(get_state, set_state)
1018
1019 def get_country_code(self):
1020 return self._get_string("c")
1021
1022 def set_country_code(self, country_code):
1023 self._set_string("c", country_code)
1024
1025 country_code = property(get_country_code, set_country_code)
1026
1027 @property
1028 def country_name(self):
1029 if self.country_code:
1030 return self.backend.get_country_name(self.country_code)
1031
1032 @property
1033 def initials(self):
1034 initials = []
1035
1036 # If a nickname is set, only use the nickname
1037 if self.nickname and len(self.nickname) >= 2:
1038 for m in re.findall(r"(\w+)", self.nickname):
1039 initials.append(m[0])
1040
1041 # If we only detected one character, we will use the first two
1042 if len(initials) < 2:
1043 initials = [self.nickname[0], self.nickname[1]]
1044
1045 # Otherwise use the first and last name
1046 else:
1047 if self.first_name:
1048 initials.append(self.first_name[0])
1049
1050 if self.last_name:
1051 initials.append(self.last_name[0])
1052
1053 # Truncate to two initials
1054 initials = initials[:2]
1055
1056 return [i.upper() for i in initials]
1057
1058 # Email
1059
1060 @property
1061 def email(self):
1062 return self._get_string("mail")
1063
1064 @property
1065 def email_to(self):
1066 return "%s <%s>" % (self, self.email)
1067
1068 @lazy_property
1069 def alternate_email_addresses(self):
1070 addresses = self._get_strings("mailAlternateAddress")
1071
1072 return sorted(addresses)
1073
1074 # Mail Routing Address
1075
1076 def get_mail_routing_address(self):
1077 return self._get_string("mailRoutingAddress", None)
1078
1079 def set_mail_routing_address(self, address):
1080 self._set_string("mailRoutingAddress", address or None)
1081
1082 mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
1083
1084 @property
1085 def sip_id(self):
1086 if "sipUser" in self.classes:
1087 return self._get_string("sipAuthenticationUser")
1088
1089 if "sipRoutingObject" in self.classes:
1090 return self._get_string("sipLocalAddress")
1091
1092 @property
1093 def sip_password(self):
1094 return self._get_string("sipPassword")
1095
1096 @staticmethod
1097 def _generate_sip_password():
1098 return util.random_string(8)
1099
1100 @property
1101 def sip_url(self):
1102 return "%s@ipfire.org" % self.sip_id
1103
1104 def uses_sip_forwarding(self):
1105 if self.sip_routing_address:
1106 return True
1107
1108 return False
1109
1110 # SIP Routing
1111
1112 def get_sip_routing_address(self):
1113 if "sipRoutingObject" in self.classes:
1114 return self._get_string("sipRoutingAddress")
1115
1116 def set_sip_routing_address(self, address):
1117 if not address:
1118 address = None
1119
1120 # Don't do anything if nothing has changed
1121 if self.get_sip_routing_address() == address:
1122 return
1123
1124 if address:
1125 # This is no longer a SIP user any more
1126 try:
1127 self._modify([
1128 (ldap.MOD_DELETE, "objectClass", b"sipUser"),
1129 (ldap.MOD_DELETE, "sipAuthenticationUser", None),
1130 (ldap.MOD_DELETE, "sipPassword", None),
1131 ])
1132 except ldap.NO_SUCH_ATTRIBUTE:
1133 pass
1134
1135 # Set new routing object
1136 try:
1137 self._modify([
1138 (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
1139 (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
1140 (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
1141 ])
1142
1143 # If this is a change, we cannot add this again
1144 except ldap.TYPE_OR_VALUE_EXISTS:
1145 self._set_string("sipRoutingAddress", address)
1146 else:
1147 try:
1148 self._modify([
1149 (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
1150 (ldap.MOD_DELETE, "sipLocalAddress", None),
1151 (ldap.MOD_DELETE, "sipRoutingAddress", None),
1152 ])
1153 except ldap.NO_SUCH_ATTRIBUTE:
1154 pass
1155
1156 self._modify([
1157 (ldap.MOD_ADD, "objectClass", b"sipUser"),
1158 (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
1159 (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
1160 ])
1161
1162 # XXX Cache is invalid here
1163
1164 sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
1165
1166 # SIP Registrations
1167
1168 async def get_sip_registrations(self):
1169 if not self.has_sip():
1170 return []
1171
1172 return await self.backend.asterisk.get_registrations(self.sip_id)
1173
1174 # SIP Channels
1175
1176 async def get_sip_channels(self):
1177 if not self.has_sip():
1178 return []
1179
1180 return await self.backend.asterisk.get_sip_channels(self.sip_id)
1181
1182 # Phone Numbers
1183
1184 @lazy_property
1185 def phone_number(self):
1186 """
1187 Returns the IPFire phone number
1188 """
1189 if self.sip_id:
1190 return phonenumbers.parse("+4923636035%s" % self.sip_id)
1191
1192 @lazy_property
1193 def fax_number(self):
1194 if self.sip_id:
1195 return phonenumbers.parse("+49236360359%s" % self.sip_id)
1196
1197 def get_phone_numbers(self):
1198 ret = []
1199
1200 for field in ("telephoneNumber", "homePhone", "mobile"):
1201 for number in self._get_phone_numbers(field):
1202 ret.append(number)
1203
1204 return ret
1205
1206 def set_phone_numbers(self, phone_numbers):
1207 # Sort phone numbers by landline and mobile
1208 _landline_numbers = []
1209 _mobile_numbers = []
1210
1211 for number in phone_numbers:
1212 try:
1213 number = phonenumbers.parse(number, None)
1214 except phonenumbers.phonenumberutil.NumberParseException:
1215 continue
1216
1217 # Convert to string (in E.164 format)
1218 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1219
1220 # Separate mobile numbers
1221 if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE:
1222 _mobile_numbers.append(s)
1223 else:
1224 _landline_numbers.append(s)
1225
1226 # Save
1227 self._set_strings("telephoneNumber", _landline_numbers)
1228 self._set_strings("mobile", _mobile_numbers)
1229
1230 phone_numbers = property(get_phone_numbers, set_phone_numbers)
1231
1232 @property
1233 def _all_telephone_numbers(self):
1234 ret = [ self.sip_id, ]
1235
1236 if self.phone_number:
1237 s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
1238 ret.append(s)
1239
1240 for number in self.phone_numbers:
1241 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1242 ret.append(s)
1243
1244 return ret
1245
1246 # Description
1247
1248 def get_description(self):
1249 return self._get_string("description")
1250
1251 def set_description(self, description):
1252 self._set_string("description", description)
1253
1254 description = property(get_description, set_description)
1255
1256 # Avatar
1257
1258 @lazy_property
1259 def avatar_hash(self):
1260 # Fetch the timestamp (or fall back to the last LDAP change)
1261 t = self._fetch_avatar_timestamp() or self.modified_at
1262
1263 # Create the payload
1264 payload = "%s-%s" % (self.uid, t)
1265
1266 # Compute a hash over the payload
1267 h = hashlib.new("blake2b", payload.encode())
1268
1269 return h.hexdigest()[:7]
1270
1271 def avatar_url(self, size=None, absolute=False):
1272 # This cannot be async because we are calling it from the template engine
1273 url = "/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
1274
1275 # Return an absolute URL
1276 if absolute:
1277 url = urllib.parse.urljoin("https://www.ipfire.org", url)
1278
1279 if size:
1280 url += "&size=%s" % size
1281
1282 return url
1283
1284 async def get_avatar(self, size=None, format=None):
1285 # Check the PostgreSQL database
1286 photo = self._fetch_avatar()
1287
1288 # Fall back to LDAP
1289 if not photo:
1290 photo = self._get_bytes("jpegPhoto")
1291
1292 # Exit if no avatar is available
1293 if not photo:
1294 return
1295
1296 # Return the raw image if no size was requested
1297 if size is None:
1298 return photo
1299
1300 # Compose the cache key
1301 key = "accounts:%s:avatar:%s:%s:%s" \
1302 % (self.uid, self.avatar_hash, format or "N/A", size)
1303
1304 # Try to retrieve something from the cache
1305 avatar = await self.backend.cache.get(key)
1306 if avatar:
1307 return avatar
1308
1309 # Generate a new thumbnail
1310 avatar = util.generate_thumbnail(photo, size, square=True, format=format)
1311
1312 # Save to cache for 24h
1313 await self.backend.cache.set(key, avatar, 86400)
1314
1315 return avatar
1316
1317 def _fetch_avatar(self):
1318 """
1319 Fetches the original avatar blob as being uploaded by the user
1320 """
1321 res = self.db.get("""
1322 SELECT
1323 blob
1324 FROM
1325 account_avatars
1326 WHERE
1327 uid = %s
1328 AND
1329 deleted_at IS NULL
1330 """, self.uid,
1331 )
1332
1333 if res:
1334 return res.blob
1335
1336 def _fetch_avatar_timestamp(self):
1337 res = self.db.get("""
1338 SELECT
1339 created_at
1340 FROM
1341 account_avatars
1342 WHERE
1343 uid = %s
1344 AND
1345 deleted_at IS NULL
1346 """, self.uid,
1347 )
1348
1349 if res:
1350 return res.created_at
1351
1352 async def upload_avatar(self, avatar):
1353 # Remove all previous avatars
1354 self.db.execute("""
1355 UPDATE
1356 account_avatars
1357 SET
1358 deleted_at = CURRENT_TIMESTAMP
1359 WHERE
1360 uid = %s
1361 AND
1362 deleted_at IS NULL
1363 """, self.uid,
1364 )
1365
1366 # Store the new avatar in the database
1367 self.db.execute("""
1368 INSERT INTO
1369 account_avatars
1370 (
1371 uid,
1372 blob
1373 )
1374 VALUES
1375 (
1376 %s, %s
1377 )
1378 """, self.uid, avatar,
1379 )
1380
1381 # Remove anything in the LDAP database
1382 photo = self._get_bytes("jpegPhoto")
1383 if photo:
1384 self._delete("jpegPhoto", [photo])
1385
1386 # Consent to promotional emails
1387
1388 def get_consents_to_promotional_emails(self):
1389 return self.is_member_of_group("promotional-consent")
1390
1391 def set_contents_to_promotional_emails(self, value):
1392 group = self.backend.groups.get_by_gid("promotional-consent")
1393 assert group, "Could not find group: promotional-consent"
1394
1395 if value is True:
1396 group.add_member(self)
1397 else:
1398 group.del_member(self)
1399
1400 consents_to_promotional_emails = property(
1401 get_consents_to_promotional_emails,
1402 set_contents_to_promotional_emails,
1403 )
1404
1405 # Bugzilla
1406
1407 async def _disable_on_bugzilla(self, text=None):
1408 """
1409 Disables the user on Bugzilla
1410 """
1411 user = await self.backend.bugzilla.get_user(self.email)
1412
1413 # Do nothing if the user does not exist
1414 if not user:
1415 return
1416
1417 # Disable the user
1418 await user.disable(text)
1419
1420 # Mailman
1421
1422 async def get_lists(self):
1423 return await self.backend.lists.get_subscribed_lists(self)
1424
1425
1426 class Groups(Object):
1427 hidden_groups = (
1428 "cn=LDAP Read Only,ou=Group,dc=ipfire,dc=org",
1429 "cn=LDAP Read Write,ou=Group,dc=ipfire,dc=org",
1430
1431 # Everyone is a member of people
1432 "cn=people,ou=Group,dc=ipfire,dc=org",
1433 )
1434
1435 @property
1436 def search_base(self):
1437 return "ou=Group,%s" % self.backend.accounts.search_base
1438
1439 def _query(self, *args, **kwargs):
1440 kwargs.update({
1441 "search_base" : self.backend.groups.search_base,
1442 })
1443
1444 return self.backend.accounts._query(*args, **kwargs)
1445
1446 def __iter__(self):
1447 groups = self.get_all()
1448
1449 return iter(groups)
1450
1451 def _get_groups(self, query, **kwargs):
1452 res = self._query(query, **kwargs)
1453
1454 groups = []
1455 for dn, attrs in res:
1456 # Skip any hidden groups
1457 if dn in self.hidden_groups:
1458 continue
1459
1460 g = Group(self.backend, dn, attrs)
1461 groups.append(g)
1462
1463 return sorted(groups)
1464
1465 def _get_group(self, query, **kwargs):
1466 kwargs.update({
1467 "limit" : 1,
1468 })
1469
1470 groups = self._get_groups(query, **kwargs)
1471 if groups:
1472 return groups[0]
1473
1474 def get_all(self):
1475 return self._get_groups(
1476 "(|(objectClass=posixGroup)(objectClass=groupOfNames))",
1477 )
1478
1479 def get_by_gid(self, gid):
1480 return self._get_group(
1481 "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % gid,
1482 )
1483
1484
1485 class Group(LDAPObject):
1486 def __repr__(self):
1487 if self.description:
1488 return "<%s %s (%s)>" % (
1489 self.__class__.__name__,
1490 self.gid,
1491 self.description,
1492 )
1493
1494 return "<%s %s>" % (self.__class__.__name__, self.gid)
1495
1496 def __str__(self):
1497 return self.description or self.gid
1498
1499 def __lt__(self, other):
1500 if isinstance(other, self.__class__):
1501 return (self.description or self.gid) < (other.description or other.gid)
1502
1503 return NotImplemented
1504
1505 def __bool__(self):
1506 return True
1507
1508 def __len__(self):
1509 """
1510 Returns the number of members in this group
1511 """
1512 l = 0
1513
1514 for attr in ("member", "memberUid"):
1515 a = self.attributes.get(attr, None)
1516 if a:
1517 l += len(a)
1518
1519 return l
1520
1521 def __iter__(self):
1522 return iter(self.members)
1523
1524 @property
1525 def gid(self):
1526 return self._get_string("cn")
1527
1528 @property
1529 def description(self):
1530 return self._get_string("description")
1531
1532 @property
1533 def email(self):
1534 return self._get_string("mail")
1535
1536 @lazy_property
1537 def members(self):
1538 members = []
1539
1540 # Get all members by DN
1541 for dn in self._get_strings("member"):
1542 member = self.backend.accounts.get_by_dn(dn)
1543 if member:
1544 members.append(member)
1545
1546 # Get all members by UID
1547 for uid in self._get_strings("memberUid"):
1548 member = self.backend.accounts.get_by_uid(uid)
1549 if member:
1550 members.append(member)
1551
1552 return sorted(members)
1553
1554 def add_member(self, account):
1555 """
1556 Adds a member to this group
1557 """
1558 # Do nothing if this user is already in the group
1559 if account.is_member_of_group(self.gid):
1560 return
1561
1562 if "posixGroup" in self.objectclasses:
1563 self._add_string("memberUid", account.uid)
1564 else:
1565 self._add_string("member", account.dn)
1566
1567 # Append to cached list of members
1568 self.members.append(account)
1569 self.members.sort()
1570
1571 def del_member(self, account):
1572 """
1573 Removes a member from a group
1574 """
1575 # Do nothing if this user is not in the group
1576 if not account.is_member_of_group(self.gid):
1577 return
1578
1579 if "posixGroup" in self.objectclasses:
1580 self._delete_string("memberUid", account.uid)
1581 else:
1582 self._delete_string("member", account.dn)
1583
1584
1585 if __name__ == "__main__":
1586 a = Accounts()
1587
1588 print(a.list())