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