Skip to content

Commit c956eeb

Browse files
committed
Support for Federated Authentication with SharePoint Online (related with #170)
1 parent 7bda8d2 commit c956eeb

File tree

9 files changed

+87
-62
lines changed

9 files changed

+87
-62
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ Office 365 & Microsoft Graph Library for Python
1111

1212
## Status
1313

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

1719
# Installation
1820

examples/sharepoint/file_operations.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import json
21
import os
32

43
from settings import settings

examples/sharepoint/web_read_direct.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import json
2-
32
from settings import settings
4-
53
from office365.runtime.auth.authentication_context import AuthenticationContext
64
from office365.runtime.utilities.request_options import RequestOptions
75
from office365.sharepoint.client_context import ClientContext
@@ -11,6 +9,7 @@
119
context_auth = AuthenticationContext(url=settings['url'])
1210
if context_auth.acquire_token_for_user(username=settings['user_credentials']['username'],
1311
password=settings['user_credentials']['password']):
12+
1413
"""Read Web client object"""
1514
ctx = ClientContext(settings['url'], context_auth)
1615
options = RequestOptions("{0}/_api/web/".format(settings['url']))

office365/directory/directoryObjectCollection.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1+
from office365.directory.directoryObject import DirectoryObject
12
from office365.runtime.client_object_collection import ClientObjectCollection
23
from office365.runtime.client_query import ServiceOperationQuery
34
from office365.runtime.client_result import ClientResult
45
from office365.runtime.resource_path import ResourcePath
5-
from office365.directory.user import User
66

77

88
class DirectoryObjectCollection(ClientObjectCollection):
99
"""User's collection"""
1010

1111
def __getitem__(self, key):
12-
return User(self.context,
13-
ResourcePath(key, self.resourcePath))
12+
return DirectoryObject(self.context,
13+
ResourcePath(key, self.resourcePath))
1414

1515
def getByIds(self, ids):
1616
"""Returns the directory objects specified in a list of IDs."""

office365/runtime/auth/saml_token_provider.py

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import requests.utils
66
import office365.logger
77
from office365.runtime.auth.base_token_provider import BaseTokenProvider
8-
from office365.runtime.auth.sts_info import STSInfo
8+
from office365.runtime.auth.sts_profile import STSProfile
99
from office365.runtime.auth.user_realm_info import UserRealmInfo
1010

1111
office365.logger.ensure_debug_secrets()
@@ -18,12 +18,10 @@ def __init__(self, authority_url, username, password):
1818
self.__username = username
1919
self.__password = password
2020
# Security Token Service info
21-
self.sts = STSInfo(authority_url)
21+
self.__sts_profile = STSProfile(authority_url)
2222
# Last occurred error
2323
self.error = ''
24-
self.FedAuth = None
25-
self.rtFa = None
26-
self._auth_cookies = []
24+
self._auth_cookies = {}
2725
self.__ns_prefixes = {
2826
'S': '{http://www.w3.org/2003/05/soap-envelope}',
2927
's': '{http://www.w3.org/2003/05/soap-envelope}',
@@ -53,15 +51,14 @@ def acquire_token(self, **kwargs):
5351
token = self.acquire_service_token_from_adfs(user_realm.STSAuthUrl, self.__username, self.__password)
5452
else:
5553
token = self.acquire_service_token(self.__username, self.__password)
56-
self.acquire_authentication_cookie(token)
57-
return True
54+
return self.acquire_authentication_cookie(token, user_realm.IsFederated)
5855
except requests.exceptions.RequestException as e:
5956
self.error = "Error: {}".format(e)
6057
return False
6158

6259
def get_user_realm(self, login):
6360
"""Get User Realm"""
64-
response = requests.post(self.sts.userRealmServiceUrl, data="login={0}&xml=1".format(login),
61+
response = requests.post(self.__sts_profile.userRealmServiceUrl, data="login={0}&xml=1".format(login),
6562
headers={'Content-Type': 'application/x-www-form-urlencoded'})
6663
xml = ElementTree.fromstring(response.content)
6764
node = xml.find('NameSpaceType')
@@ -76,9 +73,8 @@ def get_user_realm(self, login):
7673
def get_authentication_cookie(self):
7774
"""Generate Auth Cookie"""
7875
logger = self.logger(self.get_authentication_cookie.__name__)
79-
80-
logger.debug_secrets("self.FedAuth: %s\nself.rtFa: %s", self.FedAuth, self.rtFa)
81-
return 'FedAuth=' + self.FedAuth + '; rtFa=' + self.rtFa
76+
logger.debug_secrets(self._auth_cookies)
77+
return "; ".join(["=".join([key, str(val)]) for key, val in self._auth_cookies.items()])
8278

8379
def get_last_error(self):
8480
return self.error
@@ -90,9 +86,9 @@ def acquire_service_token_from_adfs(self, adfs_url, username, password):
9086
'username': username,
9187
'password': password,
9288
'message_id': str(uuid.uuid4()),
93-
'created': self.sts.created,
94-
'expires': self.sts.expires,
95-
'issuer': self.sts.federationTokenIssuer
89+
'created': self.__sts_profile.created,
90+
'expires': self.__sts_profile.expires,
91+
'issuer': self.__sts_profile.federationTokenIssuer
9692
})
9793
response = requests.post(adfs_url, data=payload,
9894
headers={'Content-Type': 'application/soap+xml; charset=utf-8'})
@@ -107,10 +103,11 @@ def acquire_service_token_from_adfs(self, adfs_url, username, password):
107103
logger.error(self.error)
108104
return None
109105
# 2. prepare & submit token request
110-
self.sts.securityTokenServicePath = 'rst2.srf'
106+
self.__sts_profile.signInPage = '_vti_bin/idcrl.svc'
107+
self.__sts_profile.securityTokenServicePath = 'rst2.srf'
111108
template = self._prepare_request_from_template('RST2.xml', {
112-
'auth_url': self.sts.authorityUrl,
113-
'serviceTokenUrl': self.sts.securityTokenServiceUrl
109+
'auth_url': self.__sts_profile.authorityUrl,
110+
'serviceTokenUrl': self.__sts_profile.securityTokenServiceUrl
114111
})
115112
template_xml = ElementTree.fromstring(template)
116113
security_node = template_xml.find(
@@ -119,7 +116,7 @@ def acquire_service_token_from_adfs(self, adfs_url, username, password):
119116
security_node.insert(1, assertion_node)
120117
payload = ElementTree.tostring(template_xml).decode()
121118
# 3. get token
122-
response = requests.post(self.sts.securityTokenServiceUrl, data=payload,
119+
response = requests.post(self.__sts_profile.securityTokenServiceUrl, data=payload,
123120
headers={'Content-Type': 'application/soap+xml'})
124121
token = self._process_service_token_response(response)
125122
logger.debug_secrets('security token: %s', token)
@@ -133,16 +130,16 @@ def acquire_service_token(self, username, password, service_target=None, service
133130
"""Retrieve service token"""
134131
logger = self.logger(self.acquire_service_token.__name__)
135132
payload = self._prepare_request_from_template('SAML.xml', {
136-
'auth_url': self.sts.authorityUrl,
133+
'auth_url': self.__sts_profile.authorityUrl,
137134
'username': username,
138135
'password': password,
139136
'message_id': str(uuid.uuid4()),
140-
'created': self.sts.created,
141-
'expires': self.sts.expires,
142-
'issuer': self.sts.federationTokenIssuer
137+
'created': self.__sts_profile.created,
138+
'expires': self.__sts_profile.expires,
139+
'issuer': self.__sts_profile.federationTokenIssuer
143140
})
144141
logger.debug_secrets('options: %s', payload)
145-
response = requests.post(self.sts.securityTokenServiceUrl, data=payload,
142+
response = requests.post(self.__sts_profile.securityTokenServiceUrl, data=payload,
146143
headers={'Content-Type': 'application/x-www-form-urlencoded'})
147144
token = self._process_service_token_response(response)
148145
logger.debug_secrets('security token: %s', token)
@@ -161,8 +158,9 @@ def _process_service_token_response(self, response):
161158

162159
# check for errors
163160
if xml.find('{0}Body/{0}Fault'.format(self.__ns_prefixes['s'])) is not None:
164-
error = xml.find('{0}Body/{0}Fault/{0}Detail/{1}error/{1}internalerror/{1}text'.format(self.__ns_prefixes['s'],
165-
self.__ns_prefixes['psf']))
161+
error = xml.find(
162+
'{0}Body/{0}Fault/{0}Detail/{1}error/{1}internalerror/{1}text'.format(self.__ns_prefixes['s'],
163+
self.__ns_prefixes['psf']))
166164
if error is None:
167165
self.error = 'An error occurred while retrieving token from XML response.'
168166
else:
@@ -175,30 +173,43 @@ def _process_service_token_response(self, response):
175173
'{0}Body/{1}RequestSecurityTokenResponse/{1}RequestedSecurityToken/{2}BinarySecurityToken'.format(
176174
self.__ns_prefixes['s'], self.__ns_prefixes['wst'], self.__ns_prefixes['wsse']))
177175
if token is None:
178-
self.error = 'Cannot get binary security token for from {0}'.format(self.sts.securityTokenServiceUrl)
176+
self.error = 'Cannot get binary security token for from {0}'.format(
177+
self.__sts_profile.securityTokenServiceUrl)
179178
logger.error(self.error)
180179
return None
181180
logger.debug_secrets("token: %s", token)
182181
return token.text
183182

184-
def acquire_authentication_cookie(self, security_token):
185-
"""Retrieve SPO auth cookie"""
183+
def acquire_authentication_cookie(self, security_token, federated=False):
184+
"""Retrieve auth cookie from STS"""
186185
logger = self.logger(self.acquire_authentication_cookie.__name__)
187186
session = requests.session()
188-
logger.debug_secrets("session: %s\nsession.post(%s, data=%s)", session, self.sts.signInPageUrl,
187+
logger.debug_secrets("session: %s\nsession.post(%s, data=%s)", session, self.__sts_profile.signInPageUrl,
189188
security_token)
190-
session.post(self.sts.signInPageUrl, data=security_token,
191-
headers={'Content-Type': 'application/x-www-form-urlencoded'})
189+
if not federated:
190+
self._auth_cookies['FedAuth'] = None
191+
self._auth_cookies['rtFa'] = None
192+
session.post(self.__sts_profile.signInPageUrl, data=security_token,
193+
headers={'Content-Type': 'application/x-www-form-urlencoded'})
194+
else:
195+
self._auth_cookies['SPOIDCRL'] = None
196+
session.head(self.__sts_profile.signInPageUrl,
197+
headers={
198+
'User-Agent': 'Office365 Python Client',
199+
'X-IDCRL_ACCEPTED': 't',
200+
'Authorization': 'BPOSIDCRL {0}'.format(security_token),
201+
'Content-Type': 'application/x-www-form-urlencoded'
202+
})
192203
logger.debug_secrets("session.cookies: %s", session.cookies)
193204
cookies = requests.utils.dict_from_cookiejar(session.cookies)
194205
logger.debug_secrets("cookies: %s", cookies)
195-
if 'FedAuth' in cookies and 'rtFa' in cookies:
196-
self.FedAuth = cookies['FedAuth']
197-
self.rtFa = cookies['rtFa']
198-
return True
199-
self.error = "An error occurred while retrieving auth cookies"
200-
logger.error(self.error)
201-
return False
206+
if not cookies:
207+
self.error = "An error occurred while retrieving auth cookies from {0}".format(self.__sts_profile.signInPageUrl)
208+
logger.error(self.error)
209+
return False
210+
for name in self._auth_cookies.keys():
211+
self._auth_cookies[name] = cookies[name]
212+
return True
202213

203214
@staticmethod
204215
def _prepare_request_from_template(template_name, params):

office365/runtime/auth/sts_info.py renamed to office365/runtime/auth/sts_profile.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import datetime
22

33

4-
class STSInfo(object):
4+
class STSProfile(object):
55

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

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

1920
@property
2021
def signInPageUrl(self):
21-
return "/".join([self.authorityUrl, '_forms/default.aspx?wa=wsignin1.0'])
22+
return "/".join([self.authorityUrl, self.signInPage])
2223

2324
@property
2425
def userRealmServiceUrl(self):

office365/runtime/client_object_collection.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class ClientObjectCollection(ClientObject):
77

88
def __init__(self, context, item_type, resource_path=None):
99
super(ClientObjectCollection, self).__init__(context, resource_path)
10-
self.__data = []
10+
self._data = []
1111
self.__next_query_url = None
1212
self._item_type = item_type
1313

@@ -21,21 +21,21 @@ def create_typed_object(self, properties, client_object_type):
2121
return client_object
2222

2323
def map_json(self, json):
24-
self.__data = []
24+
self._data = []
2525
for properties in json["collection"]:
2626
child_client_object = self.create_typed_object(properties, self._item_type)
2727
self.add_child(child_client_object)
2828
self.__next_query_url = json["next"]
2929

3030
def add_child(self, client_object):
3131
client_object._parent_collection = self
32-
self.__data.append(client_object)
32+
self._data.append(client_object)
3333

3434
def remove_child(self, client_object):
35-
self.__data.remove(client_object)
35+
self._data.remove(client_object)
3636

3737
def __iter__(self):
38-
for _object in self.__data:
38+
for _object in self._data:
3939
yield _object
4040
while self.__next_query_url:
4141
# create a request with the __next_query_url
@@ -61,15 +61,15 @@ def __len__(self):
6161
# resolve all items first
6262
list(iter(self))
6363

64-
return len(self.__data)
64+
return len(self._data)
6565

6666
def __getitem__(self, index):
6767
# fetch only as much items as necessary
6868
item_iterator = iter(self)
69-
while len(self.__data) <= index and self.__next_query_url:
69+
while len(self._data) <= index and self.__next_query_url:
7070
next(item_iterator)
7171

72-
return self.__data[index]
72+
return self._data[index]
7373

7474
def filter(self, value):
7575
self.queryOptions['filter'] = value

setup.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
# -*- coding: utf-8 -*-
33
import io
44
from distutils.core import setup
5-
65
import setuptools
76

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

1110
setup(
1211
name="Office365-REST-Python-Client",
13-
version="2.1.6-1",
12+
version="2.1.7",
1413
author="Vadim Gremyachev",
1514
author_email="[email protected]",
1615
maintainer="Konrad Gądek",
@@ -32,7 +31,15 @@
3231
"Intended Audience :: Developers",
3332
"License :: OSI Approved :: MIT License",
3433
"Topic :: Internet :: WWW/HTTP",
35-
"Topic :: Software Development :: Libraries"
34+
"Topic :: Software Development :: Libraries",
35+
'Programming Language :: Python',
36+
'Programming Language :: Python :: 2',
37+
'Programming Language :: Python :: 2.7',
38+
'Programming Language :: Python :: 3',
39+
'Programming Language :: Python :: 3.5',
40+
'Programming Language :: Python :: 3.6',
41+
'Programming Language :: Python :: 3.7',
42+
'Programming Language :: Python :: 3.8',
3643
],
3744
packages=setuptools.find_packages(),
3845
package_data={

tests/test_graph_group.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import unittest
21
import uuid
3-
42
from office365.directory.groupCreationProperties import GroupCreationProperties
53
from office365.runtime.client_request_exception import ClientRequestException
64
from tests.graph_case import GraphTestCase
@@ -11,7 +9,15 @@ class TestGraphGroup(GraphTestCase):
119

1210
target_group = None
1311

14-
def test1_create_group(self):
12+
def test1_get_group_list(self):
13+
groups = self.client.groups.top(1)
14+
self.client.load(groups)
15+
self.client.execute_query()
16+
self.assertEquals(len(groups), 1)
17+
for group in groups:
18+
self.assertIsNotNone(group.properties['id'])
19+
20+
def test2_create_group(self):
1521
try:
1622
grp_name = "Group_" + uuid.uuid4().hex
1723
properties = GroupCreationProperties(grp_name)
@@ -28,7 +34,7 @@ def test1_create_group(self):
2834
else:
2935
raise
3036

31-
def test2_delete_group(self):
37+
def test3_delete_group(self):
3238
grp_to_delete = self.__class__.target_group
3339
if grp_to_delete is not None:
3440
grp_to_delete.delete_object()

0 commit comments

Comments
 (0)