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