diff options
| author | Grégoire Duchêne <gduchene@awhk.org> | 2021-03-14 15:41:22 +0000 |
|---|---|---|
| committer | Grégoire Duchêne <gduchene@awhk.org> | 2021-03-14 15:41:22 +0000 |
| commit | ce3182d4c3f5fb723a16bece3157a5058b1236f9 (patch) | |
| tree | 858301133dd6a547e935da356f049ac165261c06 /cmd | |
| parent | d10731043bfd7429ebd22c717794c8363f462caf (diff) | |
Move Twilio authn code into its own package
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/fwdsms/config.go | 2 | ||||
| -rw-r--r-- | cmd/fwdsms/mailer.go | 8 | ||||
| -rw-r--r-- | cmd/fwdsms/mailer_test.go | 13 | ||||
| -rw-r--r-- | cmd/fwdsms/main.go | 20 | ||||
| -rw-r--r-- | cmd/fwdsms/twilio.go | 115 | ||||
| -rw-r--r-- | cmd/fwdsms/twilio_test.go | 132 |
6 files changed, 32 insertions, 258 deletions
diff --git a/cmd/fwdsms/config.go b/cmd/fwdsms/config.go index 0a50901..4eb0fdf 100644 --- a/cmd/fwdsms/config.go +++ b/cmd/fwdsms/config.go @@ -30,7 +30,7 @@ type SMTP struct { type Twilio struct { Address string `yaml:"address"` - AuthToken string `yaml:"authToken"` + AuthToken []byte `yaml:"authToken"` Endpoint string `yaml:"endpoint"` } diff --git a/cmd/fwdsms/mailer.go b/cmd/fwdsms/mailer.go index b61c981..f8c1db6 100644 --- a/cmd/fwdsms/mailer.go +++ b/cmd/fwdsms/mailer.go @@ -13,6 +13,8 @@ import ( "net/smtp" "text/template" "time" + + "go.awhk.org/fwdsms/pkg/twilio" ) type email struct { @@ -23,7 +25,7 @@ type email struct { type mailer struct { auth smtp.Auth hostname string - sms <-chan SMS + sms <-chan twilio.SMS tmplFrom, tmplTo, tmplMsg *template.Template } @@ -64,7 +66,7 @@ func (m *mailer) sendEmail(e email) error { return c.Quit() } -func (m *mailer) newEmail(sms SMS) email { +func (m *mailer) newEmail(sms twilio.SMS) email { var from, to, msg bytes.Buffer if err := m.tmplFrom.Execute(&from, sms); err != nil { log.Printf("Failed to apply a template: %v.", err) @@ -91,7 +93,7 @@ func (m *mailer) start(ctx context.Context) { } } -func newMailer(cfg *Config, sms <-chan SMS) *mailer { +func newMailer(cfg *Config, sms <-chan twilio.SMS) *mailer { if cfg.Message.From == "" { log.Fatal("Missing From field.") } diff --git a/cmd/fwdsms/mailer_test.go b/cmd/fwdsms/mailer_test.go index b537ffb..e1376c3 100644 --- a/cmd/fwdsms/mailer_test.go +++ b/cmd/fwdsms/mailer_test.go @@ -9,6 +9,8 @@ import ( "time" "github.com/stretchr/testify/assert" + + "go.awhk.org/fwdsms/pkg/twilio" ) func TestMailer_newEmail(t *testing.T) { @@ -19,12 +21,17 @@ func TestMailer_newEmail(t *testing.T) { Subject: "New SMS From {{.From}}", Template: `From: {{.From}} To: {{.To}} -Date: {{.Date.UTC}} +Date: {{.DateReceived.UTC}} -{{.Message}}`, +{{.Body}}`, }}, nil) // Reserved phone numbers, see Ofcom's website. - sms := SMS{time.Unix(0, 0), "+442079460123", "+447700900123", "Hello World!"} + sms := twilio.SMS{ + DateReceived: time.Unix(0, 0), + From: "+442079460123", + To: "+447700900123", + Body: "Hello World!", + } wants := email{ from: "fwdsms@example.com", to: "sms+447700900123@example.com", diff --git a/cmd/fwdsms/main.go b/cmd/fwdsms/main.go index 47af33a..a810be1 100644 --- a/cmd/fwdsms/main.go +++ b/cmd/fwdsms/main.go @@ -14,7 +14,10 @@ import ( "time" "github.com/gorilla/handlers" + "github.com/gorilla/mux" "golang.org/x/sys/unix" + + "go.awhk.org/fwdsms/pkg/twilio" ) var cfgFilename = flag.String("c", "/etc/fwdsms.yaml", "configuration file") @@ -30,10 +33,19 @@ func main() { done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, unix.SIGTERM) - sms := make(chan SMS) - mux := http.NewServeMux() - mux.Handle(cfg.Twilio.Endpoint, handlers.ProxyHeaders(newSMSHandler(cfg, sms))) - srv := http.Server{Handler: mux} + sms := make(chan twilio.SMS) + + r := mux.NewRouter() + r.Path(cfg.Twilio.Endpoint). + Methods(http.MethodPost). + Handler(handlers.ProxyHeaders(&twilio.Filter{ + AuthToken: cfg.Twilio.AuthToken, + Handler: &twilio.SMSTee{ + Chan: sms, + Handler: twilio.EmptyResponseHandler, + }, + })) + srv := http.Server{Handler: r} go func() { var ( l net.Listener diff --git a/cmd/fwdsms/twilio.go b/cmd/fwdsms/twilio.go deleted file mode 100644 index f997465..0000000 --- a/cmd/fwdsms/twilio.go +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> -// SPDX-License-Identifier: ISC - -package main - -import ( - "crypto/hmac" - "crypto/sha1" - "encoding/base64" - "errors" - "fmt" - "hash" - "log" - "net/http" - "sort" - "strings" - "time" -) - -type SMS struct { - Date time.Time - From, To, Message string -} - -type smsHandler struct { - hash hash.Hash - sms chan SMS -} - -var _ http.Handler = &smsHandler{} - -func (h *smsHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - resp.Header().Set("Allow", http.MethodPost) - resp.WriteHeader(http.StatusMethodNotAllowed) - return - } - if err := h.checkRequestSignature(req); err != nil { - log.Printf("Failed to check the request signature: %v.", err) - resp.WriteHeader(http.StatusBadRequest) - return - } - - from, ok := req.PostForm["From"] - if !ok || len(from) == 0 { - resp.WriteHeader(http.StatusBadRequest) - return - } - to, ok := req.PostForm["To"] - if !ok || len(to) == 0 { - resp.WriteHeader(http.StatusBadRequest) - return - } - msg, ok := req.PostForm["Body"] - if !ok || len(msg) == 0 { - resp.WriteHeader(http.StatusBadRequest) - return - } - resp.Header().Set("Content-Type", "text/xml") - fmt.Fprintf(resp, "<Response/>") - h.sms <- SMS{time.Now(), from[0], to[0], msg[0]} -} - -func (h *smsHandler) checkRequestSignature(req *http.Request) error { - reqSig, err := func() ([]byte, error) { - h := req.Header.Get("X-Twilio-Signature") - if len(h) == 0 { - return nil, errors.New("missing X-Twilio-Signature header") - } - b, err := base64.StdEncoding.DecodeString(h) - if err != nil { - return nil, errors.New("bad X-Twilio-Signature header") - } - return b, nil - }() - if err != nil { - return err - } - - if err := req.ParseForm(); err != nil { - return err - } - ourSig := func() []byte { - defer h.hash.Reset() - parts := []string{} - for k := range req.PostForm { - parts = append(parts, k) - } - sort.Strings(parts) - for i := range parts { - parts[i] += req.PostForm[parts[i]][0] - } - blob := req.Host + req.URL.Path + strings.Join(parts, "") - if req.URL.Scheme != "" { - blob = fmt.Sprintf("%s://%s", req.URL.Scheme, blob) - } - h.hash.Write([]byte(blob)) - return h.hash.Sum(nil) - }() - - if !hmac.Equal(ourSig, reqSig) { - return errors.New("signature mismatch") - } - return nil -} - -func newSMSHandler(cfg *Config, sms chan SMS) *smsHandler { - if cfg.Twilio.AuthToken == "" { - log.Fatal("Twilio auth token unspecified.") - } - if cfg.Twilio.Endpoint == "" { - log.Fatal("Twilio endpoint unspecified.") - } - return &smsHandler{hmac.New(sha1.New, []byte(cfg.Twilio.AuthToken)), sms} -} diff --git a/cmd/fwdsms/twilio_test.go b/cmd/fwdsms/twilio_test.go deleted file mode 100644 index b17bf06..0000000 --- a/cmd/fwdsms/twilio_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-FileCopyrightText: © 2020 Grégoire Duchêne <gduchene@awhk.org> -// SPDX-License-Identifier: ISC - -package main - -import ( - "io/ioutil" - "net/http" - "net/url" - "strings" - "testing" - "time" - - "github.com/gorilla/handlers" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "go.awhk.org/pipeln" -) - -var handler = newSMSHandler(&Config{ - Twilio: Twilio{ - AuthToken: "token", - Endpoint: "/endpoint", - }, -}, make(chan SMS)) - -func TestSMSHandler_checkRequestSignature_MissingHeader(t *testing.T) { - req, err := http.NewRequest(http.MethodPost, "https://example.com/endpoint", nil) - assert.NoError(t, err) - assert.EqualError(t, handler.checkRequestSignature(req), "missing X-Twilio-Signature header") -} - -func TestSMSHandler_checkRequestSignature_SignatureBad(t *testing.T) { - form := url.Values{} - form.Set("foo", "bar") - req, err := http.NewRequest(http.MethodPost, "https://example.com/endpoint", strings.NewReader(form.Encode())) - assert.NoError(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-Twilio-Signature", "a bad signature") - assert.NoError(t, req.ParseForm()) - assert.EqualError(t, handler.checkRequestSignature(req), "bad X-Twilio-Signature header") -} - -func TestSMSHandler_checkRequestSignature_SignatureMismatch(t *testing.T) { - form := url.Values{} - form.Set("foo", "bar") - req, err := http.NewRequest(http.MethodPost, "https://example.com/endpoint", strings.NewReader(form.Encode())) - assert.NoError(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-Twilio-Signature", "DYIRnXpKIjrgAMxc0FD01B55+ag=") - assert.NoError(t, req.ParseForm()) - assert.EqualError(t, handler.checkRequestSignature(req), "signature mismatch") -} - -func TestSMSHandler_checkRequestSignature_SignatureGood(t *testing.T) { - form := url.Values{} - form.Set("foo", "bar") - form.Set("bar", "baz") - req, err := http.NewRequest(http.MethodPost, "/endpoint", strings.NewReader(form.Encode())) - req.Host = "example.com" - req.URL.Scheme = "https" - assert.NoError(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - // Signature generated with: - // % echo -n "https://example.com/endpointbarbazfoobar" | openssl dgst -binary -hmac "token" -sha1 | base64 - req.Header.Set("X-Twilio-Signature", "NpKVG88Z4y6ayJIxLJrzgEHeEwY=") - assert.NoError(t, req.ParseForm()) - assert.NoError(t, handler.checkRequestSignature(req)) -} - -func TestSMSHandler_EndToEnd(t *testing.T) { - mux := http.NewServeMux() - mux.Handle("/endpoint", handlers.ProxyHeaders(handler)) - srv := http.Server{Handler: mux} - ln := pipeln.New("localhost.test:80") - go srv.Serve(ln) - defer srv.Close() - - client := http.Client{Transport: &http.Transport{Dial: ln.Dial}} - form := url.Values{} - form.Set("From", "Foo") - form.Set("To", "Bar") - form.Set("Body", "Test") - req, err := http.NewRequest(http.MethodPost, "http://localhost.test:80/endpoint", strings.NewReader(form.Encode())) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-Forwarded-Scheme", "http") - - t.Run("Bad HTTP Method", func(t *testing.T) { - resp, err := client.Head("http://localhost.test:80/endpoint") - require.NoError(t, err) - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) - }) - - t.Run("Bad Signature", func(t *testing.T) { - req.Header.Set("X-Twilio-Signature", "DYIRnXpKIjrgAMxc0FD01B55+ag=") - resp, err := client.Do(req) - require.NoError(t, err) - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) - - t.Run("Good Signature", func(t *testing.T) { - done := make(chan struct{}) - - go func() { - defer close(done) - select { - case sms := <-handler.sms: - assert.Equal(t, "Foo", sms.From) - assert.Equal(t, "Bar", sms.To) - assert.Equal(t, "Test", sms.Message) - case <-time.After(time.Second): - t.Error("Timed out while waiting on handler.sms.") - } - }() - - // Signature generated with: - // % echo -n "http://localhost.test:80/endpointBodyTestFromFooToBar" | openssl dgst -binary -hmac "token" -sha1 | base64 - req.Header.Set("X-Twilio-Signature", "iiifXqv3dP5j8Oj5eB4RAOm/3tI=") - resp, err := client.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "text/xml", resp.Header.Get("Content-Type")) - assert.Equal(t, "<Response/>", string(body)) - <-done - }) -} |
