]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/accounts.py
people: Do not allow setting weak passwords
[ipfire.org.git] / src / backend / accounts.py
CommitLineData
940227cb 1#!/usr/bin/python
78fdedae 2# encoding: utf-8
940227cb 3
2cd9af74 4import PIL
22153577 5import PIL.ImageOps
3ea97943 6import datetime
11347e46 7import io
940227cb 8import ldap
e96e445b 9import ldap.modlist
27066195 10import logging
e96e445b 11import phonenumbers
f4672785 12import sshpubkeys
eea71144
MT
13import urllib.parse
14import urllib.request
6b582a4f 15import zxcvbn
940227cb 16
e96e445b 17from . import util
917434b8 18from .decorators import *
11347e46 19from .misc import Object
940227cb 20
a6dc0bad 21class Accounts(Object):
9f05796c
MT
22 def __iter__(self):
23 # Only return developers (group with ID 1000)
1bae74c7 24 accounts = self._search("(&(objectClass=posixAccount)(gidNumber=1000))")
9f05796c 25
1bae74c7 26 return iter(sorted(accounts))
9f05796c 27
0ab42c1d 28 @lazy_property
66862195 29 def ldap(self):
0ab42c1d
MT
30 # Connect to LDAP server
31 ldap_uri = self.settings.get("ldap_uri")
32 conn = ldap.initialize(ldap_uri)
940227cb 33
0ab42c1d
MT
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)
66862195 39
0ab42c1d 40 return conn
940227cb 41
1bae74c7 42 def _query(self, query, attrlist=None, limit=0):
66862195 43 logging.debug("Performing LDAP query: %s" % query)
940227cb 44
66862195 45 search_base = self.settings.get("ldap_search_base")
a395634c 46
a69e87a1
MT
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
0ab42c1d
MT
52 self.ldap.close()
53 del self.ldap
a69e87a1
MT
54
55 raise
940227cb 56
66862195 57 return results
940227cb 58
1bae74c7 59 def _search(self, query, attrlist=None, limit=0):
66862195 60 accounts = []
1bae74c7
MT
61
62 for dn, attrs in self._query(query, attrlist=attrlist, limit=limit):
66862195
MT
63 account = Account(self.backend, dn, attrs)
64 accounts.append(account)
65
1bae74c7
MT
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:
69b63fce 76 for account in self._search("(&(objectClass=posixAccount)(|(cn=*%s*)(uid=*%s*)))" % (query, query)):
1bae74c7
MT
77 if not account in accounts:
78 accounts.append(account)
79
66862195
MT
80 return sorted(accounts)
81
1bae74c7
MT
82 def _search_one(self, query):
83 result = self._search(query, limit=1)
66862195
MT
84 assert len(result) <= 1
85
86 if result:
87 return result[0]
88
66862195 89 def get_by_uid(self, uid):
1bae74c7 90 return self._search_one("(&(objectClass=posixAccount)(uid=%s))" % uid)
66862195
MT
91
92 def get_by_mail(self, mail):
1bae74c7 93 return self._search_one("(&(objectClass=posixAccount)(mail=%s))" % mail)
66862195
MT
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)
940227cb 103
66862195 104 def get_by_sip_id(self, sip_id):
1bae74c7 105 return self._search_one("(|(&(objectClass=sipUser)(sipAuthenticationUser=%s)) \
66862195 106 (&(objectClass=sipRoutingObject)(sipLocalAddress=%s)))" % (sip_id, sip_id))
940227cb 107
525c01f7
MT
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
66862195 113 # Session stuff
940227cb 114
66862195
MT
115 def _cleanup_expired_sessions(self):
116 self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
940227cb 117
66862195
MT
118 def create_session(self, account, host):
119 self._cleanup_expired_sessions()
940227cb 120
66862195
MT
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
940227cb 156
a6dc0bad 157class Account(Object):
66862195 158 def __init__(self, backend, dn, attrs=None):
a6dc0bad 159 Object.__init__(self, backend)
940227cb
MT
160 self.dn = dn
161
e96e445b 162 self.attributes = attrs or {}
940227cb 163
917434b8
MT
164 def __str__(self):
165 return self.name
166
940227cb
MT
167 def __repr__(self):
168 return "<%s %s>" % (self.__class__.__name__, self.dn)
169
541c952b
MT
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
940227cb
MT
177
178 @property
66862195
MT
179 def ldap(self):
180 return self.accounts.ldap
940227cb 181
e96e445b
MT
182 def _exists(self, key):
183 try:
184 self.attributes[key]
185 except KeyError:
186 return False
940227cb 187
e96e445b 188 return True
940227cb 189
e96e445b
MT
190 def _get(self, key):
191 for value in self.attributes.get(key, []):
192 yield value
940227cb 193
e96e445b
MT
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 = []
940227cb 229
e96e445b
MT
230 if self._exists(key):
231 modlist.append((ldap.MOD_DELETE, key, None))
940227cb 232
e96e445b 233 # Add new values
47bb098f
MT
234 if values:
235 modlist.append((ldap.MOD_ADD, key, values))
e96e445b
MT
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):
47bb098f 247 return self._set(key, [e.encode() for e in values if e])
e96e445b
MT
248
249 def _set_string(self, key, value):
250 return self._set_strings(key, [value,])
940227cb 251
0d1fb712
MT
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
6b582a4f 278 def passwd(self, password):
3ea97943
MT
279 """
280 Sets a new password
281 """
6b582a4f
MT
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)
3ea97943 288
940227cb
MT
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 """
0d1fb712
MT
296 if not password:
297 return
298
940227cb 299 logging.debug("Checking credentials for %s" % self.dn)
3ea97943
MT
300
301 # Create a new LDAP connection
302 ldap_uri = self.backend.settings.get("ldap_uri")
303 conn = ldap.initialize(ldap_uri)
304
940227cb 305 try:
3ea97943 306 conn.simple_bind_s(self.dn, password.encode("utf-8"))
940227cb 307 except ldap.INVALID_CREDENTIALS:
3ea97943 308 logging.debug("Account credentials are invalid for %s" % self)
940227cb
MT
309 return False
310
3ea97943
MT
311 logging.info("Successfully authenticated %s" % self)
312
940227cb
MT
313 return True
314
6b582a4f
MT
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
940227cb 324 def is_admin(self):
d82bc8e3 325 return "wheel" in self.groups
66862195
MT
326
327 def is_talk_enabled(self):
06c1d39c
MT
328 return "sipUser" in self.classes or "sipRoutingObject" in self.classes \
329 or self.telephone_numbers or self.address
66862195 330
e96e445b
MT
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
66862195
MT
342 @property
343 def classes(self):
e96e445b 344 return self._get_strings("objectClass")
66862195
MT
345
346 @property
347 def uid(self):
e96e445b 348 return self._get_string("uid")
940227cb 349
a6dc0bad
MT
350 @property
351 def name(self):
e96e445b 352 return self._get_string("cn")
66862195 353
e96e445b
MT
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)
66862195 379
1bae74c7 380 @lazy_property
66862195 381 def groups(self):
1bae74c7 382 groups = []
66862195 383
1bae74c7
MT
384 res = self.accounts._query("(&(objectClass=posixGroup) \
385 (memberUid=%s))" % self.uid, ["cn"])
66862195 386
1bae74c7
MT
387 for dn, attrs in res:
388 cns = attrs.get("cn")
389 if cns:
390 groups.append(cns[0].decode())
66862195 391
1bae74c7 392 return groups
66862195 393
e96e445b
MT
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(","))
66862195 401
e96e445b
MT
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)
a6dc0bad 410
940227cb
MT
411 @property
412 def email(self):
66862195 413 name = self.name.lower()
940227cb 414 name = name.replace(" ", ".")
78fdedae
MT
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")
940227cb 421
66862195 422 for mail in self.attributes.get("mail", []):
7aee4b8d 423 if mail.decode().startswith("%s@ipfire.org" % name):
940227cb
MT
424 return mail
425
2cd9af74
MT
426 # If everything else fails, we will go with the UID
427 return "%s@ipfire.org" % self.uid
940227cb 428
e96e445b
MT
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):
47bb098f 435 self._set_string("mailRoutingAddress", address or None)
e96e445b
MT
436
437 mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
438
66862195
MT
439 @property
440 def sip_id(self):
441 if "sipUser" in self.classes:
e96e445b 442 return self._get_string("sipAuthenticationUser")
66862195
MT
443
444 if "sipRoutingObject" in self.classes:
e96e445b 445 return self._get_string("sipLocalAddress")
66862195 446
2f51147a
MT
447 @property
448 def sip_password(self):
e96e445b
MT
449 return self._get_string("sipPassword")
450
451 @staticmethod
452 def _generate_sip_password():
453 return util.random_string(8)
2f51147a 454
66862195
MT
455 @property
456 def sip_url(self):
457 return "%s@ipfire.org" % self.sip_id
458
459 def uses_sip_forwarding(self):
e96e445b 460 if self.sip_routing_address:
66862195
MT
461 return True
462
463 return False
464
e96e445b
MT
465 # SIP Routing
466
467 def get_sip_routing_address(self):
66862195 468 if "sipRoutingObject" in self.classes:
e96e445b
MT
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)
66862195 507
917434b8
MT
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
1f38be5a
MT
519 @lazy_property
520 def sip_channels(self):
521 return self.backend.talk.freeswitch.get_sip_channels(self)
522
bdaf6b46
MT
523 def get_cdr(self, date=None, limit=None):
524 return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit)
525c01f7 525
e96e445b 526 # Phone Numbers
6ff61434 527
d3208ac7
MT
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
e96e445b
MT
541 def get_phone_numbers(self):
542 ret = []
6ff61434 543
e96e445b
MT
544 for field in ("telephoneNumber", "homePhone", "mobile"):
545 for number in self._get_phone_numbers(field):
546 ret.append(number)
6ff61434 547
e96e445b
MT
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)
8476e80f
MT
575
576 @property
577 def _all_telephone_numbers(self):
6ccc8acb
MT
578 ret = [ self.sip_id, ]
579
d3208ac7
MT
580 if self.phone_number:
581 s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
582 ret.append(s)
583
6ccc8acb
MT
584 for number in self.phone_numbers:
585 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
586 ret.append(s)
587
588 return ret
66862195 589
2cd9af74
MT
590 def avatar_url(self, size=None):
591 if self.backend.debug:
03706893 592 hostname = "http://people.dev.ipfire.org"
2cd9af74 593 else:
03706893 594 hostname = "https://people.ipfire.org"
2cd9af74 595
03706893 596 url = "%s/users/%s.jpg" % (hostname, self.uid)
2cd9af74
MT
597
598 if size:
599 url += "?size=%s" % size
600
601 return url
602
2cd9af74 603 def get_avatar(self, size=None):
e96e445b 604 avatar = self._get_bytes("jpegPhoto")
2cd9af74
MT
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):
1a226c83
MT
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
22153577
MT
620 # Resize the image to the desired resolution (and make it square)
621 thumbnail = PIL.ImageOps.fit(image, (size, size), PIL.Image.ANTIALIAS)
1a226c83
MT
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:
22153577 627 thumbnail.save(f, "JPEG", optimize=True, quality=98)
1a226c83 628 except:
22153577 629 thumbnail.save(f, "JPEG", quality=98)
1a226c83
MT
630
631 return f.getvalue()
2cd9af74 632
5cc10421
MT
633 def upload_avatar(self, avatar):
634 self._set("jpegPhoto", avatar)
635
f4672785
MT
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
60024cc8 654
44b75370 655 def get_ssh_key_by_hash_sha256(self, hash_sha256):
55b67ca4 656 for key in self.ssh_keys:
44b75370 657 if not key.hash_sha256() == hash_sha256:
55b67ca4
MT
658 continue
659
660 return key
661
0d1fb712
MT
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
55b67ca4 694
940227cb
MT
695if __name__ == "__main__":
696 a = Accounts()
697
11347e46 698 print(a.list())