Skip to content

Commit acd3780

Browse files
author
KP
committed
#29803 Add support for 2 factor authentication (2FA)
Adding support for 2FA the API so it is ready when Shotgun supports this. The `auth_token` parameter added to the authentication process defaults to `None` and can be ignored by all clients until 2FA is made available. Closes #85
1 parent 96e6bac commit acd3780

4 files changed

Lines changed: 69 additions & 12 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ Integration and unit tests are provided.
5353

5454
## Changelog
5555

56+
**v3.0.20 - TBD**
57+
58+
+ Add authentication support for Shotgun servers with two-factor authentication turned on.
59+
5660
**v3.0.19 - 2015 Mar 25**
5761

5862
+ Add ability to authenticate with Shotgun using `session_token`.

shotgun_api3/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from shotgun import (Shotgun, ShotgunError, ShotgunFileDownloadError, Fault,
2-
AuthenticationFault, ProtocolError, ResponseError, Error, __version__)
2+
AuthenticationFault, MissingTwoFactorAuthenticationFault,
3+
ProtocolError, ResponseError, Error, __version__)
34
from shotgun import SG_TIMEZONE as sg_timezone
45

shotgun_api3/shotgun.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ class AuthenticationFault(Fault):
9797
"""Exception when the server side reports an error related to authentication"""
9898
pass
9999

100+
class MissingTwoFactorAuthenticationFault(Fault):
101+
"""Exception when the server side reports an error related to missing
102+
two factor authentication credentials
103+
"""
104+
pass
105+
100106
# ----------------------------------------------------------------------------
101107
# API
102108

@@ -231,6 +237,7 @@ def __init__(self):
231237
self.script_name = None
232238
self.user_login = None
233239
self.user_password = None
240+
self.auth_token = None
234241
self.sudo_as_login = None
235242
# uuid as a string
236243
self.session_uuid = None
@@ -279,7 +286,8 @@ def __init__(self,
279286
login=None,
280287
password=None,
281288
sudo_as_login=None,
282-
session_token=None):
289+
session_token=None,
290+
auth_token=None):
283291
"""Initialises a new instance of the Shotgun client.
284292
285293
:param base_url: http or https url to the shotgun server.
@@ -329,6 +337,13 @@ def __init__(self,
329337
:param session_token: The session token to use to authenticate to the server. This
330338
can be used as an alternative to authenticating with a script user or regular user.
331339
You retrieve the session token by running the get_session_token() method.
340+
341+
:param auth_token: The authentication token required to authenticate to
342+
a server with two factor authentication turned on. If auth_token is provided,
343+
then login and password must be as well and neither script_name nor api_key
344+
can be provided. Note that these tokens can be short lived so a session is
345+
established right away if an auth_token is provided. A
346+
MissingTwoFactorAuthenticationFault will be raised if the auth_token is invalid.
332347
"""
333348

