]> git.ipfire.org Git - pbs.git/blame - src/buildservice/users.py
Merge branch 'master' into bootstrap4
[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
b9c2a52b
MT
343 @lazy_property
344 def emails(self):
345 res = self.backend.users._get_user_emails("SELECT * FROM users_emails \
346 WHERE user_id = %s AND activated IS TRUE ORDER BY email", self.id)
347
348 return list(res)
349
26fe80df
JS
350 @property
351 def email(self):
b9c2a52b
MT
352 for email in self.emails:
353 if email.primary:
354 return email
f6e6ff79 355
b9c2a52b
MT
356 def get_email(self, email):
357 for e in self.emails:
358 if e == email:
359 return e
9137135a 360
26fe80df
JS
361 def set_primary_email(self, email):
362 if not email in self.emails:
363 raise ValueError("Email address does not belong to user")
9137135a 364
26fe80df
JS
365 # Mark previous primary email as non-primary
366 self.db.execute("UPDATE users_emails SET \"primary\" = FALSE \
367 WHERE user_id = %s AND \"primary\" IS TRUE" % self.id)
f6e6ff79 368
26fe80df
JS
369 # Mark new primary email
370 self.db.execute("UPDATE users_emails SET \"primary\" = TRUE \
371 WHERE user_id = %s AND email = %s AND activated IS TRUE",
372 self.id, email)
9137135a 373
040fc249
MT
374 def has_email_address(self, email_address):
375 try:
376 mail, email_address = email.utils.parseaddr(email_address)
377 except:
378 pass
379
380 return email_address in self.emails
381
26fe80df 382 def activate_email(self, code):
b9c2a52b
MT
383 # Search email by activation code
384 email = self.backend.users._get_user_email("SELECT * FROM users_emails \
385 WHERE user_id = %s AND activated IS FALSE AND activation_code = %s", self.id, code)
26fe80df 386
b9c2a52b 387 if not email:
26fe80df
JS
388 return False
389
b9c2a52b
MT
390 # Activate email address
391 email.activate()
26fe80df
JS
392 return True
393
394 # Te activated flag is useful for LDAP users
395 def add_email(self, email, activated=False):
396 # Check if the email is in use
b9c2a52b 397 if self.backend.users.email_in_use(email):
26fe80df
JS
398 raise ValueError("Email %s is already in use" % email)
399
400 activation_code = None
401 if not activated:
402 activation_code = generate_random_string(64)
403
b9c2a52b
MT
404 user_email = self.backend.users._get_user_email("INSERT INTO users_emails(user_id, email, \
405 \"primary\", activated, activation_code) VALUES(%s, %s, %s, %s, %s) RETURNING *",
406 self.id, email, not self.emails, activated, activation_code)
407 self.emails.append(user_email)
26fe80df 408
b9c2a52b 409 # Send activation email if activation is needed
8d8d65b4 410 if not activated:
b9c2a52b 411 user_email.send_activation_mail()
9137135a 412
b9c2a52b 413 return user_email
9137135a 414
68dd077d
MT
415 def send_template(self, *args, **kwargs):
416 return self.backend.messages.send_template(self, *args, **kwargs)
417
b9c2a52b 418 def is_admin(self):
e304790f 419 return self.data.admin is True
9137135a 420
4947da2d
MT
421 def get_locale(self):
422 return tornado.locale.get(self.data.locale)
423
9137135a 424 def set_locale(self, locale):
b9c2a52b 425 self._set_attribute("locale", locale)
9137135a 426
4947da2d 427 locale = property(get_locale, set_locale)
9137135a 428
f6e6ff79
MT
429 def get_timezone(self, tz=None):
430 if tz is None:
431 tz = self.data.timezone or ""
432
433 try:
434 tz = pytz.timezone(tz)
435 except pytz.UnknownTimeZoneError:
436 tz = pytz.timezone("UTC")
437
438 return tz
439
440 def set_timezone(self, timezone):
441 if not timezone is None:
442 tz = self.get_timezone(timezone)
443 timezone = tz.zone
444
b9c2a52b 445 self._set_attribute("timezone", timezone)
f6e6ff79
MT
446
447 timezone = property(get_timezone, set_timezone)
448
98b826d4
JS
449 def get_password_recovery_code(self):
450 return self.data.password_recovery_code
451
452 def set_password_recovery_code(self, code):
453 self._set_attribute("password_recovery_code", code)
454
455 self._set_attribute("password_recovery_code_expires_at",
456 datetime.datetime.utcnow() + datetime.timedelta(days=1))
457
458 password_recovery_code = property(get_password_recovery_code, set_password_recovery_code)
459
460 def forgot_password(self):
461 log.debug("User %s reqested password recovery" % self.name)
462
463 # We cannot reset te password for ldap users
464 if self.ldap_dn:
465 # Maybe we should send an email with an explanation
466 return
467
468 # Add a recovery code to the database and a timestamp when this code expires
469 self.password_recovery_code = generate_random_string(64)
470
a76becd9
JS
471 # Send an email with the activation code
472 self.send_template("messages/users/password-reset", user=self)
98b826d4 473
9137135a
MT
474 @property
475 def activated(self):
26fe80df 476 return self.data.activated
9137135a 477
ba1958a5
JS
478 @property
479 def deleted(self):
480 return self.data.deleted
481
9137135a 482 @property
b01825aa
MT
483 def registered_at(self):
484 return self.data.registered_at
9137135a
MT
485
486 def gravatar_icon(self, size=128):
9bf767c3
MT
487 h = hashlib.new("md5")
488 if self.email:
489 h.update("%s" % self.email)
490
9137135a 491 # construct the url
9bf767c3 492 gravatar_url = "http://www.gravatar.com/avatar/%s?" % h.hexdigest()
9137135a
MT
493 gravatar_url += urllib.urlencode({'d': "mm", 's': str(size)})
494
495 return gravatar_url
496
b9c2a52b 497 @lazy_property
f6e6ff79 498 def perms(self):
b9c2a52b 499 return self.db.get("SELECT * FROM users_permissions WHERE user_id = %s", self.id)
f6e6ff79
MT
500
501 def has_perm(self, perm):
502 """
503 Returns True if the user has the requested permission.
504 """
505 # Admins have the permission for everything.
506 if self.is_admin():
507 return True
508
f6e6ff79 509 # All others must be checked individually.
8d8d65b4 510 return self.perms.get(perm, False) == True
f6e6ff79 511
b0315eb4
MT
512 @property
513 def sessions(self):
514 return self.backend.sessions._get_sessions("SELECT * FROM sessions \
515 WHERE user_id = %s AND valid_until >= NOW() ORDER BY created_at")
516
b9c2a52b
MT
517
518class UserEmail(base.DataObject):
519 table = "users_emails"
520
521 def __str__(self):
522 return self.email
523
524 def __eq__(self, other):
525 if isinstance(other, self.__class__):
526 return self.id == other.id
527
528 elif isinstance(other, str):
529 return self.email == other
530
531 @lazy_property
532 def user(self):
533 return self.backend.users.get_by_id(self.data.user_id)
534
535 @property
536 def recipient(self):
e50f17fe 537 return "%s <%s>" % (self.user.realname, self.email)
b9c2a52b
MT
538
539 @property
540 def email(self):
541 return self.data.email
542
543 def set_primary(self, primary):
544 self._set_attribute("primary", primary)
545
546 primary = property(lambda s: s.data.primary, set_primary)
547
548 @property
549 def activated(self):
550 return self.data.activated
551
552 def activate(self):
553 self._set_attribute("activated", True)
554 self._set_attribute("activation_code", None)
555
556 @property
557 def activation_code(self):
558 return self.data.activation_code
559
9137135a 560 def send_activation_mail(self):
b9c2a52b
MT
561 if not self.primary:
562 return self.send_email_activation_email()
563
9137135a
MT
564 logging.debug("Sending activation mail to %s" % self.email)
565
68dd077d 566 self.user.send_template("messages/users/account-activation")
9137135a 567
9ad522a5 568 def send_email_activation_mail(self):
b9c2a52b 569 logging.debug("Sending email address activation mail to %s" % self.email)
26fe80df 570
9ad522a5 571 self.user.send_template("messages/users/email-activation", email=self)
26fe80df 572
f6e6ff79
MT
573
574# Some testing code.
575if __name__ == "__main__":
576 for password in ("1234567890", "abcdefghij"):
577 digest = generate_password_hash(password)
578
579 print "%s %s" % (password, digest)
580 print " Matches? %s" % check_password_hash(password, digest)
9137135a 581