diff --git a/vehicle/audi.go b/vehicle/audi.go index b95c466f0..6b2298d10 100644 --- a/vehicle/audi.go +++ b/vehicle/audi.go @@ -10,18 +10,17 @@ import ( "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/andig/evcc/vehicle/vwidentity" "golang.org/x/net/publicsuffix" ) const ( - vwIdentity = "https://identity.vwgroup.io" - vwAPI = "https://msg.volkswagen.de/fs-car" - vwToken = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token" + vwAPI = "https://msg.volkswagen.de/fs-car" + vwToken = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token" ) // OIDCResponse is the well-known OIDC provider response @@ -133,15 +132,6 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { return v, err } -func (v *Audi) redirect(resp *http.Response, err error) (*http.Response, error) { - if err == nil { - uri := resp.Header.Get("Location") - resp, err = v.Get(uri) - } - - return resp, err -} - func (v *Audi) request(method, uri string, data io.Reader, headers ...map[string]string) (*http.Request, error) { req, err := http.NewRequest(method, uri, data) if err != nil { @@ -157,120 +147,17 @@ func (v *Audi) request(method, uri string, data io.Reader, headers ...map[string return req, nil } -type formVars struct { - action string - csrf string - relayState string - hmac string -} - -func formValues(reader io.Reader, id string) (formVars, error) { - vars := formVars{} - - doc, err := goquery.NewDocumentFromReader(reader) - if err == nil { - form := doc.Find(id).First() - if form.Length() != 1 { - return vars, errors.New("unexpected length") - } - - var exists bool - vars.action, exists = form.Attr("action") - if !exists { - return vars, errors.New("attribute not found") - } - - vars.csrf, err = attr(form, "input[name=_csrf]", "value") - if err == nil { - vars.relayState, err = attr(form, "input[name=relayState]", "value") - } - if err == nil { - vars.hmac, err = attr(form, "input[name=hmac]", "value") - } - } - - return vars, err -} - -func attr(doc *goquery.Selection, path, attr string) (res string, err error) { - sel := doc.Find(path) - if sel.Length() != 1 { - return "", errors.New("unexpected length") - } - - v, exists := sel.Attr(attr) - if !exists { - return "", errors.New("attribute not found") - } - - return v, nil -} - func (v *Audi) authFlow() error { - var err error var uri, body string - var vars formVars var req *http.Request - var resp *http.Response uri = "https://identity.vwgroup.io/oidc/v1/authorize?" + "response_type=code&client_id=09b6cbec-cd19-4589-82fd-363dfa8c24da%40apps_vw-dilab_com&" + "redirect_uri=myaudi%3A%2F%2F%2F&scope=address%20profile%20badge%20birthdate%20birthplace%20nationalIdentifier%20nationality%20profession%20email%20vin%20phone%20nickname%20name%20picture%20mbb%20gallery%20openid&" + "state=7f8260b5-682f-4db8-b171-50a5189a1c08&nonce=583b9af2-7799-4c72-9cb0-e6c0f42b87b3&prompt=login&ui_locales=de-DE" - resp, err = v.Get(uri) - if err == nil { - uri = resp.Header.Get("Location") - resp, err = v.Get(uri) - } - if err == nil { - vars, err = formValues(resp.Body, "form#emailPasswordForm") - } - if err == nil { - uri = vwIdentity + vars.action - body := fmt.Sprintf( - "_csrf=%s&relayState=%s&hmac=%s&email=%s", - vars.csrf, vars.relayState, vars.hmac, url.QueryEscape(v.user), - ) - req, err = http.NewRequest(http.MethodPost, uri, strings.NewReader(body)) - } - if err == nil { - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - resp, err = v.Do(req) - } - - if err == nil { - uri = vwIdentity + resp.Header.Get("Location") - req, err = http.NewRequest(http.MethodGet, uri, nil) - - } - if err == nil { - resp, err = v.Do(req) - } - - if err == nil { - vars, err = formValues(resp.Body, "form#credentialsForm") - } - if err == nil { - uri = vwIdentity + vars.action - body = fmt.Sprintf( - "_csrf=%s&relayState=%s&email=%s&hmac=%s&password=%s", - vars.csrf, - vars.relayState, - url.QueryEscape(v.user), - vars.hmac, - url.QueryEscape(v.password), - ) - req, err = http.NewRequest(http.MethodPost, uri, strings.NewReader(body)) - } - if err == nil { - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - resp, err = v.Do(req) - } - - for i := 6; i < 9; i++ { - resp, err = v.redirect(resp, err) - } + identity := &vwidentity.Identity{Client: v.Client} + resp, err := identity.Login(uri, v.user, v.password) var tokens audiTokenResponse if err == nil { @@ -289,9 +176,7 @@ func (v *Audi) authFlow() error { url.QueryEscape("token id_token"), ) - req, err = v.request(http.MethodPost, uri, strings.NewReader(body), - map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, - ) + req, err = request.New(http.MethodPost, uri, strings.NewReader(body), request.URLEncoding) } if err == nil { err = v.DoJSON(req, &tokens) diff --git a/vehicle/vw.go b/vehicle/vw.go new file mode 100644 index 000000000..0c778d54e --- /dev/null +++ b/vehicle/vw.go @@ -0,0 +1,261 @@ +package vehicle + +import ( + "fmt" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "github.com/andig/evcc/vehicle/vwidentity" + "golang.org/x/net/publicsuffix" +) + +type vwVehiclesResponse struct { + FullyLoadedVehiclesResponse struct { + CompleteVehicles []struct { + VIN string + } + VehiclesNotFullyLoaded []struct { + VIN string + } + } +} + +type vwChargerResponse struct { + ErrorCode int `json:",string"` + // /-/emanager/get-emanager + EManager struct { + RBC struct { + Status struct { + BatteryPercentage int + ChargingRemaningHour int `json:",string"` + ChargingRemaningMinute int `json:",string"` + } + } + } + // /-/vsr/get-vsr + VehicleStatusData struct { + BatteryRange int + BatteryLevel int + } +} + +// based on https://github.com/wez3/volkswagen-carnet-client + +// VW is an api.Vehicle implementation for VW cars +type VW struct { + *embed + *request.Helper + user, password, vin string + baseURI, csrf string + chargeStateG func() (float64, error) + finishTimeG func() (time.Time, error) +} + +func init() { + registry.Add("vw", NewVWFromConfig) +} + +// NewVWFromConfig creates a new vehicle +func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + Title string + Capacity int64 + User, Password, VIN string + Cache time.Duration + }{} + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("vw") + + v := &VW{ + embed: &embed{cc.Title, cc.Capacity}, + Helper: request.NewHelper(log), + user: cc.User, + password: cc.Password, + vin: cc.VIN, + } + + v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() + v.finishTimeG = provider.NewCached(v.finishTime, cc.Cache).TimeGetter() + + var err error + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + // track cookies and don't follow redirects + v.Client.Jar = jar + v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + if err == nil { + err = v.authFlow() + } + + if err == nil && cc.VIN == "" { + v.vin, err = findVehicle(v.vehicles()) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", v.vin) + } + } + + return v, err +} + +func (v *VW) authFlow() error { + var err error + var uri, body string + var vars vwidentity.FormVars + var req *http.Request + var resp *http.Response + + // GET www.portal.volkswagen-we.com/portal/de_DE/web/guest/home + uri = "https://www.portal.volkswagen-we.com/portal/de_DE/web/guest/home" + resp, err = v.Get(uri) + + if err == nil { + vars, err = vwidentity.FormValues(resp.Body, "meta") + } + + // POST www.portal.volkswagen-we.com/portal/en_GB/web/guest/home/-/csrftokenhandling/get-login-url + if err == nil { + uri = "https://www.portal.volkswagen-we.com/portal/en_GB/web/guest/home/-/csrftokenhandling/get-login-url" + if req, err = request.New(http.MethodPost, uri, nil, map[string]string{"X-CSRF-Token": vars.Csrf}); err == nil { + + res := struct { + ErrorCode int `json:",string"` + LoginURL struct { + Path string + } + }{} + + if err = v.DoJSON(req, &res); err == nil { + uri = strings.ReplaceAll(res.LoginURL.Path, " ", "%20") + if res.ErrorCode != 0 { + err = fmt.Errorf("login url error code: %d", res.ErrorCode) + } + } + } + } + + // execute login + if err == nil { + identity := &vwidentity.Identity{Client: v.Client} + resp, err = identity.Login(uri, v.user, v.password) + } + + // get base url + if err == nil { + var code, state string + var locationURL *url.URL + + location := resp.Header.Get("Location") + + locationURL, err = url.Parse(location) + if err == nil { + code = locationURL.Query().Get("code") + state = locationURL.Query().Get("state") + } + + uri = fmt.Sprintf( + "%s?p_auth=%s&p_p_id=33_WAR_cored5portlet&p_p_lifecycle=1&p_p_state=normal&p_p_mode=view&p_p_col_id=column-1&p_p_col_count=1&_33_WAR_cored5portlet_javax.portlet.action=getLoginStatus", + locationURL.Scheme+"://"+locationURL.Host+locationURL.Path, + state, + ) + + body = fmt.Sprintf("_33_WAR_cored5portlet_code=%s", url.QueryEscape(code)) + + req, err = request.New(http.MethodPost, uri, strings.NewReader(body), request.URLEncoding) + if err == nil { + resp, err = v.Do(req) + uri = resp.Header.Get("Location") + + v.baseURI = uri + v.csrf = vars.Csrf + } + } + + return err +} + +func (v *VW) vehicles() ([]string, error) { + uri := v.baseURI + "/-/mainnavigation/get-fully-loaded-cars" + req, err := request.New(http.MethodPost, uri, nil, map[string]string{ + "Accept": "application/json", + "X-CSRF-Token": v.csrf, + }) + + var res vwVehiclesResponse + if err == nil { + err = v.DoJSON(req, &res) + } + + vehicles := make([]string, 0) + for _, v := range res.FullyLoadedVehiclesResponse.CompleteVehicles { + vehicles = append(vehicles, v.VIN) + } + for _, v := range res.FullyLoadedVehiclesResponse.VehiclesNotFullyLoaded { + vehicles = append(vehicles, v.VIN) + } + + return vehicles, err +} + +// chargeState implements the Vehicle.ChargeState interface +func (v *VW) chargeState() (float64, error) { + uri := v.baseURI + "/-/emanager/get-emanager" + req, err := request.New(http.MethodPost, uri, nil, map[string]string{ + "Accept": "application/json", + "X-CSRF-Token": v.csrf, + }) + + var res vwChargerResponse + if err == nil { + err = v.DoJSON(req, &res) + } + + return float64(res.EManager.RBC.Status.BatteryPercentage), err +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *VW) ChargeState() (float64, error) { + return v.chargeStateG() +} + +// finishTime implements the Vehicle.ChargeFinishTimer interface +func (v *VW) finishTime() (time.Time, error) { + uri := v.baseURI + "/-/emanager/get-emanager" + req, err := request.New(http.MethodPost, uri, nil, map[string]string{ + "Accept": "application/json", + "X-CSRF-Token": v.csrf, + }) + + var res vwChargerResponse + if err == nil { + err = v.DoJSON(req, &res) + } + + var duration time.Duration + if err == nil { + hour := res.EManager.RBC.Status.ChargingRemaningHour + min := res.EManager.RBC.Status.ChargingRemaningMinute + duration = time.Hour*time.Duration(hour) + time.Minute*time.Duration(min) + } + + return time.Now().Add(duration), err +} + +// FinishTime implements the Vehicle.ChargeFinishTimer interface +func (v *VW) FinishTime() (time.Time, error) { + return v.finishTimeG() +} diff --git a/vehicle/vwidentity/vwidenity.go b/vehicle/vwidentity/vwidenity.go new file mode 100644 index 000000000..ad44b6224 --- /dev/null +++ b/vehicle/vwidentity/vwidenity.go @@ -0,0 +1,167 @@ +package vwidentity + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" + "github.com/andig/evcc/util/request" +) + +const RootURI = "https://identity.vwgroup.io" + +// Identity provides the identity.vwgroup.io login +type Identity struct { + *http.Client +} + +func (v *Identity) redirect(resp *http.Response, err error) (*http.Response, error) { + if err == nil { + uri := resp.Header.Get("Location") + resp, err = v.Get(uri) + } + + return resp, err +} + +type FormVars struct { + Action string + Csrf string + RelayState string + Hmac string +} + +func FormValues(reader io.Reader, id string) (FormVars, error) { + vars := FormVars{} + + doc, err := goquery.NewDocumentFromReader(reader) + if err == nil { + // only interested in meta tag? + if meta := doc.Find("meta[name=_csrf]"); id == "meta" { + if meta.Length() != 1 { + return vars, errors.New("unexpected length") + } + + var exists bool + vars.Csrf, exists = meta.Attr("content") + if !exists { + return vars, errors.New("meta not found") + } + return vars, nil + } + + form := doc.Find(id).First() + if form.Length() != 1 { + return vars, errors.New("unexpected length") + } + + var exists bool + vars.Action, exists = form.Attr("action") + if !exists { + return vars, errors.New("attribute not found") + } + + vars.Csrf, err = attr(form, "input[name=_csrf]", "value") + if err == nil { + vars.RelayState, err = attr(form, "input[name=relayState]", "value") + } + if err == nil { + vars.Hmac, err = attr(form, "input[name=hmac]", "value") + } + } + + return vars, err +} + +func attr(doc *goquery.Selection, path, attr string) (res string, err error) { + sel := doc.Find(path) + if sel.Length() != 1 { + return "", errors.New("unexpected length") + } + + v, exists := sel.Attr(attr) + if !exists { + return "", errors.New("attribute not found") + } + + return v, nil +} + +// Login performs the identity.vwgroup.io login +func (v *Identity) Login(uri, user, password string) (*http.Response, error) { + var vars FormVars + var req *http.Request + + // GET identity.vwgroup.io/oidc/v1/authorize?ui_locales=de&scope=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&response_type=code&state=gmiJOaB4&redirect_uri=https%3A%2F%2Fwww.portal.volkswagen-we.com%2Fportal%2Fweb%2Fguest%2Fcomplete-login&nonce=38042ee3-b7a7-43cf-a9c1-63d2f3f2d9f3&prompt=login&client_id=b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com + resp, err := v.Get(uri) + + // GET identity.vwgroup.io/signin-service/v1/signin/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa + if err == nil { + uri = resp.Header.Get("Location") + resp, err = v.Get(uri) + + if err == nil { + vars, err = FormValues(resp.Body, "form#emailPasswordForm") + } + } + + // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/identifier + if err == nil { + uri = RootURI + vars.Action + + body := fmt.Sprintf( + "_csrf=%s&relayState=%s&hmac=%s&email=%s", + vars.Csrf, vars.RelayState, vars.Hmac, url.QueryEscape(user), + ) + + req, err = request.New(http.MethodPost, uri, strings.NewReader(body), request.URLEncoding) + if err == nil { + resp, err = v.Do(req) + } + } + + // GET identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&email=... + if err == nil { + uri = RootURI + resp.Header.Get("Location") + req, err = http.NewRequest(http.MethodGet, uri, nil) + + if err == nil { + resp, err = v.Do(req) + } + + if err == nil { + vars, err = FormValues(resp.Body, "form#credentialsForm") + } + } + + // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate + if err == nil { + uri = RootURI + vars.Action + body := fmt.Sprintf( + "_csrf=%s&relayState=%s&email=%s&hmac=%s&password=%s", + vars.Csrf, + vars.RelayState, + url.QueryEscape(user), + vars.Hmac, + url.QueryEscape(password), + ) + + req, err = request.New(http.MethodPost, uri, strings.NewReader(body), request.URLEncoding) + if err == nil { + resp, err = v.Do(req) + } + } + + // GET identity.vwgroup.io/oidc/v1/oauth/sso?clientId=b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com&relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&userId=bca09cc0-8eba-4110-af71-7242868e1bf1&HMAC=2b01ce6a351fad4dd97dc8110d0967b46c95889ab5010c660a616462e66a83ca + // GET identity.vwgroup.io/signin-service/v1/consent/users/bca09cc0-8eba-4110-af71-7242868e1bf1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com?scopes=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&callback=https://identity.vwgroup.io/oidc/v1/oauth/client/callback&hmac=a590931ca3cd9dc3a27f1d1c0c162bf1e5c5c32c9f5b40fcb36d4c6edc631e03 + // GET identity.vwgroup.io/oidc/v1/oauth/client/callback/success?user_id=bca09cc0-8eba-4110-af71-7242868e1bf1&client_id=b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com&scopes=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&consentedScopes=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&relayState=f89a0b750c93e278a7ace170ce374e9cb9eb0a74&hmac=2b728f463c3cfe80f3271fbb35680e5e5218ca70025a46e7fadf7c7982decc2b + for i := 6; i < 9; i++ { + resp, err = v.redirect(resp, err) + } + + return resp, err +}