Skip to content

Commit

Permalink
Support for Federated Authentication with SharePoint Online (related …
Browse files Browse the repository at this point in the history
…with #170)
  • Loading branch information
vgrem committed Feb 21, 2020
1 parent 7bda8d2 commit c956eeb
Show file tree
Hide file tree
Showing 9 changed files with 87 additions and 62 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ Office 365 & Microsoft Graph Library for Python

## Status

[![Build Status](https://travis-ci.org/vgrem/Office365-REST-Python-Client.svg?branch=master)](https://travis-ci.org/vgrem/Office365-REST-Python-Client)
[![Downloads](https://pepy.tech/badge/office365-rest-python-client)](https://pepy.tech/project/office365-rest-python-client)
[![PyPI](https://img.shields.io/pypi/v/Office365-REST-Python-Client.svg)](https://pypi.python.org/pypi/Office365-REST-Python-Client)
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/Office365-REST-Python-Client.svg)](https://pypi.python.org/pypi/Office365-REST-Python-Client/)
[![Build Status](https://travis-ci.org/vgrem/Office365-REST-Python-Client.svg?branch=master)](https://travis-ci.org/vgrem/Office365-REST-Python-Client)

# Installation

Expand Down
1 change: 0 additions & 1 deletion examples/sharepoint/file_operations.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
import os

from settings import settings
Expand Down
3 changes: 1 addition & 2 deletions examples/sharepoint/web_read_direct.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import json

from settings import settings

from office365.runtime.auth.authentication_context import AuthenticationContext
from office365.runtime.utilities.request_options import RequestOptions
from office365.sharepoint.client_context import ClientContext
Expand All @@ -11,6 +9,7 @@
context_auth = AuthenticationContext(url=settings['url'])
if context_auth.acquire_token_for_user(username=settings['user_credentials']['username'],
password=settings['user_credentials']['password']):

"""Read Web client object"""
ctx = ClientContext(settings['url'], context_auth)
options = RequestOptions("{0}/_api/web/".format(settings['url']))
Expand Down
6 changes: 3 additions & 3 deletions office365/directory/directoryObjectCollection.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from office365.directory.directoryObject import DirectoryObject
from office365.runtime.client_object_collection import ClientObjectCollection
from office365.runtime.client_query import ServiceOperationQuery
from office365.runtime.client_result import ClientResult
from office365.runtime.resource_path import ResourcePath
from office365.directory.user import User


class DirectoryObjectCollection(ClientObjectCollection):
"""User's collection"""

def __getitem__(self, key):
return User(self.context,
ResourcePath(key, self.resourcePath))
return DirectoryObject(self.context,
ResourcePath(key, self.resourcePath))

def getByIds(self, ids):
"""Returns the directory objects specified in a list of IDs."""
Expand Down
87 changes: 49 additions & 38 deletions office365/runtime/auth/saml_token_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import requests.utils
import office365.logger
from office365.runtime.auth.base_token_provider import BaseTokenProvider
from office365.runtime.auth.sts_info import STSInfo
from office365.runtime.auth.sts_profile import STSProfile
from office365.runtime.auth.user_realm_info import UserRealmInfo

office365.logger.ensure_debug_secrets()
Expand All @@ -18,12 +18,10 @@ def __init__(self, authority_url, username, password):
self.__username = username
self.__password = password
# Security Token Service info
self.sts = STSInfo(authority_url)
self.__sts_profile = STSProfile(authority_url)
# Last occurred error
self.error = ''
self.FedAuth = None
self.rtFa = None
self._auth_cookies = []
self._auth_cookies = {}
self.__ns_prefixes = {
'S': '{http://www.w3.org/2003/05/soap-envelope}',
's': '{http://www.w3.org/2003/05/soap-envelope}',
Expand Down Expand Up @@ -53,15 +51,14 @@ def acquire_token(self, **kwargs):
token = self.acquire_service_token_from_adfs(user_realm.STSAuthUrl, self.__username, self.__password)
else:
token = self.acquire_service_token(self.__username, self.__password)
self.acquire_authentication_cookie(token)
return True
return self.acquire_authentication_cookie(token, user_realm.IsFederated)
except requests.exceptions.RequestException as e:
self.error = "Error: {}".format(e)
return False

def get_user_realm(self, login):
"""Get User Realm"""
response = requests.post(self.sts.userRealmServiceUrl, data="login={0}&xml=1".format(login),
response = requests.post(self.__sts_profile.userRealmServiceUrl, data="login={0}&xml=1".format(login),
headers={'Content-Type': 'application/x-www-form-urlencoded'})
xml = ElementTree.fromstring(response.content)
node = xml.find('NameSpaceType')
Expand All @@ -76,9 +73,8 @@ def get_user_realm(self, login):
def get_authentication_cookie(self):
"""Generate Auth Cookie"""
logger = self.logger(self.get_authentication_cookie.__name__)

logger.debug_secrets("self.FedAuth: %s\nself.rtFa: %s", self.FedAuth, self.rtFa)
return 'FedAuth=' + self.FedAuth + '; rtFa=' + self.rtFa
logger.debug_secrets(self._auth_cookies)
return "; ".join(["=".join([key, str(val)]) for key, val in self._auth_cookies.items()])

def get_last_error(self):
return self.error
Expand All @@ -90,9 +86,9 @@ def acquire_service_token_from_adfs(self, adfs_url, username, password):
'username': username,
'password': password,
'message_id': str(uuid.uuid4()),
'created': self.sts.created,
'expires': self.sts.expires,
'issuer': self.sts.federationTokenIssuer
'created': self.__sts_profile.created,
'expires': self.__sts_profile.expires,
'issuer': self.__sts_profile.federationTokenIssuer
})
response = requests.post(adfs_url, data=payload,
headers={'Content-Type': 'application/soap+xml; charset=utf-8'})
Expand All @@ -107,10 +103,11 @@ def acquire_service_token_from_adfs(self, adfs_url, username, password):
logger.error(self.error)
return None
# 2. prepare & submit token request
self.sts.securityTokenServicePath = 'rst2.srf'
self.__sts_profile.signInPage = '_vti_bin/idcrl.svc'
self.__sts_profile.securityTokenServicePath = 'rst2.srf'
template = self._prepare_request_from_template('RST2.xml', {
'auth_url': self.sts.authorityUrl,
'serviceTokenUrl': self.sts.securityTokenServiceUrl
'auth_url': self.__sts_profile.authorityUrl,
'serviceTokenUrl': self.__sts_profile.securityTokenServiceUrl
})
template_xml = ElementTree.fromstring(template)
security_node = template_xml.find(
Expand All @@ -119,7 +116,7 @@ def acquire_service_token_from_adfs(self, adfs_url, username, password):
security_node.insert(1, assertion_node)
payload = ElementTree.tostring(template_xml).decode()
# 3. get token
response = requests.post(self.sts.securityTokenServiceUrl, data=payload,
response = requests.post(self.__sts_profile.securityTokenServiceUrl, data=payload,
headers={'Content-Type': 'application/soap+xml'})
token = self._process_service_token_response(response)
logger.debug_secrets('security token: %s', token)
Expand All @@ -133,16 +130,16 @@ def acquire_service_token(self, username, password, service_target=None, service
"""Retrieve service token"""
logger = self.logger(self.acquire_service_token.__name__)
payload = self._prepare_request_from_template('SAML.xml', {
'auth_url': self.sts.authorityUrl,
'auth_url': self.__sts_profile.authorityUrl,
'username': username,
'password': password,
'message_id': str(uuid.uuid4()),
'created': self.sts.created,
'expires': self.sts.expires,
'issuer': self.sts.federationTokenIssuer
'created': self.__sts_profile.created,
'expires': self.__sts_profile.expires,
'issuer': self.__sts_profile.federationTokenIssuer
})
logger.debug_secrets('options: %s', payload)
response = requests.post(self.sts.securityTokenServiceUrl, data=payload,
response = requests.post(self.__sts_profile.securityTokenServiceUrl, data=payload,
headers={'Content-Type': 'application/x-www-form-urlencoded'})
token = self._process_service_token_response(response)
logger.debug_secrets('security token: %s', token)
Expand All @@ -161,8 +158,9 @@ def _process_service_token_response(self, response):

# check for errors
if xml.find('{0}Body/{0}Fault'.format(self.__ns_prefixes['s'])) is not None:
error = xml.find('{0}Body/{0}Fault/{0}Detail/{1}error/{1}internalerror/{1}text'.format(self.__ns_prefixes['s'],
self.__ns_prefixes['psf']))
error = xml.find(
'{0}Body/{0}Fault/{0}Detail/{1}error/{1}internalerror/{1}text'.format(self.__ns_prefixes['s'],
self.__ns_prefixes['psf']))
if error is None:
self.error = 'An error occurred while retrieving token from XML response.'
else:
Expand All @@ -175,30 +173,43 @@ def _process_service_token_response(self, response):
'{0}Body/{1}RequestSecurityTokenResponse/{1}RequestedSecurityToken/{2}BinarySecurityToken'.format(
self.__ns_prefixes['s'], self.__ns_prefixes['wst'], self.__ns_prefixes['wsse']))
if token is None:
self.error = 'Cannot get binary security token for from {0}'.format(self.sts.securityTokenServiceUrl)
self.error = 'Cannot get binary security token for from {0}'.format(
self.__sts_profile.securityTokenServiceUrl)
logger.error(self.error)
return None
logger.debug_secrets("token: %s", token)
return token.text

def acquire_authentication_cookie(self, security_token):
"""Retrieve SPO auth cookie"""
def acquire_authentication_cookie(self, security_token, federated=False):
"""Retrieve auth cookie from STS"""
logger = self.logger(self.acquire_authentication_cookie.__name__)
session = requests.session()
logger.debug_secrets("session: %s\nsession.post(%s, data=%s)", session, self.sts.signInPageUrl,
logger.debug_secrets("session: %s\nsession.post(%s, data=%s)", session, self.__sts_profile.signInPageUrl,
security_token)
session.post(self.sts.signInPageUrl, data=security_token,
headers={'Content-Type': 'application/x-www-form-urlencoded'})
if not federated:
self._auth_cookies['FedAuth'] = None
self._auth_cookies['rtFa'] = None
session.post(self.__sts_profile.signInPageUrl, data=security_token,
headers={'Content-Type': 'application/x-www-form-urlencoded'})
else:
self._auth_cookies['SPOIDCRL'] = None
session.head(self.__sts_profile.signInPageUrl,
headers={
'User-Agent': 'Office365 Python Client',
'X-IDCRL_ACCEPTED': 't',
'Authorization': 'BPOSIDCRL {0}'.format(security_token),
'Content-Type': 'application/x-www-form-urlencoded'
})
logger.debug_secrets("session.cookies: %s", session.cookies)
cookies = requests.utils.dict_from_cookiejar(session.cookies)
logger.debug_secrets("cookies: %s", cookies)
if 'FedAuth' in cookies and 'rtFa' in cookies:
self.FedAuth = cookies['FedAuth']
self.rtFa = cookies['rtFa']
return True
self.error = "An error occurred while retrieving auth cookies"
logger.error(self.error)
return False
if not cookies:
self.error = "An error occurred while retrieving auth cookies from {0}".format(self.__sts_profile.signInPageUrl)
logger.error(self.error)
return False
for name in self._auth_cookies.keys():
self._auth_cookies[name] = cookies[name]
return True

@staticmethod
def _prepare_request_from_template(template_name, params):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import datetime


class STSInfo(object):
class STSProfile(object):

def __init__(self, authority_url):
self.authorityUrl = authority_url
Expand All @@ -11,14 +11,15 @@ def __init__(self, authority_url):
self.federationTokenIssuer = 'urn:federation:MicrosoftOnline'
self.created = datetime.datetime.now()
self.expires = self.created + datetime.timedelta(minutes=10)
self.signInPage = '_forms/default.aspx?wa=wsignin1.0'

@property
def securityTokenServiceUrl(self):
return "/".join([self.serviceUrl, self.securityTokenServicePath])

@property
def signInPageUrl(self):
return "/".join([self.authorityUrl, '_forms/default.aspx?wa=wsignin1.0'])
return "/".join([self.authorityUrl, self.signInPage])

@property
def userRealmServiceUrl(self):
Expand Down
16 changes: 8 additions & 8 deletions office365/runtime/client_object_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class ClientObjectCollection(ClientObject):

def __init__(self, context, item_type, resource_path=None):
super(ClientObjectCollection, self).__init__(context, resource_path)
self.__data = []
self._data = []
self.__next_query_url = None
self._item_type = item_type

Expand All @@ -21,21 +21,21 @@ def create_typed_object(self, properties, client_object_type):
return client_object

def map_json(self, json):
self.__data = []
self._data = []
for properties in json["collection"]:
child_client_object = self.create_typed_object(properties, self._item_type)
self.add_child(child_client_object)
self.__next_query_url = json["next"]

def add_child(self, client_object):
client_object._parent_collection = self
self.__data.append(client_object)
self._data.append(client_object)

def remove_child(self, client_object):
self.__data.remove(client_object)
self._data.remove(client_object)

def __iter__(self):
for _object in self.__data:
for _object in self._data:
yield _object
while self.__next_query_url:
# create a request with the __next_query_url
Expand All @@ -61,15 +61,15 @@ def __len__(self):
# resolve all items first
list(iter(self))

return len(self.__data)
return len(self._data)

def __getitem__(self, index):
# fetch only as much items as necessary
item_iterator = iter(self)
while len(self.__data) <= index and self.__next_query_url:
while len(self._data) <= index and self.__next_query_url:
next(item_iterator)

return self.__data[index]
return self._data[index]

def filter(self, value):
self.queryOptions['filter'] = value
Expand Down
13 changes: 10 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
# -*- coding: utf-8 -*-
import io
from distutils.core import setup

import setuptools

with io.open("README.md", mode='r', encoding='utf-8') as fh:
long_description = fh.read()

setup(
name="Office365-REST-Python-Client",
version="2.1.6-1",
version="2.1.7",
author="Vadim Gremyachev",
author_email="[email protected]",
maintainer="Konrad Gądek",
Expand All @@ -32,7 +31,15 @@
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Software Development :: Libraries"
"Topic :: Software Development :: Libraries",
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
],
packages=setuptools.find_packages(),
package_data={
Expand Down
14 changes: 10 additions & 4 deletions tests/test_graph_group.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import unittest
import uuid

from office365.directory.groupCreationProperties import GroupCreationProperties
from office365.runtime.client_request_exception import ClientRequestException
from tests.graph_case import GraphTestCase
Expand All @@ -11,7 +9,15 @@ class TestGraphGroup(GraphTestCase):

target_group = None

def test1_create_group(self):
def test1_get_group_list(self):
groups = self.client.groups.top(1)
self.client.load(groups)
self.client.execute_query()
self.assertEquals(len(groups), 1)
for group in groups:
self.assertIsNotNone(group.properties['id'])

def test2_create_group(self):
try:
grp_name = "Group_" + uuid.uuid4().hex
properties = GroupCreationProperties(grp_name)
Expand All @@ -28,7 +34,7 @@ def test1_create_group(self):
else:
raise

def test2_delete_group(self):
def test3_delete_group(self):
grp_to_delete = self.__class__.target_group
if grp_to_delete is not None:
grp_to_delete.delete_object()
Expand Down

0 comments on commit c956eeb

Please sign in to comment.