~ubuntu-branches/ubuntu/raring/trac-accountmanager/raring

« back to all changes in this revision

Viewing changes to acct_mgr/web_ui.py

  • Committer: Bazaar Package Importer
  • Author(s): Leo Costela
  • Date: 2008-07-15 17:21:11 UTC
  • Revision ID: james.westby@ubuntu.com-20080715172111-ool7wmy573gqolfr
Tags: upstream-0.2.1~vcs20080715
ImportĀ upstreamĀ versionĀ 0.2.1~vcs20080715

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
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
import base64
 
13
import os
 
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.prefs import IPreferencePanelProvider
 
22
from trac.web import auth
 
23
from trac.web.api import IAuthenticator
 
24
from trac.web.main import IRequestHandler, IRequestFilter
 
25
from trac.web import chrome
 
26
from trac.web.chrome import INavigationContributor, ITemplateProvider
 
27
from genshi.builder import tag
 
28
 
 
29
from api import AccountManager
 
30
from acct_mgr.util import urandom
 
31
 
 
32
def _create_user(req, env, check_permissions=True):
 
33
    mgr = AccountManager(env)
 
34
 
 
35
    user = req.args.get('user')
 
36
    if not user:
 
37
        raise TracError('Username cannot be empty.')
 
38
 
 
39
    if mgr.has_user(user):
 
40
        raise TracError('Another account with that name already exists.')
 
41
 
 
42
    if check_permissions:
 
43
        # disallow registration of accounts which have existing permissions
 
44
        permission_system = perm.PermissionSystem(env)
 
45
        if permission_system.get_user_permissions(user) != \
 
46
           permission_system.get_user_permissions('authenticated'):
 
47
            raise TracError('Another account with that name already exists.')
 
48
 
 
49
    password = req.args.get('password')
 
50
    if not password:
 
51
        raise TracError('Password cannot be empty.')
 
52
 
 
53
    if password != req.args.get('password_confirm'):
 
54
        raise TracError('The passwords must match.')
 
55
 
 
56
    mgr.set_password(user, password)
 
57
 
 
58
    db = env.get_db_cnx()
 
59
    cursor = db.cursor()
 
60
    cursor.execute("SELECT count(*) FROM session "
 
61
                   "WHERE sid=%s AND authenticated=1",
 
62
                   (user,))
 
63
    exists, = cursor.fetchone()
 
64
    if not exists:
 
65
        cursor.execute("INSERT INTO session "
 
66
                       "(sid, authenticated, last_visit) "
 
67
                       "VALUES (%s, 1, 0)",
 
68
                       (user,))
 
69
 
 
70
    for key in ('name', 'email'):
 
71
        value = req.args.get(key)
 
72
        if not value:
 
73
            continue
 
74
        cursor.execute("UPDATE session_attribute SET value=%s "
 
75
                       "WHERE name=%s AND sid=%s AND authenticated=1",
 
76
                       (value, key, user))
 
77
        if not cursor.rowcount:
 
78
            cursor.execute("INSERT INTO session_attribute "
 
79
                           "(sid,authenticated,name,value) "
 
80
                           "VALUES (%s,1,%s,%s)",
 
81
                           (user, key, value))
 
82
    db.commit()
 
83
 
 
84
 
 
85
class SingleUserNofification(NotifyEmail):
 
86
    """Helper class used for account email notifications which should only be
 
87
    sent to one persion, not including the rest of the normally CCed users
 
88
    """
 
89
    _username = None
 
90
 
 
91
    def get_recipients(self, resid):
 
92
        return ([resid],[])
 
93
 
 
94
    def get_smtp_address(self, addr):
 
95
        """Overrides `get_smtp_address` in order to prevent CCing users
 
96
        other than the user whose password is being reset.
 
97
        """
 
98
        if addr == self._username:
 
99
            return NotifyEmail.get_smtp_address(self, addr)
 
100
        else:
 
101
            return None
 
