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