Hyundai: use refresh token instead of password (#24491)
Signed-off-by: Stefan Rubner <stefan@whocares.de>
This commit is contained in:
parent
cb67dbfb77
commit
f8cc09afcb
4 changed files with 21 additions and 347 deletions
|
|
@ -5,8 +5,14 @@ products:
|
|||
generic: Bluelink
|
||||
requirements:
|
||||
description:
|
||||
en: Some models (e.g. Kona) switch internally to 2 phases at low charging currents (< 8A). In cases where the wallbox also measures the phase currents, this leads to undesirable fluctuations in the charging power. The remedy here is to set the minimum charging current to 8A.
|
||||
de: Manche Modelle (z.B. Kona) schalten bei geringen Ladeströmen (< 8A) intern auf 2 Phasen um. In den Fällen, in denen die Wallbox auch die Phasenströme misst, führt das zu unerwünschten Schwankungen der Ladeleistung. Abhilfe schafft hier, den Mindestladestrom auf 8A zu setzen.
|
||||
en: |
|
||||
Instead of your account's password, the password field needs to be filled with a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln#english-version)).
|
||||
|
||||
Some models (e.g. Kona) switch internally to 2 phases at low charging currents (< 8A). In cases where the wallbox also measures the phase currents, this leads to undesirable fluctuations in the charging power. The remedy here is to set the minimum charging current to 8A.
|
||||
de: |
|
||||
Anstelle des Passworts muss in das Passwort-Feld ein `refresh_token` eingetragen werden ([Anleitung](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln)).
|
||||
|
||||
Manche Modelle (z.B. Kona) schalten bei geringen Ladeströmen (< 8A) intern auf 2 Phasen um. In den Fällen, in denen die Wallbox auch die Phasenströme misst, führt das zu unerwünschten Schwankungen der Ladeleistung. Abhilfe schafft hier, den Mindestladestrom auf 8A zu setzen.
|
||||
params:
|
||||
- preset: vehicle-base
|
||||
- preset: vehicle-language
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ products:
|
|||
requirements:
|
||||
description:
|
||||
en: |
|
||||
Instead of your account's password, the password field needs to be filled with a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Kia:-Refresh%E2%80%90Token-ermitteln#english-version)).
|
||||
Instead of your account's password, the password field needs to be filled with a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln#english-version)).
|
||||
|
||||
Some models (e.g. Niro EV) switch internally to 2 phases at low charging currents (< 8A). In cases where the wallbox also measures the phase currents, this leads to undesirable fluctuations in the charging power. The remedy here is to set the minimum charging current to 8A.
|
||||
de: |
|
||||
Anstelle des Passworts muss in das Passwort-Feld ein `refresh_token` eingetragen werden ([Anleitung](https://github.com/evcc-io/evcc/wiki/Kia:-Refresh%E2%80%90Token-ermitteln)).
|
||||
Anstelle des Passworts muss in das Passwort-Feld ein `refresh_token` eingetragen werden ([Anleitung](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln)).
|
||||
|
||||
Manche Modelle (z.B. Niro EV) schalten bei geringen Ladeströmen (< 8A) intern auf 2 Phasen um. In den Fällen, in denen die Wallbox auch die Phasenströme misst, führt das zu unerwünschten Schwankungen der Ladeleistung. Abhilfe schafft hier, den Mindestladestrom auf 8A zu setzen.
|
||||
params:
|
||||
|
|
|
|||
|
|
@ -26,18 +26,15 @@ func init() {
|
|||
func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
|
||||
settings := bluelink.Config{
|
||||
URI: "https://prd.eu-ccapi.hyundai.com:8080",
|
||||
BasicToken: "NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==",
|
||||
BasicToken: "KUy49XxPzLpLuoK0xhBC77W6VXhmtQR9iQhmIFjjoY4IpxsV",
|
||||
CCSPServiceID: "6d477c38-3ca4-4cf3-9557-2a1929a94654",
|
||||
CCSPApplicationID: bluelink.HyundaiAppID,
|
||||
AuthClientID: "64621b96-0f0d-11ec-82a8-0242ac130003",
|
||||
BrandAuthUrl: "https://eu-account.hyundai.com/auth/realms/euhyundaiidm/protocol/openid-connect/auth?client_id=%s&scope=openid+profile+email+phone&response_type=code&hkid_session_reset=true&redirect_uri=%s/api/v1/user/integration/redirect/login&ui_locales=%s&state=%s:%s",
|
||||
PushType: "GCM",
|
||||
Cfb: "RFtoRq/vDXJmRndoZaZQyfOot7OrIqGVFj96iY2WL3yyH5Z/pUvlUhqmCxD2t+D65SQ=",
|
||||
Brand: "hyundai",
|
||||
// for oauth2??
|
||||
// LoginFormHost: "https://idpconnect-eu.hyundai.com",
|
||||
// AuthClientID: "6d477c38-3ca4-4cf3-9557-2a1929a94654",
|
||||
// BrandAuthUrl: "%s/auth/api/v2/user/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect&lang=%s&state=ccsp",
|
||||
LoginFormHost: "https://idpconnect-eu.hyundai.com",
|
||||
BrandAuthUrl: "%s/auth/api/v2/user/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect&lang=%s&state=ccsp",
|
||||
}
|
||||
|
||||
return newBluelinkFromConfig("hyundai", other, settings)
|
||||
|
|
|
|||
|
|
@ -5,20 +5,17 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/oauth"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/google/uuid"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
|
|
@ -106,289 +103,6 @@ func (v *Identity) getDeviceID() (string, error) {
|
|||
return res.ResMsg.DeviceID, err
|
||||
}
|
||||
|
||||
func (v *Identity) getCookies() (cookieClient *request.Helper, err error) {
|
||||
cookieClient = request.NewHelper(v.log)
|
||||
cookieClient.Client.Jar, _ = cookiejar.New(&cookiejar.Options{
|
||||
PublicSuffixList: publicsuffix.List,
|
||||
})
|
||||
|
||||
// TODO: check whether &lang= is necessary
|
||||
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,
|
||||
)
|
||||
|
||||
resp, err := cookieClient.Get(uri)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
return cookieClient, err
|
||||
}
|
||||
|
||||
func (v *Identity) setLanguage(cookieClient *request.Helper, language string) error {
|
||||
data := map[string]interface{}{
|
||||
"lang": language,
|
||||
}
|
||||
|
||||
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) brandLoginHyundaiEU(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.AuthClientID, 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": {user},
|
||||
"password": {password},
|
||||
"credentialId": {""},
|
||||
"rememberMe": {"on"},
|
||||
}
|
||||
|
||||
req, err = request.New(http.MethodPost, action, strings.NewReader(data.Encode()), request.URLEncoding)
|
||||
if err == nil {
|
||||
cookieClient.CheckRedirect = request.DontFollow
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
resp, err = cookieClient.Get(resp.Header.Get("Location"))
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var code string
|
||||
if err == nil {
|
||||
data := map[string]string{
|
||||
"intUserId": "",
|
||||
}
|
||||
|
||||
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 = request.DontFollow
|
||||
|
||||
var res struct {
|
||||
RedirectUrl string `json:"redirectUrl"`
|
||||
}
|
||||
|
||||
if err = cookieClient.DoJSON(req, &res); err == nil {
|
||||
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
|
||||
}
|
||||
|
||||
/* Unused for now
|
||||
func (v *Identity) brandLoginKiaEU(user, password string) (string, error) {
|
||||
cookieClient := request.NewHelper(v.log)
|
||||
cookieClient.Client.Jar, _ = cookiejar.New(&cookiejar.Options{
|
||||
PublicSuffixList: publicsuffix.List,
|
||||
})
|
||||
|
||||
headers := map[string]string{
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19_CCS_APP_AOS",
|
||||
// "User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19",
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {"peukiaidm-online-sales"},
|
||||
"encryptedPassword": {"false"},
|
||||
"password": {password},
|
||||
"redirect_uri": {"https://www.kia.com/api/bin/oneid/login"},
|
||||
"state": {"aHR0cHM6Ly93d3cua2lhLmNvbTo0NDMvZGUvP3ZlZD0yYWhVS0V3akI2ZFc3dDQtUEF4WFBSZkVESGNDQ0J4UVFnVTk2QkFnY0VBZyZfdG09MTc1NTg1NTY2ODE2Mg==_default"},
|
||||
"username": {user},
|
||||
"remember_me": {"false"},
|
||||
}
|
||||
|
||||
req, _ := request.New(http.MethodPost, "https://idpconnect-eu.kia.com/auth/account/signin", strings.NewReader(data.Encode()), headers)
|
||||
|
||||
if _, err := cookieClient.Do(req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
v.deviceID, _ = v.getDeviceID()
|
||||
|
||||
// get the connector_session_key
|
||||
uri := fmt.Sprintf(v.config.BrandAuthUrl, v.config.LoginFormHost, v.config.CCSPServiceID, v.config.URI, "en")
|
||||
headers = map[string]string{
|
||||
"ccsp-application-id": v.config.CCSPApplicationID,
|
||||
"ccsp-device-id": v.deviceID,
|
||||
"ccsp-service-id": v.config.CCSPServiceID,
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19",
|
||||
}
|
||||
req, _ = request.New(http.MethodGet, uri, nil, headers)
|
||||
resp, err := cookieClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// get redirect URL from request
|
||||
nextUri := resp.Request.URL.Query().Get("next_uri")
|
||||
if nextUri == "" {
|
||||
return "", errors.New("empty redirect url on connector session key request")
|
||||
}
|
||||
|
||||
// create a client that doesn't honor redirects so we receive the original response
|
||||
// no idea how to do that with the internal request.New(...) function
|
||||
sc := http.Client{
|
||||
Jar: cookieClient.Client.Jar,
|
||||
Transport: request.NewTripper(v.log, http.DefaultTransport),
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
req, err = request.New(http.MethodGet, nextUri, nil, headers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err = sc.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return "", errors.New("missing location header")
|
||||
}
|
||||
|
||||
locationUrl, err := url.Parse(location)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
code := locationUrl.Query().Get("code")
|
||||
if code == "" {
|
||||
return "", errors.New("missing code")
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
*/
|
||||
|
||||
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 res struct {
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
ErrCode string `json:"errCode"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
}
|
||||
|
||||
var accCode string
|
||||
if err = cookieClient.DoJSON(req, &res); err == nil {
|
||||
if parsed, err := url.Parse(res.RedirectURL); err == nil {
|
||||
accCode = parsed.Query().Get("code")
|
||||
}
|
||||
} else if res.ErrCode != "" {
|
||||
err = fmt.Errorf("%w: %s (%s)", err, res.ErrMsg, res.ErrCode)
|
||||
}
|
||||
|
||||
return accCode, err
|
||||
}
|
||||
|
||||
func (v *Identity) exchangeCodeHyundaiEU(accCode string) (*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{
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"},
|
||||
"code": {accCode},
|
||||
}
|
||||
|
||||
var token oauth2.Token
|
||||
|
||||
req, _ := request.New(http.MethodPost, v.config.URI+TokenURL, strings.NewReader(data.Encode()), headers)
|
||||
err := v.DoJSON(req, &token)
|
||||
|
||||
return util.TokenWithExpiry(&token), err
|
||||
}
|
||||
|
||||
func (v *Identity) exchangeCodeKiaEURefreshToken(accCode string) (*oauth2.Token, error) {
|
||||
uri := v.config.LoginFormHost + "/auth/api/v2/user/oauth2/token"
|
||||
headers := map[string]string{
|
||||
|
|
@ -413,30 +127,6 @@ func (v *Identity) exchangeCodeKiaEURefreshToken(accCode string) (*oauth2.Token,
|
|||
return util.TokenWithExpiry(&token), err
|
||||
}
|
||||
|
||||
/* Unused for now
|
||||
func (v *Identity) exchangeCodeKiaEU(accCode string) (*oauth2.Token, error) {
|
||||
uri := v.config.LoginFormHost + "/auth/api/v2/user/oauth2/token"
|
||||
headers := map[string]string{
|
||||
"Content-type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "okhttp/3.10.0",
|
||||
}
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {accCode},
|
||||
"redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"},
|
||||
"client_id": {v.config.CCSPServiceID},
|
||||
"client_secret": {"secret"},
|
||||
}
|
||||
|
||||
var token oauth2.Token
|
||||
|
||||
req, _ := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), headers)
|
||||
err := v.DoJSON(req, &token)
|
||||
|
||||
return util.TokenWithExpiry(&token), err
|
||||
}
|
||||
*/
|
||||
|
||||
// RefreshToken implements oauth.TokenRefresher
|
||||
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
|
||||
var res oauth2.Token
|
||||
|
|
@ -450,8 +140,7 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
|
|||
headers = map[string]string{
|
||||
"Authorization": "Basic " + v.config.BasicToken,
|
||||
"Content-type": "application/x-www-form-urlencoded",
|
||||
// "User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19_CCS_APP_AOS",
|
||||
"User-Agent": "okhttp/3.10.0",
|
||||
"User-Agent": "okhttp/3.10.0",
|
||||
}
|
||||
|
||||
data = url.Values{
|
||||
|
|
@ -473,7 +162,7 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
|
|||
"client_secret": {"secret"},
|
||||
}
|
||||
default:
|
||||
err = errors.New("Unsupported brand")
|
||||
err = errors.New("unsupported brand")
|
||||
}
|
||||
|
||||
// request token only if we didn't run unto the default branch
|
||||
|
|
@ -499,43 +188,25 @@ func (v *Identity) Login(user, password, language, brand string) (err error) {
|
|||
if user == "" || password == "" {
|
||||
return api.ErrMissingCredentials
|
||||
}
|
||||
var code string
|
||||
// var code string
|
||||
switch brand {
|
||||
case "kia":
|
||||
// the "password" now is the refresh token ...
|
||||
// code, err = v.brandLoginKiaEU(user, password)
|
||||
// if err == nil {
|
||||
var token *oauth2.Token
|
||||
token, err = v.exchangeCodeKiaEURefreshToken(password)
|
||||
if err == nil {
|
||||
v.TokenSource = oauth.RefreshTokenSource(token, v)
|
||||
v.deviceID, err = v.getDeviceID()
|
||||
}
|
||||
// }
|
||||
|
||||
case "hyundai":
|
||||
v.deviceID, err = v.getDeviceID()
|
||||
|
||||
var cookieClient *request.Helper
|
||||
var token *oauth2.Token
|
||||
token, err = v.exchangeCodeKiaEURefreshToken(password)
|
||||
if err == nil {
|
||||
cookieClient, err = v.getCookies()
|
||||
v.TokenSource = oauth.RefreshTokenSource(token, v)
|
||||
v.deviceID, err = v.getDeviceID()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = v.setLanguage(cookieClient, language)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// try new login first, then fallback
|
||||
if code, err = v.brandLoginHyundaiEU(cookieClient, user, password); err != nil {
|
||||
code, err = v.bluelinkLogin(cookieClient, user, password)
|
||||
}
|
||||
if err == nil {
|
||||
var token *oauth2.Token
|
||||
if token, err = v.exchangeCodeHyundaiEU(code); err == nil {
|
||||
v.TokenSource = oauth.RefreshTokenSource(token, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown brand (%s)", brand)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue