| 1 | """ |
|---|
| 2 | plugin for Trac to give anonymous users authenticated access |
|---|
| 3 | using a CAPTCHA |
|---|
| 4 | """ |
|---|
| 5 | |
|---|
| 6 | # Plugin for trac 0.11 |
|---|
| 7 | |
|---|
| 8 | import random |
|---|
| 9 | import sys |
|---|
| 10 | import time |
|---|
| 11 | import urllib |
|---|
| 12 | |
|---|
| 13 | from componentdependencies import IRequireComponents |
|---|
| 14 | |
|---|
| 15 | from genshi.builder import Markup |
|---|
| 16 | from genshi.builder import tag |
|---|
| 17 | from genshi.filters.transform import Transformer |
|---|
| 18 | |
|---|
| 19 | from pkg_resources import resource_filename |
|---|
| 20 | |
|---|
| 21 | from skimpyGimpy import skimpyAPI |
|---|
| 22 | |
|---|
| 23 | from trac.core import * |
|---|
| 24 | from trac.db import Table, Column |
|---|
| 25 | from trac.env import IEnvironmentSetupParticipant |
|---|
| 26 | from trac.web import IRequestFilter |
|---|
| 27 | from trac.web import ITemplateStreamFilter |
|---|
| 28 | from trac.web.api import IAuthenticator |
|---|
| 29 | from trac.web.auth import LoginModule |
|---|
| 30 | from trac.web.chrome import add_warning |
|---|
| 31 | from trac.web.chrome import Chrome |
|---|
| 32 | from trac.web.chrome import INavigationContributor |
|---|
| 33 | from trac.web.chrome import ITemplateProvider |
|---|
| 34 | from trac.config import ListOption |
|---|
| 35 | from trac.config import Option |
|---|
| 36 | |
|---|
| 37 | from tracsqlhelper import create_table |
|---|
| 38 | from tracsqlhelper import execute_non_query |
|---|
| 39 | from tracsqlhelper import get_scalar |
|---|
| 40 | from tracsqlhelper import get_table |
|---|
| 41 | from tracsqlhelper import insert_update |
|---|
| 42 | |
|---|
| 43 | from utils import random_word |
|---|
| 44 | from web_ui import ImageCaptcha |
|---|
| 45 | |
|---|
| 46 | try: |
|---|
| 47 | from acct_mgr.api import AccountManager |
|---|
| 48 | from acct_mgr.web_ui import RegistrationModule |
|---|
| 49 | except: |
|---|
| 50 | AccountManager = None |
|---|
| 51 | |
|---|
| 52 | class AuthCaptcha(Component): |
|---|
| 53 | |
|---|
| 54 | ### class data |
|---|
| 55 | implements(IRequestFilter, |
|---|
| 56 | ITemplateStreamFilter, |
|---|
| 57 | ITemplateProvider, |
|---|
| 58 | IAuthenticator, |
|---|
| 59 | IEnvironmentSetupParticipant, |
|---|
| 60 | IRequireComponents, |
|---|
| 61 | INavigationContributor |
|---|
| 62 | ) |
|---|
| 63 | |
|---|
| 64 | dict_file = Option('captchaauth', 'dictionary_file', |
|---|
| 65 | default="http://java.sun.com/docs/books/tutorial/collections/interfaces/examples/dictionary.txt") |
|---|
| 66 | captcha_type = Option('captchaauth', 'type', |
|---|
| 67 | default="png") |
|---|
| 68 | realms = ListOption('captchaauth', 'realms', |
|---|
| 69 | default="wiki, newticket") |
|---|
| 70 | permissions = { 'wiki': [ 'WIKI_CREATE', 'WIKI_MODIFY' ], |
|---|
| 71 | 'newticket': [ 'TICKET_CREATE' ] } |
|---|
| 72 | |
|---|
| 73 | xpath = { 'ticket.html': "//div[@class='buttons']" } |
|---|
| 74 | delete = { 'ticket.html': "//div[@class='field']" } |
|---|
| 75 | |
|---|
| 76 | ### IRequestFilter methods |
|---|
| 77 | |
|---|
| 78 | def pre_process_request(self, req, handler): |
|---|
| 79 | """Called after initial handler selection, and can be used to change |
|---|
| 80 | the selected handler or redirect request. |
|---|
| 81 | |
|---|
| 82 | Always returns the request handler, even if unchanged. |
|---|
| 83 | """ |
|---|
| 84 | |
|---|
| 85 | if req.method == 'GET': |
|---|
| 86 | if req.path_info.strip('/') in ['register', 'login'] and req.authname != 'anonymous': |
|---|
| 87 | login_module = LoginModule(self.env) |
|---|
| 88 | login_module._do_logout(req) |
|---|
| 89 | req.redirect(req.href(req.path_info)) |
|---|
| 90 | |
|---|
| 91 | |
|---|
| 92 | if req.method == 'POST': |
|---|
| 93 | |
|---|
| 94 | realm = self.realm(req) |
|---|
| 95 | |
|---|
| 96 | # set the session data for name and email if CAPTCHA-authenticated |
|---|
| 97 | if 'captchaauth' in req.args: |
|---|
| 98 | name, email = self.identify(req) |
|---|
| 99 | for field in 'name', 'email': |
|---|
| 100 | value = locals()[field] |
|---|
| 101 | if value: |
|---|
| 102 | req.session[field] = value |
|---|
| 103 | req.session.save() |
|---|
| 104 | if req.authname != 'anonymous' and realm == 'newticket': |
|---|
| 105 | req.args['author'] = name |
|---|
| 106 | if email: |
|---|
| 107 | req.args['author'] += ' <%s>' % email |
|---|
| 108 | |
|---|
| 109 | # redirect anonymous user posts that are not CAPTCHA-identified |
|---|
| 110 | if req.authname == 'anonymous' and realm in self.realms: |
|---|
| 111 | |
|---|
| 112 | if 'captchaauth' in req.args and 'captchaid' in req.args: |
|---|
| 113 | # add warnings from CAPTCHA authentication |
|---|
| 114 | captcha = self.captcha(req) |
|---|
| 115 | if req.args['captchaauth'] != captcha: |
|---|
| 116 | add_warning(req, "You typed the wrong word. Please try again.") |
|---|
| 117 | try: |
|---|
| 118 | # delete used CAPTCHA |
|---|
| 119 | execute_non_query(self.env, "DELETE FROM captcha WHERE id=%s", req.args['captchaid']) |
|---|
| 120 | except: |
|---|
| 121 | pass |
|---|
| 122 | |
|---|
| 123 | name, email = self.identify(req) |
|---|
| 124 | if not name: |
|---|
| 125 | add_warning(req, 'Please provide your name') |
|---|
| 126 | if AccountManager and name in AccountManager(self.env).get_users(): |
|---|
| 127 | add_warning(req, '%s is already taken as by a registered user. Please login or use a different name' % name) |
|---|
| 128 | |
|---|
| 129 | # redirect to previous location |
|---|
| 130 | location = req.get_header('referer') |
|---|
| 131 | if location: |
|---|
| 132 | location, query = urllib.splitquery(location) |
|---|
| 133 | |
|---|
| 134 | if realm == 'newticket': |
|---|
| 135 | args = [(key.split('field_',1)[-1], value) |
|---|
| 136 | for key, value in req.args.items() |
|---|
| 137 | if key.startswith('field_')] |
|---|
| 138 | location += '?%s' % urllib.urlencode(args) |
|---|
| 139 | else: |
|---|
| 140 | location = req.href() |
|---|
| 141 | req.redirect(location) |
|---|
| 142 | |
|---|
| 143 | return handler |
|---|
| 144 | |
|---|
| 145 | # for ClearSilver templates |
|---|
| 146 | def post_process_request(self, req, template, content_type): |
|---|
| 147 | """Do any post-processing the request might need; typically adding |
|---|
| 148 | values to req.hdf, or changing template or mime type. |
|---|
| 149 | |
|---|
| 150 | Always returns a tuple of (template, content_type), even if |
|---|
| 151 | unchanged. |
|---|
| 152 | |
|---|
| 153 | Note that `template`, `content_type` will be `None` if: |
|---|
| 154 | - called when processing an error page |
|---|
| 155 | - the default request handler did not return any result |
|---|
| 156 | |
|---|
| 157 | (for 0.10 compatibility; only used together with ClearSilver templates) |
|---|
| 158 | """ |
|---|
| 159 | return (template, content_type) |
|---|
| 160 | |
|---|
| 161 | # for Genshi templates |
|---|
| 162 | def post_process_request(self, req, template, data, content_type): |
|---|
| 163 | """Do any post-processing the request might need; typically adding |
|---|
| 164 | values to the template `data` dictionary, or changing template or |
|---|
| 165 | mime type. |
|---|
| 166 | |
|---|
| 167 | `data` may be update in place. |
|---|
| 168 | |
|---|
| 169 | Always returns a tuple of (template, data, content_type), even if |
|---|
| 170 | unchanged. |
|---|
| 171 | |
|---|
| 172 | Note that `template`, `data`, `content_type` will be `None` if: |
|---|
| 173 | - called when processing an error page |
|---|
| 174 | - the default request handler did not return any result |
|---|
| 175 | |
|---|
| 176 | (Since 0.11) |
|---|
| 177 | """ |
|---|
| 178 | return (template, data, content_type) |
|---|
| 179 | |
|---|
| 180 | |
|---|
| 181 | ### ITemplateStreamFilter method |
|---|
| 182 | |
|---|
| 183 | def filter_stream(self, req, method, filename, stream, data): |
|---|
| 184 | """Return a filtered Genshi event stream, or the original unfiltered |
|---|
| 185 | stream if no match. |
|---|
| 186 | |
|---|
| 187 | `req` is the current request object, `method` is the Genshi render |
|---|
| 188 | method (xml, xhtml or text), `filename` is the filename of the template |
|---|
| 189 | to be rendered, `stream` is the event stream and `data` is the data for |
|---|
| 190 | the current template. |
|---|
| 191 | |
|---|
| 192 | See the Genshi documentation for more information. |
|---|
| 193 | """ |
|---|
| 194 | |
|---|
| 195 | # only show CAPTCHAs for anonymous users |
|---|
| 196 | if req.authname != 'anonymous': |
|---|
| 197 | return stream |
|---|
| 198 | |
|---|
| 199 | # only put CAPTCHAs in the realms specified |
|---|
| 200 | realm = self.realm(req) |
|---|
| 201 | if realm not in self.realms: |
|---|
| 202 | return stream |
|---|
| 203 | |
|---|
| 204 | # add the CAPTCHA to the stream |
|---|
| 205 | if filename in self.xpath: |
|---|
| 206 | |
|---|
| 207 | # store CAPTCHA in DB and session |
|---|
| 208 | word = random_word(self.dict_file) |
|---|
| 209 | insert_update(self.env, 'captcha', 'id', req.session.sid, dict(word=word)) |
|---|
| 210 | req.session['captcha'] = word |
|---|
| 211 | req.session.save() |
|---|
| 212 | |
|---|
| 213 | # render the template |
|---|
| 214 | chrome = Chrome(self.env) |
|---|
| 215 | template = chrome.load_template('captcha.html') |
|---|
| 216 | _data = {} |
|---|
| 217 | |
|---|
| 218 | # CAPTCHA type |
|---|
| 219 | if self.captcha_type == 'png': |
|---|
| 220 | captcha = tag.img(None, src=req.href('captcha.png')) |
|---|
| 221 | else: |
|---|
| 222 | captcha = Markup(skimpyAPI.Pre(word).data()) |
|---|
| 223 | |
|---|
| 224 | _data['captcha'] = captcha |
|---|
| 225 | _data['email'] = req.session.get('email', '') |
|---|
| 226 | _data['name'] = req.session.get('name', '') |
|---|
| 227 | _data['captchaid'] = req.session.sid |
|---|
| 228 | xpath = self.xpath[filename] |
|---|
| 229 | stream |= Transformer(xpath).before(template.generate(**_data)) |
|---|
| 230 | if filename in self.delete: |
|---|
| 231 | stream |= Transformer(self.delete[filename]).remove() |
|---|
| 232 | |
|---|
| 233 | return stream |
|---|
| 234 | |
|---|
| 235 | ### methods for ITemplateProvider |
|---|
| 236 | |
|---|
| 237 | """Extension point interface for components that provide their own |
|---|
| 238 | ClearSilver templates and accompanying static resources. |
|---|
| 239 | """ |
|---|
| 240 | |
|---|
| 241 | def get_htdocs_dirs(self): |
|---|
| 242 | """Return a list of directories with static resources (such as style |
|---|
| 243 | sheets, images, etc.) |
|---|
| 244 | |
|---|
| 245 | Each item in the list must be a `(prefix, abspath)` tuple. The |
|---|
| 246 | `prefix` part defines the path in the URL that requests to these |
|---|
| 247 | resources are prefixed with. |
|---|
| 248 | |
|---|
| 249 | The `abspath` is the absolute path to the directory containing the |
|---|
| 250 | resources on the local file system. |
|---|
| 251 | """ |
|---|
| 252 | return [] |
|---|
| 253 | |
|---|
| 254 | def get_templates_dirs(self): |
|---|
| 255 | """Return a list of directories containing the provided template |
|---|
| 256 | files. |
|---|
| 257 | """ |
|---|
| 258 | return [resource_filename(__name__, 'templates')] |
|---|
| 259 | |
|---|
| 260 | ### method for IAuthenticator |
|---|
| 261 | |
|---|
| 262 | """Extension point interface for components that can provide the name |
|---|
| 263 | of the remote user.""" |
|---|
| 264 | |
|---|
| 265 | def authenticate(self, req): |
|---|
| 266 | """Return the name of the remote user, or `None` if the identity of the |
|---|
| 267 | user is unknown.""" |
|---|
| 268 | |
|---|
| 269 | # check for an authenticated user |
|---|
| 270 | login_module = LoginModule(self.env) |
|---|
| 271 | remote_user = login_module.authenticate(req) |
|---|
| 272 | if remote_user: |
|---|
| 273 | return remote_user |
|---|
| 274 | |
|---|
| 275 | # authenticate via a CAPTCHA |
|---|
| 276 | if 'captchaauth' in req.args and 'captchaid' in req.args: |
|---|
| 277 | |
|---|
| 278 | # ensure CAPTCHA identification |
|---|
| 279 | captcha = self.captcha(req) |
|---|
| 280 | if captcha != req.args['captchaauth']: |
|---|
| 281 | return |
|---|
| 282 | |
|---|
| 283 | # ensure sane identity |
|---|
| 284 | name, email = self.identify(req) |
|---|
| 285 | if name is None: |
|---|
| 286 | return |
|---|
| 287 | if AccountManager and name in AccountManager(self.env).get_users(): |
|---|
| 288 | return |
|---|
| 289 | |
|---|
| 290 | # delete used CAPTCHA on success |
|---|
| 291 | try: |
|---|
| 292 | execute_non_query(self.env, "DELETE FROM captcha WHERE id=%s", req.args['captchaid']) |
|---|
| 293 | except: |
|---|
| 294 | pass |
|---|
| 295 | |
|---|
| 296 | # log the user in |
|---|
| 297 | req.environ['REMOTE_USER'] = name |
|---|
| 298 | login_module._do_login(req) |
|---|
| 299 | |
|---|
| 300 | ### methods for INavigationContributor |
|---|
| 301 | |
|---|
| 302 | """Extension point interface for components that contribute items to the |
|---|
| 303 | navigation. |
|---|
| 304 | """ |
|---|
| 305 | |
|---|
| 306 | def get_active_navigation_item(self, req): |
|---|
| 307 | """This method is only called for the `IRequestHandler` processing the |
|---|
| 308 | request. |
|---|
| 309 | |
|---|
| 310 | It should return the name of the navigation item that should be |
|---|
| 311 | highlighted as active/current. |
|---|
| 312 | """ |
|---|
| 313 | return None |
|---|
| 314 | |
|---|
| 315 | def get_navigation_items(self, req): |
|---|
| 316 | """Should return an iterable object over the list of navigation items to |
|---|
| 317 | add, each being a tuple in the form (category, name, text). |
|---|
| 318 | """ |
|---|
| 319 | if req.authname != 'anonymous' and 'captcha' in req.session: |
|---|
| 320 | return [('metanav', '_login', tag.a("Login", |
|---|
| 321 | href=req.href.login())), |
|---|
| 322 | ('metanav', '_register', tag.a("Register", |
|---|
| 323 | href=req.href.register()))] |
|---|
| 324 | return [] |
|---|
| 325 | |
|---|
| 326 | ### methods for IEnvironmentSetupParticipant |
|---|
| 327 | |
|---|
| 328 | """Extension point interface for components that need to participate in the |
|---|
| 329 | creation and upgrading of Trac environments, for example to create |
|---|
| 330 | additional database tables.""" |
|---|
| 331 | |
|---|
| 332 | def environment_created(self): |
|---|
| 333 | """Called when a new Trac environment is created.""" |
|---|
| 334 | if self.environment_needs_upgrade(None): |
|---|
| 335 | self.upgrade_environment(None) |
|---|
| 336 | |
|---|
| 337 | def environment_needs_upgrade(self, db): |
|---|
| 338 | """Called when Trac checks whether the environment needs to be upgraded. |
|---|
| 339 | |
|---|
| 340 | Should return `True` if this participant needs an upgrade to be |
|---|
| 341 | performed, `False` otherwise. |
|---|
| 342 | """ |
|---|
| 343 | try: |
|---|
| 344 | get_table(self.env, 'captcha') |
|---|
| 345 | except: |
|---|
| 346 | return True |
|---|
| 347 | return False |
|---|
| 348 | |
|---|
| 349 | def upgrade_environment(self, db): |
|---|
| 350 | """Actually perform an environment upgrade. |
|---|
| 351 | |
|---|
| 352 | Implementations of this method should not commit any database |
|---|
| 353 | transactions. This is done implicitly after all participants have |
|---|
| 354 | performed the upgrades they need without an error being raised. |
|---|
| 355 | """ |
|---|
| 356 | |
|---|
| 357 | # table of CAPTCHAs |
|---|
| 358 | captcha_table = Table('captcha', key='key')[ |
|---|
| 359 | Column('id'), |
|---|
| 360 | Column('word')] |
|---|
| 361 | create_table(self.env, captcha_table) |
|---|
| 362 | |
|---|
| 363 | ### method for IRequireComponents |
|---|
| 364 | |
|---|
| 365 | def requires(self): |
|---|
| 366 | """list of component classes that this component depends on""" |
|---|
| 367 | return [ImageCaptcha] |
|---|
| 368 | |
|---|
| 369 | |
|---|
| 370 | ### internal methods |
|---|
| 371 | |
|---|
| 372 | def identify(self, req): |
|---|
| 373 | """ |
|---|
| 374 | identify the user, ensuring uniqueness (TODO); |
|---|
| 375 | returns a tuple of (name, email) or success or None |
|---|
| 376 | """ |
|---|
| 377 | name = req.args.get('name', None).strip() |
|---|
| 378 | email = req.args.get('email', None).strip() |
|---|
| 379 | return name, email |
|---|
| 380 | |
|---|
| 381 | |
|---|
| 382 | def realm(self, req): |
|---|
| 383 | """ |
|---|
| 384 | returns the realm according to the request |
|---|
| 385 | """ |
|---|
| 386 | path = req.path_info.strip('/').split('/') |
|---|
| 387 | if not path: |
|---|
| 388 | return |
|---|
| 389 | # TODO: default handler ('/') |
|---|
| 390 | return path[0] |
|---|
| 391 | |
|---|
| 392 | def captcha(self, req): |
|---|
| 393 | return get_scalar(self.env, "SELECT word FROM captcha WHERE id=%s", 0, req.args['captchaid']) |
|---|