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