]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/accounts.py
accounts: Store avatars in PostgreSQL
[ipfire.org.git] / src / backend / accounts.py
CommitLineData
940227cb 1#!/usr/bin/python
78fdedae 2# encoding: utf-8
940227cb 3
26ccb61a 4import asyncio
2dac7110 5import base64
3ea97943 6import datetime
969a05eb 7import hashlib
2dac7110 8import hmac
226d2676 9import iso3166
23f84bbc 10import json
da8538e5 11import kerberos
940227cb 12import ldap
e96e445b 13import ldap.modlist
27066195 14import logging
30e11b1b 15import os
e96e445b 16import phonenumbers
689effd0 17import re
da8538e5 18import socket
f001fd03 19import sys
f0c9d237 20import time
23f84bbc 21import tornado.httpclient
eea71144
MT
22import urllib.parse
23import urllib.request
6b582a4f 24import zxcvbn
940227cb 25
0099c2a7 26from . import countries
e96e445b 27from . import util
917434b8 28from .decorators import *
11347e46 29from .misc import Object
940227cb 30
30e11b1b
MT
31# Set the client keytab name
32os.environ["KRB5_CLIENT_KTNAME"] = "/etc/ipfire.org/ldap.keytab"
33
da8538e5
MT
34FQDN = socket.gethostname()
35
959d8d2a
MT
36class 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
03e6aafb
MT
46 return NotImplemented
47
959d8d2a
MT
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
959d8d2a
MT
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
1118f2b1
MT
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
a5449be9
MT
167 @property
168 def objectclasses(self):
169 return self._get_strings("objectClass")
170
e3f34bb5
MT
171 @staticmethod
172 def _parse_date(s):
173 return datetime.datetime.strptime(s.decode(), "%Y%m%d%H%M%SZ")
174
959d8d2a 175
a6dc0bad 176class Accounts(Object):
58d22b5d
MT
177 def init(self):
178 self.search_base = self.settings.get("ldap_search_base")
179
226d2676 180 def __len__(self):
14a30250 181 return self._count("(objectClass=person)")
226d2676 182
9f05796c 183 def __iter__(self):
408a8ffe 184 accounts = self._search("(objectClass=person)")
9f05796c 185
1bae74c7 186 return iter(sorted(accounts))
9f05796c 187
0ab42c1d 188 @lazy_property
66862195 189 def ldap(self):
0ab42c1d
MT
190 # Connect to LDAP server
191 ldap_uri = self.settings.get("ldap_uri")
940227cb 192
867c06a1
MT
193 logging.debug("Connecting to LDAP server: %s" % ldap_uri)
194
6c9a8663 195 # Connect to the LDAP server
4e3cfa04
MT
196 connection = ldap.ldapobject.ReconnectLDAPObject(ldap_uri,
197 trace_level=2 if self.backend.debug else 0,
f001fd03 198 retry_max=sys.maxsize, retry_delay=3)
6c9a8663 199
4e3cfa04
MT
200 # Set maximum timeout for operations
201 connection.set_option(ldap.OPT_TIMEOUT, 10)
202
203 return connection
204
6e33e8e1 205 def _authenticate(self):
30e11b1b
MT
206 # Authenticate against LDAP server using Kerberos
207 self.ldap.sasl_gssapi_bind_s()
208
75cde144 209 async def test_ldap(self):
30e11b1b
MT
210 logging.info("Testing LDAP connection...")
211
212 self._authenticate()
213
214 logging.info("Successfully authenticated as %s" % self.ldap.whoami_s())
940227cb 215
a3bbc04e 216 def _query(self, query, attrlist=None, limit=0, search_base=None):
9150881e
MT
217 logging.debug("Performing LDAP query (%s): %s" \
218 % (search_base or self.search_base, query))
940227cb 219
f0c9d237 220 t = time.time()
a69e87a1 221
41e86198
MT
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:
41e86198
MT
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
f0c9d237
MT
254
255 # Log time it took to perform the query
41e86198 256 logging.debug("Query took %.2fms (%s page(s))" % ((time.time() - t) * 1000.0, pages))
940227cb 257
66862195 258 return results
940227cb 259
226d2676 260 def _count(self, query):
41e86198 261 res = self._query(query, attrlist=["dn"])
226d2676
MT
262
263 return len(res)
264
1bae74c7 265 def _search(self, query, attrlist=None, limit=0):
a3bbc04e
MT
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)
91f72160 270
a3bbc04e 271 return accounts
0dcf4344 272
a3bbc04e
MT
273 def _get_attrs(self, dn):
274 """
275 Fetches all attributes for the given distinguished name
276 """
9150881e
MT
277 results = self._query("(objectClass=*)", search_base=dn, limit=1,
278 attrlist=("*", "createTimestamp", "modifyTimestamp"))
91f72160 279
a3bbc04e
MT
280 for dn, attrs in results:
281 return attrs
91f72160 282
2cdf68d8 283 def get_by_dn(self, dn):
2d6fff58 284 attrs = self._get_attrs(dn)
91f72160
MT
285
286 return Account(self.backend, dn, attrs)
287
e3f34bb5
MT
288 @staticmethod
289 def _format_date(t):
290 return t.strftime("%Y%m%d%H%M%SZ")
291
beb13102
MT
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
9150881e 308 def get_created_after(self, ts):
e3f34bb5 309 return self._search("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
9150881e 310
e3f34bb5
MT
311 def count_created_after(self, ts):
312 return self._count("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
9150881e 313
1bae74c7 314 def search(self, query):
beb13102
MT
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))
1bae74c7 339
66862195
MT
340 return sorted(accounts)
341
1bae74c7 342 def _search_one(self, query):
18209c78 343 results = self._search(query, limit=1)
66862195 344
18209c78
MT
345 for result in results:
346 return result
66862195 347
689effd0 348 def uid_is_valid(self, uid):
689effd0 349 # https://unix.stackexchange.com/questions/157426/what-is-the-regex-to-validate-linux-users
48e3ea58 350 m = re.match(r"^[a-z_][a-z0-9_-]{3,31}$", uid)
689effd0
MT
351 if m:
352 return True
353
354 return False
355
f32dd17f
MT
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
3095c017
MT
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
66181c96
MT
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 \
52e32ba0 395 WHERE domain = %s OR %s LIKE '%%.' || domain", domain, domain)
66181c96
MT
396
397 if res and res.found:
398 return True
399
400 return False
401
66862195 402 def get_by_uid(self, uid):
73a54cb6 403 return self._search_one("(&(objectClass=person)(uid=%s))" % uid)
66862195
MT
404
405 def get_by_mail(self, mail):
73a54cb6 406 return self._search_one("(&(objectClass=inetOrgPerson)(mail=%s))" % mail)
66862195 407
66862195
MT
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)
940227cb 414
66862195 415 def get_by_sip_id(self, sip_id):
df70e85e
MT
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))
940227cb 422
525c01f7 423 def get_by_phone_number(self, number):
df70e85e
MT
424 if not number:
425 return
426
427 return self._search_one(
428 "(&(objectClass=inetOrgPerson)(|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
525c01f7
MT
429 % (number, number, number, number))
430
285b963c
MT
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
328a7710
MT
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
268a972b 445 # Join
f32dd17f 446
268a972b 447 def join(self, uid, email, first_name, last_name, country_code=None):
16048b22
MT
448 # Convert all uids to lowercase
449 uid = uid.lower()
450
689effd0
MT
451 # Check if UID is valid
452 if not self.uid_is_valid(uid):
453 raise ValueError("UID is invalid: %s" % uid)
454
f32dd17f 455 # Check if UID is unique
16048b22 456 if self.uid_exists(uid):
f32dd17f
MT
457 raise ValueError("UID exists: %s" % uid)
458
3095c017
MT
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
66181c96
MT
463 # Check if the email address is blacklisted
464 if self.mail_is_blacklisted(email):
465 raise ValueError("Email is blacklisted: %s" % email)
466
718d1375
MT
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, \
757372cd
MT
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)
718d1375
MT
475
476 # Send an account activation email
268a972b 477 self.backend.messages.send_template("auth/messages/join",
5eebd27e 478 priority=100, uid=uid, activation_code=activation_code, email=email,
718d1375
MT
479 first_name=first_name, last_name=last_name)
480
b4d72c76
MT
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
f32dd17f 489
cd5c8452
MT
490 # Return the account if it has already been created
491 account = self.get_by_uid(uid)
492 if account:
493 return account
494
b4d72c76 495 # Create a new account on the LDAP database
fd86345d 496 account = self.create(uid, res.email,
757372cd
MT
497 first_name=res.first_name, last_name=res.last_name,
498 country_code=res.country_code)
b4d72c76 499
92c4b559
MT
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
fd86345d
MT
504 # Send email about account registration
505 self.backend.messages.send_template("people/messages/new-account",
ba14044c 506 account=account)
fd86345d 507
922609cc
MT
508 # Launch drip campaigns
509 for campaign in ("signup", "christmas"):
510 self.backend.campaigns.launch(campaign, account)
d73bba54 511
fd86345d
MT
512 return account
513
757372cd 514 def create(self, uid, email, first_name, last_name, country_code=None):
a151df3f
MT
515 cn = "%s %s" % (first_name, last_name)
516
f32dd17f
MT
517 # Account Parameters
518 account = {
519 "objectClass" : [b"top", b"person", b"inetOrgPerson"],
f32dd17f
MT
520 "mail" : email.encode(),
521
522 # Name
a151df3f 523 "cn" : cn.encode(),
f32dd17f
MT
524 "sn" : last_name.encode(),
525 "givenName" : first_name.encode(),
526 }
527
b4d72c76
MT
528 logging.info("Creating new account: %s: %s" % (uid, account))
529
530 # Create DN
c51fd4bf 531 dn = "uid=%s,ou=People,dc=ipfire,dc=org" % uid
b4d72c76 532
f32dd17f 533 # Create account on LDAP
6e33e8e1 534 self.accounts._authenticate()
b4d72c76 535 self.ldap.add_s(dn, ldap.modlist.addModlist(account))
f32dd17f 536
757372cd
MT
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
b4d72c76 544 # Return account
757372cd 545 return account
f32dd17f 546
66862195 547 # Session stuff
940227cb 548
66862195 549 def create_session(self, account, host):
4b91a306
MT
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)
66862195
MT
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)
66862195
MT
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)
d86f6f18 585
8e69850a
MT
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
b2b94608
MT
593 # Cleanup expired account password resets
594 self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()")
595
26ccb61a
MT
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
2dac7110
MT
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
226d2676
MT
646 @property
647 def countries(self):
648 ret = {}
649
650 for country in iso3166.countries:
ff1bdac9 651 count = self._count("(&(objectClass=person)(c=%s))" % country.alpha2)
226d2676
MT
652
653 if count:
654 ret[country] = count
655
656 return ret
657
5bfc6729
MT
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
940227cb 668
959d8d2a 669class Account(LDAPObject):
917434b8 670 def __str__(self):
d6e57f73
MT
671 if self.nickname:
672 return self.nickname
673
917434b8
MT
674 return self.name
675
940227cb
MT
676 def __repr__(self):
677 return "<%s %s>" % (self.__class__.__name__, self.dn)
678
541c952b
MT
679 def __lt__(self, other):
680 if isinstance(other, self.__class__):
681 return self.name < other.name
940227cb 682
a2532d7c
MT
683 return NotImplemented
684
1118f2b1
MT
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
ddfa1d3d
MT
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
ddfa1d3d
MT
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
6b582a4f 737 def passwd(self, password):
3ea97943
MT
738 """
739 Sets a new password
740 """
6b582a4f
MT
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
1babcd04 746 self.accounts._authenticate()
6b582a4f 747 self.ldap.passwd_s(self.dn, None, password)
3ea97943 748
940227cb
MT
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 """
0d1fb712
MT
756 if not password:
757 return
758
940227cb 759 logging.debug("Checking credentials for %s" % self.dn)
3ea97943 760
da8538e5 761 # Check the credentials against the Kerberos database
940227cb 762 try:
da8538e5
MT
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
940227cb
MT
769 return False
770
da8538e5
MT
771 # Otherwise return True
772 else:
773 logging.info("Successfully authenticated %s" % self)
3ea97943 774
da8538e5 775 return True
940227cb 776
6b582a4f
MT
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
391ede9e 786 def request_password_reset(self, address=None):
c7594d58
MT
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",
5eebd27e 794 priority=100, account=self, reset_code=reset_code)
c7594d58 795
391ede9e
MT
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
940227cb 809 def is_admin(self):
eae206f4 810 return self.is_member_of_group("sudo")
66862195 811
71a3109c 812 def is_staff(self):
eae206f4
MT
813 return self.is_member_of_group("staff")
814
815 def is_moderator(self):
816 return self.is_member_of_group("moderators")
71a3109c
MT
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
66862195 826
868c68ac
MT
827 def is_blog_author(self):
828 return self.is_member_of_group("blog-authors")
829
b01a1ee3
MT
830 def is_lwl(self):
831 return self.is_member_of_group("lwl-staff")
832
e96e445b
MT
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
66862195
MT
844 @property
845 def classes(self):
e96e445b 846 return self._get_strings("objectClass")
66862195
MT
847
848 @property
849 def uid(self):
e96e445b 850 return self._get_string("uid")
940227cb 851
a6dc0bad
MT
852 @property
853 def name(self):
e96e445b 854 return self._get_string("cn")
66862195 855
26ccb61a
MT
856 # Delete
857
858 async def delete(self, user):
859 """
860 Deletes this user
861 """
862 # Check if this user can be deleted
1cd4d7d3 863 if not self.can_be_deleted_by(user):
26ccb61a
MT
864 raise RuntimeError("Cannot delete user %s" % self)
865
1118f2b1
MT
866 logging.info("Deleting user %s" % self)
867
26ccb61a
MT
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
1118f2b1
MT
878 # Delete on LDAP
879 self._delete()
26ccb61a 880
1cd4d7d3 881 def can_be_deleted_by(self, user):
26ccb61a
MT
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
1118f2b1
MT
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
d6e57f73
MT
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
e96e445b
MT
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)
66862195 941
1bae74c7 942 @lazy_property
66862195 943 def groups(self):
18b13823 944 return self.backend.groups._get_groups("(| \
ae485256
MT
945 (&(objectClass=groupOfNames)(member=%s)) \
946 (&(objectClass=posixGroup)(memberUid=%s)) \
18b13823 947 )" % (self.dn, self.uid))
66862195 948
eae206f4
MT
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
9150881e
MT
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
e96e445b
MT
965 # Address
966
0099c2a7
MT
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
e5f0d12b 985 return [line for line in address if line]
0099c2a7
MT
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)
e96e445b 992
0099c2a7 993 street = property(get_street, set_street)
66862195 994
0099c2a7
MT
995 def get_city(self):
996 return self._get_string("l") or ""
e96e445b 997
0099c2a7
MT
998 def set_city(self, city):
999 self._set_string("l", city)
e96e445b 1000
0099c2a7 1001 city = property(get_city, set_city)
e96e445b 1002
0099c2a7
MT
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
ff1bdac9 1011 def get_state(self):
0099c2a7
MT
1012 return self._get_string("st")
1013
ff1bdac9
MT
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
0099c2a7 1022 def set_country_code(self, country_code):
ff1bdac9 1023 self._set_string("c", country_code)
0099c2a7
MT
1024
1025 country_code = property(get_country_code, set_country_code)
1026
1027 @property
1028 def country_name(self):
1029 if self.country_code:
e929ed92 1030 return self.backend.get_country_name(self.country_code)
a6dc0bad 1031
940227cb
MT
1032 @property
1033 def email(self):
d86f6f18 1034 return self._get_string("mail")
940227cb 1035
d73bba54
MT
1036 @property
1037 def email_to(self):
1038 return "%s <%s>" % (self, self.email)
1039
e5f0d12b
MT
1040 @lazy_property
1041 def alternate_email_addresses(self):
1042 addresses = self._get_strings("mailAlternateAddress")
1043
1044 return sorted(addresses)
1045
e96e445b
MT
1046 # Mail Routing Address
1047
1048 def get_mail_routing_address(self):
1049 return self._get_string("mailRoutingAddress", None)
1050
1051 def set_mail_routing_address(self, address):
47bb098f 1052 self._set_string("mailRoutingAddress", address or None)
e96e445b
MT
1053
1054 mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
1055
66862195
MT
1056 @property
1057 def sip_id(self):
1058 if "sipUser" in self.classes:
e96e445b 1059 return self._get_string("sipAuthenticationUser")
66862195
MT
1060
1061 if "sipRoutingObject" in self.classes:
e96e445b 1062 return self._get_string("sipLocalAddress")
66862195 1063
2f51147a
MT
1064 @property
1065 def sip_password(self):
e96e445b
MT
1066 return self._get_string("sipPassword")
1067
1068 @staticmethod
1069 def _generate_sip_password():
1070 return util.random_string(8)
2f51147a 1071
66862195
MT
1072 @property
1073 def sip_url(self):
1074 return "%s@ipfire.org" % self.sip_id
1075
1076 def uses_sip_forwarding(self):
e96e445b 1077 if self.sip_routing_address:
66862195
MT
1078 return True
1079
1080 return False
1081
e96e445b
MT
1082 # SIP Routing
1083
1084 def get_sip_routing_address(self):
66862195 1085 if "sipRoutingObject" in self.classes:
e96e445b
MT
1086 return self._get_string("sipRoutingAddress")
1087
1088 def set_sip_routing_address(self, address):
1089 if not address:
1090 address = None
1091
1092 # Don't do anything if nothing has changed
1093 if self.get_sip_routing_address() == address:
1094 return
1095
1096 if address:
79cce555
MT
1097 # This is no longer a SIP user any more
1098 try:
1099 self._modify([
1100 (ldap.MOD_DELETE, "objectClass", b"sipUser"),
1101 (ldap.MOD_DELETE, "sipAuthenticationUser", None),
1102 (ldap.MOD_DELETE, "sipPassword", None),
1103 ])
1104 except ldap.NO_SUCH_ATTRIBUTE:
1105 pass
1106
1107 # Set new routing object
1108 try:
1109 self._modify([
1110 (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
1111 (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
1112 (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
1113 ])
1114
1115 # If this is a change, we cannot add this again
1116 except ldap.TYPE_OR_VALUE_EXISTS:
1117 self._set_string("sipRoutingAddress", address)
e96e445b 1118 else:
79cce555
MT
1119 try:
1120 self._modify([
1121 (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
1122 (ldap.MOD_DELETE, "sipLocalAddress", None),
1123 (ldap.MOD_DELETE, "sipRoutingAddress", None),
1124 ])
1125 except ldap.NO_SUCH_ATTRIBUTE:
1126 pass
1127
1128 self._modify([
e96e445b
MT
1129 (ldap.MOD_ADD, "objectClass", b"sipUser"),
1130 (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
1131 (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
79cce555 1132 ])
e96e445b
MT
1133
1134 # XXX Cache is invalid here
1135
1136 sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
66862195 1137
4235ba55 1138 # SIP Registrations
917434b8 1139
4235ba55
MT
1140 async def get_sip_registrations(self):
1141 if not self.has_sip():
1142 return []
917434b8 1143
4235ba55 1144 return await self.backend.asterisk.get_registrations(self.sip_id)
917434b8 1145
4235ba55 1146 # SIP Channels
917434b8 1147
d6c41da2
MT
1148 async def get_sip_channels(self):
1149 if not self.has_sip():
1150 return []
1151
1152 return await self.backend.asterisk.get_sip_channels(self.sip_id)
1f38be5a 1153
e96e445b 1154 # Phone Numbers
6ff61434 1155
d3208ac7
MT
1156 @lazy_property
1157 def phone_number(self):
1158 """
1159 Returns the IPFire phone number
1160 """
1161 if self.sip_id:
1162 return phonenumbers.parse("+4923636035%s" % self.sip_id)
1163
1164 @lazy_property
1165 def fax_number(self):
1166 if self.sip_id:
1167 return phonenumbers.parse("+49236360359%s" % self.sip_id)
1168
e96e445b
MT
1169 def get_phone_numbers(self):
1170 ret = []
6ff61434 1171
e96e445b
MT
1172 for field in ("telephoneNumber", "homePhone", "mobile"):
1173 for number in self._get_phone_numbers(field):
1174 ret.append(number)
6ff61434 1175
e96e445b
MT
1176 return ret
1177
1178 def set_phone_numbers(self, phone_numbers):
1179 # Sort phone numbers by landline and mobile
1180 _landline_numbers = []
1181 _mobile_numbers = []
1182
1183 for number in phone_numbers:
1184 try:
1185 number = phonenumbers.parse(number, None)
1186 except phonenumbers.phonenumberutil.NumberParseException:
1187 continue
1188
1189 # Convert to string (in E.164 format)
1190 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1191
1192 # Separate mobile numbers
1193 if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE:
1194 _mobile_numbers.append(s)
1195 else:
1196 _landline_numbers.append(s)
1197
1198 # Save
1199 self._set_strings("telephoneNumber", _landline_numbers)
1200 self._set_strings("mobile", _mobile_numbers)
1201
1202 phone_numbers = property(get_phone_numbers, set_phone_numbers)
8476e80f
MT
1203
1204 @property
1205 def _all_telephone_numbers(self):
6ccc8acb
MT
1206 ret = [ self.sip_id, ]
1207
d3208ac7
MT
1208 if self.phone_number:
1209 s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
1210 ret.append(s)
1211
6ccc8acb
MT
1212 for number in self.phone_numbers:
1213 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1214 ret.append(s)
1215
1216 return ret
66862195 1217
1c4522dc
MT
1218 # Description
1219
1220 def get_description(self):
1221 return self._get_string("description")
1222
1223 def set_description(self, description):
1224 self._set_string("description", description)
1225
1226 description = property(get_description, set_description)
1227
1228 # Avatar
1229
77cab7da 1230 @lazy_property
14a30250 1231 def avatar_hash(self):
77cab7da
MT
1232 # Fetch the timestamp (or fall back to the last LDAP change)
1233 t = self._fetch_avatar_timestamp() or self.modified_at
14a30250 1234
77cab7da
MT
1235 # Create the payload
1236 payload = "%s-%s" % (self.uid, t)
5d42f49b 1237
77cab7da
MT
1238 # Compute a hash over the payload
1239 h = hashlib.new("blake2b", payload.encode())
5d42f49b 1240
14a30250 1241 return h.hexdigest()[:7]
5d42f49b 1242
0da30c28 1243 def avatar_url(self, size=None, absolute=False):
14a30250 1244 # This cannot be async because we are calling it from the template engine
0da30c28
MT
1245 url = "/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
1246
1247 # Return an absolute URL
1248 if absolute:
3b88a1d3 1249 url = urllib.parse.urljoin("https://www.ipfire.org", url)
2cd9af74
MT
1250
1251 if size:
969a05eb 1252 url += "&size=%s" % size
2cd9af74
MT
1253
1254 return url
1255
14a30250 1256 async def get_avatar(self, size=None):
77cab7da
MT
1257 # Check the PostgreSQL database
1258 photo = self._fetch_avatar()
1259
1260 # Fall back to LDAP
1261 if not photo:
1262 photo = self._get_bytes("jpegPhoto")
2cd9af74 1263
0109451c
MT
1264 # Exit if no avatar is available
1265 if not photo:
1266 return
1267
5ef115cd
MT
1268 # Return the raw image if no size was requested
1269 if size is None:
1270 return photo
2cd9af74 1271
77cab7da
MT
1272 # Compose the cache key
1273 key = "accounts:%s:avatar:%s:%s" % (self.uid, self.avatar_hash, size)
1274
5ef115cd 1275 # Try to retrieve something from the cache
77cab7da 1276 avatar = await self.backend.cache.get(key)
5ef115cd
MT
1277 if avatar:
1278 return avatar
1a226c83 1279
5ef115cd 1280 # Generate a new thumbnail
2de3dacc 1281 avatar = util.generate_thumbnail(photo, size, square=True)
1a226c83 1282
77cab7da
MT
1283 # Save to cache for 24h
1284 await self.backend.cache.set(key, avatar, 86400)
1a226c83 1285
5ef115cd 1286 return avatar
2cd9af74 1287
77cab7da
MT
1288 def _fetch_avatar(self):
1289 """
1290 Fetches the original avatar blob as being uploaded by the user
1291 """
1292 res = self.db.get("""
1293 SELECT
1294 blob
1295 FROM
1296 account_avatars
1297 WHERE
1298 uid = %s
1299 AND
1300 deleted_at IS NULL
1301 """, self.uid,
1302 )
1303
1304 if res:
1305 return res.blob
1306
1307 def _fetch_avatar_timestamp(self):
1308 res = self.db.get("""
1309 SELECT
1310 created_at
1311 FROM
1312 account_avatars
1313 WHERE
1314 uid = %s
1315 AND
1316 deleted_at IS NULL
1317 """, self.uid,
1318 )
1319
1320 if res:
1321 return res.created_at
1322
1323 async def upload_avatar(self, avatar):
1324 # Remove all previous avatars
1325 self.db.execute("""
1326 UPDATE
1327 account_avatars
1328 SET
1329 deleted_at = CURRENT_TIMESTAMP
1330 WHERE
1331 uid = %s
1332 AND
1333 deleted_at IS NULL
1334 """, self.uid,
1335 )
1336
1337 # Store the new avatar in the database
1338 self.db.execute("""
1339 INSERT INTO
1340 account_avatars
1341 (
1342 uid,
1343 blob
1344 )
1345 VALUES
1346 (
1347 %s, %s
1348 )
1349 """, self.uid, avatar,
1350 )
1351
1352 # Remove anything in the LDAP database
1353 photo = self._get_bytes("jpegPhoto")
1354 if photo:
1355 self._delete("jpegPhoto", [photo])
5cc10421 1356
92c4b559
MT
1357 # Consent to promotional emails
1358
1359 def get_consents_to_promotional_emails(self):
1360 return self.is_member_of_group("promotional-consent")
1361
1362 def set_contents_to_promotional_emails(self, value):
1363 group = self.backend.groups.get_by_gid("promotional-consent")
1364 assert group, "Could not find group: promotional-consent"
1365
1366 if value is True:
1367 group.add_member(self)
1368 else:
1369 group.del_member(self)
1370
1371 consents_to_promotional_emails = property(
1372 get_consents_to_promotional_emails,
1373 set_contents_to_promotional_emails,
1374 )
1375
26ccb61a
MT
1376 # Bugzilla
1377
1378 async def _disable_on_bugzilla(self, text=None):
1379 """
1380 Disables the user on Bugzilla
1381 """
1382 user = await self.backend.bugzilla.get_user(self.email)
1383
1384 # Do nothing if the user does not exist
1385 if not user:
1386 return
1387
1388 # Disable the user
1389 await user.disable(text)
1390
97e15cf6
MT
1391 # Mailman
1392
1393 async def get_lists(self):
1394 return await self.backend.lists.get_subscribed_lists(self)
1395
55b67ca4 1396
d8b04c72 1397class Groups(Object):
b6365721
MT
1398 hidden_groups = (
1399 "cn=LDAP Read Only,ou=Group,dc=ipfire,dc=org",
1400 "cn=LDAP Read Write,ou=Group,dc=ipfire,dc=org",
d71fd279
MT
1401
1402 # Everyone is a member of people
1403 "cn=people,ou=Group,dc=ipfire,dc=org",
b6365721
MT
1404 )
1405
d8b04c72
MT
1406 @property
1407 def search_base(self):
1408 return "ou=Group,%s" % self.backend.accounts.search_base
1409
18b13823
MT
1410 def _query(self, *args, **kwargs):
1411 kwargs.update({
1412 "search_base" : self.backend.groups.search_base,
1413 })
1414
1415 return self.backend.accounts._query(*args, **kwargs)
1416
1417 def __iter__(self):
1418 groups = self.get_all()
1419
1420 return iter(groups)
1421
1422 def _get_groups(self, query, **kwargs):
1423 res = self._query(query, **kwargs)
1424
1425 groups = []
1426 for dn, attrs in res:
b6365721
MT
1427 # Skip any hidden groups
1428 if dn in self.hidden_groups:
1429 continue
1430
18b13823
MT
1431 g = Group(self.backend, dn, attrs)
1432 groups.append(g)
1433
1434 return sorted(groups)
1435
bef47ee8
MT
1436 def _get_group(self, query, **kwargs):
1437 kwargs.update({
1438 "limit" : 1,
1439 })
1440
1441 groups = self._get_groups(query, **kwargs)
1442 if groups:
1443 return groups[0]
1444
18b13823
MT
1445 def get_all(self):
1446 return self._get_groups(
1447 "(|(objectClass=posixGroup)(objectClass=groupOfNames))",
1448 )
1449
bef47ee8
MT
1450 def get_by_gid(self, gid):
1451 return self._get_group(
1452 "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % gid,
1453 )
1454
d8b04c72 1455
959d8d2a 1456class Group(LDAPObject):
d8b04c72
MT
1457 def __repr__(self):
1458 if self.description:
1459 return "<%s %s (%s)>" % (
1460 self.__class__.__name__,
1461 self.gid,
1462 self.description,
1463 )
1464
1465 return "<%s %s>" % (self.__class__.__name__, self.gid)
1466
1467 def __str__(self):
1468 return self.description or self.gid
1469
d8b04c72
MT
1470 def __lt__(self, other):
1471 if isinstance(other, self.__class__):
1472 return (self.description or self.gid) < (other.description or other.gid)
1473
03e6aafb
MT
1474 return NotImplemented
1475
d71fd279
MT
1476 def __bool__(self):
1477 return True
1478
74f967de
MT
1479 def __len__(self):
1480 """
1481 Returns the number of members in this group
1482 """
1483 l = 0
1484
1485 for attr in ("member", "memberUid"):
1486 a = self.attributes.get(attr, None)
1487 if a:
1488 l += len(a)
1489
1490 return l
1491
bef47ee8
MT
1492 def __iter__(self):
1493 return iter(self.members)
1494
18b13823
MT
1495 @property
1496 def gid(self):
959d8d2a 1497 return self._get_string("cn")
d8b04c72
MT
1498
1499 @property
1500 def description(self):
959d8d2a 1501 return self._get_string("description")
d8b04c72 1502
83a4b1d5
MT
1503 @property
1504 def email(self):
959d8d2a 1505 return self._get_string("mail")
83a4b1d5 1506
bef47ee8
MT
1507 @lazy_property
1508 def members(self):
1509 members = []
1510
1511 # Get all members by DN
959d8d2a
MT
1512 for dn in self._get_strings("member"):
1513 member = self.backend.accounts.get_by_dn(dn)
bef47ee8
MT
1514 if member:
1515 members.append(member)
1516
959d8d2a
MT
1517 # Get all members by UID
1518 for uid in self._get_strings("memberUid"):
1519 member = self.backend.accounts.get_by_uid(uid)
bef47ee8
MT
1520 if member:
1521 members.append(member)
1522
1523 return sorted(members)
d8b04c72 1524
a5449be9
MT
1525 def add_member(self, account):
1526 """
1527 Adds a member to this group
1528 """
92c4b559
MT
1529 # Do nothing if this user is already in the group
1530 if account.is_member_of_group(self.gid):
1531 return
1532
a5449be9
MT
1533 if "posixGroup" in self.objectclasses:
1534 self._add_string("memberUid", account.uid)
1535 else:
1536 self._add_string("member", account.dn)
1537
1538 # Append to cached list of members
1539 self.members.append(account)
1540 self.members.sort()
1541
92c4b559
MT
1542 def del_member(self, account):
1543 """
1544 Removes a member from a group
1545 """
1546 # Do nothing if this user is not in the group
1547 if not account.is_member_of_group(self.gid):
1548 return
1549
1550 if "posixGroup" in self.objectclasses:
1551 self._delete_string("memberUid", account.uid)
1552 else:
1553 self._delete_string("member", account.dn)
1554
1555
940227cb
MT
1556if __name__ == "__main__":
1557 a = Accounts()
1558
11347e46 1559 print(a.list())