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