PSA: change authentication from user/password to token (#13612)

This commit is contained in:
hurzhurz 2024-05-01 16:33:31 +02:00 • committed by GitHub
parent fd653ec838
commit db8808627a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 380 additions and 97 deletions

View file

@ -51,11 +51,19 @@ func runToken(cmd *cobra.Command, args []string) {
var token *oauth2.Token
var err error
switch strings.ToLower(vehicleConf.Type) {
typ := strings.ToLower(vehicleConf.Type)
if typ == "template" {
typ = strings.ToLower(vehicleConf.Other["template"].(string))
}
switch typ {
case "mercedes":
token, err = mercedesToken()
case "tronity":
token, err = tronityToken(conf, vehicleConf)
case "citroen", "ds", "opel", "peugeot":
token, err = psaToken(typ)
default:
log.FATAL.Fatalf("vehicle type '%s' does not support token authentication", vehicleConf.Type)
}

44
cmd/token_psa.go Normal file
View file

@ -0,0 +1,44 @@
package cmd
import (
"context"
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/psa"
"golang.org/x/oauth2"
)
func psaToken(brand string) (*oauth2.Token, error) {
var country string
prompt_country := &survey.Input{
Message: "Please enter your country code:",
}
if err := survey.AskOne(prompt_country, &country, survey.WithValidator(survey.Required)); err != nil {
return nil, err
}
cv := oauth2.GenerateVerifier()
oc := psa.Oauth2Config(brand, strings.ToLower(country))
authorize_url := oc.AuthCodeURL("", oauth2.S256ChallengeOption(cv))
fmt.Println("Please visit: ", authorize_url)
fmt.Println("And grab the authorization code like described here: https://github.com/flobz/psa_car_controller/discussions/779")
var code string
prompt_code := &survey.Input{
Message: "Please enter your authorization code:",
}
if err := survey.AskOne(prompt_code, &code, survey.WithValidator(survey.Required)); err != nil {
return nil, err
}
client := request.NewClient(util.NewLogger(brand))
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
return oc.Exchange(ctx, code, oauth2.VerifierOption(cv))
}

View file

@ -1,10 +1,44 @@
template: citroen
products:
- brand: Citroën
requirements:
description:
de: |
Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden.
en: |
Citroën `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]".
params:
- preset: vehicle-base
- name: title
- name: icon
default: car
advanced: true
- name: user
required: true
- name: password
deprecated: true
- name: tokens
required: true
mask: true
help:
en: "See https://docs.evcc.io/en/docs/devices/vehicles#citroen"
de: "Siehe https://docs.evcc.io/docs/devices/vehicles#citroen"
- name: vin
example: V...
- name: capacity
- name: phases
advanced: true
- name: password
deprecated: true
- preset: vehicle-identify
render: |
type: citroen
{{ include "vehicle-base" . }}
title: {{ .title }}
icon: {{ .icon }}
user: {{ .user }}
tokens:
access: {{ .accessToken }}
refresh: {{ .refreshToken }}
capacity: {{ .capacity }}
phases: {{ .phases }}
vin: {{ .vin }}
{{ include "vehicle-identify" . }}

View file

@ -1,10 +1,44 @@
template: ds
products:
- brand: DS
requirements:
description:
de: |
Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden.
en: |
DS `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]".
params:
- preset: vehicle-base
- name: title
- name: icon
default: car
advanced: true
- name: user
required: true
- name: password
deprecated: true
- name: tokens
required: true
mask: true
help:
en: "See https://docs.evcc.io/en/docs/devices/vehicles#ds"
de: "Siehe https://docs.evcc.io/docs/devices/vehicles#ds"
- name: vin
example: V...
- name: capacity
- name: phases
advanced: true
- name: password
deprecated: true
- preset: vehicle-identify
render: |
type: ds
{{ include "vehicle-base" . }}
title: {{ .title }}
icon: {{ .icon }}
user: {{ .user }}
tokens:
access: {{ .accessToken }}
refresh: {{ .refreshToken }}
capacity: {{ .capacity }}
phases: {{ .phases }}
vin: {{ .vin }}
{{ include "vehicle-identify" . }}

View file

@ -1,12 +1,44 @@
template: opel
products:
- brand: Opel
requirements:
description:
de: |
Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden.
en: |
Opel `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]".
params:
- preset: vehicle-base
- preset: vehicle-identify
- name: title
- name: icon
default: car
advanced: true
- name: user
required: true
- name: password
deprecated: true
- name: tokens
required: true
mask: true
help:
en: "See https://docs.evcc.io/en/docs/devices/vehicles#opel"
de: "Siehe https://docs.evcc.io/docs/devices/vehicles#opel"
- name: vin
example: WP0...
example: V...
- name: capacity
- name: phases
advanced: true
- name: password
deprecated: true
- preset: vehicle-identify
render: |
type: opel
{{ include "vehicle-base" . }}
title: {{ .title }}
icon: {{ .icon }}
user: {{ .user }}
tokens:
access: {{ .accessToken }}
refresh: {{ .refreshToken }}
capacity: {{ .capacity }}
phases: {{ .phases }}
vin: {{ .vin }}
{{ include "vehicle-identify" . }}

View file

@ -1,10 +1,44 @@
template: peugeot
products:
- brand: Peugeot
requirements:
description:
de: |
Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden.
en: |
Peugeot `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]".
params:
- preset: vehicle-base
- name: title
- name: icon
default: car
advanced: true
- name: user
required: true
- name: password
deprecated: true
- name: tokens
required: true
mask: true
help:
en: "See https://docs.evcc.io/en/docs/devices/vehicles#peugeot"
de: "Siehe https://docs.evcc.io/docs/devices/vehicles#peugeot"
- name: vin
example: V...
- name: capacity
- name: phases
advanced: true
- name: password
deprecated: true
- preset: vehicle-identify
render: |
type: peugeot
{{ include "vehicle-base" . }}
title: {{ .title }}
icon: {{ .icon }}
user: {{ .user }}
tokens:
access: {{ .accessToken }}
refresh: {{ .refreshToken }}
capacity: {{ .capacity }}
phases: {{ .phases }}
vin: {{ .vin }}
{{ include "vehicle-identify" . }}

View file

@ -1,7 +1,7 @@
package vehicle
import (
"fmt"
"strings"
"time"
"github.com/evcc-io/evcc/api"
@ -12,46 +12,18 @@ import (
// https://github.com/TA2k/ioBroker.psa
func init() {
registry.Add("citroen", NewCitroenFromConfig)
registry.Add("ds", NewDSFromConfig)
registry.Add("opel", NewOpelFromConfig)
registry.Add("peugeot", NewPeugeotFromConfig)
}
// NewCitroenFromConfig creates a new vehicle
func NewCitroenFromConfig(other map[string]interface{}) (api.Vehicle, error) {
log := util.NewLogger("citroen")
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",
"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",
"1eebc2d5-5df3-459b-a624-20abfcf82530", "T5tP7iS0cO8sC0lA2iE2aR7gK6uE5rF3lJ8pC3nO1pR7tL8vU1",
other)
registry.Add("citroen", func(other map[string]any) (api.Vehicle, error) {
return newPSA("citroen", "clientsB2CCitroen", other)
})
registry.Add("ds", func(other map[string]any) (api.Vehicle, error) {
return newPSA("ds", "clientsB2CDS", other)
})
registry.Add("opel", func(other map[string]any) (api.Vehicle, error) {
return newPSA("opel", "clientsB2COpel", other)
})
registry.Add("peugeot", func(other map[string]any) (api.Vehicle, error) {
return newPSA("peugeot", "clientsB2CPeugeot", other)
})
}
// PSA is an api.Vehicle implementation for PSA cars
@ -61,17 +33,16 @@ type PSA struct {
}
// newPSA creates a new vehicle
func newPSA(log *util.Logger, brand, realm, id, secret string, other map[string]interface{}) (api.Vehicle, error) {
func newPSA(brand, realm string, other map[string]interface{}) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
Credentials ClientCredentials
User, Password, VIN string
VIN string
User string
Password string `mapstructure:"password"`
Country string
Tokens Tokens
Cache time.Duration
}{
Credentials: ClientCredentials{
ID: id,
Secret: secret,
},
Cache: interval,
}
@ -79,22 +50,30 @@ func newPSA(log *util.Logger, brand, realm, id, secret string, other map[string]
return nil, err
}
if cc.User == "" || cc.Password == "" {
if cc.User == "" {
return nil, api.ErrMissingCredentials
}
token, err := cc.Tokens.Token()
if err != nil {
return nil, err
}
v := &PSA{
embed: &cc.embed,
}
log.Redact(cc.User, cc.Password, cc.VIN)
identity := psa.NewIdentity(log, brand, cc.Credentials.ID, cc.Credentials.Secret)
log := util.NewLogger(brand)
log.Redact(cc.User, cc.Tokens.Access, cc.Tokens.Refresh)
if err := identity.Login(cc.User, cc.Password); err != nil {
return v, fmt.Errorf("login failed: %w", err)
oc := psa.Oauth2Config(brand, strings.ToLower(cc.Country))
identity, err := psa.NewIdentity(log, brand, cc.User, oc, token)
if err != nil {
return nil, err
}
api := psa.NewAPI(log, identity, realm, cc.Credentials.ID)
// TODO still needed?
api := psa.NewAPI(log, identity, realm, oc.ClientID)
vehicle, err := ensureVehicleEx(
cc.VIN, api.Vehicles,

20
vehicle/psa/helper.go Normal file
View file

@ -0,0 +1,20 @@
package psa
import (
"sync"
"golang.org/x/oauth2"
)
var (
mu sync.Mutex
identities = make(map[string]oauth2.TokenSource)
)
func getInstance(subject string) oauth2.TokenSource {
return identities[subject]
}
func addInstance(subject string, identity oauth2.TokenSource) {
identities[subject] = identity
}

View file

@ -2,48 +2,80 @@ package psa
import (
"context"
"fmt"
"errors"
"strings"
"sync"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
type Identity struct {
*request.Helper
oc *oauth2.Config
oauth2.TokenSource
mu sync.Mutex
oc *oauth2.Config
log *util.Logger
subject string
}
// 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 NewIdentity(log *util.Logger, brand, user string, oc *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
// serialise instance handling
mu.Lock()
defer mu.Unlock()
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)
// reuse identity instance
subject := "psa." + strings.ToLower(brand) + "." + strings.ToLower(user)
if instance := getInstance(subject); instance != nil {
return instance, nil
}
return err
v := &Identity{
log: log,
oc: oc,
subject: subject,
}
var tok oauth2.Token
if err := settings.Json(v.subject, &tok); err == nil {
token = &tok
}
if !token.Valid() {
if tok, err := v.RefreshToken(token); err == nil {
token = tok
}
}
if !token.Valid() {
return nil, errors.New("token expired")
}
v.TokenSource = oauth.RefreshTokenSource(token, v)
// add instance
addInstance(v.subject, v)
return v, nil
}
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
v.mu.Lock()
defer v.mu.Unlock()
client := request.NewClient(v.log)
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
tok, err := v.oc.TokenSource(ctx, token).Token()
if err != nil {
return nil, err
}
v.TokenSource = oauth.RefreshTokenSource(tok, v)
err = settings.SetJson(v.subject, tok)
return tok, err
}

66
vehicle/psa/oauth2.go Normal file
View file

@ -0,0 +1,66 @@
package psa
import (
"fmt"
"golang.org/x/oauth2"
)
func Oauth2Config(brand, country string) *oauth2.Config {
switch brand {
case "citroen":
return &oauth2.Config{
ClientID: "5364defc-80e6-447b-bec6-4af8d1542cae",
ClientSecret: "iE0cD8bB0yJ0dS6rO3nN1hI2wU7uA5xR4gP7lD6vM0oH0nS8dN",
Endpoint: oauth2.Endpoint{
AuthURL: "https://idpcvs.citroen.com/am/oauth2/authorize",
TokenURL: "https://idpcvs.citroen.com/am/oauth2/access_token",
AuthStyle: oauth2.AuthStyleInHeader,
},
RedirectURL: fmt.Sprintf("mymacsdk://oauth2redirect/%s", country),
Scopes: []string{"openid", "profile"},
}
case "ds":
return &oauth2.Config{
ClientID: "cbf74ee7-a303-4c3d-aba3-29f5994e2dfa",
ClientSecret: "X6bE6yQ3tH1cG5oA6aW4fS6hK0cR0aK5yN2wE4hP8vL8oW5gU3",
Endpoint: oauth2.Endpoint{
AuthURL: "https://idpcvs.driveds.com/am/oauth2/authorize",
TokenURL: "https://idpcvs.driveds.com/am/oauth2/access_token",
AuthStyle: oauth2.AuthStyleInHeader,
},
RedirectURL: fmt.Sprintf("mymdssdk://oauth2redirect/%s", country),
Scopes: []string{"openid", "profile"},
}
case "opel":
return &oauth2.Config{
ClientID: "07364655-93cb-4194-8158-6b035ac2c24c",
ClientSecret: "F2kK7lC5kF5qN7tM0wT8kE3cW1dP0wC5pI6vC0sQ5iP5cN8cJ8",
Endpoint: oauth2.Endpoint{
AuthURL: "https://idpcvs.opel.com/am/oauth2/authorize",
TokenURL: "https://idpcvs.opel.com/am/oauth2/access_token",
AuthStyle: oauth2.AuthStyleInHeader,
},
RedirectURL: fmt.Sprintf("mymopsdk://oauth2redirect/%s", country),
Scopes: []string{"openid", "profile"},
}
case "peugeot":
return &oauth2.Config{
ClientID: "1eebc2d5-5df3-459b-a624-20abfcf82530",
ClientSecret: "T5tP7iS0cO8sC0lA2iE2aR7gK6uE5rF3lJ8pC3nO1pR7tL8vU1",
Endpoint: oauth2.Endpoint{
AuthURL: "https://idpcvs.peugeot.com/am/oauth2/authorize",
TokenURL: "https://idpcvs.peugeot.com/am/oauth2/access_token",
AuthStyle: oauth2.AuthStyleInHeader,
},
RedirectURL: fmt.Sprintf("mymap://oauth2redirect/%s", country),
Scopes: []string{"openid", "profile"},
}
default:
panic("invalid brand: " + brand)
}
}