102
 
 
103
    def notify(self, username, subject):
 
104
        # save the username for use in `get_smtp_address`
 
105
        self._username = username
 
106
        old_public_cc = self.config.getbool('notification', 'use_public_cc')
 
107
        # override public cc option so that the user's email is included in the To: field
 
108
        self.config.set('notification', 'use_public_cc', 'true')
 
109
        try:
 
110
            NotifyEmail.notify(self, username, subject)
 
111
        finally:
 
112
            self.config.set('notification', 'use_public_cc', old_public_cc)
 
113
 
 
114
 
 
115
class PasswordResetNotification(SingleUserNofification):
 
116
    template_name = 'reset_password_email.txt'
 
117
 
 
118
    def notify(self, username, password):
 
119
        self.data.update({
 
120
            'account': {
 
121
                'username': username,
 
122
                'password': password,
 
123
            },
 
124
            'login': {
 
125
                'link': self.env.abs_href.login(),
 
126
            }
 
127
        })
 
128
 
 
129
        projname = self.config.get('project', 'name')
 
130
        subject = '[%s] Trac password reset for user: %s' % (projname, username)
 
131
 
 
132
        SingleUserNofification.notify(self, username, subject)
 
133
 
 
134
 
 
135
class AccountModule(Component):
 
136
    """Allows users to change their password, reset their password if they've
 
137
    forgotten it, or delete their account.  The settings for the AccountManager
 
138
    module must be set in trac.ini in order to use this.
 
139
    """
 
140
 
 
141
    implements(IPreferencePanelProvider, IRequestHandler, ITemplateProvider,
 
142
               INavigationContributor, IRequestFilter)
 
143
 
 
144
    _password_chars = string.ascii_letters + string.digits
 
145
    password_length = IntOption('account-manager', 'generated_password_length',
 
146
                                8, 'Length of the randomly-generated passwords '
 
147
                                'created when resetting the password for an '
 
148
                                'account.')
 
149
 
 
150
    def __init__(self):
 
151
        self._write_check(log=True)
 
152
 
 
153
    def _write_check(self, log=False):
 
154
        writable = AccountManager(self.env).supports('set_password')
 
155
        if not writable and log:
 
156
            self.log.warn('AccountModule is disabled because the password '
 
157
                          'store does not support writing.')
 
158
        return writable
 
159
 
 
160
    #IPreferencePanelProvider methods
 
161
    def get_preference_panels(self, req):
 
162
        if not self._write_check():
 
163
            return
 
164
        if req.authname and req.authname != 'anonymous':
 
165
            yield 'account', 'Account'
 
166
 
 
167
    def render_preference_panel(self, req, panel):
 
168
        data = {'account': self._do_account(req)}
 
169
        return 'prefs_account.html', data
 
170
 
 
171
    # IRequestHandler methods
 
172
    def match_request(self, req):
 
173
        return (req.path_info == '/reset_password'
 
174
                and self._write_check(log=True))
 
175
 
 
176
    def process_request(self, req):
 
177
        data = {'reset': self._do_reset_password(req)}
 
178
        return 'reset_password.html', data, None
 
179
 
 
180
    # IRequestFilter methods
 
181
    def pre_process_request(self, req, handler):
 
182
        return handler
 
183
 
 
184
    def post_process_request(self, req, template, data, content_type):
 
185
        if req.authname and req.authname != 'anonymous':
 
186
            if req.session.get('force_change_passwd', False):
 
187
                redirect_url = req.href.prefs('account')
 
188
                if req.path_info != redirect_url:
 
189
                    req.redirect(redirect_url)
 
190
        return (template, data, content_type)
 
191
 
 
192
    # INavigationContributor methods
 
193
    def get_active_navigation_item(self, req):
 
194
        return 'reset_password'
 
195
 
 
196
    def get_navigation_items(self, req):
 
197
        if not self.reset_password_enabled or LoginModule(self.env).enabled:
 
198
            return
 
