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