Skip to content

Commit

Permalink
Optional captcha support (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
tdorssers committed Jun 5, 2021
1 parent 2abb38c commit 22540ab
Show file tree
Hide file tree
Showing 6 changed files with 59 additions and 47 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ This module depends on Python [requests](https://pypi.org/project/requests/) and
* Authentication is only needed when a new token is requested.
* The token is automatically refreshed when expired without the need to reauthenticate.
* An email registered in another region (e.g. auth.tesla.cn) is also supported.
* Login form captcha verification support
* Captcha verification support if required by the login form.

The constructor takes two arguments required for authentication (email and password) and six optional arguments: a passcode getter function, a factor selector function, a captcha resolver function, a verify SSL certificate bool, a proxy server URL, the maximum number of retries or an instance of the `teslapy.Retry` class and a User-Agent string. The convenience method `api()` uses named endpoints listed in *endpoints.json* to perform calls, so the module does not require changes if the API is updated. Any error message returned by the API is raised as an `HTTPError` exception. Additionally, the class implements the following methods:
The constructor takes two arguments required for authentication (email and password) and six optional arguments: a passcode getter function, a factor selector function, a captcha resolver function, a verify SSL certificate bool, a proxy server URL, the maximum number of retries or an instance of the `teslapy.Retry` class and a User-Agent string. The class will use `stdio` to get a passcode, factor and captcha by default. The convenience method `api()` uses named endpoints listed in *endpoints.json* to perform calls, so the module does not require changes if the API is updated. Any error message returned by the API is raised as an `HTTPError` exception. Additionally, the class implements the following methods:

| Call | Description |
| --- | --- |
Expand Down Expand Up @@ -71,19 +71,19 @@ with teslapy.Tesla('elon@tesla.com', 'starship') as tesla:
print(vehicles[0].get_vehicle_data()['vehicle_state']['car_version'])
```

The constructor requires a function that returns a passcode string as the third argument (otherwise you will get a ```ValueError: `passcode_getter` callback is not set```) in case your Tesla account has MFA enabled:
The constructor takes a function that returns a passcode string as the third argument in case your Tesla account has MFA enabled and you don't want to use the default passcode getter method:

```python
with teslapy.Tesla('elon@tesla.com', 'starship', lambda: '123456') as tesla:
```

Tesla allows you to enable more then one MFA device. In this case the constructor requires a function that takes a list of dicts as an argument and returns the selected factor dict as the fourth argument. The function may return the selected factor name as well:
Tesla allows you to enable more then one MFA device. If you don't want to use the default factor selector method, you can pass a function that takes a list of dicts as an argument and returns the selected factor dict as the constructor's fourth argument. The function may return the selected factor name as well:

```python
with teslapy.Tesla('elon@tesla.com', 'starship', lambda: '123456', lambda _: 'Device #1') as tesla:
```

Take a look at [menu.py](https://github.com/tdorssers/TeslaPy/blob/master/menu.py) or [gui.py](https://github.com/tdorssers/TeslaPy/blob/master/gui.py) for examples of passcode getter functions, factor selector functions and captcha solver functions.
Take a look at [cli.py](https://github.com/tdorssers/TeslaPy/blob/master/cli.py), [menu.py](https://github.com/tdorssers/TeslaPy/blob/master/menu.py) or [gui.py](https://github.com/tdorssers/TeslaPy/blob/master/gui.py) for examples of passcode getter, factor selector and captcha solver functions.

These are the major commands:

Expand Down Expand Up @@ -147,7 +147,7 @@ When you pass an empty string as passcode or factor to the constructor and your

If you get a `requests.exceptions.HTTPError: 400 Client Error: endpoint_deprecated:_please_update_your_app for url: https://owner-api.teslamotors.com/oauth/token` then you are probably using an old version of this module. As of January 29, 2021, Tesla updated this endpoint to follow [RFC 7523](https://tools.ietf.org/html/rfc7523) and requires the use of the SSO service (auth.tesla.com) for authentication.

As of May 28, 2021 Tesla has added captcha verification to the login form. If you get a `ValueError: Credentials rejected` and you are using correct credentials then you are probably using an old version of this module.
As of May 28, 2021, Tesla has added captcha verification to the login form. If you get a `ValueError: Credentials rejected` and you are using correct credentials then you are probably using an old version of this module.

## Demo applications

Expand Down Expand Up @@ -188,11 +188,11 @@ Example usage of [cli.py](https://github.com/tdorssers/TeslaPy/blob/master/cli.p

`python cli.py -e elon@tesla.com -w -a ACTUATE_TRUNK -k which_trunk=front`

[menu.py](https://github.com/tdorssers/TeslaPy/blob/master/cli.py) is a menu-based console application that displays vehicle data in a tabular format. The application depends on [geopy](https://pypi.org/project/geopy/) to convert GPS coordinates to a human readable address:
[menu.py](https://github.com/tdorssers/TeslaPy/blob/master/menu.py) is a menu-based console application that displays vehicle data in a tabular format. The application depends on [geopy](https://pypi.org/project/geopy/) to convert GPS coordinates to a human readable address:

![](https://raw.githubusercontent.com/tdorssers/TeslaPy/master/media/menu.png)

[gui.py](https://github.com/tdorssers/TeslaPy/blob/master/gui.py) is a graphical interface using `tkinter`. API calls are performed asynchronously using threading. The GUI supports auto refreshing of the vehicle data and the GUI displays a composed vehicle image. Note that the vehicle will not go to sleep, if auto refresh is enabled. The application also depends on [svglib](https://pypi.org/project/svglib/) to display the captcha image if required by the login form and therefore requires Python 3.6+.
[gui.py](https://github.com/tdorssers/TeslaPy/blob/master/gui.py) is a graphical interface using `tkinter`. API calls are performed asynchronously using threading. The GUI supports auto refreshing of the vehicle data and the GUI displays a composed vehicle image. Note that the vehicle will not go to sleep, if auto refresh is enabled. The application depends on [geopy](https://pypi.org/project/geopy/) to convert GPS coordinates to a human readable address, [pillow](https://pypi.org/project/Pillow/) to display the vehicle image and [svglib](https://pypi.org/project/svglib/) to display the captcha image if required by the login form.

![](https://raw.githubusercontent.com/tdorssers/TeslaPy/master/media/gui.png)

Expand Down Expand Up @@ -463,9 +463,9 @@ TeslaPy is available on PyPI:

`python -m pip install teslapy`

Make sure you have [Python](https://www.python.org/) 2.7+ or 3.5+ installed on your system. Alternatively, clone the repository to your machine and run demo application [cli.py](https://github.com/tdorssers/TeslaPy/blob/master/cli.py), [menu.py](https://github.com/tdorssers/TeslaPy/blob/master/cli.py) or [gui.py](https://github.com/tdorssers/TeslaPy/blob/master/cli.py) to get started, after installing [requests_oauthlib](https://pypi.org/project/requests-oauthlib/) and [geopy](https://pypi.org/project/geopy/) using [PIP](https://pypi.org/project/pip/) as follows:
Make sure you have [Python](https://www.python.org/) 2.7+ or 3.5+ installed on your system. Alternatively, clone the repository to your machine and run demo application [cli.py](https://github.com/tdorssers/TeslaPy/blob/master/cli.py), [menu.py](https://github.com/tdorssers/TeslaPy/blob/master/menu.py) or [gui.py](https://github.com/tdorssers/TeslaPy/blob/master/gui.py) to get started, after installing [requests_oauthlib](https://pypi.org/project/requests-oauthlib/), [geopy](https://pypi.org/project/geopy/) and [svglib](https://pypi.org/project/svglib/) using [PIP](https://pypi.org/project/pip/) as follows:

`python -m pip install requests_oauthlib geopy`
`python -m pip install requests_oauthlib geopy svglib`

or on Ubuntu as follows:

Expand Down
11 changes: 1 addition & 10 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,13 @@
import logging
import getpass
import argparse
import tempfile
import webbrowser
from teslapy import Tesla, Vehicle

raw_input = vars(__builtins__).get('raw_input', input) # Py2/3 compatibility

def get_passcode(args):
return args.passcode if args.passcode else raw_input('Passcode: ')

def get_captcha(svg):
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as f:
f.write(svg)
webbrowser.open('file://' + f.name)
return raw_input('Captcha: ')

def main():
parser = argparse.ArgumentParser(description='Tesla Owner API CLI')
parser.add_argument('-e', dest='email', help='login email', required=True)
Expand Down Expand Up @@ -63,8 +55,7 @@ def main():
else:
password = args.password
with Tesla(args.email, password, lambda: get_passcode(args),
(lambda _: args.factor) if args.factor else None,
get_captcha) as tesla:
(lambda _: args.factor) if args.factor else None) as tesla:
tesla.fetch_token()
selected = prod = tesla.vehicle_list() + tesla.battery_list()
if args.filter:
Expand Down
17 changes: 11 additions & 6 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@

import io
import time
import base64
import logging
import threading
from geopy.geocoders import Nominatim
from geopy.exc import *
from tkinter import *
from tkinter.simpledialog import *
from configparser import *
try:
from Tkinter import *
from tkSimpleDialog import *
from ConfigParser import *
except ImportError:
from tkinter import *
from tkinter.simpledialog import *
from configparser import *
from PIL import Image, ImageTk
from svglib.svglib import svg2rlg
from reportlab.graphics import renderPM
Expand Down Expand Up @@ -119,7 +123,7 @@ def __init__(self, master, title=None, photo=None):
def body(self, master):
Label(master, image=self.photo).pack()
self.captcha = Entry(master)
self.captcha.pack()
self.captcha.pack(pady=5)
return self.captcha

def apply(self):
Expand Down Expand Up @@ -534,13 +538,14 @@ def show_dialog():
def captcha(self, response):
""" Show captcha image and let user enter characters """
result = ['not_set']
# Convert SVG to PhotoImage
drawing = svg2rlg(io.BytesIO(response))
image = io.BytesIO()
renderPM.drawToFile(drawing, image, fmt='PNG')
photo = ImageTk.PhotoImage(Image.open(image))
def show_dialog():
""" Inner function to show dialog """
dlg = CaptchaDialog(self, 'Enter characters', photo)
dlg = CaptchaDialog(self, 'Enter captcha characters', photo)
result[0] = dlg.result
self.after_idle(show_dialog) # Start from main thread
while result[0] == 'not_set':
Expand Down
11 changes: 1 addition & 10 deletions menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from __future__ import print_function
import logging
import getpass
import tempfile
import webbrowser
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut
from teslapy import Tesla
Expand Down Expand Up @@ -243,19 +241,12 @@ def select_factor(factors):
print('-'*80)
return factors[idx]

def get_captcha(svg):
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as f:
f.write(svg)
webbrowser.open('file://' + f.name)
return raw_input('Captcha: ')

def main():
default_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
logging.basicConfig(level=logging.DEBUG, format=default_format)
email = raw_input('Enter email: ')
password = getpass.getpass('Password: ')
with Tesla(email, password, get_passcode, select_factor,
get_captcha) as tesla:
with Tesla(email, password, get_passcode, select_factor) as tesla:
tesla.fetch_token()
vehicles = tesla.vehicle_list()
print('-'*80)
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
geopy==1.20.0
requests-oauthlib==1.3.0
svglib==0.9.4
46 changes: 35 additions & 11 deletions teslapy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import hashlib
import logging
import pkgutil
import tempfile
import webbrowser
try:
from HTMLParser import HTMLParser
from urlparse import urljoin
Expand Down Expand Up @@ -65,6 +67,12 @@ def _masquerade(arg):
logger.addFilter(PasswdFilter())
logging.getLogger('requests_oauthlib.oauth2_session').addFilter(PasswdFilter())

# Py2/3 compatibility
try:
input = raw_input
except NameError:
pass


class Tesla(requests.Session):
""" Implements a session manager for the Tesla Motors Owner API
Expand All @@ -74,7 +82,8 @@ class Tesla(requests.Session):
:passcode_getter: Function that returns the TOTP passcode.
:factor_selector: Function with one argument, a list of factor dicts, that
returns the selected dict or factor name.
:captcha_solver: Function with one argument that returns the captcha code.
:captcha_solver: Function with one argument, SVG image content, that
returns the captcha characters.
:param verify: Verify SSL certificate.
:param proxy: URL of proxy server.
:param retry: Number of connection retries or :class:`Retry` instance.
Expand All @@ -89,9 +98,9 @@ def __init__(self, email, password, passcode_getter=None,
raise ValueError('`email` is not set')
self.email = email
self.password = password
self.passcode_getter = passcode_getter
self.factor_selector = factor_selector
self.captcha_solver = captcha_solver
self.passcode_getter = passcode_getter or self._get_passcode
self.factor_selector = factor_selector or self._select_factor
self.captcha_solver = captcha_solver or self._solve_captcha
self.token = {}
self.expires_at = 0
self.authorized = False
Expand Down Expand Up @@ -172,13 +181,11 @@ def fetch_token(self):
transaction_id = form['transaction_id']
# Retrieve captcha image if required
if 'captcha' in form:
if not self.captcha_solver:
raise ValueError('`captcha_solver` callback is not set')
response = oauth.get(self.sso_base + 'captcha')
response.raise_for_status() # Raise HTTPError, if one occurred
form['captcha'] = self.captcha_solver(response.content)
if not form['captcha']:
raise ValueError('`captcha_solver` returned nothing')
raise ValueError('Missing captcha response')
# Submit login credentials to get authorization code through redirect
form.update({'identity': self.email, 'credential': self.password})
response = oauth.post(self.sso_base + 'oauth2/v3/authorize',
Expand Down Expand Up @@ -208,13 +215,9 @@ def _check_mfa(self, oauth, transaction_id):
factors = response.json()['data']
if not factors:
raise ValueError('No registered factors')
if not self.passcode_getter:
raise ValueError('`passcode_getter` callback is not set')
if len(factors) == 1:
factor = factors[0] # Auto select only factor
elif len(factors) > 1:
if not self.factor_selector:
raise ValueError('`factor_selector` callback is not set')
# Get selected factor
factor = self.factor_selector(factors)
if not factor:
Expand Down Expand Up @@ -266,6 +269,27 @@ def _fetch_jwt(self, oauth):
self.authorized = True
self._token_updater() # Save new token

@staticmethod
def _get_passcode():
""" Default passcode_getter method """
return input('Passcode: ')

@staticmethod
def _select_factor(factors):
""" Default factor_selector method """
for i, factor in enumerate(factors):
print('{:2} {}'.format(i, factor['name']))
return factors[int(input('Select factor: '))]

@staticmethod
def _solve_captcha(svg):
""" Default captcha_solver method """
# Use web browser to display SVG image
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as f:
f.write(svg)
webbrowser.open('file://' + f.name)
return input('Captcha: ')

def _token_updater(self):
""" Handles token persistency """
# Open cache file
Expand Down

0 comments on commit 22540ab

Please sign in to comment.