199
        if req.authname == 'anonymous':
 
200
            yield 'metanav', 'reset_password', tag.a(
 
201
                "Forgot your password?", href=req.href.reset_password())
 
202
 
 
203
    def reset_password_enabled(self):
 
204
        return (self.env.is_component_enabled(AccountModule)
 
205
                and NotificationSystem(self.env).smtp_enabled
 
206
                and self._write_check())
 
207
    reset_password_enabled = property(reset_password_enabled)
 
208
 
 
209
    def _do_account(self, req):
 
210
        if not req.authname or req.authname == 'anonymous':
 
211
            req.redirect(req.href.wiki())
 
212
        action = req.args.get('action')
 
213
        delete_enabled = AccountManager(self.env).supports('delete_user')
 
214
        data = {'delete_enabled': delete_enabled}
 
215
        force_change_password = req.session.get('force_change_passwd', False)
 
216
        if req.method == 'POST':
 
217
            if action == 'save':
 
218
                data.update(self._do_change_password(req))
 
219
                if force_change_password:
 
220
                    del(req.session['force_change_passwd'])
 
221
                    req.session.save()
 
222
                    chrome.add_notice(req, MessageWrapper(tag(
 
223
                        "Thank you for taking the time to update your password."
 
224
                    )))
 
225
                    force_change_password = False
 
226
            elif action == 'delete' and delete_enabled:
 
227
                data.update(self._do_delete(req))
 
228
            else:
 
229
                data.update({'error': 'Invalid action'})
 
230
        if force_change_password:
 
231
            chrome.add_warning(req, MessageWrapper(tag(
 
232
                "You are required to change password because of a recent "
 
233
                "password change request. ",
 
234
                tag.b("Please change your password now."))))
 
235
        return data
 
236
 
 
237
    def _do_reset_password(self, req):
 
238
        if req.authname and req.authname != 'anonymous':
 
239
            return {'logged_in': True}
 
240
        if req.method != 'POST':
 
241
            return {}
 
242
        username = req.args.get('username')
 
243
        email = req.args.get('email')
 
244
        if not username:
 
245
            return {'error': 'Username is required'}
 
246
        if not email:
 
247
            return {'error': 'Email is required'}
 
248
 
 
249
        notifier = PasswordResetNotification(self.env)
 
250
 
 
251
        if email != notifier.email_map.get(username):
 
252
            return {'error': 'The email and username do not '
 
253
                             'match a known account.'}
 
254
 
 
255
        new_password = self._random_password()
 
256
        notifier.notify(username, new_password)
 
257
        mgr = AccountManager(self.env)
 
258
        mgr.set_password(username, new_password)
 
259
        if mgr.force_passwd_change:
 
260
            db = self.env.get_db_cnx()
 
261
            cursor = db.cursor()
 
262
            cursor.execute("UPDATE session_attribute SET value=%s "
 
263
                           "WHERE name=%s AND sid=%s AND authenticated=1",
 
264
                           (1, "force_change_passwd", username))
 
265
            if not cursor.rowcount:
 
266
                cursor.execute("INSERT INTO session_attribute "
 
267
                               "(sid,authenticated,name,value) "
 
268
                               "VALUES (%s,1,%s,%s)",
 
269
                               (username, "force_change_passwd", 1))
 
270
            db.commit()
 
271
 
 
272
        return {'sent_to_email': email}
 
273
 
 
274
    def _random_password(self):
 
275
        return ''.join([random.choice(self._password_chars)
 
276
                        for _ in xrange(self.password_length)])
 
277
 
 
278
    def _do_change_password(self, req):
 
279
        user = req.authname
 
280
        mgr = AccountManager(self.env)
 
281
 
 
282
        old_password = req.args.get('old_password')
 
283
        if not old_password:
 
284
            return {'save_error': 'Old Password cannot be empty.'}
 
285
        if not mgr.check_password(user, old_password):
 
286
            return {'save_error': 'Old Password is incorrect.'}
 
