From 7176cc6c234feff5886f201aae31b52feb92ce02 Mon Sep 17 00:00:00 2001 From: Flavio Garcia Date: Sun, 21 Jul 2024 00:22:46 -0400 Subject: [PATCH] feat(service): add Nonced middleware and add IsNonced The Nonced middleware wraps a handler into NoncedHandlerFunc requiring any request to have a valid nonce or any condition that makes the service IsNonced false. The IsNonced method added to the service is a way to unblock requests from a handler or function to be allowed to proceed without a nonce validation. This method was added to allow a handler to have a request that will provide a new nonce. IsNonced can be used for requests where nonce is not necessary after a validation. Refs: #2 --- README.md | 35 +++++++++------- middleware.go | 17 ++++++++ middleware_test.go | 79 +++++++++++++++++++++++++++++++++++ service.go | 101 +++++++++++++++++++++------------------------ service_test.go | 93 +++++++++++++++++++++-------------------- 5 files changed, 211 insertions(+), 114 deletions(-) create mode 100644 middleware.go create mode 100644 middleware_test.go diff --git a/README.md b/README.md index a94840d..94b56e1 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,24 @@ # Candango Go Peasant -Peasant is a protocol abstraction of how to control agents that need to -communicate with a central entity or entities. - -We define agents as peasants and central entities (bases) as bastions. - -This project won't define the implementation, security level neither levels of -redundancies but instead a minimal contract of what should be implemented. - -A bastion/peasant relationship could be defined as stateful or not. If stateful -it is necessary to implement a session control in the bastion where peasants -need to perform knocks (as knock at the door) for permission or a valid session. -In a stateless case we just ignore any knock implementation. - -What must be implemented in the protocol are nonce generation, consumption and -validation on both sides and a directory list of available resources offered by -a bastion for peasants to consume. +Peasant Protocol: A Contract for Controlling Agents + +The Peasant protocol is a high-level abstraction designed to facilitate +communication between agents (peasants) and central entities (bastions). It +does not impose specific implementation details, security requirements, or +redundancy levels, but instead establishes a minimal contract for what must be +implemented. + +In this protocol, agents are referred to as peasants, while central entities +are called bastions. The relationship between a bastion and peasant can be +either stateful or stateless. In a stateful scenario, bastions must implement +a session control mechanism, requiring peasants to perform "knocks" (similar +to knocking on a door) to request permission or establish a valid session. In a +stateless scenario, the concept of knocking is ignored. + +The Peasant protocol mandates the implementation of nonce generation, +consumption and validation on both the peasant and bastion sides. +Additionally, bastions must provide a directory list of available resources +that peasants can consume. ## Support diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..db8a0e2 --- /dev/null +++ b/middleware.go @@ -0,0 +1,17 @@ +package peasant + +import ( + "net/http" +) + +// Nonced is a middleware that verifies the presence of a valid nonce in a +// request. +// If the nonce is not provided or is invalid, it prevents the request from +// proceeding. +func Nonced(next http.Handler, s NonceService) http.Handler { + return http.HandlerFunc(NoncedHandlerFunc(s, + func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }), + ) +} diff --git a/middleware_test.go b/middleware_test.go new file mode 100644 index 0000000..f51b980 --- /dev/null +++ b/middleware_test.go @@ -0,0 +1,79 @@ +package peasant + +import ( + "net/http" + "testing" + "time" + + "github.com/candango/gopeasant/testrunner" + "github.com/stretchr/testify/assert" +) + +func NewNoncedServeMux(t *testing.T) http.Handler { + s := &InMemoryNonceService{ + nonceMap: make(map[string]*interface{}), + t: t, + } + nonced := &NoncedHandler{ + service: s, + } + h := http.NewServeMux() + h.HandleFunc("/new-nonce", nonced.getNonce) + h.HandleFunc("/do-nonced-something", nonced.doNoncedFunc) + return Nonced(h, s) +} + +func TestNoncedServer(t *testing.T) { + handler := NewNoncedServeMux(t) + runner := testrunner.NewHttpTestRunner(t).WithHandler(handler) + + t.Run("Retrieve a new nonce", func(t *testing.T) { + t.Run("Request OK", func(t *testing.T) { + res, err := runner.WithPath("/new-nonce").Head() + if err != nil { + t.Error(err) + } + + assert.Equal(t, "200 OK", res.Status) + assert.Equal(t, http.NoBody, res.Body) + assert.Equal(t, 32, len(res.Header.Get("nonce"))) + }) + }) + + t.Run("Run a nonced function", func(t *testing.T) { + t.Run("Request OK", func(t *testing.T) { + res, err := runner.WithPath("/new-nonce").Head() + if err != nil { + t.Error(err) + } + nonce := res.Header.Get("nonce") + + res, err = runner.WithPath( + "/do-nonced-something").WithHeader("nonce", nonce).Get() + if err != nil { + t.Error(err) + } + + assert.Equal(t, "200 OK", res.Status) + assert.Equal(t, "Func done with nonce "+nonce, + testrunner.BodyAsString(t, res)) + assert.Equal(t, 32, len(res.Header.Get("nonce"))) + }) + t.Run("Expired nonce", func(t *testing.T) { + res, err := runner.WithPath("/new-nonce").Head() + if err != nil { + t.Error(err) + } + nonce := res.Header.Get("nonce") + + time.Sleep(250 * time.Millisecond) + res, err = runner.WithPath( + "/do-nonced-something").WithHeader("nonce", nonce).Get() + if err != nil { + t.Error(err) + } + + assert.Equal(t, "403 Forbidden", res.Status) + }) + }) +} diff --git a/service.go b/service.go index c412064..87356e7 100644 --- a/service.go +++ b/service.go @@ -1,6 +1,8 @@ package peasant -import "net/http" +import ( + "net/http" +) // NonceService defines methods for managing nonces in HTTP requests. // It provides functionality for blocking, clearing, consuming, getting, @@ -19,85 +21,76 @@ type NonceService interface { // Consume processes the nonce associated with the specified key and // returns a boolean indicating whether the nonce was successfully // consumed, along with any error encountered. - // If the specified key is not present in the map, the method will return - // false without performing any action. - Consume(http.ResponseWriter, *http.Request) (bool, error) + // If nonce connot be consumed header sould be set with the respective http + // error code. + Consume(http.ResponseWriter, *http.Request) error // GetNonce generates a new nonce, and stores it for a future validation. // It returns the nonce as a string and an error if any occurred during // the nonce generation or header update. GetNonce(*http.Request) (string, error) - // Provided verifies the presence of a valid nonce in the specified HTTP + // IsNonced return if the request should be nonced or not. + IsNonced(*http.Request) bool + + // IsProvided verifies the presence of a valid nonce in the specified HTTP // request. // // If the nonce is not provided or is invalid, it sets the response HTTP // status to "Unauthorized", "Forbidden", or another appropriate status // based on the specific conditions and checks performed within the method. - // It returns a boolean indicating whether the nonce was provided and - // valid, along with any error encountered. - Provided(http.ResponseWriter, *http.Request) (bool, error) + // If nonce is not provided header sould be set with the respective http + // error code. + IsProvided(http.ResponseWriter, *http.Request) error } -func NoncedHandlerFunc(s NonceService, - f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { - return func(res http.ResponseWriter, req *http.Request) { - ok, err := s.Provided(res, req) - if err != nil { - res.WriteHeader(http.StatusInternalServerError) +type WrappedWriter struct { + http.ResponseWriter + StatusCode int +} + +func (w *WrappedWriter) WriteHeader(c int) { + w.ResponseWriter.WriteHeader(c) + w.StatusCode = c +} + +func NoncedHandlerFunc( + s NonceService, f func(http.ResponseWriter, *http.Request), +) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if !s.IsNonced(r) { + f(w, r) return } - if !ok { - return + wrapped := &WrappedWriter{ + ResponseWriter: w, + StatusCode: http.StatusOK, } - ok, err = s.Consume(res, req) + err := s.IsProvided(wrapped, r) if err != nil { - res.WriteHeader(http.StatusInternalServerError) + wrapped.WriteHeader(http.StatusInternalServerError) return } - if !ok { + if wrapped.StatusCode >= 300 { return } - nonce, err := s.GetNonce(req) + err = s.Consume(wrapped, r) if err != nil { - res.WriteHeader(http.StatusInternalServerError) + wrapped.WriteHeader(http.StatusInternalServerError) return } - res.Header().Add("nonce", nonce) - f(res, req) - } -} - -// Nonced processes the provided HTTP request to verify and consume a -// valid nonce. It returns an error if any occurred during the process. -func Nonced(res http.ResponseWriter, req *http.Request, - service NonceService) (err error) { - ok, err := service.Provided(res, req) - if err != nil { - return err - } - if !ok { - err = service.Block(res, req) - if err != nil { - return err + if wrapped.StatusCode >= 300 { + return } - return nil - } - nonce, err := service.GetNonce(req) - if err != nil { - return err - } - - ok, err = service.Consume(res, req) - if err != nil { - return err - } - if ok { - err = service.Clear(nonce) + nonce, err := s.GetNonce(r) if err != nil { - return err + wrapped.WriteHeader(http.StatusInternalServerError) + return } + if wrapped.StatusCode >= 300 { + return + } + wrapped.Header().Add("nonce", nonce) + f(wrapped, r) } - - return nil } diff --git a/service_test.go b/service_test.go index 813b9e5..8450e51 100644 --- a/service_test.go +++ b/service_test.go @@ -3,6 +3,7 @@ package peasant import ( "math/rand" "net/http" + "strings" "testing" "time" @@ -48,22 +49,22 @@ func (s *InMemoryNonceService) Clear(nonce string) error { // Consume consumes the nonce associated with a specified key and returns // whether the nonce was successfully consumed and any error that occurred. func (s *InMemoryNonceService) Consume(res http.ResponseWriter, - req *http.Request) (bool, error) { + req *http.Request) error { nonce := req.Header.Get("nonce") if nonce == "" { res.WriteHeader(http.StatusForbidden) - return false, nil + return nil } _, ok := s.nonceMap[nonce] if !ok { res.WriteHeader(http.StatusForbidden) - return false, nil + return nil } err := s.Clear(nonce) if err != nil { - return false, err + return err } - return true, nil + return nil } func (s *InMemoryNonceService) GetNonce(req *http.Request) (string, error) { @@ -87,14 +88,21 @@ func (s *InMemoryNonceService) GetNonce(req *http.Request) (string, error) { return nonce, nil } -func (s *InMemoryNonceService) Provided(res http.ResponseWriter, - req *http.Request) (bool, error) { - nonce := req.Header.Get("nonce") +func (s *InMemoryNonceService) IsNonced(r *http.Request) bool { + if strings.Contains(r.URL.String(), "new-nonce") { + return false + } + return true +} + +func (s *InMemoryNonceService) IsProvided(w http.ResponseWriter, + r *http.Request) error { + nonce := r.Header.Get("nonce") if nonce == "" { - res.WriteHeader(http.StatusForbidden) - return false, nil + w.WriteHeader(http.StatusForbidden) + return nil } - return true, nil + return nil } type NoncedHandler struct { @@ -102,55 +110,52 @@ type NoncedHandler struct { service NonceService } -func NewNoncedServeMux(t *testing.T) *http.ServeMux { - s := &InMemoryNonceService{ - nonceMap: make(map[string]*interface{}), - t: t, - } - nonced := &NoncedHandler{ - service: s, - } - h := http.NewServeMux() - h.HandleFunc("/new-nonce", nonced.getNonce) - h.HandleFunc("/do-nonced-something", - NoncedHandlerFunc(s, nonced.doNoncedFunc)) - return h -} - -func (h *NoncedHandler) getNonce(res http.ResponseWriter, req *http.Request) { - method := req.Method +func (h *NoncedHandler) getNonce(w http.ResponseWriter, r *http.Request) { + method := r.Method if method != http.MethodHead { - res.WriteHeader(http.StatusMethodNotAllowed) + w.WriteHeader(http.StatusMethodNotAllowed) return } - nonce, err := h.service.GetNonce(req) + nonce, err := h.service.GetNonce(r) if err != nil { - res.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) return } - res.Header().Add("nonce", nonce) + w.Header().Add("nonce", nonce) } -func (h *NoncedHandler) doNoncedFunc(res http.ResponseWriter, req *http.Request) { - method := req.Method +func (h *NoncedHandler) doNoncedFunc(w http.ResponseWriter, r *http.Request) { + method := r.Method if method != http.MethodGet { - res.WriteHeader(http.StatusMethodNotAllowed) + w.WriteHeader(http.StatusMethodNotAllowed) return } - nonce := req.Header.Get("nonce") - res.Write([]byte("Func done with nonce " + nonce)) + nonce := r.Header.Get("nonce") + w.Write([]byte("Func done with nonce " + nonce)) +} + +func NewNoncedFuncServeMux(t *testing.T) *http.ServeMux { + s := &InMemoryNonceService{ + nonceMap: make(map[string]*interface{}), + t: t, + } + nonced := &NoncedHandler{ + service: s, + } + h := http.NewServeMux() + h.HandleFunc("/new-nonce", NoncedHandlerFunc(s, nonced.getNonce)) + h.HandleFunc("/do-nonced-something", + NoncedHandlerFunc(s, nonced.doNoncedFunc)) + return h } -func TestServer(t *testing.T) { - runner := testrunner.NewHttpTestRunner(t).WithHandler(NewNoncedServeMux(t)) +func TestNoncedFuncServer(t *testing.T) { + handler := NewNoncedFuncServeMux(t) + runner := testrunner.NewHttpTestRunner(t).WithHandler(handler) t.Run("Retrieve a new nonce", func(t *testing.T) { t.Run("Request OK", func(t *testing.T) { - res, err := runner.WithPath("/new-nonce").Get() - assert.Equal(t, "405 Method Not Allowed", res.Status) - assert.Equal(t, http.NoBody, res.Body) - - res, err = runner.WithPath("/new-nonce").Head() + res, err := runner.WithPath("/new-nonce").Head() if err != nil { t.Error(err) }