diff -r 2a18f6b85da2 Doc/library/urllib.request.rst --- a/Doc/library/urllib.request.rst Sun Apr 12 18:47:56 2015 -0400 +++ b/Doc/library/urllib.request.rst Wed Apr 15 19:52:17 2015 -0400 @@ -282,7 +282,13 @@ ``None`` is considered a catch-all realm, which is searched if no other realm fits. +.. class:: HTTPPasswordMgrWithPriorAuth() + A variant of :class:`HTTPPasswordMgrWithDefaultRealm` that also has a + database of ``(realm, uri) -> (is_authenticated)`` mappings. Allows + :class:`HTTPPasswordMgrWithPriorAuth` to send credentials everytime without + waiting for a 401 response first. + .. class:: AbstractBasicAuthHandler(password_mgr=None) This is a mixin class that helps with HTTP authentication, both to the remote @@ -304,11 +310,19 @@ .. class:: HTTPBasicPriorAuthHandler(password_mgr=None) A variant of :class:`HTTPBasicAuthHandler` which automatically sends - authorization credentials with the first request, rather than waiting to - first receive a HTTP 401 "Unauthorised" error response. This allows - authentication to sites that don't provide a 401 response when receiving - a request without an Authorization header. Aside from this difference, - this behaves exactly as :class:`HTTPBasicAuthHandler`. + authorization credentials after first authenticated request, + rather than waiting to first receive a HTTP 401 "Unauthorised" error + response on each request. + + For sites that don't provide a 401 response when receiving a request without + an Authorization header, specify ``is_authenticated=True`` in + :class:`HTTPPasswordMgrWithPriorAuth` to not even wait for the first + authenticated response. + + For sites that do provide a 401 response, ``is_authenticated`` is updated to + ``True`` after first successful response. + + Aside from this difference, this behaves exactly as :class:`HTTPBasicAuthHandler`. .. versionadded:: 3.5 @@ -851,6 +865,33 @@ For :class:`HTTPPasswordMgrWithDefaultRealm` objects, the realm ``None`` will be searched if the given *realm* has no matching user/password. +.. _http-password-mgr-with-prior-auth: + +HTTPPasswordMgrWithPriorAuth Objects +------------------------------------ + + +.. method:: HTTPPasswordMgrWithPriorAuth.update_authenticated(self, realm, uri, is_authenticated=False) + + Updates the ``(realm, uri) -> (is_authenticated)`` mapping. + If ``is_authenticated`` is ``True`` for ``(realm, uri)`` + :class:HTTPBasicPriorAuthHandler(password_mgr=None) will send credentials + with every request. + +.. method:: HTTPPasswordMgrWithPriorAuth.add_password(realm, uri, user, passwd, is_authenticated=False) + + If ``is_authenticated`` is ``True`` for ``(realm, uri)`` + :class:HTTPBasicPriorAuthHandler(password_mgr=None) will send credentails + with every request. Rest same as :meth:`HTTPPasswordMgr.add_password` + +.. method:: HTTPPasswordMgr.find_user_password(realm, authuri) + + Same as for :class:`HTTPPasswordMgrWithDefaultRealm` objects + +.. method:: HTTPPasswordMgrWithPriorAuth.is_authenticated(self, realm, authuri) + + Checks if given realm and URI combination were authenticated before. + .. _abstract-basic-auth-handler: diff -r 2a18f6b85da2 Lib/test/test_urllib2.py --- a/Lib/test/test_urllib2.py Sun Apr 12 18:47:56 2015 -0400 +++ b/Lib/test/test_urllib2.py Wed Apr 15 19:52:17 2015 -0400 @@ -11,7 +11,9 @@ import urllib.request # The proxy bypass method imported below has logic specific to the OSX # proxy config data structure but is testable on all platforms. -from urllib.request import Request, OpenerDirector, _parse_proxy, _proxy_bypass_macosx_sysconf +from urllib.request import (Request, OpenerDirector, HTTPBasicPriorAuthHandler, + HTTPPasswordMgrWithPriorAuth, _parse_proxy, + _proxy_bypass_macosx_sysconf) from urllib.parse import urlparse import urllib.error import http.client @@ -447,6 +449,25 @@ def https_open(self, req): return self.do_open(self.httpconn, req) + +class MockHTTPHandlerCheckAuth(urllib.request.BaseHandler): + # useful for testing auth + # sends supplied code response + # checks if auth header is specified in request + def __init__(self, code): + self.code = code + self.has_auth_header = False + + def reset(self): + self.has_auth_header = False + + def http_open(self, req): + if req.has_header('Authorization'): + self.has_auth_header = True + name = http.client.responses[self.code] + return MockResponse(self.code, name, MockFile(), "", req.get_full_url()) + + class MockPasswordManager: def add_password(self, realm, uri, user, password): self.realm = realm @@ -1395,6 +1416,71 @@ self.assertEqual(len(http_handler.requests), 1) self.assertFalse(http_handler.requests[0].has_header(auth_header)) + def test_basic_prior_auth_auto_send(self): + # Assume already authenticated if is_authenticated=True + # for APIs like Github that don't return 401 + + user, password = "wile", "coyote" + request_url = "http://acme.example.com/protected" + + http_handler = MockHTTPHandlerCheckAuth(200) + + pwd_manager = HTTPPasswordMgrWithPriorAuth() + auth_prior_handler = HTTPBasicPriorAuthHandler(pwd_manager) + auth_prior_handler.add_password( + None, request_url, user, password, is_authenticated=True) + + is_auth = pwd_manager.is_authenticated(None, request_url) + self.assertTrue(is_auth) + + opener = OpenerDirector() + opener.add_handler(auth_prior_handler) + opener.add_handler(http_handler) + + opener.open(request_url) + + # expect request to be sent with auth header + self.assertTrue(http_handler.has_auth_header) + + def test_basic_prior_auth_send_after_first_success(self): + # Auto send auth header after authentication is successful once + + user, password = "wile", "coyote" + request_url = "http://acme.example.com/protected" + + pwd_manager = HTTPPasswordMgrWithPriorAuth() + auth_prior_handler = HTTPBasicPriorAuthHandler(pwd_manager) + auth_prior_handler.add_password(None, request_url, user, password) + + is_auth = pwd_manager.is_authenticated(None, request_url) + self.assertFalse(is_auth) + + opener = OpenerDirector() + opener.add_handler(auth_prior_handler) + + http_handler = MockHTTPHandler( + 401, 'WWW-Authenticate: Basic realm="%s"\r\n\r\n' % None) + opener.add_handler(http_handler) + + opener.open(request_url) + + is_auth = pwd_manager.is_authenticated(None, request_url) + self.assertTrue(is_auth) + + http_handler = MockHTTPHandlerCheckAuth(200) + self.assertFalse(http_handler.has_auth_header) + + opener = OpenerDirector() + opener.add_handler(auth_prior_handler) + opener.add_handler(http_handler) + + # After getting 200 from MockHTTPHandler + # Next request sends header in the first request + opener.open(request_url) + + # expect request to be sent with auth header + self.assertTrue(http_handler.has_auth_header) + def test_http_closed(self): """Test the connection is cleaned up when the response is closed""" for (transfer, data) in ( @@ -1422,21 +1508,6 @@ handler.do_open(conn, req) self.assertTrue(conn.fakesock.closed, "Connection not closed") - def test_auth_prior_handler(self): - pwd_manager = MockPasswordManager() - pwd_manager.add_password(None, 'https://example.com', - 'somebody', 'verysecret') - auth_prior_handler = urllib.request.HTTPBasicPriorAuthHandler( - pwd_manager) - http_hand = MockHTTPSHandler() - - opener = OpenerDirector() - opener.add_handler(http_hand) - opener.add_handler(auth_prior_handler) - - req = Request("https://example.com") - opener.open(req) - self.assertNotIn('Authorization', http_hand.httpconn.req_headers) class MiscTests(unittest.TestCase): diff -r 2a18f6b85da2 Lib/urllib/request.py --- a/Lib/urllib/request.py Sun Apr 12 18:47:56 2015 -0400 +++ b/Lib/urllib/request.py Wed Apr 15 19:52:17 2015 -0400 @@ -120,9 +120,10 @@ 'Request', 'OpenerDirector', 'BaseHandler', 'HTTPDefaultErrorHandler', 'HTTPRedirectHandler', 'HTTPCookieProcessor', 'ProxyHandler', 'HTTPPasswordMgr', 'HTTPPasswordMgrWithDefaultRealm', - 'AbstractBasicAuthHandler', 'HTTPBasicAuthHandler', 'ProxyBasicAuthHandler', - 'AbstractDigestAuthHandler', 'HTTPDigestAuthHandler', 'ProxyDigestAuthHandler', - 'HTTPHandler', 'FileHandler', 'FTPHandler', 'CacheFTPHandler', 'DataHandler', + 'HTTPPasswordMgrWithPriorAuth', 'AbstractBasicAuthHandler', + 'HTTPBasicAuthHandler', 'ProxyBasicAuthHandler', 'AbstractDigestAuthHandler', + 'HTTPDigestAuthHandler', 'ProxyDigestAuthHandler', 'HTTPHandler', + 'FileHandler', 'FTPHandler', 'CacheFTPHandler', 'DataHandler', 'UnknownHandler', 'HTTPErrorProcessor', # Functions 'urlopen', 'install_opener', 'build_opener', @@ -835,6 +836,39 @@ return HTTPPasswordMgr.find_user_password(self, None, authuri) +class HTTPPasswordMgrWithPriorAuth(HTTPPasswordMgrWithDefaultRealm): + + def __init__(self, *args, **kwargs): + self.authenticated = {} + super().__init__(*args, **kwargs) + + def add_password(self, realm, uri, user, passwd, is_authenticated=False): + self.update_authenticated(realm, uri, is_authenticated) + super().add_password(realm, uri, user, passwd) + + def update_authenticated(self, realm, uri, is_authenticated=False): + # uri could be a single URI or a sequence + if isinstance(uri, str): + uri = [uri] + if realm not in self.authenticated: + self.authenticated[realm] = {} + + for default_port in True, False: + reduced_uri = tuple( + [self.reduce_uri(u, default_port) for u in uri]) + self.authenticated[realm][reduced_uri] = is_authenticated + + def is_authenticated(self, realm, authuri): + domains = self.authenticated.get(realm, {}) + for default_port in True, False: + reduced_authuri = self.reduce_uri(authuri, default_port) + for uris, is_authenticated in domains.items(): + for uri in uris: + if self.is_suburi(uri, reduced_authuri): + return is_authenticated + return False + + class AbstractBasicAuthHandler: # XXX this allows for multiple auth-schemes, but will stupidly pick @@ -920,15 +954,27 @@ handler_order = 400 def http_request(self, req): + if (not hasattr(self.passwd, 'is_authenticated') or + not self.passwd.is_authenticated(None, req.full_url)): + return req + if not req.has_header('Authorization'): - user, passwd = self.passwd.find_user_password(None, req.host) + user, passwd = self.passwd.find_user_password(None, req.full_url) credentials = '{0}:{1}'.format(user, passwd).encode() auth_str = base64.standard_b64encode(credentials).decode() req.add_unredirected_header('Authorization', 'Basic {}'.format(auth_str.strip())) return req + def http_response(self, req, response): + if 200 <= response.code < 300: + self.passwd.update_authenticated(None, req.full_url, True) + else: + self.passwd.update_authenticated(None, req.full_url, False) + return response + https_request = http_request + https_response = http_response # Return n random bytes.