| 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(u'Le nom d\'utilisateur ne peut être vide.') | 
|---|
| 35 |  | 
|---|
| 36 | if mgr.has_user(user): | 
|---|
| 37 | raise TracError(u'Un autre coompte avec ce nom existe déjà.') | 
|---|
| 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(u'Un autre coompte avec ce nom existe déjà.') | 
|---|
| 45 |  | 
|---|
| 46 | password = req.args.get('password') | 
|---|
| 47 | if not password: | 
|---|
| 48 | raise TracError(u'Le mot de passe ne peut pas être vide.') | 
|---|
| 49 |  | 
|---|
| 50 | if password != req.args.get('password_confirm'): | 
|---|
| 51 | raise TracError(u'Le mot de passe doit correspondre.') | 
|---|
| 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 = u'[%s] Ré-initialisation du mot de passe pour l\'utilisateur: %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 | u'Longueur des mots de passe aléatoirement générés,' | 
|---|
| 122 | u' créés à l\'occasion de la ré-initialisation du ' | 
|---|
| 123 | u'mot de passe d\'un compte.') | 
|---|
| 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(u'<a href="%s">Mon Compte</a>', | 
|---|
| 147 | (req.href.account())) | 
|---|
| 148 | elif self.reset_password_enabled and not LoginModule(self.env).enabled: | 
|---|
| 149 | yield 'metanav', 'reset_password', Markup(u'<a href="%s">Mot de passe oublié ?</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'] = u'Nom d\'utilisateur requis' | 
|---|
| 193 | return | 
|---|
| 194 | if not email: | 
|---|
| 195 | req.hdf['reset.error'] = u'Courriel requis' | 
|---|
| 196 | return | 
|---|
| 197 |  | 
|---|
| 198 | notifier = PasswordResetNotification(self.env) | 
|---|
| 199 |  | 
|---|
| 200 | if email != notifier.email_map.get(username): | 
|---|
| 201 | req.hdf['reset.error'] = u'Le nom d\'utilisateur et le courriel ' \ | 
|---|
| 202 | u'ne correspondent pas à un compte connu.' | 
|---|
| 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'] = u'L\'ancien mot de passe ne peut pas être vide.' | 
|---|
| 220 | return | 
|---|
| 221 | if not mgr.check_password(user, old_password): | 
|---|
| 222 | req.hdf['account.save_error'] = u'L\'ancien mot de passe n\'est pas correct.' | 
|---|
| 223 | return | 
|---|
| 224 |  | 
|---|
| 225 | password = req.args.get('password') | 
|---|
| 226 | if not password: | 
|---|
| 227 | req.hdf['account.save_error'] = u'Le mot de passe ne peut pas être vide.' | 
|---|
| 228 | return | 
|---|
| 229 |  | 
|---|
| 230 | if password != req.args.get('password_confirm'): | 
|---|
| 231 | req.hdf['account.save_error'] = u'Les mots de passe doivent correspondre.' | 
|---|
| 232 | return | 
|---|
| 233 |  | 
|---|
| 234 | mgr.set_password(user, password) | 
|---|
| 235 | req.hdf['account.message'] = u'Mot de passe mis à jour avec succés.' | 
|---|
| 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'] = u'Le mot de passe ne peut pas être vide.' | 
|---|
| 243 | return | 
|---|
| 244 | if not mgr.check_password(user, password): | 
|---|
| 245 | req.hdf['account.delete_error'] = u'Le mot de passe n\'est pas correct.' | 
|---|
| 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(u'<a href="%s">S\'enregistrer</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'] = u'Utilisateur ou mot de passe invalide' | 
|---|
| 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')] | 
|---|
| 416 |  | 
|---|