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