From 2f6f48b5f562a9ab4ffeb590fb3aba89d626e5c2 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 22 Apr 2021 12:46:51 +0200 Subject: [PATCH] Kia/Hyundai: support account migration and multiple vehicles (#904) --- internal/vehicle/bluelink/bluelink.go | 189 +++++++++++++++++++++----- internal/vehicle/hyundai.go | 27 ++++ internal/vehicle/kia.go | 27 ++++ 3 files changed, 212 insertions(+), 31 deletions(-) diff --git a/internal/vehicle/bluelink/bluelink.go b/internal/vehicle/bluelink/bluelink.go index c2a4a6bcf..99e701d22 100644 --- a/internal/vehicle/bluelink/bluelink.go +++ b/internal/vehicle/bluelink/bluelink.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/PuerkitoBio/goquery" "github.com/andig/evcc/api" "github.com/andig/evcc/provider" "github.com/andig/evcc/util" @@ -27,18 +28,23 @@ var ( errAuthFail = errors.New("authorization failed") defaults = Config{ - DeviceID: "/api/v1/spa/notifications/register", - Lang: "/api/v1/user/language", - Login: "/api/v1/user/signin", - AccessToken: "/api/v1/user/oauth2/token", - Vehicles: "/api/v1/spa/vehicles", - Status: "/api/v1/spa/vehicles/%s/status", + DeviceID: "/api/v1/spa/notifications/register", + IntegrationInfo: "/api/v1/user/integrationinfo", + SilentSignin: "/api/v1/user/silentsignin", + Lang: "/api/v1/user/language", + Login: "/api/v1/user/signin", + AccessToken: "/api/v1/user/oauth2/token", + Vehicles: "/api/v1/spa/vehicles", + Status: "/api/v1/spa/vehicles/%s/status", } ) // Config is the bluelink API configuration type Config struct { URI string + BrandAuthUrl string // v2 + IntegrationInfo string // v2 + SilentSignin string // v2 TokenAuth string CCSPServiceID string CCSPApplicationID string @@ -60,13 +66,17 @@ type API struct { apiG func() (interface{}, error) config Config auth Auth + Vehicle Vehicle } // Auth bundles miscellaneous authorization data type Auth struct { - accToken string - deviceID string - vehicleID string + accToken string + deviceID string +} + +type Vehicle struct { + Vin, VehicleName, VehicleID string } type response struct { @@ -81,15 +91,13 @@ type response struct { Value, Unit int } } - DrvDistance []drvDistance - } - Vehicles []struct { - VehicleID string + DrvDistance []DrivingDistance } + Vehicles []Vehicle } } -type drvDistance struct { +type DrivingDistance struct { RangeByFuel struct { EvModeRange struct { Value int @@ -188,7 +196,118 @@ func (v *API) setLanguage(cookieClient *request.Helper) error { return err } -func (v *API) login(cookieClient *request.Helper) (string, error) { +func (v *API) brandLogin(cookieClient *request.Helper) (string, error) { + req, err := request.New(http.MethodGet, v.config.URI+v.config.IntegrationInfo, nil, request.JSONEncoding) + + var info struct { + UserId string `json:"userId"` + ServiceId string `json:"serviceId"` + } + + if err == nil { + err = cookieClient.DoJSON(req, &info) + } + + var action string + var resp *http.Response + + if err == nil { + uri := fmt.Sprintf(v.config.BrandAuthUrl, v.config.URI, "en", info.ServiceId, info.UserId) + + req, err = request.New(http.MethodGet, uri, nil) + if err == nil { + if resp, err = cookieClient.Do(req); err == nil { + defer resp.Body.Close() + + var doc *goquery.Document + if doc, err = goquery.NewDocumentFromReader(resp.Body); err == nil { + err = errors.New("form not found") + + if form := doc.Find("form"); form != nil && form.Length() == 1 { + var ok bool + if action, ok = form.Attr("action"); ok { + err = nil + } + } + } + } + } + } + + if err == nil { + data := url.Values{ + "username": []string{v.user}, + "password": []string{v.password}, + "credentialId": []string{""}, + "rememberMe": []string{"on"}, + } + + req, err = request.New(http.MethodPost, action, strings.NewReader(data.Encode()), request.URLEncoding) + if err == nil { + cookieClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // don't follow redirects + if resp, err = cookieClient.Do(req); err == nil { + defer resp.Body.Close() + + // need 302 + if resp.StatusCode != http.StatusFound { + err = errors.New("missing redirect") + + if doc, err2 := goquery.NewDocumentFromReader(resp.Body); err2 == nil { + if span := doc.Find("span[class=kc-feedback-text]"); span != nil && span.Length() == 1 { + err = errors.New(span.Text()) + } + } + } + } + + cookieClient.CheckRedirect = nil + } + } + + var userId string + if err == nil { + resp, err = cookieClient.Get(resp.Header.Get("Location")) + if err == nil { + defer resp.Body.Close() + + userId = resp.Request.URL.Query().Get("userId") + if len(userId) == 0 { + err = errors.New("usedId not found") + } + } + } + + var code string + if err == nil { + data := map[string]string{ + "userId": userId, + } + + req, err = request.New(http.MethodPost, v.config.URI+v.config.SilentSignin, request.MarshalJSON(data), request.JSONEncoding) + if err == nil { + req.Header.Set("ccsp-service-id", v.config.CCSPServiceID) + req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 11_1 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Version/11.0 Mobile/15B92 Safari/604.1") + + cookieClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // don't follow redirects + var res struct { + RedirectUrl string `json:"redirectUrl"` + } + if err = cookieClient.DoJSON(req, &res); err == nil { + fmt.Println(res) + var uri *url.URL + if uri, err = url.Parse(res.RedirectUrl); err == nil { + if code = uri.Query().Get("code"); len(code) == 0 { + err = errors.New("code not found") + } + } + } + } + } + + return code, err +} + +func (v *API) bluelinkLogin(cookieClient *request.Helper) (string, error) { data := map[string]interface{}{ "email": v.user, "password": v.password, @@ -244,10 +363,16 @@ func (v *API) getToken(accCode string) (string, error) { return accToken, err } -func (v *API) getVehicles(accToken, did string) (string, error) { +func (v *API) Vehicles() ([]Vehicle, error) { + if v.auth.accToken == "" { + if err := v.authFlow(); err != nil { + return nil, err + } + } + headers := map[string]string{ - "Authorization": accToken, - "ccsp-device-id": did, + "Authorization": v.auth.accToken, + "ccsp-device-id": v.auth.deviceID, "ccsp-application-id": v.config.CCSPApplicationID, "offset": "1", "User-Agent": "okhttp/3.10.0", @@ -255,18 +380,13 @@ func (v *API) getVehicles(accToken, did string) (string, error) { } req, err := request.New(http.MethodGet, v.config.URI+v.config.Vehicles, nil, headers) - if err == nil { - var resp response - if err = v.DoJSON(req, &resp); err == nil { - if len(resp.ResMsg.Vehicles) == 1 { - return resp.ResMsg.Vehicles[0].VehicleID, nil - } - err = errors.New("couldn't find vehicle") - } + var resp response + if err == nil { + err = v.DoJSON(req, &resp) } - return "", err + return resp.ResMsg.Vehicles, err } func (v *API) authFlow() (err error) { @@ -283,15 +403,22 @@ func (v *API) authFlow() (err error) { var accCode string if err == nil { - accCode, err = v.login(cookieClient) + // try new login first, then fallback + if accCode, err = v.brandLogin(cookieClient); err != nil { + accCode, err = v.bluelinkLogin(cookieClient) + } + + if err != nil { + err = fmt.Errorf("login failed: %w", err) + } } if err == nil { v.auth.accToken, err = v.getToken(accCode) } - if err == nil { - v.auth.vehicleID, err = v.getVehicles(v.auth.accToken, v.auth.deviceID) + if err != nil { + err = fmt.Errorf("login failed: %w", err) } return err @@ -313,7 +440,7 @@ func (v *API) getStatus() (response, error) { "Stamp": v.stamp(), } - uri := fmt.Sprintf(v.config.URI+v.config.Status, v.auth.vehicleID) + uri := fmt.Sprintf(v.config.URI+v.config.Status, v.Vehicle.VehicleID) req, err := request.New(http.MethodGet, uri, nil, headers) if err == nil { err = v.DoJSON(req, &resp) diff --git a/internal/vehicle/hyundai.go b/internal/vehicle/hyundai.go index 6b6732784..3d4ab9ccf 100644 --- a/internal/vehicle/hyundai.go +++ b/internal/vehicle/hyundai.go @@ -1,6 +1,8 @@ package vehicle import ( + "errors" + "strings" "time" "github.com/andig/evcc/api" @@ -24,6 +26,7 @@ func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) { Title string Capacity int64 User, Password string + VIN string Cache time.Duration }{ Cache: interval, @@ -33,11 +36,16 @@ func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, err } + if cc.User == "" || cc.Password == "" { + return nil, errors.New("missing credentials") + } + settings := bluelink.Config{ URI: "https://prd.eu-ccapi.hyundai.com:8080", TokenAuth: "NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==", CCSPServiceID: "6d477c38-3ca4-4cf3-9557-2a1929a94654", CCSPApplicationID: "99cfff84-f4e2-4be8-a5ed-e5b755eb6581", + BrandAuthUrl: "https://eu-account.hyundai.com/auth/realms/euhyundaiidm/protocol/openid-connect/auth?client_id=97516a3c-2060-48b4-98cd-8e7dcd3c47b2&scope=openid%%20profile%%20email%%20phone&response_type=code&hkid_session_reset=true&redirect_uri=%s/api/v1/user/integration/redirect/login&ui_locales=%s&state=%s:%s", } log := util.NewLogger("hyundai") @@ -46,6 +54,25 @@ func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, err } + vehicles, err := api.Vehicles() + if err != nil { + return nil, err + } + + if cc.VIN == "" && len(vehicles) == 1 { + api.Vehicle = vehicles[0] + } else { + for _, vehicle := range vehicles { + if vehicle.Vin == strings.ToUpper(cc.VIN) { + api.Vehicle = vehicle + } + } + } + + if len(api.Vehicle.Vin) == 0 { + return nil, errors.New("vin not found") + } + v := &Hyundai{ embed: &embed{cc.Title, cc.Capacity}, API: api, diff --git a/internal/vehicle/kia.go b/internal/vehicle/kia.go index 5584ce9b9..550ca5cf8 100644 --- a/internal/vehicle/kia.go +++ b/internal/vehicle/kia.go @@ -1,6 +1,8 @@ package vehicle import ( + "errors" + "strings" "time" "github.com/andig/evcc/api" @@ -24,6 +26,7 @@ func NewKiaFromConfig(other map[string]interface{}) (api.Vehicle, error) { Title string Capacity int64 User, Password string + VIN string Cache time.Duration }{ Cache: interval, @@ -33,11 +36,16 @@ func NewKiaFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, err } + if cc.User == "" || cc.Password == "" { + return nil, errors.New("missing credentials") + } + settings := bluelink.Config{ URI: "https://prd.eu-ccapi.kia.com:8080", TokenAuth: "ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==", CCSPServiceID: "fdc85c00-0a2f-4c64-bcb4-2cfb1500730a", CCSPApplicationID: "693a33fa-c117-43f2-ae3b-61a02d24f417", + BrandAuthUrl: "https://eu-account.kia.com/auth/realms/eukiaidm/protocol/openid-connect/auth?client_id=f4d531c7-1043-444d-b09a-ad24bd913dd4&scope=openid%%20profile%%20email%%20phone&response_type=code&hkid_session_reset=true&redirect_uri=%s/api/v1/user/integration/redirect/login&ui_locales=%s&state=%s:%s", } log := util.NewLogger("kia") @@ -46,6 +54,25 @@ func NewKiaFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, err } + vehicles, err := api.Vehicles() + if err != nil { + return nil, err + } + + if cc.VIN == "" && len(vehicles) == 1 { + api.Vehicle = vehicles[0] + } else { + for _, vehicle := range vehicles { + if vehicle.Vin == strings.ToUpper(cc.VIN) { + api.Vehicle = vehicle + } + } + } + + if len(api.Vehicle.Vin) == 0 { + return nil, errors.New("vin not found") + } + v := &Kia{ embed: &embed{cc.Title, cc.Capacity}, API: api,