From e70c231df82f4e5f09869f8d570d809a5f70993c Mon Sep 17 00:00:00 2001 From: Steve Manuel Date: Wed, 9 Nov 2016 18:09:36 -0800 Subject: adding initial partial implementation of account recovery flow --- cmd/ponzu/vendor/github.com/nilslice/email/LICENSE | 21 +++ .../vendor/github.com/nilslice/email/README.md | 51 ++++++ .../vendor/github.com/nilslice/email/email.go | 114 +++++++++++++ .../vendor/github.com/nilslice/email/email_test.go | 19 +++ system/admin/admin.go | 115 ++++++++++++- system/admin/handlers.go | 182 ++++++++++++++++++++- system/admin/server.go | 4 + system/db/user.go | 55 +++++++ 8 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 cmd/ponzu/vendor/github.com/nilslice/email/LICENSE create mode 100644 cmd/ponzu/vendor/github.com/nilslice/email/README.md create mode 100644 cmd/ponzu/vendor/github.com/nilslice/email/email.go create mode 100644 cmd/ponzu/vendor/github.com/nilslice/email/email_test.go diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/LICENSE b/cmd/ponzu/vendor/github.com/nilslice/email/LICENSE new file mode 100644 index 0000000..6ac9da6 --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Steve Manuel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/README.md b/cmd/ponzu/vendor/github.com/nilslice/email/README.md new file mode 100644 index 0000000..323004c --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/README.md @@ -0,0 +1,51 @@ +## Email + +I needed a way to send email from a [Ponzu](https://ponzu-cms.org) installation +running on all kinds of systems without shelling out. `sendmail` or `postfix` et +al are not standard on all systems, and I didn't want to force users to add API +keys from a third-party just to send something like an account recovery email. + +### Usage: +`$ go get github.com/nilslice/email` + +```go +package main + +import ( + "fmt" + "github.com/nilslice/email" +) + +func main() { + msg := email.Message{ + To: "you@server.name", // do not add < > or name in quotes + From: "Name ", // ok to format in From field + Subject: "A simple email", + Body: "Plain text email body. HTML not yet supported, but send a PR!", + } + + err := msg.Send() + if err != nil { + fmt.Println(err) + } +} + +``` + +### Under the hood +`email` looks at a `Message`'s `To` field, splits the string on the @ symbol and +issues an MX lookup to find the mail exchange server(s). Then it iterates over +all the possibilities in combination with commonly used SMTP ports for non-SSL +clients: `25, 2525, & 587` + +It stops once it has an active client connected to a mail server and sends the +initial information, the message, and then closes the connection. + +Currently, this doesn't support any additional headers or `To` field formatting +(the recipient's email must be the only string `To` takes). Although these would +be fairly strightforward to implement, I don't need them yet.. so feel free to +contribute anything you find useful. + +#### Warning +Be cautious of how often you run this locally or in testing, as it's quite +likely your IP will be blocked/blacklisted if it is not already. \ No newline at end of file diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/email.go b/cmd/ponzu/vendor/github.com/nilslice/email/email.go new file mode 100644 index 0000000..ed843e3 --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/email.go @@ -0,0 +1,114 @@ +package email + +import ( + "fmt" + "net" + "net/smtp" + "strings" +) + +// Message creates a email to be sent +type Message struct { + To string + From string + Subject string + Body string +} + +var ( + ports = []int{25, 2525, 587} +) + +// Send sends a message to recipient(s) listed in the 'To' field of a Message +func (m Message) Send() error { + if !strings.Contains(m.To, "@") { + return fmt.Errorf("Invalid recipient address: <%s>", m.To) + } + + host := strings.Split(m.To, "@")[1] + addrs, err := net.LookupMX(host) + if err != nil { + return err + } + + c, err := newClient(addrs, ports) + if err != nil { + return err + } + + err = send(m, c) + if err != nil { + return err + } + + return nil +} + +func newClient(mx []*net.MX, ports []int) (*smtp.Client, error) { + for i := range mx { + for j := range ports { + server := strings.TrimSuffix(mx[i].Host, ".") + hostPort := fmt.Sprintf("%s:%d", server, ports[j]) + client, err := smtp.Dial(hostPort) + if err != nil { + if j == len(ports)-1 { + return nil, err + } + + continue + } + + return client, nil + } + } + + return nil, fmt.Errorf("Coudln't connect to servers %v on any common port.", mx) +} + +func send(m Message, c *smtp.Client) error { + if err := c.Mail(m.From); err != nil { + return err + } + + if err := c.Rcpt(m.To); err != nil { + return err + } + + msg, err := c.Data() + if err != nil { + return err + } + + if m.Subject != "" { + _, err = msg.Write([]byte("Subject: " + m.Subject + "\r\n")) + if err != nil { + return err + } + } + + if m.From != "" { + _, err = msg.Write([]byte("From: " + m.From + "\r\n")) + if err != nil { + return err + } + } + + if m.To != "" { + _, err = msg.Write([]byte("To: " + m.To + "\r\n")) + if err != nil { + return err + } + } + + _, err = fmt.Fprint(msg, m.Body) + if err != nil { + return err + } + + err = msg.Close() + if err != nil { + return err + } + + return nil +} diff --git a/cmd/ponzu/vendor/github.com/nilslice/email/email_test.go b/cmd/ponzu/vendor/github.com/nilslice/email/email_test.go new file mode 100644 index 0000000..3576a79 --- /dev/null +++ b/cmd/ponzu/vendor/github.com/nilslice/email/email_test.go @@ -0,0 +1,19 @@ +package email + +import ( + "testing" +) + +func TestSend(t *testing.T) { + m := Message{ + To: "", + From: "", + Subject: "", + Body: "", + } + + err := m.Send() + if err != nil { + t.Fatal("Send returned error:", err) + } +} diff --git a/system/admin/admin.go b/system/admin/admin.go index 8c13582..e0689b3 100644 --- a/system/admin/admin.go +++ b/system/admin/admin.go @@ -205,7 +205,8 @@ var loginAdminHTML = `
- + Forgot password? +
@@ -246,6 +247,118 @@ func Login() ([]byte, error) { return buf.Bytes(), nil } +var forgotPasswordHTML = ` +
+
+
+
Account Recovery
+
Please enter the email for your account and a recovery message will be sent to you at this address. Check your spam folder in case the message was flagged.
+
+
+ + +
+ + +
+
+
+
+ +` + +// ForgotPassword ... +func ForgotPassword() ([]byte, error) { + html := startAdminHTML + forgotPasswordHTML + endAdminHTML + + cfg, err := db.Config("name") + if err != nil { + return nil, err + } + + if cfg == nil { + cfg = []byte("") + } + + a := admin{ + Logo: string(cfg), + } + + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("forgotPassword").Parse(html)) + err = tmpl.Execute(buf, a) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +var recoveryKeyHTML = ` +
+
+
+
Account Recovery
+
Please check for your recovery key inside an email sent to the address you provided. Check your spam folder in case the message was flagged.
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ +` + +// RecoveryKey ... +func RecoveryKey() ([]byte, error) { + html := startAdminHTML + recoveryKeyHTML + endAdminHTML + + cfg, err := db.Config("name") + if err != nil { + return nil, err + } + + if cfg == nil { + cfg = []byte("") + } + + a := admin{ + Logo: string(cfg), + } + + buf := &bytes.Buffer{} + tmpl := template.Must(template.New("recoveryKey").Parse(html)) + err = tmpl.Execute(buf, a) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + // UsersList ... func UsersList(req *http.Request) ([]byte, error) { html := ` diff --git a/system/admin/handlers.go b/system/admin/handlers.go index fe04601..998db6f 100644 --- a/system/admin/handlers.go +++ b/system/admin/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/bosssauce/ponzu/system/db" "github.com/gorilla/schema" + emailer "github.com/nilslice/email" "github.com/nilslice/jwt" ) @@ -521,12 +522,187 @@ func logoutHandler(res http.ResponseWriter, req *http.Request) { http.Redirect(res, req, req.URL.Scheme+req.URL.Host+"/admin/login", http.StatusFound) } +func forgotPasswordHandler(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + view, err := ForgotPassword() + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + res.Write(view) + + case http.MethodPost: + err := req.ParseMultipartForm(1024 * 1024 * 4) // maxMemory 4MB + if err != nil { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + // check email for user, if no user return Error + email := strings.ToLower(req.FormValue("email")) + if email == "" { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + err, u := db.User(email) + if err.Error() == db.ErrNoUserExists { + res.WriteHeader(http.StatusBadRequest) + errView, err := Error400() + if err != nil { + return + } + + res.Write(errView) + return + } + + if err.Error() != db.ErrNoUserExists && err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + // create temporary key to verify user + key, err := db.SetRecoveryKey(email) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + errView, err := Error500() + if err != nil { + return + } + + res.Write(errView) + return + } + + domain := db.ConfigCache("domain") + body := fmt.Sprintf(` + There has been an account recovery request made for the user with email: + %s + + To recover your account, please go to http://%s/admin/recover/key and enter + this email address along with the following secret key: + + %s + + If you did not make the request, ignore this message and your password + will remain as-is. + + + Thank you, + Ponzu CMS at %s + + `, email, domain, key, domain) + msg := emailer.Message{ + To: email, + From: fmt.Sprintf("Ponzu CMS