From d2b376fcebf25338603ea81673fb0ca598f4cb2f Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 10 Aug 2021 10:11:12 +0200 Subject: [PATCH] PSA: refactor authentication (#1319) --- README.md | 3 ++- vehicle/nissan.go | 6 ++--- vehicle/psa.go | 53 ++++++++++++++++++++++++++++------------- vehicle/psa/api.go | 44 +++++++--------------------------- vehicle/psa/identity.go | 49 +++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 55 deletions(-) create mode 100644 vehicle/psa/identity.go diff --git a/README.md b/README.md index fcbbefa04..2fbc7dae0 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ Available charger implementations are: - `custom`: default charger implementation using configurable [plugins](#plugins) for integrating any type of charger Smart-Home outlet charger implementations: + - `fritzdect`: Fritz!DECT 200/210 outlets - `shelly`: Shelly outlets - `tasmota`: Tasmota outlets @@ -261,7 +262,7 @@ Available vehicle remote interface implementations are: - `audi`: Audi (eTron, Q55) - `bmw`: BMW (i3) - `carwings`: Nissan (Leaf pre 2019) -- `citroen`, `opel`, `peugeot`: Follow this [tutorial](https://github.com/flobz/psa_car_controller) to obtain client credentials for PSA. +- `citroen`, `ds`, `opel`, `peugeot`: All PSA brands - `fiat`: Fiat (500e) - `ford`: Ford (Kuga, Mustang) - `kia`: Kia (Soul and other Bluelink models) diff --git a/vehicle/nissan.go b/vehicle/nissan.go index 803fb29f7..fe1cbd9d7 100644 --- a/vehicle/nissan.go +++ b/vehicle/nissan.go @@ -50,14 +50,14 @@ func NewNissanFromConfig(other map[string]interface{}) (api.Vehicle, error) { log := util.NewLogger("nissan") identity := nissan.NewIdentity(log) - err := identity.Login(cc.User, cc.Password) - if err != nil { + if err := identity.Login(cc.User, cc.Password); err != nil { return v, fmt.Errorf("login failed: %w", err) } api := nissan.NewAPI(log, identity, strings.ToUpper(cc.VIN)) - if err == nil && cc.VIN == "" { + var err error + if cc.VIN == "" { api.VIN, err = findVehicle(api.Vehicles()) if err == nil { log.DEBUG.Printf("found vehicle: %v", api.VIN) diff --git a/vehicle/psa.go b/vehicle/psa.go index fe15ab0ed..f88ddeb6c 100644 --- a/vehicle/psa.go +++ b/vehicle/psa.go @@ -11,10 +11,11 @@ import ( "github.com/andig/evcc/vehicle/psa" ) -// https://github.com/flobz/psa_car_controller +// https://github.com/TA2k/ioBroker.psa func init() { registry.Add("citroen", NewCitroenFromConfig) + registry.Add("ds", NewDSFromConfig) registry.Add("opel", NewOpelFromConfig) registry.Add("peugeot", NewPeugeotFromConfig) } @@ -22,19 +23,37 @@ func init() { // NewCitroenFromConfig creates a new vehicle func NewCitroenFromConfig(other map[string]interface{}) (api.Vehicle, error) { log := util.NewLogger("citroen") - return newPSA(log, "citroen.com", "clientsB2CCitroen", other) + return newPSA(log, + "citroen.com", "clientsB2CCitroen", + "5364defc-80e6-447b-bec6-4af8d1542cae", "iE0cD8bB0yJ0dS6rO3nN1hI2wU7uA5xR4gP7lD6vM0oH0nS8dN", + other) +} + +// NewDSFromConfig creates a new vehicle +func NewDSFromConfig(other map[string]interface{}) (api.Vehicle, error) { + log := util.NewLogger("ds") + return newPSA(log, + "driveds.com", "clientsB2CDS", + "cbf74ee7-a303-4c3d-aba3-29f5994e2dfa", "X6bE6yQ3tH1cG5oA6aW4fS6hK0cR0aK5yN2wE4hP8vL8oW5gU3", + other) } // NewOpelFromConfig creates a new vehicle func NewOpelFromConfig(other map[string]interface{}) (api.Vehicle, error) { log := util.NewLogger("opel") - return newPSA(log, "opel.com", "clientsB2COpel", other) + return newPSA(log, + "opel.com", "clientsB2COpel", + "07364655-93cb-4194-8158-6b035ac2c24c", "F2kK7lC5kF5qN7tM0wT8kE3cW1dP0wC5pI6vC0sQ5iP5cN8cJ8", + other) } // NewPeugeotFromConfig creates a new vehicle func NewPeugeotFromConfig(other map[string]interface{}) (api.Vehicle, error) { log := util.NewLogger("peugeot") - return newPSA(log, "peugeot.com", "clientsB2CPeugeot", other) + return newPSA(log, + "peugeot.com", "clientsB2CPeugeot", + "1eebc2d5-5df3-459b-a624-20abfcf82530", "T5tP7iS0cO8sC0lA2iE2aR7gK6uE5rF3lJ8pC3nO1pR7tL8vU1", + other) } // PSA is an api.Vehicle implementation for PSA cars @@ -44,13 +63,17 @@ type PSA struct { } // newPSA creates a new vehicle -func newPSA(log *util.Logger, brand, realm string, other map[string]interface{}) (api.Vehicle, error) { +func newPSA(log *util.Logger, brand, realm, id, secret string, other map[string]interface{}) (api.Vehicle, error) { cc := struct { - embed `mapstructure:",squash"` - ClientID, ClientSecret string - User, Password, VIN string - Cache time.Duration + embed `mapstructure:",squash"` + Credentials ClientCredentials + User, Password, VIN string + Cache time.Duration }{ + Credentials: ClientCredentials{ + ID: id, + Secret: secret, + }, Cache: interval, } @@ -58,20 +81,18 @@ func newPSA(log *util.Logger, brand, realm string, other map[string]interface{}) return nil, err } - if cc.ClientID == "" || cc.ClientSecret == "" { - return nil, errors.New("missing client id and/or secret (see https://github.com/flobz/psa_car_controller)") - } - v := &PSA{ embed: &cc.embed, } - api := psa.NewAPI(log, brand, realm, cc.ClientID, cc.ClientSecret) - err := api.Login(cc.User, cc.Password) - if err != nil { + identity := psa.NewIdentity(log, brand, cc.Credentials.ID, cc.Credentials.Secret) + + if err := identity.Login(cc.User, cc.Password); err != nil { return v, fmt.Errorf("login failed: %w", err) } + api := psa.NewAPI(log, identity, realm, cc.Credentials.ID) + vehicles, err := api.Vehicles() if err != nil { return nil, err diff --git a/vehicle/psa/api.go b/vehicle/psa/api.go index 304f5afac..1fb09c2f0 100644 --- a/vehicle/psa/api.go +++ b/vehicle/psa/api.go @@ -1,7 +1,6 @@ package psa import ( - "context" "fmt" "net/http" "net/url" @@ -12,7 +11,6 @@ import ( "golang.org/x/oauth2" ) -// https://github.com/flobz/psa_car_controller // https://developer.groupe-psa.io/webapi/b2c/api-reference/specification // BaseURL is the API base url @@ -21,51 +19,27 @@ const BaseURL = "https://api.groupe-psa.com/connectedcar/v4" // API is an api.Vehicle implementation for PSA cars type API struct { *request.Helper - brand, realm string - id, secret string // client + realm string + id string } // NewAPI creates a new vehicle -func NewAPI(log *util.Logger, brand, realm, id, secret string) *API { +func NewAPI(log *util.Logger, identity oauth2.TokenSource, realm, id string) *API { v := &API{ Helper: request.NewHelper(log), - brand: brand, realm: realm, id: id, - secret: secret, + } + + // replace client transport with authenticated transport + v.Client.Transport = &oauth2.Transport{ + Source: identity, + Base: v.Client.Transport, } return v } -// Login performs the login -func (v *API) Login(user, password string) error { - config := oauth2.Config{ - ClientID: v.id, - ClientSecret: v.secret, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize", - TokenURL: fmt.Sprintf("https://idpcvs.%s/am/oauth2/access_token", v.brand), - AuthStyle: oauth2.AuthStyleInHeader, - }, - Scopes: []string{"openid profile"}, - } - - ctx := context.WithValue( - context.Background(), - oauth2.HTTPClient, - v.Client, - ) - - // replace client with authenticated oauth client - token, err := config.PasswordCredentialsToken(ctx, user, password) - if err == nil { - v.Client = config.Client(ctx, token) - } - - return err -} - // Vehicle is a single vehicle type Vehicle struct { ID string `json:"id"` diff --git a/vehicle/psa/identity.go b/vehicle/psa/identity.go new file mode 100644 index 000000000..4b7c965ae --- /dev/null +++ b/vehicle/psa/identity.go @@ -0,0 +1,49 @@ +package psa + +import ( + "context" + "fmt" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" +) + +type Identity struct { + *request.Helper + oc *oauth2.Config + oauth2.TokenSource +} + +// NewIdentity creates PSA identity +func NewIdentity(log *util.Logger, brand, id, secret string) *Identity { + return &Identity{ + Helper: request.NewHelper(log), + oc: &oauth2.Config{ + ClientID: id, + ClientSecret: secret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize", + TokenURL: fmt.Sprintf("https://idpcvs.%s/am/oauth2/access_token", brand), + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: []string{"openid profile"}, + }, + } +} + +func (v *Identity) Login(user, password string) error { + ctx := context.WithValue( + context.Background(), + oauth2.HTTPClient, + v.Client, + ) + + // replace client with authenticated oauth client + token, err := v.oc.PasswordCredentialsToken(ctx, user, password) + if err == nil { + v.TokenSource = v.oc.TokenSource(ctx, token) + } + + return err +}