287
 
 
288
        password = req.args.get('password')
 
289
        if not password:
 
290
            return {'save_error': 'Password cannot be empty.'}
 
291
 
 
292
        if password != req.args.get('password_confirm'):
 
293
            return {'save_error': 'The passwords must match.'}
 
294
 
 
295
        mgr.set_password(user, password)
 
296
        return {'message': 'Password successfully updated.'}
 
297
 
 
298
    def _do_delete(self, req):
 
299
        user = req.authname
 
300
        mgr = AccountManager(self.env)
 
301
 
 
302
        password = req.args.get('password')
 
303
        if not password:
 
304
            return {'delete_error': 'Password cannot be empty.'}
 
305
        if not mgr.check_password(user, password):
 
306
            return {'delete_error': 'Password is incorrect.'}
 
307
 
 
308
        mgr.delete_user(user)
 
309
        req.redirect(req.href.logout())
 
310
 
 
311
    # ITemplateProvider
 
312
 
 
313
    def get_htdocs_dirs(self):
 
314
        """Return the absolute path of a directory containing additional
 
315
        static resources (such as images, style sheets, etc).
 
316
        """
 
317
        return []
 
318
 
 
319
    def get_templates_dirs(self):
 
320
        """Return the absolute path of the directory containing the provided
 
321
        ClearSilver templates.
 
322
        """
 
323
        from pkg_resources import resource_filename
 
324
        return [resource_filename(__name__, 'templates')]
 
325
 
 
326
 
 
327
class RegistrationModule(Component):
 
328
    """Provides users the ability to register a new account.
 
329
    Requires configuration of the AccountManager module in trac.ini.
 
330
    """
 
331
 
 
332
    implements(INavigationContributor, IRequestHandler, ITemplateProvider)
 
333
 
 
334
    def __init__(self):
 
335
        self._enable_check(log=True)
 
336
 
 
337
    def _enable_check(self, log=False):
 
338
        writable = AccountManager(self.env).supports('set_password')
 
339
        ignore_case = auth.LoginModule(self.env).ignore_case
 
340
        if log:
 
341
            if not writable:
 
342
                self.log.warn('RegistrationModule is disabled because the '
 
343
                              'password store does not support writing.')
 
344
            if ignore_case:
 
345
                self.log.warn('RegistrationModule is disabled because '
 
346
                              'ignore_auth_case is enabled in trac.ini.  '
 
347
                              'This setting needs disabled to support '
 
348
                              'registration.')
 
349
        return writable and not ignore_case
 
350
 
 
351
    #INavigationContributor methods
 
352
 
 
353
    def get_active_navigation_item(self, req):
 
354
        return 'register'
 
355
 
 
356
    def get_navigation_items(self, req):
 
357
        if not self._enable_check():
 
358
            return
 
359
        if req.authname == 'anonymous':
 
360
            yield 'metanav', 'register', tag.a("Register",
 
361
                                               href=req.href.register())
 
362
 
 
363
 
 
364
    # IRequestHandler methods
 
365
 
 
366
    def match_request(self, req):
 
367
        return req.path_info == '/register' and self._enable_check(log=True)
 
368
 
 
369
    def process_request(self, req):
 
370
        if req.authname != 'anonymous':
 
371
            req.redirect(req.href.prefs('account'))
 
372
        action = req.args.get('action')
 
373
        data = {}
 
374
        if req.method == 'POST' and action == 'create':
 
375
            try:
 
376
                _create_user(req, self.env)
 
377
            except TracError, e:
 
378
                data['registration_error'] = e.message
 
379
            else:
 
380
                req.redirect(req.href.login())
 
381
        data['reset_password_enabled'] = \
 
382
            (self.env.is_component_enabled(AccountModule)
 
383
             and NotificationSystem(self.env).smtp_enabled)
 
384
 
 
385
        return 'register.html', data, None
 
386
 
 
387
 
 
388
    # ITemplateProvider
 
