diff --git a/tconnectsync/api/__init__.py b/tconnectsync/api/__init__.py index 745ef52..dead215 100644 --- a/tconnectsync/api/__init__.py +++ b/tconnectsync/api/__init__.py @@ -4,6 +4,7 @@ from .android import AndroidApi from .controliq import ControlIQApi from .ws2 import WS2Api from .webui import WebUIScraper +from .tandemsource import TandemSourceApi logger = logging.getLogger(__name__) @@ -19,6 +20,17 @@ class TConnectApi: self._ws2 = None self._android = None self._webui = None + self._tandemsource = None + + @property + def tandemsource(self): + if self._tandemsource and not self._tandemsource.needs_relogin(): + return self._tandemsource + + logger.debug("Instantiating new TandemSourceApi") + + self._tandemsource = TandemSourceApi(self.email, self.password) + return self._tandemsource @property diff --git a/tconnectsync/api/tandemsource.py b/tconnectsync/api/tandemsource.py index aec57ed..1e8ce51 100644 --- a/tconnectsync/api/tandemsource.py +++ b/tconnectsync/api/tandemsource.py @@ -7,6 +7,7 @@ import base64 import hashlib import os import jwt +import pickle from requests_oidc import make_auth_code_session from requests_oidc.plugins import OSCachedPlugin @@ -17,6 +18,7 @@ from jwt.algorithms import RSAAlgorithm from ..util import timeago, cap_length from .common import parse_ymd_date, base_headers, base_session, ApiException, ApiLoginException +from ..secret import CACHE_CREDENTIALS, CACHE_CREDENTIALS_PATH logger = logging.getLogger(__name__) @@ -38,9 +40,13 @@ class TandemSourceApi: def login(self, email, password): logger.info("Logging in to TandemSourceApi...") + if self.try_load_cached_creds(email): + logger.info("Successfully used cached credentials") + return True + with base_session() as s: initial = s.get(self.LOGIN_PAGE_URL, headers=base_headers()) - + data = { "username": email, "password": password @@ -58,7 +64,7 @@ class TandemSourceApi: if not login_ok: raise ApiException(req.status_code, 'Error parsing login_api_url: %s' % json.dumps(req_json)) - + logger.debug("2. starting OIDC") # oidc @@ -79,7 +85,7 @@ class TandemSourceApi: sha256_digest = hashlib.sha256(verifier.encode('utf-8')).digest() code_challenge = base64.urlsafe_b64encode(sha256_digest).decode('utf-8').rstrip('=') return code_challenge - + code_verifier = generate_code_verifier() code_challenge = generate_code_challenge(code_verifier) @@ -130,7 +136,7 @@ class TandemSourceApi: if oidc_step2.status_code//100 != 2: raise ApiException(oidc_step1.status_code, 'Got unexpected status code for oidc step2: %s' % oidc_step1.text) - + oidc_json = oidc_step2.json() logger.debug("5. parsing oidc_step2 json response: %s" % json.dumps(oidc_json)) @@ -139,7 +145,7 @@ class TandemSourceApi: if not 'id_token' in oidc_json: raise ApiException(oidc_step1.status_code, 'Missing id_token in oidc_step2 json: %s' % json.dumps(oidc_json)) - + self.loginSession = s self.idToken = oidc_json['id_token'] self.extract_jwt() @@ -147,9 +153,11 @@ class TandemSourceApi: self.accessToken = oidc_json['access_token'] self.accessTokenExpiresAt = arrow.get(arrow.get().int_timestamp + oidc_json['expires_in']) - + + self.cache_creds(email) + return True - + def extract_jwt(self): logger.debug("6. extracting JWT from %s" % self.idToken) id_token = self.idToken @@ -182,11 +190,111 @@ class TandemSourceApi: ) logger.info("Decoded JWT: %s" % json.dumps(id_token_claims)) - + self.jwtData = id_token_claims self.pumperId = id_token_claims['pumperId'] self.accountId = id_token_claims['accountId'] + def try_load_cached_creds(self, email): + if not CACHE_CREDENTIALS: + return False + + if not os.path.exists(CACHE_CREDENTIALS_PATH): + logger.info("No cached credentials exist") + return False + + _saved_blob = {} + try: + with open(CACHE_CREDENTIALS_PATH, 'rb') as f: + _saved_blob = pickle.load(f) + except Exception as e: + logger.warning(f"Could not load cached credentials at {CACHE_CREDENTIALS_PATH}: {e}") + return False + + if not _saved_blob: + logger.warning(f"Could not load cached credentials at {CACHE_CREDENTIALS_PATH}: empty dict") + return False + + if _saved_blob.get('cache_creds_version') != 1.0: + logger.warning(f"Unexpected cache_creds_version at {CACHE_CREDENTIALS_PATH}: {_saved_blob['cache_creds_version']}, expected 1.0") + return False + + if _saved_blob.get('cache_creds_email') != email: + logger.warning(f"Cached credentials are for a different email ({_saved_blob['cache_creds_email']} in cache, but using {email}), skipping") + return False + + at_expiry = _saved_blob['accessTokenExpiresAt'] + if arrow.get().int_timestamp >= arrow.get(at_expiry).int_timestamp: + logger.info(f"Cached credentials have expired ({_saved_blob['accessTokenExpiresAt']}), skipping") + return False + + self.jwtData = _saved_blob['jwtData'] + self.pumperId = _saved_blob['pumperId'] + self.accountId = _saved_blob['accountId'] + self.idToken = _saved_blob['idToken'] + self.accessToken = _saved_blob['accessToken'] + self.accessTokenExpiresAt = _saved_blob['accessTokenExpiresAt'] + self.loginSession = _saved_blob['loginSession'] + + def est_time(t): + now = arrow.get() + if now < t: + sec = (t - now).seconds + else: + sec = (now - t).seconds + min = sec//60 + hr = min//60 + min = min % 60 + sec = sec % 60 + r = '' + if hr: + r += f'{hr} hr ' + if min: + r += f'{min} min ' + if sec: + r += f'{sec} sec ' + if not r: + return 'now' + elif now < t: + return 'in '+r.strip() + else: + return r.strip()+' ago' + + + sa = _saved_blob['cache_creds_saved_at'] + ex = _saved_blob['accessTokenExpiresAt'] + logger.info(f"Loaded cached credentials from {CACHE_CREDENTIALS_PATH}: saved at {sa} ({est_time(sa)}), access token expiry {ex} ({est_time(ex)})") + + return True + + + def cache_creds(self, email): + if not CACHE_CREDENTIALS: + logger.info("Credentials caching is disabled, skipping save") + return + + _saved_blob = { + 'cache_creds_version': 1.0, + 'cache_creds_saved_at': arrow.get(), + 'cache_creds_email': email, + 'jwtData': self.jwtData, + 'pumperId': self.pumperId, + 'accountId': self.accountId, + 'idToken': self.idToken, + 'accessToken': self.accessToken, + 'accessTokenExpiresAt': self.accessTokenExpiresAt, + 'loginSession': self.loginSession + } + + if not os.path.exists(CACHE_CREDENTIALS_PATH): + mkdir = os.path.dirname(CACHE_CREDENTIALS_PATH) + logger.debug(f"Running mkdir on {mkdir}") + os.makedirs(mkdir, exist_ok=True) + + with open(CACHE_CREDENTIALS_PATH, 'wb') as f: + pickle.dump(_saved_blob, f) + logger.info(f"Saved cached credentials to {CACHE_CREDENTIALS_PATH}") + def needs_relogin(self): if not self.accessTokenExpiresAt: @@ -239,7 +347,7 @@ class TandemSourceApi: """ def pumper_info(self): return self.get('api/pumpers/pumpers/%s' % (self.pumperId), {}) - + """ Returns metadata for pump events. Returns a list of dict's per-pump on the account. [ @@ -248,7 +356,7 @@ class TandemSourceApi: """ def pump_event_metadata(self): return self.get('api/reports/reportsfacade/%s/pumpeventmetadata' % (self.pumperId), {}) - + """ Returns raw unparsed string for pump events tconnect_device_id is "tconnectDeviceId" from pump_event_metadata() @@ -256,6 +364,7 @@ class TandemSourceApi: def pump_events_raw(self, tconnect_device_id, min_date=None, max_date=None): minDate = parse_ymd_date(min_date) maxDate = parse_ymd_date(max_date) + logger.debug(f'pump_events_raw({minDate}, {maxDate})') # 229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,399,256,213,406,394,212,404,214,405,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191 eventIdsFilter = '229%2C5%2C28%2C4%2C26%2C99%2C279%2C3%2C16%2C59%2C21%2C55%2C20%2C280%2C64%2C65%2C66%2C61%2C33%2C371%2C171%2C369%2C460%2C172%2C370%2C461%2C372%2C399%2C256%2C213%2C406%2C394%2C212%2C404%2C214%2C405%2C447%2C313%2C60%2C14%2C6%2C90%2C230%2C140%2C12%2C11%2C53%2C13%2C63%2C203%2C307%2C191'