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