| [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 | |
|---|
| 12 | from __future__ import generators |
|---|
| 13 | |
|---|
| [1085] | 14 | import random |
|---|
| 15 | import string |
|---|
| 16 | |
|---|
| [76] | 17 | from trac import perm, util |
|---|
| 18 | from trac.core import * |
|---|
| [1085] | 19 | from trac.config import IntOption |
|---|
| 20 | from trac.notification import NotificationSystem, NotifyEmail |
|---|
| [132] | 21 | from trac.web import auth |
|---|
| 22 | from trac.web.api import IAuthenticator |
|---|
| [76] | 23 | from trac.web.main import IRequestHandler |
|---|
| 24 | from trac.web.chrome import INavigationContributor, ITemplateProvider |
|---|
| [304] | 25 | from trac.util import Markup |
|---|
| [76] | 26 | |
|---|
| 27 | from api import AccountManager |
|---|
| 28 | |
|---|
| [1127] | 29 | def _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] | 82 | class 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] | 111 | class 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] | 273 | class 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] | 348 | def 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] | 356 | class 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 | |
|---|