Implement digest auth for websocket
Signed-off-by: Marcel Goerentz <m.goerentz@t-online.de>
This commit is contained in:
parent
217062b222
commit
cdb947e551
2 changed files with 194 additions and 19 deletions
|
|
@ -20,7 +20,6 @@ import (
|
|||
"github.com/evcc-io/evcc/charger/warp"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/jpfielding/go-http-digest/pkg/digest"
|
||||
)
|
||||
|
||||
type WarpWS struct {
|
||||
|
|
@ -32,6 +31,8 @@ type WarpWS struct {
|
|||
uri string
|
||||
pmURI string
|
||||
meterIndex uint
|
||||
user string
|
||||
password string
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
|
|
@ -117,7 +118,11 @@ func NewWarpWSFromConfig(ctx context.Context, other map[string]any) (api.Charger
|
|||
}
|
||||
wb.pmHelper = request.NewHelper(wb.log)
|
||||
if cc.EnergyManagerUser != "" {
|
||||
wb.pmHelper.Client.Transport = digest.NewTransport(cc.EnergyManagerUser, cc.EnergyManagerPassword, wb.pmHelper.Client.Transport)
|
||||
wb.pmHelper.Client.Transport = &warp.DigestTransport{
|
||||
Username: cc.EnergyManagerUser,
|
||||
Password: cc.EnergyManagerPassword,
|
||||
Base: wb.pmHelper.Client.Transport,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +161,11 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint)
|
|||
|
||||
client := request.NewHelper(log)
|
||||
if user != "" {
|
||||
client.Client.Transport = digest.NewTransport(user, password, client.Client.Transport)
|
||||
client.Client.Transport = &warp.DigestTransport{
|
||||
Username: user,
|
||||
Password: password,
|
||||
Base: client.Client.Transport,
|
||||
}
|
||||
}
|
||||
|
||||
w := &WarpWS{
|
||||
|
|
@ -166,6 +175,8 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint)
|
|||
meterMap: map[int]int{},
|
||||
metersValueIDsTopic: fmt.Sprintf("meters/%d/value_ids", meterIndex),
|
||||
metersValuesTopic: fmt.Sprintf("meters/%d/values", meterIndex),
|
||||
user: user,
|
||||
password: password,
|
||||
}
|
||||
|
||||
uri, err := parseURI(w.uri, true)
|
||||
|
|
@ -184,9 +195,11 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint)
|
|||
func (w *WarpWS) run(uri string, ctx context.Context) {
|
||||
bo := backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(0))
|
||||
bo.MaxInterval = 30 * time.Second
|
||||
|
||||
for ctx.Err() == nil {
|
||||
w.log.DEBUG.Printf("ws connecting to %s …", uri)
|
||||
conn, resp, err := websocket.Dial(ctx, uri, nil)
|
||||
|
||||
conn, resp, err := dialWebsocket(ctx, uri, w.user, w.password)
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
|
|
@ -194,29 +207,59 @@ func (w *WarpWS) run(uri string, ctx context.Context) {
|
|||
if conn != nil {
|
||||
conn.Close(websocket.StatusInternalError, "dial failed")
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
d := bo.NextBackOff()
|
||||
w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d)
|
||||
w.log.ERROR.Printf("ws dial failed: %v", err)
|
||||
} else {
|
||||
w.log.DEBUG.Printf("ws connected to %s", uri)
|
||||
bo.Reset()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(d):
|
||||
// continue to next reconnect attempt
|
||||
if err := w.handleConnection(ctx, conn); err != nil {
|
||||
w.log.ERROR.Println(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
w.log.DEBUG.Printf("ws connected to %s", uri)
|
||||
bo.Reset()
|
||||
if err := w.handleConnection(ctx, conn); err != nil {
|
||||
w.log.ERROR.Println(err)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
d := bo.NextBackOff()
|
||||
w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dialWebsocket(ctx context.Context, wsURL, user, pass string) (*websocket.Conn, *http.Response, error) {
|
||||
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err == nil {
|
||||
return conn, resp, err
|
||||
}
|
||||
|
||||
// Extract challeng from response
|
||||
www := resp.Header.Get("WWW-Authenticate")
|
||||
resp.Body.Close()
|
||||
|
||||
ch, err := warp.ParseDigestChallenge(www)
|
||||
if err != nil {
|
||||
return nil, resp, fmt.Errorf("digest parse error: %w", err)
|
||||
}
|
||||
|
||||
// Build authorization header
|
||||
u, _ := url.Parse(wsURL)
|
||||
auth := warp.BuildDigestAuthHeader(ch, "GET", u.Path, user, pass)
|
||||
|
||||
// Dial with Digest Auth
|
||||
dialer := websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Authorization": []string{auth},
|
||||
},
|
||||
}
|
||||
|
||||
return websocket.Dial(ctx, wsURL, &dialer)
|
||||
}
|
||||
|
||||
func parseURI(uri string, toWS bool) (string, error) {
|
||||
u, err := url.Parse(util.DefaultScheme(strings.TrimRight(uri, "/"), "http"))
|
||||
if err != nil {
|
||||
|
|
|
|||
132
charger/warp/digest_auth.go
Normal file
132
charger/warp/digest_auth.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package warp
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type DigestTransport struct {
|
||||
Username string
|
||||
Password string
|
||||
Base http.RoundTripper
|
||||
mu sync.Mutex
|
||||
challenge *DigestChallenge
|
||||
}
|
||||
|
||||
type DigestChallenge struct {
|
||||
Realm string
|
||||
Nonce string
|
||||
Qop string
|
||||
Opaque string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func ParseDigestChallenge(h string) (*DigestChallenge, error) {
|
||||
dc := &DigestChallenge{}
|
||||
parts := strings.Split(h, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if strings.HasPrefix(p, "Digest ") {
|
||||
p = strings.TrimPrefix(p, "Digest ")
|
||||
}
|
||||
kv := strings.SplitN(p, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(kv[0])
|
||||
val := strings.Trim(kv[1], `"`)
|
||||
switch key {
|
||||
case "realm":
|
||||
dc.Realm = val
|
||||
case "nonce":
|
||||
dc.Nonce = val
|
||||
case "opaque":
|
||||
dc.Opaque = val
|
||||
case "qop":
|
||||
dc.Qop = val
|
||||
case "algorithm":
|
||||
dc.Algorithm = val
|
||||
}
|
||||
}
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
func BuildDigestAuthHeader(ch *DigestChallenge, method, uri, user, pass string) string {
|
||||
ha1 := md5Hex(fmt.Sprintf("%s:%s:%s", user, ch.Realm, pass))
|
||||
ha2 := md5Hex(fmt.Sprintf("%s:%s", method, uri))
|
||||
|
||||
cnonce := randomHex(16)
|
||||
nc := "00000001"
|
||||
|
||||
response := md5Hex(fmt.Sprintf("%s:%s:%s:%s:%s:%s",
|
||||
ha1, ch.Nonce, nc, cnonce, ch.Qop, ha2))
|
||||
|
||||
return fmt.Sprintf(
|
||||
`Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm="MD5", response="%s", qop=%s, nc=%s, cnonce="%s"`,
|
||||
user, ch.Realm, ch.Nonce, uri, response, ch.Qop, nc, cnonce,
|
||||
)
|
||||
}
|
||||
|
||||
func md5Hex(s string) string {
|
||||
h := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (t *DigestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if t.Base == nil {
|
||||
t.Base = http.DefaultTransport
|
||||
}
|
||||
// If we already have a challenge → send digest directly
|
||||
t.mu.Lock()
|
||||
ch := t.challenge
|
||||
t.mu.Unlock()
|
||||
if ch != nil {
|
||||
return t.roundTripWithDigest(req, ch)
|
||||
}
|
||||
// 1. First try without Auth
|
||||
resp, err := t.Base.RoundTrip(req)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
// 2. Parse challenge
|
||||
hdr := resp.Header.Get("WWW-Authenticate")
|
||||
ch, err = ParseDigestChallenge(hdr)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
// Save challenge
|
||||
t.mu.Lock()
|
||||
t.challenge = ch
|
||||
t.mu.Unlock()
|
||||
// Reread body (if necessery)
|
||||
if req.Body != nil {
|
||||
bodyBytes, _ := io.ReadAll(req.Body)
|
||||
req.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
|
||||
}
|
||||
// 3. Second try with Digest Auth
|
||||
return t.roundTripWithDigest(req, ch)
|
||||
}
|
||||
|
||||
func (t *DigestTransport) roundTripWithDigest(req *http.Request, ch *DigestChallenge) (*http.Response, error) {
|
||||
// Copy request
|
||||
r2 := req.Clone(req.Context())
|
||||
uri := r2.URL.RequestURI()
|
||||
auth := BuildDigestAuthHeader(ch, r2.Method, uri, t.Username, t.Password)
|
||||
r2.Header.Set("Authorization", auth)
|
||||
return t.Base.RoundTrip(r2)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue