From 2a8a20195a233e49c797d7491e10f5dd790553b0 Mon Sep 17 00:00:00 2001 From: William Petit Date: Sun, 3 Feb 2019 20:56:58 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + Makefile | 30 ++++ README.md | 25 ++++ chi/mount.go | 16 ++ chi/mount_test.go | 35 +++++ client/client.go | 187 ++++++++++++++++++++++++ client/option.go | 56 +++++++ crypto/pem.go | 25 ++++ doc/sequence-diagram/advertise.seq | 2 + doc/sequence-diagram/advertise.svg | 35 +++++ doc/sequence-diagram/ping.seq | 2 + doc/sequence-diagram/ping.svg | 35 +++++ doc/sequence-diagram/update.seq | 2 + doc/sequence-diagram/update.svg | 36 +++++ go.mod | 7 + go.sum | 8 + memory/store.go | 153 +++++++++++++++++++ modd.conf | 8 + peer.go | 37 +++++ request.go | 24 +++ server/advertise_test.go | 158 ++++++++++++++++++++ server/handler.go | 227 +++++++++++++++++++++++++++++ server/middleware.go | 157 ++++++++++++++++++++ server/option.go | 60 ++++++++ server/util_test.go | 14 ++ store.go | 26 ++++ test/advertise_test.go | 56 +++++++ test/ping_test.go | 46 ++++++ test/update_test.go | 62 ++++++++ test/util_test.go | 64 ++++++++ 30 files changed, 1595 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 chi/mount.go create mode 100644 chi/mount_test.go create mode 100644 client/client.go create mode 100644 client/option.go create mode 100644 crypto/pem.go create mode 100644 doc/sequence-diagram/advertise.seq create mode 100644 doc/sequence-diagram/advertise.svg create mode 100644 doc/sequence-diagram/ping.seq create mode 100644 doc/sequence-diagram/ping.svg create mode 100644 doc/sequence-diagram/update.seq create mode 100644 doc/sequence-diagram/update.svg create mode 100644 go.mod create mode 100644 go.sum create mode 100644 memory/store.go create mode 100644 modd.conf create mode 100644 peer.go create mode 100644 request.go create mode 100644 server/advertise_test.go create mode 100644 server/handler.go create mode 100644 server/middleware.go create mode 100644 server/option.go create mode 100644 server/util_test.go create mode 100644 store.go create mode 100644 test/advertise_test.go create mode 100644 test/ping_test.go create mode 100644 test/update_test.go create mode 100644 test/util_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4662e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/coverage +/vendor \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4aa5ca5 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +test: + go clean -testcache + go test -cover -v ./... + +watch: + modd + +deps: + GO111MODULE=off go get -u golang.org/x/tools/cmd/godoc + GO111MODULE=off go get -u github.com/cortesi/modd/cmd/modd + GO111MODULE=off go get -u github.com/golangci/golangci-lint/cmd/golangci-lint + GO111MODULE=off go get -u github.com/lmika/goseq + +tidy: + go mod tidy + +lint: + golangci-lint run --tests=false --enable-all + +sequence-diagram: sd-advertise sd-update sd-ping + +sd-%: + goseq doc/sequence-diagram/$*.seq > doc/sequence-diagram/$*.svg + +doc: + @echo "open your browser to http://localhost:6060/pkg/forge.cadoles.com/wpetit/go-http-peering to see the documentation" + godoc -http=:6060 + + +.PHONY: test lint doc sequence-diagram \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfe3809 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# go-http-peering + +Librairie implémentant un protocole d'authentification par "appairage" d'un serveur et client HTTP basé sur [JWT](https://jwt.io/). + +[Documentation](https://godoc.org/forge.cadoles.com/wpetit/go-http-peering) + +## Séquences + +### Annonce du client + +![](./doc/sequence-diagram/advertise.svg) + +### Mise à jour des attributs + +![](./doc/sequence-diagram/update.svg) + +### Ping + +![](./doc/sequence-diagram/ping.svg) + +**Statut** Instable + +## Licence + +AGPL-3.0 \ No newline at end of file diff --git a/chi/mount.go b/chi/mount.go new file mode 100644 index 0000000..7751c6a --- /dev/null +++ b/chi/mount.go @@ -0,0 +1,16 @@ +package chi + +import ( + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/server" + "github.com/go-chi/chi" +) + +func Mount(r chi.Router, store peering.Store, funcs ...server.OptionFunc) { + r.Post(peering.AdvertisePath, server.AdvertiseHandler(store, funcs...)) + r.Group(func(r chi.Router) { + r.Use(server.Authenticate(store, funcs...)) + r.Post(peering.UpdatePath, server.UpdateHandler(store, funcs...)) + r.Post(peering.PingPath, server.PingHandler(store, funcs...)) + }) +} diff --git a/chi/mount_test.go b/chi/mount_test.go new file mode 100644 index 0000000..f153b8e --- /dev/null +++ b/chi/mount_test.go @@ -0,0 +1,35 @@ +package chi + +import ( + "testing" + + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/memory" + "github.com/go-chi/chi" +) + +func TestMount(t *testing.T) { + + r := chi.NewRouter() + store := memory.NewStore() + + Mount(r, store) + + routes := r.Routes() + + if g, e := len(routes), 3; g != e { + t.Errorf("len(r.Routes()): got '%v', expected '%v'", g, e) + } + + if g, e := routes[0].Pattern, peering.AdvertisePath; g != e { + t.Errorf("routes[0].Pattern: got '%v', expected '%v'", g, e) + } + + if g, e := routes[1].Pattern, peering.PingPath; g != e { + t.Errorf("routes[1].Pattern: got '%v', expected '%v'", g, e) + } + + if g, e := routes[2].Pattern, peering.UpdatePath; g != e { + t.Errorf("routes[2].Pattern: got '%v', expected '%v'", g, e) + } +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..339d18f --- /dev/null +++ b/client/client.go @@ -0,0 +1,187 @@ +package client + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/dgrijalva/jwt-go" + + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/crypto" + "forge.cadoles.com/wpetit/go-http-peering/server" +) + +const ( + DefaultBody = "{}" +) + +var ( + ErrInvalidPrivateKey = errors.New("invalid private key") + ErrUnexpectedResponse = errors.New("unexpected response") + ErrUnauthorized = errors.New("unauthorized") + ErrRejected = errors.New("rejected") +) + +type Client struct { + options *Options +} + +func (c *Client) Advertise(attrs peering.PeerAttributes) error { + url := c.options.BaseURL + peering.AdvertisePath + + publicKey, err := crypto.EncodePublicKeyToPEM(c.options.PrivateKey.Public()) + if err != nil { + return err + } + + data := &peering.AdvertisingRequest{ + ID: c.options.PeerID, + Attributes: attrs, + PublicKey: publicKey, + } + + req, _, err := c.createPostRequest(url, data) + if err != nil { + return err + } + + res, err := c.options.HTTPClient.Do(req) + + switch res.StatusCode { + case http.StatusCreated: + return nil + case http.StatusConflict: + return peering.ErrPeerExists + default: + return ErrUnexpectedResponse + } +} + +func (c *Client) UpdateAttributes(attrs peering.PeerAttributes) error { + url := c.options.BaseURL + peering.UpdatePath + + data := &peering.UpdateRequest{ + Attributes: attrs, + } + + res, err := c.Post(url, data) + if err != nil { + return err + } + + switch res.StatusCode { + case http.StatusNoContent: + return nil + case http.StatusUnauthorized: + return ErrUnauthorized + case http.StatusForbidden: + return ErrRejected + default: + return ErrUnexpectedResponse + } +} + +func (c *Client) Ping() error { + url := c.options.BaseURL + peering.PingPath + + res, err := c.Post(url, nil) + if err != nil { + return err + } + + switch res.StatusCode { + case http.StatusNoContent: + return nil + case http.StatusUnauthorized: + return ErrUnauthorized + case http.StatusForbidden: + return ErrRejected + default: + return ErrUnexpectedResponse + } +} + +func (c *Client) Get(url string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if err := c.signRequest(req, nil); err != nil { + return nil, err + } + return c.options.HTTPClient.Do(req) +} + +func (c *Client) Post(url string, data interface{}) (*http.Response, error) { + req, body, err := c.createPostRequest(url, data) + if err != nil { + return nil, err + } + if err := c.signRequest(req, body); err != nil { + return nil, err + } + return c.options.HTTPClient.Do(req) +} + +func (c *Client) createPostRequest(url string, data interface{}) (*http.Request, []byte, error) { + body, err := json.Marshal(data) + if err != nil { + return nil, nil, err + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + + req.Header.Set("Content-Type", "application/json") + + return req, body, nil +} + +func (c *Client) signRequest(r *http.Request, body []byte) error { + bodySum, err := c.createBodySum(body) + if err != nil { + return err + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, peering.PeerClaims{ + StandardClaims: jwt.StandardClaims{ + NotBefore: time.Now().Unix(), + Issuer: string(c.options.PeerID), + ExpiresAt: time.Now().Add(time.Minute * 10).Unix(), + }, + BodySum: bodySum, + }) + + tokenStr, err := token.SignedString(c.options.PrivateKey) + if err != nil { + return err + } + + r.Header.Set("Authorization", fmt.Sprintf("%s %s", server.AuthorizationType, tokenStr)) + + return nil +} + +func (c *Client) createBodySum(body []byte) ([]byte, error) { + if body == nil || len(body) == 0 { + body = []byte(DefaultBody) + } + sha := sha256.New() + _, err := sha.Write(body) + if err != nil { + return nil, err + } + return sha.Sum(nil), nil +} + +func New(funcs ...OptionFunc) *Client { + options := createOptions(funcs...) + return &Client{options} +} diff --git a/client/option.go b/client/option.go new file mode 100644 index 0000000..3961a43 --- /dev/null +++ b/client/option.go @@ -0,0 +1,56 @@ +package client + +import ( + "crypto/rsa" + "net/http" + + peering "forge.cadoles.com/wpetit/go-http-peering" +) + +type Options struct { + PeerID peering.PeerID + HTTPClient *http.Client + BaseURL string + PrivateKey *rsa.PrivateKey +} + +type OptionFunc func(*Options) + +func WithPeerID(id peering.PeerID) OptionFunc { + return func(opts *Options) { + opts.PeerID = id + } +} + +func WithPrivateKey(pk *rsa.PrivateKey) OptionFunc { + return func(opts *Options) { + opts.PrivateKey = pk + } +} + +func WithHTTPClient(client *http.Client) OptionFunc { + return func(opts *Options) { + opts.HTTPClient = client + } +} + +func WithBaseURL(url string) OptionFunc { + return func(opts *Options) { + opts.BaseURL = url + } +} + +func defaultOptions() *Options { + return &Options{ + HTTPClient: http.DefaultClient, + PeerID: peering.NewPeerID(), + } +} + +func createOptions(funcs ...OptionFunc) *Options { + options := defaultOptions() + for _, fn := range funcs { + fn(options) + } + return options +} diff --git a/crypto/pem.go b/crypto/pem.go new file mode 100644 index 0000000..eeb398d --- /dev/null +++ b/crypto/pem.go @@ -0,0 +1,25 @@ +package crypto + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + + jwt "github.com/dgrijalva/jwt-go" +) + +func EncodePublicKeyToPEM(key interface{}) ([]byte, error) { + pub, err := x509.MarshalPKIXPublicKey(key) + if err != nil { + return nil, err + } + data := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pub, + }) + return data, nil +} + +func DecodePEMToPublicKey(pem []byte) (*rsa.PublicKey, error) { + return jwt.ParseRSAPublicKeyFromPEM(pem) +} diff --git a/doc/sequence-diagram/advertise.seq b/doc/sequence-diagram/advertise.seq new file mode 100644 index 0000000..0c52219 --- /dev/null +++ b/doc/sequence-diagram/advertise.seq @@ -0,0 +1,2 @@ +Client -> Server: POST /advertise\n\n{"ID": , "Attributes": , "PublicKey": } +Server -> Client: 201 Created \ No newline at end of file diff --git a/doc/sequence-diagram/advertise.svg b/doc/sequence-diagram/advertise.svg new file mode 100644 index 0000000..ae856ae --- /dev/null +++ b/doc/sequence-diagram/advertise.svg @@ -0,0 +1,35 @@ + + + + + + + + +Client + +Client + + +Server + +Server + +POST /advertise +{"ID": <PEER_ID>, "Attributes": <PEER_ATTRIBUTES>, "PublicKey": <PUBLIC_KEY> } + + + +201 Created + + + diff --git a/doc/sequence-diagram/ping.seq b/doc/sequence-diagram/ping.seq new file mode 100644 index 0000000..22e7153 --- /dev/null +++ b/doc/sequence-diagram/ping.seq @@ -0,0 +1,2 @@ +Client -> Server: POST /ping\nAuthorization: Bearer +Server -> Client: 204 No Content \ No newline at end of file diff --git a/doc/sequence-diagram/ping.svg b/doc/sequence-diagram/ping.svg new file mode 100644 index 0000000..6c01e44 --- /dev/null +++ b/doc/sequence-diagram/ping.svg @@ -0,0 +1,35 @@ + + + + + + + + +Client + +Client + + +Server + +Server + +POST /ping +Authorization: Bearer <JWT_SIGNING_TOKEN> + + + +204 No Content + + + diff --git a/doc/sequence-diagram/update.seq b/doc/sequence-diagram/update.seq new file mode 100644 index 0000000..652f0af --- /dev/null +++ b/doc/sequence-diagram/update.seq @@ -0,0 +1,2 @@ +Client -> Server: POST /update\nAuthorization: Bearer \n\n{"Attributes": } +Server -> Client: 204 No Content \ No newline at end of file diff --git a/doc/sequence-diagram/update.svg b/doc/sequence-diagram/update.svg new file mode 100644 index 0000000..7f29b61 --- /dev/null +++ b/doc/sequence-diagram/update.svg @@ -0,0 +1,36 @@ + + + + + + + + +Client + +Client + + +Server + +Server + +POST /update +Authorization: Bearer <JWT_SIGNING_TOKEN> +{"Attributes": <PEER_ATTRIBUTES>} + + + +204 No Content + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28ef76b --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module forge.cadoles.com/wpetit/go-http-peering + +require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/go-chi/chi v4.0.1+incompatible + github.com/pborman/uuid v1.2.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1e1c9a8 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/go-chi/chi v4.0.1+incompatible h1:RSRC5qmFPtO90t7pTL0DBMNpZFsb/sHF3RXVlDgFisA= +github.com/go-chi/chi v4.0.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= diff --git a/memory/store.go b/memory/store.go new file mode 100644 index 0000000..37e24b6 --- /dev/null +++ b/memory/store.go @@ -0,0 +1,153 @@ +package memory + +import ( + "sync" + "time" + + peering "forge.cadoles.com/wpetit/go-http-peering" +) + +type Store struct { + peers map[peering.PeerID]*peering.Peer + mutex sync.RWMutex +} + +func (s *Store) Create(id peering.PeerID, attrs peering.PeerAttributes) (*peering.Peer, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if _, exists := s.peers[id]; exists { + return nil, peering.ErrPeerExists + } + + peer := &peering.Peer{ + PeerHeader: peering.PeerHeader{ + ID: id, + Status: peering.StatusPending, + }, + Attributes: attrs, + } + + s.peers[id] = peer + + return copy(peer), nil +} + +func (s *Store) Get(id peering.PeerID) (*peering.Peer, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + peer, exists := s.peers[id] + if !exists { + return nil, peering.ErrPeerNotFound + } + + return copy(peer), nil +} + +func (s *Store) List() ([]peering.PeerHeader, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + headers := make([]peering.PeerHeader, len(s.peers)) + i := 0 + for _, p := range s.peers { + headers[i] = p.PeerHeader + i++ + } + + return headers, nil +} + +func (s *Store) UpdateAttributes(id peering.PeerID, attrs peering.PeerAttributes) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + peer, exists := s.peers[id] + if !exists { + return peering.ErrPeerNotFound + } + + peer.Attributes = attrs + + return nil +} + +func (s *Store) UpdateLastContact(id peering.PeerID, remoteAddress string, ts time.Time) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + peer, exists := s.peers[id] + if !exists { + return peering.ErrPeerNotFound + } + + peer.LastAddress = remoteAddress + peer.LastContact = ts + + return nil +} + +func (s *Store) UpdatePublicKey(id peering.PeerID, publicKey []byte) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + peer, exists := s.peers[id] + if !exists { + return peering.ErrPeerNotFound + } + + peer.PublicKey = publicKey + + return nil +} + +func (s *Store) Delete(id peering.PeerID) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + if _, exists := s.peers[id]; !exists { + return peering.ErrPeerNotFound + } + + delete(s.peers, id) + + return nil +} + +func (s *Store) Accept(id peering.PeerID) error { + return s.updateStatus(id, peering.StatusPeered) +} + +func (s *Store) Forget(id peering.PeerID) error { + return s.updateStatus(id, peering.StatusPending) +} + +func (s *Store) Reject(id peering.PeerID) error { + return s.updateStatus(id, peering.StatusRejected) +} + +func (s *Store) updateStatus(id peering.PeerID, status peering.PeerStatus) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + peer, exists := s.peers[id] + if !exists { + return peering.ErrPeerNotFound + } + + peer.Status = status + + return nil +} + +func NewStore() *Store { + return &Store{ + peers: make(map[peering.PeerID]*peering.Peer), + } +} + +func copy(p *peering.Peer) *peering.Peer { + copy := *p + return © +} diff --git a/modd.conf b/modd.conf new file mode 100644 index 0000000..5702665 --- /dev/null +++ b/modd.conf @@ -0,0 +1,8 @@ +**/*.go +!vendor/**.go { + prep: make test +} + +doc/sequence-diagram/*.seq { + prep: make sequence-diagram +} \ No newline at end of file diff --git a/peer.go b/peer.go new file mode 100644 index 0000000..71b19e2 --- /dev/null +++ b/peer.go @@ -0,0 +1,37 @@ +package peering + +import ( + "time" + + "github.com/pborman/uuid" +) + +type PeerID string + +func NewPeerID() PeerID { + id := uuid.New() + return PeerID(id) +} + +type PeerStatus int + +const ( + StatusPending PeerStatus = iota + StatusPeered + StatusRejected +) + +type PeerHeader struct { + ID PeerID + Status PeerStatus + LastAddress string + LastContact time.Time +} + +type PeerAttributes map[string]interface{} + +type Peer struct { + PeerHeader + Attributes PeerAttributes + PublicKey []byte +} diff --git a/request.go b/request.go new file mode 100644 index 0000000..a1a9cd9 --- /dev/null +++ b/request.go @@ -0,0 +1,24 @@ +package peering + +import jwt "github.com/dgrijalva/jwt-go" + +const ( + AdvertisePath = "/advertise" + UpdatePath = "/update" + PingPath = "/ping" +) + +type AdvertisingRequest struct { + ID PeerID + Attributes PeerAttributes + PublicKey []byte +} + +type UpdateRequest struct { + Attributes PeerAttributes +} + +type PeerClaims struct { + jwt.StandardClaims + BodySum []byte `json:"bodySum"` +} diff --git a/server/advertise_test.go b/server/advertise_test.go new file mode 100644 index 0000000..46de11c --- /dev/null +++ b/server/advertise_test.go @@ -0,0 +1,158 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.cadoles.com/wpetit/go-http-peering/crypto" + + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/memory" +) + +func TestAdvertiseHandlerBadRequest(t *testing.T) { + store := memory.NewStore() + handler := AdvertiseHandler(store) + + req := httptest.NewRequest("POST", peering.AdvertisePath, nil) + w := httptest.NewRecorder() + + handler(w, req) + + res := w.Result() + + if g, e := res.StatusCode, http.StatusBadRequest; g != e { + t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e) + } + + peers, err := store.List() + if err != nil { + t.Fatal(err) + } + + if g, e := len(peers), 0; g != e { + t.Errorf("len(peers): got '%v', expected '%v'", g, e) + } +} + +func TestAdvertiseHandlerInvalidPublicKeyFormat(t *testing.T) { + store := memory.NewStore() + handler := AdvertiseHandler(store) + + advertising := &peering.AdvertisingRequest{ + ID: peering.NewPeerID(), + PublicKey: []byte("Test"), + } + + body, err := json.Marshal(advertising) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body)) + w := httptest.NewRecorder() + + handler(w, req) + + res := w.Result() + + if g, e := res.StatusCode, http.StatusBadRequest; g != e { + t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e) + } + + peers, err := store.List() + if err != nil { + t.Fatal(err) + } + + if g, e := len(peers), 0; g != e { + t.Errorf("len(peers): got '%v', expected '%v'", g, e) + } +} + +func TestAdvertiseHandlerExistingPeer(t *testing.T) { + store := memory.NewStore() + handler := AdvertiseHandler(store) + + pk := mustGeneratePrivateKey() + pem, err := crypto.EncodePublicKeyToPEM(pk.Public()) + if err != nil { + t.Fatal(err) + } + + peerID := peering.NewPeerID() + + advertising := &peering.AdvertisingRequest{ + ID: peerID, + PublicKey: pem, + } + + body, err := json.Marshal(advertising) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body)) + w := httptest.NewRecorder() + + handler(w, req) + + req = httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body)) + w = httptest.NewRecorder() + + handler(w, req) + + res := w.Result() + + if g, e := res.StatusCode, http.StatusConflict; g != e { + t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e) + } + +} + +func TestAdvertiseHandlerValidRequest(t *testing.T) { + store := memory.NewStore() + handler := AdvertiseHandler(store) + + pk := mustGeneratePrivateKey() + pem, err := crypto.EncodePublicKeyToPEM(pk.Public()) + if err != nil { + t.Fatal(err) + } + + peerID := peering.NewPeerID() + + advertising := &peering.AdvertisingRequest{ + ID: peerID, + PublicKey: pem, + } + + body, err := json.Marshal(advertising) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body)) + w := httptest.NewRecorder() + + handler(w, req) + + res := w.Result() + + if g, e := res.StatusCode, http.StatusCreated; g != e { + t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e) + } + + peer, err := store.Get(peerID) + if err != nil { + t.Fatal(err) + } + + if g, e := peer.PublicKey, advertising.PublicKey; !bytes.Equal(peer.PublicKey, advertising.PublicKey) { + t.Errorf("peer.PublicKey: got '%v', expected '%v'", g, e) + } + +} diff --git a/server/handler.go b/server/handler.go new file mode 100644 index 0000000..b07d50a --- /dev/null +++ b/server/handler.go @@ -0,0 +1,227 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/crypto" +) + +var ( + ErrInvalidAdvertisingRequest = errors.New("invalid advertising request") + ErrInvalidUpdateRequest = errors.New("invalid update request") + ErrPeerRejected = errors.New("peer rejected") + ErrPeerIDAlreadyInUse = errors.New("peer id already in use") + ErrUnauthorized = errors.New("unauthorized") +) + +func AdvertiseHandler(store peering.Store, funcs ...OptionFunc) http.HandlerFunc { + + options := createOptions(funcs...) + logger := options.Logger + + handler := func(w http.ResponseWriter, r *http.Request) { + advertising := &peering.AdvertisingRequest{} + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(advertising); err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, ErrInvalidAdvertisingRequest) + return + } + + if !options.PeerIDValidator(advertising.ID) { + logger.Printf("[ERROR] %s", ErrInvalidAdvertisingRequest) + options.ErrorHandler(w, r, ErrInvalidAdvertisingRequest) + return + } + + if _, err := crypto.DecodePEMToPublicKey(advertising.PublicKey); err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, ErrInvalidAdvertisingRequest) + return + } + + peer, err := store.Get(advertising.ID) + + if err == nil { + logger.Printf("[ERROR] %s", ErrPeerIDAlreadyInUse) + options.ErrorHandler(w, r, ErrPeerIDAlreadyInUse) + return + } + + if err != peering.ErrPeerNotFound { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + attrs := filterAttributes(options.PeerAttributes, advertising.Attributes) + + peer, err = store.Create(advertising.ID, attrs) + if err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + if err := store.UpdateLastContact(peer.ID, r.RemoteAddr, time.Now()); err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + if err := store.UpdatePublicKey(peer.ID, advertising.PublicKey); err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + w.WriteHeader(http.StatusCreated) + + } + + return handler +} + +func UpdateHandler(store peering.Store, funcs ...OptionFunc) http.HandlerFunc { + options := createOptions(funcs...) + logger := options.Logger + + handler := func(w http.ResponseWriter, r *http.Request) { + + update := &peering.UpdateRequest{} + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(update); err != nil { + options.ErrorHandler(w, r, ErrInvalidUpdateRequest) + return + } + + peerID, err := GetPeerID(r) + if err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + peer, err := store.Get(peerID) + if err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + if peer == nil { + logger.Printf("[ERROR] %s", ErrUnauthorized) + options.ErrorHandler(w, r, ErrUnauthorized) + return + } + + if peer.Status == peering.StatusRejected { + logger.Printf("[ERROR] %s", ErrPeerRejected) + options.ErrorHandler(w, r, ErrPeerRejected) + return + } + + if err := store.UpdateLastContact(peer.ID, r.RemoteAddr, time.Now()); err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + attrs := filterAttributes(options.PeerAttributes, update.Attributes) + if err := store.UpdateAttributes(peer.ID, attrs); err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + w.WriteHeader(http.StatusNoContent) + + } + + return handler +} + +func PingHandler(store peering.Store, funcs ...OptionFunc) http.HandlerFunc { + options := createOptions(funcs...) + logger := options.Logger + + handler := func(w http.ResponseWriter, r *http.Request) { + + update := &peering.UpdateRequest{} + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(update); err != nil { + options.ErrorHandler(w, r, ErrInvalidUpdateRequest) + return + } + + peerID, err := GetPeerID(r) + if err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + peer, err := store.Get(peerID) + if err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + if peer == nil { + logger.Printf("[ERROR] %s", ErrUnauthorized) + options.ErrorHandler(w, r, ErrUnauthorized) + return + } + + if peer.Status == peering.StatusRejected { + logger.Printf("[ERROR] %s", ErrPeerRejected) + options.ErrorHandler(w, r, ErrPeerRejected) + return + } + + if err := store.UpdateLastContact(peer.ID, r.RemoteAddr, time.Now()); err != nil { + logger.Printf("[ERROR] %s", err) + options.ErrorHandler(w, r, err) + return + } + + w.WriteHeader(http.StatusNoContent) + } + + return handler +} + +func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + switch err { + case ErrInvalidAdvertisingRequest: + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + case ErrPeerIDAlreadyInUse: + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + case ErrUnauthorized: + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + case ErrPeerRejected: + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + default: + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +func DefaultPeerIDValidator(id peering.PeerID) bool { + return string(id) != "" +} + +func filterAttributes(filters []string, attrs peering.PeerAttributes) peering.PeerAttributes { + filtered := peering.PeerAttributes{} + for _, key := range filters { + if _, exists := attrs[key]; exists { + filtered[key] = attrs[key] + } + } + return filtered +} diff --git a/server/middleware.go b/server/middleware.go new file mode 100644 index 0000000..68a8f6f --- /dev/null +++ b/server/middleware.go @@ -0,0 +1,157 @@ +package server + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "io/ioutil" + "strings" + "time" + + "forge.cadoles.com/wpetit/go-http-peering/crypto" + + peering "forge.cadoles.com/wpetit/go-http-peering" + jwt "github.com/dgrijalva/jwt-go" + + "net/http" +) + +const ( + AuthorizationType = "Bearer" + KeyPeerID ContextKey = "peerID" +) + +var ( + ErrInvalidClaims = errors.New("invalid claims") + ErrInvalidChecksum = errors.New("invalid checksum") + ErrNotPeered = errors.New("not peered") +) + +type ContextKey string + +func Authenticate(store peering.Store, funcs ...OptionFunc) func(http.Handler) http.Handler { + options := createOptions(funcs...) + logger := options.Logger + + middleware := func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + authorization := r.Header.Get("Authorization") + + if authorization == "" { + sendError(w, http.StatusUnauthorized) + return + } + + parts := strings.SplitN(authorization, " ", 2) + + if len(parts) != 2 || parts[0] != AuthorizationType { + sendError(w, http.StatusUnauthorized) + return + } + + token, err := jwt.ParseWithClaims(parts[1], &peering.PeerClaims{}, func(token *jwt.Token) (interface{}, error) { + claims, ok := token.Claims.(*peering.PeerClaims) + if !ok { + return nil, ErrInvalidClaims + } + peerID := peering.PeerID(claims.Issuer) + peer, err := store.Get(peerID) + if err != nil { + return nil, err + } + if peer.Status == peering.StatusRejected { + return nil, ErrPeerRejected + } + if peer.Status != peering.StatusPeered { + return nil, ErrNotPeered + } + publicKey, err := crypto.DecodePEMToPublicKey(peer.PublicKey) + if err != nil { + return nil, err + } + return publicKey, nil + }) + if err != nil || !token.Valid { + logger.Printf("[ERROR] %s", err) + if err == ErrPeerRejected { + sendError(w, http.StatusForbidden) + } else { + sendError(w, http.StatusUnauthorized) + } + return + } + + claims, ok := token.Claims.(*peering.PeerClaims) + if !ok { + logger.Printf("[ERROR] %s", ErrInvalidClaims) + sendError(w, http.StatusUnauthorized) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + logger.Printf("[ERROR] %s", err) + sendError(w, http.StatusInternalServerError) + return + } + + if err := r.Body.Close(); err != nil { + logger.Printf("[ERROR] %s", err) + sendError(w, http.StatusInternalServerError) + return + } + + match, err := compareChecksum(body, claims.BodySum) + if err != nil { + logger.Printf("[ERROR] %s", err) + sendError(w, http.StatusUnauthorized) + return + } + + if !match { + logger.Printf("[ERROR] %s", ErrInvalidChecksum) + sendError(w, http.StatusBadRequest) + return + } + + peerID := peering.PeerID(claims.Issuer) + + if err := store.UpdateLastContact(peerID, r.RemoteAddr, time.Now()); err != nil { + logger.Printf("[ERROR] %s", err) + sendError(w, http.StatusInternalServerError) + return + } + + ctx := context.WithValue(r.Context(), KeyPeerID, peerID) + r = r.WithContext(ctx) + r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + + next.ServeHTTP(w, r) + + } + return http.HandlerFunc(fn) + } + return middleware +} + +func GetPeerID(r *http.Request) (peering.PeerID, error) { + peerID, ok := r.Context().Value(KeyPeerID).(peering.PeerID) + if !ok { + return "", ErrUnauthorized + } + return peerID, nil +} + +func sendError(w http.ResponseWriter, status int) { + http.Error(w, http.StatusText(status), status) +} + +func compareChecksum(body []byte, sum []byte) (bool, error) { + sha := sha256.New() + _, err := sha.Write(body) + if err != nil { + return false, err + } + return bytes.Equal(sum, sha.Sum(nil)), nil +} diff --git a/server/option.go b/server/option.go new file mode 100644 index 0000000..376cb10 --- /dev/null +++ b/server/option.go @@ -0,0 +1,60 @@ +package server + +import ( + "log" + "net/http" + "os" + + peering "forge.cadoles.com/wpetit/go-http-peering" +) + +type Logger interface { + Printf(string, ...interface{}) +} + +type Options struct { + PeerAttributes []string + ErrorHandler ErrorHandler + PeerIDValidator func(peering.PeerID) bool + Logger Logger +} + +type OptionFunc func(*Options) + +type ErrorHandler func(http.ResponseWriter, *http.Request, error) + +func WithPeerAttributes(attrs ...string) OptionFunc { + return func(options *Options) { + options.PeerAttributes = attrs + } +} + +func WithLogger(logger Logger) OptionFunc { + return func(options *Options) { + options.Logger = logger + } +} + +func WithErrorHandler(handler ErrorHandler) OptionFunc { + return func(options *Options) { + options.ErrorHandler = handler + } +} + +func defaultOptions() *Options { + logger := log.New(os.Stdout, "[go-http-peering] ", log.LstdFlags|log.Lshortfile) + return &Options{ + PeerAttributes: []string{"Label"}, + ErrorHandler: DefaultErrorHandler, + PeerIDValidator: DefaultPeerIDValidator, + Logger: logger, + } +} + +func createOptions(funcs ...OptionFunc) *Options { + options := defaultOptions() + for _, fn := range funcs { + fn(options) + } + return options +} diff --git a/server/util_test.go b/server/util_test.go new file mode 100644 index 0000000..c93a369 --- /dev/null +++ b/server/util_test.go @@ -0,0 +1,14 @@ +package server + +import ( + "crypto/rand" + "crypto/rsa" +) + +func mustGeneratePrivateKey() *rsa.PrivateKey { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + return privateKey +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..77807b8 --- /dev/null +++ b/store.go @@ -0,0 +1,26 @@ +package peering + +import ( + "errors" + "time" +) + +var ( + ErrPeerNotFound = errors.New("peer not found") + ErrPeerExists = errors.New("peer exists") +) + +type Store interface { + Create(id PeerID, attrs PeerAttributes) (*Peer, error) + Get(id PeerID) (*Peer, error) + Delete(id PeerID) error + List() ([]PeerHeader, error) + + UpdatePublicKey(id PeerID, publicKey []byte) error + UpdateAttributes(id PeerID, attrs PeerAttributes) error + UpdateLastContact(id PeerID, remoteAddress string, ts time.Time) error + + Accept(id PeerID) error + Forget(id PeerID) error + Reject(id PeerID) error +} diff --git a/test/advertise_test.go b/test/advertise_test.go new file mode 100644 index 0000000..c24c3ea --- /dev/null +++ b/test/advertise_test.go @@ -0,0 +1,56 @@ +package test + +import ( + "reflect" + "testing" + "time" + + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/crypto" +) + +func TestAdvertise(t *testing.T) { + + if t.Skipped() { + t.SkipNow() + } + + id, pk, client, store := setup(t) + + attrs := peering.PeerAttributes{} + if err := client.Advertise(attrs); err != nil { + t.Fatal(err) + } + + peer, err := store.Get(id) + if err != nil { + t.Error(err) + } + + if g, e := peer.ID, id; g != e { + t.Errorf("peer.ID: got '%v', expected '%v'", g, e) + } + + if g, e := peer.Attributes, attrs; !reflect.DeepEqual(g, e) { + t.Errorf("peer.Attributes: got '%v', expected '%v'", g, e) + } + + var defaultTime time.Time + if peer.LastContact == defaultTime { + t.Error("peer.LastContact should not be time.Time zero value") + } + + if peer.LastAddress == "" { + t.Error("peer.LastAddress should not be empty") + } + + pem, err := crypto.EncodePublicKeyToPEM(pk.Public()) + if err != nil { + t.Fatal(err) + } + + if g, e := peer.PublicKey, pem; !reflect.DeepEqual(g, e) { + t.Errorf("peer.PublicKey: got '%v', expected '%v'", g, e) + } + +} diff --git a/test/ping_test.go b/test/ping_test.go new file mode 100644 index 0000000..5a86f45 --- /dev/null +++ b/test/ping_test.go @@ -0,0 +1,46 @@ +package test + +import ( + "testing" + + peering "forge.cadoles.com/wpetit/go-http-peering" +) + +func TestPing(t *testing.T) { + + if t.Skipped() { + t.SkipNow() + } + + id, _, client, store := setup(t) + + attrs := peering.PeerAttributes{} + if err := client.Advertise(attrs); err != nil { + t.Fatal(err) + } + + peer, err := store.Get(id) + if err != nil { + t.Fatal(err) + } + + lastContact := peer.LastContact + + if err := store.Accept(id); err != nil { + t.Error(err) + } + + if err := client.Ping(); err != nil { + t.Fatal(err) + } + + peer, err = store.Get(id) + if err != nil { + t.Fatal(err) + } + + if peer.LastContact == lastContact { + t.Error("peer.LastContact should have been updated") + } + +} diff --git a/test/update_test.go b/test/update_test.go new file mode 100644 index 0000000..e940edb --- /dev/null +++ b/test/update_test.go @@ -0,0 +1,62 @@ +package test + +import ( + "reflect" + "testing" + "time" + + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/crypto" +) + +func TestUpdate(t *testing.T) { + + if t.Skipped() { + t.SkipNow() + } + + id, pk, client, store := setup(t) + + attrs := peering.PeerAttributes{} + + if err := client.Advertise(attrs); err != nil { + t.Fatal(err) + } + + if err := store.Accept(id); err != nil { + t.Error(err) + } + + attrs["Label"] = "Foo Bar" + if err := client.UpdateAttributes(attrs); err != nil { + t.Fatal(err) + } + + peer, err := store.Get(id) + if err != nil { + t.Fatal(err) + } + + if g, e := peer.ID, id; g != e { + t.Errorf("peer.ID: got '%v', expected '%v'", g, e) + } + + if g, e := peer.Attributes, attrs; !reflect.DeepEqual(g, e) { + t.Errorf("peer.Attributes: got '%v', expected '%v'", g, e) + } + + var defaultTime time.Time + if peer.LastContact == defaultTime { + t.Error("peer.LastContact should not be time.Time zero value") + } + + pem, err := crypto.EncodePublicKeyToPEM(pk.Public()) + if err != nil { + t.Fatal(err) + } + + if g, e := peer.PublicKey, pem; !reflect.DeepEqual(g, e) { + t.Errorf("peer.PublicKey: got '%v', expected '%v'", g, e) + } + +} diff --git a/test/util_test.go b/test/util_test.go new file mode 100644 index 0000000..dfd7978 --- /dev/null +++ b/test/util_test.go @@ -0,0 +1,64 @@ +package test + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "net" + "net/http" + "testing" + + peering "forge.cadoles.com/wpetit/go-http-peering" + "forge.cadoles.com/wpetit/go-http-peering/client" + "forge.cadoles.com/wpetit/go-http-peering/memory" + "forge.cadoles.com/wpetit/go-http-peering/server" +) + +func mustGeneratePrivateKey() *rsa.PrivateKey { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + return privateKey +} + +func startServer(store peering.Store) (int, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return -1, err + } + mux := createServerMux(store) + go http.Serve(listener, mux) + port := listener.Addr().(*net.TCPAddr).Port + return port, nil +} + +func createServerMux(store peering.Store) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc(peering.AdvertisePath, server.AdvertiseHandler(store)) + update := server.Authenticate(store)(server.UpdateHandler(store)) + mux.Handle(peering.UpdatePath, update) + ping := server.Authenticate(store)(server.PingHandler(store)) + mux.Handle(peering.PingPath, ping) + return mux +} + +func setup(t *testing.T) (peering.PeerID, *rsa.PrivateKey, *client.Client, peering.Store) { + store := memory.NewStore() + + port, err := startServer(store) + if err != nil { + t.Fatal(err) + } + + pk := mustGeneratePrivateKey() + id := peering.NewPeerID() + + c := client.New( + client.WithBaseURL(fmt.Sprintf("http://127.0.0.1:%d", port)), + client.WithPrivateKey(pk), + client.WithPeerID(id), + ) + + return id, pk, c, store +}