root/accountmanagerplugin/0.10/acct_mgr/web_ui.py

Revision 2548, 14.8 kB (checked in by mgood, 1 year ago)

AccountManagerPlugin:

commit 0.10 patch for metanav password reset link (fixes #950)

Line 
1 # -*- coding: utf-8 -*-
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
12 from __future__ import generators
13
14 import random
15 import string
16
17 from trac import perm, util
18 from trac.core import *
19 from trac.config import IntOption
20 from trac.notification import NotificationSystem, NotifyEmail
21 from trac.web import auth
22 from trac.web.api import IAuthenticator
23 from trac.web.main import IRequestHandler
24 from trac.web.chrome import INavigationContributor, ITemplateProvider
25 from trac.util import Markup
26
27 from api import AccountManager
28
29 def _create_user(req, env, check_permissions=True):
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
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.')
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
55     db = env.get_db_cnx()
56     cursor = db.cursor()
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
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()
80
81
82 class PasswordResetNotification(NotifyEmail):
83     template_name = 'reset_password_email.cs'
84     _username = None
85
86     def get_recipients(self, resid):
87         return ([resid],[])
88
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
98     def notify(self, username, password):
99         # save the username for use in `get_smtp_address`
100         self._username = username
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
111 class AccountModule(Component):
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.
115     """
116
117     implements(INavigationContributor, IRequestHandler, ITemplateProvider)
118
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
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
135     #INavigationContributor methods
136     def get_active_navigation_item(self, req):
137         if req.path_info == '/account':
138             return 'account'
139         elif req.path_info == '/reset_password':
140             return 'reset_password'
141
142     def get_navigation_items(self, req):
143         if not self._write_check():
144             return
145         if req.authname != 'anonymous':
146             yield 'metanav', 'account', Markup('<a href="%s">My Account</a>',
147                                                (req.href.account()))
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()))
151
152     # IRequestHandler methods
153     def match_request(self, req):
154         return (req.path_info in ('/account', '/reset_password')
155                 and self._write_check(log=True))
156
157     def process_request(self, req):
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
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
171     def _do_account(self, req):
172         if req.authname == 'anonymous':
173             req.redirect(self.env.href.wiki())
174         action = req.args.get('action')
175         delete_enabled = AccountManager(self.env).supports('delete_user')
176         req.hdf['delete_enabled'] = delete_enabled
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
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
197
198             notifier = PasswordResetNotification(self.env)
199
200             if email != notifier.email_map.get(username):
201                 req.hdf['reset.error'] = 'The email and username do not ' \
202                                          'match a known account.'
203                 return
204
205             new_password = self._random_password()
206             notifier.notify(username, new_password)
207             AccountManager(self.env).set_password(username, new_password)
208             req.hdf['reset.sent_to_email'] = email
209
210     def _random_password(self):
211         return ''.join([random.choice(self._password_chars)
212                         for _ in xrange(self.password_length)])
213
214     def _do_change_password(self, req):
215         user = req.authname
216         mgr = AccountManager(self.env)
217         old_password = req.args.get('old_password')
218         if not old_password:
219             req.hdf['account.save_error'] = 'Old Password cannot be empty.'
220             return
221         if not mgr.check_password(user, old_password):
222             req.hdf['account.save_error'] = 'Old Password is incorrect.'
223             return
224
225         password = req.args.get('password')
226         if not password:
227             req.hdf['account.save_error'] = 'Password cannot be empty.'
228             return
229
230         if password != req.args.get('password_confirm'):
231             req.hdf['account.save_error'] = 'The passwords must match.'
232             return
233
234         mgr.set_password(user, password)
235         req.hdf['account.message'] = 'Password successfully updated.'
236
237     def _do_delete(self, req):
238         user = req.authname
239         mgr = AccountManager(self.env)
240         password = req.args.get('password')
241         if not password:
242             req.hdf['account.delete_error'] = 'Password cannot be empty.'
243             return
244         if not mgr.check_password(user, password):
245             req.hdf['account.delete_error'] = 'Password is incorrect.'
246             return
247
248         mgr.delete_user(user)
249         req.redirect(self.env.href.logout())
250
251     # ITemplateProvider
252    
253     def get_htdocs_dirs(self):
254         """Return the absolute path of a directory containing additional
255         static resources (such as images, style sheets, etc).
256         """
257         return []
258
259     def get_templates_dirs(self):
260         """Return the absolute path of the directory containing the provided
261         ClearSilver templates.
262         """
263         from pkg_resources import resource_filename
264         return [resource_filename(__name__, 'templates')]
265
266
267 class RegistrationModule(Component):
268     """Provides users the ability to register a new account.
269     Requires configuration of the AccountManager module in trac.ini.
270     """
271
272     implements(INavigationContributor, IRequestHandler, ITemplateProvider)
273
274     def __init__(self):
275         self._enable_check(log=True)
276
277     def _enable_check(self, log=False):
278         writable = AccountManager(self.env).supports('set_password')
279         ignore_case = auth.LoginModule(self.env).ignore_case
280         if log:
281             if not writable:
282                 self.log.warn('RegistrationModule is disabled because the '
283                               'password store does not support writing.')
284             if ignore_case:
285                 self.log.warn('RegistrationModule is disabled because '
286                               'ignore_auth_case is enabled in trac.ini.  '
287                               'This setting needs disabled to support '
288                               'registration.')
289         return writable and not ignore_case
290
291     #INavigationContributor methods
292
293     def get_active_navigation_item(self, req):
294         return 'register'
295
296     def get_navigation_items(self, req):
297         if not self._enable_check():
298             return
299         if req.authname == 'anonymous':
300             yield 'metanav', 'register', Markup('<a href="%s">Register</a>',
301                                                 (self.env.href.register()))
302
303     # IRequestHandler methods
304
305     def match_request(self, req):
306         return req.path_info == '/register' and self._enable_check(log=True)
307
308     def process_request(self, req):
309         if req.authname != 'anonymous':
310             req.redirect(self.env.href.account())
311         action = req.args.get('action')
312         if req.method == 'POST' and action == 'create':
313             try:
314                 _create_user(req, self.env)
315             except TracError, e:
316                 req.hdf['registration.error'] = e.message
317             else:
318                 req.redirect(self.env.href.login())
319         req.hdf['reset_password_enabled'] = \
320             (self.env.is_component_enabled(AccountModule)
321              and NotificationSystem(self.env).smtp_enabled)
322
323         return 'register.cs', None
324
325
326     # ITemplateProvider
327    
328     def get_htdocs_dirs(self):
329         """Return the absolute path of a directory containing additional
330         static resources (such as images, style sheets, etc).
331         """
332         return []
333
334     def get_templates_dirs(self):
335         """Return the absolute path of the directory containing the provided
336         ClearSilver templates.
337         """
338         from pkg_resources import resource_filename
339         return [resource_filename(__name__, 'templates')]
340
341
342 def if_enabled(func):
343     def wrap(self, *args, **kwds):
344         if not self.enabled:
345             return None
346         return func(self, *args, **kwds)
347     return wrap
348
349
350 class LoginModule(auth.LoginModule):
351
352     implements(ITemplateProvider)
353
354     def authenticate(self, req):
355         if req.method == 'POST' and req.path_info.startswith('/login'):
356             req.environ['REMOTE_USER'] = self._remote_user(req)
357         return auth.LoginModule.authenticate(self, req)
358     authenticate = if_enabled(authenticate)
359
360     match_request = if_enabled(auth.LoginModule.match_request)
361
362     def process_request(self, req):
363         if req.path_info.startswith('/login') and req.authname == 'anonymous':
364             req.hdf['referer'] = self._referer(req)
365             if AccountModule(self.env).reset_password_enabled:
366                 req.hdf['trac.href.reset_password'] = req.href.reset_password()
367             if req.method == 'POST':
368                 req.hdf['login.error'] = 'Invalid username or password'
369             return 'login.cs', None
370         return auth.LoginModule.process_request(self, req)
371
372     def _do_login(self, req):
373         if not req.remote_user:
374             req.redirect(self.env.abs_href())
375         return auth.LoginModule._do_login(self, req)
376
377     def _remote_user(self, req):
378         user = req.args.get('user')
379         password = req.args.get('password')
380         if not user or not password:
381             return None
382         if AccountManager(self.env).check_password(user, password):
383             return user
384         return None
385
386     def _redirect_back(self, req):
387         """Redirect the user back to the URL she came from."""
388         referer = self._referer(req)
389         if referer and not referer.startswith(req.base_url):
390             # don't redirect to external sites
391             referer = None
392         req.redirect(referer or self.env.abs_href())
393
394     def _referer(self, req):
395         return req.args.get('referer') or req.get_header('Referer')
396
397     def enabled(self):
398         # Users should disable the built-in authentication to use this one
399         return not self.env.is_component_enabled(auth.LoginModule)
400     enabled = property(enabled)
401
402     # ITemplateProvider
403    
404     def get_htdocs_dirs(self):
405         """Return the absolute path of a directory containing additional
406         static resources (such as images, style sheets, etc).
407         """
408         return []
409
410     def get_templates_dirs(self):
411         """Return the absolute path of the directory containing the provided
412         ClearSilver templates.
413         """
414         from pkg_resources import resource_filename
415         return [resource_filename(__name__, 'templates')]
Note: See TracBrowser for help on using the browser.