diff --git a/.github/workflows/docker+pypi.yml b/.github/workflows/docker+pypi.yml index f3f41c4a..fdefad8a 100644 --- a/.github/workflows/docker+pypi.yml +++ b/.github/workflows/docker+pypi.yml @@ -130,7 +130,7 @@ jobs: uses: docker/build-push-action@v2 with: context: contrib/docker - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true tags: twcmanager/twcmanager:latest build-args: | @@ -176,7 +176,7 @@ jobs: uses: docker/build-push-action@v2 with: context: contrib/docker - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true tags: twcmanager/twcmanager:${{ steps.branch_name.outputs.SOURCE_TAG }} build-args: | diff --git a/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 54c58906..cca19cbf 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -669,23 +669,27 @@ def do_GET(self): return webroutes = [ - { "route": "/debug", "tmpl": "debug.html.j2" }, - { "route": "/schedule", "tmpl": "schedule.html.j2" }, - { "route": "/settings", "tmpl": "settings.html.j2" }, - { "rstart": "/vehicleDetail", "tmpl": "vehicleDetail.html.j2" }, - { "route": "/vehicles", "tmpl": "vehicles.html.j2" } + { "route": "/debug", "tmpl": "debug.html.j2" }, + { "route": "/schedule", "tmpl": "schedule.html.j2" }, + { "route": "/settings", "tmpl": "settings.html.j2" }, + { "route": "/teslaAccount/login", "error": "insecure" }, + { "route": "/teslaAccount/mfaCode", "error": "insecure" }, + { "route": "/teslaAccount/submitCaptcha", "error": "insecure" }, + { "rstart": "/teslaAccount", "tmpl": "main.html.j2" }, + { "rstart": "/vehicleDetail", "tmpl": "vehicleDetail.html.j2" }, + { "route": "/vehicles", "tmpl": "vehicles.html.j2" } ] - if (self.url.path == "/teslaAccount/login" or - self.url.path == "/teslaAccount/mfaCode"): - # For security, these details should be submitted via a POST request - # Send a 405 Method Not Allowed in response. - self.send_response(405) - page = "This function may only be requested via the POST HTTP method." - self.wfile.write(page.encode("utf-8")) + if self.url.path == "/teslaAccount/getCaptchaImage": + self.send_response(200) + self.send_header("Content-type", "image/svg+xml") + self.end_headers() + self.wfile.write(master.getModuleByName( + "TeslaAPI" + ).getCaptchaImage()) return - if self.url.path == "/" or self.url.path.startswith("/teslaAccount"): + if self.url.path == "/": self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() @@ -711,10 +715,27 @@ def do_GET(self): for webroute in webroutes: if self.url.path == webroute.get("route", "INVALID"): route = webroute + break elif self.url.path.startswith(webroute.get("rstart", "INVALID")): route = webroute + break + + if route and route.get("error", None): + + if route["error"] == "insecure": + # For security, these details should be submitted via a POST request + # Send a 405 Method Not Allowed in response. + self.send_response(405) + page = "This function may only be requested via the POST HTTP method." + self.wfile.write(page.encode("utf-8")) + return + + else: + self.send_response(500) + self.wfile.write("".encode("utf-8")) + return - if route: + elif route: self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() @@ -836,6 +857,19 @@ def do_POST(self): self.wfile.write("".encode("utf-8")) return + if self.url.path == "/teslaAccount/submitCaptcha": + captchaCode = self.getFieldValue("captchaCode") + + resp = master.getModuleByName( + "TeslaAPI" + ).submitCaptchaCode(captchaCode) + + self.send_response(302) + self.send_header("Location", "/teslaAccount/" + str(resp)) + self.end_headers() + self.wfile.write("".encode("utf-8")) + return + if self.url.path == "/graphs/dates": # User has submitted dates to graph this period. objIni = self.getFieldValue("dateIni") diff --git a/lib/TWCManager/Control/themes/Default/handle_teslalogin.html.j2 b/lib/TWCManager/Control/themes/Default/handle_teslalogin.html.j2 index 4faddf6c..7a7f13ed 100644 --- a/lib/TWCManager/Control/themes/Default/handle_teslalogin.html.j2 +++ b/lib/TWCManager/Control/themes/Default/handle_teslalogin.html.j2 @@ -38,6 +38,18 @@ Error encountered during Phase 1 (GET) of the Tesla Authentication process. +{% elif url.path == "/teslaAccount/Phase1Captcha" %} + + Tesla Login has requested a Captcha code for this login session. Please type the characters shown on the Captcha image into the textbox below. + +
+ +
+ Enter Captcha Code: + + +
+
{% elif url.path == "/teslaAccount/Phase2Error" or url.path == "/teslaAccount/Phase2ErrorTip" %} Error encountered during Phase 2 (POST) of the Tesla Authentication process. @@ -58,7 +70,8 @@ {% if not master.teslaLoginAskLater and url.path != "/teslaAccount/True" - and not url.path.startswith("/teslaAccount/MFA") %} + and not url.path.startswith("/teslaAccount/MFA") + and not url.path == "/teslaAccount/Phase1Captcha" %} {% if not apiAvailable %} diff --git a/lib/TWCManager/Vehicle/TeslaAPI.py b/lib/TWCManager/Vehicle/TeslaAPI.py index cba40396..d02ed9f8 100644 --- a/lib/TWCManager/Vehicle/TeslaAPI.py +++ b/lib/TWCManager/Vehicle/TeslaAPI.py @@ -14,8 +14,11 @@ class TeslaAPI: + __apiCaptcha = None + __apiCaptchaCode = None authURL = "https://auth.tesla.com/oauth2/v3/authorize" callbackURL = "https://auth.tesla.com/void/callback" + captchaURL = "https://auth.tesla.com/captcha" carApiLastErrorTime = 0 carApiBearerToken = "" carApiRefreshToken = "" @@ -33,11 +36,14 @@ class TeslaAPI: carApiVehicles = [] config = None master = None + __email = None errorCount = 0 maxLoginRetries = 10 minChargeLevel = -1 params = None + __password = None refreshURL = "https://owner-api.teslamotors.com/oauth/token" + __resp = None session = None verifier = "" @@ -77,6 +83,10 @@ def addVehicle(self, json): def apiLogin(self, email, password): + # Populate auth details for Phase 1 + self.__email = email + self.__password = password + for attempt in range(self.maxLoginRetries): self.verifier = base64.urlsafe_b64encode(os.urandom(86)).rstrip(b"=") @@ -98,14 +108,22 @@ def apiLogin(self, email, password): ) self.session = requests.Session() - resp = self.session.get(self.authURL, params=self.params) + self.__resp = self.session.get(self.authURL, params=self.params) - if resp.ok and "" in resp.text: + if self.__resp.ok and "<title>" in self.__resp.text: logger.log( logging.INFO6, "Tesla Auth form fetch success, attempt: " + str(attempt), ) - break + + if 'img data-id="captcha"' in self.__resp.text: + logger.log( + logging.INFO6, + "Tesla Auth form challenged us for Captcha. Redirecting.") + self.getApiCaptcha() + return "Phase1Captcha" + else: + return self.apiLoginPhaseOne() else: logger.log( logging.INFO6, @@ -122,9 +140,15 @@ def apiLogin(self, email, password): ) return "Phase1Error" - csrf = re.search(r'name="_csrf".+value="([^"]+)"', resp.text).group(1) + def apiLoginPhaseOne(self): + + # Picks up on the first phase of authentication, after redirecting to + # handle Captcha if this was requested, or directly if we were lucky + # enough not to be challenged. + + csrf = re.search(r'name="_csrf".+value="([^"]+)"', self.__resp.text).group(1) transaction_id = re.search( - r'name="transaction_id".+value="([^"]+)"', resp.text + r'name="transaction_id".+value="([^"]+)"', self.__resp.text ).group(1) if not csrf or not transaction_id: @@ -138,10 +162,22 @@ def apiLogin(self, email, password): "_process": "1", "transaction_id": transaction_id, "cancel": "", - "identity": email, - "credential": password, + "identity": self.__email, + "credential": self.__password, } + # If a captcha code is stored, inject it into the data parameter + if self.__apiCaptchaCode: + data["captcha"] = self.__apiCaptchaCode + + # Clear captcha data + self.__apiCaptcha = None + + # Clear stored credentials + self.__email = None + self.__password = None + + # Call login Phase 2 return self.apiLoginPhaseTwo(data) def apiLoginPhaseTwo(self, data): @@ -1057,6 +1093,24 @@ def applyChargeLimit(self, limit, checkArrival=False, checkDeparture=False): if checkArrival: self.updateChargeAtHome() + def getApiCaptcha(self): + # This will fetch the current Captcha image displayed by Tesla's auth + # website, and store it in memory + + self.__apiCaptcha = self.session.get(self.captchaURL) + + def getCaptchaImage(self): + # This will serve the Tesla Captcha image + + if self.__apiCaptcha: + return(self.__apiCaptcha.content) + else: + logger.log( + logging.INFO2, + "ERROR: Captcha image requested, but we have none buffered. This is likely due to a stale login session, but if you see it regularly, please report it." + ) + return "" + def getCarApiBearerToken(self): return self.carApiBearerToken @@ -1181,6 +1235,10 @@ def setCarApiTokenExpireTime(self, value): self.carApiTokenExpireTime = value return True + def submitCaptchaCode(self, code): + self.__apiCaptchaCode = code + return self.apiLoginPhaseOne() + def updateCarApiLastErrorTime(self, vehicle=None): timestamp = time.time() logger.log(