]> git.ipfire.org Git - pbs.git/blame - src/buildservice/users.py
Correctly set cache of user email objects
[pbs.git] / src / buildservice / users.py
CommitLineData
9137135a
MT
1#!/usr/bin/python
2
98b826d4 3import datetime
b9c2a52b 4import email.utils
9137135a
MT
5import hashlib
6import logging
f6e6ff79 7import pytz
9137135a 8import random
f6e6ff79 9import re
9137135a
MT
10import string
11import urllib
8d8d65b4 12import ldap
9137135a
MT
13
14import tornado.locale
15
b9c2a52b
MT
16log = logging.getLogger("users")
17log.propagate = 1
18
2c909128 19from . import base
b7debe9f 20from . import ldap
9137135a 21
b9c2a52b
MT
22from .decorators import *
23
f6e6ff79
MT
24# A list of possible random characters.
25random_chars = string.ascii_letters + string.digits
26
27def generate_random_string(length=16):
28 """
29 Return a string with random chararcters A-Za-z0-9 with given length.
30 """
31 return "".join([random.choice(random_chars) for i in range(length)])
32
33
34def generate_password_hash(password, salt=None, algo="sha512"):
35 """
36 This function creates a salted digest of the given password.
37 """
38 # Generate the salt (length = 16) of none was given.
39 if salt is None:
40 salt = generate_random_string(length=16)
41
42 # Compute the hash.
43 # <SALT> + <PASSWORD>
44 if not algo in hashlib.algorithms:
45 raise Exception, "Unsupported password hash algorithm: %s" % algo
46
47 # Calculate the digest.
48 h = hashlib.new(algo)
49 h.update(salt)
50 h.update(password)
51
52 # Output string is of kind "<algo>$<salt>$<hash>".
53 return "$".join((algo, salt, h.hexdigest()))
54
55def check_password_hash(password, password_hash):
56 """
57 Check a plain-text password with the given digest.
58 """
59 # Handle plaintext passwords (plain$<password>).
60 if password_hash.startswith("plain$"):
61 return password_hash[6:] == password
62
63 try:
64 algo, salt, digest = password_hash.split("$", 2)
65 except ValueError:
66 logging.warning("Unknown password hash: %s" % password_hash)
67 return False
68
69 # Re-generate the password hash and compare the result.
70 return password_hash == generate_password_hash(password, salt=salt, algo=algo)
71
f6e6ff79 72
9137135a 73class Users(base.Object):
8d8d65b4 74 def init(self):
b7debe9f 75 self.ldap = ldap.LDAP(self.backend)
8d8d65b4 76
b9c2a52b
MT
77 def _get_user(self, query, *args):
78 res = self.db.get(query, *args)
9137135a 79
b9c2a52b
MT
80 if res:
81 return User(self.backend, res.id, data=res)
9137135a 82
b9c2a52b
MT
83 def _get_users(self, query, *args):
84 res = self.db.query(query, *args)
8d8d65b4 85
b9c2a52b
MT
86 for row in res:
87 yield User(self.backend, row.id, data=row)
9137135a 88
b9c2a52b
MT
89 def _get_user_email(self, query, *args):
90 res = self.db.get(query, *args)
f6e6ff79 91
b9c2a52b
MT
92 if res:
93 return UserEmail(self.backend, res.id, data=res)
f6e6ff79 94
b9c2a52b
MT
95 def _get_user_emails(self, query, *args):
96 res = self.db.query(query, *args)
f6e6ff79 97
b9c2a52b
MT
98 for row in res:
99 yield UserEmail(self.backend, row.id, data=row)
9137135a 100
b9c2a52b
MT
101 def __iter__(self):
102 users = self._get_users("SELECT * FROM users \
103 WHERE activated IS TRUE AND deleted IS FALSE ORDER BY name")
8d8d65b4 104
b9c2a52b 105 return iter(users)
8d8d65b4 106
b9c2a52b
MT
107 def __len__(self):
108 res = self.db.get("SELECT COUNT(*) AS count FROM users \
109 WHERE activated IS TRUE AND deleted IS FALSE")
8d8d65b4 110
b9c2a52b 111 return res.count
9137135a 112
b9c2a52b
MT
113 def create(self, name, realname=None, ldap_dn=None):
114 # XXX check if username has the correct name
9137135a 115
b9c2a52b
MT
116 # Check if name is already taken
117 user = self.get_by_name(name)
118 if user:
119 raise ValueError("Username %s already taken" % name)
9137135a 120
b9c2a52b
MT
121 # Create new user
122 user = self._get_user("INSERT INTO users(name, realname, ldap_dn) \
123 VALUES(%s, %s, %s) RETURNING *", name, realname, ldap_dn)
9137135a 124
b9c2a52b
MT
125 # Create row in permissions table.
126 self.db.execute("INSERT INTO users_permissions(user_id) VALUES(%s)", user.id)
9137135a 127
b9c2a52b 128 log.debug("Created user %s" % user.name)
9137135a 129
b9c2a52b 130 return user
9137135a 131
b9c2a52b
MT
132 def create_from_ldap(self, name):
133 log.debug("Creating user %s from LDAP" % name)
9137135a 134
b9c2a52b
MT
135 # Get required attributes from LDAP
136 dn, attr = self.ldap.get_user(name, attrlist=["uid", "cn", "mail"])
137 assert dn
9137135a 138
b9c2a52b
MT
139 # Create regular user
140 user = self.create(name, realname=attr["cn"][0], ldap_dn=dn)
141 user.activate()
9137135a 142
b9c2a52b
MT
143 # Add all email addresses and activate them
144 for email in attr["mail"]:
145 user.add_email(email, activated=True)
9137135a 146
b9c2a52b 147 return user
9137135a 148
b9c2a52b
MT
149 def auth(self, name, password):
150 # If either name or password is None, we don't check at all.
151 if None in (name, password):
152 return
9137135a 153
ba1958a5
JS
154 # usually we will get an email address as name
155 user = self.get_by_email(name) or self.get_by_name(name)
f6e6ff79 156
b9c2a52b 157 if not user:
ba1958a5
JS
158 # If no user could be found, we search for a matching user in
159 # the LDAP database
b9c2a52b
MT
160 if not self.ldap.auth(name, password):
161 return
f6e6ff79 162
b9c2a52b
MT
163 # If a LDAP user is found (and password matches), we will
164 # create a new local user with the information from LDAP.
ba1958a5
JS
165 user = self.create_from_ldap(name)
166
167 if not user.activated or user.deleted:
168 return
b9c2a52b
MT
169
170 # Check if the password matches
171 if user.check_password(password):
172 return user
c9619eec 173
b9c2a52b
MT
174 def email_in_use(self, email):
175 return self._get_user_email("SELECT * FROM users_emails \
176 WHERE email = %s AND activated IS TRUE", email)
f6e6ff79 177
b9c2a52b
MT
178 def get_by_id(self, id):
179 return self._get_user("SELECT * FROM users WHERE id = %s", id)
f6e6ff79 180
b9c2a52b
MT
181 def get_by_name(self, name):
182 return self._get_user("SELECT * FROM users WHERE name = %s", name)
f6e6ff79 183
b9c2a52b
MT
184 def get_by_email(self, email):
185 return self._get_user("SELECT users.* FROM users \
186 LEFT JOIN users_emails ON users.id = users_emails.user_id \
187 WHERE users_emails.email = %s", email)
f6e6ff79 188
98b826d4
JS
189 def get_by_password_recovery_code(self, code):
190 return self._get_user("SELECT * FROM users \
191 WHERE password_recovery_code = %s AND password_recovery_code_expires_at > NOW()", code)
192
040fc249
MT
193 def find_maintainers(self, maintainers):
194 email_addresses = []
195
196 # Make a unique list of all email addresses
197 for maintainer in maintainers:
198 name, email_address = email.utils.parseaddr(maintainer)
199
200 if not email_address in email_addresses:
201 email_addresses.append(email_address)
202
203 users = self._get_users("SELECT DISTINCT users.* FROM users \
204 LEFT JOIN users_emails ON users.id = users_emails.user_id \
205 WHERE users_emails.activated IS TRUE \
206 AND users_emails.email = ANY(%s)", email_addresses)
207
208 return sorted(users)
209
f6e6ff79 210 def find_maintainer(self, s):
b9c2a52b 211 name, email_address = email.utils.parseaddr(s)
f6e6ff79 212
b9c2a52b
MT
213 # Got invalid input
214 if not email_address:
f6e6ff79
MT
215 return
216
b9c2a52b
MT
217 return self.get_by_email(email_address)
218
219 def search(self, pattern, limit=None):
220 pattern = "%%%s%%" % pattern
221
efbd7501 222 users = self._get_users("SELECT * FROM users \
b9c2a52b
MT
223 WHERE (name LIKE %s OR realname LIKE %s) \
224 AND activated IS TRUE AND deleted IS FALSE \
225 ORDER BY name LIMIT %s", pattern, pattern, limit)
f6e6ff79 226
efbd7501
MT
227 return list(users)
228
c0b302b3
JS
229 @staticmethod
230 def check_password_strength(password):
231 score = 0
232 accepted = False
233
234 # Empty passwords cannot be used.
235 if len(password) == 0:
236 return False, 0
237
238 # Passwords with less than 6 characters are also too weak.
239 if len(password) < 6:
240 return False, 1
241
242 # Password with at least 8 characters are secure.
243 if len(password) >= 8:
244 score += 1
245
246 # 10 characters are even more secure.
247 if len(password) >= 10:
248 score += 1
249
250 # Digits in the password are good.
251 if re.search("\d+", password):
252 score += 1
253
254 # Check for lowercase AND uppercase characters.
255 if re.search("[a-z]", password) and re.search("[A-Z]", password):
256 score += 1
257
258 # Search for special characters.
259 if re.search(".[!,@,#,$,%,^,&,*,?,_,~,-,(,)]", password):
260 score += 1
261
262 if score >= 3:
263 accepted = True
264
265 return accepted, score
f6e6ff79 266
9137135a 267
b9c2a52b
MT
268class User(base.DataObject):
269 table = "users"
9137135a 270
20d7f5eb
MT
271 def __repr__(self):
272 return "<%s %s>" % (self.__class__.__name__, self.realname)
273
b0315eb4
MT
274 def __hash__(self):
275 return hash(self.id)
276
b9c2a52b
MT
277 def __eq__(self, other):
278 if isinstance(other, self.__class__):
279 return self.id == other.id
f6e6ff79 280
b9c2a52b
MT
281 def __lt__(self, other):
282 if isinstance(other, self.__class__):
283 return self.name < other.name
f6e6ff79 284
b9c2a52b
MT
285 elif isinstance(other, str):
286 return self.name < other
f6e6ff79 287
9137135a 288 def delete(self):
b9c2a52b 289 self._set_attribute("deleted", True)
9137135a
MT
290
291 def activate(self):
b9c2a52b 292 self._set_attribute("activated", True)
f6e6ff79
MT
293
294 def check_password(self, password):
295 """
296 Compare the given password with the one stored in the database.
297 """
8d8d65b4
JS
298 if self.ldap_dn:
299 return self.backend.users.ldap.bind(self.ldap_dn, password)
300
f6e6ff79 301 return check_password_hash(password, self.data.passphrase)
9137135a
MT
302
303 def set_passphrase(self, passphrase):
304 """
305 Update the passphrase the users uses to log on.
306 """
98b826d4
JS
307 # We cannot set the password for ldap users
308 if self.ldap_dn:
309 raise AttributeError("Cannot set passphrase for LDAP user")
310
f6e6ff79
MT
311 self.db.execute("UPDATE users SET passphrase = %s WHERE id = %s",
312 generate_password_hash(passphrase), self.id)
9137135a
MT
313
314 passphrase = property(lambda x: None, set_passphrase)
315
9137135a 316 def get_realname(self):
b9c2a52b 317 return self.data.realname or self.name
9137135a
MT
318
319 def set_realname(self, realname):
b9c2a52b 320 self._set_attribute("realname", realname)
9137135a
MT
321
322 realname = property(get_realname, set_realname)
323
324 @property
325 def name(self):
326 return self.data.name
327
8d8d65b4
JS
328 @property
329 def ldap_dn(self):
330 return self.data.ldap_dn
331
f6e6ff79
MT
332 @property
333 def firstname(self):
3f82e940
MT
334 # Try to split the string into first and last name.
335 # If that is not successful, return the entire realname.
336 try:
337 firstname, rest = self.realname.split(" ", 1)
338 except:
339 return self.realname
f6e6ff79
MT
340
341 return firstname
342
d0bce25d
MT
343 @property
344 def envelope_from(self):
345 return "%s <%s>" % (self.realname, self.email)
346
b9c2a52b
MT
347 @lazy_property
348 def emails(self):
349 res = self.backend.users._get_user_emails("SELECT * FROM users_emails \
350 WHERE user_id = %s AND activated IS TRUE ORDER BY email", self.id)
351
352 return list(res)
353
26fe80df
JS
354 @property
355 def email(self):
b9c2a52b
MT
356 for email in self.emails:
357 if email.primary:
358 return email
f6e6ff79 359
b9c2a52b
MT
360 def get_email(self, email):
361 for e in self.emails:
362 if e == email:
363 return e
9137135a 364
26fe80df
JS
365 def set_primary_email(self, email):
366 if not email in self.emails:
367 raise ValueError("Email address does not belong to user")
9137135a 368
26fe80df
JS
369 # Mark previous primary email as non-primary
370 self.db.execute("UPDATE users_emails SET \"primary\" = FALSE \
371 WHERE user_id = %s AND \"primary\" IS TRUE" % self.id)
f6e6ff79 372
26fe80df
JS
373 # Mark new primary email
374 self.db.execute("UPDATE users_emails SET \"primary\" = TRUE \
375 WHERE user_id = %s AND email = %s AND activated IS TRUE",
376 self.id, email)
9137135a 377
040fc249
MT
378 def has_email_address(self, email_address):
379 try:
380 mail, email_address = email.utils.parseaddr(email_address)
381 except:
382 pass
383
384 return email_address in self.emails
385
26fe80df 386 def activate_email(self, code):
b9c2a52b
MT
387 # Search email by activation code
388 email = self.backend.users._get_user_email("SELECT * FROM users_emails \
389 WHERE user_id = %s AND activated IS FALSE AND activation_code = %s", self.id, code)
26fe80df 390
b9c2a52b 391 if not email:
26fe80df
JS
392 return False
393
b9c2a52b
MT
394 # Activate email address
395 email.activate()
26fe80df
JS
396 return True
397
398 # Te activated flag is useful for LDAP users
399 def add_email(self, email, activated=False):
400 # Check if the email is in use
b9c2a52b 401 if self.backend.users.email_in_use(email):
26fe80df
JS
402 raise ValueError("Email %s is already in use" % email)
403
404 activation_code = None
405 if not activated:
406 activation_code = generate_random_string(64)
407
b9c2a52b
MT
408 user_email = self.backend.users._get_user_email("INSERT INTO users_emails(user_id, email, \
409 \"primary\", activated, activation_code) VALUES(%s, %s, %s, %s, %s) RETURNING *",
410 self.id, email, not self.emails, activated, activation_code)
7b5528b5
MT
411
412 # Set caches
413 user_email.user = self
b9c2a52b 414 self.emails.append(user_email)
26fe80df 415
b9c2a52b 416 # Send activation email if activation is needed
8d8d65b4 417 if not activated:
b9c2a52b 418 user_email.send_activation_mail()
9137135a 419
b9c2a52b 420 return user_email
9137135a 421
68dd077d
MT
422 def send_template(self, *args, **kwargs):
423 return self.backend.messages.send_template(self, *args, **kwargs)
424
b9c2a52b 425 def is_admin(self):
e304790f 426 return self.data.admin is True
9137135a 427
4947da2d
MT
428 def get_locale(self):
429 return tornado.locale.get(self.data.locale)
430
9137135a 431 def set_locale(self, locale):
b9c2a52b 432 self._set_attribute("locale", locale)
9137135a 433
4947da2d 434 locale = property(get_locale, set_locale)
9137135a 435
f6e6ff79
MT
436 def get_timezone(self, tz=None):
437 if tz is None:
438 tz = self.data.timezone or ""
439
440 try:
441 tz = pytz.timezone(tz)
442 except pytz.UnknownTimeZoneError:
443 tz = pytz.timezone("UTC")
444
445 return tz
446
447 def set_timezone(self, timezone):
448 if not timezone is None:
449 tz = self.get_timezone(timezone)
450 timezone = tz.zone
451
b9c2a52b 452 self._set_attribute("timezone", timezone)
f6e6ff79
MT
453
454 timezone = property(get_timezone, set_timezone)
455
98b826d4
JS
456 def get_password_recovery_code(self):
457 return self.data.password_recovery_code
458
459 def set_password_recovery_code(self, code):
460 self._set_attribute("password_recovery_code", code)
461
462 self._set_attribute("password_recovery_code_expires_at",
463 datetime.datetime.utcnow() + datetime.timedelta(days=1))
464
465 password_recovery_code = property(get_password_recovery_code, set_password_recovery_code)
466
467 def forgot_password(self):
468 log.debug("User %s reqested password recovery" % self.name)
469
470 # We cannot reset te password for ldap users
471 if self.ldap_dn:
472 # Maybe we should send an email with an explanation
473 return
474
475 # Add a recovery code to the database and a timestamp when this code expires
476 self.password_recovery_code = generate_random_string(64)
477
a76becd9
JS
478 # Send an email with the activation code
479 self.send_template("messages/users/password-reset", user=self)
98b826d4 480
9137135a
MT
481 @property
482 def activated(self):
26fe80df 483 return self.data.activated
9137135a 484
ba1958a5
JS
485 @property
486 def deleted(self):
487 return self.data.deleted
488
9137135a 489 @property
b01825aa
MT
490 def registered_at(self):
491 return self.data.registered_at
9137135a
MT
492
493 def gravatar_icon(self, size=128):
9bf767c3
MT
494 h = hashlib.new("md5")
495 if self.email:
496 h.update("%s" % self.email)
497
9137135a 498 # construct the url
9bf767c3 499 gravatar_url = "http://www.gravatar.com/avatar/%s?" % h.hexdigest()
9137135a
MT
500 gravatar_url += urllib.urlencode({'d': "mm", 's': str(size)})
501
502 return gravatar_url
503
b9c2a52b 504 @lazy_property
f6e6ff79 505 def perms(self):
b9c2a52b 506 return self.db.get("SELECT * FROM users_permissions WHERE user_id = %s", self.id)
f6e6ff79
MT
507
508 def has_perm(self, perm):
509 """
510 Returns True if the user has the requested permission.
511 """
512 # Admins have the permission for everything.
513 if self.is_admin():
514 return True
515
f6e6ff79 516 # All others must be checked individually.
8d8d65b4 517 return self.perms.get(perm, False) == True
f6e6ff79 518
b0315eb4
MT
519 @property
520 def sessions(self):
521 return self.backend.sessions._get_sessions("SELECT * FROM sessions \
522 WHERE user_id = %s AND valid_until >= NOW() ORDER BY created_at")
523
b9c2a52b
MT
524
525class UserEmail(base.DataObject):
526 table = "users_emails"
527
528 def __str__(self):
529 return self.email
530
531 def __eq__(self, other):
532 if isinstance(other, self.__class__):
533 return self.id == other.id
534
535 elif isinstance(other, str):
536 return self.email == other
537
538 @lazy_property
539 def user(self):
540 return self.backend.users.get_by_id(self.data.user_id)
541
542 @property
543 def recipient(self):
e50f17fe 544 return "%s <%s>" % (self.user.realname, self.email)
b9c2a52b
MT
545
546 @property
547 def email(self):
548 return self.data.email
549
550 def set_primary(self, primary):
551 self._set_attribute("primary", primary)
552
553 primary = property(lambda s: s.data.primary, set_primary)
554
555 @property
556 def activated(self):
557 return self.data.activated
558
559 def activate(self):
560 self._set_attribute("activated", True)
561 self._set_attribute("activation_code", None)
562
563 @property
564 def activation_code(self):
565 return self.data.activation_code
566
9137135a 567 def send_activation_mail(self):
b9c2a52b
MT
568 if not self.primary:
569 return self.send_email_activation_email()
570
9137135a
MT
571 logging.debug("Sending activation mail to %s" % self.email)
572
68dd077d 573 self.user.send_template("messages/users/account-activation")
9137135a 574
9ad522a5 575 def send_email_activation_mail(self):
b9c2a52b 576 logging.debug("Sending email address activation mail to %s" % self.email)
26fe80df 577
9ad522a5 578 self.user.send_template("messages/users/email-activation", email=self)
26fe80df 579
f6e6ff79
MT
580
581# Some testing code.
582if __name__ == "__main__":
583 for password in ("1234567890", "abcdefghij"):
584 digest = generate_password_hash(password)
585
586 print "%s %s" % (password, digest)
587 print " Matches? %s" % check_password_hash(password, digest)
9137135a 588