Skip to content

Commit

Permalink
Merge branch 'master' of github.com:skybet/go-helpdesk
Browse files Browse the repository at this point in the history
  • Loading branch information
adampointer committed Jul 24, 2018
2 parents 0eb0342 + faff29d commit d00ce6f
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 134 deletions.
11 changes: 3 additions & 8 deletions handlers/handlers.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package handlers

import (
"encoding/json"
"fmt"

"github.com/nlopes/slack"
log "github.com/sirupsen/logrus"

"github.com/skybet/go-helpdesk/server"
"github.com/skybet/go-helpdesk/wrapper"
)
Expand All @@ -20,14 +20,9 @@ func Init(sw wrapper.SlackWrapper) {
// HelpCallback is a handler that takes a dialogCallback, generated by the HelpRequest
// handler and logs the help request
func HelpCallback(res *server.Response, req *server.Request, ctx interface{}) error {
s, ok := ctx.(string)
d, ok := ctx.(*slack.DialogCallback)
if !ok {
return fmt.Errorf("Expected a string to be passed to the handler")
}
var d slack.DialogCallback
err := json.Unmarshal([]byte(s), &d)
if err != nil {
return fmt.Errorf("%s", err)
return fmt.Errorf("Expected a *slack.DialogCallback to be passed to the handler")
}
log.Printf("User: '%s' Requested Help: '%s'", d.User.Name, d.Submission["HelpRequestDescription"])
return nil
Expand Down
5 changes: 4 additions & 1 deletion handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
)

func TestHelpCallback(t *testing.T) {
t.Skip("Test no longer relevant. Consider implementing when callback does something.")
tt := []struct {
name string
jsonString interface{}
Expand Down Expand Up @@ -44,7 +45,9 @@ func TestHelpCallback(t *testing.T) {

err := HelpCallback(res, req, tc.jsonString)
if err != nil {
if err.Error() != tc.err.Error() {
if tc.err == nil {
t.Errorf("Test Name: %s - Should not error - Got: %s", tc.name, err)
} else if err.Error() != tc.err.Error() {
t.Errorf("Test Name: %s - Should result in: %s - Got: %s", tc.name, tc.err, err)
}
}
Expand Down
126 changes: 126 additions & 0 deletions server/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package server

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"strings"
"time"

"github.com/mitchellh/mapstructure"
"github.com/nlopes/slack"
)

// Request wraps http.Request
type Request struct {
*http.Request
payload CallbackPayload
}

// Validate the request comes from Slack
func (r *Request) Validate(secret string) error {
slackTimestampHeader := r.Header.Get("X-Slack-Request-Timestamp")
slackTimestamp, err := strconv.ParseInt(slackTimestampHeader, 10, 64)

// Abort if timestamp is invalid
if err != nil {
return fmt.Errorf("Invalid timestamp sent from slack: %s", err)
}

// Abort if timestamp is stale (older than 5 minutes)
now := int64(time.Now().Unix())
if (now - slackTimestamp) > (60 * 5) {
return fmt.Errorf("Stale timestamp sent from slack: %s", err)
}

// Abort if request body is invalid
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("Invalid request body sent from slack: %s", err)
}
slackBody := string(body)

// Abort if the signature does not correspond to the signing secret
slackBaseStr := []byte(fmt.Sprintf("v0:%d:%s", slackTimestamp, slackBody))
slackSignature := r.Header.Get("X-Slack-Signature")
sec := hmac.New(sha256.New, []byte(secret))
sec.Write(slackBaseStr)
mySig := fmt.Sprintf("v0=%s", []byte(hex.EncodeToString(sec.Sum(nil))))
if mySig != slackSignature {
return errors.New("Invalid signature sent from slack")
}
// All good! The request is valid
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
return nil
}

// CallbackPayload returns the parsed payload if it exists and is valid
func (r *Request) CallbackPayload() (CallbackPayload, error) {
if r.payload == nil {
if err := r.parsePayload(); err != nil {
return nil, err
}
if err := r.payload.Validate(); err != nil {
return nil, err
}
}
return r.payload, nil
}

func (r *Request) parsePayload() error {
var payload CallbackPayload
j := r.Form.Get("payload")
if j == "" {
return errors.New("Empty payload")
}
if err := json.Unmarshal([]byte(j), &payload); err != nil {
return fmt.Errorf("Error parsing payload JSON: %s", err)
}
r.payload = payload
return nil
}

// CallbackPayload represents the data sent by Slack on a user initiated event
type CallbackPayload map[string]interface{}

// Validate the payload
func (c CallbackPayload) Validate() error {
var errs []string
if c["type"] == nil {
errs = append(errs, "Missing value for 'type' key")
}
if c["callback_id"] == nil {
errs = append(errs, "Missing value for 'callback_id' key")
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, ", "))
}
return nil
}

// MatchRoute determines if we can route this request based on the payload
func (c CallbackPayload) MatchRoute(r *Route) bool {
if c["type"] == r.InteractionType && c["callback_id"] == r.CallbackID {
return true
}
return false
}

// Mutate the payload into a go type matching it's type field
func (c CallbackPayload) Mutate() (interface{}, error) {
switch c["type"] {
case "dialog_submission":
var result slack.DialogCallback
err := mapstructure.Decode(c, &result)
return &result, err
default:
return c, nil
}
}
19 changes: 19 additions & 0 deletions server/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package server

import (
"fmt"
"io"
"net/http"
)

// Response wraps http.ResponseWriter
type Response struct {
http.ResponseWriter
}

// Text is a convenience method for sending a response
func (r *Response) Text(code int, body string) {
r.Header().Set("Content-Type", "text/plain")
r.WriteHeader(code)
io.WriteString(r, fmt.Sprintf("%s\n", body))
}
139 changes: 28 additions & 111 deletions server/server.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
package server

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"strconv"
"time"

"github.com/nlopes/slack"
)
Expand All @@ -35,24 +25,6 @@ type Route struct {
Handler SlackHandlerFunc
}

