]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/accounts.py
mirrors: Stop emailing Michael if a mirror is down
[ipfire.org.git] / src / backend / accounts.py
CommitLineData
940227cb 1#!/usr/bin/python
78fdedae 2# encoding: utf-8
940227cb 3
2dac7110 4import base64
3ea97943 5import datetime
969a05eb 6import hashlib
2dac7110 7import hmac
226d2676 8import iso3166
23f84bbc 9import json
940227cb 10import ldap
e96e445b 11import ldap.modlist
27066195 12import logging
30e11b1b 13import os
e96e445b 14import phonenumbers
689effd0 15import re
f0c9d237 16import time
23f84bbc 17import tornado.httpclient
eea71144
MT
18import urllib.parse
19import urllib.request
6b582a4f 20import zxcvbn
940227cb 21
0099c2a7 22from . import countries
e96e445b 23from . import util
917434b8 24from .decorators import *
11347e46 25from .misc import Object
940227cb 26
92d075d0
MT
27INT_MAX = (2**31) - 1
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,
6c9a8663
MT
199 retry_max=10, retry_delay=3)
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
210 def test_ldap(self):
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
9150881e 299 def get_created_after(self, ts):
e3f34bb5 300 return self._search("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
9150881e 301
e3f34bb5
MT
302 def count_created_after(self, ts):
303 return self._count("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
9150881e 304
1bae74c7 305 def search(self, query):
53d0ec1d
MT
306 accounts = self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)(displayName=*%s*)(mail=*%s*)))" \
307 % (query, query, query, query))
1bae74c7 308
66862195
MT
309 return sorted(accounts)
310
1bae74c7 311 def _search_one(self, query):
18209c78 312 results = self._search(query, limit=1)
66862195 313
18209c78
MT
314 for result in results:
315 return result
66862195 316
689effd0 317 def uid_is_valid(self, uid):
689effd0 318 # https://unix.stackexchange.com/questions/157426/what-is-the-regex-to-validate-linux-users
48e3ea58 319 m = re.match(r"^[a-z_][a-z0-9_-]{3,31}$", uid)
689effd0
MT
320 if m:
321 return True
322
323 return False
324
f32dd17f
MT
325 def uid_exists(self, uid):
326 if self.get_by_uid(uid):
327 return True
328
329 res = self.db.get("SELECT 1 FROM account_activations \
330 WHERE uid = %s AND expires_at > NOW()", uid)
331
332 if res:
333 return True
334
335 # Account with uid does not exist, yet
336 return False
337
3095c017
MT
338 def mail_is_valid(self, mail):
339 username, delim, domain = mail.partition("@")
340
341 # There must be an @ and a domain part
342 if not domain:
343 return False
344
345 # The domain cannot end on a dot
346 if domain.endswith("."):
347 return False
348
349 # The domain should at least have one dot to fully qualified
350 if not "." in domain:
351 return False
352
353 # Looks like a valid email address
354 return True
355
66181c96
MT
356 def mail_is_blacklisted(self, mail):
357 username, delim, domain = mail.partition("@")
358
359 if domain:
360 return self.domain_is_blacklisted(domain)
361
362 def domain_is_blacklisted(self, domain):
363 res = self.db.get("SELECT TRUE AS found FROM blacklisted_domains \
364 WHERE domain = %s", domain)
365
366 if res and res.found:
367 return True
368
369 return False
370
66862195 371 def get_by_uid(self, uid):
73a54cb6 372 return self._search_one("(&(objectClass=person)(uid=%s))" % uid)
66862195
MT
373
374 def get_by_mail(self, mail):
73a54cb6 375 return self._search_one("(&(objectClass=inetOrgPerson)(mail=%s))" % mail)
66862195 376
66862195
MT
377 def find_account(self, s):
378 account = self.get_by_uid(s)
379 if account:
380 return account
381
382 return self.get_by_mail(s)
940227cb 383
66862195 384 def get_by_sip_id(self, sip_id):
df70e85e
MT
385 if not sip_id:
386 return
387
388 return self._search_one(
389 "(|(&(objectClass=sipUser)(sipAuthenticationUser=%s))(&(objectClass=sipRoutingObject)(sipLocalAddress=%s)))" \
390 % (sip_id, sip_id))
940227cb 391
525c01f7 392 def get_by_phone_number(self, number):
df70e85e
MT
393 if not number:
394 return
395
396 return self._search_one(
397 "(&(objectClass=inetOrgPerson)(|(sipAuthenticationUser=%s)(telephoneNumber=%s)(homePhone=%s)(mobile=%s)))" \
525c01f7
MT
398 % (number, number, number, number))
399
285b963c
MT
400 @property
401 def pending_registrations(self):
402 res = self.db.get("SELECT COUNT(*) AS c FROM account_activations")
403
404 return res.c or 0
405
a5343559
MT
406 async def check_spam(self, email, address):
407 sfs = StopForumSpam(self.backend, email, address)
23f84bbc
MT
408
409 # Get spam score
9fdf4fb7 410 score = await sfs.check()
23f84bbc
MT
411
412 return score >= 50
413
328a7710
MT
414 def auth(self, username, password):
415 # Find account
416 account = self.backend.accounts.find_account(username)
417
418 # Check credentials
419 if account and account.check_password(password):
420 return account
421
f32dd17f
MT
422 # Registration
423
757372cd 424 def register(self, uid, email, first_name, last_name, country_code=None):
16048b22
MT
425 # Convert all uids to lowercase
426 uid = uid.lower()
427
689effd0
MT
428 # Check if UID is valid
429 if not self.uid_is_valid(uid):
430 raise ValueError("UID is invalid: %s" % uid)
431
f32dd17f 432 # Check if UID is unique
16048b22 433 if self.uid_exists(uid):
f32dd17f
MT
434 raise ValueError("UID exists: %s" % uid)
435
3095c017
MT
436 # Check if the email address is valid
437 if not self.mail_is_valid(email):
438 raise ValueError("Email is invalid: %s" % email)
439
66181c96
MT
440 # Check if the email address is blacklisted
441 if self.mail_is_blacklisted(email):
442 raise ValueError("Email is blacklisted: %s" % email)
443
718d1375
MT
444 # Generate a random activation code
445 activation_code = util.random_string(36)
446
447 # Create an entry in our database until the user
448 # has activated the account
449 self.db.execute("INSERT INTO account_activations(uid, activation_code, \
757372cd
MT
450 email, first_name, last_name, country_code) VALUES(%s, %s, %s, %s, %s, %s)",
451 uid, activation_code, email, first_name, last_name, country_code)
718d1375
MT
452
453 # Send an account activation email
454 self.backend.messages.send_template("auth/messages/register",
5eebd27e 455 priority=100, uid=uid, activation_code=activation_code, email=email,
718d1375
MT
456 first_name=first_name, last_name=last_name)
457
b4d72c76
MT
458 def activate(self, uid, activation_code):
459 res = self.db.get("DELETE FROM account_activations \
460 WHERE uid = %s AND activation_code = %s AND expires_at > NOW() \
461 RETURNING *", uid, activation_code)
462
463 # Return nothing when account was not found
464 if not res:
465 return
f32dd17f 466
cd5c8452
MT
467 # Return the account if it has already been created
468 account = self.get_by_uid(uid)
469 if account:
470 return account
471
b4d72c76 472 # Create a new account on the LDAP database
fd86345d 473 account = self.create(uid, res.email,
757372cd
MT
474 first_name=res.first_name, last_name=res.last_name,
475 country_code=res.country_code)
b4d72c76 476
92c4b559
MT
477 # Non-EU users do not need to consent to promo emails
478 if account.country_code and not account.country_code in countries.EU_COUNTRIES:
479 account.consents_to_promotional_emails = True
480
fd86345d
MT
481 # Send email about account registration
482 self.backend.messages.send_template("people/messages/new-account",
ba14044c 483 account=account)
fd86345d 484
922609cc
MT
485 # Launch drip campaigns
486 for campaign in ("signup", "christmas"):
487 self.backend.campaigns.launch(campaign, account)
d73bba54 488
fd86345d
MT
489 return account
490
757372cd 491 def create(self, uid, email, first_name, last_name, country_code=None):
a151df3f
MT
492 cn = "%s %s" % (first_name, last_name)
493
f32dd17f
MT
494 # Account Parameters
495 account = {
496 "objectClass" : [b"top", b"person", b"inetOrgPerson"],
f32dd17f
MT
497 "mail" : email.encode(),
498
499 # Name
a151df3f 500 "cn" : cn.encode(),
f32dd17f
MT
501 "sn" : last_name.encode(),
502 "givenName" : first_name.encode(),
503 }
504
b4d72c76
MT
505 logging.info("Creating new account: %s: %s" % (uid, account))
506
507 # Create DN
c51fd4bf 508 dn = "uid=%s,ou=People,dc=ipfire,dc=org" % uid
b4d72c76 509
f32dd17f 510 # Create account on LDAP
6e33e8e1 511 self.accounts._authenticate()
b4d72c76 512 self.ldap.add_s(dn, ldap.modlist.addModlist(account))
f32dd17f 513
757372cd
MT
514 # Fetch the account
515 account = self.get_by_dn(dn)
516
517 # Optionally set country code
518 if country_code:
519 account.country_code = country_code
520
b4d72c76 521 # Return account
757372cd 522 return account
f32dd17f 523
66862195 524 # Session stuff
940227cb 525
66862195 526 def create_session(self, account, host):
4b91a306
MT
527 session_id = util.random_string(64)
528
529 res = self.db.get("INSERT INTO sessions(host, uid, session_id) VALUES(%s, %s, %s) \
530 RETURNING session_id, time_expires", host, account.uid, session_id)
66862195
MT
531
532 # Session could not be created
533 if not res:
534 return None, None
535
536 logging.info("Created session %s for %s which expires %s" \
537 % (res.session_id, account, res.time_expires))
538 return res.session_id, res.time_expires
539
540 def destroy_session(self, session_id, host):
541 logging.info("Destroying session %s" % session_id)
542
543 self.db.execute("DELETE FROM sessions \
544 WHERE session_id = %s AND host = %s", session_id, host)
66862195
MT
545
546 def get_by_session(self, session_id, host):
547 logging.debug("Looking up session %s" % session_id)
548
549 res = self.db.get("SELECT uid FROM sessions WHERE session_id = %s \
550 AND host = %s AND NOW() BETWEEN time_created AND time_expires",
551 session_id, host)
552
553 # Session does not exist or has expired
554 if not res:
555 return
556
557 # Update the session expiration time
558 self.db.execute("UPDATE sessions SET time_expires = NOW() + INTERVAL '14 days' \
559 WHERE session_id = %s AND host = %s", session_id, host)
560
561 return self.get_by_uid(res.uid)
d86f6f18 562
8e69850a
MT
563 def cleanup(self):
564 # Cleanup expired sessions
565 self.db.execute("DELETE FROM sessions WHERE time_expires <= NOW()")
566
567 # Cleanup expired account activations
568 self.db.execute("DELETE FROM account_activations WHERE expires_at <= NOW()")
569
b2b94608
MT
570 # Cleanup expired account password resets
571 self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()")
572
2dac7110
MT
573 # Discourse
574
575 def decode_discourse_payload(self, payload, signature):
576 # Check signature
577 calculated_signature = self.sign_discourse_payload(payload)
578
579 if not hmac.compare_digest(signature, calculated_signature):
580 raise ValueError("Invalid signature: %s" % signature)
581
582 # Decode the query string
583 qs = base64.b64decode(payload).decode()
584
585 # Parse the query string
586 data = {}
587 for key, val in urllib.parse.parse_qsl(qs):
588 data[key] = val
589
590 return data
591
592 def encode_discourse_payload(self, **args):
593 # Encode the arguments into an URL-formatted string
594 qs = urllib.parse.urlencode(args).encode()
595
596 # Encode into base64
597 return base64.b64encode(qs).decode()
598
599 def sign_discourse_payload(self, payload, secret=None):
600 if secret is None:
601 secret = self.settings.get("discourse_sso_secret")
602
603 # Calculate a HMAC using SHA256
604 h = hmac.new(secret.encode(),
605 msg=payload.encode(), digestmod="sha256")
606
607 return h.hexdigest()
608
226d2676
MT
609 @property
610 def countries(self):
611 ret = {}
612
613 for country in iso3166.countries:
614 count = self._count("(&(objectClass=person)(st=%s))" % country.alpha2)
615
616 if count:
617 ret[country] = count
618
619 return ret
620
5bfc6729
MT
621 async def get_all_emails(self):
622 # Returns all email addresses
623 for dn, attrs in self._query("(objectClass=person)", attrlist=("mail",)):
624 mails = attrs.get("mail", None)
625 if not mails:
626 continue
627
628 for mail in mails:
629 print(mail.decode())
630
940227cb 631
959d8d2a 632class Account(LDAPObject):
917434b8 633 def __str__(self):
d6e57f73
MT
634 if self.nickname:
635 return self.nickname
636
917434b8
MT
637 return self.name
638
940227cb
MT
639 def __repr__(self):
640 return "<%s %s>" % (self.__class__.__name__, self.dn)
641
541c952b
MT
642 def __lt__(self, other):
643 if isinstance(other, self.__class__):
644 return self.name < other.name
940227cb 645
959d8d2a 646 def _clear_cache(self):
91f72160 647 # Delete cached attributes
9c01e5ac 648 self.memcache.delete("accounts:%s:attrs" % self.dn)
91f72160 649
ddfa1d3d
MT
650 @lazy_property
651 def kerberos_attributes(self):
652 res = self.backend.accounts._query(
653 "(&(objectClass=krbPrincipal)(krbPrincipalName=%s@IPFIRE.ORG))" % self.uid,
654 attrlist=[
655 "krbLastSuccessfulAuth",
656 "krbLastPasswordChange",
657 "krbLastFailedAuth",
658 "krbLoginFailedCount",
659 ],
660 limit=1,
661 search_base="cn=krb5,%s" % self.backend.accounts.search_base)
662
663 for dn, attrs in res:
664 return { key : attrs[key][0] for key in attrs }
665
666 return {}
667
ddfa1d3d
MT
668 @property
669 def last_successful_authentication(self):
670 try:
671 s = self.kerberos_attributes["krbLastSuccessfulAuth"]
672 except KeyError:
673 return None
674
675 return self._parse_date(s)
676
677 @property
678 def last_failed_authentication(self):
679 try:
680 s = self.kerberos_attributes["krbLastFailedAuth"]
681 except KeyError:
682 return None
683
684 return self._parse_date(s)
685
686 @property
687 def failed_login_count(self):
688 try:
689 count = self.kerberos_attributes["krbLoginFailedCount"].decode()
690 except KeyError:
691 return 0
692
693 try:
694 return int(count)
695 except ValueError:
696 return 0
697
6b582a4f 698 def passwd(self, password):
3ea97943
MT
699 """
700 Sets a new password
701 """
6b582a4f
MT
702 # The new password must have a score of 3 or better
703 quality = self.check_password_quality(password)
704 if quality["score"] < 3:
705 raise ValueError("Password too weak")
706
1babcd04 707 self.accounts._authenticate()
6b582a4f 708 self.ldap.passwd_s(self.dn, None, password)
3ea97943 709
940227cb
MT
710 def check_password(self, password):
711 """
712 Bind to the server with given credentials and return
713 true if password is corrent and false if not.
714
715 Raises exceptions from the server on any other errors.
716 """
0d1fb712
MT
717 if not password:
718 return
719
940227cb 720 logging.debug("Checking credentials for %s" % self.dn)
3ea97943
MT
721
722 # Create a new LDAP connection
723 ldap_uri = self.backend.settings.get("ldap_uri")
724 conn = ldap.initialize(ldap_uri)
725
940227cb 726 try:
3ea97943 727 conn.simple_bind_s(self.dn, password.encode("utf-8"))
940227cb 728 except ldap.INVALID_CREDENTIALS:
3ea97943 729 logging.debug("Account credentials are invalid for %s" % self)
940227cb
MT
730 return False
731
3ea97943
MT
732 logging.info("Successfully authenticated %s" % self)
733
940227cb
MT
734 return True
735
6b582a4f
MT
736 def check_password_quality(self, password):
737 """
738 Passwords are passed through zxcvbn to make sure
739 that they are strong enough.
740 """
741 return zxcvbn.zxcvbn(password, user_inputs=(
742 self.first_name, self.last_name,
743 ))
744
391ede9e 745 def request_password_reset(self, address=None):
c7594d58
MT
746 reset_code = util.random_string(64)
747
748 self.db.execute("INSERT INTO account_password_resets(uid, reset_code, address) \
749 VALUES(%s, %s, %s)", self.uid, reset_code, address)
750
751 # Send a password reset email
752 self.backend.messages.send_template("auth/messages/password-reset",
5eebd27e 753 priority=100, account=self, reset_code=reset_code)
c7594d58 754
391ede9e
MT
755 def reset_password(self, reset_code, new_password):
756 # Delete the reset token
757 res = self.db.query("DELETE FROM account_password_resets \
758 WHERE uid = %s AND reset_code = %s AND expires_at >= NOW() \
759 RETURNING *", self.uid, reset_code)
760
761 # The reset code was invalid
762 if not res:
763 raise ValueError("Invalid password reset token for %s: %s" % (self, reset_code))
764
765 # Perform password change
766 return self.passwd(new_password)
767
940227cb 768 def is_admin(self):
eae206f4 769 return self.is_member_of_group("sudo")
66862195 770
71a3109c 771 def is_staff(self):
eae206f4
MT
772 return self.is_member_of_group("staff")
773
774 def is_moderator(self):
775 return self.is_member_of_group("moderators")
71a3109c
MT
776
777 def has_shell(self):
778 return "posixAccount" in self.classes
779
780 def has_mail(self):
781 return "postfixMailUser" in self.classes
782
783 def has_sip(self):
784 return "sipUser" in self.classes or "sipRoutingObject" in self.classes
66862195 785
e96e445b
MT
786 def can_be_managed_by(self, account):
787 """
788 Returns True if account is allowed to manage this account
789 """
790 # Admins can manage all accounts
791 if account.is_admin():
792 return True
793
794 # Users can manage themselves
795 return self == account
796
66862195
MT
797 @property
798 def classes(self):
e96e445b 799 return self._get_strings("objectClass")
66862195
MT
800
801 @property
802 def uid(self):
e96e445b 803 return self._get_string("uid")
940227cb 804
a6dc0bad
MT
805 @property
806 def name(self):
e96e445b 807 return self._get_string("cn")
66862195 808
d6e57f73
MT
809 # Nickname
810
811 def get_nickname(self):
812 return self._get_string("displayName")
813
814 def set_nickname(self, nickname):
815 self._set_string("displayName", nickname)
816
817 nickname = property(get_nickname, set_nickname)
818
e96e445b
MT
819 # First Name
820
821 def get_first_name(self):
822 return self._get_string("givenName")
823
824 def set_first_name(self, first_name):
825 self._set_string("givenName", first_name)
826
827 # Update Common Name
828 self._set_string("cn", "%s %s" % (first_name, self.last_name))
829
830 first_name = property(get_first_name, set_first_name)
831
832 # Last Name
833
834 def get_last_name(self):
835 return self._get_string("sn")
836
837 def set_last_name(self, last_name):
838 self._set_string("sn", last_name)
839
840 # Update Common Name
841 self._set_string("cn", "%s %s" % (self.first_name, last_name))
842
843 last_name = property(get_last_name, set_last_name)
66862195 844
1bae74c7 845 @lazy_property
66862195 846 def groups(self):
18b13823 847 return self.backend.groups._get_groups("(| \
ae485256
MT
848 (&(objectClass=groupOfNames)(member=%s)) \
849 (&(objectClass=posixGroup)(memberUid=%s)) \
18b13823 850 )" % (self.dn, self.uid))
66862195 851
eae206f4
MT
852 def is_member_of_group(self, gid):
853 """
854 Returns True if this account is a member of this group
855 """
856 return gid in (g.gid for g in self.groups)
857
9150881e
MT
858 # Created/Modified at
859
860 @property
861 def created_at(self):
862 return self._get_timestamp("createTimestamp")
863
864 @property
865 def modified_at(self):
866 return self._get_timestamp("modifyTimestamp")
867
e96e445b
MT
868 # Address
869
0099c2a7
MT
870 @property
871 def address(self):
872 address = []
873
874 if self.street:
875 address += self.street.splitlines()
876
877 if self.postal_code and self.city:
878 if self.country_code in ("AT", "DE"):
879 address.append("%s %s" % (self.postal_code, self.city))
880 else:
881 address.append("%s, %s" % (self.city, self.postal_code))
882 else:
883 address.append(self.city or self.postal_code)
884
885 if self.country_name:
886 address.append(self.country_name)
887
888 return address
889
890 def get_street(self):
891 return self._get_string("street") or self._get_string("homePostalAddress")
892
893 def set_street(self, street):
894 self._set_string("street", street)
e96e445b 895
0099c2a7 896 street = property(get_street, set_street)
66862195 897
0099c2a7
MT
898 def get_city(self):
899 return self._get_string("l") or ""
e96e445b 900
0099c2a7
MT
901 def set_city(self, city):
902 self._set_string("l", city)
e96e445b 903
0099c2a7 904 city = property(get_city, set_city)
e96e445b 905
0099c2a7
MT
906 def get_postal_code(self):
907 return self._get_string("postalCode") or ""
908
909 def set_postal_code(self, postal_code):
910 self._set_string("postalCode", postal_code)
911
912 postal_code = property(get_postal_code, set_postal_code)
913
914 # XXX This should be c
915 def get_country_code(self):
916 return self._get_string("st")
917
918 def set_country_code(self, country_code):
919 self._set_string("st", country_code)
920
921 country_code = property(get_country_code, set_country_code)
922
923 @property
924 def country_name(self):
925 if self.country_code:
926 return countries.get_name(self.country_code)
a6dc0bad 927
940227cb
MT
928 @property
929 def email(self):
d86f6f18 930 return self._get_string("mail")
940227cb 931
d73bba54
MT
932 @property
933 def email_to(self):
934 return "%s <%s>" % (self, self.email)
935
e96e445b
MT
936 # Mail Routing Address
937
938 def get_mail_routing_address(self):
939 return self._get_string("mailRoutingAddress", None)
940
941 def set_mail_routing_address(self, address):
47bb098f 942 self._set_string("mailRoutingAddress", address or None)
e96e445b
MT
943
944 mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
945
66862195
MT
946 @property
947 def sip_id(self):
948 if "sipUser" in self.classes:
e96e445b 949 return self._get_string("sipAuthenticationUser")
66862195
MT
950
951 if "sipRoutingObject" in self.classes:
e96e445b 952 return self._get_string("sipLocalAddress")
66862195 953
2f51147a
MT
954 @property
955 def sip_password(self):
e96e445b
MT
956 return self._get_string("sipPassword")
957
958 @staticmethod
959 def _generate_sip_password():
960 return util.random_string(8)
2f51147a 961
66862195
MT
962 @property
963 def sip_url(self):
964 return "%s@ipfire.org" % self.sip_id
965
c66f2152
MT
966 @lazy_property
967 def agent_status(self):
968 return self.backend.talk.freeswitch.get_agent_status(self)
969
66862195 970 def uses_sip_forwarding(self):
e96e445b 971 if self.sip_routing_address:
66862195
MT
972 return True
973
974 return False
975
e96e445b
MT
976 # SIP Routing
977
978 def get_sip_routing_address(self):
66862195 979 if "sipRoutingObject" in self.classes:
e96e445b
MT
980 return self._get_string("sipRoutingAddress")
981
982 def set_sip_routing_address(self, address):
983 if not address:
984 address = None
985
986 # Don't do anything if nothing has changed
987 if self.get_sip_routing_address() == address:
988 return
989
990 if address:
79cce555
MT
991 # This is no longer a SIP user any more
992 try:
993 self._modify([
994 (ldap.MOD_DELETE, "objectClass", b"sipUser"),
995 (ldap.MOD_DELETE, "sipAuthenticationUser", None),
996 (ldap.MOD_DELETE, "sipPassword", None),
997 ])
998 except ldap.NO_SUCH_ATTRIBUTE:
999 pass
1000
1001 # Set new routing object
1002 try:
1003 self._modify([
1004 (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
1005 (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
1006 (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
1007 ])
1008
1009 # If this is a change, we cannot add this again
1010 except ldap.TYPE_OR_VALUE_EXISTS:
1011 self._set_string("sipRoutingAddress", address)
e96e445b 1012 else:
79cce555
MT
1013 try:
1014 self._modify([
1015 (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
1016 (ldap.MOD_DELETE, "sipLocalAddress", None),
1017 (ldap.MOD_DELETE, "sipRoutingAddress", None),
1018 ])
1019 except ldap.NO_SUCH_ATTRIBUTE:
1020 pass
1021
1022 self._modify([
e96e445b
MT
1023 (ldap.MOD_ADD, "objectClass", b"sipUser"),
1024 (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
1025 (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
79cce555 1026 ])
e96e445b
MT
1027
1028 # XXX Cache is invalid here
1029
1030 sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
66862195 1031
917434b8
MT
1032 @lazy_property
1033 def sip_registrations(self):
1034 sip_registrations = []
1035
1036 for reg in self.backend.talk.freeswitch.get_sip_registrations(self.sip_url):
1037 reg.account = self
1038
1039 sip_registrations.append(reg)
1040
1041 return sip_registrations
1042
1f38be5a
MT
1043 @lazy_property
1044 def sip_channels(self):
1045 return self.backend.talk.freeswitch.get_sip_channels(self)
1046
bdaf6b46
MT
1047 def get_cdr(self, date=None, limit=None):
1048 return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit)
525c01f7 1049
e96e445b 1050 # Phone Numbers
6ff61434 1051
d3208ac7
MT
1052 @lazy_property
1053 def phone_number(self):
1054 """
1055 Returns the IPFire phone number
1056 """
1057 if self.sip_id:
1058 return phonenumbers.parse("+4923636035%s" % self.sip_id)
1059
1060 @lazy_property
1061 def fax_number(self):
1062 if self.sip_id:
1063 return phonenumbers.parse("+49236360359%s" % self.sip_id)
1064
e96e445b
MT
1065 def get_phone_numbers(self):
1066 ret = []
6ff61434 1067
e96e445b
MT
1068 for field in ("telephoneNumber", "homePhone", "mobile"):
1069 for number in self._get_phone_numbers(field):
1070 ret.append(number)
6ff61434 1071
e96e445b
MT
1072 return ret
1073
1074 def set_phone_numbers(self, phone_numbers):
1075 # Sort phone numbers by landline and mobile
1076 _landline_numbers = []
1077 _mobile_numbers = []
1078
1079 for number in phone_numbers:
1080 try:
1081 number = phonenumbers.parse(number, None)
1082 except phonenumbers.phonenumberutil.NumberParseException:
1083 continue
1084
1085 # Convert to string (in E.164 format)
1086 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1087
1088 # Separate mobile numbers
1089 if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE:
1090 _mobile_numbers.append(s)
1091 else:
1092 _landline_numbers.append(s)
1093
1094 # Save
1095 self._set_strings("telephoneNumber", _landline_numbers)
1096 self._set_strings("mobile", _mobile_numbers)
1097
1098 phone_numbers = property(get_phone_numbers, set_phone_numbers)
8476e80f
MT
1099
1100 @property
1101 def _all_telephone_numbers(self):
6ccc8acb
MT
1102 ret = [ self.sip_id, ]
1103
d3208ac7
MT
1104 if self.phone_number:
1105 s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
1106 ret.append(s)
1107
6ccc8acb
MT
1108 for number in self.phone_numbers:
1109 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1110 ret.append(s)
1111
1112 return ret
66862195 1113
1c4522dc
MT
1114 # Description
1115
1116 def get_description(self):
1117 return self._get_string("description")
1118
1119 def set_description(self, description):
1120 self._set_string("description", description)
1121
1122 description = property(get_description, set_description)
1123
1124 # Avatar
1125
5d42f49b
MT
1126 def has_avatar(self):
1127 has_avatar = self.memcache.get("accounts:%s:has-avatar" % self.uid)
1128 if has_avatar is None:
1129 has_avatar = True if self.get_avatar() else False
1130
1131 # Cache avatar status for up to 24 hours
1132 self.memcache.set("accounts:%s:has-avatar" % self.uid, has_avatar, 3600 * 24)
1133
1134 return has_avatar
1135
2cd9af74 1136 def avatar_url(self, size=None):
969a05eb 1137 url = "https://people.ipfire.org/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
2cd9af74
MT
1138
1139 if size:
969a05eb 1140 url += "&size=%s" % size
2cd9af74
MT
1141
1142 return url
1143
2cd9af74 1144 def get_avatar(self, size=None):
5ef115cd 1145 photo = self._get_bytes("jpegPhoto")
2cd9af74 1146
0109451c
MT
1147 # Exit if no avatar is available
1148 if not photo:
1149 return
1150
5ef115cd
MT
1151 # Return the raw image if no size was requested
1152 if size is None:
1153 return photo
2cd9af74 1154
5ef115cd 1155 # Try to retrieve something from the cache
9c01e5ac 1156 avatar = self.memcache.get("accounts:%s:avatar:%s" % (self.dn, size))
5ef115cd
MT
1157 if avatar:
1158 return avatar
1a226c83 1159
5ef115cd 1160 # Generate a new thumbnail
2de3dacc 1161 avatar = util.generate_thumbnail(photo, size, square=True)
1a226c83 1162
5ef115cd 1163 # Save to cache for 15m
9c01e5ac 1164 self.memcache.set("accounts:%s:avatar:%s" % (self.dn, size), avatar, 900)
1a226c83 1165
5ef115cd 1166 return avatar
2cd9af74 1167
969a05eb
MT
1168 @property
1169 def avatar_hash(self):
1170 hash = self.memcache.get("accounts:%s:avatar-hash" % self.dn)
1171 if not hash:
1172 h = hashlib.new("md5")
1173 h.update(self.get_avatar() or b"")
1174 hash = h.hexdigest()[:7]
1175
1176 self.memcache.set("accounts:%s:avatar-hash" % self.dn, hash, 86400)
1177
1178 return hash
1179
5cc10421
MT
1180 def upload_avatar(self, avatar):
1181 self._set("jpegPhoto", avatar)
1182
5d42f49b 1183 # Delete cached avatar status
5a9176c5 1184 self.memcache.delete("accounts:%s:has-avatar" % self.dn)
5d42f49b 1185
969a05eb 1186 # Delete avatar hash
5a9176c5 1187 self.memcache.delete("accounts:%s:avatar-hash" % self.dn)
969a05eb 1188
92c4b559
MT
1189 # Consent to promotional emails
1190
1191 def get_consents_to_promotional_emails(self):
1192 return self.is_member_of_group("promotional-consent")
1193
1194 def set_contents_to_promotional_emails(self, value):
1195 group = self.backend.groups.get_by_gid("promotional-consent")
1196 assert group, "Could not find group: promotional-consent"
1197
1198 if value is True:
1199 group.add_member(self)
1200 else:
1201 group.del_member(self)
1202
1203 consents_to_promotional_emails = property(
1204 get_consents_to_promotional_emails,
1205 set_contents_to_promotional_emails,
1206 )
1207
55b67ca4 1208
23f84bbc 1209class StopForumSpam(Object):
a5343559
MT
1210 def init(self, email, address):
1211 self.email, self.address = email, address
23f84bbc 1212
9fdf4fb7 1213 async def send_request(self, **kwargs):
23f84bbc
MT
1214 arguments = {
1215 "json" : "1",
1216 }
1217 arguments.update(kwargs)
1218
1219 # Create request
1220 request = tornado.httpclient.HTTPRequest(
f57364de
MT
1221 "https://api.stopforumspam.org/api", method="POST",
1222 connect_timeout=2, request_timeout=5)
23f84bbc
MT
1223 request.body = urllib.parse.urlencode(arguments)
1224
1225 # Send the request
9fdf4fb7 1226 response = await self.backend.http_client.fetch(request)
23f84bbc
MT
1227
1228 # Decode the JSON response
1229 return json.loads(response.body.decode())
1230
9fdf4fb7
MT
1231 async def check_address(self):
1232 response = await self.send_request(ip=self.address)
23f84bbc
MT
1233
1234 try:
1235 confidence = response["ip"]["confidence"]
1236 except KeyError:
1237 confidence = 100
1238
1239 logging.debug("Confidence for %s: %s" % (self.address, confidence))
1240
1241 return confidence
1242
9fdf4fb7
MT
1243 async def check_email(self):
1244 response = await self.send_request(email=self.email)
23f84bbc
MT
1245
1246 try:
1247 confidence = response["email"]["confidence"]
1248 except KeyError:
1249 confidence = 100
1250
1251 logging.debug("Confidence for %s: %s" % (self.email, confidence))
1252
1253 return confidence
1254
9fdf4fb7 1255 async def check(self, threshold=95):
23f84bbc
MT
1256 """
1257 This function tries to detect if we have a spammer.
1258
1259 To honour the privacy of our users, we only send the IP
1260 address and username and if those are on the database, we
1261 will send the email address as well.
1262 """
a5343559 1263 confidences = [await self.check_address(), await self.check_email()]
23f84bbc
MT
1264
1265 # Build a score based on the lowest confidence
1266 return 100 - min(confidences)
1267
1268
d8b04c72 1269class Groups(Object):
b6365721
MT
1270 hidden_groups = (
1271 "cn=LDAP Read Only,ou=Group,dc=ipfire,dc=org",
1272 "cn=LDAP Read Write,ou=Group,dc=ipfire,dc=org",
d71fd279
MT
1273
1274 # Everyone is a member of people
1275 "cn=people,ou=Group,dc=ipfire,dc=org",
b6365721
MT
1276 )
1277
d8b04c72
MT
1278 @property
1279 def search_base(self):
1280 return "ou=Group,%s" % self.backend.accounts.search_base
1281
18b13823
MT
1282 def _query(self, *args, **kwargs):
1283 kwargs.update({
1284 "search_base" : self.backend.groups.search_base,
1285 })
1286
1287 return self.backend.accounts._query(*args, **kwargs)
1288
1289 def __iter__(self):
1290 groups = self.get_all()
1291
1292 return iter(groups)
1293
1294 def _get_groups(self, query, **kwargs):
1295 res = self._query(query, **kwargs)
1296
1297 groups = []
1298 for dn, attrs in res:
b6365721
MT
1299 # Skip any hidden groups
1300 if dn in self.hidden_groups:
1301 continue
1302
18b13823
MT
1303 g = Group(self.backend, dn, attrs)
1304 groups.append(g)
1305
1306 return sorted(groups)
1307
bef47ee8
MT
1308 def _get_group(self, query, **kwargs):
1309 kwargs.update({
1310 "limit" : 1,
1311 })
1312
1313 groups = self._get_groups(query, **kwargs)
1314 if groups:
1315 return groups[0]
1316
18b13823
MT
1317 def get_all(self):
1318 return self._get_groups(
1319 "(|(objectClass=posixGroup)(objectClass=groupOfNames))",
1320 )
1321
bef47ee8
MT
1322 def get_by_gid(self, gid):
1323 return self._get_group(
1324 "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % gid,
1325 )
1326
d8b04c72 1327
959d8d2a 1328class Group(LDAPObject):
d8b04c72
MT
1329 def __repr__(self):
1330 if self.description:
1331 return "<%s %s (%s)>" % (
1332 self.__class__.__name__,
1333 self.gid,
1334 self.description,
1335 )
1336
1337 return "<%s %s>" % (self.__class__.__name__, self.gid)
1338
1339 def __str__(self):
1340 return self.description or self.gid
1341
d8b04c72
MT
1342 def __lt__(self, other):
1343 if isinstance(other, self.__class__):
1344 return (self.description or self.gid) < (other.description or other.gid)
1345
d71fd279
MT
1346 def __bool__(self):
1347 return True
1348
74f967de
MT
1349 def __len__(self):
1350 """
1351 Returns the number of members in this group
1352 """
1353 l = 0
1354
1355 for attr in ("member", "memberUid"):
1356 a = self.attributes.get(attr, None)
1357 if a:
1358 l += len(a)
1359
1360 return l
1361
bef47ee8
MT
1362 def __iter__(self):
1363 return iter(self.members)
1364
18b13823
MT
1365 @property
1366 def gid(self):
959d8d2a 1367 return self._get_string("cn")
d8b04c72
MT
1368
1369 @property
1370 def description(self):
959d8d2a 1371 return self._get_string("description")
d8b04c72 1372
83a4b1d5
MT
1373 @property
1374 def email(self):
959d8d2a 1375 return self._get_string("mail")
83a4b1d5 1376
bef47ee8
MT
1377 @lazy_property
1378 def members(self):
1379 members = []
1380
1381 # Get all members by DN
959d8d2a
MT
1382 for dn in self._get_strings("member"):
1383 member = self.backend.accounts.get_by_dn(dn)
bef47ee8
MT
1384 if member:
1385 members.append(member)
1386
959d8d2a
MT
1387 # Get all members by UID
1388 for uid in self._get_strings("memberUid"):
1389 member = self.backend.accounts.get_by_uid(uid)
bef47ee8
MT
1390 if member:
1391 members.append(member)
1392
1393 return sorted(members)
d8b04c72 1394
a5449be9
MT
1395 def add_member(self, account):
1396 """
1397 Adds a member to this group
1398 """
92c4b559
MT
1399 # Do nothing if this user is already in the group
1400 if account.is_member_of_group(self.gid):
1401 return
1402
a5449be9
MT
1403 if "posixGroup" in self.objectclasses:
1404 self._add_string("memberUid", account.uid)
1405 else:
1406 self._add_string("member", account.dn)
1407
1408 # Append to cached list of members
1409 self.members.append(account)
1410 self.members.sort()
1411
92c4b559
MT
1412 def del_member(self, account):
1413 """
1414 Removes a member from a group
1415 """
1416 # Do nothing if this user is not in the group
1417 if not account.is_member_of_group(self.gid):
1418 return
1419
1420 if "posixGroup" in self.objectclasses:
1421 self._delete_string("memberUid", account.uid)
1422 else:
1423 self._delete_string("member", account.dn)
1424
1425
940227cb
MT
1426if __name__ == "__main__":
1427 a = Accounts()
1428
11347e46 1429 print(a.list())