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