| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358 |
- package spnego
- import (
- "bytes"
- "encoding/base64"
- "errors"
- "fmt"
- "io"
- "io/ioutil"
- "net"
- "net/http"
- "net/http/cookiejar"
- "net/url"
- "strings"
- "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 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) {
- 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 {
- err = fmt.Errorf("error in unmarshaling SPNEGO token: %v", err)
- spnegoNegotiateKRB5MechType(spnego, w, "%s - SPNEGO %v", r.RemoteAddr, err)
- return nil, err
- }
- 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)
- }
|