Skip to content

Commit 535715c

Browse files
committed
Add two factor callback
1 parent 257cc6b commit 535715c

File tree

7 files changed

+261
-3
lines changed

7 files changed

+261
-3
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Unreleased
1010
**Added**
1111

1212
- Add a ``URITooLarge`` exception.
13+
- :class:`.ScriptAuthorizer` has a new parameter ``two_factor_callback `` that supplies
14+
OTPs (One-Time Passcodes) when :meth:`.ScriptAuthorizer.refresh` is called.
1315
1416
2.0.0 (2021-02-23)
1517
------------------

prawcore/auth.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,22 +356,29 @@ class ScriptAuthorizer(Authorizer):
356356

357357
AUTHENTICATOR_CLASS = TrustedAuthenticator
358358

359-
def __init__(self, authenticator, username, password):
359+
def __init__(
360+
self, authenticator, username, password, two_factor_callback=None
361+
):
360362
"""Represent a single personal-use authorization to Reddit's API.
361363
362364
:param authenticator: An instance of :class:`TrustedAuthenticator`.
363365
:param username: The Reddit username of one of the application's developers.
364366
:param password: The password associated with ``username``.
367+
:param two_factor_callback: A function that returns OTPs (One-Time
368+
Passcodes), also known as 2FA auth codes. If this function is
369+
provided, prawcore will call it when authenticating.
365370
366371
"""
367372
super(ScriptAuthorizer, self).__init__(authenticator)
368373
self._username = username
369374
self._password = password
375+
self._two_factor_callback = two_factor_callback
370376

