1# -*- coding: iso-8859-1 -*- 2""" 3 MoinMoin - User Accounts 4 5 This module contains functions to access user accounts (list all users, get 6 some specific user). User instances are used to access the user profile of 7 some specific user (name, password, email, bookmark, trail, settings, ...). 8 9 Some related code is in the userform and userprefs modules. 10 11 TODO: 12 * code is a mixture of highlevel user stuff and lowlevel storage functions, 13 this has to get separated into: 14 * user object highlevel stuff 15 * storage code 16 17 @copyright: 2000-2004 Juergen Hermann <jh@web.de>, 18 2003-2013 MoinMoin:ThomasWaldmann, 19 2010 Michael Foetsch <foetsch@yahoo.com> 20 @license: GNU GPL, see COPYING for details. 21""" 22 23import os, time, codecs, base64 24import hashlib 25import hmac 26from copy import deepcopy 27import md5crypt 28import uuid 29 30try: 31 import crypt 32except ImportError: 33 crypt = None 34 35from MoinMoin import log 36logging = log.getLogger(__name__) 37 38from MoinMoin import config, caching, wikiutil, i18n, events 39from werkzeug.security import safe_str_cmp as safe_str_equal 40from MoinMoin.util import timefuncs, random_string 41from MoinMoin.wikiutil import url_quote_plus 42 43# for efficient lookup <attr> -> userid, we keep an index of this in the cache. 44# the attribute names in here should be uniquely identifying a user. 45CACHED_USER_ATTRS = ['name', 'email', 'jid', 'openids', ] 46 47 48def getUserList(request): 49 """ Get a list of all (numerical) user IDs. 50 51 @param request: current request 52 @rtype: list 53 @return: all user IDs 54 """ 55 import re 56 user_re = re.compile(r'^\d+\.\d+(\.\d+)?$') 57 files = os.listdir(request.cfg.user_dir) 58 userlist = [f for f in files if user_re.match(f)] 59 return userlist 60 61def get_by_filter(request, filter_func): 62 """ Searches for a user with a given filter function 63 64 Be careful: SLOW for big wikis, rather use _getUserIdByKey & related. 65 """ 66 for uid in getUserList(request): 67 theuser = User(request, uid) 68 if filter_func(theuser): 69 return theuser 70 71def get_by_email_address(request, email_address): 72 """ Searches for an user with a particular e-mail address and returns it. """ 73 uid = _getUserIdByKey(request, 'email', email_address, case=False) 74 if uid is not None: 75 return User(request, uid) 76 77def get_by_jabber_id(request, jabber_id): 78 """ Searches for an user with a perticular jabber id and returns it. """ 79 uid = _getUserIdByKey(request, 'jid', jabber_id, case=False) 80 if uid is not None: 81 return User(request, uid) 82 83def _getUserIdByKey(request, key, search, case=True): 84 """ Get the user ID for a specified key/value pair. 85 86 This method must only be called for keys that are 87 guaranteed to be unique. 88 89 @param key: the key to look in 90 @param search: the value to look for 91 @param case: do a case-sensitive lookup? 92 @return the corresponding user ID or None 93 """ 94 if key not in CACHED_USER_ATTRS: 95 raise ValueError("unsupported key, must be in CACHED_USER_ATTRS") 96 if not search: 97 return None 98 cfg_cache_attr = key + "2id" 99 if not case: 100 cfg_cache_attr += "_lower" 101 search = search.lower() 102 cfg = request.cfg 103 try: 104 attr2id = getattr(cfg.cache, cfg_cache_attr) 105 from_disk = False 106 except AttributeError: 107 # no in-memory cache there - initialize it / load it from disk 108 loadLookupCaches(request) 109 attr2id = getattr(cfg.cache, cfg_cache_attr) 110 from_disk = True # we just loaded the stuff from disk 111 uid = attr2id.get(search, None) 112 if uid is None and not from_disk: 113 # we do not have the entry we searched for. 114 # we didn't find it in some in-memory cache, try refreshing these from disk 115 loadLookupCaches(request) 116 attr2id = getattr(cfg.cache, cfg_cache_attr) 117 from_disk = True # we just loaded the stuff from disk 118 uid = attr2id.get(search, None) 119 if uid is None: 120 # we do not have the entry we searched for. 121 # we don't have it in the on-disk cache, cache MISS. 122 # could be because: 123 # a) ok: we have no such search value in the profiles 124 # b) fault: the cache is incoherent with the profiles 125 # c) fault: reading the cache from disk failed, due to an error 126 # d) ok: same as c), but just because no ondisk cache has been built yet 127 rebuildLookupCaches(request) # XXX expensive 128 attr2id = getattr(cfg.cache, cfg_cache_attr) 129 uid = attr2id.get(search, None) 130 return uid 131 132 133def setMemoryLookupCaches(request, cache): 134 """set the in-memory cache from the given cache contents 135 136 @param request: the request object 137 @param cache: either a dict of attrname -> attrcache to set the in-memory cache, 138 or None to delete the in-memory cache. 139 """ 140 for attrname in CACHED_USER_ATTRS: 141 if cache is None: 142 try: 143 delattr(request.cfg.cache, attrname + "2id") 144 except: 145 pass 146 try: 147 delattr(request.cfg.cache, attrname + "2id_lower") 148 except: 149 pass 150 else: 151 setattr(request.cfg.cache, attrname + "2id", cache[attrname]) 152 setattr(request.cfg.cache, attrname + "2id_lower", cache[attrname + "_lower"]) 153 154 155def loadLookupCaches(request): 156 """load lookup cache contents into memory: cfg.cache.XXX2id""" 157 scope, arena, cachekey = 'userdir', 'users', 'lookup' 158 diskcache = caching.CacheEntry(request, arena, cachekey, scope=scope, use_pickle=True) 159 try: 160 cache = diskcache.content() 161 except caching.CacheError: 162 cache = {} 163 for attrname in CACHED_USER_ATTRS: 164 cache[attrname] = {} 165 cache_with_lowercase = addLowerCaseKeys(cache) 166 setMemoryLookupCaches(request, cache_with_lowercase) 167 168 169def rebuildLookupCaches(request): 170 """complete attrs -> userid lookup cache rebuild""" 171 # as there may be thousands of users and reading all profiles is 172 # expensive, we just have 1 lookup cache for all interesting user attrs, 173 # so we only need to read all profiles ONCE to build the cache. 174 scope, arena, key = 'userdir', 'users', 'lookup' 175 diskcache = caching.CacheEntry(request, arena, key, scope=scope, use_pickle=True, do_locking=False) 176 diskcache.lock('w') 177 178 cache = {} 179 for attrname in CACHED_USER_ATTRS: 180 cache[attrname] = {} 181 for userid in getUserList(request): 182 u = User(request, id=userid) 183 if u.valid: 184 for attrname in CACHED_USER_ATTRS: 185 if hasattr(u, attrname): 186 attr2id = cache[attrname] 187 value = getattr(u, attrname) 188 if isinstance(value, list): 189 for val in value: 190 attr2id[val] = userid 191 else: 192 attr2id[value] = userid 193 194 cache_with_lowercase = addLowerCaseKeys(cache) 195 setMemoryLookupCaches(request, cache_with_lowercase) 196 diskcache.update(cache) 197 diskcache.unlock() 198 return cache 199 200 201def clearLookupCaches(request): 202 """kill the userid lookup cache""" 203 # this triggers a rebuild of the cache. 204 setMemoryLookupCaches(request, None) 205 scope, arena, key = 'userdir', 'users', 'lookup' 206 caching.CacheEntry(request, arena, key, scope=scope).remove() 207 208 209def addLowerCaseKeys(cache): 210 """add lowercased lookup keys, so we can support case-insensitive lookup""" 211 c = deepcopy(cache) # we do not want to modify cache itself 212 for attrname in CACHED_USER_ATTRS: 213 attr2id = c[attrname] 214 attr2id_lower = c[attrname + "_lower"] = {} 215 for key, value in attr2id.iteritems(): 216 attr2id_lower[key.lower()] = value 217 return c 218 219 220def getUserId(request, searchName): 221 """ Get the user ID for a specific user NAME. 222 223 @param searchName: the user name to look up 224 @rtype: string 225 @return: the corresponding user ID or None 226 """ 227 return _getUserIdByKey(request, 'name', searchName) 228 229 230def getUserIdByOpenId(request, openid): 231 """ Get the user ID for a specific OpenID. 232 233 @param openid: the openid to look up 234 @rtype: string 235 @return: the corresponding user ID or None 236 """ 237 return _getUserIdByKey(request, 'openids', openid) 238 239 240def superusers(request): 241 """ 242 yields superuser User objects 243 """ 244 for name in request.cfg.superuser: 245 u = User(request, auth_username=name) 246 if u.isSuperUser(): # this checks for addtl. criteria 247 yield u 248 249 250def getUserIdentification(request, username=None): 251 """ Return user name or IP or '<unknown>' indicator. 252 253 @param request: the request object 254 @param username: (optional) user name 255 @rtype: string 256 @return: user name or IP or unknown indicator 257 """ 258 _ = request.getText 259 260 if username is None: 261 username = request.user.name 262 263 return username or (request.cfg.show_hosts and request.remote_addr) or _("<unknown>") 264 265 266def encodePassword(cfg, pwd, salt=None, scheme=None): 267 """ Encode a cleartext password using the default algorithm. 268 269 @param cfg: the wiki config 270 @param pwd: the cleartext password, (unicode) 271 @param salt: the salt for the password (string) or None to generate a 272 random salt. 273 @param scheme: scheme to use (by default will use cfg.password_scheme) 274 @rtype: string 275 @return: the password hash in apache htpasswd compatible encoding, 276 """ 277 if scheme is None: 278 scheme = cfg.password_scheme 279 configured_scheme = True 280 else: 281 configured_scheme = False 282 if scheme == '{PASSLIB}': 283 return '{PASSLIB}' + cfg.cache.pwd_context.encrypt(pwd, salt=salt) 284 elif scheme == '{SSHA}': 285 pwd = pwd.encode('utf-8') 286 if salt is None: 287 salt = random_string(20) 288 assert isinstance(salt, str) 289 hash = hashlib.new('sha1', pwd) 290 hash.update(salt) 291 return '{SSHA}' + base64.encodestring(hash.digest() + salt).rstrip() 292 else: 293 # should never happen as we check the value of cfg.password_scheme 294 raise NotImplementedError 295 296 297class Fault(Exception): 298 """something went wrong""" 299 300class NoSuchUser(Fault): 301 """raised if no such user exists""" 302 303class UserHasNoEMail(Fault): 304 """raised if user has no e-mail address in his profile""" 305 306class MailFailed(Fault): 307 """raised if e-mail sending failed""" 308 309 310def set_password(request, newpass, u=None, uid=None, uname=None, 311 notify=False, skip_invalid=False, subject=None, 312 text_intro=None, text_msg=None, text_data=None): 313 if uid: 314 u = User(request, uid) 315 elif uname: 316 u = User(request, auth_username=uname) 317 if u and u.exists(): 318 if skip_invalid and u.enc_password == '': 319 return 320 if not newpass: 321 # set a invalid password hash 322 u.enc_password = '' 323 else: 324 u.enc_password = encodePassword(request.cfg, newpass) 325 u.save() 326 if not u.email: 327 raise UserHasNoEMail('User profile does not have an E-Mail address (name: %r id: %r)!' % (u.name, u.id)) 328 if notify and not u.disabled: 329 mailok, msg = u.mailAccountData(subject=subject, 330 text_intro=text_intro, text_msg=text_msg, text_data=text_data) 331 if not mailok: 332 raise MailFailed(msg) 333 else: 334 raise NoSuchUser('User does not exist (name: %r id: %r)!' % (u.name, u.id)) 335 336 337def normalizeName(name): 338 """ Make normalized user name 339 340 Prevent impersonating another user with names containing leading, 341 trailing or multiple whitespace, or using invisible unicode 342 characters. 343 344 Prevent creating user page as sub page, because '/' is not allowed 345 in user names. 346 347 Prevent using ':' and ',' which are reserved by acl. 348 349 @param name: user name, unicode 350 @rtype: unicode 351 @return: user name that can be used in acl lines 352 """ 353 username_allowedchars = "'@.-_" # ' for names like O'Brian or email addresses. 354 # "," and ":" must not be allowed (ACL delimiters). 355 # We also allow _ in usernames for nicer URLs. 356 # Strip non alpha numeric characters (except username_allowedchars), keep white space 357 name = ''.join([c for c in name if c.isalnum() or c.isspace() or c in username_allowedchars]) 358 359 # Normalize white space. Each name can contain multiple 360 # words separated with only one space. 361 name = ' '.join(name.split()) 362 363 return name 364 365 366def isValidName(request, name): 367 """ Validate user name 368 369 @param name: user name, unicode 370 """ 371 normalized = normalizeName(name) 372 return (name == normalized) and not wikiutil.isGroupPage(name, request.cfg) 373 374 375def encodeList(items): 376 """ Encode list of items in user data file 377 378 Items are separated by '\t' characters. 379 380 @param items: list unicode strings 381 @rtype: unicode 382 @return: list encoded as unicode 383 """ 384 line = [] 385 for item in items: 386 item = item.strip() 387 if not item: 388 continue 389 line.append(item) 390 391 line = '\t'.join(line) 392 return line 393 394def decodeList(line): 395 """ Decode list of items from user data file 396 397 @param line: line containing list of items, encoded with encodeList 398 @rtype: list of unicode strings 399 @return: list of items in encoded in line 400 """ 401 items = [] 402 for item in line.split('\t'): 403 item = item.strip() 404 if not item: 405 continue 406 items.append(item) 407 return items 408 409def encodeDict(items): 410 """ Encode dict of items in user data file 411 412 Items are separated by '\t' characters. 413 Each item is key:value. 414 415 @param items: dict of unicode:unicode 416 @rtype: unicode 417 @return: dict encoded as unicode 418 """ 419 line = [] 420 for key, value in items.items(): 421 item = u'%s:%s' % (key, value) 422 line.append(item) 423 line = '\t'.join(line) 424 return line 425 426def decodeDict(line): 427 """ Decode dict of key:value pairs from user data file 428 429 @param line: line containing a dict, encoded with encodeDict 430 @rtype: dict 431 @return: dict unicode:unicode items 432 """ 433 items = {} 434 for item in line.split('\t'): 435 item = item.strip() 436 if not item: 437 continue 438 key, value = item.split(':', 1) 439 items[key] = value 440 return items 441 442 443class User: 444 """ A MoinMoin User """ 445 446 def __init__(self, request, id=None, name="", password=None, auth_username="", **kw): 447 """ Initialize User object 448 449 TODO: when this gets refactored, use "uid" not builtin "id" 450 451 @param request: the request object 452 @param id: (optional) user ID 453 @param name: (optional) user name 454 @param password: (optional) user password (unicode) 455 @param auth_username: (optional) already authenticated user name 456 (e.g. when using http basic auth) (unicode) 457 @keyword auth_method: method that was used for authentication, 458 default: 'internal' 459 @keyword auth_attribs: tuple of user object attribute names that are 460 determined by auth method and should not be 461 changeable by preferences, default: (). 462 First tuple element was used for authentication. 463 """ 464 self._cfg = request.cfg 465 self.valid = 0 466 self.id = id 467 self.auth_username = auth_username 468 self.auth_method = kw.get('auth_method', 'internal') 469 self.auth_attribs = kw.get('auth_attribs', ()) 470 self.bookmarks = {} # interwikiname: bookmark 471 472 # create some vars automatically 473 self.__dict__.update(self._cfg.user_form_defaults) 474 475 if name: 476 self.name = name 477 elif auth_username: # this is needed for user autocreate 478 self.name = auth_username 479 480 # create checkbox fields (with default 0) 481 for key, label in self._cfg.user_checkbox_fields: 482 setattr(self, key, self._cfg.user_checkbox_defaults.get(key, 0)) 483 484 self.recoverpass_key = "" 485 486 if password: 487 self.enc_password = encodePassword(self._cfg, password) 488 489 #self.edit_cols = 80 490 self.tz_offset = int(float(self._cfg.tz_offset) * 3600) 491 self.language = "" 492 self.real_language = "" # In case user uses "Browser setting". For language-statistics 493 self._stored = False 494 self.date_fmt = "" 495 self.datetime_fmt = "" 496 self.quicklinks = self._cfg.quicklinks_default 497 self.subscribed_pages = self._cfg.subscribed_pages_default 498 self.email_subscribed_events = self._cfg.email_subscribed_events_default 499 self.jabber_subscribed_events = self._cfg.jabber_subscribed_events_default 500 self.theme_name = self._cfg.theme_default 501 self.editor_default = self._cfg.editor_default 502 self.editor_ui = self._cfg.editor_ui 503 self.last_saved = str(time.time()) 504 505 # attrs not saved to profile 506 self._request = request 507 508 # we got an already authenticated username: 509 check_password = None 510 if not self.id and self.auth_username: 511 self.id = getUserId(request, self.auth_username) 512 if not password is None: 513 check_password = password 514 if self.id: 515 self.load_from_id(check_password) 516 elif self.name: 517 self.id = getUserId(self._request, self.name) 518 if self.id: 519 # no password given should fail 520 self.load_from_id(password or u'') 521 # Still no ID - make new user 522 if not self.id: 523 self.id = self.make_id() 524 if password is not None: 525 self.enc_password = encodePassword(self._cfg, password) 526 self.account_creation_date = str(time.time()) 527 self.account_creation_host = self._request.remote_addr 528 if self._cfg.require_email_verification: 529 self.account_verification = uuid.uuid4() 530 else: 531 self.account_verification = "" 532 533 # "may" so we can say "if user.may.read(pagename):" 534 if self._cfg.SecurityPolicy: 535 self.may = self._cfg.SecurityPolicy(self) 536 else: 537 from MoinMoin.security import Default 538 self.may = Default(self) 539 540 if self.language and not self.language in i18n.wikiLanguages(): 541 self.language = 'en' 542 543 def __repr__(self): 544 return "<%s.%s at 0x%x name:%r valid:%r>" % ( 545 self.__class__.__module__, self.__class__.__name__, 546 id(self), self.name, self.valid) 547 548 def make_id(self): 549 """ make a new unique user id """ 550 #!!! this should probably be a hash of REMOTE_ADDR, HTTP_USER_AGENT 551 # and some other things identifying remote users, then we could also 552 # use it reliably in edit locking 553 from random import randint 554 return "%s.%d" % (str(time.time()), randint(0, 65535)) 555 556 def create_or_update(self, changed=False): 557 """ Create or update a user profile 558 559 @param changed: bool, set this to True if you updated the user profile values 560 """ 561 if not self.valid and not self.disabled or changed: # do we need to save/update? 562 self.save() # yes, create/update user profile 563 564 def __filename(self): 565 """ Get filename of the user's file on disk 566 567 @rtype: string 568 @return: full path and filename of user account file 569 """ 570 return os.path.join(self._cfg.user_dir, self.id or "...NONE...") 571 572 def exists(self): 573 """ Do we have a user account for this user? 574 575 @rtype: bool 576 @return: true, if we have a user account 577 """ 578 return os.path.exists(self.__filename()) 579 580 def remove(self): 581 """ Remove user profile from disk """ 582 os.remove(self.__filename()) 583 584 def load_from_id(self, password=None): 585 """ Load user account data from disk. 586 587 Can only load user data if the id number is already known. 588 589 This loads all member variables, except "id" and "valid" and 590 those starting with an underscore. 591 592 @param password: If not None, then the given password must match the 593 password in the user account file. 594 """ 595 if not self.exists(): 596 return 597 598 data = codecs.open(self.__filename(), "r", config.charset).readlines() 599 user_data = {'enc_password': ''} 600 for line in data: 601 if line[0] == '#': 602 continue 603 604 try: 605 key, val = line.strip().split('=', 1) 606 if key not in self._cfg.user_transient_fields and key[0] != '_': 607 # Decode list values 608 if key.endswith('[]'): 609 key = key[:-2] 610 val = decodeList(val) 611 # Decode dict values 612 elif key.endswith('{}'): 613 key = key[:-2] 614 val = decodeDict(val) 615 # for compatibility reading old files, keep these explicit 616 # we will store them with [] appended 617 elif key in ['quicklinks', 'subscribed_pages', 'subscribed_events']: 618 val = decodeList(val) 619 user_data[key] = val 620 except ValueError: 621 pass 622 623 # Validate data from user file. In case we need to change some 624 # values, we set 'changed' flag, and later save the user data. 625 changed = 0 626 627 if password is not None: 628 # Check for a valid password, possibly changing storage 629 valid, changed = self._validatePassword(user_data, password) 630 if not valid: 631 return 632 633 # Remove ignored checkbox values from user data 634 for key, label in self._cfg.user_checkbox_fields: 635 if key in user_data and key in self._cfg.user_checkbox_disable: 636 del user_data[key] 637 638 # Copy user data into user object 639 for key, val in user_data.items(): 640 vars(self)[key] = val 641 642 self.tz_offset = int(self.tz_offset) 643 644 # Remove old unsupported attributes from user data file. 645 remove_attributes = ['passwd', 'show_emoticons'] 646 for attr in remove_attributes: 647 if hasattr(self, attr): 648 delattr(self, attr) 649 changed = 1 650 651 # make sure checkboxes are boolean 652 for key, label in self._cfg.user_checkbox_fields: 653 try: 654 setattr(self, key, int(getattr(self, key))) 655 except ValueError: 656 setattr(self, key, 0) 657 658 # convert (old) hourly format to seconds 659 if -24 <= self.tz_offset and self.tz_offset <= 24: 660 self.tz_offset = self.tz_offset * 3600 661 662 if not self.disabled: 663 self.valid = 1 664 665 # Mark this user as stored so saves don't send 666 # the "user created" event 667 self._stored = True 668 669 # If user data has been changed, save fixed user data. 670 if changed: 671 self.save() 672 673 def _validatePassword(self, data, password): 674 """ 675 Check user password. 676 677 This is a private method and should not be used by clients. 678 679 @param data: dict with user data (from storage) 680 @param password: password to verify [unicode] 681 @rtype: 2 tuple (bool, bool) 682 @return: password is valid, enc_password changed 683 """ 684 epwd = data['enc_password'] 685 686 # If we have no password set, we don't accept login with username 687 if not epwd: 688 return False, False 689 690 # require non empty password 691 if not password: 692 return False, False 693 694 password_correct = recompute_hash = False 695 wanted_scheme = self._cfg.password_scheme 696 697 # Check password and upgrade weak hashes to strong default algorithm: 698 for scheme in config.password_schemes_supported: 699 if epwd.startswith(scheme): 700 is_passlib = False 701 d = epwd[len(scheme):] 702 703 if scheme == '{PASSLIB}': 704 # a password hash to be checked by passlib library code 705 if not self._cfg.passlib_support: 706 logging.error('in user profile %r, password hash with {PASSLIB} scheme encountered, but passlib_support is False' % (self.id, )) 707 else: 708 pwd_context = self._cfg.cache.pwd_context 709 try: 710 password_correct = pwd_context.verify(password, d) 711 except ValueError, err: 712 # can happen for unknown scheme 713 logging.error('in user profile %r, verifying the passlib pw hash crashed [%s]' % (self.id, str(err))) 714 if password_correct: 715 # check if we need to recompute the hash. this is needed if either the 716 # passlib hash scheme / hash params changed or if we shall change to a 717 # builtin hash scheme (not recommended): 718 recompute_hash = pwd_context.hash_needs_update(d) or wanted_scheme != '{PASSLIB}' 719 720 else: 721 # a password hash to be checked by legacy, builtin code 722 if scheme == '{SSHA}': 723 d = base64.decodestring(d) 724 salt = d[20:] 725 hash = hashlib.new('sha1', password.encode('utf-8')) 726 hash.update(salt) 727 enc = base64.encodestring(hash.digest() + salt).rstrip() 728 729 elif scheme == '{SHA}': 730 enc = base64.encodestring( 731 hashlib.new('sha1', password.encode('utf-8')).digest()).rstrip() 732 733 elif scheme == '{APR1}': 734 # d is of the form "$apr1$<salt>$<hash>" 735 salt = d.split('$')[2] 736 enc = md5crypt.apache_md5_crypt(password.encode('utf-8'), 737 salt.encode('ascii')) 738 elif scheme == '{MD5}': 739 # d is of the form "$1$<salt>$<hash>" 740 salt = d.split('$')[2] 741 enc = md5crypt.unix_md5_crypt(password.encode('utf-8'), 742 salt.encode('ascii')) 743 elif scheme == '{DES}': 744 if crypt is None: 745 return False, False 746 # d is 2 characters salt + 11 characters hash 747 salt = d[:2] 748 enc = crypt.crypt(password.encode('utf-8'), salt.encode('ascii')) 749 750 else: 751 logging.error('in user profile %r, password hash with unknown scheme encountered: %r' % (self.id, scheme)) 752 raise NotImplementedError 753 754 if safe_str_equal(epwd, scheme + enc): 755 password_correct = True 756 recompute_hash = scheme != wanted_scheme 757 758 if recompute_hash: 759 data['enc_password'] = encodePassword(self._cfg, password) 760 return password_correct, recompute_hash 761 762 # unsupported algorithm 763 return False, False 764 765 def persistent_items(self): 766 """ items we want to store into the user profile """ 767 return [(key, value) for key, value in vars(self).items() 768 if key not in self._cfg.user_transient_fields and key[0] != '_'] 769 770 def save(self): 771 """ Save user account data to user account file on disk. 772 773 This saves all member variables, except "id" and "valid" and 774 those starting with an underscore. 775 """ 776 if not self.id: 777 return 778 779 user_dir = self._cfg.user_dir 780 if not os.path.exists(user_dir): 781 os.makedirs(user_dir) 782 783 self.last_saved = str(time.time()) 784 785 # !!! should write to a temp file here to avoid race conditions, 786 # or even better, use locking 787 788 data = codecs.open(self.__filename(), "w", config.charset) 789 data.write("# Data saved '%s' for id '%s'\n" % ( 790 time.strftime(self._cfg.datetime_fmt, time.localtime(time.time())), 791 self.id)) 792 attrs = self.persistent_items() 793 attrs.sort() 794 for key, value in attrs: 795 # Encode list values 796 if isinstance(value, list): 797 key += '[]' 798 value = encodeList(value) 799 # Encode dict values 800 elif isinstance(value, dict): 801 key += '{}' 802 value = encodeDict(value) 803 line = u"%s=%s" % (key, unicode(value)) 804 line = line.replace('\n', ' ').replace('\r', ' ') # no lineseps 805 data.write(line + '\n') 806 data.close() 807 808 if not self.disabled: 809 self.valid = 1 810 811 self.updateLookupCaches() 812 813 if not self._stored: 814 self._stored = True 815 event = events.UserCreatedEvent(self._request, self) 816 events.send_event(event) 817 818 # update page subscriber's cache after saving user preferences 819 self.updatePageSubCache() 820 821 # ----------------------------------------------------------------- 822 # Time and date formatting 823 824 def getTime(self, tm): 825 """ Get time in user's timezone. 826 827 @param tm: time (UTC UNIX timestamp) 828 @rtype: int 829 @return: tm tuple adjusted for user's timezone 830 """ 831 return timefuncs.tmtuple(tm + self.tz_offset) 832 833 834 def getFormattedDate(self, tm): 835 """ Get formatted date adjusted for user's timezone. 836 837 @param tm: time (UTC UNIX timestamp) 838 @rtype: string 839 @return: formatted date, see cfg.date_fmt 840 """ 841 date_fmt = self.date_fmt or self._cfg.date_fmt 842 return time.strftime(date_fmt, self.getTime(tm)) 843 844 845 def getFormattedDateTime(self, tm): 846 """ Get formatted date and time adjusted for user's timezone. 847 848 @param tm: time (UTC UNIX timestamp) 849 @rtype: string 850 @return: formatted date and time, see cfg.datetime_fmt 851 """ 852 datetime_fmt = self.datetime_fmt or self._cfg.datetime_fmt 853 return time.strftime(datetime_fmt, self.getTime(tm)) 854 855 # ----------------------------------------------------------------- 856 # Bookmark 857 858 def setBookmark(self, tm): 859 """ Set bookmark timestamp. 860 861 @param tm: timestamp 862 """ 863 if self.valid: 864 interwikiname = self._cfg.interwikiname or u'' 865 bookmark = unicode(tm) 866 self.bookmarks[interwikiname] = bookmark 867 self.save() 868 869 def getBookmark(self): 870 """ Get bookmark timestamp. 871 872 @rtype: int 873 @return: bookmark timestamp or None 874 """ 875 bm = None 876 interwikiname = self._cfg.interwikiname or u'' 877 if self.valid: 878 try: 879 bm = int(self.bookmarks[interwikiname]) 880 except (ValueError, KeyError): 881 pass 882 return bm 883 884 def delBookmark(self): 885 """ Removes bookmark timestamp. 886 887 @rtype: int 888 @return: 0 on success, 1 on failure 889 """ 890 interwikiname = self._cfg.interwikiname or u'' 891 if self.valid: 892 try: 893 del self.bookmarks[interwikiname] 894 except KeyError: 895 return 1 896 self.save() 897 return 0 898 return 1 899 900 # ----------------------------------------------------------------- 901 # Subscribe 902 903 def getSubscriptionList(self): 904 """ Get list of pages this user has subscribed to 905 906 @rtype: list 907 @return: pages this user has subscribed to 908 """ 909 return self.subscribed_pages 910 911 def isSubscribedTo(self, pagelist): 912 """ Check if user subscription matches any page in pagelist. 913 914 The subscription list may contain page names or interwiki page 915 names. e.g 'Page Name' or 'WikiName:Page_Name' 916 917 @param pagelist: list of pages to check for subscription 918 @rtype: bool 919 @return: if user is subscribed any page in pagelist 920 """ 921 if not self.valid: 922 return False 923 924 import re 925 # Create a new list with both names and interwiki names. 926 pages = pagelist[:] 927 if self._cfg.interwikiname: 928 pages += [self._interWikiName(pagename) for pagename in pagelist] 929 # Create text for regular expression search 930 text = '\n'.join(pages) 931 932 for pattern in self.getSubscriptionList(): 933 # Try simple match first 934 if pattern in pages: 935 return True 936 # Try regular expression search, skipping bad patterns 937 try: 938 pattern = re.compile(r'^%s$' % pattern, re.M) 939 except re.error: 940 continue 941 if pattern.search(text): 942 return True 943 944 return False 945 946 def subscribe(self, pagename): 947 """ Subscribe to a wiki page. 948 949 To enable shared farm users, if the wiki has an interwiki name, 950 page names are saved as interwiki names. 951 952 @param pagename: name of the page to subscribe 953 @type pagename: unicode 954 @rtype: bool 955 @return: if page was subscribed 956 """ 957 if self._cfg.interwikiname: 958 pagename = self._interWikiName(pagename) 959 960 if pagename not in self.subscribed_pages: 961 self.subscribed_pages.append(pagename) 962 self.save() 963 964 # Send a notification 965 from MoinMoin.events import SubscribedToPageEvent, send_event 966 e = SubscribedToPageEvent(self._request, pagename, self.name) 967 send_event(e) 968 return True 969 970 return False 971 972 def unsubscribe(self, pagename): 973 """ Unsubscribe a wiki page. 974 975 Try to unsubscribe by removing non-interwiki name (leftover 976 from old use files) and interwiki name from the subscription 977 list. 978 979 Its possible that the user will be subscribed to a page by more 980 then one pattern. It can be both pagename and interwiki name, 981 or few patterns that all of them match the page. Therefore, we 982 must check if the user is still subscribed to the page after we 983 try to remove names from the list. 984 985 @param pagename: name of the page to subscribe 986 @type pagename: unicode 987 @rtype: bool 988 @return: if unsubscrieb was successful. If the user has a 989 regular expression that match, it will always fail. 990 """ 991 changed = False 992 if pagename in self.subscribed_pages: 993 self.subscribed_pages.remove(pagename) 994 changed = True 995 996 interWikiName = self._interWikiName(pagename) 997 if interWikiName and interWikiName in self.subscribed_pages: 998 self.subscribed_pages.remove(interWikiName) 999 changed = True 1000 1001 if changed: 1002 self.save() 1003 return not self.isSubscribedTo([pagename]) 1004 1005 def updatePageSubCache(self): 1006 """ When a user profile is saved, we update the page subscriber's cache """ 1007 1008 scope, arena, key = 'userdir', 'users', 'pagesubscriptions' 1009 1010 cache = caching.CacheEntry(self._request, arena=arena, key=key, scope=scope, use_pickle=True, do_locking=False) 1011 if not cache.exists(): 1012 return # if no cache file exists, just don't do anything 1013 1014 cache.lock('w') 1015 page_sub = cache.content() 1016 1017 # we only store entries for valid users with some page subscriptions 1018 if self.valid and self.subscribed_pages: 1019 page_sub[self.id] = { 1020 'name': self.name, 1021 'email': self.email, 1022 'subscribed_pages': self.subscribed_pages, 1023 } 1024 elif page_sub.get(self.id): 1025 del page_sub[self.id] 1026 1027 cache.update(page_sub) 1028 cache.unlock() 1029 1030 def updateLookupCaches(self): 1031 """ When a user profile is saved, we update the userid lookup caches """ 1032 1033 scope, arena, key = 'userdir', 'users', 'lookup' 1034 1035 diskcache = caching.CacheEntry(self._request, arena=arena, key=key, scope=scope, use_pickle=True, do_locking=False) 1036 if not diskcache.exists(): 1037 return # if no cache file exists, just don't do anything 1038 1039 diskcache.lock('w') 1040 cache = diskcache.content() 1041 userid = self.id 1042 1043 # first remove all old entries mapping to this userid: 1044 for attrname in CACHED_USER_ATTRS: 1045 attr2id = cache[attrname] 1046 for key, value in attr2id.items(): 1047 if value == userid: 1048 del attr2id[key] 1049 1050 # then, if user is valid, update with the current attr values: 1051 if self.valid: 1052 for attrname in CACHED_USER_ATTRS: 1053 if hasattr(self, attrname): 1054 value = getattr(self, attrname) 1055 if value: 1056 # we do not store empty values, likely not unique 1057 attr2id = cache[attrname] 1058 if isinstance(value, list): 1059 for val in value: 1060 attr2id[val] = userid 1061 else: 1062 attr2id[value] = userid 1063 1064 cache_with_lowercase = addLowerCaseKeys(cache) 1065 setMemoryLookupCaches(self._request, cache_with_lowercase) 1066 diskcache.update(cache) 1067 diskcache.unlock() 1068 1069 # ----------------------------------------------------------------- 1070 # Quicklinks 1071 1072 def getQuickLinks(self): 1073 """ Get list of pages this user wants in the navibar 1074 1075 @rtype: list 1076 @return: quicklinks from user account 1077 """ 1078 return self.quicklinks 1079 1080 def isQuickLinkedTo(self, pagelist): 1081 """ Check if user quicklink matches any page in pagelist. 1082 1083 @param pagelist: list of pages to check for quicklinks 1084 @rtype: bool 1085 @return: if user has quicklinked any page in pagelist 1086 """ 1087 if not self.valid: 1088 return False 1089 1090 for pagename in pagelist: 1091 if pagename in self.quicklinks: 1092 return True 1093 interWikiName = self._interWikiName(pagename) 1094 if interWikiName and interWikiName in self.quicklinks: 1095 return True 1096 1097 return False 1098 1099 def addQuicklink(self, pagename): 1100 """ Adds a page to the user quicklinks 1101 1102 If the wiki has an interwiki name, all links are saved as 1103 interwiki names. If not, as simple page name. 1104 1105 @param pagename: page name 1106 @type pagename: unicode 1107 @rtype: bool 1108 @return: if pagename was added 1109 """ 1110 changed = False 1111 interWikiName = self._interWikiName(pagename) 1112 if interWikiName: 1113 if pagename in self.quicklinks: 1114 self.quicklinks.remove(pagename) 1115 changed = True 1116 if interWikiName not in self.quicklinks: 1117 self.quicklinks.append(interWikiName) 1118 changed = True 1119 else: 1120 if pagename not in self.quicklinks: 1121 self.quicklinks.append(pagename) 1122 changed = True 1123 1124 if changed: 1125 self.save() 1126 return changed 1127 1128 def removeQuicklink(self, pagename): 1129 """ Remove a page from user quicklinks 1130 1131 Remove both interwiki and simple name from quicklinks. 1132 1133 @param pagename: page name 1134 @type pagename: unicode 1135 @rtype: bool 1136 @return: if pagename was removed 1137 """ 1138 changed = False 1139 interWikiName = self._interWikiName(pagename) 1140 if interWikiName and interWikiName in self.quicklinks: 1141 self.quicklinks.remove(interWikiName) 1142 changed = True 1143 if pagename in self.quicklinks: 1144 self.quicklinks.remove(pagename) 1145 changed = True 1146 1147 if changed: 1148 self.save() 1149 return changed 1150 1151 def _interWikiName(self, pagename): 1152 """ Return the inter wiki name of a page name 1153 1154 @param pagename: page name 1155 @type pagename: unicode 1156 """ 1157 if not self._cfg.interwikiname: 1158 return None 1159 1160 return "%s:%s" % (self._cfg.interwikiname, pagename) 1161 1162 # ----------------------------------------------------------------- 1163 # Trail 1164 1165 def _wantTrail(self): 1166 return (not self.valid and self._request.cfg.cookie_lifetime[0] # anon sessions enabled 1167 or self.valid and (self.show_page_trail or self.remember_last_visit)) # logged-in session 1168 1169 def addTrail(self, page): 1170 """ Add page to trail. 1171 1172 @param page: the page (object) to add to the trail 1173 """ 1174 if self._wantTrail(): 1175 pagename = page.page_name 1176 # Add only existing pages that the user may read 1177 if not (page.exists() and self._request.user.may.read(pagename)): 1178 return 1179 1180 # Save interwiki links internally 1181 if self._cfg.interwikiname: 1182 pagename = self._interWikiName(pagename) 1183 1184 trail = self._request.session.get('trail', []) 1185 trail_current = trail[:] 1186 1187 # Don't append tail to trail ;) 1188 if trail and trail[-1] == pagename: 1189 return 1190 1191 # Append new page, limiting the length 1192 trail = [p for p in trail if p != pagename] 1193 pagename_stripped = pagename.strip() 1194 if pagename_stripped: 1195 trail.append(pagename_stripped) 1196 trail = trail[-self._cfg.trail_size:] 1197 if trail != trail_current: 1198 # we only modify the session if we have something different: 1199 self._request.session['trail'] = trail 1200 1201 def getTrail(self): 1202 """ Return list of recently visited pages. 1203 1204 @rtype: list 1205 @return: pages in trail 1206 """ 1207 if self._wantTrail(): 1208 trail = self._request.session.get('trail', []) 1209 else: 1210 trail = [] 1211 return trail 1212 1213 # ----------------------------------------------------------------- 1214 # Other 1215 1216 def isCurrentUser(self): 1217 """ Check if this user object is the user doing the current request """ 1218 return self._request.user.name == self.name 1219 1220 def isSuperUser(self): 1221 """ Check if this user is superuser """ 1222 if not self.valid: 1223 return False 1224 request = self._request 1225 if request.cfg.DesktopEdition and request.remote_addr == '127.0.0.1': 1226 # the DesktopEdition gives any local user superuser powers 1227 return True 1228 superusers = request.cfg.superuser 1229 assert isinstance(superusers, (list, tuple)) 1230 return self.name and self.name in superusers 1231 1232 def host(self): 1233 """ Return user host """ 1234 _ = self._request.getText 1235 host = self.isCurrentUser() and self._cfg.show_hosts and self._request.remote_addr 1236 return host or _("<unknown>") 1237 1238 def wikiHomeLink(self): 1239 """ Return wiki markup usable as a link to the user homepage, 1240 it doesn't matter whether it already exists or not. 1241 """ 1242 wikiname, pagename = wikiutil.getInterwikiHomePage(self._request, self.name) 1243 if wikiname == 'Self': 1244 if wikiutil.isStrictWikiname(self.name): 1245 markup = pagename 1246 else: 1247 markup = '[[%s]]' % pagename 1248 else: 1249 markup = '[[%s:%s]]' % (wikiname, pagename) 1250 return markup 1251 1252 def signature(self): 1253 """ Return user signature using wiki markup 1254 1255 Users sign with a link to their homepage. 1256 Visitors return their host address. 1257 1258 TODO: The signature use wiki format only, for example, it will 1259 not create a link when using rst format. It will also break if 1260 we change wiki syntax. 1261 """ 1262 if self.name: 1263 return self.wikiHomeLink() 1264 else: 1265 return self.host() 1266 1267 def generate_recovery_token(self): 1268 key = random_string(64, "abcdefghijklmnopqrstuvwxyz0123456789") 1269 msg = str(int(time.time())) 1270 h = hmac.new(key, msg, digestmod=hashlib.sha1).hexdigest() 1271 self.recoverpass_key = key 1272 self.save() 1273 return msg + '-' + h 1274 1275 def apply_recovery_token(self, tok, newpass): 1276 parts = tok.split('-') 1277 if len(parts) != 2: 1278 return False 1279 try: 1280 stamp = int(parts[0]) 1281 except ValueError: 1282 return False 1283 lifetime = self._request.cfg.recovery_token_lifetime * 3600 1284 if time.time() > stamp + lifetime: 1285 return False 1286 # check hmac 1287 # key must be of type string 1288 h = hmac.new(str(self.recoverpass_key), str(stamp), digestmod=hashlib.sha1).hexdigest() 1289 if not safe_str_equal(h, parts[1]): 1290 return False 1291 self.recoverpass_key = "" 1292 self.enc_password = encodePassword(self._cfg, newpass) 1293 self.save() 1294 return True 1295 1296 def mailAccountData(self, cleartext_passwd=None, 1297 subject=None, 1298 text_intro=None, text_msg=None, text_data=None): 1299 """ Mail a user who forgot his password a message enabling 1300 him to login again. 1301 """ 1302 from MoinMoin.mail import sendmail 1303 _ = self._request.getText 1304 1305 if not self.email: 1306 return False, "user has no E-Mail address in his profile." 1307 1308 tok = self.generate_recovery_token() 1309 1310 if subject is None: 1311 subject = _('[%(sitename)s] Your wiki account data') 1312 subject = subject % dict(sitename=self._cfg.sitename or "Wiki") 1313 if text_intro is None: 1314 text_intro = '' 1315 if text_msg is None: 1316 text_msg = _("""\ 1317Somebody has requested to email you a password recovery token. 1318 1319If you lost your password, please go to the password reset URL below or 1320go to the password recovery page again and enter your username and the 1321recovery token. 1322""") 1323 if text_data is None: 1324 text_data = _("""\ 1325Login Name: %s 1326 1327Password recovery token: %s 1328 1329Password reset URL: %s?action=recoverpass&name=%s&token=%s 1330""") 1331 # note: text_intro is for custom stuff, we do not have i18n for it anyway 1332 text = text_intro + '\n' + _(text_msg) + '\n' + _(text_data) % ( 1333 self.name, 1334 tok, 1335 self._request.url, # use full url, including current page 1336 url_quote_plus(self.name), 1337 tok, ) 1338 1339 mailok, msg = sendmail.sendmail(self._request, [self.email], subject, 1340 text, mail_from=self._cfg.mail_from) 1341 return mailok, msg 1342 1343