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