diff --git a/controllers/api.go b/controllers/api.go deleted file mode 100644 index a4341355..00000000 --- a/controllers/api.go +++ /dev/null @@ -1,772 +0,0 @@ -package controllers - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/PuerkitoBio/goquery" - "github.com/gophish/gophish/auth" - ctx "github.com/gophish/gophish/context" - log "github.com/gophish/gophish/logger" - "github.com/gophish/gophish/models" - "github.com/gophish/gophish/util" - "github.com/gorilla/mux" - "github.com/jinzhu/gorm" - "github.com/jordan-wright/email" - "github.com/sirupsen/logrus" -) - -// APIReset (/api/reset) resets the currently authenticated user's API key -func (as *AdminServer) APIReset(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "POST": - u := ctx.Get(r, "user").(models.User) - u.ApiKey = auth.GenerateSecureKey() - err := models.PutUser(&u) - if err != nil { - http.Error(w, "Error setting API Key", http.StatusInternalServerError) - } else { - JSONResponse(w, models.Response{Success: true, Message: "API Key successfully reset!", Data: u.ApiKey}, http.StatusOK) - } - } -} - -// APICampaigns returns a list of campaigns if requested via GET. -// If requested via POST, APICampaigns creates a new campaign and returns a reference to it. -func (as *AdminServer) APICampaigns(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - cs, err := models.GetCampaigns(ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - } - JSONResponse(w, cs, http.StatusOK) - //POST: Create a new campaign and return it as JSON - case r.Method == "POST": - c := models.Campaign{} - // Put the request into a campaign - err := json.NewDecoder(r.Body).Decode(&c) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) - return - } - err = models.PostCampaign(&c, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - // If the campaign is scheduled to launch immediately, send it to the worker. - // Otherwise, the worker will pick it up at the scheduled time - if c.Status == models.CampaignInProgress { - go as.worker.LaunchCampaign(c) - } - JSONResponse(w, c, http.StatusCreated) - } -} - -// APICampaignsSummary returns the summary for the current user's campaigns -func (as *AdminServer) APICampaignsSummary(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - cs, err := models.GetCampaignSummaries(ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - JSONResponse(w, cs, http.StatusOK) - } -} - -// APICampaign returns details about the requested campaign. If the campaign is not -// valid, APICampaign returns null. -func (as *AdminServer) APICampaign(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - c, err := models.GetCampaign(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: "Campaign not found"}, http.StatusNotFound) - return - } - switch { - case r.Method == "GET": - JSONResponse(w, c, http.StatusOK) - case r.Method == "DELETE": - err = models.DeleteCampaign(id) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error deleting campaign"}, http.StatusInternalServerError) - return - } - JSONResponse(w, models.Response{Success: true, Message: "Campaign deleted successfully!"}, http.StatusOK) - } -} - -// APICampaignResults returns just the results for a given campaign to -// significantly reduce the information returned. -func (as *AdminServer) APICampaignResults(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - cr, err := models.GetCampaignResults(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: "Campaign not found"}, http.StatusNotFound) - return - } - if r.Method == "GET" { - JSONResponse(w, cr, http.StatusOK) - return - } -} - -// APICampaignSummary returns the summary for a given campaign. -func (as *AdminServer) APICampaignSummary(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - switch { - case r.Method == "GET": - cs, err := models.GetCampaignSummary(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - if err == gorm.ErrRecordNotFound { - JSONResponse(w, models.Response{Success: false, Message: "Campaign not found"}, http.StatusNotFound) - } else { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - } - log.Error(err) - return - } - JSONResponse(w, cs, http.StatusOK) - } -} - -// APICampaignComplete effectively "ends" a campaign. -// Future phishing emails clicked will return a simple "404" page. -func (as *AdminServer) APICampaignComplete(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - switch { - case r.Method == "GET": - err := models.CompleteCampaign(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error completing campaign"}, http.StatusInternalServerError) - return - } - JSONResponse(w, models.Response{Success: true, Message: "Campaign completed successfully!"}, http.StatusOK) - } -} - -// APIGroups returns a list of groups if requested via GET. -// If requested via POST, APIGroups creates a new group and returns a reference to it. -func (as *AdminServer) APIGroups(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - gs, err := models.GetGroups(ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "No groups found"}, http.StatusNotFound) - return - } - JSONResponse(w, gs, http.StatusOK) - //POST: Create a new group and return it as JSON - case r.Method == "POST": - g := models.Group{} - // Put the request into a group - err := json.NewDecoder(r.Body).Decode(&g) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) - return - } - _, err = models.GetGroupByName(g.Name, ctx.Get(r, "user_id").(int64)) - if err != gorm.ErrRecordNotFound { - JSONResponse(w, models.Response{Success: false, Message: "Group name already in use"}, http.StatusConflict) - return - } - g.ModifiedDate = time.Now().UTC() - g.UserId = ctx.Get(r, "user_id").(int64) - err = models.PostGroup(&g) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - JSONResponse(w, g, http.StatusCreated) - } -} - -// APIGroupsSummary returns a summary of the groups owned by the current user. -func (as *AdminServer) APIGroupsSummary(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - gs, err := models.GetGroupSummaries(ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - JSONResponse(w, gs, http.StatusOK) - } -} - -// APIGroup returns details about the requested group. -// If the group is not valid, APIGroup returns null. -func (as *AdminServer) APIGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - g, err := models.GetGroup(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound) - return - } - switch { - case r.Method == "GET": - JSONResponse(w, g, http.StatusOK) - case r.Method == "DELETE": - err = models.DeleteGroup(&g) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error deleting group"}, http.StatusInternalServerError) - return - } - JSONResponse(w, models.Response{Success: true, Message: "Group deleted successfully!"}, http.StatusOK) - case r.Method == "PUT": - // Change this to get from URL and uid (don't bother with id in r.Body) - g = models.Group{} - err = json.NewDecoder(r.Body).Decode(&g) - if g.Id != id { - JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError) - return - } - g.ModifiedDate = time.Now().UTC() - g.UserId = ctx.Get(r, "user_id").(int64) - err = models.PutGroup(&g) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - JSONResponse(w, g, http.StatusOK) - } -} - -// APIGroupSummary returns a summary of the groups owned by the current user. -func (as *AdminServer) APIGroupSummary(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - g, err := models.GetGroupSummary(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound) - return - } - JSONResponse(w, g, http.StatusOK) - } -} - -// APITemplates handles the functionality for the /api/templates endpoint -func (as *AdminServer) APITemplates(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - ts, err := models.GetTemplates(ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - } - JSONResponse(w, ts, http.StatusOK) - //POST: Create a new template and return it as JSON - case r.Method == "POST": - t := models.Template{} - // Put the request into a template - err := json.NewDecoder(r.Body).Decode(&t) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) - return - } - _, err = models.GetTemplateByName(t.Name, ctx.Get(r, "user_id").(int64)) - if err != gorm.ErrRecordNotFound { - JSONResponse(w, models.Response{Success: false, Message: "Template name already in use"}, http.StatusConflict) - return - } - t.ModifiedDate = time.Now().UTC() - t.UserId = ctx.Get(r, "user_id").(int64) - err = models.PostTemplate(&t) - if err == models.ErrTemplateNameNotSpecified { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - if err == models.ErrTemplateMissingParameter { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error inserting template into database"}, http.StatusInternalServerError) - log.Error(err) - return - } - JSONResponse(w, t, http.StatusCreated) - } -} - -// APITemplate handles the functions for the /api/templates/:id endpoint -func (as *AdminServer) APITemplate(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - t, err := models.GetTemplate(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Template not found"}, http.StatusNotFound) - return - } - switch { - case r.Method == "GET": - JSONResponse(w, t, http.StatusOK) - case r.Method == "DELETE": - err = models.DeleteTemplate(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error deleting template"}, http.StatusInternalServerError) - return - } - JSONResponse(w, models.Response{Success: true, Message: "Template deleted successfully!"}, http.StatusOK) - case r.Method == "PUT": - t = models.Template{} - err = json.NewDecoder(r.Body).Decode(&t) - if err != nil { - log.Error(err) - } - if t.Id != id { - JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and template_id mismatch"}, http.StatusBadRequest) - return - } - t.ModifiedDate = time.Now().UTC() - t.UserId = ctx.Get(r, "user_id").(int64) - err = models.PutTemplate(&t) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - JSONResponse(w, t, http.StatusOK) - } -} - -// APIPages handles requests for the /api/pages/ endpoint -func (as *AdminServer) APIPages(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - ps, err := models.GetPages(ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - } - JSONResponse(w, ps, http.StatusOK) - //POST: Create a new page and return it as JSON - case r.Method == "POST": - p := models.Page{} - // Put the request into a page - err := json.NewDecoder(r.Body).Decode(&p) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Invalid request"}, http.StatusBadRequest) - return - } - // Check to make sure the name is unique - _, err = models.GetPageByName(p.Name, ctx.Get(r, "user_id").(int64)) - if err != gorm.ErrRecordNotFound { - JSONResponse(w, models.Response{Success: false, Message: "Page name already in use"}, http.StatusConflict) - log.Error(err) - return - } - p.ModifiedDate = time.Now().UTC() - p.UserId = ctx.Get(r, "user_id").(int64) - err = models.PostPage(&p) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - JSONResponse(w, p, http.StatusCreated) - } -} - -// APIPage contains functions to handle the GET'ing, DELETE'ing, and PUT'ing -// of a Page object -func (as *AdminServer) APIPage(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - p, err := models.GetPage(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Page not found"}, http.StatusNotFound) - return - } - switch { - case r.Method == "GET": - JSONResponse(w, p, http.StatusOK) - case r.Method == "DELETE": - err = models.DeletePage(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error deleting page"}, http.StatusInternalServerError) - return - } - JSONResponse(w, models.Response{Success: true, Message: "Page Deleted Successfully"}, http.StatusOK) - case r.Method == "PUT": - p = models.Page{} - err = json.NewDecoder(r.Body).Decode(&p) - if err != nil { - log.Error(err) - } - if p.Id != id { - JSONResponse(w, models.Response{Success: false, Message: "/:id and /:page_id mismatch"}, http.StatusBadRequest) - return - } - p.ModifiedDate = time.Now().UTC() - p.UserId = ctx.Get(r, "user_id").(int64) - err = models.PutPage(&p) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error updating page: " + err.Error()}, http.StatusInternalServerError) - return - } - JSONResponse(w, p, http.StatusOK) - } -} - -// APISendingProfiles handles requests for the /api/smtp/ endpoint -func (as *AdminServer) APISendingProfiles(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET": - ss, err := models.GetSMTPs(ctx.Get(r, "user_id").(int64)) - if err != nil { - log.Error(err) - } - JSONResponse(w, ss, http.StatusOK) - //POST: Create a new SMTP and return it as JSON - case r.Method == "POST": - s := models.SMTP{} - // Put the request into a page - err := json.NewDecoder(r.Body).Decode(&s) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Invalid request"}, http.StatusBadRequest) - return - } - // Check to make sure the name is unique - _, err = models.GetSMTPByName(s.Name, ctx.Get(r, "user_id").(int64)) - if err != gorm.ErrRecordNotFound { - JSONResponse(w, models.Response{Success: false, Message: "SMTP name already in use"}, http.StatusConflict) - log.Error(err) - return - } - s.ModifiedDate = time.Now().UTC() - s.UserId = ctx.Get(r, "user_id").(int64) - err = models.PostSMTP(&s) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - JSONResponse(w, s, http.StatusCreated) - } -} - -// APISendingProfile contains functions to handle the GET'ing, DELETE'ing, and PUT'ing -// of a SMTP object -func (as *AdminServer) APISendingProfile(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, _ := strconv.ParseInt(vars["id"], 0, 64) - s, err := models.GetSMTP(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "SMTP not found"}, http.StatusNotFound) - return - } - switch { - case r.Method == "GET": - JSONResponse(w, s, http.StatusOK) - case r.Method == "DELETE": - err = models.DeleteSMTP(id, ctx.Get(r, "user_id").(int64)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error deleting SMTP"}, http.StatusInternalServerError) - return - } - JSONResponse(w, models.Response{Success: true, Message: "SMTP Deleted Successfully"}, http.StatusOK) - case r.Method == "PUT": - s = models.SMTP{} - err = json.NewDecoder(r.Body).Decode(&s) - if err != nil { - log.Error(err) - } - if s.Id != id { - JSONResponse(w, models.Response{Success: false, Message: "/:id and /:smtp_id mismatch"}, http.StatusBadRequest) - return - } - err = s.Validate() - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - s.ModifiedDate = time.Now().UTC() - s.UserId = ctx.Get(r, "user_id").(int64) - err = models.PutSMTP(&s) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error updating page"}, http.StatusInternalServerError) - return - } - JSONResponse(w, s, http.StatusOK) - } -} - -// APIImportGroup imports a CSV of group members -func (as *AdminServer) APIImportGroup(w http.ResponseWriter, r *http.Request) { - ts, err := util.ParseCSV(r) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error parsing CSV"}, http.StatusInternalServerError) - return - } - JSONResponse(w, ts, http.StatusOK) - return -} - -// APIImportEmail allows for the importing of email. -// Returns a Message object -func (as *AdminServer) APIImportEmail(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest) - return - } - ir := struct { - Content string `json:"content"` - ConvertLinks bool `json:"convert_links"` - }{} - err := json.NewDecoder(r.Body).Decode(&ir) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error decoding JSON Request"}, http.StatusBadRequest) - return - } - e, err := email.NewEmailFromReader(strings.NewReader(ir.Content)) - if err != nil { - log.Error(err) - } - // If the user wants to convert links to point to - // the landing page, let's make it happen by changing up - // e.HTML - if ir.ConvertLinks { - d, err := goquery.NewDocumentFromReader(bytes.NewReader(e.HTML)) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - d.Find("a").Each(func(i int, a *goquery.Selection) { - a.SetAttr("href", "{{.URL}}") - }) - h, err := d.Html() - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - e.HTML = []byte(h) - } - er := emailResponse{ - Subject: e.Subject, - Text: string(e.Text), - HTML: string(e.HTML), - } - JSONResponse(w, er, http.StatusOK) - return -} - -// APIImportSite allows for the importing of HTML from a website -// Without "include_resources" set, it will merely place a "base" tag -// so that all resources can be loaded relative to the given URL. -func (as *AdminServer) APIImportSite(w http.ResponseWriter, r *http.Request) { - cr := cloneRequest{} - if r.Method != "POST" { - JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest) - return - } - err := json.NewDecoder(r.Body).Decode(&cr) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error decoding JSON Request"}, http.StatusBadRequest) - return - } - if err = cr.validate(); err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - tr := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - } - client := &http.Client{Transport: tr} - resp, err := client.Get(cr.URL) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - // Insert the base href tag to better handle relative resources - d, err := goquery.NewDocumentFromResponse(resp) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - // Assuming we don't want to include resources, we'll need a base href - if d.Find("head base").Length() == 0 { - d.Find("head").PrependHtml(fmt.Sprintf("", cr.URL)) - } - forms := d.Find("form") - forms.Each(func(i int, f *goquery.Selection) { - // We'll want to store where we got the form from - // (the current URL) - url := f.AttrOr("action", cr.URL) - if !strings.HasPrefix(url, "http") { - url = fmt.Sprintf("%s%s", cr.URL, url) - } - f.PrependHtml(fmt.Sprintf("", url)) - }) - h, err := d.Html() - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - cs := cloneResponse{HTML: h} - JSONResponse(w, cs, http.StatusOK) - return -} - -// APISendTestEmail sends a test email using the template name -// and Target given. -func (as *AdminServer) APISendTestEmail(w http.ResponseWriter, r *http.Request) { - s := &models.EmailRequest{ - ErrorChan: make(chan error), - UserId: ctx.Get(r, "user_id").(int64), - } - if r.Method != "POST" { - JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest) - return - } - err := json.NewDecoder(r.Body).Decode(s) - if err != nil { - JSONResponse(w, models.Response{Success: false, Message: "Error decoding JSON Request"}, http.StatusBadRequest) - return - } - - storeRequest := false - - // If a Template is not specified use a default - if s.Template.Name == "" { - //default message body - text := "It works!\n\nThis is an email letting you know that your gophish\nconfiguration was successful.\n" + - "Here are the details:\n\nWho you sent from: {{.From}}\n\nWho you sent to: \n" + - "{{if .FirstName}} First Name: {{.FirstName}}\n{{end}}" + - "{{if .LastName}} Last Name: {{.LastName}}\n{{end}}" + - "{{if .Position}} Position: {{.Position}}\n{{end}}" + - "\nNow go send some phish!" - t := models.Template{ - Subject: "Default Email from Gophish", - Text: text, - } - s.Template = t - } else { - // Get the Template requested by name - s.Template, err = models.GetTemplateByName(s.Template.Name, s.UserId) - if err == gorm.ErrRecordNotFound { - log.WithFields(logrus.Fields{ - "template": s.Template.Name, - }).Error("Template does not exist") - JSONResponse(w, models.Response{Success: false, Message: models.ErrTemplateNotFound.Error()}, http.StatusBadRequest) - return - } else if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - s.TemplateId = s.Template.Id - // We'll only save the test request to the database if there is a - // user-specified template to use. - storeRequest = true - } - - if s.Page.Name != "" { - s.Page, err = models.GetPageByName(s.Page.Name, s.UserId) - if err == gorm.ErrRecordNotFound { - log.WithFields(logrus.Fields{ - "page": s.Page.Name, - }).Error("Page does not exist") - JSONResponse(w, models.Response{Success: false, Message: models.ErrPageNotFound.Error()}, http.StatusBadRequest) - return - } else if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - s.PageId = s.Page.Id - } - - // If a complete sending profile is provided use it - if err := s.SMTP.Validate(); err != nil { - // Otherwise get the SMTP requested by name - smtp, lookupErr := models.GetSMTPByName(s.SMTP.Name, s.UserId) - // If the Sending Profile doesn't exist, let's err on the side - // of caution and assume that the validation failure was more important. - if lookupErr != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - s.SMTP = smtp - } - s.FromAddress = s.SMTP.FromAddress - - // Validate the given request - if err = s.Validate(); err != nil { - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) - return - } - - // Store the request if this wasn't the default template - if storeRequest { - err = models.PostEmailRequest(s) - if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - } - // Send the test email - err = as.worker.SendTestEmail(s) - if err != nil { - log.Error(err) - JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) - return - } - JSONResponse(w, models.Response{Success: true, Message: "Email Sent"}, http.StatusOK) - return -} - -// JSONResponse attempts to set the status code, c, and marshal the given interface, d, into a response that -// is written to the given ResponseWriter. -func JSONResponse(w http.ResponseWriter, d interface{}, c int) { - dj, err := json.MarshalIndent(d, "", " ") - if err != nil { - http.Error(w, "Error creating JSON response", http.StatusInternalServerError) - log.Error(err) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(c) - fmt.Fprintf(w, "%s", dj) -} - -type cloneRequest struct { - URL string `json:"url"` - IncludeResources bool `json:"include_resources"` -} - -func (cr *cloneRequest) validate() error { - if cr.URL == "" { - return errors.New("No URL Specified") - } - return nil -} - -type cloneResponse struct { - HTML string `json:"html"` -} - -type emailResponse struct { - Text string `json:"text"` - HTML string `json:"html"` - Subject string `json:"subject"` -} diff --git a/controllers/api/api_test.go b/controllers/api/api_test.go new file mode 100644 index 00000000..c4a4a8b7 --- /dev/null +++ b/controllers/api/api_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gophish/gophish/config" + "github.com/gophish/gophish/models" + "github.com/stretchr/testify/suite" +) + +type APISuite struct { + suite.Suite + apiKey string + config *config.Config + apiServer *Server +} + +func (s *APISuite) SetupSuite() { + conf := &config.Config{ + DBName: "sqlite3", + DBPath: ":memory:", + MigrationsPath: "../../db/db_sqlite3/migrations/", + } + err := models.Setup(conf) + if err != nil { + s.T().Fatalf("Failed creating database: %v", err) + } + s.config = conf + s.Nil(err) + // Get the API key to use for these tests + u, err := models.GetUser(1) + s.Nil(err) + s.apiKey = u.ApiKey + // Move our cwd up to the project root for help with resolving + // static assets + err = os.Chdir("../") + s.Nil(err) + s.apiServer = NewServer() +} + +func (s *APISuite) TearDownTest() { + campaigns, _ := models.GetCampaigns(1) + for _, campaign := range campaigns { + models.DeleteCampaign(campaign.Id) + } +} + +func (s *APISuite) SetupTest() { + // Add a group + group := models.Group{Name: "Test Group"} + group.Targets = []models.Target{ + models.Target{BaseRecipient: models.BaseRecipient{Email: "test1@example.com", FirstName: "First", LastName: "Example"}}, + models.Target{BaseRecipient: models.BaseRecipient{Email: "test2@example.com", FirstName: "Second", LastName: "Example"}}, + } + group.UserId = 1 + models.PostGroup(&group) + + // Add a template + t := models.Template{Name: "Test Template"} + t.Subject = "Test subject" + t.Text = "Text text" + t.HTML = "Test" + t.UserId = 1 + models.PostTemplate(&t) + + // Add a landing page + p := models.Page{Name: "Test Page"} + p.HTML = "Test" + p.UserId = 1 + models.PostPage(&p) + + // Add a sending profile + smtp := models.SMTP{Name: "Test Page"} + smtp.UserId = 1 + smtp.Host = "example.com" + smtp.FromAddress = "test@test.com" + models.PostSMTP(&smtp) + + // Setup and "launch" our campaign + // Set the status such that no emails are attempted + c := models.Campaign{Name: "Test campaign"} + c.UserId = 1 + c.Template = t + c.Page = p + c.SMTP = smtp + c.Groups = []models.Group{group} + models.PostCampaign(&c, c.UserId) + c.UpdateStatus(models.CampaignEmailsSent) +} + +func (s *APISuite) TestSiteImportBaseHref() { + h := "" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, h) + })) + hr := fmt.Sprintf("\n", ts.URL) + defer ts.Close() + req := httptest.NewRequest(http.MethodPost, "/api/import/site", + bytes.NewBuffer([]byte(fmt.Sprintf(` + { + "url" : "%s", + "include_resources" : false + } + `, ts.URL)))) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + s.apiServer.ImportSite(response, req) + cs := cloneResponse{} + err := json.NewDecoder(response.Body).Decode(&cs) + s.Nil(err) + s.Equal(cs.HTML, hr) +} + +func TestAPISuite(t *testing.T) { + suite.Run(t, new(APISuite)) +} diff --git a/controllers/api/campaign.go b/controllers/api/campaign.go new file mode 100644 index 00000000..33c08fe7 --- /dev/null +++ b/controllers/api/campaign.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + ctx "github.com/gophish/gophish/context" + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/gorilla/mux" + "github.com/jinzhu/gorm" +) + +// Campaigns returns a list of campaigns if requested via GET. +// If requested via POST, APICampaigns creates a new campaign and returns a reference to it. +func (as *Server) Campaigns(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + cs, err := models.GetCampaigns(ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + } + JSONResponse(w, cs, http.StatusOK) + //POST: Create a new campaign and return it as JSON + case r.Method == "POST": + c := models.Campaign{} + // Put the request into a campaign + err := json.NewDecoder(r.Body).Decode(&c) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) + return + } + err = models.PostCampaign(&c, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + // If the campaign is scheduled to launch immediately, send it to the worker. + // Otherwise, the worker will pick it up at the scheduled time + if c.Status == models.CampaignInProgress { + go as.worker.LaunchCampaign(c) + } + JSONResponse(w, c, http.StatusCreated) + } +} + +// CampaignsSummary returns the summary for the current user's campaigns +func (as *Server) CampaignsSummary(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + cs, err := models.GetCampaignSummaries(ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, cs, http.StatusOK) + } +} + +// Campaign returns details about the requested campaign. If the campaign is not +// valid, APICampaign returns null. +func (as *Server) Campaign(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + c, err := models.GetCampaign(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: "Campaign not found"}, http.StatusNotFound) + return + } + switch { + case r.Method == "GET": + JSONResponse(w, c, http.StatusOK) + case r.Method == "DELETE": + err = models.DeleteCampaign(id) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error deleting campaign"}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Campaign deleted successfully!"}, http.StatusOK) + } +} + +// CampaignResults returns just the results for a given campaign to +// significantly reduce the information returned. +func (as *Server) CampaignResults(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + cr, err := models.GetCampaignResults(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: "Campaign not found"}, http.StatusNotFound) + return + } + if r.Method == "GET" { + JSONResponse(w, cr, http.StatusOK) + return + } +} + +// CampaignSummary returns the summary for a given campaign. +func (as *Server) CampaignSummary(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + switch { + case r.Method == "GET": + cs, err := models.GetCampaignSummary(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + if err == gorm.ErrRecordNotFound { + JSONResponse(w, models.Response{Success: false, Message: "Campaign not found"}, http.StatusNotFound) + } else { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + } + log.Error(err) + return + } + JSONResponse(w, cs, http.StatusOK) + } +} + +// CampaignComplete effectively "ends" a campaign. +// Future phishing emails clicked will return a simple "404" page. +func (as *Server) CampaignComplete(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + switch { + case r.Method == "GET": + err := models.CompleteCampaign(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error completing campaign"}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Campaign completed successfully!"}, http.StatusOK) + } +} diff --git a/controllers/api/group.go b/controllers/api/group.go new file mode 100644 index 00000000..26dcfb39 --- /dev/null +++ b/controllers/api/group.go @@ -0,0 +1,118 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + ctx "github.com/gophish/gophish/context" + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/gorilla/mux" + "github.com/jinzhu/gorm" +) + +// Groups returns a list of groups if requested via GET. +// If requested via POST, APIGroups creates a new group and returns a reference to it. +func (as *Server) Groups(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + gs, err := models.GetGroups(ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "No groups found"}, http.StatusNotFound) + return + } + JSONResponse(w, gs, http.StatusOK) + //POST: Create a new group and return it as JSON + case r.Method == "POST": + g := models.Group{} + // Put the request into a group + err := json.NewDecoder(r.Body).Decode(&g) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) + return + } + _, err = models.GetGroupByName(g.Name, ctx.Get(r, "user_id").(int64)) + if err != gorm.ErrRecordNotFound { + JSONResponse(w, models.Response{Success: false, Message: "Group name already in use"}, http.StatusConflict) + return + } + g.ModifiedDate = time.Now().UTC() + g.UserId = ctx.Get(r, "user_id").(int64) + err = models.PostGroup(&g) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + JSONResponse(w, g, http.StatusCreated) + } +} + +// GroupsSummary returns a summary of the groups owned by the current user. +func (as *Server) GroupsSummary(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + gs, err := models.GetGroupSummaries(ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, gs, http.StatusOK) + } +} + +// Group returns details about the requested group. +// If the group is not valid, Group returns null. +func (as *Server) Group(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + g, err := models.GetGroup(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound) + return + } + switch { + case r.Method == "GET": + JSONResponse(w, g, http.StatusOK) + case r.Method == "DELETE": + err = models.DeleteGroup(&g) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error deleting group"}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Group deleted successfully!"}, http.StatusOK) + case r.Method == "PUT": + // Change this to get from URL and uid (don't bother with id in r.Body) + g = models.Group{} + err = json.NewDecoder(r.Body).Decode(&g) + if g.Id != id { + JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError) + return + } + g.ModifiedDate = time.Now().UTC() + g.UserId = ctx.Get(r, "user_id").(int64) + err = models.PutGroup(&g) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + JSONResponse(w, g, http.StatusOK) + } +} + +// GroupSummary returns a summary of the groups owned by the current user. +func (as *Server) GroupSummary(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + g, err := models.GetGroupSummary(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound) + return + } + JSONResponse(w, g, http.StatusOK) + } +} diff --git a/controllers/api/import.go b/controllers/api/import.go new file mode 100644 index 00000000..f1b164d9 --- /dev/null +++ b/controllers/api/import.go @@ -0,0 +1,157 @@ +package api + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/PuerkitoBio/goquery" + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/gophish/gophish/util" + "github.com/jordan-wright/email" +) + +type cloneRequest struct { + URL string `json:"url"` + IncludeResources bool `json:"include_resources"` +} + +func (cr *cloneRequest) validate() error { + if cr.URL == "" { + return errors.New("No URL Specified") + } + return nil +} + +type cloneResponse struct { + HTML string `json:"html"` +} + +type emailResponse struct { + Text string `json:"text"` + HTML string `json:"html"` + Subject string `json:"subject"` +} + +// ImportGroup imports a CSV of group members +func (as *Server) ImportGroup(w http.ResponseWriter, r *http.Request) { + ts, err := util.ParseCSV(r) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error parsing CSV"}, http.StatusInternalServerError) + return + } + JSONResponse(w, ts, http.StatusOK) + return +} + +// ImportEmail allows for the importing of email. +// Returns a Message object +func (as *Server) ImportEmail(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest) + return + } + ir := struct { + Content string `json:"content"` + ConvertLinks bool `json:"convert_links"` + }{} + err := json.NewDecoder(r.Body).Decode(&ir) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error decoding JSON Request"}, http.StatusBadRequest) + return + } + e, err := email.NewEmailFromReader(strings.NewReader(ir.Content)) + if err != nil { + log.Error(err) + } + // If the user wants to convert links to point to + // the landing page, let's make it happen by changing up + // e.HTML + if ir.ConvertLinks { + d, err := goquery.NewDocumentFromReader(bytes.NewReader(e.HTML)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + d.Find("a").Each(func(i int, a *goquery.Selection) { + a.SetAttr("href", "{{.URL}}") + }) + h, err := d.Html() + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + e.HTML = []byte(h) + } + er := emailResponse{ + Subject: e.Subject, + Text: string(e.Text), + HTML: string(e.HTML), + } + JSONResponse(w, er, http.StatusOK) + return +} + +// ImportSite allows for the importing of HTML from a website +// Without "include_resources" set, it will merely place a "base" tag +// so that all resources can be loaded relative to the given URL. +func (as *Server) ImportSite(w http.ResponseWriter, r *http.Request) { + cr := cloneRequest{} + if r.Method != "POST" { + JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest) + return + } + err := json.NewDecoder(r.Body).Decode(&cr) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error decoding JSON Request"}, http.StatusBadRequest) + return + } + if err = cr.validate(); err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + client := &http.Client{Transport: tr} + resp, err := client.Get(cr.URL) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + // Insert the base href tag to better handle relative resources + d, err := goquery.NewDocumentFromResponse(resp) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + // Assuming we don't want to include resources, we'll need a base href + if d.Find("head base").Length() == 0 { + d.Find("head").PrependHtml(fmt.Sprintf("", cr.URL)) + } + forms := d.Find("form") + forms.Each(func(i int, f *goquery.Selection) { + // We'll want to store where we got the form from + // (the current URL) + url := f.AttrOr("action", cr.URL) + if !strings.HasPrefix(url, "http") { + url = fmt.Sprintf("%s%s", cr.URL, url) + } + f.PrependHtml(fmt.Sprintf("", url)) + }) + h, err := d.Html() + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + cs := cloneResponse{HTML: h} + JSONResponse(w, cs, http.StatusOK) + return +} diff --git a/controllers/api/page.go b/controllers/api/page.go new file mode 100644 index 00000000..bdebf5a7 --- /dev/null +++ b/controllers/api/page.go @@ -0,0 +1,91 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + ctx "github.com/gophish/gophish/context" + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/gorilla/mux" + "github.com/jinzhu/gorm" +) + +// Pages handles requests for the /api/pages/ endpoint +func (as *Server) Pages(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + ps, err := models.GetPages(ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + } + JSONResponse(w, ps, http.StatusOK) + //POST: Create a new page and return it as JSON + case r.Method == "POST": + p := models.Page{} + // Put the request into a page + err := json.NewDecoder(r.Body).Decode(&p) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid request"}, http.StatusBadRequest) + return + } + // Check to make sure the name is unique + _, err = models.GetPageByName(p.Name, ctx.Get(r, "user_id").(int64)) + if err != gorm.ErrRecordNotFound { + JSONResponse(w, models.Response{Success: false, Message: "Page name already in use"}, http.StatusConflict) + log.Error(err) + return + } + p.ModifiedDate = time.Now().UTC() + p.UserId = ctx.Get(r, "user_id").(int64) + err = models.PostPage(&p) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, p, http.StatusCreated) + } +} + +// Page contains functions to handle the GET'ing, DELETE'ing, and PUT'ing +// of a Page object +func (as *Server) Page(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + p, err := models.GetPage(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Page not found"}, http.StatusNotFound) + return + } + switch { + case r.Method == "GET": + JSONResponse(w, p, http.StatusOK) + case r.Method == "DELETE": + err = models.DeletePage(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error deleting page"}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Page Deleted Successfully"}, http.StatusOK) + case r.Method == "PUT": + p = models.Page{} + err = json.NewDecoder(r.Body).Decode(&p) + if err != nil { + log.Error(err) + } + if p.Id != id { + JSONResponse(w, models.Response{Success: false, Message: "/:id and /:page_id mismatch"}, http.StatusBadRequest) + return + } + p.ModifiedDate = time.Now().UTC() + p.UserId = ctx.Get(r, "user_id").(int64) + err = models.PutPage(&p) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error updating page: " + err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, p, http.StatusOK) + } +} diff --git a/controllers/api/reset.go b/controllers/api/reset.go new file mode 100644 index 00000000..1baf950d --- /dev/null +++ b/controllers/api/reset.go @@ -0,0 +1,24 @@ +package api + +import ( + "net/http" + + "github.com/gophish/gophish/auth" + ctx "github.com/gophish/gophish/context" + "github.com/gophish/gophish/models" +) + +// Reset (/api/reset) resets the currently authenticated user's API key +func (as *Server) Reset(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "POST": + u := ctx.Get(r, "user").(models.User) + u.ApiKey = auth.GenerateSecureKey() + err := models.PutUser(&u) + if err != nil { + http.Error(w, "Error setting API Key", http.StatusInternalServerError) + } else { + JSONResponse(w, models.Response{Success: true, Message: "API Key successfully reset!", Data: u.ApiKey}, http.StatusOK) + } + } +} diff --git a/controllers/api/response.go b/controllers/api/response.go new file mode 100644 index 00000000..bf53014a --- /dev/null +++ b/controllers/api/response.go @@ -0,0 +1,22 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + log "github.com/gophish/gophish/logger" +) + +// JSONResponse attempts to set the status code, c, and marshal the given interface, d, into a response that +// is written to the given ResponseWriter. +func JSONResponse(w http.ResponseWriter, d interface{}, c int) { + dj, err := json.MarshalIndent(d, "", " ") + if err != nil { + http.Error(w, "Error creating JSON response", http.StatusInternalServerError) + log.Error(err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(c) + fmt.Fprintf(w, "%s", dj) +} diff --git a/controllers/api/server.go b/controllers/api/server.go new file mode 100644 index 00000000..ba605424 --- /dev/null +++ b/controllers/api/server.go @@ -0,0 +1,76 @@ +package api + +import ( + "net/http" + + mid "github.com/gophish/gophish/middleware" + "github.com/gophish/gophish/worker" + "github.com/gorilla/mux" +) + +// ServerOption is an option to apply to the API server. +type ServerOption func(*Server) + +// Server represents the routes and functionality of the Gophish API. +// It's not a server in the traditional sense, in that it isn't started and +// stopped. Rather, it's meant to be used as an http.Handler in the +// AdminServer. +type Server struct { + handler http.Handler + worker worker.Worker +} + +// NewServer returns a new instance of the API handler with the provided +// options applied. +func NewServer(options ...ServerOption) *Server { + defaultWorker, _ := worker.New() + as := &Server{ + worker: defaultWorker, + } + for _, opt := range options { + opt(as) + } + as.registerRoutes() + return as +} + +// WithWorker is an option that sets the background worker. +func WithWorker(w worker.Worker) ServerOption { + return func(as *Server) { + as.worker = w + } +} + +func (as *Server) registerRoutes() { + root := mux.NewRouter() + root = root.StrictSlash(true) + router := root.PathPrefix("/api/").Subrouter() + router.Use(mid.RequireAPIKey) + router.Use(mid.EnforceViewOnly) + router.HandleFunc("/reset", as.Reset) + router.HandleFunc("/campaigns/", as.Campaigns) + router.HandleFunc("/campaigns/summary", as.CampaignsSummary) + router.HandleFunc("/campaigns/{id:[0-9]+}", as.Campaign) + router.HandleFunc("/campaigns/{id:[0-9]+}/results", as.CampaignResults) + router.HandleFunc("/campaigns/{id:[0-9]+}/summary", as.CampaignSummary) + router.HandleFunc("/campaigns/{id:[0-9]+}/complete", as.CampaignComplete) + router.HandleFunc("/groups/", as.Groups) + router.HandleFunc("/groups/summary", as.GroupsSummary) + router.HandleFunc("/groups/{id:[0-9]+}", as.Group) + router.HandleFunc("/groups/{id:[0-9]+}/summary", as.GroupSummary) + router.HandleFunc("/templates/", as.Templates) + router.HandleFunc("/templates/{id:[0-9]+}", as.Template) + router.HandleFunc("/pages/", as.Pages) + router.HandleFunc("/pages/{id:[0-9]+}", as.Page) + router.HandleFunc("/smtp/", as.SendingProfiles) + router.HandleFunc("/smtp/{id:[0-9]+}", as.SendingProfile) + router.HandleFunc("/util/send_test_email", as.SendTestEmail) + router.HandleFunc("/import/group", as.ImportGroup) + router.HandleFunc("/import/email", as.ImportEmail) + router.HandleFunc("/import/site", as.ImportSite) + as.handler = router +} + +func (as *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + as.handler.ServeHTTP(w, r) +} diff --git a/controllers/api/smtp.go b/controllers/api/smtp.go new file mode 100644 index 00000000..2da33c93 --- /dev/null +++ b/controllers/api/smtp.go @@ -0,0 +1,96 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + ctx "github.com/gophish/gophish/context" + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/gorilla/mux" + "github.com/jinzhu/gorm" +) + +// SendingProfiles handles requests for the /api/smtp/ endpoint +func (as *Server) SendingProfiles(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + ss, err := models.GetSMTPs(ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + } + JSONResponse(w, ss, http.StatusOK) + //POST: Create a new SMTP and return it as JSON + case r.Method == "POST": + s := models.SMTP{} + // Put the request into a page + err := json.NewDecoder(r.Body).Decode(&s) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid request"}, http.StatusBadRequest) + return + } + // Check to make sure the name is unique + _, err = models.GetSMTPByName(s.Name, ctx.Get(r, "user_id").(int64)) + if err != gorm.ErrRecordNotFound { + JSONResponse(w, models.Response{Success: false, Message: "SMTP name already in use"}, http.StatusConflict) + log.Error(err) + return + } + s.ModifiedDate = time.Now().UTC() + s.UserId = ctx.Get(r, "user_id").(int64) + err = models.PostSMTP(&s) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, s, http.StatusCreated) + } +} + +// SendingProfile contains functions to handle the GET'ing, DELETE'ing, and PUT'ing +// of a SMTP object +func (as *Server) SendingProfile(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + s, err := models.GetSMTP(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "SMTP not found"}, http.StatusNotFound) + return + } + switch { + case r.Method == "GET": + JSONResponse(w, s, http.StatusOK) + case r.Method == "DELETE": + err = models.DeleteSMTP(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error deleting SMTP"}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "SMTP Deleted Successfully"}, http.StatusOK) + case r.Method == "PUT": + s = models.SMTP{} + err = json.NewDecoder(r.Body).Decode(&s) + if err != nil { + log.Error(err) + } + if s.Id != id { + JSONResponse(w, models.Response{Success: false, Message: "/:id and /:smtp_id mismatch"}, http.StatusBadRequest) + return + } + err = s.Validate() + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + s.ModifiedDate = time.Now().UTC() + s.UserId = ctx.Get(r, "user_id").(int64) + err = models.PutSMTP(&s) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error updating page"}, http.StatusInternalServerError) + return + } + JSONResponse(w, s, http.StatusOK) + } +} diff --git a/controllers/api/template.go b/controllers/api/template.go new file mode 100644 index 00000000..da8cd90c --- /dev/null +++ b/controllers/api/template.go @@ -0,0 +1,97 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + ctx "github.com/gophish/gophish/context" + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/gorilla/mux" + "github.com/jinzhu/gorm" +) + +// Templates handles the functionality for the /api/templates endpoint +func (as *Server) Templates(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + ts, err := models.GetTemplates(ctx.Get(r, "user_id").(int64)) + if err != nil { + log.Error(err) + } + JSONResponse(w, ts, http.StatusOK) + //POST: Create a new template and return it as JSON + case r.Method == "POST": + t := models.Template{} + // Put the request into a template + err := json.NewDecoder(r.Body).Decode(&t) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) + return + } + _, err = models.GetTemplateByName(t.Name, ctx.Get(r, "user_id").(int64)) + if err != gorm.ErrRecordNotFound { + JSONResponse(w, models.Response{Success: false, Message: "Template name already in use"}, http.StatusConflict) + return + } + t.ModifiedDate = time.Now().UTC() + t.UserId = ctx.Get(r, "user_id").(int64) + err = models.PostTemplate(&t) + if err == models.ErrTemplateNameNotSpecified { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + if err == models.ErrTemplateMissingParameter { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error inserting template into database"}, http.StatusInternalServerError) + log.Error(err) + return + } + JSONResponse(w, t, http.StatusCreated) + } +} + +// Template handles the functions for the /api/templates/:id endpoint +func (as *Server) Template(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + t, err := models.GetTemplate(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Template not found"}, http.StatusNotFound) + return + } + switch { + case r.Method == "GET": + JSONResponse(w, t, http.StatusOK) + case r.Method == "DELETE": + err = models.DeleteTemplate(id, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error deleting template"}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Template deleted successfully!"}, http.StatusOK) + case r.Method == "PUT": + t = models.Template{} + err = json.NewDecoder(r.Body).Decode(&t) + if err != nil { + log.Error(err) + } + if t.Id != id { + JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and template_id mismatch"}, http.StatusBadRequest) + return + } + t.ModifiedDate = time.Now().UTC() + t.UserId = ctx.Get(r, "user_id").(int64) + err = models.PutTemplate(&t) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + JSONResponse(w, t, http.StatusOK) + } +} diff --git a/controllers/api/util.go b/controllers/api/util.go new file mode 100644 index 00000000..0f6c215c --- /dev/null +++ b/controllers/api/util.go @@ -0,0 +1,122 @@ +package api + +import ( + "encoding/json" + "net/http" + + ctx "github.com/gophish/gophish/context" + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/jinzhu/gorm" + "github.com/sirupsen/logrus" +) + +// SendTestEmail sends a test email using the template name +// and Target given. +func (as *Server) SendTestEmail(w http.ResponseWriter, r *http.Request) { + s := &models.EmailRequest{ + ErrorChan: make(chan error), + UserId: ctx.Get(r, "user_id").(int64), + } + if r.Method != "POST" { + JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest) + return + } + err := json.NewDecoder(r.Body).Decode(s) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Error decoding JSON Request"}, http.StatusBadRequest) + return + } + + storeRequest := false + + // If a Template is not specified use a default + if s.Template.Name == "" { + //default message body + text := "It works!\n\nThis is an email letting you know that your gophish\nconfiguration was successful.\n" + + "Here are the details:\n\nWho you sent from: {{.From}}\n\nWho you sent to: \n" + + "{{if .FirstName}} First Name: {{.FirstName}}\n{{end}}" + + "{{if .LastName}} Last Name: {{.LastName}}\n{{end}}" + + "{{if .Position}} Position: {{.Position}}\n{{end}}" + + "\nNow go send some phish!" + t := models.Template{ + Subject: "Default Email from Gophish", + Text: text, + } + s.Template = t + } else { + // Get the Template requested by name + s.Template, err = models.GetTemplateByName(s.Template.Name, s.UserId) + if err == gorm.ErrRecordNotFound { + log.WithFields(logrus.Fields{ + "template": s.Template.Name, + }).Error("Template does not exist") + JSONResponse(w, models.Response{Success: false, Message: models.ErrTemplateNotFound.Error()}, http.StatusBadRequest) + return + } else if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + s.TemplateId = s.Template.Id + // We'll only save the test request to the database if there is a + // user-specified template to use. + storeRequest = true + } + + if s.Page.Name != "" { + s.Page, err = models.GetPageByName(s.Page.Name, s.UserId) + if err == gorm.ErrRecordNotFound { + log.WithFields(logrus.Fields{ + "page": s.Page.Name, + }).Error("Page does not exist") + JSONResponse(w, models.Response{Success: false, Message: models.ErrPageNotFound.Error()}, http.StatusBadRequest) + return + } else if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + s.PageId = s.Page.Id + } + + // If a complete sending profile is provided use it + if err := s.SMTP.Validate(); err != nil { + // Otherwise get the SMTP requested by name + smtp, lookupErr := models.GetSMTPByName(s.SMTP.Name, s.UserId) + // If the Sending Profile doesn't exist, let's err on the side + // of caution and assume that the validation failure was more important. + if lookupErr != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + s.SMTP = smtp + } + s.FromAddress = s.SMTP.FromAddress + + // Validate the given request + if err = s.Validate(); err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + + // Store the request if this wasn't the default template + if storeRequest { + err = models.PostEmailRequest(s) + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + } + // Send the test email + err = as.worker.SendTestEmail(s) + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Email Sent"}, http.StatusOK) + return +} diff --git a/controllers/api_test.go b/controllers/api_test.go index 9f9e26d9..de0a7366 100644 --- a/controllers/api_test.go +++ b/controllers/api_test.go @@ -1,10 +1,6 @@ package controllers import ( - "bytes" - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "os" "testing" @@ -103,52 +99,6 @@ func (s *ControllersSuite) SetupTest() { c.UpdateStatus(models.CampaignEmailsSent) } -func (s *ControllersSuite) TestRequireAPIKey() { - resp, err := http.Post(fmt.Sprintf("%s/api/import/site", s.adminServer.URL), "application/json", nil) - s.Nil(err) - defer resp.Body.Close() - s.Equal(resp.StatusCode, http.StatusUnauthorized) -} - -func (s *ControllersSuite) TestInvalidAPIKey() { - resp, err := http.Get(fmt.Sprintf("%s/api/groups/?api_key=%s", s.adminServer.URL, "bogus-api-key")) - s.Nil(err) - defer resp.Body.Close() - s.Equal(resp.StatusCode, http.StatusUnauthorized) -} - -func (s *ControllersSuite) TestBearerToken() { - req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/groups/", s.adminServer.URL), nil) - s.Nil(err) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", s.apiKey)) - resp, err := http.DefaultClient.Do(req) - s.Nil(err) - defer resp.Body.Close() - s.Equal(resp.StatusCode, http.StatusOK) -} - -func (s *ControllersSuite) TestSiteImportBaseHref() { - h := "" - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, h) - })) - hr := fmt.Sprintf("\n", ts.URL) - defer ts.Close() - resp, err := http.Post(fmt.Sprintf("%s/api/import/site?api_key=%s", s.adminServer.URL, s.apiKey), "application/json", - bytes.NewBuffer([]byte(fmt.Sprintf(` - { - "url" : "%s", - "include_resources" : false - } - `, ts.URL)))) - s.Nil(err) - defer resp.Body.Close() - cs := cloneResponse{} - err = json.NewDecoder(resp.Body).Decode(&cs) - s.Nil(err) - s.Equal(cs.HTML, hr) -} - func (s *ControllersSuite) TearDownSuite() { // Tear down the admin and phishing servers s.adminServer.Close() diff --git a/controllers/phish.go b/controllers/phish.go index e2ff7870..a41708bc 100644 --- a/controllers/phish.go +++ b/controllers/phish.go @@ -13,6 +13,7 @@ import ( "github.com/NYTimes/gziphandler" "github.com/gophish/gophish/config" ctx "github.com/gophish/gophish/context" + "github.com/gophish/gophish/controllers/api" log "github.com/gophish/gophish/logger" "github.com/gophish/gophish/models" "github.com/gophish/gophish/util" @@ -299,7 +300,7 @@ func (ps *PhishingServer) TransparencyHandler(w http.ResponseWriter, r *http.Req SendDate: rs.SendDate, ContactAddress: ps.contactAddress, } - JSONResponse(w, tr, http.StatusOK) + api.JSONResponse(w, tr, http.StatusOK) } // setupContext handles some of the administrative work around receiving a new diff --git a/controllers/route.go b/controllers/route.go index 9adc3e4f..fd8beaa2 100644 --- a/controllers/route.go +++ b/controllers/route.go @@ -12,6 +12,7 @@ import ( "github.com/gophish/gophish/auth" "github.com/gophish/gophish/config" ctx "github.com/gophish/gophish/context" + "github.com/gophish/gophish/controllers/api" log "github.com/gophish/gophish/logger" mid "github.com/gophish/gophish/middleware" "github.com/gophish/gophish/models" @@ -106,31 +107,8 @@ func (as *AdminServer) registerRoutes() { router.HandleFunc("/settings", Use(as.Settings, mid.RequireLogin)) router.HandleFunc("/register", Use(as.Register, mid.RequireLogin, mid.RequirePermission(models.PermissionModifySystem))) // Create the API routes - api := router.PathPrefix("/api").Subrouter() - api = api.StrictSlash(true) - api.Use(mid.RequireAPIKey) - api.Use(mid.EnforceViewOnly) - api.HandleFunc("/reset", as.APIReset) - api.HandleFunc("/campaigns/", as.APICampaigns) - api.HandleFunc("/campaigns/summary", as.APICampaignsSummary) - api.HandleFunc("/campaigns/{id:[0-9]+}", as.APICampaign) - api.HandleFunc("/campaigns/{id:[0-9]+}/results", as.APICampaignResults) - api.HandleFunc("/campaigns/{id:[0-9]+}/summary", as.APICampaignSummary) - api.HandleFunc("/campaigns/{id:[0-9]+}/complete", as.APICampaignComplete) - api.HandleFunc("/groups/", as.APIGroups) - api.HandleFunc("/groups/summary", as.APIGroupsSummary) - api.HandleFunc("/groups/{id:[0-9]+}", as.APIGroup) - api.HandleFunc("/groups/{id:[0-9]+}/summary", as.APIGroupSummary) - api.HandleFunc("/templates/", as.APITemplates) - api.HandleFunc("/templates/{id:[0-9]+}", as.APITemplate) - api.HandleFunc("/pages/", as.APIPages) - api.HandleFunc("/pages/{id:[0-9]+}", as.APIPage) - api.HandleFunc("/smtp/", as.APISendingProfiles) - api.HandleFunc("/smtp/{id:[0-9]+}", as.APISendingProfile) - api.HandleFunc("/util/send_test_email", as.APISendTestEmail) - api.HandleFunc("/import/group", as.APIImportGroup) - api.HandleFunc("/import/email", as.APIImportEmail) - api.HandleFunc("/import/site", as.APIImportSite) + api := api.NewServer(api.WithWorker(as.worker)) + router.PathPrefix("/api/").Handler(api) // Setup static file serving router.PathPrefix("/").Handler(http.FileServer(unindexed.Dir("./static/"))) @@ -280,16 +258,16 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) { if err == auth.ErrInvalidPassword { msg.Message = "Invalid Password" msg.Success = false - JSONResponse(w, msg, http.StatusBadRequest) + api.JSONResponse(w, msg, http.StatusBadRequest) return } if err != nil { msg.Message = err.Error() msg.Success = false - JSONResponse(w, msg, http.StatusBadRequest) + api.JSONResponse(w, msg, http.StatusBadRequest) return } - JSONResponse(w, msg, http.StatusOK) + api.JSONResponse(w, msg, http.StatusOK) } } diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go index b17b6351..30752b01 100644 --- a/middleware/middleware_test.go +++ b/middleware/middleware_test.go @@ -1,6 +1,7 @@ package middleware import ( + "fmt" "net/http" "net/http/httptest" "testing" @@ -17,6 +18,7 @@ var successHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Reques type MiddlewareSuite struct { suite.Suite + apiKey string } func (s *MiddlewareSuite) SetupSuite() { @@ -29,6 +31,10 @@ func (s *MiddlewareSuite) SetupSuite() { if err != nil { s.T().Fatalf("Failed creating database: %v", err) } + // Get the API key to use for these tests + u, err := models.GetUser(1) + s.Nil(err) + s.apiKey = u.ApiKey } // MiddlewarePermissionTest maps an expected HTTP Method to an expected HTTP @@ -99,6 +105,35 @@ func (s *MiddlewareSuite) TestRequirePermission() { } } +func (s *MiddlewareSuite) TestRequireAPIKey() { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + // Test that making a request without an API key is denied + RequireAPIKey(successHandler).ServeHTTP(response, req) + s.Equal(response.Code, http.StatusUnauthorized) +} + +func (s *MiddlewareSuite) TestInvalidAPIKey() { + req := httptest.NewRequest(http.MethodGet, "/", nil) + query := req.URL.Query() + query.Set("api_key", "bogus-api-key") + req.URL.RawQuery = query.Encode() + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + RequireAPIKey(successHandler).ServeHTTP(response, req) + s.Equal(response.Code, http.StatusUnauthorized) +} + +func (s *MiddlewareSuite) TestBearerToken() { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", s.apiKey)) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + RequireAPIKey(successHandler).ServeHTTP(response, req) + s.Equal(response.Code, http.StatusOK) +} + func TestMiddlewareSuite(t *testing.T) { suite.Run(t, new(MiddlewareSuite)) }