Module exchangelib.transport
Expand source code
import logging
import time
import requests.auth
import requests_ntlm
import requests_oauthlib
from .errors import UnauthorizedError, TransportError
from .util import create_element, add_xml_child, xml_to_str, ns_translation, _back_off_if_needed, \
_retry_after, DummyResponse, CONNECTION_ERRORS
log = logging.getLogger(__name__)
# Authentication method enums
NOAUTH = 'no authentication'
NTLM = 'NTLM'
BASIC = 'basic'
DIGEST = 'digest'
GSSAPI = 'gssapi'
SSPI = 'sspi'
OAUTH2 = 'OAuth 2.0'
CBA = 'CBA' # Certificate Based Authentication
# The auth types that must be accompanied by a credentials object
CREDENTIALS_REQUIRED = (NTLM, BASIC, DIGEST, OAUTH2)
AUTH_TYPE_MAP = {
NTLM: requests_ntlm.HttpNtlmAuth,
BASIC: requests.auth.HTTPBasicAuth,
DIGEST: requests.auth.HTTPDigestAuth,
OAUTH2: requests_oauthlib.OAuth2,
CBA: None,
NOAUTH: None,
}
try:
import requests_gssapi
AUTH_TYPE_MAP[GSSAPI] = requests_gssapi.HTTPSPNEGOAuth
except ImportError:
# Kerberos auth is optional
pass
try:
import requests_negotiate_sspi
AUTH_TYPE_MAP[SSPI] = requests_negotiate_sspi.HttpNegotiateAuth
except ImportError:
# SSPI auth is optional
pass
DEFAULT_ENCODING = 'utf-8'
DEFAULT_HEADERS = {'Content-Type': 'text/xml; charset=%s' % DEFAULT_ENCODING, 'Accept-Encoding': 'gzip, deflate'}
def wrap(content, api_version, account_to_impersonate=None, timezone=None):
"""Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version.
ExchangeImpersonation allows to act as the user we want to impersonate.
RequestServerVersion element on MSDN:
https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion
ExchangeImpersonation element on MSDN:
https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation
TimeZoneContent element on MSDN:
https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext
:param content:
:param api_version:
:param account_to_impersonate: (Default value = None)
:param timezone: (Default value = None)
"""
envelope = create_element('s:Envelope', nsmap=ns_translation)
header = create_element('s:Header')
requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version))
header.append(requestserverversion)
if account_to_impersonate:
exchangeimpersonation = create_element('t:ExchangeImpersonation')
connectingsid = create_element('t:ConnectingSID')
# We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with
# https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid
for attr, tag in (
('sid', 'SID'),
('upn', 'PrincipalName'),
('smtp_address', 'SmtpAddress'),
('primary_smtp_address', 'PrimarySmtpAddress'),
):
val = getattr(account_to_impersonate, attr)
if val:
add_xml_child(connectingsid, 't:%s' % tag, val)
break
exchangeimpersonation.append(connectingsid)
header.append(exchangeimpersonation)
if timezone:
timezonecontext = create_element('t:TimeZoneContext')
timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id))
timezonecontext.append(timezonedefinition)
header.append(timezonecontext)
envelope.append(header)
body = create_element('s:Body')
body.append(content)
envelope.append(body)
return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
def get_auth_instance(auth_type, **kwargs):
"""Return an *Auth instance suitable for the requests package.
:param auth_type:
:param kwargs:
"""
model = AUTH_TYPE_MAP[auth_type]
if model is None:
return None
if auth_type == GSSAPI:
# Kerberos auth relies on credentials supplied via a ticket available externally to this library
return model()
if auth_type == SSPI:
# SSPI auth does not require credentials, but can have it
return model(**kwargs)
return model(**kwargs)
def get_service_authtype(service_endpoint, retry_policy, api_versions, name):
# Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error prone, and some servers
# are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx
#
# We don't know the API version yet, but we need it to create a valid request because some Exchange servers only
# respond when given a valid request. Try all known versions. Gross.
from .protocol import BaseProtocol
retry = 0
wait = 10 # seconds
t_start = time.monotonic()
headers = DEFAULT_HEADERS.copy()
for api_version in api_versions:
data = dummy_xml(api_version=api_version, name=name)
log.debug('Requesting %s from %s', data, service_endpoint)
while True:
_back_off_if_needed(retry_policy.back_off_until)
log.debug('Trying to get service auth type for %s', service_endpoint)
with BaseProtocol.raw_session(service_endpoint) as s:
try:
r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False,
timeout=BaseProtocol.TIMEOUT)
r.close() # Release memory
break
except CONNECTION_ERRORS as e:
# Don't retry on TLS errors. They will most likely be persistent.
total_wait = time.monotonic() - t_start
r = DummyResponse(url=service_endpoint, headers={}, request_headers=headers)
if retry_policy.may_retry_on_error(response=r, wait=total_wait):
wait = _retry_after(r, wait)
log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs",
service_endpoint, retry, e, wait)
retry_policy.back_off(wait)
retry += 1
continue
raise TransportError(str(e)) from e
if r.status_code not in (200, 401):
log.debug('Unexpected response: %s %s', r.status_code, r.reason)
continue
try:
auth_type = get_auth_method_from_response(response=r)
log.debug('Auth type is %s', auth_type)
return auth_type, api_version
except UnauthorizedError:
continue
raise TransportError('Failed to get auth type from service')
def get_auth_method_from_response(response):
# First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller.
log.debug('Request headers: %s', response.request.headers)
log.debug('Response headers: %s', response.headers)
if response.status_code == 200:
return NOAUTH
# Get auth type from headers
for key, val in response.headers.items():
if key.lower() == 'www-authenticate':
# Requests will combine multiple HTTP headers into one in 'request.headers'
vals = _tokenize(val.lower())
for v in vals:
if v.startswith('realm'):
realm = v.split('=')[1].strip('"')
log.debug('realm: %s', realm)
# Prefer most secure auth method if more than one is offered. See discussion at
# http://docs.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html
if 'digest' in vals:
return DIGEST
if 'ntlm' in vals:
return NTLM
if 'basic' in vals:
return BASIC
raise UnauthorizedError('No compatible auth type was reported by server')
def _tokenize(val):
# Splits cookie auth values
auth_methods = []
auth_method = ''
quote = False
for c in val:
if c in (' ', ',') and not quote:
if auth_method not in ('', ','):
auth_methods.append(auth_method)
auth_method = ''
continue
if c == '"':
auth_method += c
if quote:
auth_methods.append(auth_method)
auth_method = ''
quote = not quote
continue
auth_method += c
if auth_method:
auth_methods.append(auth_method)
return auth_methods
def dummy_xml(api_version, name):
# Generate a minimal, valid EWS request
from .services import ResolveNames # Avoid circular import
return wrap(content=ResolveNames(protocol=None).get_payload(
unresolved_entries=[name],
parent_folders=None,
return_full_contact_data=False,
search_scope=None,
contact_data_shape=None,
), api_version=api_version)
Functions
def dummy_xml(api_version, name)
-
Expand source code
def dummy_xml(api_version, name): # Generate a minimal, valid EWS request from .services import ResolveNames # Avoid circular import return wrap(content=ResolveNames(protocol=None).get_payload( unresolved_entries=[name], parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None, ), api_version=api_version)
def get_auth_instance(auth_type, **kwargs)
-
Return an *Auth instance suitable for the requests package.
:param auth_type: :param kwargs:
Expand source code
def get_auth_instance(auth_type, **kwargs): """Return an *Auth instance suitable for the requests package. :param auth_type: :param kwargs: """ model = AUTH_TYPE_MAP[auth_type] if model is None: return None if auth_type == GSSAPI: # Kerberos auth relies on credentials supplied via a ticket available externally to this library return model() if auth_type == SSPI: # SSPI auth does not require credentials, but can have it return model(**kwargs) return model(**kwargs)
def get_auth_method_from_response(response)
-
Expand source code
def get_auth_method_from_response(response): # First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller. log.debug('Request headers: %s', response.request.headers) log.debug('Response headers: %s', response.headers) if response.status_code == 200: return NOAUTH # Get auth type from headers for key, val in response.headers.items(): if key.lower() == 'www-authenticate': # Requests will combine multiple HTTP headers into one in 'request.headers' vals = _tokenize(val.lower()) for v in vals: if v.startswith('realm'): realm = v.split('=')[1].strip('"') log.debug('realm: %s', realm) # Prefer most secure auth method if more than one is offered. See discussion at # http://docs.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html if 'digest' in vals: return DIGEST if 'ntlm' in vals: return NTLM if 'basic' in vals: return BASIC raise UnauthorizedError('No compatible auth type was reported by server')
def get_service_authtype(service_endpoint, retry_policy, api_versions, name)
-
Expand source code
def get_service_authtype(service_endpoint, retry_policy, api_versions, name): # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error prone, and some servers # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx # # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol retry = 0 wait = 10 # seconds t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: data = dummy_xml(api_version=api_version, name=name) log.debug('Requesting %s from %s', data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) log.debug('Trying to get service auth type for %s', service_endpoint) with BaseProtocol.raw_session(service_endpoint) as s: try: r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False, timeout=BaseProtocol.TIMEOUT) r.close() # Release memory break except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. total_wait = time.monotonic() - t_start r = DummyResponse(url=service_endpoint, headers={}, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): wait = _retry_after(r, wait) log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", service_endpoint, retry, e, wait) retry_policy.back_off(wait) retry += 1 continue raise TransportError(str(e)) from e if r.status_code not in (200, 401): log.debug('Unexpected response: %s %s', r.status_code, r.reason) continue try: auth_type = get_auth_method_from_response(response=r) log.debug('Auth type is %s', auth_type) return auth_type, api_version except UnauthorizedError: continue raise TransportError('Failed to get auth type from service')
def wrap(content, api_version, account_to_impersonate=None, timezone=None)
-
Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. ExchangeImpersonation allows to act as the user we want to impersonate.
RequestServerVersion element on MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion
ExchangeImpersonation element on MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation
TimeZoneContent element on MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext
:param content: :param api_version: :param account_to_impersonate: (Default value = None) :param timezone: (Default value = None)
Expand source code
def wrap(content, api_version, account_to_impersonate=None, timezone=None): """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. ExchangeImpersonation allows to act as the user we want to impersonate. RequestServerVersion element on MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion ExchangeImpersonation element on MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation TimeZoneContent element on MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext :param content: :param api_version: :param account_to_impersonate: (Default value = None) :param timezone: (Default value = None) """ envelope = create_element('s:Envelope', nsmap=ns_translation) header = create_element('s:Header') requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) header.append(requestserverversion) if account_to_impersonate: exchangeimpersonation = create_element('t:ExchangeImpersonation') connectingsid = create_element('t:ConnectingSID') # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid for attr, tag in ( ('sid', 'SID'), ('upn', 'PrincipalName'), ('smtp_address', 'SmtpAddress'), ('primary_smtp_address', 'PrimarySmtpAddress'), ): val = getattr(account_to_impersonate, attr) if val: add_xml_child(connectingsid, 't:%s' % tag, val) break exchangeimpersonation.append(connectingsid) header.append(exchangeimpersonation) if timezone: timezonecontext = create_element('t:TimeZoneContext') timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) timezonecontext.append(timezonedefinition) header.append(timezonecontext) envelope.append(header) body = create_element('s:Body') body.append(content) envelope.append(body) return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)