1
# -*- coding: utf-8 -*-
3
# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
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
10
# Author: Matthew Good <trac@matt-good.net>
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
29
from api import AccountManager
30
from acct_mgr.util import urandom
32
def _create_user(req, env, check_permissions=True):
33
mgr = AccountManager(env)
35
user = req.args.get('user')
37
raise TracError('Username cannot be empty.')
39
if mgr.has_user(user):
40
raise TracError('Another account with that name already exists.')
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.')
49
password = req.args.get('password')
51
raise TracError('Password cannot be empty.')
53
if password != req.args.get('password_confirm'):
54
raise TracError('The passwords must match.')
56
mgr.set_password(user, password)
60
cursor.execute("SELECT count(*) FROM session "
61
"WHERE sid=%s AND authenticated=1",
63
exists, = cursor.fetchone()
65
cursor.execute("INSERT INTO session "
66
"(sid, authenticated, last_visit) "
70
for key in ('name', 'email'):
71
value = req.args.get(key)
74
cursor.execute("UPDATE session_attribute SET value=%s "
75
"WHERE name=%s AND sid=%s AND authenticated=1",
77
if not cursor.rowcount:
78
cursor.execute("INSERT INTO session_attribute "
79
"(sid,authenticated,name,value) "
80
"VALUES (%s,1,%s,%s)",
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
91
def get_recipients(self, resid):
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.
98
if addr == self._username:
99
return NotifyEmail.get_smtp_address(self, addr)
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')
110
NotifyEmail.notify(self, username, subject)
112
self.config.set('notification', 'use_public_cc', old_public_cc)
115
class PasswordResetNotification(SingleUserNofification):
116
template_name = 'reset_password_email.txt'
118
def notify(self, username, password):
121
'username': username,
122
'password': password,
125
'link': self.env.abs_href.login(),
129
projname = self.config.get('project', 'name')
130
subject = '[%s] Trac password reset for user: %s' % (projname, username)
132
SingleUserNofification.notify(self, username, subject)
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.
141
implements(IPreferencePanelProvider, IRequestHandler, ITemplateProvider,
142
INavigationContributor, IRequestFilter)
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 '
151
self._write_check(log=True)
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.')
160
#IPreferencePanelProvider methods
161
def get_preference_panels(self, req):
162
if not self._write_check():
164
if req.authname and req.authname != 'anonymous':
165
yield 'account', 'Account'
167
def render_preference_panel(self, req, panel):
168
data = {'account': self._do_account(req)}
169
return 'prefs_account.html', data
171
# IRequestHandler methods
172
def match_request(self, req):
173
return (req.path_info == '/reset_password'
174
and self._write_check(log=True))
176
def process_request(self, req):
177
data = {'reset': self._do_reset_password(req)}
178
return 'reset_password.html', data, None
180
# IRequestFilter methods
181
def pre_process_request(self, req, handler):
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)
192
# INavigationContributor methods
193
def get_active_navigation_item(self, req):
194
return 'reset_password'
196
def get_navigation_items(self, req):
197
if not self.reset_password_enabled or LoginModule(self.env).enabled:
199
if req.authname == 'anonymous':
200
yield 'metanav', 'reset_password', tag.a(
201
"Forgot your password?", href=req.href.reset_password())
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)
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':
218
data.update(self._do_change_password(req))
219
if force_change_password:
220
del(req.session['force_change_passwd'])
222
chrome.add_notice(req, MessageWrapper(tag(
223
"Thank you for taking the time to update your password."
225
force_change_password = False
226
elif action == 'delete' and delete_enabled:
227
data.update(self._do_delete(req))
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."))))
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':
242
username = req.args.get('username')
243
email = req.args.get('email')
245
return {'error': 'Username is required'}
247
return {'error': 'Email is required'}
249
notifier = PasswordResetNotification(self.env)
251
if email != notifier.email_map.get(username):
252
return {'error': 'The email and username do not '
253
'match a known account.'}
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()
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))
272
return {'sent_to_email': email}
274
def _random_password(self):
275
return ''.join([random.choice(self._password_chars)
276
for _ in xrange(self.password_length)])
278
def _do_change_password(self, req):
280
mgr = AccountManager(self.env)
282
old_password = req.args.get('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.'}
288
password = req.args.get('password')
290
return {'save_error': 'Password cannot be empty.'}
292
if password != req.args.get('password_confirm'):
293
return {'save_error': 'The passwords must match.'}
295
mgr.set_password(user, password)
296
return {'message': 'Password successfully updated.'}
298
def _do_delete(self, req):
300
mgr = AccountManager(self.env)
302
password = req.args.get('password')
304
return {'delete_error': 'Password cannot be empty.'}
305
if not mgr.check_password(user, password):
306
return {'delete_error': 'Password is incorrect.'}
308
mgr.delete_user(user)
309
req.redirect(req.href.logout())
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).
319
def get_templates_dirs(self):
320
"""Return the absolute path of the directory containing the provided
321
ClearSilver templates.
323
from pkg_resources import resource_filename
324
return [resource_filename(__name__, 'templates')]
327
class RegistrationModule(Component):
328
"""Provides users the ability to register a new account.
329
Requires configuration of the AccountManager module in trac.ini.
332
implements(INavigationContributor, IRequestHandler, ITemplateProvider)
335
self._enable_check(log=True)
337
def _enable_check(self, log=False):
338
writable = AccountManager(self.env).supports('set_password')
339
ignore_case = auth.LoginModule(self.env).ignore_case
342
self.log.warn('RegistrationModule is disabled because the '
343
'password store does not support writing.')
345
self.log.warn('RegistrationModule is disabled because '
346
'ignore_auth_case is enabled in trac.ini. '
347
'This setting needs disabled to support '
349
return writable and not ignore_case
351
#INavigationContributor methods
353
def get_active_navigation_item(self, req):
356
def get_navigation_items(self, req):
357
if not self._enable_check():
359
if req.authname == 'anonymous':
360
yield 'metanav', 'register', tag.a("Register",
361
href=req.href.register())
364
# IRequestHandler methods
366
def match_request(self, req):
367
return req.path_info == '/register' and self._enable_check(log=True)
369
def process_request(self, req):
370
if req.authname != 'anonymous':
371
req.redirect(req.href.prefs('account'))
372
action = req.args.get('action')
374
if req.method == 'POST' and action == 'create':
376
_create_user(req, self.env)
378
data['registration_error'] = e.message
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)
385
return 'register.html', data, None
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).
396
def get_templates_dirs(self):
397
"""Return the absolute path of the directory containing the provided
398
ClearSilver templates.
400
from pkg_resources import resource_filename
401
return [resource_filename(__name__, 'templates')]
404
def if_enabled(func):
405
def wrap(self, *args, **kwds):
408
return func(self, *args, **kwds)
412
class LoginModule(auth.LoginModule):
414
implements(ITemplateProvider)
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)
422
match_request = if_enabled(auth.LoginModule.match_request)
424
def process_request(self, req):
425
if req.path_info.startswith('/login') and req.authname == 'anonymous':
427
'referer': self._referer(req),
428
'reset_password_enabled': AccountModule(self.env).reset_password_enabled
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)
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)
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:
445
if AccountManager(self.env).check_password(user, password):
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
455
req.redirect(referer or self.env.abs_href())
457
def _referer(self, req):
458
return req.args.get('referer') or req.get_header('Referer')
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)
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).
473
def get_templates_dirs(self):
474
"""Return the absolute path of the directory containing the provided
475
ClearSilver templates.
477
from pkg_resources import resource_filename
478
return [resource_filename(__name__, 'templates')]
481
class MessageWrapper(object):
482
"""Wrapper for add_warning and add_notice to work around the requirement
484
def __init__(self, body):
487
def __mod__(self, rhs):
491
class EmailVerificationNotification(SingleUserNofification):
492
template_name = 'verify_email.txt'
494
def notify(self, username, token):
497
'username': username,
501
'link': self.env.abs_href.verify_email(token=token),
505
projname = self.config.get('project', 'name')
506
subject = '[%s] Trac email verification for user: %s' % (projname, username)
508
SingleUserNofification.notify(self, username, subject)
511
class EmailVerificationModule(Component):
512
implements(IRequestFilter, IRequestHandler)
514
# IRequestFilter methods
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
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')
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'],
543
tag.a(href=req.href.verify_email())(
544
'verify your new email address'))))
545
return template, data, content_type
547
# IRequestHandler methods
549
def match_request(self, req):
550
return req.path_info == '/verify_email'
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':
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')
567
chrome.add_warning(req, 'Invalid verification token')
569
if 'token' in req.args:
570
data['token'] = req.args['token']
571
return 'verify_email.html', data, None
573
def _gen_token(self):
574
return base64.urlsafe_b64encode(urandom(6))
576
def _send_email(self, req):
577
notifier = EmailVerificationNotification(self.env)
578
notifier.notify(req.authname, req.session['email_verification_token'])