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