]> git.ipfire.org Git - pbs.git/blob - src/buildservice/users.py
Correctly set cache of user email objects
[pbs.git] / src / buildservice / users.py
1 #!/usr/bin/python
2
3 import datetime
4 import email.utils
5 import hashlib
6 import logging
7 import pytz
8 import random
9 import re
10 import string
11 import urllib
12 import ldap
13
14 import tornado.locale
15
16 log = logging.getLogger("users")
17 log.propagate = 1
18
19 from . import base
20 from . import ldap
21
22 from .decorators import *
23
24 # A list of possible random characters.
25 random_chars = string.ascii_letters + string.digits
26
27 def 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
34 def 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
55 def 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
72
73 class Users(base.Object):
74 def init(self):
75 self.ldap = ldap.LDAP(self.backend)
76
77 def _get_user(self, query, *args):
78 res = self.db.get(query, *args)
79
80 if res:
81 return User(self.backend, res.id, data=res)
82
83 def _get_users(self, query, *args):
84 res = self.db.query(query, *args)
85
86 for row in res:
87 yield User(self.backend, row.id, data=row)
88
89 def _get_user_email(self, query, *args):
90 res = self.db.get(query, *args)
91
92 if res:
93 return UserEmail(self.backend, res.id, data=res)
94
95 def _get_user_emails(self, query, *args):
96 res = self.db.query(query, *args)
97
98 for row in res:
99 yield UserEmail(self.backend, row.id, data=row)
100
101 def __iter__(self):
102 users = self._get_users("SELECT * FROM users \
103 WHERE activated IS TRUE AND deleted IS FALSE ORDER BY name")
104
105 return iter(users)
106
107 def __len__(self):
108 res = self.db.get("SELECT COUNT(*) AS count FROM users \
109 WHERE activated IS TRUE AND deleted IS FALSE")
110
111 return res.count
112
113 def create(self, name, realname=None, ldap_dn=None):
114 # XXX check if username has the correct name
115
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)
120
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)
124
125 # Create row in permissions table.
126 self.db.execute("INSERT INTO users_permissions(user_id) VALUES(%s)", user.id)
127
128 log.debug("Created user %s" % user.name)
129
130 return user
131
132 def create_from_ldap(self, name):
133 log.debug("Creating user %s from LDAP" % name)
134
135 # Get required attributes from LDAP
136 dn, attr = self.ldap.get_user(name, attrlist=["uid", "cn", "mail"])
137 assert dn
138
139 # Create regular user
140 user = self.create(name, realname=attr["cn"][0], ldap_dn=dn)
141 user.activate()
142
143 # Add all email addresses and activate them
144 for email in attr["mail"]:
145 user.add_email(email, activated=True)
146
147 return user
148
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
153
154 # usually we will get an email address as name
155 user = self.get_by_email(name) or self.get_by_name(name)
156
157 if not user:
158 # If no user could be found, we search for a matching user in
159 # the LDAP database
160 if not self.ldap.auth(name, password):
161 return
162
163 # If a LDAP user is found (and password matches), we will
164 # create a new local user with the information from LDAP.
165 user = self.create_from_ldap(name)
166
167 if not user.activated or user.deleted:
168 return
169
170 # Check if the password matches
171 if user.check_password(password):
172 return user
173
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)
177
178 def get_by_id(self, id):
179 return self._get_user("SELECT * FROM users WHERE id = %s", id)
180
181 def get_by_name(self, name):
182 return self._get_user("SELECT * FROM users WHERE name = %s", name)
183
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)
188
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
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
210 def find_maintainer(self, s):
211 name, email_address = email.utils.parseaddr(s)
212
213 # Got invalid input
214 if not email_address:
215 return
216
217 return self.get_by_email(email_address)
218
219 def search(self, pattern, limit=None):
220 pattern = "%%%s%%" % pattern
221
222 users = self._get_users("SELECT * FROM users \
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)
226
227 return list(users)
228
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
266
267
268 class User(base.DataObject):
269 table = "users"
270
271 def __repr__(self):
272 return "<%s %s>" % (self.__class__.__name__, self.realname)
273
274 def __hash__(self):
275 return hash(self.id)
276
277 def __eq__(self, other):
278 if isinstance(other, self.__class__):
279 return self.id == other.id
280
281 def __lt__(self, other):
282 if isinstance(other, self.__class__):
283 return self.name < other.name
284
285 elif isinstance(other, str):
286 return self.name < other
287
288 def delete(self):
289 self._set_attribute("deleted", True)
290
291 def activate(self):
292 self._set_attribute("activated", True)
293
294 def check_password(self, password):
295 """
296 Compare the given password with the one stored in the database.
297 """
298 if self.ldap_dn:
299 return self.backend.users.ldap.bind(self.ldap_dn, password)
300
301 return check_password_hash(password, self.data.passphrase)
302
303 def set_passphrase(self, passphrase):
304 """
305 Update the passphrase the users uses to log on.
306 """
307 # We cannot set the password for ldap users
308 if self.ldap_dn:
309 raise AttributeError("Cannot set passphrase for LDAP user")
310
311 self.db.execute("UPDATE users SET passphrase = %s WHERE id = %s",
312 generate_password_hash(passphrase), self.id)
313
314 passphrase = property(lambda x: None, set_passphrase)
315
316 def get_realname(self):
317 return self.data.realname or self.name
318
319 def set_realname(self, realname):
320 self._set_attribute("realname", realname)
321
322 realname = property(get_realname, set_realname)
323
324 @property
325 def name(self):
326 return self.data.name
327
328 @property
329 def ldap_dn(self):
330 return self.data.ldap_dn
331
332 @property
333 def firstname(self):
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
340
341 return firstname
342
343 @property
344 def envelope_from(self):
345 return "%s <%s>" % (self.realname, self.email)
346
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
354 @property
355 def email(self):
356 for email in self.emails:
357 if email.primary:
358 return email
359
360 def get_email(self, email):
361 for e in self.emails:
362 if e == email:
363 return e
364
365 def set_primary_email(self, email):
366 if not email in self.emails:
367 raise ValueError("Email address does not belong to user")
368
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)
372
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)
377
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
386 def activate_email(self, code):
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)
390
391 if not email:
392 return False
393
394 # Activate email address
395 email.activate()
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
401 if self.backend.users.email_in_use(email):
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
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)
411
412 # Set caches
413 user_email.user = self
414 self.emails.append(user_email)
415
416 # Send activation email if activation is needed
417 if not activated:
418 user_email.send_activation_mail()
419
420 return user_email
421
422 def send_template(self, *args, **kwargs):
423 return self.backend.messages.send_template(self, *args, **kwargs)
424
425 def is_admin(self):
426 return self.data.admin is True
427
428 def get_locale(self):
429 return tornado.locale.get(self.data.locale)
430
431 def set_locale(self, locale):
432 self._set_attribute("locale", locale)
433
434 locale = property(get_locale, set_locale)
435
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
452 self._set_attribute("timezone", timezone)
453
454 timezone = property(get_timezone, set_timezone)
455
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
478 # Send an email with the activation code
479 self.send_template("messages/users/password-reset", user=self)
480
481 @property
482 def activated(self):
483 return self.data.activated
484
485 @property
486 def deleted(self):
487 return self.data.deleted
488
489 @property
490 def registered_at(self):
491 return self.data.registered_at
492
493 def gravatar_icon(self, size=128):
494 h = hashlib.new("md5")
495 if self.email:
496 h.update("%s" % self.email)
497
498 # construct the url
499 gravatar_url = "http://www.gravatar.com/avatar/%s?" % h.hexdigest()
500 gravatar_url += urllib.urlencode({'d': "mm", 's': str(size)})
501
502 return gravatar_url
503
504 @lazy_property
505 def perms(self):
506 return self.db.get("SELECT * FROM users_permissions WHERE user_id = %s", self.id)
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
516 # All others must be checked individually.
517 return self.perms.get(perm, False) == True
518
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
524
525 class 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):
544 return "%s <%s>" % (self.user.realname, self.email)
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
567 def send_activation_mail(self):
568 if not self.primary:
569 return self.send_email_activation_email()
570
571 logging.debug("Sending activation mail to %s" % self.email)
572
573 self.user.send_template("messages/users/account-activation")
574
575 def send_email_activation_mail(self):
576 logging.debug("Sending email address activation mail to %s" % self.email)
577
578 self.user.send_template("messages/users/email-activation", email=self)
579
580
581 # Some testing code.
582 if __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)
588