source: accountmanagerplugin/trunk/acct_mgr/register.py @ 12689

Last change on this file since 12689 was 12689, checked in by Steffen Hoffmann, 11 years ago

AccountManagerPlugin: Disregard conflicting, but earlier configured emails addresses, refs #10204 and #10910.

It has been reported, that under certain conditions, i.e. late activation of
email verification, a legal name change in user preferences might be rejected,
if his/her current email address is not unique among all registered accounts.
Consistency checking has been improved lately; anyway I agree, that this
should be handled gracefully, if the email address remains unchanged.

At this occasion it appeared sensible to roll full email checks on input to
user preferences too, causing much more changes than initially intended.

File size: 26.8 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
4# Copyright (C) 2010-2013 Steffen Hoffmann <hoff.st@web.de>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution.
9#
10# Author: Matthew Good <trac@matt-good.net>
11
12import base64
13import re
14import time
15
16from genshi.builder import tag
17from genshi.core import Markup
18from os import urandom
19
20from trac import perm, util
21from trac.core import Component, TracError, implements
22from trac.config import BoolOption, Option
23from trac.env import open_environment
24from trac.web import auth, chrome
25from trac.web.main import IRequestHandler, IRequestFilter
26
27from acct_mgr.api import AccountManager, CommonTemplateProvider
28from acct_mgr.api import IAccountRegistrationInspector
29from acct_mgr.api import _, N_, dgettext, gettext, tag_
30from acct_mgr.model import email_associated, get_user_attribute
31from acct_mgr.model import set_user_attribute
32from acct_mgr.util import containsAny, is_enabled
33
34
35class RegistrationError(TracError):
36    """Exception raised when a registration check fails."""
37
38    def __init__(self, message, *args, **kwargs):
39        """TracError sub-class with extended i18n support.
40
41        It eases error initialization with messages optionally including
42        arguments meant for string substitution after deferred translation.
43        """
44        title = N_("Registration Error")
45        tb = 'show_traceback'
46        # Care for the 2nd TracError standard keyword argument only.
47        show_traceback = tb in kwargs and kwargs.pop(tb, False)
48        super(RegistrationError, self).__init__(message, title, show_traceback)
49        self.msg_args = args
50
51
52class GenericRegistrationInspector(Component):
53    """Generic check class, great for creating simple checks quickly."""
54
55    implements(IAccountRegistrationInspector)
56
57    abstract = True
58
59    @property
60    def doc(self):
61        return N_(self.__class__.__doc__)
62
63    def render_registration_fields(self, req, data):
64        """Emit one or multiple additional fields for registration form built.
65
66        Returns a dict containing a 'required' and/or 'optional' tuple of
67         * Genshi Fragment or valid XHTML markup for registration form
68         * modified or unchanged data object (used to render `register.html`)
69        If the return value is just a single tuple, its fragment or markup
70        will be inserted into the 'required' section.
71        """
72        template = ''
73        return template, data
74
75    def validate_registration(self, req):
76        """Check registration form input.
77
78        Returns a RegistrationError with error message, or None on success.
79        """
80        # Nicer than a plain NotImplementedError.
81        raise NotImplementedError, _(
82            "No check method 'validate_registration' defined in %(module)s",
83            module=self.__class__.__name__)
84
85
86class BasicCheck(GenericRegistrationInspector):
87    """A collection of basic checks.
88
89This includes checking for
90 * emptiness (no user input for username and/or password)
91 * some blacklisted username characters
92 * upper-cased usernames (reserved for Trac permission actions)
93 * some reserved usernames
94 * a username duplicate in configured password stores
95
96''This check is bypassed for requests regarding user's own preferences.''
97"""
98
99    def validate_registration(self, req):
100        if req.path_info == '/prefs':
101            return
102
103        acctmgr = AccountManager(self.env)
104        username = acctmgr.handle_username_casing(
105            req.args.get('username', '').strip())
106
107        if not username:
108            raise RegistrationError(N_("Username cannot be empty."))
109
110        # Always exclude some special characters, i.e.
111        #   ':' can't be used in HtPasswdStore
112        #   '[' and ']' can't be used in SvnServePasswordStore
113        blacklist = acctmgr.username_char_blacklist
114        if containsAny(username, blacklist):
115            pretty_blacklist = ''
116            for c in blacklist:
117                if pretty_blacklist == '':
118                    pretty_blacklist = tag(' \'', tag.b(c), '\'')
119                else:
120                    pretty_blacklist = tag(pretty_blacklist,
121                                           ', \'', tag.b(c), '\'')
122            raise RegistrationError(N_(
123                "The username must not contain any of these characters: %s"),
124                tag.b(pretty_blacklist)
125            )
126
127        # All upper-cased names are reserved for permission action names.
128        if username.isupper():
129            raise RegistrationError(N_(
130                "A username with only upper-cased characters is not allowed.")
131            )
132 
133        # Prohibit some user names, that are important for Trac and therefor
134        # reserved, even if not in the permission store for some reason.
135        if username.lower() in ['anonymous', 'authenticated']:
136            raise RegistrationError(N_("Username %s is not allowed."),
137                                    tag.b(username)
138            )
139
140        # NOTE: A user may exist in a password store but not in the permission
141        #   store.  I.e. this happens, when the user (from the password store)
142        #   never logged in into Trac.  So we have to perform this test here
143        #   and cannot just check for the user being in the permission store.
144        #   And better obfuscate whether an existing user or group name
145        #   was responsible for rejection of this user name.
146        for store_user in acctmgr.get_users():
147            # Do it carefully by disregarding case.
148            if store_user.lower() == username.lower():
149                raise RegistrationError(N_(
150                    "Another account or group already exists, who's name "
151                    "differs from %s only by case or is identical."),
152                    tag.b(username)
153                )
154
155        # Password consistency checks follow.
156        password = req.args.get('password')
157        if not password:
158            raise RegistrationError(N_("Password cannot be empty."))
159        elif password != req.args.get('password_confirm'):
160            raise RegistrationError(N_("The passwords must match."))
161
162
163class BotTrapCheck(GenericRegistrationInspector):
164    """A collection of simple bot checks.
165
166''This check is bypassed for requests by an authenticated user.''
167"""
168
169    reg_basic_token = Option('account-manager', 'register_basic_token', '',
170        doc="A string required as input to pass verification.")
171
172    def render_registration_fields(self, req, data):
173        """Add a hidden text input field to the registration form, and
174        a visible one with mandatory input as well, if token is configured.
175        """
176        if self.reg_basic_token:
177            # Preserve last input for editing on failure instead of typing
178            # everything again.
179            old_value = req.args.get('basic_token', '')
180
181            # TRANSLATOR: Hint for visible bot trap registration input field.
182            hint = tag.p(Markup(_(
183                """Please type [%(token)s] as verification token,
184                exactly replicating everything within the braces.""",
185                token=tag.b(self.reg_basic_token))), class_='hint')
186            insert = tag(
187                tag.label(_("Parole:"),
188                          tag.input(type='text', name='basic_token', size=20,
189                                    class_='textwidget', value=old_value)),
190                          hint
191                )
192        else:
193            insert = None
194        # TRANSLATOR: Registration form hint for hidden bot trap input field.
195        insert = tag(insert,
196                     tag.input(type='hidden', name='sentinel',
197                               title=_("Better do not fill this field."))
198                 )
199        return insert, data
200
201    def validate_registration(self, req):
202        if req.authname and req.authname != 'anonymous':
203            return
204        # Input must be an exact replication of the required token.
205        basic_token = req.args.get('basic_token', '')
206        # Unlike the former, the hidden bot-trap input field must stay empty.
207        keep_empty = req.args.get('sentinel', '')
208        if keep_empty or self.reg_basic_token and \
209                self.reg_basic_token != basic_token:
210            raise RegistrationError(N_("Are you human? If so, try harder!"))
211
212
213class EmailCheck(GenericRegistrationInspector):
214    """A collection of checks for email addresses.
215
216''This check is bypassed, if account verification is disabled.''
217"""
218
219    def render_registration_fields(self, req, data):
220        """Add an email address text input field to the registration form."""
221        # Preserve last input for editing on failure instead of typing
222        # everything again.
223        old_value = req.args.get('email', '').strip()
224        insert = tag.label(_("Email:"),
225                           tag.input(type='text', name='email', size=20,
226                                     class_='textwidget', value=old_value)
227                 )
228        # Deferred import required to aviod circular import dependencies.
229        from acct_mgr.web_ui import AccountModule
230        reset_password = AccountModule(self.env).reset_password_enabled
231        verify_account = is_enabled(self.env, EmailVerificationModule) and \
232                         EmailVerificationModule(self.env).verify_email
233        if verify_account:
234            # TRANSLATOR: Registration form hints for a mandatory input field.
235            hint = tag.p(_("""The email address is required for Trac to send
236                           you a verification token."""), class_='hint')
237            if reset_password:
238                hint = tag(hint, tag.p(_(
239                           """Entering your email address will also enable you
240                           to reset your password if you ever forget it."""),
241                           class_='hint')
242                       )
243            return tag(insert, hint), data
244        elif reset_password:
245            # TRANSLATOR: Registration form hint, if email input is optional.
246            hint = tag.p(_("""Entering your email address will enable you to
247                           reset your password if you ever forget it."""),
248                         class_='hint')
249            return dict(optional=tag(insert, hint)), data
250        else:
251            # Always return the email text input itself as optional field.
252            return dict(optional=insert), data
253
254    def validate_registration(self, req):
255        acctmgr = AccountManager(self.env)
256        email = req.args.get('email', '').strip()
257        if is_enabled(self.env, EmailVerificationModule) and \
258                EmailVerificationModule(self.env).verify_email:
259            # Initial configuration case.
260            if not email and not req.args.get('active'):
261                raise RegistrationError(N_(
262                    "You must specify a valid email address.")
263                )
264            # User preferences case.
265            elif req.path_info == '/prefs' and email == req.session.get('email'):
266                return
267            elif email_associated(self.env, email):
268                raise RegistrationError(N_(
269                    "The email address specified is already in use. "
270                    "Please specify a different one.")
271                )
272
273
274class RegExpCheck(GenericRegistrationInspector):
275    """A collection of checks based on regular expressions.
276
277''It depends on !EmailCheck being enabled too for using it's input field.
278Likewise email checking is bypassed, if account verification is disabled.''
279"""
280
281    username_regexp = Option('account-manager', 'username_regexp',
282        r'(?i)^[A-Z0-9.\-_]{5,}$',
283        doc="A validation regular expression describing new usernames.")
284    email_regexp = Option('account-manager', 'email_regexp',
285        r'(?i)^[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,6}$',
286        doc="A validation regular expression describing new account emails.")
287
288    def validate_registration(self, req):
289        acctmgr = AccountManager(self.env)
290
291        username = acctmgr.handle_username_casing(
292            req.args.get('username', '').strip())
293        if req.path_info != '/prefs' and self.username_regexp != "" and \
294                not re.match(self.username_regexp.strip(), username):
295            raise RegistrationError(N_(
296                "Username %s doesn't match local naming policy."),
297                tag.b(username)
298            )
299
300        email = req.args.get('email', '').strip()
301        if is_enabled(self.env, EmailCheck) and \
302                is_enabled(self.env, EmailVerificationModule) and \
303                EmailVerificationModule(self.env).verify_email:
304            if self.email_regexp.strip() != "" and \
305                    not re.match(self.email_regexp.strip(), email) and \
306                    not req.args.get('active'):
307                raise RegistrationError(N_(
308                    "The email address specified appears to be invalid. "
309                    "Please specify a valid email address.")
310                )
311
312
313class UsernamePermCheck(GenericRegistrationInspector):
314    """Check for usernames referenced in the permission system.
315
316''This check is bypassed for requests by an authenticated user.''
317"""
318
319    def validate_registration(self, req):
320        if req.authname and req.authname != 'anonymous':
321            return
322        username = AccountManager(self.env).handle_username_casing(
323            req.args.get('username', '').strip())
324
325        # NOTE: We can't use 'get_user_permissions(username)' here
326        #   as this always returns a list - even if the user doesn't exist.
327        #   In this case the permissions of "anonymous" are returned.
328        #
329        #   Also note that we can't simply compare the result of
330        #   'get_user_permissions(username)' to some known set of permission,
331        #   i.e. "get_user_permissions('authenticated') as this is always
332        #   false when 'username' is the name of an existing permission group.
333        #
334        #   And again obfuscate whether an existing user or group name
335        #   was responsible for rejection of this username.
336        for (perm_user, perm_action) in \
337                perm.PermissionSystem(self.env).get_all_permissions():
338            if perm_user.lower() == username.lower():
339                raise RegistrationError(N_(
340                    "Another account or group already exists, who's name "
341                    "differs from %s only by case or is identical."),
342                    tag.b(username)
343                )
344
345
346class RegistrationModule(CommonTemplateProvider):
347    """Provides users the ability to register a new account.
348
349    Requires configuration of the AccountManager module in trac.ini.
350    """
351
352    implements(chrome.INavigationContributor, IRequestHandler)
353
354    require_approval = BoolOption(
355        'account-manager', 'require_approval', False,
356        doc="Whether account registration requires administrative approval "
357            "to enable the account or not.")
358
359    def __init__(self):
360        self.acctmgr = AccountManager(self.env)
361        self._enable_check(log=True)
362
363    def _enable_check(self, log=False):
364        env = self.env
365        writable = self.acctmgr.supports('set_password')
366        ignore_case = auth.LoginModule(env).ignore_case
367        if log:
368            if not writable:
369                self.log.warn('RegistrationModule is disabled because the '
370                              'password store does not support writing.')
371            if ignore_case:
372                self.log.debug('RegistrationModule will allow lowercase '
373                               'usernames only and convert them forcefully '
374                               'as required, while \'ignore_auth_case\' is '
375                               'enabled in [trac] section of your trac.ini.')
376        return is_enabled(env, self.__class__) and writable
377
378    enabled = property(_enable_check)
379
380    # INavigationContributor methods
381
382    def get_active_navigation_item(self, req):
383        return 'register'
384
385    def get_navigation_items(self, req):
386        if not self.enabled:
387            return
388        if req.authname == 'anonymous':
389            yield 'metanav', 'register', tag.a(_("Register"),
390                                               href=req.href.register())
391
392    # IRequestHandler methods
393
394    def match_request(self, req):
395        return req.path_info == '/register' and self._enable_check(log=True)
396
397    def process_request(self, req):
398        acctmgr = self.acctmgr
399        if req.authname != 'anonymous':
400            req.redirect(req.href.prefs('account'))
401        action = req.args.get('action')
402        name = req.args.get('name', '').strip()
403        username = acctmgr.handle_username_casing(req.args.get('username',
404                                                               '').strip())
405        data = {
406                '_dgettext': dgettext,
407                  'acctmgr': dict(name=name, username=username),
408         'ignore_auth_case': self.config.getbool('trac', 'ignore_auth_case')
409        }
410        verify_enabled = is_enabled(self.env, EmailVerificationModule) and \
411                         EmailVerificationModule(self.env).verify_email
412        data['verify_account_enabled'] = verify_enabled
413        if req.method == 'POST' and action == 'create':
414            try:
415                # Check request and prime account on success.
416                acctmgr.validate_account(req, True)
417            except RegistrationError, e:
418                # Attempt deferred translation.
419                message = gettext(e.message)
420                # Check for (matching number of) message arguments before
421                #   attempting string substitution.
422                if e.msg_args and \
423                        len(e.msg_args) == len(re.findall('%s', message)):
424                    message = message % e.msg_args
425                chrome.add_warning(req, Markup(message))
426            else:
427                if self.require_approval:
428                    set_user_attribute(self.env, username, 'approval',
429                                       N_('pending'))
430                    # Notify admin user about registration pending for review.
431                    acctmgr._notify('registration_approval_required',
432                                    username)
433                    chrome.add_notice(req, Markup(tag.span(Markup(_(
434                        "Your username has been registered successfully, but "
435                        "your account requires administrative approval. "
436                        "Please proceed according to local policy."))))
437                    )
438                if verify_enabled:
439                    chrome.add_notice(req, Markup(tag.span(Markup(_(
440                        """Your username has been successfully registered but
441                        your account still requires activation. Please login
442                        as user %(user)s, and follow the instructions.""",
443                        user=tag.b(username)))))
444                    )
445                    req.redirect(req.href.login())
446                chrome.add_notice(req, Markup(tag.span(Markup(_(
447                     """Registration has been finished successfully.
448                     You may log in as user %(user)s now.""",
449                     user=tag.b(username)))))
450                )
451                req.redirect(req.href.login())
452        # Collect additional fields from IAccountRegistrationInspector's.
453        fragments = dict(required=[], optional=[])
454        for inspector in acctmgr.register_checks:
455            try:
456                fragment, f_data = inspector.render_registration_fields(req,
457                                                                        data)
458            except TypeError, e:
459                # Add some robustness by logging the most likely errors.
460                self.env.log.warn("%s.render_registration_fields failed: %s"
461                                  % (inspector.__class__.__name__, e))
462                fragment = None
463            if fragment:
464                try:
465                    # Python<2.5: Can't have 'except' and 'finally' in same
466                    #   'try' statement together.
467                    try:
468                        if 'optional' in fragment.keys():
469                            fragments['optional'].append(fragment['optional'])
470                    except AttributeError:
471                        # No dict, just append Genshi Fragment or str/unicode.
472                        fragments['required'].append(fragment)
473                    else:
474                        fragments['required'].append(fragment.get('required',
475                                                                  ''))
476                finally:
477                    data.update(f_data)
478        data['required_fields'] = fragments['required']
479        data['optional_fields'] = fragments['optional']
480        return 'register.html', data, None
481
482
483class EmailVerificationModule(CommonTemplateProvider):
484    """Performs email verification on every new or changed address.
485
486    A working email sender for Trac (!TracNotification or !TracAnnouncer)
487    is strictly required to enable this module's functionality.
488
489    Anonymous users should register and perms should be tweaked, so that
490    anonymous users can't edit wiki pages and change or create tickets.
491    So this email verification code won't be used on them.
492    """
493
494    implements(IRequestFilter, IRequestHandler)
495
496    verify_email = BoolOption(
497        'account-manager', 'verify_email', True,
498        doc="Verify the email address of Trac users.")
499
500    def __init__(self, *args, **kwargs):
501        self.email_enabled = True
502        if self.config.getbool('announcer', 'email_enabled') != True and \
503                self.config.getbool('notification', 'smtp_enabled') != True:
504            self.email_enabled = False
505            if is_enabled(self.env, self.__class__) == True:
506                self.env.log.warn(
507                    ' '.join([self.__class__.__name__, 
508                              "can't work because of missing email setup."])
509                )
510
511    # IRequestFilter methods
512
513    def pre_process_request(self, req, handler):
514        if not req.authname or req.authname == 'anonymous':
515            # Permissions for anonymous users remain unchanged.
516            return handler
517        elif req.path_info == '/prefs' and req.method == 'POST' and \
518                not 'restore' in req.args:
519            try:
520                AccountManager(self.env).validate_account(req)
521                # Check passed without error: New email address seems good.
522            except RegistrationError, e:
523                # Always warn about issues.
524                chrome.add_warning(
525                    req, Markup(gettext(e.message)))
526                # Look, if the issue existed before.
527                attributes = get_user_attribute(self.env, req.authname,
528                                                attribute='email')
529                email = req.authname in attributes and \
530                        attributes[req.authname][1].get('email') or None
531                new_email = req.args.get('email', '').strip()
532                if (email or new_email) and email != new_email:
533                    # Attempt to change email to an empty or invalid
534                    # address detected, resetting to previously stored value.
535                    req.redirect(req.href.prefs(None))
536        if self.verify_email and handler is not self and \
537                'email_verification_token' in req.session and \
538                not req.perm.has_permission('ACCTMGR_ADMIN'):
539            # TRANSLATOR: Your permissions have been limited until you ...
540            link = tag.a(_("verify your email address"),
541                         href=req.href.verify_email()
542                   )
543            # TRANSLATOR: ... verify your email address
544            chrome.add_warning(req, Markup(tag.span(Markup(_(
545                "Your permissions have been limited until you %(link)s.",
546                link=link))))
547            )
548            req.perm = perm.PermissionCache(self.env, 'anonymous')
549        return handler
550
551    def post_process_request(self, req, template, data, content_type):
552        if not req.session.authenticated:
553            # Don't start the email verification procedure on anonymous users.
554            return template, data, content_type
555
556        email = req.session.get('email')
557        # Only send verification if the user entered an email address.
558        if self.verify_email and self.email_enabled is True and email and \
559                email != req.session.get('email_verification_sent_to') and \
560                not req.perm.has_permission('ACCTMGR_ADMIN'):
561            req.session['email_verification_token'] = self._gen_token()
562            req.session['email_verification_sent_to'] = email
563            AccountManager(self.env)._notify(
564                'email_verification_requested', 
565                req.authname, 
566                req.session['email_verification_token']
567            )
568            # TRANSLATOR: An email has been sent to <%(email)s>
569            # with a token to ... (the link label for following message)
570            link = tag.a(_("verify your new email address"),
571                         href=req.href.verify_email()
572                   )
573            # TRANSLATOR: ... verify your new email address
574            chrome.add_notice(req, Markup(tag.span(Markup(_(
575                """An email has been sent to <%(email)s> with a token to
576                %(link)s.""", email=email, link=link))))
577            )
578        return template, data, content_type
579
580    # IRequestHandler methods
581
582    def match_request(self, req):
583        return req.path_info == '/verify_email'
584
585    def process_request(self, req):
586        if not req.session.authenticated:
587            chrome.add_warning(req, Markup(tag.span(tag_(
588                "Please log in to finish email verification procedure.")))
589            )
590            req.redirect(req.href.login())
591        if 'email_verification_token' not in req.session:
592            chrome.add_notice(req, _("Your email is already verified."))
593        elif req.method == 'POST' and 'resend' in req.args:
594            AccountManager(self.env)._notify(
595                'email_verification_requested', 
596                req.authname, 
597                req.session['email_verification_token']
598            )
599            chrome.add_notice(req,
600                    _("A notification email has been resent to <%s>."),
601                    req.session.get('email')
602            )
603        elif 'verify' in req.args:
604            # allow via POST or GET (the latter for email links)
605            if req.args['token'] == req.session['email_verification_token']:
606                del req.session['email_verification_token']
607                chrome.add_notice(
608                    req, _("Thank you for verifying your email address.")
609                )
610                req.redirect(req.href.prefs())
611            else:
612                chrome.add_warning(req, _("Invalid verification token"))
613        data = {'_dgettext': dgettext}
614        if 'token' in req.args:
615            data['token'] = req.args['token']
616        if 'email_verification_token' not in req.session:
617            data['button_state'] = { 'disabled': 'disabled' }
618        return 'verify_email.html', data, None
619
620    def _gen_token(self):
621        return base64.urlsafe_b64encode(urandom(6))
Note: See TracBrowser for help on using the repository browser.