// Request wraps http.Request
type Request struct {
*http.Request
}

// Response wraps http.ResponseWriter
type Response struct {
http.ResponseWriter
}

// Text is a convenience method for sending a response
func (r *Response) Text(code int, body string) {
r.Header().Set("Content-Type", "text/plain")
r.WriteHeader(code)

io.WriteString(r, fmt.Sprintf("%s\n", body))
}

// SlackHandler is a function executed when a route is invoked
type SlackHandler struct {
Log LogFunc
Expand Down Expand Up @@ -110,27 +82,27 @@ func (h *SlackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req := &Request{Request: r}
res := &Response{w}

// Generic serve function which captures and logs handler errors
serve := func(f SlackHandlerFunc, ctx interface{}) {
if err := f(res, req, ctx); err != nil {
h.Logf("HTTP handler error: %s", err)
}
}

if !h.validRequest(r) {
http.Error(w, "invalid slack request", 400)
// If the request did not look like it came from slack, 400 and abort
if err := req.Validate(h.secretToken); err != nil {
h.Logf("Bad request from slack: %s", err)
res.Text(400, "invalid slack request")
return
}

// First check if path matches our BasePath
// First check if path matches our BasePath and has valid form data
// If yes then attempt to decode it to match on Command or CallbackID / InteractionType
// If no then match custom paths
if r.URL.Path == h.basePath {
if err := r.ParseForm(); err != nil {
_ = fmt.Errorf("Unable to parse request from Slack: %s", err)
serve(h.DefaultRoute, nil)
return
}
err := r.ParseForm()
if r.URL.Path == h.basePath && err == nil {

// Is it a slash command?
if r.Form.Get("command") != "" {
sc, _ := slack.SlashCommandParse(r)
// Loop through all our routes and attempt a match on the Command
Expand All @@ -141,90 +113,35 @@ func (h *SlackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
// It's a command but we have no handler for it - 404
serve(h.DefaultRoute, nil)
return
} else {
var payloadMap map[string]interface{}
payloadJSON := r.Form.Get("payload")
if err := json.Unmarshal([]byte(payloadJSON), &payloadMap); err != nil {
h.Logf("%s", err)
serve(h.DefaultRoute, nil)
return
}
if payloadMap["type"] == nil {
h.Log("Error parsing Slack Event: Missing value for 'type' key")
serve(h.DefaultRoute, nil)
return
}
if payloadMap["callback_id"] == nil {
h.Log("Error parsing Slack Event: Missing value for 'callback_id' key")
serve(h.DefaultRoute, nil)
return
}
// Loop through all our routes and attempt a match on the InteractionType / CallbackID pair
for _, rt := range h.Routes {
if payloadMap["type"] == rt.InteractionType && payloadMap["callback_id"] == rt.CallbackID {
// Send the payloadJSON as context
serve(rt.Handler, payloadJSON)
return
}

// Does it have a valid callback payload? - If so, it's a callback
payload, err := req.CallbackPayload()
if err != nil {
h.Logf("Error parsing payload: %s", err)
}
// Loop through all our routes and attempt a match on the InteractionType / CallbackID pair
for _, rt := range h.Routes {
if payload.MatchRoute(rt) {
// Send the payload as context
t, err := payload.Mutate()
if err != nil {
h.Logf("Error mutating %s payload: %s", payload["type"], err)
}
serve(rt.Handler, t)
return
}
}
// Its path is the basepath, but we dont have a matching command or
// action handler for it - 404
serve(h.DefaultRoute, nil)
return
} else {
// Loop through all our routes and attempt a match on the path
// If nothing else works, loop through all our routes and attempt a match on the path
for _, rt := range h.Routes {
if rt.Path == r.URL.Path {
serve(rt.Handler, nil)
return
}
}
}
// 404
serve(h.DefaultRoute, nil)
}

func (h *SlackHandler) validRequest(r *http.Request) bool {
slackTimestampHeader := r.Header.Get("X-Slack-Request-Timestamp")
slackTimestamp, err := strconv.ParseInt(slackTimestampHeader, 10, 64)

// Abort if timestamp is invalid
if err != nil {
h.Logf("Invalid timestamp sent from slack: %s", err)
return false
}

// Abort if timestamp is stale (older than 5 minutes)
now := int64(time.Now().Unix())
if (now - slackTimestamp) > (60 * 5) {
h.Logf("Stale timestamp sent from slack: %s", err)
return false
}

// Abort if request body is invalid
body, err := ioutil.ReadAll(r.Body)
if err != nil {
h.Logf("Invalid request body sent from slack: %s", err)
return false
}
slackBody := string(body)

// Abort if the signature does not correspond to the signing secret
slackBaseStr := []byte(fmt.Sprintf("v0:%d:%s", slackTimestamp, slackBody))
slackSignature := r.Header.Get("X-Slack-Signature")
sec := hmac.New(sha256.New, []byte(h.secretToken))
sec.Write(slackBaseStr)
mySig := fmt.Sprintf("v0=%s", []byte(hex.EncodeToString(sec.Sum(nil))))
if mySig != slackSignature {
h.Log("Invalid signature sent from slack, ignoring request.")
return false
}

// All good! The request is valid
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
return true
// No matches - 404
serve(h.DefaultRoute, nil)
}
Loading

0 comments on commit d00ce6f

Please sign in to comment.