Tres Seaver
2012-03-18 6919cb8daf7c7f7291b8aa10a28334c1a61848e9
commit | author | age
7dfea7 1 import logging
6919cb 2 try:
TS 3     from StringIO import StringIO
4 except ImportError: #pragma NO COVER Python >= 3.0
5     from io import StringIO
7dfea7 6 import sys
a400b0 7
993216 8 from repoze.who.api import APIFactory
cb5426 9 from repoze.who.interfaces import IChallenger
7dfea7 10
88e646 11 _STARTED = '-- repoze.who request started (%s) --'
CM 12 _ENDED = '-- repoze.who request ended (%s) --'
7dfea7 13
d85ba6 14 class PluggableAuthenticationMiddleware(object):
993216 15     def __init__(self,
TS 16                  app,
c51195 17                  identifiers,
CM 18                  authenticators,
19                  challengers,
f1582f 20                  mdproviders,
cbc983 21                  request_classifier = None,
TS 22                  challenge_decider = None,
db4cf5 23                  log_stream = None,
CM 24                  log_level = logging.INFO,
25                  remote_user_key = 'REMOTE_USER',
cbc983 26                  classifier = None
c51195 27                  ):
cbc983 28         if challenge_decider is None:
TS 29             raise ValueError('challenge_decider is required')
30         if request_classifier is not None and classifier is not None:
31             raise ValueError(
32                     'Only one of request_classifier and classifier is allowed')
33         if request_classifier is None:
34             if classifier is None:
35                 raise ValueError(
9b56fd 36                         'Either request_classifier or classifier is required')
cbc983 37             request_classifier = classifier
d85ba6 38         self.app = app
993216 39         logger = self.logger = None
a84601 40         if isinstance(log_stream, logging.Logger):
993216 41             logger = self.logger = log_stream
a84601 42         elif log_stream:
7dfea7 43             handler = logging.StreamHandler(log_stream)
CM 44             fmt = '%(asctime)s %(message)s'
45             formatter = logging.Formatter(fmt)
46             handler.setFormatter(formatter)
993216 47             logger = self.logger = logging.Logger('repoze.who')
TS 48             logger.addHandler(handler)
49             logger.setLevel(log_level)
50         self.remote_user_key = remote_user_key
51
52         self.api_factory = APIFactory(identifiers,
53                                       authenticators,
54                                       challengers, 
55                                       mdproviders,
56                                       request_classifier,
57                                       challenge_decider,
b482a1 58                                       remote_user_key,
TS 59                                       logger
60                                      )
993216 61
d85ba6 62
CM 63     def __call__(self, environ, start_response):
db4cf5 64         if self.remote_user_key in environ:
CM 65             # act as a pass through if REMOTE_USER (or whatever) is
66             # already set
c51195 67             return self.app(environ, start_response)
CM 68
993216 69         api = self.api_factory(environ)
88e646 70
993216 71         environ['repoze.who.plugins'] = api.name_registry # BBB?
cb5426 72         environ['repoze.who.logger'] = self.logger
a400b0 73         environ['repoze.who.application'] = self.app
c51195 74
7dfea7 75         logger = self.logger
993216 76         path_info = environ.get('PATH_INFO', None)
88e646 77         logger and logger.info(_STARTED % path_info)
c51195 78         identity = None
b482a1 79         identity = api.authenticate()
c51195 80
a400b0 81         # allow identifier plugins to replace the downstream
CM 82         # application (to do redirection and unauthorized themselves
83         # mostly)
84         app = environ.pop('repoze.who.application')
85         if  app is not self.app:
86             logger and logger.info(
87                 'static downstream application replaced with %s' % app)
88
c51195 89         wrapper = StartResponseWrapper(start_response)
a400b0 90         app_iter = app(environ, wrapper.wrap_start_response)
7dfea7 91
08b2ae 92         # The challenge decider almost(?) always needs information from the
CM 93         # response.  The WSGI spec (PEP 333) states that a WSGI application
94         # must call start_response by the iterable's first iteration.  If
95         # start_response hasn't been called, we'll wrap it in a way that
96         # triggers that call.
97         if not wrapper.called:
98             app_iter = wrap_generator(app_iter)
99
993216 100         if api.challenge_decider(environ, wrapper.status, wrapper.headers):
e99c8b 101             logger and logger.info('challenge required')
493726 102             close = getattr(app_iter, 'close', _no_op)
7dfea7 103
b482a1 104             challenge_app = api.challenge(wrapper.status, wrapper.headers)
c51195 105             if challenge_app is not None:
e99c8b 106                 logger and logger.info('executing challenge app')
c51195 107                 if app_iter:
CM 108                     list(app_iter) # unwind the original app iterator
b01f44 109                 # PEP 333 requires that we call the original iterator's
TS 110                 # 'close' method, if it exists, before releasing it.
111                 close()
c51195 112                 # replace the downstream app with the challenge app
CM 113                 app_iter = challenge_app(environ, start_response)
114             else:
e99c8b 115                 logger and logger.info('configuration error: no challengers')
493726 116                 close()
c51195 117                 raise RuntimeError('no challengers found')
7dfea7 118         else:
97cfa2 119             logger and logger.info('no challenge required')
b482a1 120             remember_headers = api.remember(identity)
e99c8b 121             wrapper.finish_response(remember_headers)
7dfea7 122
88e646 123         logger and logger.info(_ENDED % path_info)
c51195 124         return app_iter
7dfea7 125
493726 126 def _no_op():
TS 127     pass
128
08b2ae 129 def wrap_generator(result):
CM 130     """\
131     This function returns a generator that behaves exactly the same as the
132     original.  It's only difference is it pulls the first iteration off and
133     caches it to trigger any immediate side effects (in a WSGI world, this
134     ensures start_response is called).
135     """
b01f44 136     # PEP 333 requires that we call the original iterator's
TS 137     # 'close' method, if it exists, before releasing it.
138     close = getattr(result, 'close', lambda: None)
08b2ae 139     # Neat trick to pull the first iteration only. We need to do this outside
CM 140     # of the generator function to ensure it is called.
141     for iter in result:
142         first = iter
143         break
144
145     # Wrapper yields the first iteration, then passes result's iterations
146     # directly up.
147     def wrapper():
148         yield first
149         for iter in result:
150             # We'll let result's StopIteration bubble up directly.
151             yield iter
b01f44 152         close()
08b2ae 153     return wrapper()
CM 154
c51195 155 class StartResponseWrapper(object):
CM 156     def __init__(self, start_response):
157         self.start_response = start_response
740830 158         self.status = None
c51195 159         self.headers = []
740830 160         self.exc_info = None
c51195 161         self.buffer = StringIO()
08b2ae 162         # A WSGI app may delay calling start_response until the first iteration
CM 163         # of its generator.  We track this so we know whether or not we need to
164         # trigger an iteration before examining the response.
165         self.called = False
c51195 166
CM 167     def wrap_start_response(self, status, headers, exc_info=None):
168         self.headers = headers
169         self.status = status
740830 170         self.exc_info = exc_info
08b2ae 171         # The response has been initiated, so we have a valid code.
CM 172         self.called = True
c51195 173         return self.buffer.write
CM 174
e99c8b 175     def finish_response(self, extra_headers):
CM 176         if not extra_headers:
177             extra_headers = []
178         headers = self.headers + extra_headers
740830 179         write = self.start_response(self.status, headers, self.exc_info)
c51195 180         if write:
CM 181             self.buffer.seek(0)
182             value = self.buffer.getvalue()
183             if value:
184                 write(value)
185             if hasattr(write, 'close'):
186                 write.close()
7dfea7 187
d85ba6 188 def make_test_middleware(app, global_conf):
80a263 189     """ Functionally equivalent to
CM 190
d7f613 191     [plugin:redirector]
TS 192     use = repoze.who.plugins.redirector.RedirectorPlugin
193     login_url = /login.html
80a263 194
d7f613 195     [plugin:auth_tkt]
TS 196     use = repoze.who.plugins.auth_tkt:AuthTktCookiePlugin
197     secret = SEEKRIT
80a263 198     cookie_name = oatmeal
CM 199
200     [plugin:basicauth]
cb5426 201     use = repoze.who.plugins.basicauth.BasicAuthPlugin
CM 202     realm = repoze.who
80a263 203
CM 204     [plugin:htpasswd]
cb5426 205     use = repoze.who.plugins.htpasswd.HTPasswdPlugin
80a263 206     filename = <...>
cb5426 207     check_fn = repoze.who.plugins.htpasswd:crypt_check
80a263 208
CM 209     [general]
cb5426 210     request_classifier = repoze.who.classifiers:default_request_classifier
CM 211     challenge_decider = repoze.who.classifiers:default_challenge_decider
80a263 212
CM 213     [identifiers]
d7f613 214     plugins = authtkt basicauth
80a263 215
CM 216     [authenticators]
d7f613 217     plugins = authtkt htpasswd
80a263 218
CM 219     [challengers]
d7f613 220     plugins = redirector:browser basicauth
80a263 221     """
7dfea7 222     # be able to test without a config file
cb5426 223     from repoze.who.plugins.basicauth import BasicAuthPlugin
CM 224     from repoze.who.plugins.auth_tkt import AuthTktCookiePlugin
d7f613 225     from repoze.who.plugins.redirector import RedirectorPlugin
cb5426 226     from repoze.who.plugins.htpasswd import HTPasswdPlugin
7dfea7 227     io = StringIO()
c51195 228     for name, password in [ ('admin', 'admin'), ('chris', 'chris') ]:
53e216 229         io.write('%s:%s\n' % (name, password))
c51195 230     io.seek(0)
53e216 231     def cleartext_check(password, hashed):
d32c12 232         return password == hashed #pragma NO COVERAGE
53e216 233     htpasswd = HTPasswdPlugin(io, cleartext_check)
cb5426 234     basicauth = BasicAuthPlugin('repoze.who')
40a968 235     auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt')
d7f613 236     redirector = RedirectorPlugin('/login.html')
TS 237     redirector.classifications = {IChallenger: ['browser']} # only for browser
238     identifiers = [('auth_tkt', auth_tkt),
239                    ('basicauth', basicauth),
240                   ]
c51195 241     authenticators = [('htpasswd', htpasswd)]
d7f613 242     challengers = [('redirector', redirector),
TS 243                    ('basicauth', basicauth)]
d9f046 244     mdproviders = []
cb5426 245     from repoze.who.classifiers import default_request_classifier
CM 246     from repoze.who.classifiers import default_challenge_decider
d9f046 247     log_stream = None
c80cab 248     import os
d9f046 249     if os.environ.get('WHO_LOG'):
CM 250         log_stream = sys.stdout
c51195 251     middleware = PluggableAuthenticationMiddleware(
CM 252         app,
253         identifiers,
254         authenticators,
255         challengers,
b9c2d6 256         mdproviders,
c51195 257         default_request_classifier,
CM 258         default_challenge_decider,
db4cf5 259         log_stream = log_stream,
c51195 260         log_level = logging.DEBUG
7dfea7 261         )
d85ba6 262     return middleware