]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/accounts.py
accounts: Don't rely on objects being of class posixAccount
[ipfire.org.git] / src / backend / accounts.py
1 #!/usr/bin/python
2 # encoding: utf-8
3
4 import PIL
5 import PIL.ImageOps
6 import datetime
7 import io
8 import ldap
9 import ldap.modlist
10 import logging
11 import phonenumbers
12 import sshpubkeys
13 import urllib.parse
14 import urllib.request
15 import zxcvbn
16
17 from . import util
18 from .decorators import *
19 from .misc import Object
20
21 class Accounts(Object):
22 def __iter__(self):
23 # Only return developers (group with ID 1000)
24 accounts = self._search("(&(objectClass=posixAccount)(gidNumber=1000))")
25
26 return iter(sorted(accounts))
27
28 @lazy_property
29 def ldap(self):
30 # Connect to LDAP server
31 ldap_uri = self.settings.get("ldap_uri")
32 conn = ldap.initialize(ldap_uri)
33
34 # Bind with username and password
35 bind_dn = self.settings.get("ldap_bind_dn")
36 if bind_dn:
37 bind_pw = self.settings.get("ldap_bind_pw", "")
38 conn.simple_bind(bind_dn, bind_pw)
39
40 return conn
41
42 def _query(self, query, attrlist=None, limit=0):
43 logging.debug("Performing LDAP query: %s" % query)
44
45 search_base = self.settings.get("ldap_search_base")
46
47 try:
48 results = self.ldap.search_ext_s(search_base, ldap.SCOPE_SUBTREE,
49 query, attrlist=attrlist, sizelimit=limit)
50 except:
51 # Close current connection
52 del self.ldap
53
54 raise
55
56 return results
57
58 def _search(self, query, attrlist=None, limit=0):
59 accounts = []
60
61 for dn, attrs in self._query(query, attrlist=attrlist, limit=limit):
62 account = Account(self.backend, dn, attrs)
63 accounts.append(account)
64
65 return accounts
66
67 def search(self, query):
68 # Search for exact matches
69 accounts = self._search("(&(objectClass=person) \
70 (|(uid=%s)(mail=%s)(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
71 % (query, query, query, query, query, query))
72
73 # Find accounts by name
74 if not accounts:
75 for account in self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)))" % (query, query)):
76 if not account in accounts:
77 accounts.append(account)
78
79 return sorted(accounts)
80
81 def _search_one(self, query):
82 result = self._search(query, limit=1)
83 assert len(result) <= 1
84
85 if result:
86 return result[0]
87
88 def get_by_uid(self, uid):
89 return self._search_one("(&(objectClass=person)(uid=%s))" % uid)
90
91 def get_by_mail(self, mail):
92 return self._search_one("(&(objectClass=inetOrgPerson)(mail=%s))" % mail)
93
94 find = get_by_uid
95
96 def find_account(self, s):
97 account = self.get_by_uid(s)
98 if account:
99 return account
100
101 return self.get_by_mail(s)
102
103 def get_by_sip_id(self, sip_id):
104 return self._search_one("(|(&(objectClass=sipUser)(sipAuthenticationUser=%s)) \
105 (&(objectClass=sipRoutingObject)(sipLocalAddress=%s)))" % (sip_id, sip_id))
106
107 def get_by_phone_number(self, number):
108 return self._search_one("(&(objectClass=inetOrgPerson) \
109 (|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
110 % (number, number, number, number))
111
112 # Session stuff
113
114 def _cleanup_expired_sessions(self):
115 self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
116
117 def create_session(self, account, host):
118 self._cleanup_expired_sessions()
119
120 res = self.db.get("INSERT INTO sessions(host, uid) VALUES(%s, %s) \
121 RETURNING session_id, time_expires", host, account.uid)
122
123 # Session could not be created
124 if not res:
125 return None, None
126
127 logging.info("Created session %s for %s which expires %s" \
128 % (res.session_id, account, res.time_expires))
129 return res.session_id, res.time_expires
130
131 def destroy_session(self, session_id, host):
132 logging.info("Destroying session %s" % session_id)
133
134 self.db.execute("DELETE FROM sessions \
135 WHERE session_id = %s AND host = %s", session_id, host)
136 self._cleanup_expired_sessions()
137
138 def get_by_session(self, session_id, host):
139 logging.debug("Looking up session %s" % session_id)
140
141 res = self.db.get("SELECT uid FROM sessions WHERE session_id = %s \
142 AND host = %s AND NOW() BETWEEN time_created AND time_expires",
143 session_id, host)
144
145 # Session does not exist or has expired
146 if not res:
147 return
148
149 # Update the session expiration time
150 self.db.execute("UPDATE sessions SET time_expires = NOW() + INTERVAL '14 days' \
151 WHERE session_id = %s AND host = %s", session_id, host)
152
153 return self.get_by_uid(res.uid)
154
155
156 class Account(Object):
157 def __init__(self, backend, dn, attrs=None):
158 Object.__init__(self, backend)
159 self.dn = dn
160
161 self.attributes = attrs or {}
162
163 def __str__(self):
164 return self.name
165
166 def __repr__(self):
167 return "<%s %s>" % (self.__class__.__name__, self.dn)
168
169 def __eq__(self, other):
170 if isinstance(other, self.__class__):
171 return self.dn == other.dn
172
173 def __lt__(self, other):
174 if isinstance(other, self.__class__):
175 return self.name < other.name
176
177 @property
178 def ldap(self):
179 return self.accounts.ldap
180
181 def _exists(self, key):
182 try:
183 self.attributes[key]
184 except KeyError:
185 return False
186
187 return True
188
189 def _get(self, key):
190 for value in self.attributes.get(key, []):
191 yield value
192
193 def _get_bytes(self, key, default=None):
194 for value in self._get(key):
195 return value
196
197 return default
198
199 def _get_strings(self, key):
200 for value in self._get(key):
201 yield value.decode()
202
203 def _get_string(self, key, default=None):
204 for value in self._get_strings(key):
205 return value
206
207 return default
208
209 def _get_phone_numbers(self, key):
210 for value in self._get_strings(key):
211 yield phonenumbers.parse(value, None)
212
213 def _modify(self, modlist):
214 logging.debug("Modifying %s: %s" % (self.dn, modlist))
215
216 # Run modify operation
217 self.ldap.modify_s(self.dn, modlist)
218
219 def _set(self, key, values):
220 current = self._get(key)
221
222 # Don't do anything if nothing has changed
223 if list(current) == values:
224 return
225
226 # Remove all old values and add all new ones
227 modlist = []
228
229 if self._exists(key):
230 modlist.append((ldap.MOD_DELETE, key, None))
231
232 # Add new values
233 if values:
234 modlist.append((ldap.MOD_ADD, key, values))
235
236 # Run modify operation
237 self._modify(modlist)
238
239 # Update cache
240 self.attributes.update({ key : values })
241
242 def _set_bytes(self, key, values):
243 return self._set(key, values)
244
245 def _set_strings(self, key, values):
246 return self._set(key, [e.encode() for e in values if e])
247
248 def _set_string(self, key, value):
249 return self._set_strings(key, [value,])
250
251 def _add(self, key, values):
252 modlist = [
253 (ldap.MOD_ADD, key, values),
254 ]
255
256 self._modify(modlist)
257
258 def _add_strings(self, key, values):
259 return self._add(key, [e.encode() for e in values])
260
261 def _add_string(self, key, value):
262 return self._add_strings(key, [value,])
263
264 def _delete(self, key, values):
265 modlist = [
266 (ldap.MOD_DELETE, key, values),
267 ]
268
269 self._modify(modlist)
270
271 def _delete_strings(self, key, values):
272 return self._delete(key, [e.encode() for e in values])
273
274 def _delete_string(self, key, value):
275 return self._delete_strings(key, [value,])
276
277 def passwd(self, password):
278 """
279 Sets a new password
280 """
281 # The new password must have a score of 3 or better
282 quality = self.check_password_quality(password)
283 if quality["score"] < 3:
284 raise ValueError("Password too weak")
285
286 self.ldap.passwd_s(self.dn, None, password)
287
288 def check_password(self, password):
289 """
290 Bind to the server with given credentials and return
291 true if password is corrent and false if not.
292
293 Raises exceptions from the server on any other errors.
294 """
295 if not password:
296 return
297
298 logging.debug("Checking credentials for %s" % self.dn)
299
300 # Create a new LDAP connection
301 ldap_uri = self.backend.settings.get("ldap_uri")
302 conn = ldap.initialize(ldap_uri)
303
304 try:
305 conn.simple_bind_s(self.dn, password.encode("utf-8"))
306 except ldap.INVALID_CREDENTIALS:
307 logging.debug("Account credentials are invalid for %s" % self)
308 return False
309
310 logging.info("Successfully authenticated %s" % self)
311
312 return True
313
314 def check_password_quality(self, password):
315 """
316 Passwords are passed through zxcvbn to make sure
317 that they are strong enough.
318 """
319 return zxcvbn.zxcvbn(password, user_inputs=(
320 self.first_name, self.last_name,
321 ))
322
323 def is_admin(self):
324 return "wheel" in self.groups
325
326 def is_talk_enabled(self):
327 return "sipUser" in self.classes or "sipRoutingObject" in self.classes \
328 or self.telephone_numbers or self.address
329
330 def can_be_managed_by(self, account):
331 """
332 Returns True if account is allowed to manage this account
333 """
334 # Admins can manage all accounts
335 if account.is_admin():
336 return True
337
338 # Users can manage themselves
339 return self == account
340
341 @property
342 def classes(self):
343 return self._get_strings("objectClass")
344
345 @property
346 def uid(self):
347 return self._get_string("uid")
348
349 @property
350 def name(self):
351 return self._get_string("cn")
352
353 # First Name
354
355 def get_first_name(self):
356 return self._get_string("givenName")
357
358 def set_first_name(self, first_name):
359 self._set_string("givenName", first_name)
360
361 # Update Common Name
362 self._set_string("cn", "%s %s" % (first_name, self.last_name))
363
364 first_name = property(get_first_name, set_first_name)
365
366 # Last Name
367
368 def get_last_name(self):
369 return self._get_string("sn")
370
371 def set_last_name(self, last_name):
372 self._set_string("sn", last_name)
373
374 # Update Common Name
375 self._set_string("cn", "%s %s" % (self.first_name, last_name))
376
377 last_name = property(get_last_name, set_last_name)
378
379 @lazy_property
380 def groups(self):
381 groups = []
382
383 res = self.accounts._query("(&(objectClass=posixGroup) \
384 (memberUid=%s))" % self.uid, ["cn"])
385
386 for dn, attrs in res:
387 cns = attrs.get("cn")
388 if cns:
389 groups.append(cns[0].decode())
390
391 return groups
392
393 # Address
394
395 def get_address(self):
396 address = self._get_string("homePostalAddress")
397
398 if address:
399 return (line.strip() for line in address.split(","))
400
401 return []
402
403 def set_address(self, address):
404 data = ", ".join(address.splitlines())
405
406 self._set_bytes("homePostalAddress", data.encode())
407
408 address = property(get_address, set_address)
409
410 @property
411 def email(self):
412 name = self.name.lower()
413 name = name.replace(" ", ".")
414 name = name.replace("Ä", "Ae")
415 name = name.replace("Ö", "Oe")
416 name = name.replace("Ü", "Ue")
417 name = name.replace("ä", "ae")
418 name = name.replace("ö", "oe")
419 name = name.replace("ü", "ue")
420
421 for mail in self.attributes.get("mail", []):
422 if mail.decode().startswith("%s@ipfire.org" % name):
423 return mail
424
425 # If everything else fails, we will go with the UID
426 return "%s@ipfire.org" % self.uid
427
428 # Mail Routing Address
429
430 def get_mail_routing_address(self):
431 return self._get_string("mailRoutingAddress", None)
432
433 def set_mail_routing_address(self, address):
434 self._set_string("mailRoutingAddress", address or None)
435
436 mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
437
438 @property
439 def sip_id(self):
440 if "sipUser" in self.classes:
441 return self._get_string("sipAuthenticationUser")
442
443 if "sipRoutingObject" in self.classes:
444 return self._get_string("sipLocalAddress")
445
446 @property
447 def sip_password(self):
448 return self._get_string("sipPassword")
449
450 @staticmethod
451 def _generate_sip_password():
452 return util.random_string(8)
453
454 @property
455 def sip_url(self):
456 return "%s@ipfire.org" % self.sip_id
457
458 def uses_sip_forwarding(self):
459 if self.sip_routing_address:
460 return True
461
462 return False
463
464 # SIP Routing
465
466 def get_sip_routing_address(self):
467 if "sipRoutingObject" in self.classes:
468 return self._get_string("sipRoutingAddress")
469
470 def set_sip_routing_address(self, address):
471 if not address:
472 address = None
473
474 # Don't do anything if nothing has changed
475 if self.get_sip_routing_address() == address:
476 return
477
478 if address:
479 modlist = [
480 # This is no longer a SIP user any more
481 (ldap.MOD_DELETE, "objectClass", b"sipUser"),
482 (ldap.MOD_DELETE, "sipAuthenticationUser", None),
483 (ldap.MOD_DELETE, "sipPassword", None),
484
485 (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
486 (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
487 (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
488 ]
489 else:
490 modlist = [
491 (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
492 (ldap.MOD_DELETE, "sipLocalAddress", None),
493 (ldap.MOD_DELETE, "sipRoutingAddress", None),
494
495 (ldap.MOD_ADD, "objectClass", b"sipUser"),
496 (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
497 (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
498 ]
499
500 # Run modification
501 self._modify(modlist)
502
503 # XXX Cache is invalid here
504
505 sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
506
507 @lazy_property
508 def sip_registrations(self):
509 sip_registrations = []
510
511 for reg in self.backend.talk.freeswitch.get_sip_registrations(self.sip_url):
512 reg.account = self
513
514 sip_registrations.append(reg)
515
516 return sip_registrations
517
518 @lazy_property
519 def sip_channels(self):
520 return self.backend.talk.freeswitch.get_sip_channels(self)
521
522 def get_cdr(self, date=None, limit=None):
523 return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit)
524
525 # Phone Numbers
526
527 @lazy_property
528 def phone_number(self):
529 """
530 Returns the IPFire phone number
531 """
532 if self.sip_id:
533 return phonenumbers.parse("+4923636035%s" % self.sip_id)
534
535 @lazy_property
536 def fax_number(self):
537 if self.sip_id:
538 return phonenumbers.parse("+49236360359%s" % self.sip_id)
539
540 def get_phone_numbers(self):
541 ret = []
542
543 for field in ("telephoneNumber", "homePhone", "mobile"):
544 for number in self._get_phone_numbers(field):
545 ret.append(number)
546
547 return ret
548
549 def set_phone_numbers(self, phone_numbers):
550 # Sort phone numbers by landline and mobile
551 _landline_numbers = []
552 _mobile_numbers = []
553
554 for number in phone_numbers:
555 try:
556 number = phonenumbers.parse(number, None)
557 except phonenumbers.phonenumberutil.NumberParseException:
558 continue
559
560 # Convert to string (in E.164 format)
561 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
562
563 # Separate mobile numbers
564 if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE:
565 _mobile_numbers.append(s)
566 else:
567 _landline_numbers.append(s)
568
569 # Save
570 self._set_strings("telephoneNumber", _landline_numbers)
571 self._set_strings("mobile", _mobile_numbers)
572
573 phone_numbers = property(get_phone_numbers, set_phone_numbers)
574
575 @property
576 def _all_telephone_numbers(self):
577 ret = [ self.sip_id, ]
578
579 if self.phone_number:
580 s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
581 ret.append(s)
582
583 for number in self.phone_numbers:
584 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
585 ret.append(s)
586
587 return ret
588
589 def avatar_url(self, size=None):
590 if self.backend.debug:
591 hostname = "http://people.dev.ipfire.org"
592 else:
593 hostname = "https://people.ipfire.org"
594
595 url = "%s/users/%s.jpg" % (hostname, self.uid)
596
597 if size:
598 url += "?size=%s" % size
599
600 return url
601
602 def get_avatar(self, size=None):
603 avatar = self._get_bytes("jpegPhoto")
604 if not avatar:
605 return
606
607 if not size:
608 return avatar
609
610 return self._resize_avatar(avatar, size)
611
612 def _resize_avatar(self, image, size):
613 image = PIL.Image.open(io.BytesIO(image))
614
615 # Convert RGBA images into RGB because JPEG doesn't support alpha-channels
616 if image.mode == "RGBA":
617 image = image.convert("RGB")
618
619 # Resize the image to the desired resolution (and make it square)
620 thumbnail = PIL.ImageOps.fit(image, (size, size), PIL.Image.ANTIALIAS)
621
622 with io.BytesIO() as f:
623 # If writing out the image does not work with optimization,
624 # we try to write it out without any optimization.
625 try:
626 thumbnail.save(f, "JPEG", optimize=True, quality=98)
627 except:
628 thumbnail.save(f, "JPEG", quality=98)
629
630 return f.getvalue()
631
632 def upload_avatar(self, avatar):
633 self._set("jpegPhoto", avatar)
634
635 # SSH Keys
636
637 @lazy_property
638 def ssh_keys(self):
639 ret = []
640
641 for key in self._get_strings("sshPublicKey"):
642 s = sshpubkeys.SSHKey()
643
644 try:
645 s.parse(key)
646 except (sshpubkeys.InvalidKeyError, NotImplementedError) as e:
647 logging.warning("Could not parse SSH key %s: %s" % (key, e))
648 continue
649
650 ret.append(s)
651
652 return ret
653
654 def get_ssh_key_by_hash_sha256(self, hash_sha256):
655 for key in self.ssh_keys:
656 if not key.hash_sha256() == hash_sha256:
657 continue
658
659 return key
660
661 def add_ssh_key(self, key):
662 k = sshpubkeys.SSHKey()
663
664 # Try to parse the key
665 k.parse(key)
666
667 # Check for types and sufficient sizes
668 if k.key_type == b"ssh-rsa":
669 if k.bits < 4096:
670 raise sshpubkeys.TooShortKeyError("RSA keys cannot be smaller than 4096 bits")
671
672 elif k.key_type == b"ssh-dss":
673 raise sshpubkeys.InvalidKeyError("DSA keys are not supported")
674
675 # Ignore any duplicates
676 if key in (k.keydata for k in self.ssh_keys):
677 logging.debug("SSH Key has already been added for %s: %s" % (self, key))
678 return
679
680 # Prepare transaction
681 modlist = []
682
683 # Add object class if user is not in it, yet
684 if not "ldapPublicKey" in self.classes:
685 modlist.append((ldap.MOD_ADD, "objectClass", b"ldapPublicKey"))
686
687 # Add key
688 modlist.append((ldap.MOD_ADD, "sshPublicKey", key.encode()))
689
690 # Save key to LDAP
691 self._modify(modlist)
692
693 # Append to cache
694 self.ssh_keys.append(k)
695
696 def delete_ssh_key(self, key):
697 if not key in (k.keydata for k in self.ssh_keys):
698 return
699
700 # Delete key from LDAP
701 if len(self.ssh_keys) > 1:
702 self._delete_string("sshPublicKey", key)
703 else:
704 self._modify([
705 (ldap.MOD_DELETE, "objectClass", b"ldapPublicKey"),
706 (ldap.MOD_DELETE, "sshPublicKey", key.encode()),
707 ])
708
709
710 if __name__ == "__main__":
711 a = Accounts()
712
713 print(a.list())