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