334349
# verify authentication arguments
@@ -355,6 +370,13 @@ def __init__(self,
355370
if api_key is None:
356371
raise ValueError("script_name provided without api_key")
357372

373+
if auth_token is not None:
374+
if login is None or password is None:
375+
raise ValueError("must provide a user login and password with an auth_token")
376+
377+
if script_name is not None or api_key is not None:
378+
raise ValueError("cannot provide an auth_code with script_name/api_key")
379+
358380
# Can't use 'all' with python 2.4
359381
if len([x for x in [session_token, script_name, api_key, login, password] if x]) == 0:
360382
if connect:
@@ -365,6 +387,7 @@ def __init__(self,
365387
self.config.script_name = script_name
366388
self.config.user_login = login
367389
self.config.user_password = password
390+
self.config.auth_token = auth_token
368391
self.config.session_token = session_token
369392
self.config.sudo_as_login = sudo_as_login
370393
self.config.convert_datetimes_to_utc = convert_datetimes_to_utc
@@ -423,8 +446,6 @@ def __init__(self,
423446
proxy_addr = "http://%s%s:%d" % (auth_string, self.config.proxy_server, self.config.proxy_port)
424447
self.config.proxy_handler = urllib2.ProxyHandler({self.config.scheme : proxy_addr})
425448

426-
427-
428449
if ensure_ascii:
429450
self._json_loads = self._json_loads_ascii
430451

@@ -438,6 +459,15 @@ def __init__(self,
438459
if connect:
439460
self.server_caps
440461

462+
# When using auth_token in a 2FA scenario we need to switch to session-based
463+
# authentication because the auth token will no longer be valid after a first use.
464+
if self.config.auth_token != None:
465+
self.config.session_token = self.get_session_token()
466+
self.config.user_login = None
467+
self.config.user_password = None
468+
self.config.auth_token = None
469+
470+
441471
# ========================================================================
442472
# API Functions
443473

@@ -1519,11 +1549,17 @@ def get_attachment_download_url(self, attachment):
15191549
None, None, None))
15201550
return url
15211551

1522-
def authenticate_human_user(self, user_login, user_password):
1552+
def authenticate_human_user(self, user_login, user_password, auth_token=None):
15231553
'''Authenticate Shotgun HumanUser. HumanUser must be an active account.
1524-
@param user_login: Login name of Shotgun HumanUser
1525-
@param user_password: Password for Shotgun HumanUser
1526-
@return: Dictionary of HumanUser including ID if authenticated, None is unauthorized.
1554+
:param user_login: Login name of Shotgun HumanUser
1555+
1556+
:param user_password: Password for Shotgun HumanUser
1557+
1558+
:param auth_token: One-time token required to authenticate Shotgun HumanUser
1559+
when two factor authentication is turned on.
1560+
1561+
:return: Dictionary of HumanUser including ID if authenticated, None if unauthorized.
1562+
"""
15271563
'''
15281564
if not user_login:
15291565
raise ValueError('Please supply a username to authenticate.')
@@ -1534,24 +1570,29 @@ def authenticate_human_user(self, user_login, user_password):
15341570
# Override permissions on Config obj
15351571
original_login = self.config.user_login
15361572
original_password = self.config.user_password
1573+
original_auth_token = self.config.auth_token
15371574

15381575
self.config.user_login = user_login
15391576
self.config.user_password = user_password
1577+
self.config.auth_token = auth_token
15401578

15411579
try:
15421580
data = self.find_one('HumanUser', [['sg_status_list', 'is', 'act'], ['login', 'is', user_login]], ['id', 'login'], '', 'all')
15431581
# Set back to default - There finally and except cannot be used together in python2.4
15441582
self.config.user_login = original_login
15451583
self.config.user_password = original_password
1584+
self.config.auth_token = original_auth_token
15461585
return data
15471586
except Fault:
15481587
# Set back to default - There finally and except cannot be used together in python2.4
15491588
self.config.user_login = original_login
15501589
self.config.user_password = original_password
1590+
self.config.auth_token = original_auth_token
15511591
except:
15521592
# Set back to default - There finally and except cannot be used together in python2.4
15531593
self.config.user_login = original_login
15541594
self.config.user_password = original_password
1595+
self.config.auth_token = original_auth_token
15551596
raise
15561597

15571598

@@ -1597,7 +1638,8 @@ def get_session_token(self):
15971638
session_token = (rv or {}).get("session_id")
15981639
if not session_token:
15991640
raise RuntimeError("Could not extract session_id from %s", rv)
1600-
self.config.session_token = session_token
1641+
self.config.session_token = session_token
1642+
16011643
return session_token
16021644

16031645
def _build_opener(self, handler):
@@ -1669,6 +1711,8 @@ def _auth_params(self):
16691711
"user_login" : str(self.config.user_login),
16701712
"user_password" : str(self.config.user_password),
16711713
}
1714+
if self.config.auth_token:
1715+
auth_params["auth_token"] = str(self.config.auth_token)
16721716

16731717
# Use script name instead
16741718
elif self.config.script_name and self.config.api_key:
@@ -1867,14 +1911,15 @@ def _response_errors(self, sg_response):
18671911
18681912
:raises ShotgunError: If the server response contains an exception.
18691913
"""
1870-
1914+
18711915
ERR_AUTH = 102 # error code for authentication related problems
1916+
ERR_2FA = 106 # error code when 2FA authentication is required but no 23FA token provided.
18721917

18731918
if isinstance(sg_response, dict) and sg_response.get("exception"):
1874-
18751919
if sg_response.get("error_code") == ERR_AUTH:
18761920
raise AuthenticationFault(sg_response.get("message", "Unknown Authentication Error"))
1877-
1921+
elif sg_response.get("error_code") == ERR_2FA:
1922+
raise MissingTwoFactorAuthenticationFault(sg_response.get("message", "Unknown 2FA Authentication Error"))
18781923
else:
18791924
# raise general Fault
18801925
raise Fault(sg_response.get("message", "Unknown Error"))

tests/test_api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,7 @@ def test_bad_auth(self):
14081408
api_key = self.config.api_key
14091409
login = self.config.human_login
14101410
password = self.config.human_password
1411+
auth_token = "111111"
14111412

14121413
# Test various combinations of illegal arguments
14131414
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url)
@@ -1417,6 +1418,12 @@ def test_bad_auth(self):
14171418
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url, login=login)
14181419
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url, password=password)
14191420
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url, script_name, login=login, password=password)
1421+
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url, login=login, auth_token=auth_token)
1422+
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url, password=password, auth_token=auth_token)
1423+
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url, script_name, login=login,
1424+
password=password, auth_token=auth_token)
1425+
self.assertRaises(ValueError, shotgun_api3.Shotgun, server_url, api_key=api_key, login=login,
1426+
password=password, auth_token=auth_token)
14201427

14211428
# Test failed authentications
14221429
sg = shotgun_api3.Shotgun(server_url, script_name, api_key)

0 commit comments

Comments
 (0)