389
 
 
390
    def get_htdocs_dirs(self):
 
391
        """Return the absolute path of a directory containing additional
 
392
        static resources (such as images, style sheets, etc).
 
393
        """
 
394
        return []
 
395
 
 
396
    def get_templates_dirs(self):
 
397
        """Return the absolute path of the directory containing the provided
 
398
        ClearSilver templates.
 
399
        """
 
400
        from pkg_resources import resource_filename
 
401
        return [resource_filename(__name__, 'templates')]
 
402
 
 
403
 
 
404
def if_enabled(func):
 
405
    def wrap(self, *args, **kwds):
 
406
        if not self.enabled:
 
407
            return None
 
408
        return func(self, *args, **kwds)
 
409
    return wrap
 
410
 
 
411
 
 
412
class LoginModule(auth.LoginModule):
 
413
 
 
414
    implements(ITemplateProvider)
 
415
 
 
416
    def authenticate(self, req):
 
417
        if req.method == 'POST' and req.path_info.startswith('/login'):
 
418
            req.environ['REMOTE_USER'] = self._remote_user(req)
 
419
        return auth.LoginModule.authenticate(self, req)
 
420
    authenticate = if_enabled(authenticate)
 
421
 
 
422
    match_request = if_enabled(auth.LoginModule.match_request)
 
423
 
 
424
    def process_request(self, req):
 
425
        if req.path_info.startswith('/login') and req.authname == 'anonymous':
 
426
            data = {
 
427
                'referer': self._referer(req),
 
428
                'reset_password_enabled': AccountModule(self.env).reset_password_enabled
 
429
            }
 
430
            if req.method == 'POST':
 
431
                data['login_error'] = 'Invalid username or password'
 
432
            return 'login.html', data, None
 
433
        return auth.LoginModule.process_request(self, req)
 
434
 
 
435
    def _do_login(self, req):
 
436
        if not req.remote_user:
 
437
            req.redirect(self.env.abs_href())
 
438
        return auth.LoginModule._do_login(self, req)
 
439
 
 
440
    def _remote_user(self, req):
 
441
        user = req.args.get('user')
 
442
        password = req.args.get('password')
 
443
        if not user or not password:
 
444
            return None
 
445
        if AccountManager(self.env).check_password(user, password):
 
446
            return user
 
447
        return None
 
448
 
 
449
    def _redirect_back(self, req):
 
450
        """Redirect the user back to the URL she came from."""
 
451
        referer = self._referer(req)
 
452
        if referer and not referer.startswith(req.base_url):
 
453
            # don't redirect to external sites
 
454
            referer = None
 
455
        req.redirect(referer or self.env.abs_href())
 
456
 
 
457
    def _referer(self, req):
 
458
        return req.args.get('referer') or req.get_header('Referer')
 
459
 
 
460
    def enabled(self):
 
461
        # Users should disable the built-in authentication to use this one
 
462
        return not self.env.is_component_enabled(auth.LoginModule)
 
463
    enabled = property(enabled)
 
464
 
 
465
    # ITemplateProvider
 
466
 
 
467
    def get_htdocs_dirs(self):
 
468
        """Return the absolute path of a directory containing additional
 
469
        static resources (such as images, style sheets, etc).
 
470
        """
 
471
        return []
 
472
 
 
473
    def get_templates_dirs(self):
 
474
        """Return the absolute path of the directory containing the provided
 
475
        ClearSilver templates.
 
476
        """
 
477
        from pkg_resources import resource_filename
 
478
        return [resource_filename(__name__, 'templates')]
 
479
 
 
480
 
 
481
class MessageWrapper(object):
 
482
    """Wrapper for add_warning and add_notice to work around the requirement
 
483
    for a % operator."""
 
484
    def __init__(self, body):
 
485
        self.body = body
 
486
 
 
487
    def __mod__(self, rhs):
 
488
        return self.body
 