371377
def refresh(self):
372378
"""Obtain a new personal-use script type access token."""
373379
self._request_token(
374380
grant_type="password",
375381
username=self._username,
376382
password=self._password,
383+
otp=self._two_factor_callback and self._two_factor_callback(),
377384
)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
{
2+
"http_interactions": [
3+
{
4+
"recorded_at": "2021-05-21T00:48:30",
5+
"request": {
6+
"body": {
7+
"encoding": "utf-8",
8+
"string": "grant_type=password&otp=fake&password=<PASSWORD>&username=<USERNAME>"
9+
},
10+
"headers": {
11+
"Accept": [
12+
"*/*"
13+
],
14+
"Accept-Encoding": [
15+
"gzip, deflate"
16+
],
17+
"Authorization": [
18+
"Basic <BASIC_AUTH>"
19+
],
20+
"Connection": [
21+
"close"
22+
],
23+
"Content-Length": [
24+
"152"
25+
],
26+
"Content-Type": [
27+
"application/x-www-form-urlencoded"
28+
],
29+
"Cookie": [
30+
"edgebucket=7C1qdPEdiInzFy9WaL; loid=bSusfEGk5JXrZYhhyW"
31+
],
32+
"User-Agent": [
33+
"prawcore:test (by /u/bboe) prawcore/2.0.0"
34+
]
35+
},
36+
"method": "POST",
37+
"uri": "https://www.reddit.com/api/v1/access_token"
38+
},
39+
"response": {
40+
"body": {
41+
"encoding": "UTF-8",
42+
"string": "{\"error\": \"invalid_grant\"}"
43+
},
44+
"headers": {
45+
"Accept-Ranges": [
46+
"bytes"
47+
],
48+
"Connection": [
49+
"close"
50+
],
51+
"Content-Length": [
52+
"26"
53+
],
54+
"Content-Type": [
55+
"application/json; charset=UTF-8"
56+
],
57+
"Date": [
58+
"Fri, 21 May 2021 00:48:33 GMT"
59+
],
60+
"Server": [
61+
"snooserv"
62+
],
63+
"Strict-Transport-Security": [
64+
"max-age=15552000; includeSubDomains; preload"
65+
],
66+
"Via": [
67+
"1.1 varnish"
68+
],
69+
"X-Moose": [
70+
"majestic"
71+
],
72+
"cache-control": [
73+
"max-age=0, must-revalidate"
74+
],
75+
"x-content-type-options": [
76+
"nosniff"
77+
],
78+
"x-frame-options": [
79+
"SAMEORIGIN"
80+
],
81+
"x-ratelimit-remaining": [
82+
"297"
83+
],
84+
"x-ratelimit-reset": [
85+
"87"
86+
],
87+
"x-ratelimit-used": [
88+
"3"
89+
],
90+
"x-xss-protection": [
91+
"1; mode=block"
92+
]
93+
},
94+
"status": {
95+
"code": 200,
96+
"message": "OK"
97+
},
98+
"url": "https://www.reddit.com/api/v1/access_token"
99+
}
100+
}
101+
],
102+
"recorded_with": "betamax/0.8.1"
103+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
{
2+
"http_interactions": [
3+
{
4+
"recorded_at": "2021-05-21T00:43:11",
5+
"request": {
6+
"body": {
7+
"encoding": "utf-8",
8+
"string": "grant_type=password&otp=000000&password=<PASSWORD>&username=<USERNAME>"
9+
},
10+
"headers": {
11+
"Accept": [
12+
"*/*"
13+
],
14+
"Accept-Encoding": [
15+
"gzip, deflate"
16+
],
17+
"Authorization": [
18+
"Basic <BASIC_AUTH>"
19+
],
20+
"Connection": [
21+
"close"
22+
],
23+
"Content-Length": [
24+
"152"
25+
],
26+
"Content-Type": [
27+
"application/x-www-form-urlencoded"
28+
],
29+
"Cookie": [
30+
"edgebucket=W5X0f17fr5uGzS0Jxd; loid=yU0JIU6fMP2ZtL3FsU"
31+
],
32+
"User-Agent": [
33+
"prawcore:test (by /u/bboe) prawcore/2.0.0"
34+
]
35+
},
36+
"method": "POST",
37+
"uri": "https://www.reddit.com/api/v1/access_token"
38+
},
39+
"response": {
40+
"body": {
41+
"encoding": "UTF-8",
42+
"string": "{\"access_token\": \"00000000-000000000000000000000000000000\", \"token_type\": \"bearer\", \"expires_in\": 3600, \"scope\": \"*\"}"
43+
},
44+
"headers": {
45+
"Accept-Ranges": [
46+
"bytes"
47+
],
48+
"Connection": [
49+
"close"
50+
],
51+
"Content-Length": [
52+
"117"
53+
],
54+
"Content-Type": [
55+
"application/json; charset=UTF-8"
56+
],
57+
"Date": [
58+
"Fri, 21 May 2021 00:43:14 GMT"
59+
],
60+
"Server": [
61+
"snooserv"
62+
],
63+
"Strict-Transport-Security": [
64+
"max-age=15552000; includeSubDomains; preload"
65+
],
66+
"Via": [
67+
"1.1 varnish"
68+
],
69+
"X-Moose": [
70+
"majestic"
71+
],
72+
"cache-control": [
73+
"max-age=0, must-revalidate"
74+
],
75+
"x-content-type-options": [
76+
"nosniff"
77+
],
78+
"x-frame-options": [
79+
"SAMEORIGIN"
80+
],
81+
"x-ratelimit-remaining": [
82+
"299"
83+
],
84+
"x-ratelimit-reset": [
85+
"406"
86+
],
87+
"x-ratelimit-used": [
88+
"1"
89+
],
90+
"x-xss-protection": [
91+
"1; mode=block"
92+
]
93+
},
94+
"status": {
95+
"code": 200,
96+
"message": "OK"
97+
},
98+
"url": "https://www.reddit.com/api/v1/access_token"
99+
}
100+
}
101+
],
102+
"recorded_with": "betamax/0.8.1"
103+
}

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ def b64_string(input_string):
3131
return b64encode(input_string.encode("utf-8")).decode("utf-8")
3232

3333

34+
def two_factor_callback():
35+
"""Return an OTP code."""
36+
return None
37+
38+
3439
Betamax.register_request_matcher(JSONBodyMatcher)
3540
Betamax.register_serializer(pretty_json.PrettyJSONSerializer)
3641

tests/test_authorizer.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import prawcore
77

8-
from .conftest import (
8+
from .conftest import ( # noqa F401
99
CLIENT_ID,
1010
CLIENT_SECRET,
1111
PASSWORD,
@@ -14,6 +14,7 @@
1414
REFRESH_TOKEN,
1515
REQUESTOR,
1616
TEMPORARY_GRANT_CODE,
17+
two_factor_callback,
1718
USERNAME,
1819
)
1920

@@ -340,6 +341,40 @@ def test_refresh(self):
340341
self.assertEqual(set(["*"]), authorizer.scopes)
341342
self.assertTrue(authorizer.is_valid())
342343

344+
def test_refresh_with__valid_otp(self):
345+
authorizer = prawcore.ScriptAuthorizer(
346+
self.authentication,
347+
USERNAME,
348+
PASSWORD,
349+
lambda: "000000",
350+
)
351+
self.assertIsNone(authorizer.access_token)
352+
self.assertIsNone(authorizer.scopes)
353+
self.assertFalse(authorizer.is_valid())
354+
355+
with Betamax(REQUESTOR).use_cassette(
356+
"ScriptAuthorizer_refresh_with__valid_otp"
357+
):
358+
authorizer.refresh()
359+
360+
self.assertIsNotNone(authorizer.access_token)
361+
self.assertEqual(set(["*"]), authorizer.scopes)
362+
self.assertTrue(authorizer.is_valid())
363+
364+
def test_refresh_with__invalid_otp(self):
365+
authorizer = prawcore.ScriptAuthorizer(
366+
self.authentication,
367+
USERNAME,
368+
PASSWORD,
369+
lambda: "fake",
370+
)
371+
372+
with Betamax(REQUESTOR).use_cassette(
373+
"ScriptAuthorizer_refresh_with__invalid_otp"
374+
):
375+
self.assertRaises(prawcore.OAuthException, authorizer.refresh)
376+
self.assertFalse(authorizer.is_valid())
377+
343378
def test_refresh__with_invalid_username_or_password(self):
344379
authorizer = prawcore.ScriptAuthorizer(
345380
self.authentication, USERNAME, "invalidpassword"

tests/test_sessions.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
REFRESH_TOKEN,
2323
REQUESTOR,
2424
USERNAME,
25+
two_factor_callback,
2526
)
2627

2728

@@ -60,7 +61,9 @@ def script_authorizer():
6061
authenticator = prawcore.TrustedAuthenticator(
6162
REQUESTOR, CLIENT_ID, CLIENT_SECRET
6263
)
63-
authorizer = prawcore.ScriptAuthorizer(authenticator, USERNAME, PASSWORD)
64+
authorizer = prawcore.ScriptAuthorizer(
65+
authenticator, USERNAME, PASSWORD, two_factor_callback
66+
)
6467
authorizer.refresh()
6568
return authorizer
6669

0 commit comments

Comments
 (0)