]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/accounts.py
people: Drop remaining handlers and add legacy redirection
[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
868c68ac
MT
834 def is_blog_author(self):
835 return self.is_member_of_group("blog-authors")
836
b01a1ee3
MT
837 def is_lwl(self):
838 return self.is_member_of_group("lwl-staff")
839
e96e445b
MT
840 def can_be_managed_by(self, account):
841 """
842 Returns True if account is allowed to manage this account
843 """
844 # Admins can manage all accounts
845 if account.is_admin():
846 return True
847
848 # Users can manage themselves
849 return self == account
850
66862195
MT
851 @property
852 def classes(self):
e96e445b 853 return self._get_strings("objectClass")
66862195
MT
854
855 @property
856 def uid(self):
e96e445b 857 return self._get_string("uid")
940227cb 858
a6dc0bad
MT
859 @property
860 def name(self):
e96e445b 861 return self._get_string("cn")
66862195 862
26ccb61a
MT
863 # Delete
864
865 async def delete(self, user):
866 """
867 Deletes this user
868 """
869 # Check if this user can be deleted
1cd4d7d3 870 if not self.can_be_deleted_by(user):
26ccb61a
MT
871 raise RuntimeError("Cannot delete user %s" % self)
872
1118f2b1
MT
873 logging.info("Deleting user %s" % self)
874
26ccb61a
MT
875 async with asyncio.TaskGroup() as tasks:
876 t = datetime.datetime.now()
877
878 # Disable this account on Bugzilla
879 tasks.create_task(
880 self._disable_on_bugzilla("Deleted by %s, %s" % (user, t)),
881 )
882
883 # XXX Delete on Discourse
884
1118f2b1
MT
885 # Delete on LDAP
886 self._delete()
26ccb61a 887
1cd4d7d3 888 def can_be_deleted_by(self, user):
26ccb61a
MT
889 """
890 Return True if the user can be deleted by user
891 """
892 # Check permissions
893 if not self.can_be_managed_by(user):
894 return False
895
896 # Cannot delete shell users
897 if self.has_shell():
898 return False
899
900 # Looks okay
901 return True
902
1118f2b1
MT
903 def _delete(self):
904 """
905 Deletes this object from LDAP
906 """
907 # Delete the Kerberos Principal
908 self._delete_dn(self.kerberos_principal_dn)
909
910 # Delete this object
911 self._delete_dn(self.dn)
912
d6e57f73
MT
913 # Nickname
914
915 def get_nickname(self):
916 return self._get_string("displayName")
917
918 def set_nickname(self, nickname):
919 self._set_string("displayName", nickname)
920
921 nickname = property(get_nickname, set_nickname)
922
e96e445b
MT
923 # First Name
924
925 def get_first_name(self):
926 return self._get_string("givenName")
927
928 def set_first_name(self, first_name):
929 self._set_string("givenName", first_name)
930
931 # Update Common Name
932 self._set_string("cn", "%s %s" % (first_name, self.last_name))
933
934 first_name = property(get_first_name, set_first_name)
935
936 # Last Name
937
938 def get_last_name(self):
939 return self._get_string("sn")
940
941 def set_last_name(self, last_name):
942 self._set_string("sn", last_name)
943
944 # Update Common Name
945 self._set_string("cn", "%s %s" % (self.first_name, last_name))
946
947 last_name = property(get_last_name, set_last_name)
66862195 948
1bae74c7 949 @lazy_property
66862195 950 def groups(self):
18b13823 951 return self.backend.groups._get_groups("(| \
ae485256
MT
952 (&(objectClass=groupOfNames)(member=%s)) \
953 (&(objectClass=posixGroup)(memberUid=%s)) \
18b13823 954 )" % (self.dn, self.uid))
66862195 955
eae206f4
MT
956 def is_member_of_group(self, gid):
957 """
958 Returns True if this account is a member of this group
959 """
960 return gid in (g.gid for g in self.groups)
961
9150881e
MT
962 # Created/Modified at
963
964 @property
965 def created_at(self):
966 return self._get_timestamp("createTimestamp")
967
968 @property
969 def modified_at(self):
970 return self._get_timestamp("modifyTimestamp")
971
e96e445b
MT
972 # Address
973
0099c2a7
MT
974 @property
975 def address(self):
976 address = []
977
978 if self.street:
979 address += self.street.splitlines()
980
981 if self.postal_code and self.city:
982 if self.country_code in ("AT", "DE"):
983 address.append("%s %s" % (self.postal_code, self.city))
984 else:
985 address.append("%s, %s" % (self.city, self.postal_code))
986 else:
987 address.append(self.city or self.postal_code)
988
989 if self.country_name:
990 address.append(self.country_name)
991
e5f0d12b 992 return [line for line in address if line]
0099c2a7
MT
993
994 def get_street(self):
995 return self._get_string("street") or self._get_string("homePostalAddress")
996
997 def set_street(self, street):
998 self._set_string("street", street)
e96e445b 999
0099c2a7 1000 street = property(get_street, set_street)
66862195 1001
0099c2a7
MT
1002 def get_city(self):
1003 return self._get_string("l") or ""
e96e445b 1004
0099c2a7
MT
1005 def set_city(self, city):
1006 self._set_string("l", city)
e96e445b 1007
0099c2a7 1008 city = property(get_city, set_city)
e96e445b 1009
0099c2a7
MT
1010 def get_postal_code(self):
1011 return self._get_string("postalCode") or ""
1012
1013 def set_postal_code(self, postal_code):
1014 self._set_string("postalCode", postal_code)
1015
1016 postal_code = property(get_postal_code, set_postal_code)
1017
1018 # XXX This should be c
1019 def get_country_code(self):
1020 return self._get_string("st")
1021
1022 def set_country_code(self, country_code):
1023 self._set_string("st", country_code)
1024
1025 country_code = property(get_country_code, set_country_code)
1026
1027 @property
1028 def country_name(self):
1029 if self.country_code:
e929ed92 1030 return self.backend.get_country_name(self.country_code)
a6dc0bad 1031
940227cb
MT
1032 @property
1033 def email(self):
d86f6f18 1034 return self._get_string("mail")
940227cb 1035
d73bba54
MT
1036 @property
1037 def email_to(self):
1038 return "%s <%s>" % (self, self.email)
1039
e5f0d12b
MT
1040 @lazy_property
1041 def alternate_email_addresses(self):
1042 addresses = self._get_strings("mailAlternateAddress")
1043
1044 return sorted(addresses)
1045
e96e445b
MT
1046 # Mail Routing Address
1047
1048 def get_mail_routing_address(self):
1049 return self._get_string("mailRoutingAddress", None)
1050
1051 def set_mail_routing_address(self, address):
47bb098f 1052 self._set_string("mailRoutingAddress", address or None)
e96e445b
MT
1053
1054 mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
1055
66862195
MT
1056 @property
1057 def sip_id(self):
1058 if "sipUser" in self.classes:
e96e445b 1059 return self._get_string("sipAuthenticationUser")
66862195
MT
1060
1061 if "sipRoutingObject" in self.classes:
e96e445b 1062 return self._get_string("sipLocalAddress")
66862195 1063
2f51147a
MT
1064 @property
1065 def sip_password(self):
e96e445b
MT
1066 return self._get_string("sipPassword")
1067
1068 @staticmethod
1069 def _generate_sip_password():
1070 return util.random_string(8)
2f51147a 1071
66862195
MT
1072 @property
1073 def sip_url(self):
1074 return "%s@ipfire.org" % self.sip_id
1075
1076 def uses_sip_forwarding(self):
e96e445b 1077 if self.sip_routing_address:
66862195
MT
1078 return True
1079
1080 return False
1081
e96e445b
MT
1082 # SIP Routing
1083
1084 def get_sip_routing_address(self):
66862195 1085 if "sipRoutingObject" in self.classes:
e96e445b
MT
1086 return self._get_string("sipRoutingAddress")
1087
1088 def set_sip_routing_address(self, address):
1089 if not address:
1090 address = None
1091
1092 # Don't do anything if nothing has changed
1093 if self.get_sip_routing_address() == address:
1094 return
1095
1096 if address:
79cce555
MT
1097 # This is no longer a SIP user any more
1098 try:
1099 self._modify([
1100 (ldap.MOD_DELETE, "objectClass", b"sipUser"),
1101 (ldap.MOD_DELETE, "sipAuthenticationUser", None),
1102 (ldap.MOD_DELETE, "sipPassword", None),
1103 ])
1104 except ldap.NO_SUCH_ATTRIBUTE:
1105 pass
1106
1107 # Set new routing object
1108 try:
1109 self._modify([
1110 (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
1111 (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
1112 (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
1113 ])
1114
1115 # If this is a change, we cannot add this again
1116 except ldap.TYPE_OR_VALUE_EXISTS:
1117 self._set_string("sipRoutingAddress", address)
e96e445b 1118 else:
79cce555
MT
1119 try:
1120 self._modify([
1121 (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
1122 (ldap.MOD_DELETE, "sipLocalAddress", None),
1123 (ldap.MOD_DELETE, "sipRoutingAddress", None),
1124 ])
1125 except ldap.NO_SUCH_ATTRIBUTE:
1126 pass
1127
1128 self._modify([
e96e445b
MT
1129 (ldap.MOD_ADD, "objectClass", b"sipUser"),
1130 (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
1131 (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
79cce555 1132 ])
e96e445b
MT
1133
1134 # XXX Cache is invalid here
1135
1136 sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
66862195 1137
4235ba55 1138 # SIP Registrations
917434b8 1139
4235ba55
MT
1140 async def get_sip_registrations(self):
1141 if not self.has_sip():
1142 return []
917434b8 1143
4235ba55 1144 return await self.backend.asterisk.get_registrations(self.sip_id)
917434b8 1145
4235ba55 1146 # SIP Channels
917434b8 1147
d6c41da2
MT
1148 async def get_sip_channels(self):
1149 if not self.has_sip():
1150 return []
1151
1152 return await self.backend.asterisk.get_sip_channels(self.sip_id)
1f38be5a 1153
e96e445b 1154 # Phone Numbers
6ff61434 1155
d3208ac7
MT
1156 @lazy_property
1157 def phone_number(self):
1158 """
1159 Returns the IPFire phone number
1160 """
1161 if self.sip_id:
1162 return phonenumbers.parse("+4923636035%s" % self.sip_id)
1163
1164 @lazy_property
1165 def fax_number(self):
1166 if self.sip_id:
1167 return phonenumbers.parse("+49236360359%s" % self.sip_id)
1168
e96e445b
MT
1169 def get_phone_numbers(self):
1170 ret = []
6ff61434 1171
e96e445b
MT
1172 for field in ("telephoneNumber", "homePhone", "mobile"):
1173 for number in self._get_phone_numbers(field):
1174 ret.append(number)
6ff61434 1175
e96e445b
MT
1176 return ret
1177
1178 def set_phone_numbers(self, phone_numbers):
1179 # Sort phone numbers by landline and mobile
1180 _landline_numbers = []
1181 _mobile_numbers = []
1182
1183 for number in phone_numbers:
1184 try:
1185 number = phonenumbers.parse(number, None)
1186 except phonenumbers.phonenumberutil.NumberParseException:
1187 continue
1188
1189 # Convert to string (in E.164 format)
1190 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1191
1192 # Separate mobile numbers
1193 if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE:
1194 _mobile_numbers.append(s)
1195 else:
1196 _landline_numbers.append(s)
1197
1198 # Save
1199 self._set_strings("telephoneNumber", _landline_numbers)
1200 self._set_strings("mobile", _mobile_numbers)
1201
1202 phone_numbers = property(get_phone_numbers, set_phone_numbers)
8476e80f
MT
1203
1204 @property
1205 def _all_telephone_numbers(self):
6ccc8acb
MT
1206 ret = [ self.sip_id, ]
1207
d3208ac7
MT
1208 if self.phone_number:
1209 s = phonenumbers.format_number(self.phone_number, phonenumbers.PhoneNumberFormat.E164)
1210 ret.append(s)
1211
6ccc8acb
MT
1212 for number in self.phone_numbers:
1213 s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
1214 ret.append(s)
1215
1216 return ret
66862195 1217
1c4522dc
MT
1218 # Description
1219
1220 def get_description(self):
1221 return self._get_string("description")
1222
1223 def set_description(self, description):
1224 self._set_string("description", description)
1225
1226 description = property(get_description, set_description)
1227
1228 # Avatar
1229
5d42f49b
MT
1230 def has_avatar(self):
1231 has_avatar = self.memcache.get("accounts:%s:has-avatar" % self.uid)
1232 if has_avatar is None:
1233 has_avatar = True if self.get_avatar() else False
1234
1235 # Cache avatar status for up to 24 hours
1236 self.memcache.set("accounts:%s:has-avatar" % self.uid, has_avatar, 3600 * 24)
1237
1238 return has_avatar
1239
0da30c28
MT
1240 def avatar_url(self, size=None, absolute=False):
1241 url = "/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
1242
1243 # Return an absolute URL
1244 if absolute:
1245 url = urllib.parse.urljoin("https://people.ipfire.org", url)
2cd9af74
MT
1246
1247 if size:
969a05eb 1248 url += "&size=%s" % size
2cd9af74
MT
1249
1250 return url
1251
2cd9af74 1252 def get_avatar(self, size=None):
5ef115cd 1253 photo = self._get_bytes("jpegPhoto")
2cd9af74 1254
0109451c
MT
1255 # Exit if no avatar is available
1256 if not photo:
1257 return
1258
5ef115cd
MT
1259 # Return the raw image if no size was requested
1260 if size is None:
1261 return photo
2cd9af74 1262
5ef115cd 1263 # Try to retrieve something from the cache
9c01e5ac 1264 avatar = self.memcache.get("accounts:%s:avatar:%s" % (self.dn, size))
5ef115cd
MT
1265 if avatar:
1266 return avatar
1a226c83 1267
5ef115cd 1268 # Generate a new thumbnail
2de3dacc 1269 avatar = util.generate_thumbnail(photo, size, square=True)
1a226c83 1270
5ef115cd 1271 # Save to cache for 15m
9c01e5ac 1272 self.memcache.set("accounts:%s:avatar:%s" % (self.dn, size), avatar, 900)
1a226c83 1273
5ef115cd 1274 return avatar
2cd9af74 1275
969a05eb
MT
1276 @property
1277 def avatar_hash(self):
1278 hash = self.memcache.get("accounts:%s:avatar-hash" % self.dn)
1279 if not hash:
1280 h = hashlib.new("md5")
1281 h.update(self.get_avatar() or b"")
1282 hash = h.hexdigest()[:7]
1283
1284 self.memcache.set("accounts:%s:avatar-hash" % self.dn, hash, 86400)
1285
1286 return hash
1287
5cc10421
MT
1288 def upload_avatar(self, avatar):
1289 self._set("jpegPhoto", avatar)
1290
5d42f49b 1291 # Delete cached avatar status
5a9176c5 1292 self.memcache.delete("accounts:%s:has-avatar" % self.dn)
5d42f49b 1293
969a05eb 1294 # Delete avatar hash
5a9176c5 1295 self.memcache.delete("accounts:%s:avatar-hash" % self.dn)
969a05eb 1296
92c4b559
MT
1297 # Consent to promotional emails
1298
1299 def get_consents_to_promotional_emails(self):
1300 return self.is_member_of_group("promotional-consent")
1301
1302 def set_contents_to_promotional_emails(self, value):
1303 group = self.backend.groups.get_by_gid("promotional-consent")
1304 assert group, "Could not find group: promotional-consent"
1305
1306 if value is True:
1307 group.add_member(self)
1308 else:
1309 group.del_member(self)
1310
1311 consents_to_promotional_emails = property(
1312 get_consents_to_promotional_emails,
1313 set_contents_to_promotional_emails,
1314 )
1315
26ccb61a
MT
1316 # Bugzilla
1317
1318 async def _disable_on_bugzilla(self, text=None):
1319 """
1320 Disables the user on Bugzilla
1321 """
1322 user = await self.backend.bugzilla.get_user(self.email)
1323
1324 # Do nothing if the user does not exist
1325 if not user:
1326 return
1327
1328 # Disable the user
1329 await user.disable(text)
1330
55b67ca4 1331
d8b04c72 1332class Groups(Object):
b6365721
MT
1333 hidden_groups = (
1334 "cn=LDAP Read Only,ou=Group,dc=ipfire,dc=org",
1335 "cn=LDAP Read Write,ou=Group,dc=ipfire,dc=org",
d71fd279
MT
1336
1337 # Everyone is a member of people
1338 "cn=people,ou=Group,dc=ipfire,dc=org",
b6365721
MT
1339 )
1340
d8b04c72
MT
1341 @property
1342 def search_base(self):
1343 return "ou=Group,%s" % self.backend.accounts.search_base
1344
18b13823
MT
1345 def _query(self, *args, **kwargs):
1346 kwargs.update({
1347 "search_base" : self.backend.groups.search_base,
1348 })
1349
1350 return self.backend.accounts._query(*args, **kwargs)
1351
1352 def __iter__(self):
1353 groups = self.get_all()
1354
1355 return iter(groups)
1356
1357 def _get_groups(self, query, **kwargs):
1358 res = self._query(query, **kwargs)
1359
1360 groups = []
1361 for dn, attrs in res:
b6365721
MT
1362 # Skip any hidden groups
1363 if dn in self.hidden_groups:
1364 continue
1365
18b13823
MT
1366 g = Group(self.backend, dn, attrs)
1367 groups.append(g)
1368
1369 return sorted(groups)
1370
bef47ee8
MT
1371 def _get_group(self, query, **kwargs):
1372 kwargs.update({
1373 "limit" : 1,
1374 })
1375
1376 groups = self._get_groups(query, **kwargs)
1377 if groups:
1378 return groups[0]
1379
18b13823
MT
1380 def get_all(self):
1381 return self._get_groups(
1382 "(|(objectClass=posixGroup)(objectClass=groupOfNames))",
1383 )
1384
bef47ee8
MT
1385 def get_by_gid(self, gid):
1386 return self._get_group(
1387 "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(cn=%s))" % gid,
1388 )
1389
d8b04c72 1390
959d8d2a 1391class Group(LDAPObject):
d8b04c72
MT
1392 def __repr__(self):
1393 if self.description:
1394 return "<%s %s (%s)>" % (
1395 self.__class__.__name__,
1396 self.gid,
1397 self.description,
1398 )
1399
1400 return "<%s %s>" % (self.__class__.__name__, self.gid)
1401
1402 def __str__(self):
1403 return self.description or self.gid
1404
d8b04c72
MT
1405 def __lt__(self, other):
1406 if isinstance(other, self.__class__):
1407 return (self.description or self.gid) < (other.description or other.gid)
1408
03e6aafb
MT
1409 return NotImplemented
1410
d71fd279
MT
1411 def __bool__(self):
1412 return True
1413
74f967de
MT
1414 def __len__(self):
1415 """
1416 Returns the number of members in this group
1417 """
1418 l = 0
1419
1420 for attr in ("member", "memberUid"):
1421 a = self.attributes.get(attr, None)
1422 if a:
1423 l += len(a)
1424
1425 return l
1426
bef47ee8
MT
1427 def __iter__(self):
1428 return iter(self.members)
1429
18b13823
MT
1430 @property
1431 def gid(self):
959d8d2a 1432 return self._get_string("cn")
d8b04c72
MT
1433
1434 @property
1435 def description(self):
959d8d2a 1436 return self._get_string("description")
d8b04c72 1437
83a4b1d5
MT
1438 @property
1439 def email(self):
959d8d2a 1440 return self._get_string("mail")
83a4b1d5 1441
bef47ee8
MT
1442 @lazy_property
1443 def members(self):
1444 members = []
1445
1446 # Get all members by DN
959d8d2a
MT
1447 for dn in self._get_strings("member"):
1448 member = self.backend.accounts.get_by_dn(dn)
bef47ee8
MT
1449 if member:
1450 members.append(member)
1451
959d8d2a
MT
1452 # Get all members by UID
1453 for uid in self._get_strings("memberUid"):
1454 member = self.backend.accounts.get_by_uid(uid)
bef47ee8
MT
1455 if member:
1456 members.append(member)
1457
1458 return sorted(members)
d8b04c72 1459
a5449be9
MT
1460 def add_member(self, account):
1461 """
1462 Adds a member to this group
1463 """
92c4b559
MT
1464 # Do nothing if this user is already in the group
1465 if account.is_member_of_group(self.gid):
1466 return
1467
a5449be9
MT
1468 if "posixGroup" in self.objectclasses:
1469 self._add_string("memberUid", account.uid)
1470 else:
1471 self._add_string("member", account.dn)
1472
1473 # Append to cached list of members
1474 self.members.append(account)
1475 self.members.sort()
1476
92c4b559
MT
1477 def del_member(self, account):
1478 """
1479 Removes a member from a group
1480 """
1481 # Do nothing if this user is not in the group
1482 if not account.is_member_of_group(self.gid):
1483 return
1484
1485 if "posixGroup" in self.objectclasses:
1486 self._delete_string("memberUid", account.uid)
1487 else:
1488 self._delete_string("member", account.dn)
1489
1490
940227cb
MT
1491if __name__ == "__main__":
1492 a = Accounts()
1493
11347e46 1494 print(a.list())