From 9de32746eeb4f669068deb6f20464eed4b5c8a56 Mon Sep 17 00:00:00 2001 From: Glenn Wilkinson Date: Sat, 18 Jan 2020 17:58:34 +0000 Subject: [PATCH] Added IMAP support for checking reported emails (#1612) Initial support of managing reporting through IMAP. Co-Authored-By: Jordan Wright --- config.json | 2 +- controllers/api/imap.go | 62 +++ controllers/api/server.go | 2 + .../migrations/20200116000000_0.9.0_imap.sql | 8 + .../migrations/20200116000000_0.9.0_imap.sql | 8 + gophish.go | 7 + imap/imap.go | 352 ++++++++++++++++++ imap/monitor.go | 194 ++++++++++ models/imap.go | 153 ++++++++ static/js/src/app/gophish.js | 32 ++ static/js/src/app/settings.js | 204 ++++++++++ templates/settings.html | 140 +++++++ 12 files changed, 1163 insertions(+), 1 deletion(-) create mode 100644 controllers/api/imap.go create mode 100644 db/db_mysql/migrations/20200116000000_0.9.0_imap.sql create mode 100644 db/db_sqlite3/migrations/20200116000000_0.9.0_imap.sql create mode 100644 imap/imap.go create mode 100644 imap/monitor.go create mode 100644 models/imap.go diff --git a/config.json b/config.json index d25df02e..a2b813eb 100644 --- a/config.json +++ b/config.json @@ -19,4 +19,4 @@ "filename": "", "level": "" } -} \ No newline at end of file +} diff --git a/controllers/api/imap.go b/controllers/api/imap.go new file mode 100644 index 00000000..9c86af12 --- /dev/null +++ b/controllers/api/imap.go @@ -0,0 +1,62 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + ctx "github.com/gophish/gophish/context" + "github.com/gophish/gophish/imap" + "github.com/gophish/gophish/models" +) + +// IMAPServerValidate handles requests for the /api/imapserver/validate endpoint +func (as *Server) IMAPServerValidate(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + JSONResponse(w, models.Response{Success: false, Message: "Only POSTs allowed"}, http.StatusBadRequest) + case r.Method == "POST": + im := models.IMAP{} + err := json.NewDecoder(r.Body).Decode(&im) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid request"}, http.StatusBadRequest) + return + } + err = imap.Validate(&im) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusOK) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Successful login."}, http.StatusCreated) + } +} + +// IMAPServer handles requests for the /api/imapserver/ endpoint +func (as *Server) IMAPServer(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + ss, err := models.GetIMAP(ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, ss, http.StatusOK) + + // POST: Update database + case r.Method == "POST": + im := models.IMAP{} + err := json.NewDecoder(r.Body).Decode(&im) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid data. Please check your IMAP settings."}, http.StatusBadRequest) + return + } + im.ModifiedDate = time.Now().UTC() + im.UserId = ctx.Get(r, "user_id").(int64) + err = models.PostIMAP(&im, ctx.Get(r, "user_id").(int64)) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Successfully saved IMAP settings."}, http.StatusCreated) + } +} diff --git a/controllers/api/server.go b/controllers/api/server.go index 40fd03a3..9fcc9ee3 100644 --- a/controllers/api/server.go +++ b/controllers/api/server.go @@ -48,6 +48,8 @@ func (as *Server) registerRoutes() { router := root.PathPrefix("/api/").Subrouter() router.Use(mid.RequireAPIKey) router.Use(mid.EnforceViewOnly) + router.HandleFunc("/imap/", as.IMAPServer) + router.HandleFunc("/imap/validate", as.IMAPServerValidate) router.HandleFunc("/reset", as.Reset) router.HandleFunc("/campaigns/", as.Campaigns) router.HandleFunc("/campaigns/summary", as.CampaignsSummary) diff --git a/db/db_mysql/migrations/20200116000000_0.9.0_imap.sql b/db/db_mysql/migrations/20200116000000_0.9.0_imap.sql new file mode 100644 index 00000000..e583a1df --- /dev/null +++ b/db/db_mysql/migrations/20200116000000_0.9.0_imap.sql @@ -0,0 +1,8 @@ + +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +CREATE TABLE IF NOT EXISTS `imap` (user_id bigint,host varchar(255),port int,username varchar(255),password varchar(255),modified_date datetime,tls boolean,enabled boolean,folder varchar(255),restrict_domain varchar(255),delete_reported_campaign_email boolean,last_login datetime,imap_freq int); + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back +DROP TABLE `imap`; diff --git a/db/db_sqlite3/migrations/20200116000000_0.9.0_imap.sql b/db/db_sqlite3/migrations/20200116000000_0.9.0_imap.sql new file mode 100644 index 00000000..998769a0 --- /dev/null +++ b/db/db_sqlite3/migrations/20200116000000_0.9.0_imap.sql @@ -0,0 +1,8 @@ + +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +CREATE TABLE IF NOT EXISTS "imap" ("user_id" bigint, "host" varchar(255), "port" integer, "username" varchar(255), "password" varchar(255), "modified_date" datetime default CURRENT_TIMESTAMP, "tls" BOOLEAN, "enabled" BOOLEAN, "folder" varchar(255), "restrict_domain" varchar(255), "delete_reported_campaign_email" BOOLEAN, "last_login" datetime, "imap_freq" integer); + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back +DROP TABLE "imap"; diff --git a/gophish.go b/gophish.go index c8505da8..08a1a83f 100644 --- a/gophish.go +++ b/gophish.go @@ -34,6 +34,7 @@ import ( "github.com/gophish/gophish/config" "github.com/gophish/gophish/controllers" + "github.com/gophish/gophish/imap" log "github.com/gophish/gophish/logger" "github.com/gophish/gophish/middleware" "github.com/gophish/gophish/models" @@ -80,6 +81,7 @@ func main() { if err != nil { log.Fatal(err) } + // Unlock any maillogs that may have been locked for processing // when Gophish was last shutdown. err = models.UnlockAllMailLogs() @@ -99,8 +101,11 @@ func main() { phishConfig := conf.PhishConf phishServer := controllers.NewPhishingServer(phishConfig) + imapMonitor := imap.NewMonitor() + go adminServer.Start() go phishServer.Start() + go imapMonitor.Start() // Handle graceful shutdown c := make(chan os.Signal, 1) @@ -109,4 +114,6 @@ func main() { log.Info("CTRL+C Received... Gracefully shutting down servers") adminServer.Shutdown() phishServer.Shutdown() + imapMonitor.Shutdown() + } diff --git a/imap/imap.go b/imap/imap.go new file mode 100644 index 00000000..f895aac8 --- /dev/null +++ b/imap/imap.go @@ -0,0 +1,352 @@ +package imap + +// Functionality taken from https://github.com/jprobinson/eazye + +import ( + "bytes" + "crypto/tls" + "fmt" + "strconv" + "time" + + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/jordan-wright/email" + "github.com/mxk/go-imap/imap" +) + +// Client interface for IMAP interactions +type Client interface { + Close(expunge bool) (cmd *imap.Command, err error) + Login(username, password string) (cmd *imap.Command, err error) + Logout(timeout time.Duration) (cmd *imap.Command, err error) + Select(mbox string, readonly bool) (cmd *imap.Command, err error) + UIDFetch(seq *imap.SeqSet, items ...string) (cmd *imap.Command, err error) + UIDSearch(spec ...imap.Field) (cmd *imap.Command, err error) + UIDStore(seq *imap.SeqSet, item string, value imap.Field) (cmd *imap.Command, err error) +} + +// Email represents an email.Email with an included IMAP UID +type Email struct { + UID uint32 `json:"uid"` + *email.Email +} + +// Mailbox holds onto the credentials and other information +// needed for connecting to an IMAP server. +type Mailbox struct { + Host string + TLS bool + User string + Pwd string + Folder string + // Read only mode, false (original logic) if not initialized + ReadOnly bool +} + +// GetAll will pull all emails from the email folder and return them as a list. +func (mbox *Mailbox) GetAll(markAsRead, delete bool) ([]Email, error) { + // call chan, put 'em in a list, return + var emails []Email + responses, err := mbox.GenerateAll(markAsRead, delete) + if err != nil { + return emails, err + } + + for resp := range responses { + if resp.Err != nil { + return emails, resp.Err + } + emails = append(emails, resp.Email) + } + + return emails, nil +} + +// GenerateAll will find all emails in the email folder and pass them along to the responses channel. +func (mbox *Mailbox) GenerateAll(markAsRead, delete bool) (chan Response, error) { + return mbox.generateMail("ALL", nil, markAsRead, delete) +} + +// GetUnread will find all unread emails in the folder and return them as a list. +func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) { + // call chan, put 'em in a list, return + var emails []Email + + responses, err := mbox.GenerateUnread(markAsRead, delete) + if err != nil { + return emails, err + } + + for resp := range responses { + if resp.Err != nil { + return emails, resp.Err + } + emails = append(emails, resp.Email) + } + + return emails, nil +} + +// GenerateUnread will find all unread emails in the folder and pass them along to the responses channel. +func (mbox *Mailbox) GenerateUnread(markAsRead, delete bool) (chan Response, error) { + return mbox.generateMail("UNSEEN", nil, markAsRead, delete) +} + +// MarkAsUnread will set the UNSEEN flag on a supplied slice of UIDs +func (mbox *Mailbox) MarkAsUnread(uids []uint32) error { + client, err := mbox.newClient() + if err != nil { + return err + } + defer func() { + client.Close(true) + client.Logout(30 * time.Second) + }() + for _, u := range uids { + err := alterEmail(client, u, "\\SEEN", false) + if err != nil { + return err //return on first failure + } + } + return nil + +} + +// DeleteEmails will delete emails from the supplied slice of UIDs +func (mbox *Mailbox) DeleteEmails(uids []uint32) error { + client, err := mbox.newClient() + if err != nil { + return err + } + defer func() { + client.Close(true) + client.Logout(30 * time.Second) + }() + for _, u := range uids { + err := deleteEmail(client, u) + if err != nil { + return err //return on first failure + } + } + return nil + +} + +// Validate validates supplied IMAP model by connecting to the server +func Validate(s *models.IMAP) error { + + err := s.Validate() + if err != nil { + log.Error(err) + return err + } + + s.Host = s.Host + ":" + strconv.Itoa(int(s.Port)) // Append port + mailServer := Mailbox{ + Host: s.Host, + TLS: s.TLS, + User: s.Username, + Pwd: s.Password, + Folder: s.Folder} + + client, err := mailServer.newClient() + if err != nil { + log.Error(err.Error()) + } else { + client.Close(true) + client.Logout(30 * time.Second) + } + return err +} + +// Response is a helper struct to wrap the email responses and possible errors. +type Response struct { + Email Email + Err error +} + +// newClient will initiate a new IMAP connection with the given creds. +func (mbox *Mailbox) newClient() (*imap.Client, error) { + var client *imap.Client + var err error + if mbox.TLS { + client, err = imap.DialTLS(mbox.Host, new(tls.Config)) + if err != nil { + return client, err + } + } else { + client, err = imap.Dial(mbox.Host) + if err != nil { + return client, err + } + } + + _, err = client.Login(mbox.User, mbox.Pwd) + if err != nil { + return client, err + } + + _, err = imap.Wait(client.Select(mbox.Folder, mbox.ReadOnly)) + if err != nil { + return client, err + } + + return client, nil +} + +const dateFormat = "02-Jan-2006" + +// findEmails will run a find the UIDs of any emails that match the search.: +func findEmails(client Client, search string, since *time.Time) (*imap.Command, error) { + var specs []imap.Field + if len(search) > 0 { + specs = append(specs, search) + } + + if since != nil { + sinceStr := since.Format(dateFormat) + specs = append(specs, "SINCE", sinceStr) + } + + // get headers and UID for UnSeen message in src inbox... + cmd, err := imap.Wait(client.UIDSearch(specs...)) + if err != nil { + return &imap.Command{}, fmt.Errorf("uid search failed: %s", err) + } + return cmd, nil +} + +const GenerateBufferSize = 100 + +func (mbox *Mailbox) generateMail(search string, since *time.Time, markAsRead, delete bool) (chan Response, error) { + responses := make(chan Response, GenerateBufferSize) + client, err := mbox.newClient() + if err != nil { + close(responses) + return responses, fmt.Errorf("failed to create IMAP connection: %s", err) + } + + go func() { + defer func() { + client.Close(true) + client.Logout(30 * time.Second) + close(responses) + }() + + var cmd *imap.Command + // find all the UIDs + cmd, err = findEmails(client, search, since) + if err != nil { + responses <- Response{Err: err} + return + } + // gotta fetch 'em all + getEmails(client, cmd, markAsRead, delete, responses) + }() + + return responses, nil +} + +func getEmails(client Client, cmd *imap.Command, markAsRead, delete bool, responses chan Response) { + seq := &imap.SeqSet{} + msgCount := 0 + for _, rsp := range cmd.Data { + for _, uid := range rsp.SearchResults() { + msgCount++ + seq.AddNum(uid) + } + } + + if seq.Empty() { + return + } + + fCmd, err := imap.Wait(client.UIDFetch(seq, "INTERNALDATE", "BODY[]", "UID", "RFC822.HEADER")) + if err != nil { + responses <- Response{Err: fmt.Errorf("unable to perform uid fetch: %s", err)} + return + } + + var email Email + for _, msgData := range fCmd.Data { + msgFields := msgData.MessageInfo().Attrs + + // make sure is a legit response before we attempt to parse it + // deal with unsolicited FETCH responses containing only flags + // I'm lookin' at YOU, Gmail! + // http://mailman13.u.washington.edu/pipermail/imap-protocol/2014-October/002355.html + // http://stackoverflow.com/questions/26262472/gmail-imap-is-sometimes-returning-bad-results-for-fetch + if _, ok := msgFields["RFC822.HEADER"]; !ok { + continue + } + + email, err = NewEmail(msgFields) + if err != nil { + responses <- Response{Err: fmt.Errorf("unable to parse email: %s", err)} + return + } + + responses <- Response{Email: email} + + if !markAsRead { + err = removeSeen(client, imap.AsNumber(msgFields["UID"])) + if err != nil { + responses <- Response{Err: fmt.Errorf("unable to remove seen flag: %s", err)} + return + } + } + + if delete { + err = deleteEmail(client, imap.AsNumber(msgFields["UID"])) + if err != nil { + responses <- Response{Err: fmt.Errorf("unable to delete email: %s", err)} + return + } + } + } + return +} + +func deleteEmail(client Client, UID uint32) error { + return alterEmail(client, UID, "\\DELETED", true) +} + +func removeSeen(client Client, UID uint32) error { + return alterEmail(client, UID, "\\SEEN", false) +} + +func alterEmail(client Client, UID uint32, flag string, plus bool) error { + flg := "-FLAGS" + if plus { + flg = "+FLAGS" + } + fSeq := &imap.SeqSet{} + fSeq.AddNum(UID) + _, err := imap.Wait(client.UIDStore(fSeq, flg, flag)) + if err != nil { + return err + } + + return nil +} + +// NewEmail will parse an imap.FieldMap into an Email. This +// will expect the message to container the internaldate and the body with +// all headers included. +func NewEmail(msgFields imap.FieldMap) (Email, error) { + + rawBody := imap.AsBytes(msgFields["BODY[]"]) + + rawBodyStream := bytes.NewReader(rawBody) + em, err := email.NewEmailFromReader(rawBodyStream) // Parse with @jordanwright's library + if err != nil { + return Email{}, err + } + iem := Email{ + Email: em, + UID: imap.AsNumber(msgFields["UID"]), + } + + return iem, err +} diff --git a/imap/monitor.go b/imap/monitor.go new file mode 100644 index 00000000..4c0a61a9 --- /dev/null +++ b/imap/monitor.go @@ -0,0 +1,194 @@ +package imap + +/* TODO: +* - Have a counter per config for number of consecutive login errors and backoff (e.g if supplied creds are incorrect) +* - Have a DB field "last_login_error" if last login failed +* - DB counter for non-campaign emails that the admin should investigate +* - Add field to User for numner of non-campaign emails reported + */ +import ( + "context" + "regexp" + "strconv" + "strings" + "time" + + log "github.com/gophish/gophish/logger" + + "github.com/gophish/gophish/models" +) + +// Pattern for GoPhish emails e.g ?rid=AbC123 +var goPhishRegex = regexp.MustCompile("(\\?rid=[A-Za-z0-9]{7})") + +// Monitor is a worker that monitors IMAP servers for reported campaign emails +type Monitor struct { + cancel func() +} + +// Monitor.start() checks for campaign emails +// As each account can have its own polling frequency set we need to run one Go routine for +// each, as well as keeping an eye on newly created user accounts. +func (im *Monitor) start(ctx context.Context) { + + usermap := make(map[int64]int) // Keep track of running go routines, one per user. We assume incrementing non-repeating UIDs (for the case where users are deleted and re-added). + + for { + select { + case <-ctx.Done(): + return + default: + dbusers, err := models.GetUsers() //Slice of all user ids. Each user gets their own IMAP monitor routine. + if err != nil { + log.Error(err) + break + } + for _, dbuser := range dbusers { + if _, ok := usermap[dbuser.Id]; !ok { // If we don't currently have a running Go routine for this user, start one. + log.Info("Starting new IMAP monitor for user ", dbuser.Username) + usermap[dbuser.Id] = 1 + go monitor(dbuser.Id, ctx) + } + } + time.Sleep(10 * time.Second) // Every ten seconds we check if a new user has been created + } + } +} + +// monitor will continuously login to the IMAP settings associated to the supplied user id (if the user account has IMAP settings, and they're enabled.) +// It also verifies the user account exists, and returns if not (for the case of a user being deleted). +func monitor(uid int64, ctx context.Context) { + + for { + select { + case <-ctx.Done(): + return + default: + // 1. Check if user exists, if not, return. + _, err := models.GetUser(uid) + if err != nil { // Not sure if there's a better way to determine user existence via id. + log.Info("User ", uid, " seems to have been deleted. Stopping IMAP monitor for this user.") + return + } + // 2. Check if user has IMAP settings. + imapSettings, err := models.GetIMAP(uid) + if err != nil { + log.Error(err) + break + } + if len(imapSettings) > 0 { + im := imapSettings[0] + // 3. Check if IMAP is enabled + if im.Enabled { + log.Debug("Checking IMAP for user ", uid, ": ", im.Username, "@", im.Host) + checkForNewEmails(im) + time.Sleep((time.Duration(im.IMAPFreq) - 10) * time.Second) // Subtract 10 to compensate for the default sleep of 10 at the bottom + } + } + } + time.Sleep(10 * time.Second) + } +} + +// NewMonitor returns a new instance of imap.Monitor +func NewMonitor() *Monitor { + + im := &Monitor{} + return im +} + +// Start launches the IMAP campaign monitor +func (im *Monitor) Start() error { + log.Info("Starting IMAP monitor manager") + ctx, cancel := context.WithCancel(context.Background()) // ctx is the derivedContext + im.cancel = cancel + go im.start(ctx) + return nil +} + +// Shutdown attempts to gracefully shutdown the IMAP monitor. +func (im *Monitor) Shutdown() error { + log.Info("Shutting down IMAP monitor manager") + im.cancel() + return nil +} + +// checkForNewEmails logs into an IMAP account and checks unread emails +// for the rid campaign identifier. +func checkForNewEmails(im models.IMAP) { + + im.Host = im.Host + ":" + strconv.Itoa(int(im.Port)) // Append port + mailServer := Mailbox{ + Host: im.Host, + TLS: im.TLS, + User: im.Username, + Pwd: im.Password, + Folder: im.Folder} + + msgs, err := mailServer.GetUnread(true, false) + if err != nil { + log.Error(err) + return + } + // Update last_succesful_login here via im.Host + err = models.SuccessfulLogin(&im) + + if len(msgs) > 0 { + var reportingFailed []uint32 // UIDs of emails that were unable to be reported to phishing server, mark as unread + var campaignEmails []uint32 // UIDs of campaign emails. If DeleteReportedCampaignEmail is true, we will delete these + for _, m := range msgs { + // Check if sender is from company's domain, if enabled. TODO: Make this an IMAP filter + if im.RestrictDomain != "" { // e.g domainResitct = widgets.com + splitEmail := strings.Split(m.Email.From, "@") + senderDomain := splitEmail[len(splitEmail)-1] + if senderDomain != im.RestrictDomain { + log.Debug("Ignoring email as not from company domain: ", senderDomain) + continue + } + } + + body := string(append(m.Email.Text, m.Email.HTML...)) // Not sure if we need to check the Text as well as the HTML. Perhaps sometimes Text only emails won't have an HTML component? + rid := goPhishRegex.FindString(body) + + if rid != "" { + rid = rid[5:] + log.Infof("User '%s' reported email with rid %s", m.Email.From, rid) + result, err := models.GetResult(rid) + if err != nil { + log.Error("Error reporting GoPhish email with rid ", rid, ": ", err.Error()) + reportingFailed = append(reportingFailed, m.UID) + } else { + err = result.HandleEmailReport(models.EventDetails{}) + if err != nil { + log.Error("Error updating GoPhish email with rid ", rid, ": ", err.Error()) + } else { + if im.DeleteReportedCampaignEmail == true { + campaignEmails = append(campaignEmails, m.UID) + } + } + } + } else { + // In the future this should be an alert in Gophish + log.Debugf("User '%s' reported email with subject '%s'. This is not a GoPhish campaign; you should investigate it.\n", m.Email.From, m.Email.Subject) + } + // Check if any emails were unable to be reported, so we can mark them as unread + if len(reportingFailed) > 0 { + log.Debugf("Marking %d emails as unread as failed to report\n", len(reportingFailed)) + err := mailServer.MarkAsUnread(reportingFailed) // Set emails as unread that we failed to report to GoPhish + if err != nil { + log.Error("Unable to mark emails as unread: ", err.Error()) + } + } + // If the DeleteReportedCampaignEmail flag is set, delete reported Gophish campaign emails + if im.DeleteReportedCampaignEmail == true && len(campaignEmails) > 0 { + log.Debugf("Deleting %d campaign emails\n", len(campaignEmails)) + err := mailServer.DeleteEmails(campaignEmails) // Delete GoPhish campaign emails. + if err != nil { + log.Error("Failed to delete emails: ", err.Error()) + } + } + } + } else { + log.Debug("No new emails for ", im.Username) + } +} diff --git a/models/imap.go b/models/imap.go new file mode 100644 index 00000000..8218d5ea --- /dev/null +++ b/models/imap.go @@ -0,0 +1,153 @@ +package models + +import ( + "errors" + "net" + "time" + + log "github.com/gophish/gophish/logger" +) + +const DefaultIMAPFolder = "INBOX" +const DefaultIMAPFreq = 60 // Every 60 seconds + +// IMAP contains the attributes needed to handle logging into an IMAP server to check +// for reported emails +type IMAP struct { + UserId int64 `json:"-" gorm:"column:user_id"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port uint16 `json:"port,string,omitempty"` + Username string `json:"username"` + Password string `json:"password"` + TLS bool `json:"tls"` + Folder string `json:"folder"` + RestrictDomain string `json:"restrict_domain"` + DeleteReportedCampaignEmail bool `json:"delete_reported_campaign_email"` + LastLogin time.Time `json:"last_login,omitempty"` + ModifiedDate time.Time `json:"modified_date"` + IMAPFreq uint32 `json:"imap_freq,string,omitempty"` +} + +// ErrIMAPHostNotSpecified is thrown when there is no Host specified +// in the IMAP configuration +var ErrIMAPHostNotSpecified = errors.New("No IMAP Host specified") + +// ErrIMAPPortNotSpecified is thrown when there is no Port specified +// in the IMAP configuration +var ErrIMAPPortNotSpecified = errors.New("No IMAP Port specified") + +// ErrInvalidIMAPHost indicates that the IMAP server string is invalid +var ErrInvalidIMAPHost = errors.New("Invalid IMAP server address") + +// ErrInvalidIMAPPort indicates that the IMAP Port is invalid +var ErrInvalidIMAPPort = errors.New("Invalid IMAP Port") + +// ErrIMAPUsernameNotSpecified is thrown when there is no Username specified +// in the IMAP configuration +var ErrIMAPUsernameNotSpecified = errors.New("No Username specified") + +// ErrIMAPPasswordNotSpecified is thrown when there is no Password specified +// in the IMAP configuration +var ErrIMAPPasswordNotSpecified = errors.New("No Password specified") + +// ErrInvalidIMAPFreq is thrown when the frequency for polling the +// IMAP server is invalid +var ErrInvalidIMAPFreq = errors.New("Invalid polling frequency.") + +// TableName specifies the database tablename for Gorm to use +func (im IMAP) TableName() string { + return "imap" +} + +// Validate ensures that IMAP configs/connections are valid +func (im *IMAP) Validate() error { + switch { + case im.Host == "": + return ErrIMAPHostNotSpecified + case im.Port == 0: + return ErrIMAPPortNotSpecified + case im.Username == "": + return ErrIMAPUsernameNotSpecified + case im.Password == "": + return ErrIMAPPasswordNotSpecified + } + + // Set the default value for Folder + if im.Folder == "" { + im.Folder = DefaultIMAPFolder + } + + // Make sure im.Host is an IP or hostname. NB will fail if unable to resolve the hostname. + ip := net.ParseIP(im.Host) + _, err := net.LookupHost(im.Host) + if ip == nil && err != nil { + return ErrInvalidIMAPHost + } + + // Make sure 1 >= port <= 65535 + if im.Port < 1 || im.Port > 65535 { + return ErrInvalidIMAPPort + } + + // Make sure the polling frequency is between every 30 seconds and every year + // If not set it to the default + if im.IMAPFreq < 30 || im.IMAPFreq > 31540000 { + im.IMAPFreq = DefaultIMAPFreq + } + + return nil +} + +// GetIMAP returns the IMAP server owned by the given user. +func GetIMAP(uid int64) ([]IMAP, error) { + im := []IMAP{} + count := 0 + err := db.Where("user_id=?", uid).Find(&im).Count(&count).Error + + if err != nil { + log.Error(err) + return im, err + } + return im, nil +} + +// PostIMAP updates IMAP settings for a user in the database. +func PostIMAP(im *IMAP, uid int64) error { + err := im.Validate() + if err != nil { + log.Error(err) + return err + } + + // Delete old entry. TODO: Save settings and if fails to Save below replace with original + err = DeleteIMAP(uid) + if err != nil { + log.Error(err) + return err + } + + // Insert new settings into the DB + err = db.Save(im).Error + if err != nil { + log.Error("Unable to save to database: ", err.Error()) + } + return err +} + +// DeleteIMAP deletes the existing IMAP in the database. +func DeleteIMAP(uid int64) error { + err := db.Where("user_id=?", uid).Delete(&IMAP{}).Error + if err != nil { + log.Error(err) + } + return err +} + +func SuccessfulLogin(im *IMAP) error { + err := db.Model(&im).Where("user_id = ?", im.UserId).Update("last_login", time.Now().UTC()).Error + if err != nil { + log.Error("Unable to update database: ", err.Error()) + } + return err +} diff --git a/static/js/src/app/gophish.js b/static/js/src/app/gophish.js index 8f22f5a3..cb17d1da 100644 --- a/static/js/src/app/gophish.js +++ b/static/js/src/app/gophish.js @@ -10,6 +10,26 @@ function successFlash(message) { " + message + "") } +// Fade message after n seconds +function errorFlashFade(message, fade) { + $("#flashes").empty() + $("#flashes").append("
\ + " + message + "
") + setTimeout(function(){ + $("#flashes").empty() + }, fade * 1000); +} +// Fade message after n seconds +function successFlashFade(message, fade) { + $("#flashes").empty() + $("#flashes").append("
\ + " + message + "
") + setTimeout(function(){ + $("#flashes").empty() + }, fade * 1000); + +} + function modalError(message) { $("#modal\\.flashes").empty().append("
\ " + message + "
") @@ -197,6 +217,18 @@ var api = { return query("/smtp/" + id, "DELETE", {}, false) } }, + // IMAP containts the endpoints for /imap/ + IMAP: { + get: function() { + return query("/imap/", "GET", {}, !1) + }, + post: function(e) { + return query("/imap/", "POST", e, !1) + }, + validate: function(e) { + return query("/imap/validate", "POST", e, true) + } + }, // users contains the endpoints for /users users: { // get() - Queries the API for GET /users diff --git a/static/js/src/app/settings.js b/static/js/src/app/settings.js index 5f4998e8..087609df 100644 --- a/static/js/src/app/settings.js +++ b/static/js/src/app/settings.js @@ -1,4 +1,5 @@ $(document).ready(function () { + $('[data-toggle="tooltip"]').tooltip(); $("#apiResetForm").submit(function (e) { api.reset() .success(function (response) { @@ -21,9 +22,212 @@ $(document).ready(function () { }) return false }) + //$("#imapForm").submit(function (e) { + $("#savesettings").click(function() { + var imapSettings = {} + imapSettings.host = $("#imaphost").val() + imapSettings.port = $("#imapport").val() + imapSettings.username = $("#imapusername").val() + imapSettings.password = $("#imappassword").val() + imapSettings.enabled = $('#use_imap').prop('checked') + imapSettings.tls = $('#use_tls').prop('checked') + + //Advanced settings + imapSettings.folder = $("#folder").val() + imapSettings.imap_freq = $("#imapfreq").val() + imapSettings.restrict_domain = $("#restrictdomain").val() + imapSettings.delete_reported_campaign_email = $('#deletecampaign').prop('checked') + + //To avoid unmarshalling error in controllers/api/imap.go. It would fail gracefully, but with a generic error. + if (imapSettings.host == ""){ + errorFlash("No IMAP Host specified") + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + return false + } + if (imapSettings.port == ""){ + errorFlash("No IMAP Port specified") + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + return false + } + if (isNaN(imapSettings.port) || imapSettings.port <1 || imapSettings.port > 65535 ){ + errorFlash("Invalid IMAP Port") + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + return false + } + if (imapSettings.imap_freq == ""){ + imapSettings.imap_freq = "60" + } + + api.IMAP.post(imapSettings).done(function (data) { + if (data.success == true) { + successFlashFade("Successfully updated IMAP settings.", 2) + } else { + errorFlash("Unable to update IMAP settings.") + } + }) + .success(function (data){ + loadIMAPSettings() + }) + .fail(function (data) { + errorFlash(data.responseJSON.message) + }) + .always(function (data){ + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + }) + + return false + }) + + $("#validateimap").click(function() { + + // Query validate imap server endpoint + var server = {} + server.host = $("#imaphost").val() + server.port = $("#imapport").val() + server.username = $("#imapusername").val() + server.password = $("#imappassword").val() + server.tls = $('#use_tls').prop('checked') + + //To avoid unmarshalling error in controllers/api/imap.go. It would fail gracefully, but with a generic error. + if (server.host == ""){ + errorFlash("No IMAP Host specified") + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + return false + } + if (server.port == ""){ + errorFlash("No IMAP Port specified") + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + return false + } + if (isNaN(server.port) || server.port <1 || server.port > 65535 ){ + errorFlash("Invalid IMAP Port") + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + return false + } + + var oldHTML = $("#validateimap").html(); + // Disable inputs and change button text + $("#imaphost").attr("disabled", true); + $("#imapport").attr("disabled", true); + $("#imapusername").attr("disabled", true); + $("#imappassword").attr("disabled", true); + $("#use_imap").attr("disabled", true); + $("#use_tls").attr("disabled", true); + $("#folder").attr("disabled", true); + $("#restrictdomain").attr("disabled", true); + $('#deletecampaign').attr("disabled", true); + $('#lastlogin').attr("disabled", true); + $('#imapfreq').attr("disabled", true); + $("#validateimap").attr("disabled", true); + $("#validateimap").html(" Testing..."); + + api.IMAP.validate(server).done(function(data) { + if (data.success == true) { + Swal.fire({ + title: "Success", + html: "Logged into " + $("#imaphost").val() + "", + type: "success", + }) + } else { + Swal.fire({ + title: "Failed!", + html: "Unable to login to " + $("#imaphost").val() + ".", + type: "error", + showCancelButton: true, + cancelButtonText: "Close", + confirmButtonText: "More Info", + confirmButtonColor: "#428bca", + allowOutsideClick: false, + }).then(function(result) { + if (result.value) { + Swal.fire({ + title: "Error:", + text: data.message, + }) + } + }) + } + + }) + .fail(function() { + Swal.fire({ + title: "Failed!", + text: "An unecpected error occured.", + type: "error", + }) + }) + .always(function() { + //Re-enable inputs and change button text + $("#imaphost").attr("disabled", false); + $("#imapport").attr("disabled", false); + $("#imapusername").attr("disabled", false); + $("#imappassword").attr("disabled", false); + $("#use_imap").attr("disabled", false); + $("#use_tls").attr("disabled", false); + $("#folder").attr("disabled", false); + $("#restrictdomain").attr("disabled", false); + $('#deletecampaign').attr("disabled", false); + $('#lastlogin').attr("disabled", false); + $('#imapfreq').attr("disabled", false); + $("#validateimap").attr("disabled", false); + $("#validateimap").html(oldHTML); + + }); + + }); //end testclick + + $("#reporttab").click(function() { + loadIMAPSettings() + }) + + $("#advanced").click(function() { + $("#advancedarea").toggle(); + }) + + function loadIMAPSettings(){ + api.IMAP.get() + .success(function (imap) { + if (imap.length == 0){ + $('#lastlogindiv').hide() + } else { + imap = imap[0] + if (imap.enabled == false){ + $('#lastlogindiv').hide() + } else { + $('#lastlogindiv').show() + } + $("#imapusername").val(imap.username) + $("#imaphost").val(imap.host) + $("#imapport").val(imap.port) + $("#imappassword").val(imap.password) + $('#use_tls').prop('checked', imap.tls) + $('#use_imap').prop('checked', imap.enabled) + $("#folder").val(imap.folder) + $("#restrictdomain").val(imap.restrict_domain) + $('#deletecampaign').prop('checked', imap.delete_reported_campaign_email) + $('#lastloginraw').val(imap.last_login) + $('#lastlogin').val(moment.utc(imap.last_login).fromNow()) + $('#imapfreq').val(imap.imap_freq) + } + + }) + .error(function () { + errorFlash("Error fetching IMAP settings") + }) + } + var use_map = localStorage.getItem('gophish.use_map') $("#use_map").prop('checked', JSON.parse(use_map)) $("#use_map").on('change', function () { localStorage.setItem('gophish.use_map', JSON.stringify(this.checked)) }) + + loadIMAPSettings() }) \ No newline at end of file diff --git a/templates/settings.html b/templates/settings.html index eeac2c24..0f0ece47 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -10,6 +10,8 @@ data-toggle="tab">Account Settings
  • UI Settings
  • +
  • Reporting Settings
  • @@ -82,6 +84,144 @@
    + +
    +
    +
    +
    +
    + Monitor an IMAP account for emails reported by users. +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + + +
    + +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    + + +
    + +
    +
    + + +
    + +
    +
    + + + + +
    + +
    + +
    +
    + + + + + +
    +
    + {{end}} {{define "scripts"}}