[2] | 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 | |
---|