]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/accounts.py
messages: Talk to local relay instead of using sendmail
[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",
455 recipients=[email], priority=100, uid=uid,
456 activation_code=activation_code, email=email,
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",
d11a4f82 484 recipients=["moderators@ipfire.org"], 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",
754 recipients=[self.email], priority=100, account=self, reset_code=reset_code)
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())