package networking import ( "bytes" "crypto/md5" "crypto/rand" "encoding/hex" "fmt" "net/http" "net/url" "regexp" "strings" "github.com/beevik/etree" "github.com/juju/errors" ) const soapContentType = "application/soap+xml; charset=utf-8" // SendSoap send soap message func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response, error) { resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message)) if err != nil { return resp, errors.Annotate(err, "Post") } return resp, responseError(resp) } func responseError(resp *http.Response) error { if resp.StatusCode >= 400 && resp.StatusCode < 600 { return errors.Errorf("Server error: %d: %s", resp.StatusCode, resp.Status) } return nil } // SendSoapWithDigest sends a soap message and, when the device answers with an // HTTP 401 digest challenge, transparently retries the request with the // computed HTTP digest Authorization header. // // Any wsse:Security header present in the message is stripped before sending: // when a device requires HTTP digest the credentials travel in the // Authorization header, so keeping the WS-Security UsernameToken in the body // would put the credentials on the wire twice. Other SOAP header blocks (for // example WS-Addressing reference parameters) are preserved so vendor-specific // routing keeps working across the retry. func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) { if httpClient == nil { httpClient = new(http.Client) } // Avoid sending the credentials twice (WS-Security + digest) on the retry. message = stripWSSecurityHeader(message) resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message)) if err != nil { return resp, errors.Annotate(err, "Post") } // Only escalate to HTTP digest when the device explicitly asks for it. if resp.StatusCode != http.StatusUnauthorized { return resp, responseError(resp) } challenge := resp.Header.Get("WWW-Authenticate") if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(challenge)), "digest") { // Not a digest challenge (e.g. Basic) - nothing more we can do here. return resp, responseError(resp) } authorization := newDigestAuthorization(challenge, http.MethodPost, endpoint, username, password) if authorization == "" { return resp, errors.New("unsupported digest challenge") } // Release the challenge response before issuing the authenticated retry. resp.Body.Close() req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(message)) if err != nil { return nil, errors.Annotate(err, "new digest request") } req.Header.Set("Content-Type", soapContentType) req.Header.Set("Authorization", authorization) resp, err = httpClient.Do(req) if err != nil { return resp, errors.Annotate(err, "Post with digest") } return resp, responseError(resp) } // stripWSSecurityHeader removes the wsse:Security header block from a SOAP // envelope, leaving all other header blocks intact. The message is returned // unchanged if it cannot be parsed as XML or has no such header. func stripWSSecurityHeader(message string) string { doc := etree.NewDocument() if err := doc.ReadFromString(message); err != nil { return message } security := doc.FindElement("./Envelope/Header/Security") if security == nil { return message } header := doc.Root().SelectElement("Header") if header == nil { return message } header.RemoveChild(security) data, err := doc.WriteToString() if err != nil { return message } return data } var digestParamRe = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,]+))`) // parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header. func parseDigestChallenge(challenge string) map[string]string { challenge = strings.TrimSpace(challenge) if i := strings.IndexAny(challenge, " \t"); i >= 0 && strings.EqualFold(challenge[:i], "Digest") { challenge = challenge[i+1:] } result := make(map[string]string) for _, m := range digestParamRe.FindAllStringSubmatch(challenge, -1) { value := m[2] if value == "" { value = m[3] } result[strings.ToLower(m[1])] = strings.TrimSpace(value) } return result } // newDigestAuthorization builds an RFC 2617 HTTP digest Authorization header // value. It supports the MD5 and MD5-sess algorithms and the "auth" qop, which // covers the vast majority of ONVIF devices. It returns an empty string when the // challenge is missing required parameters or requires an unsupported qop. func newDigestAuthorization(challenge, method, uri, username, password string) string { parts := parseDigestChallenge(challenge) realm := parts["realm"] nonce := parts["nonce"] if realm == "" || nonce == "" { return "" } opaque := parts["opaque"] algorithm := parts["algorithm"] qop := "" if rawQop, ok := parts["qop"]; ok { for _, candidate := range strings.Split(rawQop, ",") { if strings.TrimSpace(candidate) == "auth" { qop = "auth" break } } // The server offered qop but none we support (e.g. auth-int only). if qop == "" { return "" } } digestURI := uri if u, err := url.Parse(uri); err == nil { digestURI = u.RequestURI() } cnonce := randomCnonce() const nc = "00000001" ha1 := md5Hex(username + ":" + realm + ":" + password) if strings.EqualFold(algorithm, "MD5-sess") { ha1 = md5Hex(ha1 + ":" + nonce + ":" + cnonce) } ha2 := md5Hex(method + ":" + digestURI) var response string if qop == "auth" { response = md5Hex(strings.Join([]string{ha1, nonce, nc, cnonce, qop, ha2}, ":")) } else { response = md5Hex(ha1 + ":" + nonce + ":" + ha2) } var b strings.Builder fmt.Fprintf(&b, `Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, username, realm, nonce, digestURI, response) if qop == "auth" { fmt.Fprintf(&b, `, qop=auth, nc=%s, cnonce="%s"`, nc, cnonce) } if algorithm != "" { fmt.Fprintf(&b, `, algorithm=%s`, algorithm) } if opaque != "" { fmt.Fprintf(&b, `, opaque="%s"`, opaque) } return b.String() } func md5Hex(s string) string { sum := md5.Sum([]byte(s)) return hex.EncodeToString(sum[:]) } func randomCnonce() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { return "00000000" } return hex.EncodeToString(b) }