]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/accounts.py
accounts: Fix off-by-one error when checking username length
[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
11c56bd2 131 if len(uid) < 4:
689effd0
MT
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
c66f2152
MT
829 @lazy_property
830 def agent_status(self):
831 return self.backend.talk.freeswitch.get_agent_status(self)
832
66862195 833 def uses_sip_forwarding(self):
e96e445b 834 if self.sip_routing_address:
66862195
MT
835 return True
836
837 return False
838
e96e445b
MT
839 # SIP Routing
840
841 def get_sip_routing_address(self):
66862195 842 if "sipRoutingObject" in self.classes:
e96e445b
MT
843 return self._get_string("sipRoutingAddress")
844
845 def set_sip_routing_address(self, address):
846 if not address:
847 address = None
848
849 # Don't do anything if nothing has changed
850 if self.get_sip_routing_address() == address:
851 return
852
853 if address:
79cce555
MT
854 # This is no longer a SIP user any more
855 try:
856 self._modify([
857 (ldap.MOD_DELETE, "objectClass", b"sipUser"),
858 (ldap.MOD_DELETE, "sipAuthenticationUser", None),
859 (ldap.MOD_DELETE, "sipPassword", None),
860 ])
861 except ldap.NO_SUCH_ATTRIBUTE:
862 pass
863
864 # Set new routing object
865 try:
866 self._modify([
867 (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
868 (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
869 (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
870 ])
871
872 # If this is a change, we cannot add this again
873 except ldap.TYPE_OR_VALUE_EXISTS:
874 self._set_string("sipRoutingAddress", address)
e96e445b 875 else:
79cce555
MT
876 try:
877 self._modify([
878 (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
879 (ldap.MOD_DELETE, "sipLocalAddress", None),
880 (ldap.MOD_DELETE, "sipRoutingAddress", None),
881 ])
882 except ldap.NO_SUCH_ATTRIBUTE:
883 pass
884
885 self._modify([
e96e445b
MT
886 (ldap.MOD_ADD, "objectClass", b"sipUser"),
887 (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
888 (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
79cce555 889 ])
e96e445b
MT
890
891 # XXX Cache is invalid here
892
893 sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
66862195 894
917434b8
MT
895 @lazy_property
896 def sip_registrations(self):
897 sip_registrations = []
898
899 for reg in self.backend.talk.freeswitch.get_sip_registrations(self.sip_url):
900 reg.account = self
901
902 sip_registrations.append(reg)
903
904 return sip_registrations
905
1f38be5a
MT
906 @lazy_property
907 def sip_channels(self):
908 return self.backend.talk.freeswitch.get_sip_channels(self)
909
bdaf6b46
MT
910 def get_cdr(self, date=None, limit=None):
911 return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit)
525c01f7 912
e96e445b 913 # Phone Numbers
6ff61434 914
d3208ac7
MT
915 @lazy_property
916 def phone_number(self):
917 """
918 Returns the IPFire phone number
919 """
920 if self.sip_id:
921 return phonenumbers.parse("+4923636035%s" % self.sip_id)
922
923 @lazy_property
924 def fax_number(self):
925 if self.sip_id:
926 return phonenumbers.parse("+49236360359%s" % self.sip_id)
927
e96e445b
MT
928 def get_phone_numbers(self):
929 ret = []
6ff61434 930
e96e445b
MT
931 for field in ("telephoneNumber", "homePhone", "mobile"):
932 for number in self._get_phone_numbers(field):
933 ret.append(number)
6ff61434 934
e96e445b
MT
935 return ret
936
937 def set_phone_numbers(self, phone_numbers):
938 # Sort phone numbers by landline and mobile
939 _landline_numbers = []
940 _mobile_numbers = []
941
942 for number in phone_numbers:
943 try:
944 number = phonenumbers.parse(number, None)
945 except phonenumbers.phonenumberutil.NumberParseException:
946 continue
947
948 # Convert to string (in E.164 format)
949 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
950
951 # Separate mobile numbers
952 if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE:
953 _mobile_numbers.append(s)
954 else:
955 _landline_numbers.append(s)
956
957 # Save
958 self._set_strings("telephoneNumber", _landline_numbers)
959 self._set_strings("mobile", _mobile_numbers)
960
961 phone_numbers = property(get_phone_numbers, set_phone_numbers)
8476e80f
MT
962
963 @property
964 def _all_telephone_numbers(self):
6ccc8acb
MT
965 ret = [ self.sip_id, ]
966
d3208ac7
MT
967 if self.phone_number:
968 s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
969 ret.append(s)
970
6ccc8acb
MT
971 for number in self.phone_numbers:
972 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
973 ret.append(s)
974
975 return ret
66862195 976
1c4522dc
MT
977 # Description
978
979 def get_description(self):
980 return self._get_string("description")
981
982 def set_description(self, description):
983 self._set_string("description", description)
984
985 description = property(get_description, set_description)
986
987 # Avatar
988
5d42f49b
MT
989 def has_avatar(self):
990 has_avatar = self.memcache.get("accounts:%s:has-avatar" % self.uid)
991 if has_avatar is None:
992 has_avatar = True if self.get_avatar() else False
993
994 # Cache avatar status for up to 24 hours
995 self.memcache.set("accounts:%s:has-avatar" % self.uid, has_avatar, 3600 * 24)
996
997 return has_avatar
998
2cd9af74 999 def avatar_url(self, size=None):
969a05eb 1000 url = "https://people.ipfire.org/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
2cd9af74
MT
1001
1002 if size:
969a05eb 1003 url += "&size=%s" % size
2cd9af74
MT
1004
1005 return url
1006
2cd9af74 1007 def get_avatar(self, size=None):
5ef115cd 1008 photo = self._get_bytes("jpegPhoto")
2cd9af74 1009
0109451c
MT
1010 # Exit if no avatar is available
1011 if not photo:
1012 return
1013
5ef115cd
MT
1014 # Return the raw image if no size was requested
1015 if size is None:
1016 return photo
2cd9af74 1017
5ef115cd 1018 # Try to retrieve something from the cache
9c01e5ac 1019 avatar = self.memcache.get("accounts:%s:avatar:%s" % (self.dn, size))
5ef115cd
MT
1020 if avatar:
1021 return avatar
1a226c83 1022
5ef115cd 1023 # Generate a new thumbnail
2de3dacc 1024 avatar = util.generate_thumbnail(photo, size, square=True)
1a226c83 1025
5ef115cd 1026 # Save to cache for 15m
9c01e5ac 1027 self.memcache.set("accounts:%s:avatar:%s" % (self.dn, size), avatar, 900)
1a226c83 1028
5ef115cd 1029 return avatar
2cd9af74 1030
969a05eb
MT
1031 @property
1032 def avatar_hash(self):
1033 hash = self.memcache.get("accounts:%s:avatar-hash" % self.dn)
1034 if not hash:
1035 h = hashlib.new("md5")
1036 h.update(self.get_avatar() or b"")
1037 hash = h.hexdigest()[:7]
1038
1039 self.memcache.set("accounts:%s:avatar-hash" % self.dn, hash, 86400)
1040
1041 return hash
1042
5cc10421
MT
1043 def upload_avatar(self, avatar):
1044 self._set("jpegPhoto", avatar)
1045
5d42f49b 1046 # Delete cached avatar status
5a9176c5 1047 self.memcache.delete("accounts:%s:has-avatar" % self.dn)
5d42f49b 1048
969a05eb 1049 # Delete avatar hash
5a9176c5 1050 self.memcache.delete("accounts:%s:avatar-hash" % self.dn)
969a05eb 1051
55b67ca4 1052
23f84bbc
MT
1053class StopForumSpam(Object):
1054 def init(self, uid, email, address):
1055 self.uid, self.email, self.address = uid, email, address
1056
9fdf4fb7 1057 async def send_request(self, **kwargs):
23f84bbc
MT
1058 arguments = {
1059 "json" : "1",
1060 }
1061 arguments.update(kwargs)
1062
1063 # Create request
1064 request = tornado.httpclient.HTTPRequest(
1065 "https://api.stopforumspam.org/api", method="POST")
1066 request.body = urllib.parse.urlencode(arguments)
1067
1068 # Send the request
9fdf4fb7 1069 response = await self.backend.http_client.fetch(request)
23f84bbc
MT
1070
1071 # Decode the JSON response
1072 return json.loads(response.body.decode())
1073
9fdf4fb7
MT
1074 async def check_address(self):
1075 response = await self.send_request(ip=self.address)
23f84bbc
MT
1076
1077 try:
1078 confidence = response["ip"]["confidence"]
1079 except KeyError:
1080 confidence = 100
1081
1082 logging.debug("Confidence for %s: %s" % (self.address, confidence))
1083
1084 return confidence
1085
9fdf4fb7
MT
1086 async def check_username(self):
1087 response = await self.send_request(username=self.uid)
23f84bbc
MT
1088
1089 try:
1090 confidence = response["username"]["confidence"]
1091 except KeyError:
1092 confidence = 100
1093
1094 logging.debug("Confidence for %s: %s" % (self.uid, confidence))
1095
1096 return confidence
1097
9fdf4fb7
MT
1098 async def check_email(self):
1099 response = await self.send_request(email=self.email)
23f84bbc
MT
1100
1101 try:
1102 confidence = response["email"]["confidence"]
1103 except KeyError:
1104 confidence = 100
1105
1106 logging.debug("Confidence for %s: %s" % (self.email, confidence))
1107
1108 return confidence
1109
9fdf4fb7 1110 async def check(self, threshold=95):
23f84bbc
MT
1111 """
1112 This function tries to detect if we have a spammer.
1113
1114 To honour the privacy of our users, we only send the IP
1115 address and username and if those are on the database, we
1116 will send the email address as well.
1117 """
9fdf4fb7 1118 confidences = [await self.check_address(), await self.check_username()]
23f84bbc
MT
1119
1120 if any((c < threshold for c in confidences)):
9fdf4fb7 1121 confidences.append(await self.check_email())
23f84bbc
MT
1122
1123 # Build a score based on the lowest confidence
1124 return 100 - min(confidences)
1125
1126
d8b04c72 1127class Groups(Object):
b6365721
MT
1128 hidden_groups = (
1129 "cn=LDAP Read Only,ou=Group,dc=ipfire,dc=org",
1130 "cn=LDAP Read Write,ou=Group,dc=ipfire,dc=org",
d71fd279
MT
1131
1132 # Everyone is a member of people
1133 "cn=people,ou=Group,dc=ipfire,dc=org",
b6365721
MT
1134 )
1135
d8b04c72
MT
1136 @property
1137 def search_base(self):
1138 return "ou=Group,%s" % self.backend.accounts.search_base
1139
18b13823
MT
1140 def _query(self, *args, **kwargs):
1141 kwargs.update({
1142 "search_base" : self.backend.groups.search_base,
1143 })
1144
1145 return self.backend.accounts._query(*args, **kwargs)
1146
1147 def __iter__(self):
1148 groups = self.get_all()
1149
1150 return iter(groups)
1151
1152 def _get_groups(self, query, **kwargs):
1153 res = self._query(query, **kwargs)
1154
1155 groups = []
1156 for dn, attrs in res:
b6365721
MT
1157 # Skip any hidden groups
1158 if dn in self.hidden_groups:
1159 continue
1160
18b13823
MT
1161 g = Group(self.backend, dn, attrs)
1162 groups.append(g)
1163
1164 return sorted(groups)
1165
bef47ee8
MT
1166 def _get_group(self, query, **kwargs):
1167 kwargs.update({
1168 "limit" : 1,
1169 })
1170
1171 groups = self._get_groups(query, **kwargs)
1172 if groups:
1173 return groups[0]
1174
18b13823
MT
1175 def get_all(self):
1176 return self._get_groups(
1177 "(|(objectClass=posixGroup)(objectClass=groupOfNames))",
1178 )
1179
bef47ee8
MT
1180 def get_by_gid(self, gid):
1181 return self._get_group(
1182 "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % gid,
1183 )
1184
d8b04c72
MT
1185
1186class Group(Object):
18b13823
MT
1187 def init(self, dn, attrs=None):
1188 self.dn = dn
1189
1190 self.attributes = attrs or {}
d8b04c72
MT
1191
1192 def __repr__(self):
1193 if self.description:
1194 return "<%s %s (%s)>" % (
1195 self.__class__.__name__,
1196 self.gid,
1197 self.description,
1198 )
1199
1200 return "<%s %s>" % (self.__class__.__name__, self.gid)
1201
1202 def __str__(self):
1203 return self.description or self.gid
1204
1205 def __eq__(self, other):
1206 if isinstance(other, self.__class__):
1207 return self.gid == other.gid
1208
1209 def __lt__(self, other):
1210 if isinstance(other, self.__class__):
1211 return (self.description or self.gid) < (other.description or other.gid)
1212
d71fd279
MT
1213 def __bool__(self):
1214 return True
1215
74f967de
MT
1216 def __len__(self):
1217 """
1218 Returns the number of members in this group
1219 """
1220 l = 0
1221
1222 for attr in ("member", "memberUid"):
1223 a = self.attributes.get(attr, None)
1224 if a:
1225 l += len(a)
1226
1227 return l
1228
bef47ee8
MT
1229 def __iter__(self):
1230 return iter(self.members)
1231
18b13823
MT
1232 @property
1233 def gid(self):
1234 try:
1235 gid = self.attributes["cn"][0]
1236 except KeyError:
1237 return None
d8b04c72 1238
18b13823 1239 return gid.decode()
d8b04c72
MT
1240
1241 @property
1242 def description(self):
1243 try:
1244 description = self.attributes["description"][0]
1245 except KeyError:
1246 return None
1247
1248 return description.decode()
1249
83a4b1d5
MT
1250 @property
1251 def email(self):
1252 try:
1253 email = self.attributes["mail"][0]
1254 except KeyError:
1255 return None
1256
1257 return email.decode()
1258
bef47ee8
MT
1259 @lazy_property
1260 def members(self):
1261 members = []
1262
1263 # Get all members by DN
1264 for dn in self.attributes.get("member", []):
1265 member = self.backend.accounts.get_by_dn(dn.decode())
1266 if member:
1267 members.append(member)
1268
1269 # Get all meembers by UID
1270 for uid in self.attributes.get("memberUid", []):
1271 member = self.backend.accounts.get_by_uid(uid.decode())
1272 if member:
1273 members.append(member)
1274
1275 return sorted(members)
d8b04c72 1276
940227cb
MT
1277if __name__ == "__main__":
1278 a = Accounts()
1279
11347e46 1280 print(a.list())