package spnego import ( "bytes" "encoding/base64" "errors" "fmt" "io" "io/ioutil" "net" "net/http" "net/http/cookiejar" "net/url" "strings" "github.com/jcmturner/gofork/encoding/asn1" "github.com/jcmturner/goidentity/v6" "github.com/jcmturner/gokrb5/v8/client" "github.com/jcmturner/gokrb5/v8/credentials" "github.com/jcmturner/gokrb5/v8/gssapi" "github.com/jcmturner/gokrb5/v8/keytab" "github.com/jcmturner/gokrb5/v8/krberror" "github.com/jcmturner/gokrb5/v8/service" "github.com/jcmturner/gokrb5/v8/types" ) // 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) } type teeReadCloser struct { io.Reader io.Closer } // NewClient returns a SPNEGO enabled HTTP client. // Be careful when passing in the *http.Client if it is beginning reused in multiple calls to this function. // Ensure reuse of the provided *http.Client is for the same user as a session cookie may have been added to // http.Client's cookie jar. // Incorrect reuse of the provided *http.Client could lead to access to the wrong user's session. func NewClient(krb5Cl *client.Client, httpCl *http.Client, spn string) *Client { if httpCl == nil { httpCl = &http.Client{} } // 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) { var body bytes.Buffer if req.Body != nil { // Use a tee reader to capture any body sent in case we have to replay it again teeR := io.TeeReader(req.Body, &body) teeRC := teeReadCloser{teeR, req.Body} req.Body = teeRC } 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") } if req.Body != nil { // Refresh the body reader so the body can be sent again e.reqTarget.Body = ioutil.NopCloser(&body) } return c.Do(e.reqTarget) } } return resp, err } if respUnauthorizedNegotiate(resp) { err := SetSPNEGOHeader(c.krb5Client, req, c.spn) if err != nil { return resp, err } if req.Body != nil { // Refresh the body reader so the body can be sent again req.Body = ioutil.NopCloser(&body) } 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 { if spn == "" { h := strings.TrimSuffix(strings.SplitN(r.URL.Host, ":", 2)[0], ".") name, err := net.LookupCNAME(h) if err == nil { // Underlyng canonical name should be used for SPN h = strings.TrimSuffix(name, ".") } spn = "HTTP/" + h r.Host = h } cl.Log("using SPN %s", spn) s := SPNEGOClient(cl, spn) err := s.AcquireCred() if err != nil { return fmt.Errorf("could not acquire client credential: %v", err) } st, err := s.InitSecContext() if err != nil { return fmt.Errorf("could not initialize context: %v", err) } nb, err := st.Marshal() if err != nil { return krberror.Errorf(err, krberror.EncodingError, "could not marshal SPNEGO") } hs := "Negotiate " + base64.StdEncoding.EncodeToString(nb) r.Header.Set(HTTPHeaderAuthRequest, hs) return nil } // Service side functionality // const ( // spnegoNegTokenRespKRBAcceptCompleted - The response on successful authentication always has this header. Capturing as const so we don't have marshaling and encoding overhead. spnegoNegTokenRespKRBAcceptCompleted = "Negotiate oRQwEqADCgEAoQsGCSqGSIb3EgECAg==" // spnegoNegTokenRespReject - The response on a failed authentication always has this rejection header. Capturing as const so we don't have marshaling and encoding overhead. spnegoNegTokenRespReject = "Negotiate oQcwBaADCgEC" // spnegoNegTokenRespIncompleteKRB5 - Response token specifying incomplete context and KRB5 as the supported mechtype. spnegoNegTokenRespIncompleteKRB5 = "Negotiate oRQwEqADCgEBoQsGCSqGSIb3EgECAg==" // sessionCredentials is the session value key holding the credentials jcmturner/goidentity/Identity object. sessionCredentials = "github.com/jcmturner/gokrb5/v8/sessionCredentials" // ctxCredentials is the SPNEGO context key holding the credentials jcmturner/goidentity/Identity object. ctxCredentials = "github.com/jcmturner/gokrb5/v8/ctxCredentials" // HTTPHeaderAuthRequest is the header that will hold authn/z information. HTTPHeaderAuthRequest = "Authorization" // HTTPHeaderAuthResponse is the header that will hold SPNEGO data from the server. HTTPHeaderAuthResponse = "WWW-Authenticate" // HTTPHeaderAuthResponseValueKey is the key in the auth header for SPNEGO. HTTPHeaderAuthResponseValueKey = "Negotiate" // UnauthorizedMsg is the message returned in the body when authentication fails. UnauthorizedMsg = "Unauthorised.\n" ) // SPNEGOKRB5Authenticate is a Kerberos SPNEGO authentication HTTP handler wrapper. func SPNEGOKRB5Authenticate(inner http.Handler, kt *keytab.Keytab, settings ...func(*service.Settings)) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Set up the SPNEGO GSS-API mechanism var spnego *SPNEGO h, err := types.GetHostAddress(r.RemoteAddr) if err == nil { // put in this order so that if the user provides a ClientAddress it will override the one here. o := append([]func(*service.Settings){service.ClientAddress(h)}, settings...) spnego = SPNEGOService(kt, o...) } else { spnego = SPNEGOService(kt, settings...) spnego.Log("%s - SPNEGO could not parse client address: %v", r.RemoteAddr, err) } // Check if there is a session manager and if there is an already established session for this client id, err := getSessionCredentials(spnego, r) if err == nil && id.Authenticated() { // There is an established session so bypass auth and serve spnego.Log("%s - SPNEGO request served under session %s", r.RemoteAddr, id.SessionID()) inner.ServeHTTP(w, goidentity.AddToHTTPRequestContext(&id, r)) return } st, err := getAuthorizationNegotiationHeaderAsSPNEGOToken(spnego, r, w) if st == nil || err != nil { // response to client and logging handled in function above so just return return } // Validate the context token authed, ctx, status := spnego.AcceptSecContext(st) if status.Code != gssapi.StatusComplete && status.Code != gssapi.StatusContinueNeeded { spnegoResponseReject(spnego, w, "%s - SPNEGO validation error: %v", r.RemoteAddr, status) return } if status.Code == gssapi.StatusContinueNeeded { spnegoNegotiateKRB5MechType(spnego, w, "%s - SPNEGO GSS-API continue needed", r.RemoteAddr) return } if authed { // Authentication successful; get user's credentials from the context id := ctx.Value(ctxCredentials).(*credentials.Credentials) // Create a new session if a session manager has been configured err = newSession(spnego, r, w, id) if err != nil { return } spnegoResponseAcceptCompleted(spnego, w, "%s %s@%s - SPNEGO authentication succeeded", r.RemoteAddr, id.UserName(), id.Domain()) // Add the identity to the context and serve the inner/wrapped handler inner.ServeHTTP(w, goidentity.AddToHTTPRequestContext(id, r)) return } // If we get to here we have not authenticationed so just reject spnegoResponseReject(spnego, w, "%s - SPNEGO Kerberos authentication failed", r.RemoteAddr) return }) } func getAuthorizationNegotiationHeaderAsSPNEGOToken(spnego *SPNEGO, r *http.Request, w http.ResponseWriter) (*SPNEGOToken, error) { s := strings.SplitN(r.Header.Get(HTTPHeaderAuthRequest), " ", 2) if len(s) != 2 || s[0] != HTTPHeaderAuthResponseValueKey { // No Authorization header set so return 401 with WWW-Authenticate Negotiate header w.Header().Set(HTTPHeaderAuthResponse, HTTPHeaderAuthResponseValueKey) http.Error(w, UnauthorizedMsg, http.StatusUnauthorized) return nil, errors.New("client did not provide a negotiation authorization header") } // Decode the header into an SPNEGO context token b, err := base64.StdEncoding.DecodeString(s[1]) if err != nil { err = fmt.Errorf("error in base64 decoding negotiation header: %v", err) spnegoNegotiateKRB5MechType(spnego, w, "%s - SPNEGO %v", r.RemoteAddr, err) return nil, err } var st SPNEGOToken err = st.Unmarshal(b) if err != nil { // Check if this is a raw KRB5 context token - issue #347. var k5t KRB5Token if k5t.Unmarshal(b) != nil { err = fmt.Errorf("error in unmarshaling SPNEGO token: %v", err) spnegoNegotiateKRB5MechType(spnego, w, "%s - SPNEGO %v", r.RemoteAddr, err) return nil, err } // Wrap it into an SPNEGO context token st.Init = true st.NegTokenInit = NegTokenInit{ MechTypes: []asn1.ObjectIdentifier{k5t.OID}, MechTokenBytes: b, } } return &st, nil } func getSessionCredentials(spnego *SPNEGO, r *http.Request) (credentials.Credentials, error) { var creds credentials.Credentials // Check if there is a session manager and if there is an already established session for this client if sm := spnego.serviceSettings.SessionManager(); sm != nil { cb, err := sm.Get(r, sessionCredentials) if err != nil || cb == nil || len(cb) < 1 { return creds, fmt.Errorf("%s - SPNEGO error getting session and credentials for request: %v", r.RemoteAddr, err) } err = creds.Unmarshal(cb) if err != nil { return creds, fmt.Errorf("%s - SPNEGO credentials malformed in session: %v", r.RemoteAddr, err) } return creds, nil } return creds, errors.New("no session manager configured") } func newSession(spnego *SPNEGO, r *http.Request, w http.ResponseWriter, id *credentials.Credentials) error { if sm := spnego.serviceSettings.SessionManager(); sm != nil { // create new session idb, err := id.Marshal() if err != nil { spnegoInternalServerError(spnego, w, "SPNEGO could not marshal credentials to add to the session: %v", err) return err } err = sm.New(w, r, sessionCredentials, idb) if err != nil { spnegoInternalServerError(spnego, w, "SPNEGO could not create new session: %v", err) return err } spnego.Log("%s %s@%s - SPNEGO new session (%s) created", r.RemoteAddr, id.UserName(), id.Domain(), id.SessionID()) } return nil } // Log and respond to client for error conditions func spnegoNegotiateKRB5MechType(s *SPNEGO, w http.ResponseWriter, format string, v ...interface{}) { s.Log(format, v...) w.Header().Set(HTTPHeaderAuthResponse, spnegoNegTokenRespIncompleteKRB5) http.Error(w, UnauthorizedMsg, http.StatusUnauthorized) } func spnegoResponseReject(s *SPNEGO, w http.ResponseWriter, format string, v ...interface{}) { s.Log(format, v...) w.Header().Set(HTTPHeaderAuthResponse, spnegoNegTokenRespReject) http.Error(w, UnauthorizedMsg, http.StatusUnauthorized) } func spnegoResponseAcceptCompleted(s *SPNEGO, w http.ResponseWriter, format string, v ...interface{}) { s.Log(format, v...) w.Header().Set(HTTPHeaderAuthResponse, spnegoNegTokenRespKRBAcceptCompleted) } func spnegoInternalServerError(s *SPNEGO, w http.ResponseWriter, format string, v ...interface{}) { s.Log(format, v...) http.Error(w, "Internal Server Error", http.StatusInternalServerError) }