Skip to content

Commit

Permalink
changed token refresh
Browse files Browse the repository at this point in the history
  • Loading branch information
mr-manuel committed Jun 4, 2023
1 parent e40b3b3 commit 1fd2ea6
Show file tree
Hide file tree
Showing 3 changed files with 269 additions and 24 deletions.
177 changes: 177 additions & 0 deletions dbus-enphase-envoy/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# This file is part of Enphase-API <https://github.com/Matthew1471/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 <https://www.gnu.org/licenses/>.

# 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 = '<textarea name="accessToken" id="JWTToken" cols="30" rows="10" >'

# Look for the start token text.
start_position = response.find(start_needle)

# Check the response contains the expected start of a token result.
if start_position != -1:
# Skip past the start_needle.
start_position += len(start_needle)

# Look for the end of the token text.
end_position = response.find('</textarea>', 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("[email protected]", "topsecret123")
token = session.get_token_for_commissioned_gateway("123456789012")

print("Token:")
print(token)
83 changes: 64 additions & 19 deletions dbus-enphase-envoy/dbus-enphase-envoy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down
33 changes: 28 additions & 5 deletions dbus-enphase-envoy/enphasetoken.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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",
Expand Down Expand Up @@ -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()
Expand All @@ -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


Expand Down

0 comments on commit 1fd2ea6

Please sign in to comment.