Repository: jcmturner/gokrb5
Pull request #263 (2019-02-06 21:09:06)

add new SPNEGO client


Patch:
diff --git a/README.md b/README.md
index eb9b1e7..2fb251c 100644
--- a/README.md
+++ b/README.md
@@ -114,11 +114,15 @@ cl := client.NewClientWithPassword("username", "REALM.COM", "password", cfg, cli
 #### Authenticate to a Service
 
 ##### HTTP SPNEGO
-Create the HTTP request object and then call the client's SetSPNEGOHeader method passing the Service Principal Name (SPN) or to auto generate the SPN from the request object pass a null string ""
+Create the HTTP request object and then create an SPNEGO client and use this to process the request with methods that 
+are the same as on a HTTP client.
+If nil is passed as the HTTP client when creating the SPNEGO client the http.DefaultClient is used.
+When creating the SPNEGO client pass the Service Principal Name (SPN) or auto generate the SPN from the request 
+object by passing a null string "".
 ```go
 r, _ := http.NewRequest("GET", "http://host.test.gokrb5/index.html", nil)
-err := SetSPNEGOHeader(&cl, r, "") // If "" is provided for the last argument the SPN will be derived from the URL.
-HTTPResp, err := http.DefaultClient.Do(r)
+spnegoCl := spngeo.NewClient(cl, nil, "")
+resp, err := spnegoCl.Do(r)
 ```
 
 ##### Generic Kerberos Client
diff --git a/spnego/http.go b/spnego/http.go
index f02123e..2bf931f 100644
--- a/spnego/http.go
+++ b/spnego/http.go
@@ -3,8 +3,12 @@ package spnego
 import (
 	"context"
 	"encoding/base64"
+	"errors"
 	"fmt"
+	"io"
 	"net/http"
+	"net/http/cookiejar"
+	"net/url"
 	"strings"
 
 	"gopkg.in/jcmturner/goidentity.v3"
@@ -18,6 +22,118 @@ import (
 
 // Client side functionality //
 
+// Client will negotiate authentication with a server using SPNEGO.
+type Client struct {
+	*http.Client
+	krb5Client *client.Client
+	spn        string
+	reqs       []*http.Request
+}
+
+type redirectErr struct {
+	reqTarget *http.Request
+}
+
+func (e redirectErr) Error() string {
+	return fmt.Sprintf("redirect to %v", e.reqTarget.URL)
+}
+
+// NewClient returns an SPNEGO enabled HTTP client.
+func NewClient(krb5Cl *client.Client, httpCl *http.Client, spn string) *Client {
+	if httpCl == nil {
+		httpCl = http.DefaultClient
+	}
+	// Add a cookie jar if there isn't one
+	if httpCl.Jar == nil {
+		httpCl.Jar, _ = cookiejar.New(nil)
+	}
+	// Add a CheckRedirect function that will execute any functional already defined and then error with a redirectErr
+	f := httpCl.CheckRedirect
+	httpCl.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+		if f != nil {
+			err := f(req, via)
+			if err != nil {
+				return err
+			}
+		}
+		return redirectErr{reqTarget: req}
+	}
+	return &Client{
+		Client:     httpCl,
+		krb5Client: krb5Cl,
+		spn:        spn,
+	}
+}
+
+// Do is the SPNEGO enabled HTTP client's equivalent of the http.Client's Do method.
+func (c *Client) Do(req *http.Request) (resp *http.Response, err error) {
+	resp, err = c.Client.Do(req)
+	if err != nil {
+		if ue, ok := err.(*url.Error); ok {
+			if e, ok := ue.Err.(redirectErr); ok {
+				// Picked up a redirect
+				e.reqTarget.Header.Del(HTTPHeaderAuthRequest)
+				c.reqs = append(c.reqs, e.reqTarget)
+				if len(c.reqs) >= 10 {
+					return resp, errors.New("stopped after 10 redirects")
+				}
+				return c.Do(e.reqTarget)
+			}
+		}
+		return resp, err
+	}
+	if respUnauthorizedNegotiate(resp) {
+		err := SetSPNEGOHeader(c.krb5Client, req, c.spn)
+		if err != nil {
+			return resp, err
+		}
+		return c.Do(req)
+	}
+	return resp, err
+}
+
+// Get is the SPNEGO enabled HTTP client's equivalent of the http.Client's Get method.
+func (c *Client) Get(url string) (resp *http.Response, err error) {
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, err
+	}
+	return c.Do(req)
+}
+
+// Post is the SPNEGO enabled HTTP client's equivalent of the http.Client's Post method.
+func (c *Client) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) {
+	req, err := http.NewRequest("POST", url, body)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", contentType)
+	return c.Do(req)
+}
+
+// PostForm is the SPNEGO enabled HTTP client's equivalent of the http.Client's PostForm method.
+func (c *Client) PostForm(url string, data url.Values) (resp *http.Response, err error) {
+	return c.Post(url, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
+}
+
+// Head is the SPNEGO enabled HTTP client's equivalent of the http.Client's Head method.
+func (c *Client) Head(url string) (resp *http.Response, err error) {
+	req, err := http.NewRequest("HEAD", url, nil)
+	if err != nil {
+		return nil, err
+	}
+	return c.Do(req)
+}
+
+func respUnauthorizedNegotiate(resp *http.Response) bool {
+	if resp.StatusCode == http.StatusUnauthorized {
+		if resp.Header.Get(HTTPHeaderAuthResponse) == HTTPHeaderAuthResponseValueKey {
+			return true
+		}
+	}
+	return false
+}
+
 // SetSPNEGOHeader gets the service ticket and sets it as the SPNEGO authorization header on HTTP request object.
 // To auto generate the SPN from the request object pass a null string "".
 func SetSPNEGOHeader(cl *client.Client, r *http.Request, spn string) error {
@@ -38,7 +154,7 @@ func SetSPNEGOHeader(cl *client.Client, r *http.Request, spn string) error {
 		return krberror.Errorf(err, krberror.EncodingError, "could not marshal SPNEGO")
 	}
 	hs := "Negotiate " + base64.StdEncoding.EncodeToString(nb)
-	r.Header.Set("Authorization", hs)
+	r.Header.Set(HTTPHeaderAuthRequest, hs)
 	return nil
 }
 


Test patch:
diff --git a/spnego/http_test.go b/spnego/http_test.go
index c2258aa..4c03a6d 100644
--- a/spnego/http_test.go
+++ b/spnego/http_test.go
@@ -53,15 +53,21 @@ func TestClient_SetSPNEGOHeader(t *testing.T) {
 	}
 	for _, p := range paths {
 		r, _ := http.NewRequest("GET", url+p, nil)
-		httpResp, err := http.DefaultClient.Do(r)
+		httpCl := http.DefaultClient
+		httpCl.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+			t.Logf("http client redirect: %+v", *req)
+			return nil
+		}
+		httpResp, err := httpCl.Do(r)
 		if err != nil {
-			t.Fatalf("%s request error: %v\n", url+p, err)
+			t.Fatalf("%s request error: %v", url+p, err)
 		}
 		assert.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Status code in response to client with no SPNEGO not as expected")
 		err = SetSPNEGOHeader(cl, r, "HTTP/host.test.gokrb5")
 		if err != nil {
 			t.Fatalf("error setting client SPNEGO header: %v", err)
 		}
+		//t.Logf("Reqeust: %+v\n\n", *r)
 		httpResp, err = http.DefaultClient.Do(r)
 		if err != nil {
 			t.Fatalf("%s request error: %v\n", url+p, err)
@@ -70,6 +76,51 @@ func TestClient_SetSPNEGOHeader(t *testing.T) {
 	}
 }
 
+func TestSPNEGOHTTPClient(t *testing.T) {
+	if os.Getenv("INTEGRATION") != "1" {
+		t.Skip("Skipping integration test")
+	}
+	b, _ := hex.DecodeString(testdata.TESTUSER1_KEYTAB)
+	kt := keytab.New()
+	kt.Unmarshal(b)
+	c, _ := config.NewConfigFromString(testdata.TEST_KRB5CONF)
+	addr := os.Getenv("TEST_KDC_ADDR")
+	if addr == "" {
+		addr = testdata.TEST_KDC_ADDR
+	}
+	c.Realms[0].KDC = []string{addr + ":" + testdata.TEST_KDC}
+	cl := client.NewClientWithKeytab("testuser1", "TEST.GOKRB5", kt, c)
+
+	err := cl.Login()
+	if err != nil {
+		t.Fatalf("error on AS_REQ: %v\n", err)
+	}
+	url := os.Getenv("TEST_HTTP_URL")
+	if url == "" {
+		url = testdata.TEST_HTTP_URL
+	}
+	// This path issues a redirect which the http client will automatically follow.
+	// It should cause a replay issue if the negInit token is sent in the first instance.
+	paths := []string{
+		"/modgssapi", // This issues a redirect which the http client will automatically follow. Could cause a replay issue
+		"/redirect",
+	}
+	for _, p := range paths {
+		r, _ := http.NewRequest("GET", url+p, nil)
+		httpCl := http.DefaultClient
+		httpCl.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+			t.Logf("http client redirect: %+v", *req)
+			return nil
+		}
+		spnegoCl := NewClient(cl, httpCl, "HTTP/host.test.gokrb5")
+		httpResp, err := spnegoCl.Do(r)
+		if err != nil {
+			t.Fatalf("%s request error: %v", url+p, err)
+		}
+		assert.Equal(t, http.StatusOK, httpResp.StatusCode, "Status code in response to client SPNEGO request not as expected")
+	}
+}
+
 func TestService_SPNEGOKRB_NoAuthHeader(t *testing.T) {
 	s := httpServer()
 	defer s.Close()
diff --git a/test/testenv/docker/krbhttp/Dockerfile b/test/testenv/docker/krbhttp/Dockerfile
index c72f4ae..e13bb7d 100644
--- a/test/testenv/docker/krbhttp/Dockerfile
+++ b/test/testenv/docker/krbhttp/Dockerfile
@@ -3,12 +3,15 @@ MAINTAINER Jonathan Turner <jt@jtnet.co.uk>
 
 EXPOSE 80 443
 ENV LANG C
+ENV KRB5RCACHEDIR=/var/tmp
+ENV KRB5RCACHETYPE=dfl
 ENTRYPOINT ["/usr/sbin/httpd", "-DFOREGROUND"]
 
 RUN yum install -y \
   httpd \
   mod_auth_kerb \
   mod_auth_gssapi \
+  mod_session \
   mod_ssl \
   tcpdump krb5-workstation vim \
   && yum update -y && yum clean all
diff --git a/test/testenv/docker/krbhttp/httpd-krb5.conf b/test/testenv/docker/krbhttp/httpd-krb5.conf
index aa0855a..42eccaa 100644
--- a/test/testenv/docker/krbhttp/httpd-krb5.conf
+++ b/test/testenv/docker/krbhttp/httpd-krb5.conf
@@ -4,6 +4,9 @@ ProxyIOBufferSize 65536
     AuthType GSSAPI
     AuthName "GoKrb5 Test SPNEGO"
     GssapiCredStore keytab:/etc/httpd/http.testtab
+    GssapiUseSessions On
+    Session On
+    SessionCookieName gssapi_session path=/;httponly;
     Require valid-user
 </LocationMatch>
 <LocationMatch /modkerb>
@@ -17,4 +20,7 @@ ProxyIOBufferSize 65536
         Krb5KeyTab /etc/httpd/http.testtab
         #KrbSaveCredentials On
         require valid-user
-</LocationMatch>
\ No newline at end of file
+</LocationMatch>
+
+RewriteEngine On
+RewriteRule ^/redirect(.*)$ http://%{SERVER_NAME}/modgssapi/ [R=301,L]
\ No newline at end of file