Skip to content

Commit 8cb5296

Browse files
committed
Refactor authentication flow
Solves the concurrency issue brought up in pythongssapi/requests-gssapi#8 by passing the context around through the flow instead of storing it in a global dictionary. Inlined authenticate_user() because it didn't do anything more than set the auth header and handle_401() because it became unnecessarily complicated.
1 parent 314c598 commit 8cb5296

File tree

4 files changed

+75
-115
lines changed

4 files changed

+75
-115
lines changed

Diff for: .github/workflows/tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
python -m pip install -U pip
2727
python -m pip install flake8
2828
- name: Check
29-
run: python -m flake8
29+
run: python -m flake8 httpx_gssapi
3030
test:
3131
name: Test
3232
needs: flake8

Diff for: httpx_gssapi/exceptions.py

-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,3 @@ def __repr__(self):
1919

2020
class SPNEGOExchangeError(HTTPError):
2121
"""SPNEGO Exchange Failed Error"""
22-
23-
24-
""" Deprecated compatability shim """
25-
KerberosExchangeError = SPNEGOExchangeError

Diff for: httpx_gssapi/gssapi_.py

+58-57
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from base64 import b64encode, b64decode
77

88
import gssapi
9+
from gssapi import SecurityContext as SecCtx
910
from gssapi.exceptions import GSSError
1011

1112
import httpx
@@ -34,7 +35,7 @@
3435
_find_auth = re.compile(r'Negotiate\s*([^,]*)', re.I).search
3536

3637

