From dc368b7c52643af0cbc9aa77c8c3c805e0aea1ff Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 22 Apr 2021 17:37:40 +0200 Subject: [PATCH] Hyundai/Kia: add password refresh (#905) --- internal/vehicle/bluelink/api.go | 166 ++++++++ internal/vehicle/bluelink/bluelink.go | 529 -------------------------- internal/vehicle/bluelink/identity.go | 372 ++++++++++++++++++ internal/vehicle/hyundai.go | 10 +- internal/vehicle/kia.go | 10 +- 5 files changed, 554 insertions(+), 533 deletions(-) create mode 100644 internal/vehicle/bluelink/api.go delete mode 100644 internal/vehicle/bluelink/bluelink.go create mode 100644 internal/vehicle/bluelink/identity.go diff --git a/internal/vehicle/bluelink/api.go b/internal/vehicle/bluelink/api.go new file mode 100644 index 000000000..fb5e8f283 --- /dev/null +++ b/internal/vehicle/bluelink/api.go @@ -0,0 +1,166 @@ +package bluelink + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" +) + +const resOK = "S" // auth fail: F + +const ( + VehiclesURL = "/api/v1/spa/vehicles" + StatusURL = "/api/v1/spa/vehicles/%s/status" +) + +// ErrAuthFail indicates authorization failure +var ErrAuthFail = errors.New("authorization failed") + +// API implements the Kia/Hyundai bluelink api. +// Based on https://github.com/Hacksore/bluelinky. +type API struct { + *request.Helper + log *util.Logger + identity *Identity + apiG func() (interface{}, error) + Vehicle Vehicle +} + +// New creates a new BlueLink API +func NewAPI(log *util.Logger, identity *Identity, cache time.Duration) *API { + v := &API{ + log: log, + identity: identity, + Helper: request.NewHelper(log), + } + + // api is unbelievably slow when retrieving status + v.Helper.Client.Timeout = 120 * time.Second + + v.apiG = provider.NewCached(v.statusAPI, cache).InterfaceGetter() + + return v +} + +type VehiclesResponse struct { + RetCode string + ResMsg struct { + Vehicles []Vehicle + } +} + +type Vehicle struct { + Vin, VehicleName, VehicleID string +} + +func (v *API) Vehicles() ([]Vehicle, error) { + req, err := v.identity.Request(http.MethodGet, VehiclesURL) + + var resp VehiclesResponse + if err == nil { + err = v.DoJSON(req, &resp) + } + + return resp.ResMsg.Vehicles, err +} + +type StatusResponse struct { + timestamp time.Time // add missing timestamp + RetCode string + ResMsg struct { + EvStatus struct { + BatteryStatus float64 + RemainTime2 struct { + Atc struct { + Value, Unit int + } + } + DrvDistance []DrivingDistance + } + Vehicles []Vehicle + } +} + +type DrivingDistance struct { + RangeByFuel struct { + EvModeRange struct { + Value int + } + } +} + +func (v *API) getStatus() (StatusResponse, error) { + var resp StatusResponse + + req, err := v.identity.Request(http.MethodGet, fmt.Sprintf(StatusURL, v.Vehicle.VehicleID)) + if err == nil { + if err = v.DoJSON(req, &resp); err == nil && resp.RetCode != resOK { + err = fmt.Errorf("unexpected response: %s", resp.RetCode) + } + } + + return resp, err +} + +// status retrieves the bluelink status response +func (v *API) statusAPI() (interface{}, error) { + res, err := v.getStatus() + res.timestamp = time.Now() // add local timestamp to cache for FinishTime + + return res, err +} + +var _ api.Battery = (*API)(nil) + +// SoC implements the api.Vehicle interface +func (v *API) SoC() (float64, error) { + res, err := v.apiG() + + if res, ok := res.(StatusResponse); err == nil && ok { + return float64(res.ResMsg.EvStatus.BatteryStatus), nil + } + + return 0, err +} + +var _ api.VehicleFinishTimer = (*API)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *API) FinishTime() (time.Time, error) { + res, err := v.apiG() + + if res, ok := res.(StatusResponse); err == nil && ok { + remaining := res.ResMsg.EvStatus.RemainTime2.Atc.Value + + if remaining == 0 { + return time.Time{}, api.ErrNotAvailable + } + + return res.timestamp.Add(time.Duration(remaining) * time.Minute), nil + } + + return time.Time{}, err +} + +var _ api.VehicleRange = (*API)(nil) + +// Range implements the api.VehicleRange interface +func (v *API) Range() (int64, error) { + res, err := v.apiG() + + if res, ok := res.(StatusResponse); err == nil && ok { + if dist := res.ResMsg.EvStatus.DrvDistance; len(dist) == 1 { + return int64(dist[0].RangeByFuel.EvModeRange.Value), nil + } + + return 0, api.ErrNotAvailable + } + + return 0, err +} diff --git a/internal/vehicle/bluelink/bluelink.go b/internal/vehicle/bluelink/bluelink.go deleted file mode 100644 index 99e701d22..000000000 --- a/internal/vehicle/bluelink/bluelink.go +++ /dev/null @@ -1,529 +0,0 @@ -package bluelink - -import ( - "errors" - "fmt" - "net/http" - "net/http/cookiejar" - "net/url" - "strings" - "time" - - "github.com/PuerkitoBio/goquery" - "github.com/andig/evcc/api" - "github.com/andig/evcc/provider" - "github.com/andig/evcc/util" - "github.com/andig/evcc/util/request" - "github.com/google/uuid" - "github.com/imdario/mergo" - "golang.org/x/net/publicsuffix" -) - -const ( - resOK = "S" - resAuthFail = "F" -) - -var ( - errAuthFail = errors.New("authorization failed") - - defaults = Config{ - 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 - DeviceID string - Lang string - Login string - AccessToken string - Vehicles string - Status string -} - -// API implements the Kia/Hyundai bluelink api. -// Based on https://github.com/Hacksore/bluelinky. -type API struct { - *request.Helper - log *util.Logger - user string - password string - apiG func() (interface{}, error) - config Config - auth Auth - Vehicle Vehicle -} - -// Auth bundles miscellaneous authorization data -type Auth struct { - accToken string - deviceID string -} - -type Vehicle struct { - Vin, VehicleName, VehicleID string -} - -type response struct { - timestamp time.Time // add missing timestamp - RetCode string - ResMsg struct { - DeviceID string - EvStatus struct { - BatteryStatus float64 - RemainTime2 struct { - Atc struct { - Value, Unit int - } - } - DrvDistance []DrivingDistance - } - Vehicles []Vehicle - } -} - -type DrivingDistance struct { - RangeByFuel struct { - EvModeRange struct { - Value int - } - } -} - -// New creates a new BlueLink API -func New(log *util.Logger, user, password string, cache time.Duration, config Config) (*API, error) { - if err := mergo.Merge(&config, defaults); err != nil { - return nil, err - } - - v := &API{ - log: log, - Helper: request.NewHelper(log), - config: config, - user: user, - password: password, - } - - // api is unbelievably slow when retrieving status - v.Helper.Client.Timeout = 120 * time.Second - - v.apiG = provider.NewCached(v.statusAPI, cache).InterfaceGetter() - - return v, nil -} - -// Credits to https://openwb.de/forum/viewtopic.php?f=5&t=1215&start=10#p11877 - -func (v *API) stamp() string { - return stamps.New(v.config.CCSPApplicationID) -} - -func (v *API) getDeviceID() (string, error) { - uniID, _ := uuid.NewUUID() - data := map[string]interface{}{ - "pushRegId": "1", - "pushType": "GCM", - "uuid": uniID.String(), - } - - headers := map[string]string{ - "ccsp-service-id": v.config.CCSPServiceID, - "Content-type": "application/json;charset=UTF-8", - "User-Agent": "okhttp/3.10.0", - "Stamp": v.stamp(), - } - - var resp response - req, err := request.New(http.MethodPost, v.config.URI+v.config.DeviceID, request.MarshalJSON(data), headers) - if err == nil { - err = v.DoJSON(req, &resp) - } - - return resp.ResMsg.DeviceID, err -} - -func (v *API) getCookies() (cookieClient *request.Helper, err error) { - cookieClient = request.NewHelper(v.log) - cookieClient.Client.Jar, err = cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, - }) - - if err == nil { - uri := fmt.Sprintf( - "%s/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect", - v.config.URI, - v.config.CCSPServiceID, - v.config.URI, - ) - - var resp *http.Response - if resp, err = cookieClient.Get(uri); err == nil { - resp.Body.Close() - } - } - - return cookieClient, err -} - -func (v *API) setLanguage(cookieClient *request.Helper) error { - data := map[string]interface{}{ - "lang": "en", - } - - req, err := request.New(http.MethodPost, v.config.URI+v.config.Lang, request.MarshalJSON(data), request.JSONEncoding) - if err == nil { - var resp *http.Response - if resp, err = cookieClient.Do(req); err == nil { - resp.Body.Close() - } - } - - return err -} - -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, - } - - req, err := request.New(http.MethodPost, v.config.URI+v.config.Login, request.MarshalJSON(data), request.JSONEncoding) - if err != nil { - return "", err - } - - var redirect struct { - RedirectURL string `json:"redirectUrl"` - } - - var accCode string - if err = cookieClient.DoJSON(req, &redirect); err == nil { - if parsed, err := url.Parse(redirect.RedirectURL); err == nil { - accCode = parsed.Query().Get("code") - } - } - - return accCode, err -} - -func (v *API) getToken(accCode string) (string, error) { - headers := map[string]string{ - "Authorization": "Basic " + v.config.TokenAuth, - "Content-type": "application/x-www-form-urlencoded", - "User-Agent": "okhttp/3.10.0", - } - - data := url.Values(map[string][]string{ - "grant_type": {"authorization_code"}, - "redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"}, - "code": {accCode}, - }) - - req, err := request.New(http.MethodPost, v.config.URI+v.config.AccessToken, strings.NewReader(data.Encode()), headers) - if err != nil { - return "", err - } - - var tokens struct { - TokenType string `json:"token_type"` - AccessToken string `json:"access_token"` - } - - var accToken string - if err = v.DoJSON(req, &tokens); err == nil { - accToken = fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken) - } - - return accToken, err -} - -func (v *API) Vehicles() ([]Vehicle, error) { - if v.auth.accToken == "" { - if err := v.authFlow(); err != nil { - return nil, err - } - } - - headers := map[string]string{ - "Authorization": v.auth.accToken, - "ccsp-device-id": v.auth.deviceID, - "ccsp-application-id": v.config.CCSPApplicationID, - "offset": "1", - "User-Agent": "okhttp/3.10.0", - "Stamp": v.stamp(), - } - - req, err := request.New(http.MethodGet, v.config.URI+v.config.Vehicles, nil, headers) - - var resp response - if err == nil { - err = v.DoJSON(req, &resp) - } - - return resp.ResMsg.Vehicles, err -} - -func (v *API) authFlow() (err error) { - v.auth.deviceID, err = v.getDeviceID() - - var cookieClient *request.Helper - if err == nil { - cookieClient, err = v.getCookies() - } - - if err == nil { - err = v.setLanguage(cookieClient) - } - - var accCode string - if err == nil { - // 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 { - err = fmt.Errorf("login failed: %w", err) - } - - return err -} - -func (v *API) getStatus() (response, error) { - var resp response - - if v.auth.accToken == "" { - return resp, errAuthFail - } - - headers := map[string]string{ - "Authorization": v.auth.accToken, - "ccsp-device-id": v.auth.deviceID, - "ccsp-application-id": v.config.CCSPApplicationID, - "offset": "1", - "User-Agent": "okhttp/3.10.0", - "Stamp": v.stamp(), - } - - 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) - - if err != nil { - // handle http 401, 403 - if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusUnauthorized, http.StatusForbidden) { - err = errAuthFail - } - } - - if err == nil && resp.RetCode != resOK { - err = errors.New("unexpected response") - if resp.RetCode == resAuthFail { - err = errAuthFail - } - } - } - - return resp, err -} - -// status retrieves the bluelink status response -func (v *API) statusAPI() (interface{}, error) { - res, err := v.getStatus() - - if err != nil && errors.Is(err, errAuthFail) { - if err = v.authFlow(); err == nil { - res, err = v.getStatus() - } - } - - // add local timestamp for FinishTime - res.timestamp = time.Now() - - return res, err -} - -var _ api.Battery = (*API)(nil) - -// SoC implements the api.Vehicle interface -func (v *API) SoC() (float64, error) { - res, err := v.apiG() - - if res, ok := res.(response); err == nil && ok { - return float64(res.ResMsg.EvStatus.BatteryStatus), nil - } - - return 0, err -} - -var _ api.VehicleFinishTimer = (*API)(nil) - -// FinishTime implements the api.VehicleFinishTimer interface -func (v *API) FinishTime() (time.Time, error) { - res, err := v.apiG() - - if res, ok := res.(response); err == nil && ok { - remaining := res.ResMsg.EvStatus.RemainTime2.Atc.Value - - if remaining == 0 { - return time.Time{}, api.ErrNotAvailable - } - - return res.timestamp.Add(time.Duration(remaining) * time.Minute), nil - } - - return time.Time{}, err -} - -var _ api.VehicleRange = (*API)(nil) - -// Range implements the api.VehicleRange interface -func (v *API) Range() (int64, error) { - res, err := v.apiG() - - if res, ok := res.(response); err == nil && ok { - if dist := res.ResMsg.EvStatus.DrvDistance; len(dist) == 1 { - return int64(dist[0].RangeByFuel.EvModeRange.Value), nil - } - - return 0, api.ErrNotAvailable - } - - return 0, err -} diff --git a/internal/vehicle/bluelink/identity.go b/internal/vehicle/bluelink/identity.go new file mode 100644 index 000000000..4818c6d31 --- /dev/null +++ b/internal/vehicle/bluelink/identity.go @@ -0,0 +1,372 @@ +package bluelink + +import ( + "errors" + "fmt" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/oauth" + "github.com/andig/evcc/util/request" + "github.com/google/uuid" + "golang.org/x/net/publicsuffix" + "golang.org/x/oauth2" +) + +const ( + DeviceIdURL = "/api/v1/spa/notifications/register" + IntegrationInfoURL = "/api/v1/user/integrationinfo" + SilentSigninURL = "/api/v1/user/silentsignin" + LanguageURL = "/api/v1/user/language" + LoginURL = "/api/v1/user/signin" + TokenURL = "/api/v1/user/oauth2/token" +) + +// Config is the bluelink API configuration +type Config struct { + URI string + BrandAuthUrl string // v2 + BasicToken string + CCSPServiceID string + CCSPApplicationID string +} + +// API implements the Kia/Hyundai bluelink api. +// Based on https://github.com/Hacksore/bluelinky. +type Identity struct { + *request.Helper + log *util.Logger + config Config + deviceID string + tokenSource oauth2.TokenSource +} + +// NewIdentity creates a new BlueLink API +func NewIdentity(log *util.Logger, config Config) (*Identity, error) { + v := &Identity{ + log: log, + Helper: request.NewHelper(log), + config: config, + } + + return v, nil +} + +// Credits to https://openwb.de/forum/viewtopic.php?f=5&t=1215&start=10#p11877 + +func (v *Identity) stamp() string { + return stamps.New(v.config.CCSPApplicationID) +} + +func (v *Identity) getDeviceID() (string, error) { + uniID, _ := uuid.NewUUID() + data := map[string]interface{}{ + "pushRegId": "1", + "pushType": "GCM", + "uuid": uniID.String(), + } + + headers := map[string]string{ + "ccsp-service-id": v.config.CCSPServiceID, + "Content-type": "application/json;charset=UTF-8", + "User-Agent": "okhttp/3.10.0", + "Stamp": v.stamp(), + } + + var resp struct { + RetCode string + ResMsg struct { + DeviceID string + } + } + + req, err := request.New(http.MethodPost, v.config.URI+DeviceIdURL, request.MarshalJSON(data), headers) + if err == nil { + err = v.DoJSON(req, &resp) + } + + return resp.ResMsg.DeviceID, err +} + +func (v *Identity) getCookies() (cookieClient *request.Helper, err error) { + cookieClient = request.NewHelper(v.log) + cookieClient.Client.Jar, err = cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + if err == nil { + uri := fmt.Sprintf( + "%s/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect", + v.config.URI, + v.config.CCSPServiceID, + v.config.URI, + ) + + var resp *http.Response + if resp, err = cookieClient.Get(uri); err == nil { + resp.Body.Close() + } + } + + return cookieClient, err +} + +func (v *Identity) setLanguage(cookieClient *request.Helper) error { + data := map[string]interface{}{ + "lang": "en", + } + + req, err := request.New(http.MethodPost, v.config.URI+LanguageURL, request.MarshalJSON(data), request.JSONEncoding) + if err == nil { + var resp *http.Response + if resp, err = cookieClient.Do(req); err == nil { + resp.Body.Close() + } + } + + return err +} + +func (v *Identity) brandLogin(cookieClient *request.Helper, user, password string) (string, error) { + req, err := request.New(http.MethodGet, v.config.URI+IntegrationInfoURL, 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{user}, + "password": []string{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+SilentSigninURL, request.MarshalJSON(data), request.JSONEncoding) + if err == nil { + req.Header.Set("ccsp-service-id", v.config.CCSPServiceID) + 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 *Identity) bluelinkLogin(cookieClient *request.Helper, user, password string) (string, error) { + data := map[string]interface{}{ + "email": user, + "password": password, + } + + req, err := request.New(http.MethodPost, v.config.URI+LoginURL, request.MarshalJSON(data), request.JSONEncoding) + if err != nil { + return "", err + } + + var redirect struct { + RedirectURL string `json:"redirectUrl"` + } + + var accCode string + if err = cookieClient.DoJSON(req, &redirect); err == nil { + if parsed, err := url.Parse(redirect.RedirectURL); err == nil { + accCode = parsed.Query().Get("code") + } + } + + return accCode, err +} + +func (v *Identity) exchangeCode(accCode string) (oauth.Token, error) { + headers := map[string]string{ + "Authorization": "Basic " + v.config.BasicToken, + "Content-type": "application/x-www-form-urlencoded", + "User-Agent": "okhttp/3.10.0", + } + + data := url.Values(map[string][]string{ + "grant_type": {"authorization_code"}, + "redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"}, + "code": {accCode}, + }) + + var token oauth.Token + + req, err := request.New(http.MethodPost, v.config.URI+TokenURL, strings.NewReader(data.Encode()), headers) + if err == nil { + err = v.DoJSON(req, &token) + } + + return token, err +} + +// Refresh implements token refresh +func (v *Identity) Refresh(token *oauth2.Token) (*oauth2.Token, error) { + headers := map[string]string{ + "Authorization": "Basic " + v.config.BasicToken, + "Content-type": "application/x-www-form-urlencoded", + "User-Agent": "okhttp/3.10.0", + } + + data := url.Values(map[string][]string{ + "grant_type": {"refresh_token"}, + "redirect_uri": {"https://www.getpostman.com/oauth2/callback"}, + "refresh_token": {token.RefreshToken}, + }) + + req, err := request.New(http.MethodPost, v.config.URI+TokenURL, strings.NewReader(data.Encode()), headers) + + var res oauth.Token + if err == nil { + err = v.DoJSON(req, &res) + } + + return (*oauth2.Token)(&res), err +} + +func (v *Identity) Login(user, password string) (err error) { + v.deviceID, err = v.getDeviceID() + + var cookieClient *request.Helper + if err == nil { + cookieClient, err = v.getCookies() + } + + if err == nil { + err = v.setLanguage(cookieClient) + } + + var code string + if err == nil { + // try new login first, then fallback + if code, err = v.brandLogin(cookieClient, user, password); err != nil { + code, err = v.bluelinkLogin(cookieClient, user, password) + } + + if err != nil { + err = fmt.Errorf("login failed: %w", err) + } + } + + if err == nil { + var token oauth.Token + if token, err = v.exchangeCode(code); err == nil { + v.tokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v) + } + } + + if err != nil { + err = fmt.Errorf("login failed: %w", err) + } + + return err +} + +// Request creates authenticated request +func (v *Identity) Request(method, path string) (*http.Request, error) { + token, err := v.tokenSource.Token() + if err != nil { + return nil, err + } + + headers := map[string]string{ + "Authorization": "Bearer " + token.AccessToken, + "ccsp-device-id": v.deviceID, + "ccsp-application-id": v.config.CCSPApplicationID, + "offset": "1", + "User-Agent": "okhttp/3.10.0", + "Stamp": v.stamp(), + } + + return request.New(method, v.config.URI+path, nil, headers) +} diff --git a/internal/vehicle/hyundai.go b/internal/vehicle/hyundai.go index 3d4ab9ccf..4b0bcb03a 100644 --- a/internal/vehicle/hyundai.go +++ b/internal/vehicle/hyundai.go @@ -42,18 +42,24 @@ func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) { settings := bluelink.Config{ URI: "https://prd.eu-ccapi.hyundai.com:8080", - TokenAuth: "NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==", + BasicToken: "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") - api, err := bluelink.New(log, cc.User, cc.Password, cc.Cache, settings) + identity, err := bluelink.NewIdentity(log, settings) if err != nil { return nil, err } + if err := identity.Login(cc.User, cc.Password); err != nil { + return nil, err + } + + api := bluelink.NewAPI(log, identity, cc.Cache) + vehicles, err := api.Vehicles() if err != nil { return nil, err diff --git a/internal/vehicle/kia.go b/internal/vehicle/kia.go index 550ca5cf8..02c8ccb7f 100644 --- a/internal/vehicle/kia.go +++ b/internal/vehicle/kia.go @@ -42,18 +42,24 @@ func NewKiaFromConfig(other map[string]interface{}) (api.Vehicle, error) { settings := bluelink.Config{ URI: "https://prd.eu-ccapi.kia.com:8080", - TokenAuth: "ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==", + BasicToken: "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") - api, err := bluelink.New(log, cc.User, cc.Password, cc.Cache, settings) + identity, err := bluelink.NewIdentity(log, settings) if err != nil { return nil, err } + if err := identity.Login(cc.User, cc.Password); err != nil { + return nil, err + } + + api := bluelink.NewAPI(log, identity, cc.Cache) + vehicles, err := api.Vehicles() if err != nil { return nil, err