Skip to content

Commit

Permalink
Support new Tesla login including MFA devices (evcc-io#626)
Browse files Browse the repository at this point in the history
  • Loading branch information
andig committed Feb 4, 2021
1 parent b12c112 commit 04807a2
Show file tree
Hide file tree
Showing 8 changed files with 594 additions and 35 deletions.
105 changes: 105 additions & 0 deletions cmd/tesla.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package cmd

import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"

"github.com/andig/evcc/server"
"github.com/andig/evcc/util"
auth "github.com/andig/evcc/vehicle/tesla"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/thoas/go-funk"
"github.com/uhthomas/tesla"
)

// teslaCmd represents the vehicle command
var teslaCmd = &cobra.Command{
Use: "tesla-token [name]",
Short: "Generate Tesla access token for configured vehicle",
Run: runTeslaToken,
}

func init() {
rootCmd.AddCommand(teslaCmd)
}

func codePrompt(ctx context.Context, devices []tesla.Device) (tesla.Device, string, error) {
fmt.Println("Authentication devices:", funk.Map(devices, func(d tesla.Device) string {
return fmt.Sprintf("%s (%s)", d.Name, d.FactorType)
}))
if len(devices) > 1 {
return tesla.Device{}, "", errors.New("multiple devices found, only single device supported")
}

fmt.Print("Please enter passcode: ")
reader := bufio.NewReader(os.Stdin)
code, err := reader.ReadString('\n')

return devices[0], strings.TrimSpace(code), err
}

func generateToken(user, pass string) {
client, err := auth.NewClient(log)
if err != nil {
log.FATAL.Fatalln(err)
}

client.DeviceHandler(codePrompt)

ts, err := client.Login(user, pass)
if err != nil {
log.FATAL.Fatalln(err)
}

token, err := ts.Token()
if err != nil {
log.FATAL.Fatalln(err)
}

fmt.Println()
fmt.Println("Add the following tokens to the tesla vehicle config:")
fmt.Println()
fmt.Println(" tokens:")
fmt.Println(" access:", token.AccessToken)
fmt.Println(" refresh:", token.RefreshToken)
}

func runTeslaToken(cmd *cobra.Command, args []string) {
util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels"))
log.INFO.Printf("evcc %s (%s)", server.Version, server.Commit)

// load config
conf, err := loadConfigFile(cfgFile)
if err != nil {
log.FATAL.Fatal(err)
}

var vehicleConf qualifiedConfig
if len(conf.Vehicles) == 1 {
vehicleConf = conf.Vehicles[0]
} else if len(args) == 1 {
vehicleConf = funk.Find(conf.Vehicles, func(v qualifiedConfig) bool {
return strings.ToLower(v.Name) == strings.ToLower(args[0])
}).(qualifiedConfig)
}

if vehicleConf.Name == "" {
log.FATAL.Fatal("vehicle not found")
}

var credentials struct {
User, Password string
Other map[string]interface{} `mapstructure:",remain"`
}

if err := util.DecodeOther(vehicleConf.Other, &credentials); err != nil {
log.FATAL.Fatal(err)
}

generateToken(credentials.User, credentials.Password)
}
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/andig/evcc
go 1.13

