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