37-
def _negotiate_value(response: Response) -> Optional[str]:
38+
def _negotiate_value(response: Response) -> Optional[bytes]:
3839
"""Extracts the gssapi authentication token from the appropriate header"""
3940
authreq = response.headers.get('www-authenticate', None)
4041
if authreq:
@@ -71,23 +72,27 @@ def _handle_gsserror(*, gss_stage: str, result: Any):
7172
The result to return if a GSSError is raised. If it's an Exception
7273
type, then it will be raised with the logged message.
7374
"""
75+
7476
def _decor(func):
7577
@wraps(func)
7678
def _wrapper(*args, **kwargs):
7779
try:
7880
return func(*args, **kwargs)
79-
except gssapi.exceptions.GSSError as error:
81+
except GSSError as error:
8082
msg = f"{gss_stage} context failed: {error.gen_message()}"
8183
log.exception(f"{func.__name__}(): {msg}")
8284
if isinstance(result, type) and issubclass(result, Exception):
8385
raise result(msg)
8486
return result
87+
8588
return _wrapper
89+
8690
return _decor
8791

8892

8993
class HTTPSPNEGOAuth(Auth):
90-
"""Attaches HTTP GSSAPI Authentication to the given Request object.
94+
"""
95+
Attaches HTTP GSSAPI Authentication to the given Request object.
9196
9297
`mutual_authentication` controls whether GSSAPI should attempt mutual
9398
authentication. It may be `REQUIRED`, `OPTIONAL`, or `DISABLED`
@@ -112,6 +117,7 @@ class HTTPSPNEGOAuth(Auth):
112117
server responses. See the `SanitizedResponse` class.
113118
114119
"""
120+
115121
def __init__(self,
116122
mutual_authentication: int = DISABLED,
117123
target_name: Optional[str] = "HTTP",
@@ -120,7 +126,6 @@ def __init__(self,
120126
creds: gssapi.Credentials = None,
121127
mech: bytes = None,
122128
sanitize_mutual_error_response: bool = True):
123-
self.context = {}
124129
self.mutual_authentication = mutual_authentication
125130
self.target_name = target_name
126131
self.delegate = delegate
@@ -132,42 +137,41 @@ def __init__(self,
132137
def auth_flow(self, request: Request) -> FlowGen:
133138
if self.opportunistic_auth:
134139
# add Authorization header before we receive a 401
135-
auth_header = self.generate_request_header(request.url.host)
136-
137-
log.debug(f"Preemptive Authorization header: {auth_header}")
138-
request.headers['Authorization'] = auth_header
140+
ctx = self.set_auth_header(request)
141+
else:
142+
ctx = None
139143

140144
response = yield request
141-
yield from self.handle_response(response)
145+
yield from self.handle_response(response, ctx)
142146

143-
def handle_response(self, response: Response) -> FlowGen:
147+
def handle_response(self,
148+
response: Response,
149+
ctx: SecCtx = None) -> FlowGen:
144150
num_401s = 0
145151
while response.status_code == 401 and num_401s < 2:
146152
num_401s += 1
147153
log.debug(f"Handling 401 response, total seen: {num_401s}")
148-
try:
149-
response = yield self.handle_401(response)
150-
except httpx.ProtocolError: # GSSAPI isn't supported
154+
155+
if _negotiate_value(response) is None:
156+
log.debug("GSSAPI is not supported")
151157
break
152158

153-
if response.status_code == 401:
154-
log.debug(f"Failed to authenticate, returning 401 response")
155-
return
159+
log.debug("Generating user authentication header")
160+
try:
161+
ctx = self.set_auth_header(response.request, response)
162+
except SPNEGOExchangeError:
163+
log.debug("Failed to generate authentication header")
156164

157-
self.handle_mutual_auth(response)
165+
# Try request again, hopefully with a new auth header
166+
response = yield response.request
158167

159-
def handle_401(self, response: Response) -> Request:
160-
"""Handles 401's, attempts to use GSSAPI authentication"""
161-
log.debug("handle_401(): Handling 401")
162-
if _negotiate_value(response) is None:
163-
log.debug("handle_401(): GSSAPI is not supported")
164-
raise httpx.ProtocolError("GSSAPI is not supported")
168+
if response.status_code == 401 or ctx is None:
169+
log.debug("Failed to authenticate, returning 401 response")
170+
return
165171

166-
request = self.authenticate_user(response)
167-
log.debug(f"handle_401(): returning {request}")
168-
return request
172+
self.handle_mutual_auth(response, ctx)
169173

170-
def handle_mutual_auth(self, response: Response):
174+
def handle_mutual_auth(self, response: Response, ctx: SecCtx):
171175
"""
172176
Handles all responses with the exception of 401s.
173177
@@ -176,14 +180,14 @@ def handle_mutual_auth(self, response: Response):
176180
log.debug(f"handle_mutual_auth(): Handling {response.status_code}")
177181

178182
if self.mutual_authentication == DISABLED:
179-
log.debug(f"handle_mutual_auth(): Mutual auth disabled, ignoring")
183+
log.debug("handle_mutual_auth(): Mutual auth disabled, ignoring")
180184
return
181185

182186
is_http_error = response.status_code >= 400
183187

184188
if _negotiate_value(response) is not None:
185189
log.debug("handle_mutual_auth(): Authenticating the server")
186-
if not self.authenticate_server(response):
190+
if not self.authenticate_server(response, ctx):
187191
# Mutual authentication failure when mutual auth is wanted,
188192
# raise an exception so the user doesn't use an untrusted
189193
# response.
@@ -209,52 +213,49 @@ def handle_mutual_auth(self, response: Response):
209213
raise MutualAuthenticationError(response=response)
210214

211215
@_handle_gsserror(gss_stage='stepping', result=SPNEGOExchangeError)
212-
def generate_request_header(self,
213-
host: str,
214-
response: Response = None) -> str:
216+
def set_auth_header(self,
217+
request: Request,
218+
response: Response = None) -> SecCtx:
215219
"""
216-
Generates the GSSAPI authentication token
220+
Create a new security context, generate the GSSAPI authentication
221+
token, and insert it into the request header. The new security context
222+
will be returned.
217223
218-
If any GSSAPI step fails, raise SPNEGOExchangeError
219-
with failure detail.
224+
If any GSSAPI step fails, raise SPNEGOExchangeError with failure detail.
220225
"""
221-
self.context[host] = self._make_context(host)
222-
223-
token = _negotiate_value(response) if response else None
224-
gss_resp = self.context[host].step(token)
225-
return f"Negotiate {b64encode(gss_resp).decode()}"
226-
227-
def authenticate_user(self, response: Response) -> Request:
228-
"""Handles user authentication with GSSAPI"""
229-
host = response.url.host
230-
try:
231-
auth_header = self.generate_request_header(host, response)
232-
except SPNEGOExchangeError: # GSS Failure, return existing response
233-
log.debug(f"authenticate_user(): Failed to generate auth header")
234-
else:
235-
log.debug(f"authenticate_user(): Auth header: {auth_header}")
236-
response.request.headers['Authorization'] = auth_header
226+
ctx = self._make_context(request.url.host)
227+
228+
token = _negotiate_value(response) if response is not None else None
229+
gss_resp = ctx.step(token or None)
230+
auth_header = f"Negotiate {b64encode(gss_resp).decode()}"
231+
log.debug(
232+
f"add_request_header(): "
233+
f"{'Preemptive ' if token is None else ''}"
234+
f"Authorization header: {auth_header}"
235+
)
237236

238-
return response.request
237+
request.headers['Authorization'] = auth_header
238+
return ctx
239239

240240
@_handle_gsserror(gss_stage="stepping", result=False)
241-
def authenticate_server(self, response: Response) -> bool:
241+
def authenticate_server(self, response: Response, ctx: SecCtx) -> bool:
242242
"""
243-
Uses GSSAPI to authenticate the server.
243+
Uses GSSAPI to authenticate the server by extracting the negotiate
244+
value from the response and stepping the security context.
244245
245246
Returns True on success, False on failure.
246247
"""
247248
auth_header = _negotiate_value(response)
248249
log.debug(f"authenticate_server(): Authenticate header: {auth_header}")
249250

250251
# If the handshake isn't complete here, nothing we can do
251-
self.context[response.url.host].step(auth_header)
252+
ctx.step(auth_header)
252253

253254
log.debug("authenticate_server(): authentication successful")
254255
return True
255256

256257
@_handle_gsserror(gss_stage="initializing", result=SPNEGOExchangeError)
257-
def _make_context(self, host: str) -> gssapi.SecurityContext:
258+
def _make_context(self, host: str) -> SecCtx:
258259
"""
259260
Create a GSSAPI security context for handling the authentication.
260261
@@ -268,7 +269,7 @@ def _make_context(self, host: str) -> gssapi.SecurityContext:
268269
name += f"@{host}"
269270
name = gssapi.Name(name, gssapi.NameType.hostbased_service)
270271

271-
return gssapi.SecurityContext(
272+
return SecCtx(
272273
usage="initiate",
273274
flags=self._gssflags,
274275
name=name,

0 commit comments

Comments
 (0)