From 1fd2ea6a1d3b730e827973b678f43fcf16a210c2 Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 4 Jun 2023 22:06:58 +0200 Subject: [PATCH] changed token refresh --- dbus-enphase-envoy/authentication.py | 177 +++++++++++++++++++++++ dbus-enphase-envoy/dbus-enphase-envoy.py | 83 ++++++++--- dbus-enphase-envoy/enphasetoken.py | 33 ++++- 3 files changed, 269 insertions(+), 24 deletions(-) create mode 100644 dbus-enphase-envoy/authentication.py diff --git a/dbus-enphase-envoy/authentication.py b/dbus-enphase-envoy/authentication.py new file mode 100644 index 0000000..25f7b26 --- /dev/null +++ b/dbus-enphase-envoy/authentication.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Enphase-API +# Copyright (C) 2023 Matthew1471! +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# We can check JWT claims/expiration first before making a request to prevent annoying Enphase® ("pip install pyjwt" if not already installed). +# import jwt + +# Third party library for making HTTP(S) requests; "pip install requests" if getting import errors. +import requests + + +class Authentication: + """A class to talk to Enphase®'s Cloud based Authentication Server, Entrez (French for "Access"). + This server also supports granting tokens for local access to the Gateway. + """ + + # Authentication host, Entrez (French for "Access"). + AUTHENTICATION_HOST = 'https://entrez.enphaseenergy.com' + + # This prevents the requests module from creating its own user-agent (and ask to not be included in analytics). + STEALTHY_HEADERS = {'User-Agent': None, 'Accept': 'application/json', 'DNT': '1'} + STEALTHY_HEADERS_FORM = {'User-Agent': None, 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'DNT': '1'} + + # Holds the session cookie which contains the session token. + session_cookies = None + + @staticmethod + def _extract_token_from_response(response): + # The text that indicates the beginning of a token, if this changes a lot we may have to turn this into a regular expression. + start_needle = '', start_position) + + # Check the end_position can be found. + if end_position != -1: + # The token can be returned. + return response[start_position:end_position] + + # The token cannot be returned. + raise ValueError('Unable to find the end of the token in the Authentication Server repsonse (the response page may have changed).') + + # The token cannot be returned. + raise ValueError('Unable to find access token in Authentication Server response (the response page may have changed).') + + def authenticate(self, username, password): + # Build the login request payload. + payload = {'username': username, 'password': password} + + # Send the login request. + response = requests.post(Authentication.AUTHENTICATION_HOST + '/login', headers=Authentication.STEALTHY_HEADERS_FORM, data=payload) + + # There's only 1 cookie value that is important to maintain session once we are authenticated. + # SESSION - This links our future requests to our existing login session on this server. + self.session_cookies = {'SESSION': response.cookies.get('SESSION')} + + # Return a true/false on whether login was successful. + return response.status_code == 200 + + def get_site(self, site_name): + return requests.get(Authentication.AUTHENTICATION_HOST + '/site/' + requests.utils.quote(site_name, safe=''), headers=Authentication.STEALTHY_HEADERS, cookies=self.session_cookies).json() + + def get_token_for_commissioned_gateway(self, gateway_serial_number): + # The actual website also seems to set "uncommissioned" to "on", but this is not necessary or correct for commissioned gateways. Site name also is passed but not required. + response = requests.post(Authentication.AUTHENTICATION_HOST + '/entrez_tokens', headers=Authentication.STEALTHY_HEADERS_FORM, cookies=self.session_cookies, data={'serialNum': gateway_serial_number}) + return self._extract_token_from_response(response.text) + + def get_token_for_uncommissioned_gateway(self): + # The actual website also sets an empty "Site" key, but this is not necessary for uncommissioned gateway access. + response = requests.post(Authentication.AUTHENTICATION_HOST + '/entrez_tokens', headers=Authentication.STEALTHY_HEADERS_FORM, cookies=self.session_cookies, data={'uncommissioned': 'true'}) + return self._extract_token_from_response(response.text) + + def get_token_from_enlighten_session_id(self, enlighten_session_id, gateway_serial_number, username): + # This is probably used internally by the Enlighten website itself to authorise sessions via Entrez. + return requests.post( + Authentication.AUTHENTICATION_HOST + '/tokens', + headers=Authentication.STEALTHY_HEADERS, + cookies=self.session_cookies, + json={ + 'session_id': enlighten_session_id, + 'serial_num': gateway_serial_number, + 'username': username + } + ).content + + """ + @staticmethod + def check_token_valid(token, gateway_serial_number=None): + # An installer is always allowed to access any uncommissioned Gateway serial number (currently for a shorter time however). + if gateway_serial_number: + calculated_audience = [gateway_serial_number, 'un-commissioned'] + else: + calculated_audience = ['un-commissioned'] + + try: + # Is the token still valid? + jwt.decode( + token, + key='', + algorithms='ES256', + options={ + 'verify_signature': False, + 'require': [ + 'aud', + 'iss', + 'enphaseUser', + 'exp', + 'iat', + 'jti', + 'username' + ], + 'verify_aud': True, + 'verify_iss': True, + 'verify_exp': True, + 'verify_iat': True + }, + audience=calculated_audience, + issuer='Entrez') + + # If we got to this line then no exceptions were generated by the above. + return True + + # Should never happen as we do not currently validate the token's signature. + except (jwt.exceptions.InvalidSignatureError, jwt.exceptions.InvalidKeyError): + raise + + # We mask the specific reason and just ultimately inform the user that the token is invalid. + except (jwt.exceptions.InvalidTokenError, + jwt.exceptions.DecodeError, + jwt.exceptions.ExpiredSignatureError, + jwt.exceptions.InvalidAudienceError, + jwt.exceptions.InvalidIssuerError, + jwt.exceptions.InvalidIssuedAtError, + jwt.exceptions.InvalidAlgorithmError, + jwt.exceptions.MissingRequiredClaimError): + + # The token is invalid. + return False + """ + + def logout(self): + response = requests.post(Authentication.AUTHENTICATION_HOST + '/logout', headers=Authentication.STEALTHY_HEADERS, cookies=self.session_cookies) + return response.status_code == 200 + + +if __name__ == "__main__": + print("Main run") + + session = Authentication() + + session.authenticate("user@domain.tld", "topsecret123") + token = session.get_token_for_commissioned_gateway("123456789012") + + print("Token:") + print(token) diff --git a/dbus-enphase-envoy/dbus-enphase-envoy.py b/dbus-enphase-envoy/dbus-enphase-envoy.py index c61e95a..a2903fd 100644 --- a/dbus-enphase-envoy/dbus-enphase-envoy.py +++ b/dbus-enphase-envoy/dbus-enphase-envoy.py @@ -6,7 +6,7 @@ import sys import os from time import sleep, time -from datetime import datetime +# from datetime import datetime import json import paho.mqtt.client as mqtt import configparser # for config/ini file @@ -282,23 +282,33 @@ def on_publish(client, userdata, rc): def tokenManager(): global envoy_enlighten_user, envoy_enlighten_password, envoy_serial, request_headers, auth_token - # check expiry on first run and then once every 24h - if auth_token["check_last"] < int(time()) - 86400: - logging.info("step: tokenManager") + logging.info("step: tokenManager") - # request token from file system or generate a new one if missing or about to expire - token = getToken(envoy_enlighten_user, envoy_enlighten_password, envoy_serial) - result = token.refresh() + while 1: + # check expiry on first run and then once every minute + if auth_token["check_last"] < int(time()) - 59: - if result: - request_headers = { - "Authorization": "Bearer " + result['auth_token'] - } - logging.error(f"Token created: {datetime.fromtimestamp(result['created'])} UTC") - else: - # check again in 5 minutes - auth_token["check_last"] = int(time) - 86400 + 900 - logging.error("Token was not loaded/renewed! Check again in 5 minutes") + # request token from file system or generate a new one if missing or about to expire + token = getToken(envoy_enlighten_user, envoy_enlighten_password, envoy_serial) + result = token.refresh() + + if result: + request_headers = { + "Authorization": "Bearer " + result['auth_token'] + } + auth_token = { + "auth_token": result['auth_token'], + "created": result['created'], + "check_last": int(time()), + "check_result": True + } + # logging.error(f"Token created on: {datetime.fromtimestamp(result['created'])} UTC") + else: + # check again in 5 minutes + auth_token["check_last"] = int(time()) - 86400 + 900 + logging.error("Token was not loaded/renewed! Check again in 5 minutes") + + sleep(60) # ENPHASE - ENOVY-S @@ -1411,7 +1421,7 @@ def _handlechangedvalue(self, path, value): def main(): global client, \ - fetch_production_historic_last, fetch_devices_last, fetch_inverters_last, fetch_events_last, request_schema + fetch_production_historic_last, fetch_devices_last, fetch_inverters_last, fetch_events_last, request_schema, auth_token, keep_running _thread.daemon = True # allow the program to quit @@ -1454,8 +1464,33 @@ def main(): ) client.loop_start() - # get auth token - tokenManager() + # check auth token + # start threat for fetching data every x seconds in background + tokenManager_thread = threading.Thread(target=tokenManager, name='Thread-TokenManager') + tokenManager_thread.daemon = True + tokenManager_thread.start() + + # wait to fetch data_production_historic else data_meter_stream cannot be fully merged + i = 0 + while auth_token["auth_token"] == "": + if i % 60 != 0 or i == 0: + logging.info("--> token still empty") + else: + logging.warning( + "token still empty" + ) + + if keep_running is False: + logging.info("--> wait for first data: got exit signal") + sys.exit() + + if i > 300: + logging.error("Maximum of 300 seconds wait time reached. Restarting the driver.") + keep_running = False + sys.exit() + + sleep(1) + i += 1 # Enphase Envoy-S # start threat for fetching data every x seconds in background @@ -1479,6 +1514,11 @@ def main(): logging.info("--> wait for first data: got exit signal") sys.exit() + if i > 300: + logging.error("Maximum of 300 seconds wait time reached. Restarting the driver.") + keep_running = False + sys.exit() + sleep(1) i += 1 @@ -1504,6 +1544,11 @@ def main(): logging.info("--> wait for first data: got exit signal") sys.exit() + if i > 300: + logging.error("Maximum of 300 seconds wait time reached. Restarting the driver.") + keep_running = False + sys.exit() + sleep(1) i += 1 diff --git a/dbus-enphase-envoy/enphasetoken.py b/dbus-enphase-envoy/enphasetoken.py index e1b1023..200aeac 100644 --- a/dbus-enphase-envoy/enphasetoken.py +++ b/dbus-enphase-envoy/enphasetoken.py @@ -1,12 +1,15 @@ #!/usr/bin/env python from time import time +from datetime import datetime import json -import requests +# import requests import os import sys import logging +from authentication import Authentication + class getToken: def __init__(self, user, password, serial, force=False): @@ -26,11 +29,30 @@ def refresh(self): else: json_data = {"auth_token": "", "created": 0} - # request a new token, if the old one is older than 90 days - if json_data["created"] + (60 * 60 * 24 * 90) < time(): - logging.warning("EnphaseToken: Requesting new access token...") + # request a new token, if the old one is older than 12 hours + if json_data["created"] + (60 * 60 * 12) - (60 * 5) < time(): + logging.warning(f"EnphaseToken: Token expired. Creation date: {datetime.fromtimestamp(json_data['created'])} UTC") try: + token = Authentication() + + token.authenticate(self.user, self.password) + + response_data = token.get_token_for_commissioned_gateway(self.serial) + + json_data = { + "auth_token": response_data, + "created": int(time()), + } + + with open(token_file, "w") as file: + file.write(json.dumps(json_data)) + + logging.warning(f"EnphaseToken: Token successfully requested. New creation date: {datetime.fromtimestamp(json_data['created'])} UTC") + + return json_data + + """" data_login = {"user[email]": self.user, "user[password]": self.password} response_login = requests.post( "https://enlighten.enphaseenergy.com/login/login.json", @@ -80,6 +102,7 @@ def refresh(self): logging.error("EnphaseToken: " + response_data["message"]) return False + """ except Exception: exception_type, exception_object, exception_traceback = sys.exc_info() @@ -92,7 +115,7 @@ def refresh(self): return False else: - logging.warning("EnphaseToken: Token still valid") + logging.info(f"EnphaseToken: Token still valid. Creation date {datetime.fromtimestamp(json_data['created'])} UTC") return json_data