Skip to content

Commit e5387f3

Browse files
committed
For #28019, support for session based execution and some minor tweaks.
A collection of security related changes, mostly from #77. Here's a summary of the changes: - Ability to create a sg api from a session token. This allows a user to instantiate a shotgun API given a session token produced by the sg.get_session_token() method. - Added a sg.get_session_token() method to generate session tokens. - Added a new AuthenticationFault exception type (deriving from Fault and backwards compatible) to indicate when a connection fails due to authentication. - In the interest of API symmetry, added sg.config.raw_http_proxy which contains the same raw proxy string that is passed into the API constructor. This is handy if you need to create an sg API instance based on an existing instance, and want to make sure that the same proxy settings are used. - To make it easy to set up your own httplib2 based connection to Shotgun (sometimes useful), added an sg.config.proxy_handler which represents the proxy handler that is used by Shotgun when it connects via httplib2. Closes #81.
1 parent 7c5625b commit e5387f3

4 files changed

Lines changed: 165 additions & 34 deletions

File tree

shotgun_api3/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from shotgun import (Shotgun, ShotgunError, ShotgunFileDownloadError, Fault,
2-
ProtocolError, ResponseError, Error, __version__)
2+
AuthenticationFault, ProtocolError, ResponseError, Error, __version__)
33
from shotgun import SG_TIMEZONE as sg_timezone
44

shotgun_api3/shotgun.py

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
LOG = logging.getLogger("shotgun_api3")
6464
LOG.setLevel(logging.WARN)
6565

66+
6667
SG_TIMEZONE = SgTimezone()
6768

6869

@@ -92,6 +93,10 @@ class Fault(ShotgunError):
9293
"""Exception when server side exception detected."""
9394
pass
9495

96+
class AuthenticationFault(Fault):
97+
"""Exception when the server side reports an error related to authentication"""
98+
pass
99+
95100
# ----------------------------------------------------------------------------
96101
# API
97102

@@ -209,6 +214,15 @@ def __init__(self):
209214
self.scheme = None
210215
self.server = None
211216
self.api_path = None
217+
# The raw_http_proxy reflects the exact string passed in
218+
# to the Shotgun constructor. This can be useful if you
219+
# need to construct a Shotgun API instance based on
220+
# another Shotgun API instance.
221+
self.raw_http_proxy = None
222+
# if a proxy server is being used, the proxy_handler
223+
# below will contain a urllib2.ProxyHandler instance
224+
# which can be used whenever a request needs to be made.
225+
self.proxy_handler = None
212226
self.proxy_server = None
213227
self.proxy_port = 8080
214228
self.proxy_user = None
@@ -217,6 +231,7 @@ def __init__(self):
217231
self.authorization = None
218232
self.no_ssl_validation = False
219233

234+
220235
class Shotgun(object):
221236
"""Shotgun Client Connection"""
222237

@@ -237,10 +252,11 @@ def __init__(self,
237252
http_proxy=None,
238253
ensure_ascii=True,
239254
connect=True,
240-
ca_certs=None,
255+
ca_certs=None,
241256
login=None,
242257
password=None,
243-
sudo_as_login=None):
258+
sudo_as_login=None,
259+
session_token=None):
244260
"""Initialises a new instance of the Shotgun client.
245261
246262
:param base_url: http or https url to the shotgun server.
@@ -263,9 +279,9 @@ def __init__(self,
263279
form [username:pass@]proxy.com[:8080]
264280
265281
:param connect: If True, connect to the server. Only used for testing.
266-
267-
:param ca_certs: The path to the SSL certificate file. Useful for users
268-
who would like to package their application into an executable.
282+
283+
:param ca_certs: The path to the SSL certificate file. Useful for users
284+
who would like to package their application into an executable.
269285
270286
:param login: The login to use to authenticate to the server. If login
271287
is provided, then password must be as well and neither script_name nor
@@ -279,9 +295,21 @@ def __init__(self,
279295
be applied to all actions and who will be logged as the user performing
280296
all actions. Note that logged events will have an additional extra meta-data parameter
281297
'sudo_actual_user' indicating the script or user that actually authenticated.
298+
299+
:param session_token: The session token to use to authenticate to the server. This
300+
can be used as an alternative to authenticating with a script user or regular user.
301+
You retrieve the session token by running the get_session_token() method.
282302
"""
283303