require (
github.com/PuerkitoBio/goquery v1.6.0
github.com/PuerkitoBio/goquery v1.6.1
github.com/andig/evcc-config v0.0.0-20210112213741-5c09f26e0c2a
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
github.com/avast/retry-go v3.0.0+incompatible
Expand Down Expand Up @@ -53,11 +53,14 @@ require (
github.com/spf13/viper v1.7.1
github.com/thoas/go-funk v0.7.0
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
github.com/uhthomas/tesla v0.0.0-20210202211959-8f97ef33b7b3
github.com/volkszaehler/mbmd v0.0.0-20210117183837-59dcc46d62d4
golang.org/x/net v0.0.0-20201216054612-986b41b23924
gopkg.in/ini.v1 v1.62.0
golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)

replace github.com/spf13/viper => github.com/andig/viper v1.6.3-0.20201123175942-a5af09afab5b

replace github.com/jsgoecke/tesla => github.com/andig/tesla v0.0.0-20210203084021-0d6f2d3bb496
285 changes: 281 additions & 4 deletions go.sum

Large diffs are not rendered by default.

6 changes: 1 addition & 5 deletions util/request/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ type Helper struct {
func NewHelper(log *util.Logger) *Helper {
r := &Helper{
Client: &http.Client{Timeout: 10 * time.Second},
}

// add logger
if log != nil {
r.log = log.TRACE
log: log.TRACE,
}

// intercept for logging
Expand Down
3 changes: 1 addition & 2 deletions vehicle/renault.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,7 @@ func (v *Renault) kamereonRequest(uri string) (kamereonResponse, error) {
data := url.Values{"country": []string{"DE"}}
headers := map[string]string{
"x-gigya-id_token": v.gigyaJwtToken,
// "apikey": v.kamereon.APIKey, // wrong key since 2021-02-01
"apikey": "Ae9FDWugRxZQAGm3Sxgk7uJn6Q4CGEA2", // temporary workaround
"apikey": v.kamereon.APIKey,
}

var res kamereonResponse
Expand Down
65 changes: 43 additions & 22 deletions vehicle/tesla.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ package vehicle
import (
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
auth "github.com/andig/evcc/vehicle/tesla"
"github.com/jsgoecke/tesla"
"gopkg.in/ini.v1"
"golang.org/x/oauth2"
)

// Tesla is an api.Vehicle implementation for Tesla cars
Expand All @@ -22,6 +23,11 @@ type Tesla struct {
chargedEnergyG func() (float64, error)
}

// teslaTokens contains access and refresh tokens
type teslaTokens struct {
Access, Refresh string
}

func init() {
registry.Add("tesla", NewTeslaFromConfig)
}
Expand All @@ -33,6 +39,7 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
Capacity int64
ClientID, ClientSecret string
User, Password string
Tokens teslaTokens
VIN string
Cache time.Duration
}{
Expand All @@ -47,19 +54,23 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
embed: &embed{cc.Title, cc.Capacity},
}

if cc.ClientID == "" {
// https://tesla-api.timdorr.com/api-basics/authentication
cc.ClientID, cc.ClientSecret = v.downloadClientID("https://pastebin.com/raw/pS7Z6yyP")
log := util.NewLogger("tesla")
authClient, err := auth.NewClient(log)
if err != nil {
return nil, err
}

ts, err := tokenSource(authClient, cc.User, cc.Password, cc.Tokens)
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}

client, err := tesla.NewClient(&tesla.Auth{
ClientID: cc.ClientID,
ClientSecret: cc.ClientSecret,
Email: cc.User,
Password: cc.Password,
TokenSource: ts,
HTTPClient: request.NewHelper(log),
})
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
return nil, err
}

vehicles, err := client.Vehicles()
Expand Down Expand Up @@ -87,21 +98,31 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
return v, nil
}

// download client id and secret
func (v *Tesla) downloadClientID(uri string) (string, string) {
resp, err := http.Get(uri)
if err == nil {
defer resp.Body.Close()

cfg, err := ini.Load(resp.Body)
if err == nil {
id := cfg.Section("").Key("TESLA_CLIENT_ID").String()
secret := cfg.Section("").Key("TESLA_CLIENT_SECRET").String()
return id, secret
// token creates the Tesla access token
func tokenSource(auth *auth.Client, user, password string, tokens teslaTokens) (oauth2.TokenSource, error) {
// without tokens try to login - will fail if MFA enabled
if tokens.Access == "" {
ts, err := auth.Login(user, password)
if err != nil {
err = fmt.Errorf("%w: if using multi-factor authentication, create tokens using `evcc tesla-token`", err)
}

return ts, err
}

// create tokensource with given tokens
ts := auth.TokenSource(&oauth2.Token{
AccessToken: tokens.Access,
RefreshToken: tokens.Refresh,
})

// test the token source
_, err := ts.Token()
if err != nil {
err = fmt.Errorf("%w: token refresh failed, check access and refresh tokens are valid", err)
}

return "", ""
return ts, err
}

// chargeState implements the api.Vehicle interface
Expand Down
118 changes: 118 additions & 0 deletions vehicle/tesla/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package tesla

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"

"github.com/andig/evcc/util"
"github.com/uhthomas/tesla"
"golang.org/x/oauth2"
)

// Client is the tesla authentication client
type Client struct {
config *oauth2.Config
auth *tesla.Auth
verifier string
}

// github.com/uhthomas/tesla
func state() string {
var b [9]byte
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b[:])
}

// https://www.oauth.com/oauth2-servers/pkce/
func pkce() (verifier, challenge string, err error) {
var p [87]byte
if _, err := io.ReadFull(rand.Reader, p[:]); err != nil {
return "", "", fmt.Errorf("rand read full: %w", err)
}
verifier = base64.RawURLEncoding.EncodeToString(p[:])
b := sha256.Sum256([]byte(challenge))
challenge = base64.RawURLEncoding.EncodeToString(b[:])
return verifier, challenge, nil
}

// NewClient creates a tesla authentication client
func NewClient(log *util.Logger) (*Client, error) {
httpClient := &http.Client{Transport: &roundTripper{
log: log,
transport: http.DefaultTransport,
}}

config := &oauth2.Config{
ClientID: "ownerapi",
ClientSecret: "",
RedirectURL: "https://auth.tesla.com/void/callback",
Scopes: []string{"openid email offline_access"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://auth.tesla.com/oauth2/v3/authorize",
TokenURL: "https://auth.tesla.com/oauth2/v3/token",
},
}

verifier, challenge, err := pkce()
if err != nil {
return nil, fmt.Errorf("pkce: %w", err)
}

auth := &tesla.Auth{
Client: httpClient,
AuthURL: config.AuthCodeURL(state(), oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
),
}

client := &Client{
config: config,
auth: auth,
verifier: verifier,
}
client.DeviceHandler(client.mfaUnsupported)

return client, nil
}

// Login executes the MFA or non-MFA login
func (c *Client) Login(username, password string) (oauth2.TokenSource, error) {
ctx := context.Background()
code, err := c.auth.Do(ctx, username, password)
if err != nil {
return nil, err
}

token, err := c.config.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", c.verifier),
)
if err != nil {
return nil, fmt.Errorf("exchange: %w", err)
}

return c.TokenSource(token), nil
}

// TokenSource creates an oauth tokensource from given token
func (c *Client) TokenSource(token *oauth2.Token) oauth2.TokenSource {
ctx := context.Background()
return c.config.TokenSource(ctx, token)
}

// DeviceHandler sets an alternative authentication device handler
func (c *Client) DeviceHandler(handler func(context.Context, []tesla.Device) (tesla.Device, string, error)) {
c.auth.SelectDevice = handler
}

func (c *Client) mfaUnsupported(_ context.Context, _ []tesla.Device) (tesla.Device, string, error) {
return tesla.Device{}, "", errors.New("multi factor authentication is not supported")
}
Loading

0 comments on commit 04807a2

Please sign in to comment.