Add VW api (pre-ID portal-based version) (#361)

This commit is contained in:
andig 2020-09-27 18:56:04 +02:00
parent 26eeb05c05
commit 03dd2f4128
3 changed files with 434 additions and 121 deletions

View file

@ -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)

261
vehicle/vw.go Normal file
View file

@ -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()
}

View file

@ -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
}