]> git.ipfire.org Git - pbs.git/blob - src/buildservice/users.py
users: Drop state
[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 return 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 @staticmethod
228 def check_password_strength(password):
229 score = 0
230 accepted = False
231
232 # Empty passwords cannot be used.
233 if len(password) == 0:
234 return False, 0
235
236 # Passwords with less than 6 characters are also too weak.
237 if len(password) < 6:
238 return False, 1
239
240 # Password with at least 8 characters are secure.
241 if len(password) >= 8:
242 score += 1
243
244 # 10 characters are even more secure.
245 if len(password) >= 10:
246 score += 1
247
248 # Digits in the password are good.
249 if re.search("\d+", password):
250 score += 1
251
252 # Check for lowercase AND uppercase characters.
253 if re.search("[a-z]", password) and re.search("[A-Z]", password):
254 score += 1
255
256 # Search for special characters.
257 if re.search(".[!,@,#,$,%,^,&,*,?,_,~,-,(,)]", password):
258 score += 1
259
260 if score >= 3:
261 accepted = True
262
263 return accepted, score
264
265
266 class User(base.DataObject):
267 table = "users"
268
269 def __repr__(self):
270 return "<%s %s>" % (self.__class__.__name__, self.realname)
271
272 def __hash__(self):
273 return hash(self.id)
274
275 def __eq__(self, other):
276 if isinstance(other, self.__class__):
277 return self.id == other.id
278
279 def __lt__(self, other):
280 if isinstance(other, self.__class__):
281 return self.name < other.name
282
283 elif isinstance(other, str):
284 return self.name < other
285
286 def delete(self):
287 self._set_attribute("deleted", True)
288
289 def activate(self):
290 self._set_attribute("activated", True)
291
292 def check_password(self, password):
293 """
294 Compare the given password with the one stored in the database.
295 """
296 if self.ldap_dn:
297 return self.backend.users.ldap.bind(self.ldap_dn, password)
298
299 return check_password_hash(password, self.data.passphrase)
300
301 def set_passphrase(self, passphrase):
302 """
303 Update the passphrase the users uses to log on.
304 """
305 # We cannot set the password for ldap users
306 if self.ldap_dn:
307 raise AttributeError("Cannot set passphrase for LDAP user")
308
309 self.db.execute("UPDATE users SET passphrase = %s WHERE id = %s",
310 generate_password_hash(passphrase), self.id)
311
312 passphrase = property(lambda x: None, set_passphrase)
313
314 def get_realname(self):
315 return self.data.realname or self.name
316
317 def set_realname(self, realname):
318 self._set_attribute("realname", realname)
319
320 realname = property(get_realname, set_realname)
321
322 @property
323 def name(self):
324 return self.data.name
325
326 @property
327 def ldap_dn(self):
328 return self.data.ldap_dn
329
330 @property
331 def firstname(self):
332 # Try to split the string into first and last name.
333 # If that is not successful, return the entire realname.
334 try:
335 firstname, rest = self.realname.split(" ", 1)
336 except:
337 return self.realname
338
339 return firstname
340
341 @lazy_property
342 def emails(self):
343 res = self.backend.users._get_user_emails("SELECT * FROM users_emails \
344 WHERE user_id = %s AND activated IS TRUE ORDER BY email", self.id)
345
346 return list(res)
347
348 @property
349 def email(self):
350 for email in self.emails:
351 if email.primary:
352 return email
353
354 def get_email(self, email):
355 for e in self.emails:
356 if e == email:
357 return e
358
359 def set_primary_email(self, email):
360 if not email in self.emails:
361 raise ValueError("Email address does not belong to user")
362
363 # Mark previous primary email as non-primary
364 self.db.execute("UPDATE users_emails SET \"primary\" = FALSE \
365 WHERE user_id = %s AND \"primary\" IS TRUE" % self.id)
366
367 # Mark new primary email
368 self.db.execute("UPDATE users_emails SET \"primary\" = TRUE \
369 WHERE user_id = %s AND email = %s AND activated IS TRUE",
370 self.id, email)
371
372 def has_email_address(self, email_address):
373 try:
374 mail, email_address = email.utils.parseaddr(email_address)
375 except:
376 pass
377
378 return email_address in self.emails
379
380 def activate_email(self, code):
381 # Search email by activation code
382 email = self.backend.users._get_user_email("SELECT * FROM users_emails \
383 WHERE user_id = %s AND activated IS FALSE AND activation_code = %s", self.id, code)
384
385 if not email:
386 return False
387
388 # Activate email address
389 email.activate()
390 return True
391
392 # Te activated flag is useful for LDAP users
393 def add_email(self, email, activated=False):
394 # Check if the email is in use
395 if self.backend.users.email_in_use(email):
396 raise ValueError("Email %s is already in use" % email)
397
398 activation_code = None
399 if not activated:
400 activation_code = generate_random_string(64)
401
402 user_email = self.backend.users._get_user_email("INSERT INTO users_emails(user_id, email, \
403 \"primary\", activated, activation_code) VALUES(%s, %s, %s, %s, %s) RETURNING *",
404 self.id, email, not self.emails, activated, activation_code)
405 self.emails.append(user_email)
406
407 # Send activation email if activation is needed
408 if not activated:
409 user_email.send_activation_mail()
410
411 return user_email
412
413 def send_template(self, *args, **kwargs):
414 return self.backend.messages.send_template(self, *args, **kwargs)
415
416 def is_admin(self):
417 return self.data.admin is True
418
419 def get_locale(self):
420 return tornado.locale.get(self.data.locale)
421
422 def set_locale(self, locale):
423 self._set_attribute("locale", locale)
424
425 locale = property(get_locale, set_locale)
426
427 def get_timezone(self, tz=None):
428 if tz is None:
429 tz = self.data.timezone or ""
430
431 try:
432 tz = pytz.timezone(tz)
433 except pytz.UnknownTimeZoneError:
434 tz = pytz.timezone("UTC")
435
436 return tz
437
438 def set_timezone(self, timezone):
439 if not timezone is None:
440 tz = self.get_timezone(timezone)
441 timezone = tz.zone
442
443 self._set_attribute("timezone", timezone)
444
445 timezone = property(get_timezone, set_timezone)
446
447 def get_password_recovery_code(self):
448 return self.data.password_recovery_code
449
450 def set_password_recovery_code(self, code):
451 self._set_attribute("password_recovery_code", code)
452
453 self._set_attribute("password_recovery_code_expires_at",
454 datetime.datetime.utcnow() + datetime.timedelta(days=1))
455
456 password_recovery_code = property(get_password_recovery_code, set_password_recovery_code)
457
458 def forgot_password(self):
459 log.debug("User %s reqested password recovery" % self.name)
460
461 # We cannot reset te password for ldap users
462 if self.ldap_dn:
463 # Maybe we should send an email with an explanation
464 return
465
466 # Add a recovery code to the database and a timestamp when this code expires
467 self.password_recovery_code = generate_random_string(64)
468
469 # Send an email with the activation code
470 self.send_template("messages/users/password-reset", user=self)
471
472 @property
473 def activated(self):
474 return self.data.activated
475
476 @property
477 def deleted(self):
478 return self.data.deleted
479
480 @property
481 def registered(self):
482 return self.data.registered
483
484 def gravatar_icon(self, size=128):
485 h = hashlib.new("md5")
486 if self.email:
487 h.update("%s" % self.email)
488
489 # construct the url
490 gravatar_url = "http://www.gravatar.com/avatar/%s?" % h.hexdigest()
491 gravatar_url += urllib.urlencode({'d': "mm", 's': str(size)})
492
493 return gravatar_url
494
495 @lazy_property
496 def perms(self):
497 return self.db.get("SELECT * FROM users_permissions WHERE user_id = %s", self.id)
498
499 def has_perm(self, perm):
500 """
501 Returns True if the user has the requested permission.
502 """
503 # Admins have the permission for everything.
504 if self.is_admin():
505 return True
506
507 # All others must be checked individually.
508 return self.perms.get(perm, False) == True
509
510 @property
511 def sessions(self):
512 return self.backend.sessions._get_sessions("SELECT * FROM sessions \
513 WHERE user_id = %s AND valid_until >= NOW() ORDER BY created_at")
514
515
516 class UserEmail(base.DataObject):
517 table = "users_emails"
518
519 def __str__(self):
520 return self.email
521
522 def __eq__(self, other):
523 if isinstance(other, self.__class__):
524 return self.id == other.id
525
526 elif isinstance(other, str):
527 return self.email == other
528
529 @lazy_property
530 def user(self):
531 return self.backend.users.get_by_id(self.data.user_id)
532
533 @property
534 def recipient(self):
535 return "%s <%s>" % (self.user.realname, self.email)
536
537 @property
538 def email(self):
539 return self.data.email
540
541 def set_primary(self, primary):
542 self._set_attribute("primary", primary)
543
544 primary = property(lambda s: s.data.primary, set_primary)
545
546 @property
547 def activated(self):
548 return self.data.activated
549
550 def activate(self):
551 self._set_attribute("activated", True)
552 self._set_attribute("activation_code", None)
553
554 @property
555 def activation_code(self):
556 return self.data.activation_code
557
558 def send_activation_mail(self):
559 if not self.primary:
560 return self.send_email_activation_email()
561
562 logging.debug("Sending activation mail to %s" % self.email)
563
564 self.user.send_template("messages/users/account-activation")
565
566 def send_email_activation_mail(self):
567 logging.debug("Sending email address activation mail to %s" % self.email)
568
569 self.user.send_template("messages/users/email-activation", email=self)
570
571
572 # Some testing code.
573 if __name__ == "__main__":
574 for password in ("1234567890", "abcdefghij"):
575 digest = generate_password_hash(password)
576
577 print "%s %s" % (password, digest)
578 print " Matches? %s" % check_password_hash(password, digest)
579