284304
# verify authentication arguments
305+
if session_token is not None:
306+
if script_name is not None or api_key is not None:
307+
raise ValueError("cannot provide both session_token "
308+
"and script_name/api_key")
309+
if login is not None or password is not None:
310+
raise ValueError("cannot provide both session_token "
311+
"and login/password")
312+
285313
if login is not None or password is not None:
286314
if script_name is not None or api_key is not None:
287315
raise ValueError("cannot provide both login/password "
@@ -298,19 +326,20 @@ def __init__(self,
298326
raise ValueError("script_name provided without api_key")
299327

300328
# Can't use 'all' with python 2.4
301-
if len([x for x in [script_name, api_key, login, password] if x]) == 0:
329+
if len([x for x in [session_token, script_name, api_key, login, password] if x]) == 0:
302330
if connect:
303-
raise ValueError("must provide either login/password "
304-
"or script_name/api_key")
331+
raise ValueError("must provide login/password, session_token or script_name/api_key")
305332

306333
self.config = _Config()
307334
self.config.api_key = api_key
308335
self.config.script_name = script_name
309336
self.config.user_login = login
310337
self.config.user_password = password
338+
self.config.session_token = session_token
311339
self.config.sudo_as_login = sudo_as_login
312340
self.config.convert_datetimes_to_utc = convert_datetimes_to_utc
313341
self.config.no_ssl_validation = NO_SSL_VALIDATION
342+
self.config.raw_http_proxy = http_proxy
314343
self._connection = None
315344
self.__ca_certs = ca_certs
316345

@@ -353,6 +382,15 @@ def __init__(self,
353382
". If no port is specified, a default of %d will be "\
354383
"used." % (http_proxy, self.config.proxy_port))
355384

385+
# now populate self.config.proxy_handler
386+
if self.config.proxy_user and self.config.proxy_pass:
387+
auth_string = "%s:%s@" % (self.config.proxy_user, self.config.proxy_pass)
388+
else:
389+
auth_string = ""
390+
proxy_addr = "http://%s%s:%d" % (auth_string, self.config.proxy_server, self.config.proxy_port)
391+
self.config.proxy_handler = urllib2.ProxyHandler({self.config.scheme : proxy_addr})
392+
393+
356394

357395
if ensure_ascii:
358396
self._json_loads = self._json_loads_ascii
@@ -1389,7 +1427,7 @@ def set_up_auth_cookie(self):
13891427
"""Sets up urllib2 with a cookie for authentication on the Shotgun
13901428
instance.
13911429
"""
1392-
sid = self._get_session_token()
1430+
sid = self.get_session_token()
13931431
cj = cookielib.LWPCookieJar()
13941432
c = cookielib.Cookie('0', '_session_id', sid, None, False,
13951433
self.config.server, False, False, "/", True, False, None, True,
@@ -1503,10 +1541,13 @@ def update_project_last_accessed(self, project, user=None):
15031541
record = self._call_rpc("update_project_last_accessed_by_current_user", params)
15041542
result = self._parse_records(record)[0]
15051543

1506-
1507-
def _get_session_token(self):
1508-
"""Hack to authenticate in order to download protected content
1509-
like Attachments
1544+
def get_session_token(self):
1545+
"""
1546+
Get the session token associated with the current session.
1547+
If a session token has already been established, this is returned,
1548+
otherwise a new one is generated on the server and returned.
1549+
1550+
:returns: String containing a session token
15101551
"""
15111552
if self.config.session_token:
15121553
return self.config.session_token
@@ -1515,22 +1556,13 @@ def _get_session_token(self):
15151556
session_token = (rv or {}).get("session_id")
15161557
if not session_token:
15171558
raise RuntimeError("Could not extract session_id from %s", rv)
1518-
1519-
self.config.session_token = session_token
1520-
return self.config.session_token
1559+
self.config.session_token = session_token
1560+
return session_token
15211561

15221562
def _build_opener(self, handler):
15231563
"""Build urllib2 opener with appropriate proxy handler."""
1524-
if self.config.proxy_server:
1525-
# handle proxy auth
1526-
if self.config.proxy_user and self.config.proxy_pass:
1527-
auth_string = "%s:%s@" % (self.config.proxy_user, self.config.proxy_pass)
1528-
else:
1529-
auth_string = ""
1530-
proxy_addr = "http://%s%s:%d" % (auth_string, self.config.proxy_server, self.config.proxy_port)
1531-
proxy_support = urllib2.ProxyHandler({self.config.scheme : proxy_addr})
1532-
1533-
opener = urllib2.build_opener(proxy_support, handler)
1564+
if self.config.proxy_handler:
1565+
opener = urllib2.build_opener(self.config.proxy_handler, handler)
15341566
else:
15351567
opener = urllib2.build_opener(handler)
15361568
return opener
@@ -1589,6 +1621,7 @@ def _call_rpc(self, method, params, include_auth_params=True, first=False):
15891621

15901622
def _auth_params(self):
15911623
""" return a dictionary of the authentication parameters being used. """
1624+
15921625
# Used to authenticate HumanUser credentials
15931626
if self.config.user_login and self.config.user_password:
15941627
auth_params = {
@@ -1603,6 +1636,14 @@ def _auth_params(self):
16031636
"script_key" : str(self.config.api_key),
16041637
}
16051638

1639+
# Authenticate using session_id
1640+
elif self.config.session_token:
1641+
auth_params = {
1642+
"session_token" : str(self.config.session_token),
1643+
# Request server side to raise exception for expired sessions
1644+
"reject_if_expired": True
1645+
}
1646+
16061647
else:
16071648
raise ValueError("invalid auth params")
16081649

@@ -1780,10 +1821,17 @@ def _response_errors(self, sg_response):
17801821
17811822
:raises ShotgunError: If the server response contains an exception.
17821823
"""
1824+
1825+
ERR_AUTH = 102 # error code for authentication related problems
17831826

17841827
if isinstance(sg_response, dict) and sg_response.get("exception"):
1785-
raise Fault(sg_response.get("message",
1786-
"Unknown Error"))
1828+
1829+
if sg_response.get("error_code") == ERR_AUTH:
1830+
raise AuthenticationFault(sg_response.get("message", "Unknown Authentication Error"))
1831+
1832+
else:
1833+
# raise general Fault
1834+
raise Fault(sg_response.get("message", "Unknown Error"))
17871835
return
17881836

17891837
def _visit_data(self, data, visitor):

tests/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def __init__(self, *args, **kws):
3030
self.human_password = None
3131
self.server_url = None
3232
self.server_address = None
33+
self.session_token = None
3334
self.connect = False
3435

3536

@@ -56,6 +57,19 @@ def setUp(self, auth_mode='ApiUser'):
5657
password=self.human_password,
5758
http_proxy=self.config.http_proxy,
5859
connect=self.connect )
60+
elif auth_mode == 'SessionToken':
61+
# first make an instance based on script key/name so
62+
# we can generate a session token
63+
sg = api.Shotgun(self.config.server_url,
64+
self.config.script_name,
65+
self.config.api_key,
66+
http_proxy=self.config.http_proxy )
67+
self.session_token = sg.get_session_token()
68+
# now log in using session token
69+
self.sg = api.Shotgun(self.config.server_url,
70+
session_token=self.session_token,
71+
http_proxy=self.config.http_proxy,
72+
connect=self.connect )
5973
else:
6074
raise ValueError("Unknown value for auth_mode: %s" % auth_mode)
6175

@@ -259,6 +273,14 @@ class HumanUserAuthLiveTestBase(LiveTestBase):
259273
def setUp(self):
260274
super(HumanUserAuthLiveTestBase, self).setUp('HumanUser')
261275

276+
class SessionTokenAuthLiveTestBase(LiveTestBase):
277+
'''
278+
Test base for relying on a Shotgun connection authenticate through the
279+
configured session_token parameter.
280+
'''
281+
def setUp(self):
282+
super(SessionTokenAuthLiveTestBase, self).setUp('SessionToken')
283+
262284

263285
class SgTestConfig(object):
264286
'''Reads test config and holds values'''

tests/test_api.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def test_last_accessed(self):
127127
def test_get_session_token(self):
128128
"""Got session UUID"""
129129
#TODO test results
130-
rv = self.sg._get_session_token()
130+
rv = self.sg.get_session_token()
131131
self.assertTrue(rv)
132132

133133
def test_upload_download(self):
@@ -165,7 +165,7 @@ def test_upload_download(self):
165165
file_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "sg_logo_download.jpg")
166166
result = self.sg.download_attachment(attach_id, file_path=file_path)
167167
self.assertEqual(result, file_path)
168-
# On windows read may not read to end of file unless opened 'rb'
168+
# On windows read may not read to end of file unless opened 'rb'
169169
fp = open(file_path, 'rb')
170170
attach_file = fp.read()
171171
fp.close()
@@ -1420,15 +1420,15 @@ def test_bad_auth(self):
14201420

14211421
# Test failed authentications
14221422
sg = shotgun_api3.Shotgun(server_url, script_name, api_key)
1423-
self.assertRaises(shotgun_api3.Fault, sg.find_one, 'Shot',[])
1423+
self.assertRaises(shotgun_api3.AuthenticationFault, sg.find_one, 'Shot',[])
14241424

14251425
script_name = self.config.script_name
14261426
api_key = 'notrealapikey'
14271427
sg = shotgun_api3.Shotgun(server_url, script_name, api_key)
1428-
self.assertRaises(shotgun_api3.Fault, sg.find_one, 'Shot',[])
1428+
self.assertRaises(shotgun_api3.AuthenticationFault, sg.find_one, 'Shot',[])
14291429

14301430
sg = shotgun_api3.Shotgun(server_url, login=login, password='not a real password')
1431-
self.assertRaises(shotgun_api3.Fault, sg.find_one, 'Shot',[])
1431+
self.assertRaises(shotgun_api3.AuthenticationFault, sg.find_one, 'Shot',[])
14321432

14331433
@patch('shotgun_api3.shotgun.Http.request')
14341434
def test_status_not_200(self, mock_request):
@@ -1507,6 +1507,10 @@ def test_human_user_sudo_auth_fails(self):
15071507

15081508

15091509
class TestHumanUserAuth(base.HumanUserAuthLiveTestBase):
1510+
"""
1511+
Testing the username/password authentication method
1512+
"""
1513+
15101514
def test_humanuser_find(self):
15111515
"""Called find, find_one for known entities as human user"""
15121516
filters = []
@@ -1559,6 +1563,63 @@ def test_humanuser_upload_thumbnail_for_version(self):
15591563
self.assertEqual(expected_clear_thumbnail, response_clear_thumbnail)
15601564

15611565

1566+
class TestSessionTokenAuth(base.SessionTokenAuthLiveTestBase):
1567+
"""
1568+
Testing the session token based authentication method
1569+
"""
1570+
1571+
def test_humanuser_find(self):
1572+
"""Called find, find_one for known entities as session token based user"""
1573+
filters = []
1574+
filters.append(['project', 'is', self.project])
1575+
filters.append(['id', 'is', self.version['id']])
1576+
1577+
fields = ['id']
1578+
1579+
versions = self.sg.find("Version", filters, fields=fields)
1580+
1581+
self.assertTrue(isinstance(versions, list))
1582+
version = versions[0]
1583+
self.assertEqual("Version", version["type"])
1584+
self.assertEqual(self.version['id'], version["id"])
1585+
1586+
version = self.sg.find_one("Version", filters, fields=fields)
1587+
self.assertEqual("Version", version["type"])
1588+
self.assertEqual(self.version['id'], version["id"])
1589+
1590+
def test_humanuser_upload_thumbnail_for_version(self):
1591+
"""simple upload thumbnail for version test as session based token user."""
1592+
this_dir, _ = os.path.split(__file__)
1593+
path = os.path.abspath(os.path.expanduser(
1594+
os.path.join(this_dir,"sg_logo.jpg")))
1595+
size = os.stat(path).st_size
1596+
1597+
# upload thumbnail
1598+
thumb_id = self.sg.upload_thumbnail("Version",
1599+
self.version['id'], path)
1600+
self.assertTrue(isinstance(thumb_id, int))
1601+
1602+
# check result on version
1603+
version_with_thumbnail = self.sg.find_one('Version',
1604+
[['id', 'is', self.version['id']]],
1605+
fields=['image'])
1606+
1607+
self.assertEqual(version_with_thumbnail.get('type'), 'Version')
1608+
self.assertEqual(version_with_thumbnail.get('id'), self.version['id'])
1609+
1610+
1611+
h = Http(".cache")
1612+
thumb_resp, content = h.request(version_with_thumbnail.get('image'), "GET")
1613+
self.assertEqual(thumb_resp['status'], '200')
1614+
self.assertEqual(thumb_resp['content-type'], 'image/jpeg')
1615+
1616+
# clear thumbnail
1617+
response_clear_thumbnail = self.sg.update("Version",
1618+
self.version['id'], {'image':None})
1619+
expected_clear_thumbnail = {'id': self.version['id'], 'image': None, 'type': 'Version'}
1620+
self.assertEqual(expected_clear_thumbnail, response_clear_thumbnail)
1621+
1622+
15621623
class TestProjectLastAccessedByCurrentUser(base.LiveTestBase):
15631624
# Ticket #24681
15641625
def test_logged_in_user(self):

0 commit comments

Comments
 (0)