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