Permalink
Browse files

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.
  • Loading branch information...
manneohrstrom committed Mar 18, 2015
1 parent 7c5625b commit e5387f38cf14ec3c0253b8422d0db33fc235ba69
Showing with 165 additions and 34 deletions.
  1. +1 −1 shotgun_api3/__init__.py
  2. +76 −28 shotgun_api3/shotgun.py
  3. +22 −0 tests/base.py
  4. +66 −5 tests/test_api.py
View
@@ -1,4 +1,4 @@
from shotgun import (Shotgun, ShotgunError, ShotgunFileDownloadError, Fault,
ProtocolError, ResponseError, Error, __version__)
AuthenticationFault, ProtocolError, ResponseError, Error, __version__)
from shotgun import SG_TIMEZONE as sg_timezone
View
@@ -63,6 +63,7 @@
LOG = logging.getLogger("shotgun_api3")
LOG.setLevel(logging.WARN)
SG_TIMEZONE = SgTimezone()
@@ -92,6 +93,10 @@ class Fault(ShotgunError):
"""Exception when server side exception detected."""
pass
class AuthenticationFault(Fault):
"""Exception when the server side reports an error related to authentication"""
pass
# ----------------------------------------------------------------------------
# API
@@ -209,6 +214,15 @@ def __init__(self):
self.scheme = None
self.server = None
self.api_path = None
# The raw_http_proxy reflects the exact string passed in
# to the Shotgun constructor. This can be useful if you
# need to construct a Shotgun API instance based on
# another Shotgun API instance.
self.raw_http_proxy = None
# if a proxy server is being used, the proxy_handler
# below will contain a urllib2.ProxyHandler instance
# which can be used whenever a request needs to be made.
self.proxy_handler = None
self.proxy_server = None
self.proxy_port = 8080
self.proxy_user = None
@@ -217,6 +231,7 @@ def __init__(self):
self.authorization = None
self.no_ssl_validation = False
class Shotgun(object):
"""Shotgun Client Connection"""
@@ -237,10 +252,11 @@ def __init__(self,
http_proxy=None,
ensure_ascii=True,
connect=True,
ca_certs=None,
ca_certs=None,
login=None,
password=None,
sudo_as_login=None):
sudo_as_login=None,
session_token=None):
"""Initialises a new instance of the Shotgun client.
:param base_url: http or https url to the shotgun server.
@@ -263,9 +279,9 @@ def __init__(self,
form [username:pass@]proxy.com[:8080]
:param connect: If True, connect to the server. Only used for testing.
:param ca_certs: The path to the SSL certificate file. Useful for users
who would like to package their application into an executable.
:param ca_certs: The path to the SSL certificate file. Useful for users
who would like to package their application into an executable.
:param login: The login to use to authenticate to the server. If login
is provided, then password must be as well and neither script_name nor
@@ -279,9 +295,21 @@ def __init__(self,
be applied to all actions and who will be logged as the user performing
all actions. Note that logged events will have an additional extra meta-data parameter
'sudo_actual_user' indicating the script or user that actually authenticated.
:param session_token: The session token to use to authenticate to the server. This
can be used as an alternative to authenticating with a script user or regular user.
You retrieve the session token by running the get_session_token() method.
"""
# verify authentication arguments
if session_token is not None:
if script_name is not None or api_key is not None:
raise ValueError("cannot provide both session_token "
"and script_name/api_key")
if login is not None or password is not None:
raise ValueError("cannot provide both session_token "
"and login/password")
if login is not None or password is not None:
if script_name is not None or api_key is not None:
raise ValueError("cannot provide both login/password "
@@ -298,19 +326,20 @@ def __init__(self,
raise ValueError("script_name provided without api_key")
# Can't use 'all' with python 2.4
if len([x for x in [script_name, api_key, login, password] if x]) == 0:
if len([x for x in [session_token, script_name, api_key, login, password] if x]) == 0:
if connect:
raise ValueError("must provide either login/password "
"or script_name/api_key")
raise ValueError("must provide login/password, session_token or script_name/api_key")
self.config = _Config()
self.config.api_key = api_key
self.config.script_name = script_name
self.config.user_login = login
self.config.user_password = password
self.config.session_token = session_token
self.config.sudo_as_login = sudo_as_login
self.config.convert_datetimes_to_utc = convert_datetimes_to_utc
self.config.no_ssl_validation = NO_SSL_VALIDATION
self.config.raw_http_proxy = http_proxy
self._connection = None
self.__ca_certs = ca_certs
@@ -353,6 +382,15 @@ def __init__(self,
". If no port is specified, a default of %d will be "\
"used." % (http_proxy, self.config.proxy_port))
# now populate self.config.proxy_handler
if self.config.proxy_user and self.config.proxy_pass:
auth_string = "%s:%s@" % (self.config.proxy_user, self.config.proxy_pass)
else:
auth_string = ""
proxy_addr = "http://%s%s:%d" % (auth_string, self.config.proxy_server, self.config.proxy_port)
self.config.proxy_handler = urllib2.ProxyHandler({self.config.scheme : proxy_addr})
if ensure_ascii:
self._json_loads = self._json_loads_ascii
@@ -1389,7 +1427,7 @@ def set_up_auth_cookie(self):
"""Sets up urllib2 with a cookie for authentication on the Shotgun
instance.
"""
sid = self._get_session_token()
sid = self.get_session_token()
cj = cookielib.LWPCookieJar()
c = cookielib.Cookie('0', '_session_id', sid, None, False,
self.config.server, False, False, "/", True, False, None, True,
@@ -1503,10 +1541,13 @@ def update_project_last_accessed(self, project, user=None):
record = self._call_rpc("update_project_last_accessed_by_current_user", params)
result = self._parse_records(record)[0]
def _get_session_token(self):
"""Hack to authenticate in order to download protected content
like Attachments
def get_session_token(self):
"""
Get the session token associated with the current session.
If a session token has already been established, this is returned,
otherwise a new one is generated on the server and returned.
:returns: String containing a session token
"""
if self.config.session_token:
return self.config.session_token
@@ -1515,22 +1556,13 @@ def _get_session_token(self):
session_token = (rv or {}).get("session_id")
if not session_token:
raise RuntimeError("Could not extract session_id from %s", rv)
self.config.session_token = session_token
return self.config.session_token
self.config.session_token = session_token
return session_token
def _build_opener(self, handler):
"""Build urllib2 opener with appropriate proxy handler."""
if self.config.proxy_server:
# handle proxy auth
if self.config.proxy_user and self.config.proxy_pass:
auth_string = "%s:%s@" % (self.config.proxy_user, self.config.proxy_pass)
else:
auth_string = ""
proxy_addr = "http://%s%s:%d" % (auth_string, self.config.proxy_server, self.config.proxy_port)
proxy_support = urllib2.ProxyHandler({self.config.scheme : proxy_addr})
opener = urllib2.build_opener(proxy_support, handler)
if self.config.proxy_handler:
opener = urllib2.build_opener(self.config.proxy_handler, handler)
else:
opener = urllib2.build_opener(handler)
return opener
@@ -1589,6 +1621,7 @@ def _call_rpc(self, method, params, include_auth_params=True, first=False):
def _auth_params(self):
""" return a dictionary of the authentication parameters being used. """
# Used to authenticate HumanUser credentials
if self.config.user_login and self.config.user_password:
auth_params = {
@@ -1603,6 +1636,14 @@ def _auth_params(self):
"script_key" : str(self.config.api_key),
}
# Authenticate using session_id
elif self.config.session_token:
auth_params = {
"session_token" : str(self.config.session_token),
# Request server side to raise exception for expired sessions
"reject_if_expired": True
}
else:
raise ValueError("invalid auth params")
@@ -1780,10 +1821,17 @@ def _response_errors(self, sg_response):
:raises ShotgunError: If the server response contains an exception.
"""
ERR_AUTH = 102 # error code for authentication related problems
if isinstance(sg_response, dict) and sg_response.get("exception"):
raise Fault(sg_response.get("message",
"Unknown Error"))
if sg_response.get("error_code") == ERR_AUTH:
raise AuthenticationFault(sg_response.get("message", "Unknown Authentication Error"))
else:
# raise general Fault
raise Fault(sg_response.get("message", "Unknown Error"))
return
def _visit_data(self, data, visitor):
View
@@ -30,6 +30,7 @@ def __init__(self, *args, **kws):
self.human_password = None
self.server_url = None
self.server_address = None
self.session_token = None
self.connect = False
@@ -56,6 +57,19 @@ def setUp(self, auth_mode='ApiUser'):
password=self.human_password,
http_proxy=self.config.http_proxy,
connect=self.connect )
elif auth_mode == 'SessionToken':
# first make an instance based on script key/name so
# we can generate a session token
sg = api.Shotgun(self.config.server_url,
self.config.script_name,
self.config.api_key,
http_proxy=self.config.http_proxy )
self.session_token = sg.get_session_token()
# now log in using session token
self.sg = api.Shotgun(self.config.server_url,
session_token=self.session_token,
http_proxy=self.config.http_proxy,
connect=self.connect )
else:
raise ValueError("Unknown value for auth_mode: %s" % auth_mode)
@@ -259,6 +273,14 @@ class HumanUserAuthLiveTestBase(LiveTestBase):
def setUp(self):
super(HumanUserAuthLiveTestBase, self).setUp('HumanUser')
class SessionTokenAuthLiveTestBase(LiveTestBase):
'''
Test base for relying on a Shotgun connection authenticate through the
configured session_token parameter.
'''
def setUp(self):
super(SessionTokenAuthLiveTestBase, self).setUp('SessionToken')
class SgTestConfig(object):
'''Reads test config and holds values'''
View
@@ -127,7 +127,7 @@ def test_last_accessed(self):
def test_get_session_token(self):
"""Got session UUID"""
#TODO test results
rv = self.sg._get_session_token()
rv = self.sg.get_session_token()
self.assertTrue(rv)
def test_upload_download(self):
@@ -165,7 +165,7 @@ def test_upload_download(self):
file_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "sg_logo_download.jpg")
result = self.sg.download_attachment(attach_id, file_path=file_path)
self.assertEqual(result, file_path)
# On windows read may not read to end of file unless opened 'rb'
# On windows read may not read to end of file unless opened 'rb'
fp = open(file_path, 'rb')
attach_file = fp.read()
fp.close()
@@ -1420,15 +1420,15 @@ def test_bad_auth(self):
# Test failed authentications
sg = shotgun_api3.Shotgun(server_url, script_name, api_key)
self.assertRaises(shotgun_api3.Fault, sg.find_one, 'Shot',[])
self.assertRaises(shotgun_api3.AuthenticationFault, sg.find_one, 'Shot',[])
script_name = self.config.script_name
api_key = 'notrealapikey'
sg = shotgun_api3.Shotgun(server_url, script_name, api_key)
self.assertRaises(shotgun_api3.Fault, sg.find_one, 'Shot',[])
self.assertRaises(shotgun_api3.AuthenticationFault, sg.find_one, 'Shot',[])
sg = shotgun_api3.Shotgun(server_url, login=login, password='not a real password')
self.assertRaises(shotgun_api3.Fault, sg.find_one, 'Shot',[])
self.assertRaises(shotgun_api3.AuthenticationFault, sg.find_one, 'Shot',[])
@patch('shotgun_api3.shotgun.Http.request')
def test_status_not_200(self, mock_request):
@@ -1507,6 +1507,10 @@ def test_human_user_sudo_auth_fails(self):
class TestHumanUserAuth(base.HumanUserAuthLiveTestBase):
"""
Testing the username/password authentication method
"""
def test_humanuser_find(self):
"""Called find, find_one for known entities as human user"""
filters = []
@@ -1559,6 +1563,63 @@ def test_humanuser_upload_thumbnail_for_version(self):
self.assertEqual(expected_clear_thumbnail, response_clear_thumbnail)
class TestSessionTokenAuth(base.SessionTokenAuthLiveTestBase):
"""
Testing the session token based authentication method
"""
def test_humanuser_find(self):
"""Called find, find_one for known entities as session token based user"""
filters = []
filters.append(['project', 'is', self.project])
filters.append(['id', 'is', self.version['id']])
fields = ['id']
versions = self.sg.find("Version", filters, fields=fields)
self.assertTrue(isinstance(versions, list))
version = versions[0]
self.assertEqual("Version", version["type"])
self.assertEqual(self.version['id'], version["id"])
version = self.sg.find_one("Version", filters, fields=fields)
self.assertEqual("Version", version["type"])
self.assertEqual(self.version['id'], version["id"])
def test_humanuser_upload_thumbnail_for_version(self):
"""simple upload thumbnail for version test as session based token user."""
this_dir, _ = os.path.split(__file__)
path = os.path.abspath(os.path.expanduser(
os.path.join(this_dir,"sg_logo.jpg")))
size = os.stat(path).st_size
# upload thumbnail
thumb_id = self.sg.upload_thumbnail("Version",
self.version['id'], path)
self.assertTrue(isinstance(thumb_id, int))
# check result on version
version_with_thumbnail = self.sg.find_one('Version',
[['id', 'is', self.version['id']]],
fields=['image'])
self.assertEqual(version_with_thumbnail.get('type'), 'Version')
self.assertEqual(version_with_thumbnail.get('id'), self.version['id'])
h = Http(".cache")
thumb_resp, content = h.request(version_with_thumbnail.get('image'), "GET")
self.assertEqual(thumb_resp['status'], '200')
self.assertEqual(thumb_resp['content-type'], 'image/jpeg')
# clear thumbnail
response_clear_thumbnail = self.sg.update("Version",
self.version['id'], {'image':None})
expected_clear_thumbnail = {'id': self.version['id'], 'image': None, 'type': 'Version'}
self.assertEqual(expected_clear_thumbnail, response_clear_thumbnail)
class TestProjectLastAccessedByCurrentUser(base.LiveTestBase):
# Ticket #24681
def test_logged_in_user(self):

0 comments on commit e5387f3

Please sign in to comment.