source: accountmanagerplugin/0.10/acct_mgr/web_ui.py

Last change on this file was 10314, checked in by Steffen Hoffmann, 12 years ago

AccountManagerPlugin: Password reset procedure fixed in 0.11 branch.

Due to the severity of possible implications this is backported to 0.10
as well. Stop using any earlier version without adopting a similar solution.

File size: 15.1 KB
RevLine 
[1068]1# -*- coding: utf-8 -*-
[76]2#
3# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
4#
5# "THE BEER-WARE LICENSE" (Revision 42):
6# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
7# can do whatever you want with this stuff. If we meet some day, and you think
8# this stuff is worth it, you can buy me a beer in return.   Matthew Good
9#
10# Author: Matthew Good <trac@matt-good.net>
11
12from __future__ import generators
13
[1085]14import random
15import string
16
[76]17from trac import perm, util
18from trac.core import *
[1085]19from trac.config import IntOption
20from trac.notification import NotificationSystem, NotifyEmail
[132]21from trac.web import auth
22from trac.web.api import IAuthenticator
[76]23from trac.web.main import IRequestHandler
24from trac.web.chrome import INavigationContributor, ITemplateProvider
[304]25from trac.util import Markup
[76]26
27from api import AccountManager
28
[1127]29def _create_user(req, env, check_permissions=True):
[1065]30    mgr = AccountManager(env)
31
32    user = req.args.get('user')
33    if not user:
34        raise TracError('Username cannot be empty.')
35
36    if mgr.has_user(user):
37        raise TracError('Another account with that name already exists.')
38
[1127]39    if check_permissions:
40        # disallow registration of accounts which have existing permissions
41        permission_system = perm.PermissionSystem(env)
42        if permission_system.get_user_permissions(user) != \
43           permission_system.get_user_permissions('authenticated'):
44            raise TracError('Another account with that name already exists.')
[1065]45
46    password = req.args.get('password')
47    if not password:
48        raise TracError('Password cannot be empty.')
49
50    if password != req.args.get('password_confirm'):
51        raise TracError('The passwords must match.')
52
53    mgr.set_password(user, password)
54
[1111]55    db = env.get_db_cnx()
56    cursor = db.cursor()
[1290]57    cursor.execute("SELECT count(*) FROM session "
58                   "WHERE sid=%s AND authenticated=1",
59                   (user,))
60    exists, = cursor.fetchone()
61    if not exists:
62        cursor.execute("INSERT INTO session "
63                       "(sid, authenticated, last_visit) "
64                       "VALUES (%s, 1, 0)",
65                       (user,))
66
[1111]67    for key in ('name', 'email'):
68        value = req.args.get(key)
69        if not value:
70            continue
71        cursor.execute("UPDATE session_attribute SET value=%s "
72                       "WHERE name=%s AND sid=%s AND authenticated=1",
73                       (value, key, user))
74        if not cursor.rowcount:
75            cursor.execute("INSERT INTO session_attribute "
76                           "(sid,authenticated,name,value) "
77                           "VALUES (%s,1,%s,%s)",
78                           (user, key, value))
79    db.commit()
[1065]80
[1111]81
[1085]82class PasswordResetNotification(NotifyEmail):
83    template_name = 'reset_password_email.cs'
[1109]84    _username = None
[1085]85
86    def get_recipients(self, resid):
87        return ([resid],[])
88
[1109]89    def get_smtp_address(self, addr):
90        """Overrides `get_smtp_address` in order to prevent CCing users
91        other than the user whose password is being reset.
92        """
93        if addr == self._username:
94            return NotifyEmail.get_smtp_address(self, addr)
95        else:
96            return None
97
[1085]98    def notify(self, username, password):
[1109]99        # save the username for use in `get_smtp_address`
100        self._username = username
[1085]101        self.hdf['account.username'] = username
102        self.hdf['account.password'] = password
103        self.hdf['login.link'] = self.env.abs_href.login()
104
105        projname = self.config.get('project', 'name')
106        subject = '[%s] Trac password reset for user: %s' % (projname, username)
107
108        NotifyEmail.notify(self, username, subject)
109
110
[76]111class AccountModule(Component):
[1085]112    """Allows users to change their password, reset their password if they've
113    forgotten it, or delete their account.  The settings for the AccountManager
114    module must be set in trac.ini in order to use this.
[76]115    """
116
117    implements(INavigationContributor, IRequestHandler, ITemplateProvider)
118
[1085]119    _password_chars = string.ascii_letters + string.digits
120    password_length = IntOption('account-manager', 'generated_password_length', 8,
121                                'Length of the randomly-generated passwords '
122                                'created when resetting the password for an '
123                                'account.')
124
[1549]125    def __init__(self):
126        self._write_check(log=True) 
127       
128    def _write_check(self, log=False): 
129        writable = AccountManager(self.env).supports('set_password')
130        if not writable and log: 
131            self.log.warn('AccountModule is disabled because the password '
132                          'store does not support writing.')
133        return writable
134
[76]135    #INavigationContributor methods
136    def get_active_navigation_item(self, req):
[2548]137        if req.path_info == '/account':
138            return 'account'
139        elif req.path_info == '/reset_password':
140            return 'reset_password'
[76]141
142    def get_navigation_items(self, req):
[1549]143        if not self._write_check():
144            return
[76]145        if req.authname != 'anonymous':
[304]146            yield 'metanav', 'account', Markup('<a href="%s">My Account</a>',
[1085]147                                               (req.href.account()))
[2548]148        elif self.reset_password_enabled and not LoginModule(self.env).enabled:
149            yield 'metanav', 'reset_password', Markup('<a href="%s">Forgot your password?</a>',
150                                                      (req.href.reset_password()))
[76]151
152    # IRequestHandler methods
153    def match_request(self, req):
[1549]154        return (req.path_info in ('/account', '/reset_password')
155                and self._write_check(log=True))
[76]156
157    def process_request(self, req):
[1085]158        if req.path_info == '/account':
159            self._do_account(req)
160            return 'account.cs', None
161        elif req.path_info == '/reset_password':
162            self._do_reset_password(req)
163            return 'reset_password.cs', None
164
[2548]165    def reset_password_enabled(self):
166        return (self.env.is_component_enabled(AccountModule)
167                and NotificationSystem(self.env).smtp_enabled
168                and self._write_check())
169    reset_password_enabled = property(reset_password_enabled)
170
[1085]171    def _do_account(self, req):
[76]172        if req.authname == 'anonymous':
173            req.redirect(self.env.href.wiki())
174        action = req.args.get('action')
[1549]175        delete_enabled = AccountManager(self.env).supports('delete_user')
176        req.hdf['delete_enabled'] = delete_enabled
[76]177        if req.method == 'POST':
178            if action == 'change_password':
179                self._do_change_password(req)
180            elif action == 'delete':
181                self._do_delete(req)
182
[1085]183    def _do_reset_password(self, req):
184        if req.authname != 'anonymous':
185            req.hdf['reset.logged_in'] = True
186            req.hdf['account_href'] = req.href.account()
187            return
188        if req.method == 'POST':
189            username = req.args.get('username')
190            email = req.args.get('email')
191            if not username:
192                req.hdf['reset.error'] = 'Username is required'
193                return
194            if not email:
195                req.hdf['reset.error'] = 'Email is required'
196                return
[10314]197            for username_, name, email_ in self.env.get_known_users():
198                if username_ == username and email_ == email:
199                    break
200            else:
201                req.hdf['reset.error'] = 'Username and email must match.'
202                return
[1085]203
204            notifier = PasswordResetNotification(self.env)
205
206            if email != notifier.email_map.get(username):
207                req.hdf['reset.error'] = 'The email and username do not ' \
208                                         'match a known account.'
209                return
210
211            new_password = self._random_password()
212            notifier.notify(username, new_password)
213            AccountManager(self.env).set_password(username, new_password)
214            req.hdf['reset.sent_to_email'] = email
215
216    def _random_password(self):
217        return ''.join([random.choice(self._password_chars)
218                        for _ in xrange(self.password_length)])
219
[76]220    def _do_change_password(self, req):
221        user = req.authname
[1709]222        mgr = AccountManager(self.env)
223        old_password = req.args.get('old_password')
224        if not old_password:
225            req.hdf['account.save_error'] = 'Old Password cannot be empty.'
226            return
227        if not mgr.check_password(user, old_password):
228            req.hdf['account.save_error'] = 'Old Password is incorrect.'
229            return
230
[76]231        password = req.args.get('password')
232        if not password:
[1709]233            req.hdf['account.save_error'] = 'Password cannot be empty.'
[76]234            return
235
236        if password != req.args.get('password_confirm'):
[1709]237            req.hdf['account.save_error'] = 'The passwords must match.'
[76]238            return
239
[1709]240        mgr.set_password(user, password)
[76]241        req.hdf['account.message'] = 'Password successfully updated.'
242
243    def _do_delete(self, req):
244        user = req.authname
[1709]245        mgr = AccountManager(self.env)
246        password = req.args.get('password')
247        if not password:
248            req.hdf['account.delete_error'] = 'Password cannot be empty.'
249            return
250        if not mgr.check_password(user, password):
251            req.hdf['account.delete_error'] = 'Password is incorrect.'
252            return
253
254        mgr.delete_user(user)
[76]255        req.redirect(self.env.href.logout())
256
257    # ITemplateProvider
258   
[108]259    def get_htdocs_dirs(self):
[76]260        """Return the absolute path of a directory containing additional
261        static resources (such as images, style sheets, etc).
262        """
[108]263        return []
[76]264
[108]265    def get_templates_dirs(self):
[76]266        """Return the absolute path of the directory containing the provided
267        ClearSilver templates.
268        """
269        from pkg_resources import resource_filename
[108]270        return [resource_filename(__name__, 'templates')]
[76]271
[1085]272
[76]273class RegistrationModule(Component):
274    """Provides users the ability to register a new account.
275    Requires configuration of the AccountManager module in trac.ini.
276    """
277
278    implements(INavigationContributor, IRequestHandler, ITemplateProvider)
279
[1535]280    def __init__(self):
281        self._enable_check(log=True)
282
283    def _enable_check(self, log=False):
[1549]284        writable = AccountManager(self.env).supports('set_password')
[1535]285        ignore_case = auth.LoginModule(self.env).ignore_case
[1549]286        if log:
287            if not writable:
288                self.log.warn('RegistrationModule is disabled because the '
289                              'password store does not support writing.')
290            if ignore_case:
291                self.log.warn('RegistrationModule is disabled because '
292                              'ignore_auth_case is enabled in trac.ini.  '
293                              'This setting needs disabled to support '
294                              'registration.')
295        return writable and not ignore_case
[1535]296
[76]297    #INavigationContributor methods
298
299    def get_active_navigation_item(self, req):
300        return 'register'
301
302    def get_navigation_items(self, req):
[1535]303        if not self._enable_check():
304            return
[76]305        if req.authname == 'anonymous':
[304]306            yield 'metanav', 'register', Markup('<a href="%s">Register</a>',
307                                                (self.env.href.register()))
[76]308
309    # IRequestHandler methods
310
311    def match_request(self, req):
[1535]312        return req.path_info == '/register' and self._enable_check(log=True)
[76]313
314    def process_request(self, req):
315        if req.authname != 'anonymous':
316            req.redirect(self.env.href.account())
317        action = req.args.get('action')
318        if req.method == 'POST' and action == 'create':
[1065]319            try:
320                _create_user(req, self.env)
321            except TracError, e:
322                req.hdf['registration.error'] = e.message
323            else:
324                req.redirect(self.env.href.login())
[1111]325        req.hdf['reset_password_enabled'] = \
326            (self.env.is_component_enabled(AccountModule)
327             and NotificationSystem(self.env).smtp_enabled)
328
[76]329        return 'register.cs', None
330
331
332    # ITemplateProvider
333   
[109]334    def get_htdocs_dirs(self):
[76]335        """Return the absolute path of a directory containing additional
336        static resources (such as images, style sheets, etc).
337        """
[108]338        return []
[76]339
[108]340    def get_templates_dirs(self):
[76]341        """Return the absolute path of the directory containing the provided
342        ClearSilver templates.
343        """
344        from pkg_resources import resource_filename
[108]345        return [resource_filename(__name__, 'templates')]
[76]346
[1085]347
[132]348def if_enabled(func):
349    def wrap(self, *args, **kwds):
350        if not self.enabled:
351            return None
352        return func(self, *args, **kwds)
353    return wrap
354
[1085]355
[132]356class LoginModule(auth.LoginModule):
357
[1064]358    implements(ITemplateProvider)
359
[132]360    def authenticate(self, req):
361        if req.method == 'POST' and req.path_info.startswith('/login'):
[532]362            req.environ['REMOTE_USER'] = self._remote_user(req)
[132]363        return auth.LoginModule.authenticate(self, req)
364    authenticate = if_enabled(authenticate)
365
366    match_request = if_enabled(auth.LoginModule.match_request)
367
368    def process_request(self, req):
369        if req.path_info.startswith('/login') and req.authname == 'anonymous':
370            req.hdf['referer'] = self._referer(req)
[2548]371            if AccountModule(self.env).reset_password_enabled:
[1085]372                req.hdf['trac.href.reset_password'] = req.href.reset_password()
[132]373            if req.method == 'POST':
374                req.hdf['login.error'] = 'Invalid username or password'
375            return 'login.cs', None
376        return auth.LoginModule.process_request(self, req)
377
378    def _do_login(self, req):
379        if not req.remote_user:
380            req.redirect(self.env.abs_href())
381        return auth.LoginModule._do_login(self, req)
382
383    def _remote_user(self, req):
384        user = req.args.get('user')
[1047]385        password = req.args.get('password')
386        if not user or not password:
387            return None
388        if AccountManager(self.env).check_password(user, password):
[132]389            return user
390        return None
391
392    def _redirect_back(self, req):
393        """Redirect the user back to the URL she came from."""
394        referer = self._referer(req)
395        if referer and not referer.startswith(req.base_url):
396            # don't redirect to external sites
397            referer = None
398        req.redirect(referer or self.env.abs_href())
399
400    def _referer(self, req):
401        return req.args.get('referer') or req.get_header('Referer')
402
403    def enabled(self):
404        # Users should disable the built-in authentication to use this one
405        return not self.env.is_component_enabled(auth.LoginModule)
406    enabled = property(enabled)
407
[1064]408    # ITemplateProvider
409   
410    def get_htdocs_dirs(self):
411        """Return the absolute path of a directory containing additional
412        static resources (such as images, style sheets, etc).
413        """
414        return []
415
416    def get_templates_dirs(self):
417        """Return the absolute path of the directory containing the provided
418        ClearSilver templates.
419        """
420        from pkg_resources import resource_filename
421        return [resource_filename(__name__, 'templates')]
422
Note: See TracBrowser for help on using the repository browser.