Skip to content

Commit 50cc35e

Browse files
authored
Merge branch 'fronzbot:dev' into dev
2 parents 2d649f6 + d58a6d2 commit 50cc35e

File tree

6 files changed

+81
-40
lines changed

6 files changed

+81
-40
lines changed

blinkapp/blinkapp.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime, timedelta
66
from aiohttp import ClientSession
77
from blinkpy.blinkpy import Blink
8-
from blinkpy.auth import Auth
8+
from blinkpy.auth import Auth, BlinkTwoFARequiredError
99
from blinkpy.helpers.util import json_load
1010

1111
CREDFILE = environ.get("CREDFILE")
@@ -26,7 +26,10 @@ async def start(session: ClientSession):
2626
"""Startup blink app."""
2727
blink = Blink(session=session)
2828
blink.auth = Auth(await json_load(CREDFILE), session=session)
29-
await blink.start()
29+
try:
30+
await blink.start()
31+
except BlinkTwoFARequiredError:
32+
await blink.prompt_2fa()
3033
return blink
3134

3235

blinkpy/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async def request_login(
6262

6363
if is_refresh:
6464
form_data["grant_type"] = OAUTH_GRANT_TYPE_REFRESH_TOKEN
65-
form_data["refresh_token"] = auth._refresh_token
65+
form_data["refresh_token"] = auth.refresh_token
6666
else:
6767
form_data["grant_type"] = OAUTH_GRANT_TYPE_PASSWORD
6868
form_data["password"] = login_data["password"]

blinkpy/auth.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def __init__(
3232
session=None,
3333
agent=DEFAULT_USER_AGENT,
3434
app_build=APP_BUILD,
35+
callback=None,
3536
):
3637
"""
3738
Initialize auth handler.
@@ -48,8 +49,8 @@ def __init__(
4849
self.data = login_data
4950
self.token = login_data.get("token", None)
5051
self.expires_in = login_data.get("expires_in", None)
51-
self.expiration_date = None
52-
self._refresh_token = login_data.get("refresh_token", None)
52+
self.expiration_date = login_data.get("expiration_date", None)
53+
self.refresh_token = login_data.get("refresh_token", None)
5354
self.host = login_data.get("host", None)
5455
self.region_id = login_data.get("region_id", None)
5556
self.client_id = login_data.get("client_id", None)
@@ -63,12 +64,16 @@ def __init__(
6364
self._app_build = app_build
6465
self.session = session if session else ClientSession()
6566

67+
# Callback to notify on token refresh
68+
self.callback = callback
69+
6670
@property
6771
def login_attributes(self):
6872
"""Return a dictionary of login attributes."""
6973
self.data["token"] = self.token
7074
self.data["expires_in"] = self.expires_in
71-
self.data["refresh_token"] = self._refresh_token
75+
self.data["expiration_date"] = self.expiration_date
76+
self.data["refresh_token"] = self.refresh_token
7277
self.data["host"] = self.host
7378
self.data["region_id"] = self.region_id
7479
self.data["client_id"] = self.client_id
@@ -96,12 +101,6 @@ def validate_login(self):
96101
self.data = util.prompt_login_data(self.data)
97102
self.data = util.validate_login_data(self.data)
98103

99-
def validate_2fa(self):
100-
"""Check 2FA information and prompt if not available."""
101-
self.data["2fa_code"] = self.data.get("2fa_code", None)
102-
if not self.no_prompt:
103-
self.data = util.prompt_2fa_data(self.data)
104-
105104
async def login(self, login_url=LOGIN_ENDPOINT, refresh=False):
106105
"""Attempt OAuth login to blink servers."""
107106
self.validate_login()
@@ -116,8 +115,7 @@ async def login(self, login_url=LOGIN_ENDPOINT, refresh=False):
116115
if response.status == 200:
117116
return await response.json()
118117
if response.status == 412:
119-
self.validate_2fa()
120-
return await self.login()
118+
raise BlinkTwoFARequiredError
121119
raise LoginError
122120
except AttributeError as error:
123121
raise LoginError from error
@@ -130,16 +128,24 @@ def logout(self, blink):
130128
"""Log out."""
131129
return api.request_logout(blink)
132130

133-
async def refresh_token(self):
134-
"""Refresh auth token."""
131+
async def refresh_tokens(self, refresh=False):
132+
"""Create or refresh access token."""
135133
self.is_errored = True
136134
try:
137-
_LOGGER.info("Token expired, attempting automatic refresh.")
138-
self.login_response = await self.login()
135+
_LOGGER.info(
136+
f"{'Refreshing' if refresh else 'Obtaining'} authentication token."
137+
)
138+
self.login_response = await self.login(refresh=refresh)
139139
self.extract_login_info()
140-
self.tier_info = await self.get_tier_info()
141-
self.extract_tier_info()
140+
141+
if not refresh:
142+
self.tier_info = await self.get_tier_info()
143+
self.extract_tier_info()
144+
142145
self.is_errored = False
146+
except BlinkTwoFARequiredError as error:
147+
_LOGGER.error("Two-factor authentication required. Waiting for otp.")
148+
raise BlinkTwoFARequiredError from error
143149
except LoginError as error:
144150
_LOGGER.error("Login endpoint failed. Try again later.")
145151
raise TokenRefreshFailed from error
@@ -153,7 +159,7 @@ def extract_login_info(self):
153159
self.token = self.login_response["access_token"]
154160
self.expires_in = self.login_response["expires_in"]
155161
self.expiration_date = time.time() + self.expires_in
156-
self._refresh_token = self.login_response["refresh_token"]
162+
self.refresh_token = self.login_response["refresh_token"]
157163

158164
def extract_tier_info(self):
159165
"""Extract tier info from tier info response."""
@@ -165,7 +171,7 @@ async def startup(self):
165171
"""Initialize tokens for communication."""
166172
self.validate_login()
167173
if None in self.login_attributes.values():
168-
await self.refresh_token()
174+
await self.refresh_tokens()
169175

170176
async def validate_response(self, response: ClientResponse, json_resp):
171177
"""Check for valid response."""
@@ -191,7 +197,7 @@ async def validate_response(self, response: ClientResponse, json_resp):
191197
def need_refresh(self):
192198
"""Check if token needs refresh."""
193199
if self.expiration_date is None:
194-
return self._refresh_token is not None
200+
return self.refresh_token is not None
195201

196202
return self.expiration_date - time.time() < 60
197203

@@ -219,7 +225,14 @@ async def query(
219225
"""
220226
try:
221227
if not skip_refresh_check and self.need_refresh():
222-
await self.login(refresh=True)
228+
await self.refresh_tokens(refresh=True)
229+
230+
if "Authorization" in headers:
231+
# update the authorization header with the new token
232+
headers["Authorization"] = f"Bearer {self.token}"
233+
234+
if self.callback is not None:
235+
self.callback()
223236

224237
if reqtype == "get":
225238
response = await self.session.get(
@@ -253,7 +266,7 @@ async def query(
253266
except UnauthorizedError:
254267
try:
255268
if not is_retry:
256-
await self.refresh_token()
269+
await self.refresh_tokens()
257270
return await self.query(
258271
url=url,
259272
data=data,
@@ -282,5 +295,9 @@ class BlinkBadResponse(Exception):
282295
"""Class to throw bad json response exception."""
283296

284297

298+
class BlinkTwoFARequiredError(Exception):
299+
"""Class to throw two-factor authentication required exception."""
300+
301+
285302
class UnauthorizedError(Exception):
286303
"""Class to throw an unauthorized access error."""

blinkpy/blinkpy.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
TIMEOUT_MEDIA,
3333
)
3434
from blinkpy.helpers.constants import __version__
35-
from blinkpy.auth import Auth, TokenRefreshFailed, LoginError
35+
from blinkpy.auth import Auth, BlinkTwoFARequiredError, TokenRefreshFailed, LoginError
3636

3737
_LOGGER = logging.getLogger(__name__)
3838

@@ -90,6 +90,16 @@ def account_id(self):
9090
"""Return the account id."""
9191
return self.auth.account_id
9292

93+
async def prompt_2fa(self):
94+
"""Prompt user for two-factor authentication code."""
95+
code = input("Enter the two-factor authentication code: ")
96+
await self.send_2fa_code(code)
97+
98+
async def send_2fa_code(self, code):
99+
"""Send the two-factor authentication code to complete login."""
100+
self.auth.data["2fa_code"] = code
101+
await self.start()
102+
93103
@util.Throttle(seconds=MIN_THROTTLE_TIME)
94104
async def refresh(self, force=False, force_cache=False):
95105
"""
@@ -127,6 +137,8 @@ async def start(self):
127137
_LOGGER.error("Cannot setup Blink platform.")
128138
self.available = False
129139
return False
140+
except BlinkTwoFARequiredError:
141+
raise
130142

131143
if not self.last_refresh:
132144
# Initialize last_refresh to be just before the refresh delay period.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "blinkpy"
7-
version = "0.24.0b0"
7+
version = "0.24.0b1"
88
license = {text = "MIT"}
99
description = "A Blink camera Python Library."
1010
readme = "README.rst"

tests/test_auth.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from aiohttp import ClientConnectionError, ContentTypeError
77
from blinkpy.auth import (
88
Auth,
9+
BlinkTwoFARequiredError,
910
TokenRefreshFailed,
1011
BlinkBadResponse,
1112
UnauthorizedError,
@@ -146,13 +147,13 @@ def test_header_no_token(self):
146147
self.assertEqual(self.auth.header, None)
147148

148149
@mock.patch("blinkpy.auth.Auth.validate_login")
149-
@mock.patch("blinkpy.auth.Auth.refresh_token")
150+
@mock.patch("blinkpy.auth.Auth.refresh_tokens")
150151
async def test_auth_startup(self, mock_validate, mock_refresh):
151152
"""Test auth startup."""
152153
await self.auth.startup()
153154

154155
@mock.patch("blinkpy.auth.Auth.query")
155-
async def test_refresh_token(self, mock_resp):
156+
async def test_refresh_tokens(self, mock_resp):
156157
"""Test refresh token method."""
157158
mock_json = mock.AsyncMock(
158159
return_value={
@@ -165,34 +166,42 @@ async def test_refresh_token(self, mock_resp):
165166
)
166167
mock_resp.side_effect = [
167168
# first request simulates otp required
168-
mock.AsyncMock(status=412),
169169
mock.AsyncMock(status=200, json=mock_json),
170170
{"tier": "test", "account_id": 5678},
171171
mock.AsyncMock(status=400),
172172
mock.AsyncMock(status=200, json=mock.AsyncMock(side_effect=AttributeError)),
173173
]
174174

175175
self.auth.no_prompt = True
176-
self.assertTrue(await self.auth.refresh_token())
176+
self.assertTrue(await self.auth.refresh_tokens())
177177
self.assertEqual(self.auth.region_id, "test")
178178
self.assertEqual(self.auth.token, "foobar")
179-
self.assertEqual(self.auth._refresh_token, "baz1")
179+
self.assertEqual(self.auth.refresh_token, "baz1")
180180
self.assertEqual(self.auth.account_id, 5678)
181181
self.assertEqual(self.auth.user_id, None)
182182

183183
with self.assertRaises(TokenRefreshFailed):
184-
await self.auth.refresh_token()
184+
await self.auth.refresh_tokens()
185185

186186
with self.assertRaises(TokenRefreshFailed):
187-
await self.auth.refresh_token()
187+
await self.auth.refresh_tokens()
188+
189+
@mock.patch("blinkpy.auth.Auth.query")
190+
async def test_refresh_token_otp_required(self, mock_resp):
191+
"""Test refresh token method."""
192+
mock_resp.side_effect = [mock.AsyncMock(status=412)]
193+
194+
self.auth.no_prompt = True
195+
with self.assertRaises(BlinkTwoFARequiredError):
196+
await self.auth.refresh_tokens()
188197

189198
@mock.patch("blinkpy.auth.Auth.login")
190199
async def test_refresh_token_failed(self, mock_login):
191200
"""Test refresh token failed."""
192201
mock_login.return_value = {}
193202
self.auth.is_errored = False
194203
with self.assertRaises(TokenRefreshFailed):
195-
await self.auth.refresh_token()
204+
await self.auth.refresh_tokens()
196205
self.assertTrue(self.auth.is_errored)
197206

198207
@mock.patch("blinkpy.auth.api.request_logout")
@@ -206,15 +215,15 @@ async def test_logout(self, mock_req):
206215
"blinkpy.auth.Auth.validate_response",
207216
mock.AsyncMock(side_effect=[UnauthorizedError, "foobar"]),
208217
)
209-
@mock.patch("blinkpy.auth.Auth.refresh_token", mock.AsyncMock(return_value=True))
218+
@mock.patch("blinkpy.auth.Auth.refresh_tokens", mock.AsyncMock(return_value=True))
210219
@mock.patch("blinkpy.auth.Auth.query", mock.AsyncMock(return_value="foobar"))
211220
async def test_query_retry(self): # , mock_refresh, mock_validate):
212221
"""Check handling of request retry."""
213222
self.auth.session = MockSession()
214223
self.assertEqual(await self.auth.query(url="http://example.com"), "foobar")
215224

216225
@mock.patch("blinkpy.auth.Auth.validate_response")
217-
@mock.patch("blinkpy.auth.Auth.refresh_token")
226+
@mock.patch("blinkpy.auth.Auth.refresh_tokens")
218227
async def test_query_retry_failed(self, mock_refresh, mock_validate):
219228
"""Check handling of failed retry request."""
220229
self.auth.session = MockSession()
@@ -241,7 +250,7 @@ async def test_query(self, mock_validate):
241250
self.assertIsNone(await self.auth.query("URL", "data", "headers", "post"))
242251

243252
mock_validate.side_effect = UnauthorizedError
244-
self.auth.refresh_token = mock.AsyncMock()
253+
self.auth.refresh_tokens = mock.AsyncMock()
245254
self.assertIsNone(await self.auth.query("URL", "data", "headers", "post"))
246255

247256
@mock.patch("blinkpy.auth.Auth.validate_response")
@@ -255,7 +264,7 @@ async def test_query_refresh_token_exists(
255264
mock.AsyncMock(status=200),
256265
mock.AsyncMock(status=200),
257266
]
258-
self.auth._refresh_token = "baz1"
267+
self.auth.refresh_token = "baz1"
259268
self.auth.data = {"username": "foo", "password": "bar"}
260269
await self.auth.query("URL", "data", "headers", "get")
261270

@@ -285,7 +294,7 @@ async def test_query_auth_expired(
285294
]
286295
self.auth.expiration_date = time.time() - 100
287296
self.auth.data = {"username": "foo", "password": "bar"}
288-
self.auth._refresh_token = "baz1"
297+
self.auth.refresh_token = "baz1"
289298

290299
await self.auth.query("URL", "data", "headers", "get")
291300

0 commit comments

Comments
 (0)