489
 
 
490
 
 
491
class EmailVerificationNotification(SingleUserNofification):
 
492
    template_name = 'verify_email.txt'
 
493
 
 
494
    def notify(self, username, token):
 
495
        self.data.update({
 
496
            'account': {
 
497
                'username': username,
 
498
                'token': token,
 
499
            },
 
500
            'verify': {
 
501
                'link': self.env.abs_href.verify_email(token=token),
 
502
            }
 
503
        })
 
504
 
 
505
        projname = self.config.get('project', 'name')
 
506
        subject = '[%s] Trac email verification for user: %s' % (projname, username)
 
507
 
 
508
        SingleUserNofification.notify(self, username, subject)
 
509
 
 
510
 
 
511
class EmailVerificationModule(Component):
 
512
    implements(IRequestFilter, IRequestHandler)
 
513
 
 
514
    # IRequestFilter methods
 
515
 
 
516
    def pre_process_request(self, req, handler):
 
517
        if not req.session.authenticated:
 
518
            # Anonymous users should register and perms should be tweaked so
 
519
            # that anonymous users can't edit wiki pages and change or create
 
520
            # tickets. As such, this email verifying code won't be used on them
 
521
            return handler
 
522
        if handler is not self and 'email_verification_token' in req.session:
 
523
            chrome.add_warning(req, MessageWrapper(tag.span(
 
524
                    'Your permissions have been limited until you ',
 
525
                    tag.a(href=req.href.verify_email())(
 
526
                          'verify your email address'))))
 
527
            req.perm = perm.PermissionCache(self.env, 'anonymous')
 
528
        return handler
 
529
 
 
530
    def post_process_request(self, req, template, data, content_type):
 
531
        if not req.session.authenticated:
 
532
            # Anonymous users should register and perms should be tweaked so
 
533
            # that anonymous users can't edit wiki pages and change or create
 
534
            # tickets. As such, this email verifying code won't be used on them
 
535
            return template, data, content_type
 
536
        if req.session.get('email') != req.session.get('email_verification_sent_to'):
 
537
            req.session['email_verification_token'] = self._gen_token()
 
538
            req.session['email_verification_sent_to'] = req.session.get('email')
 
539
            self._send_email(req)
 
540
            chrome.add_notice(req, MessageWrapper(tag.span(
 
541
                    'An email has been sent to ', req.session['email'],
 
542
                    ' with a token to ',
 
543
                    tag.a(href=req.href.verify_email())(
 
544
                        'verify your new email address'))))
 
545
        return template, data, content_type
 
546
 
 
547
    # IRequestHandler methods
 
548
 
 
549
    def match_request(self, req):
 
550
        return req.path_info == '/verify_email'
 
551
 
 
552
    def process_request(self, req):
 
553
        if 'email_verification_token' not in req.session:
 
554
            chrome.add_notice(req, 'Your email is already verified')
 
555
        elif req.method != 'POST':
 
556
            pass
 
557
        elif 'resend' in req.args:
 
558
            self._send_email(req)
 
559
            chrome.add_notice(req,
 
560
                    'A notification email has been resent to %s.',
 
561
                    req.session.get('email'))
 
562
        elif 'verify' in req.args:
 
563
            if req.args['token'] == req.session['email_verification_token']:
 
564
                del req.session['email_verification_token']
 
565
                chrome.add_notice(req, 'Thank you for verifying your email address')
 
566
            else:
 
567
                chrome.add_warning(req, 'Invalid verification token')
 
568
        data = {}
 
569
        if 'token' in req.args:
 
570
            data['token'] = req.args['token']
 
571
        return 'verify_email.html', data, None
 
572
 
 
573
    def _gen_token(self):
 
574
        return base64.urlsafe_b64encode(urandom(6))
 
575
 
 
576
    def _send_email(self, req):
 
577
        notifier = EmailVerificationNotification(self.env)
 
578
        notifier.notify(req.authname, req.session['email_verification_token'])