From 535715c743af8940b03a19fb03f4b54977ee379c Mon Sep 17 00:00:00 2001 From: MaybeNetwork Date: Fri, 21 May 2021 06:16:47 -0400 Subject: [PATCH] Add two factor callback --- CHANGES.rst | 2 + prawcore/auth.py | 9 +- ...tAuthorizer_refresh_with__invalid_otp.json | 103 ++++++++++++++++++ ...iptAuthorizer_refresh_with__valid_otp.json | 103 ++++++++++++++++++ tests/conftest.py | 5 + tests/test_authorizer.py | 37 ++++++- tests/test_sessions.py | 5 +- 7 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 tests/cassettes/ScriptAuthorizer_refresh_with__invalid_otp.json create mode 100644 tests/cassettes/ScriptAuthorizer_refresh_with__valid_otp.json diff --git a/CHANGES.rst b/CHANGES.rst index 30a9886..cd92e52 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,6 +10,8 @@ Unreleased **Added** - Add a ``URITooLarge`` exception. +- :class:`.ScriptAuthorizer` has a new parameter ``two_factor_callback `` that supplies + OTPs (One-Time Passcodes) when :meth:`.ScriptAuthorizer.refresh` is called. 2.0.0 (2021-02-23) ------------------ diff --git a/prawcore/auth.py b/prawcore/auth.py index 0fece60..81893d9 100644 --- a/prawcore/auth.py +++ b/prawcore/auth.py @@ -356,17 +356,23 @@ class ScriptAuthorizer(Authorizer): AUTHENTICATOR_CLASS = TrustedAuthenticator - def __init__(self, authenticator, username, password): + def __init__( + self, authenticator, username, password, two_factor_callback=None + ): """Represent a single personal-use authorization to Reddit's API. :param authenticator: An instance of :class:`TrustedAuthenticator`. :param username: The Reddit username of one of the application's developers. :param password: The password associated with ``username``. + :param two_factor_callback: A function that returns OTPs (One-Time + Passcodes), also known as 2FA auth codes. If this function is + provided, prawcore will call it when authenticating. """ super(ScriptAuthorizer, self).__init__(authenticator) self._username = username self._password = password + self._two_factor_callback = two_factor_callback def refresh(self): """Obtain a new personal-use script type access token.""" @@ -374,4 +380,5 @@ def refresh(self): grant_type="password", username=self._username, password=self._password, + otp=self._two_factor_callback and self._two_factor_callback(), ) diff --git a/tests/cassettes/ScriptAuthorizer_refresh_with__invalid_otp.json b/tests/cassettes/ScriptAuthorizer_refresh_with__invalid_otp.json new file mode 100644 index 0000000..06147a4 --- /dev/null +++ b/tests/cassettes/ScriptAuthorizer_refresh_with__invalid_otp.json @@ -0,0 +1,103 @@ +{ + "http_interactions": [ + { + "recorded_at": "2021-05-21T00:48:30", + "request": { + "body": { + "encoding": "utf-8", + "string": "grant_type=password&otp=fake&password=&username=" + }, + "headers": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "Basic " + ], + "Connection": [ + "close" + ], + "Content-Length": [ + "152" + ], + "Content-Type": [ + "application/x-www-form-urlencoded" + ], + "Cookie": [ + "edgebucket=7C1qdPEdiInzFy9WaL; loid=bSusfEGk5JXrZYhhyW" + ], + "User-Agent": [ + "prawcore:test (by /u/bboe) prawcore/2.0.0" + ] + }, + "method": "POST", + "uri": "https://www.reddit.com/api/v1/access_token" + }, + "response": { + "body": { + "encoding": "UTF-8", + "string": "{\"error\": \"invalid_grant\"}" + }, + "headers": { + "Accept-Ranges": [ + "bytes" + ], + "Connection": [ + "close" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json; charset=UTF-8" + ], + "Date": [ + "Fri, 21 May 2021 00:48:33 GMT" + ], + "Server": [ + "snooserv" + ], + "Strict-Transport-Security": [ + "max-age=15552000; includeSubDomains; preload" + ], + "Via": [ + "1.1 varnish" + ], + "X-Moose": [ + "majestic" + ], + "cache-control": [ + "max-age=0, must-revalidate" + ], + "x-content-type-options": [ + "nosniff" + ], + "x-frame-options": [ + "SAMEORIGIN" + ], + "x-ratelimit-remaining": [ + "297" + ], + "x-ratelimit-reset": [ + "87" + ], + "x-ratelimit-used": [ + "3" + ], + "x-xss-protection": [ + "1; mode=block" + ] + }, + "status": { + "code": 200, + "message": "OK" + }, + "url": "https://www.reddit.com/api/v1/access_token" + } + } + ], + "recorded_with": "betamax/0.8.1" +} \ No newline at end of file diff --git a/tests/cassettes/ScriptAuthorizer_refresh_with__valid_otp.json b/tests/cassettes/ScriptAuthorizer_refresh_with__valid_otp.json new file mode 100644 index 0000000..e9d6421 --- /dev/null +++ b/tests/cassettes/ScriptAuthorizer_refresh_with__valid_otp.json @@ -0,0 +1,103 @@ +{ + "http_interactions": [ + { + "recorded_at": "2021-05-21T00:43:11", + "request": { + "body": { + "encoding": "utf-8", + "string": "grant_type=password&otp=000000&password=&username=" + }, + "headers": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "Basic " + ], + "Connection": [ + "close" + ], + "Content-Length": [ + "152" + ], + "Content-Type": [ + "application/x-www-form-urlencoded" + ], + "Cookie": [ + "edgebucket=W5X0f17fr5uGzS0Jxd; loid=yU0JIU6fMP2ZtL3FsU" + ], + "User-Agent": [ + "prawcore:test (by /u/bboe) prawcore/2.0.0" + ] + }, + "method": "POST", + "uri": "https://www.reddit.com/api/v1/access_token" + }, + "response": { + "body": { + "encoding": "UTF-8", + "string": "{\"access_token\": \"00000000-000000000000000000000000000000\", \"token_type\": \"bearer\", \"expires_in\": 3600, \"scope\": \"*\"}" + }, + "headers": { + "Accept-Ranges": [ + "bytes" + ], + "Connection": [ + "close" + ], + "Content-Length": [ + "117" + ], + "Content-Type": [ + "application/json; charset=UTF-8" + ], + "Date": [ + "Fri, 21 May 2021 00:43:14 GMT" + ], + "Server": [ + "snooserv" + ], + "Strict-Transport-Security": [ + "max-age=15552000; includeSubDomains; preload" + ], + "Via": [ + "1.1 varnish" + ], + "X-Moose": [ + "majestic" + ], + "cache-control": [ + "max-age=0, must-revalidate" + ], + "x-content-type-options": [ + "nosniff" + ], + "x-frame-options": [ + "SAMEORIGIN" + ], + "x-ratelimit-remaining": [ + "299" + ], + "x-ratelimit-reset": [ + "406" + ], + "x-ratelimit-used": [ + "1" + ], + "x-xss-protection": [ + "1; mode=block" + ] + }, + "status": { + "code": 200, + "message": "OK" + }, + "url": "https://www.reddit.com/api/v1/access_token" + } + } + ], + "recorded_with": "betamax/0.8.1" +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 1ca206d..9fbcca4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,11 @@ def b64_string(input_string): return b64encode(input_string.encode("utf-8")).decode("utf-8") +def two_factor_callback(): + """Return an OTP code.""" + return None + + Betamax.register_request_matcher(JSONBodyMatcher) Betamax.register_serializer(pretty_json.PrettyJSONSerializer) diff --git a/tests/test_authorizer.py b/tests/test_authorizer.py index a215f5e..813f321 100644 --- a/tests/test_authorizer.py +++ b/tests/test_authorizer.py @@ -5,7 +5,7 @@ import prawcore -from .conftest import ( +from .conftest import ( # noqa F401 CLIENT_ID, CLIENT_SECRET, PASSWORD, @@ -14,6 +14,7 @@ REFRESH_TOKEN, REQUESTOR, TEMPORARY_GRANT_CODE, + two_factor_callback, USERNAME, ) @@ -340,6 +341,40 @@ def test_refresh(self): self.assertEqual(set(["*"]), authorizer.scopes) self.assertTrue(authorizer.is_valid()) + def test_refresh_with__valid_otp(self): + authorizer = prawcore.ScriptAuthorizer( + self.authentication, + USERNAME, + PASSWORD, + lambda: "000000", + ) + self.assertIsNone(authorizer.access_token) + self.assertIsNone(authorizer.scopes) + self.assertFalse(authorizer.is_valid()) + + with Betamax(REQUESTOR).use_cassette( + "ScriptAuthorizer_refresh_with__valid_otp" + ): + authorizer.refresh() + + self.assertIsNotNone(authorizer.access_token) + self.assertEqual(set(["*"]), authorizer.scopes) + self.assertTrue(authorizer.is_valid()) + + def test_refresh_with__invalid_otp(self): + authorizer = prawcore.ScriptAuthorizer( + self.authentication, + USERNAME, + PASSWORD, + lambda: "fake", + ) + + with Betamax(REQUESTOR).use_cassette( + "ScriptAuthorizer_refresh_with__invalid_otp" + ): + self.assertRaises(prawcore.OAuthException, authorizer.refresh) + self.assertFalse(authorizer.is_valid()) + def test_refresh__with_invalid_username_or_password(self): authorizer = prawcore.ScriptAuthorizer( self.authentication, USERNAME, "invalidpassword" diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 3174d46..bd97574 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -22,6 +22,7 @@ REFRESH_TOKEN, REQUESTOR, USERNAME, + two_factor_callback, ) @@ -60,7 +61,9 @@ def script_authorizer(): authenticator = prawcore.TrustedAuthenticator( REQUESTOR, CLIENT_ID, CLIENT_SECRET ) - authorizer = prawcore.ScriptAuthorizer(authenticator, USERNAME, PASSWORD) + authorizer = prawcore.ScriptAuthorizer( + authenticator, USERNAME, PASSWORD, two_factor_callback + ) authorizer.refresh() return authorizer