summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSteve Manuel <nilslice@gmail.com>2016-11-09 18:09:36 -0800
committerSteve Manuel <nilslice@gmail.com>2016-11-09 18:09:36 -0800
commite70c231df82f4e5f09869f8d570d809a5f70993c (patch)
treee1a0c76ab0483c29b78ba7c0458f5f59b5ca0598
parentea3d29602be45c49629c2a8b6b8199e4f9e9076d (diff)
adding initial partial implementation of account recovery flow
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/LICENSE21
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/README.md51
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/email.go114
-rw-r--r--cmd/ponzu/vendor/github.com/nilslice/email/email_test.go19
-rw-r--r--system/admin/admin.go115
-rw-r--r--system/admin/handlers.go182
-rw-r--r--system/admin/server.go4
-rw-r--r--system/db/user.go55
8 files changed, 557 insertions, 4 deletions
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 <me@server.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 = `
</div>
<div class="input-field col s12">
<input placeholder="Enter your password" class="validate required" type="password" id="password" name="password"/>
- <label for="password" class="active">Password</label>
+ <a href="/admin/recover" class="right">Forgot password?</a>
+ <label for="password" class="active">Password</label>
</div>
<button class="btn waves-effect waves-light right">Log in</button>
</form>
@@ -246,6 +247,118 @@ func Login() ([]byte, error) {
return buf.Bytes(), nil
}
+var forgotPasswordHTML = `
+<div class="init col s5">
+<div class="card">
+<div class="card-content">
+ <div class="card-title">Account Recovery</div>
+ <blockquote>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.</blockquote>
+ <form method="post" action="/admin/recover" class="row">
+ <div class="input-field col s12">
+ <input placeholder="Enter your email address e.g. you@example.com" class="validate required" type="email" id="email" name="email"/>
+ <label for="email" class="active">Email</label>
+ </div>
+
+ <button class="btn waves-effect waves-light right">Send Recovery Email</button>
+ </form>
+</div>
+</div>
+</div>
+<script>
+ $(function() {
+ $('.nav-wrapper ul.right').hide();
+ });
+</script>
+`
+
+// 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 = `
+<div class="init col s5">
+<div class="card">
+<div class="card-content">
+ <div class="card-title">Account Recovery</div>
+ <blockquote>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.</blockquote>
+ <form method="post" action="/admin/recover/key" class="row">
+ <div class="input-field col s12">
+ <input placeholder="Enter your recovery key" class="validate required" type="text" id="key" name="key"/>
+ <label for="key" class="active">Recovery Key</label>
+ </div>
+
+ <div class="input-field col s12">
+ <input placeholder="Enter your email address e.g. you@example.com" class="validate required" type="email" id="email" name="email"/>
+ <label for="email" class="active">Email</label>
+ </div>
+
+ <div class="input-field col s12">
+ <input placeholder="Enter your password" class="validate required" type="password" id="password" name="password"/>
+ <label for="password" class="active">New Password</label>
+ </div>
+
+ <button class="btn waves-effect waves-light right">Update Account</button>
+ </form>
+</div>
+</div>
+</div>
+<script>
+ $(function() {
+ $('.nav-wrapper ul.right').hide();
+ });
+</script>
+`
+
+// 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 <ponzu-cms@%s", domain),
+ Subject: fmt.Sprintf("Account Recovery [%s]", domain),
+ Body: body,
+ }
+
+ /*
+ err = msg.Send()
+ if err != nil {
+ res.WriteHeader(http.StatusInternalServerError)
+ errView, err := Error500()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+ */
+ fmt.Println(msg)
+
+ // redirect to /admin/recover/key and send email with key and URL
+ http.Redirect(res, req, req.URL.Scheme+req.URL.Host+"/admin/recover/key", http.StatusFound)
+
+ default:
+ res.WriteHeader(http.StatusMethodNotAllowed)
+ errView, err := Error405()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+}
+
+func recoveryKeyHandler(res http.ResponseWriter, req *http.Request) {
+ switch req.Method {
+ case http.MethodGet:
+ view, err := RecoveryKey()
+ if err != nil {
+ res.WriteHeader(http.StatusInternalServerError)
+ errView, err := Error500()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+
+ res.Write(view)
+
+ case http.MethodPost:
+
+ // check for email & key match
+
+ // set user with new password
+
+ // redirect to /admin/login
+
+ default:
+ res.WriteHeader(http.StatusMethodNotAllowed)
+ errView, err := Error405()
+ if err != nil {
+ return
+ }
+
+ res.Write(errView)
+ return
+ }
+}
+
+func recoveryEditHandler(res http.ResponseWriter, req *http.Request) {
+
+}
+
func postsHandler(res http.ResponseWriter, req *http.Request) {
q := req.URL.Query()
t := q.Get("type")
if t == "" {
res.WriteHeader(http.StatusBadRequest)
- errView, err := Error405()
+ errView, err := Error400()
if err != nil {
return
}
@@ -837,9 +1013,9 @@ func approvePostHandler(res http.ResponseWriter, req *http.Request) {
}
// check if we have a Mergeable
- m, ok := post.(content.Mergeable)
+ m, ok := post.(editor.Mergeable)
if !ok {
- log.Println("Content type", t, "must implement api.Mergable before it can bee approved.")
+ log.Println("Content type", t, "must implement editor.Mergable before it can bee approved.")
res.WriteHeader(http.StatusBadRequest)
errView, err := Error400()
if err != nil {
diff --git a/system/admin/server.go b/system/admin/server.go
index 5d93d84..ef2ae4b 100644
--- a/system/admin/server.go
+++ b/system/admin/server.go
@@ -18,6 +18,10 @@ func Run() {
http.HandleFunc("/admin/login", loginHandler)
http.HandleFunc("/admin/logout", logoutHandler)
+ http.HandleFunc("/admin/recover", forgotPasswordHandler)
+ http.HandleFunc("/admin/recover/key", recoveryKeyHandler)
+ http.HandleFunc("/admin/recover/edit", recoveryEditHandler)
+
http.HandleFunc("/admin/configure", user.Auth(configHandler))
http.HandleFunc("/admin/configure/users", user.Auth(configUsersHandler))
http.HandleFunc("/admin/configure/users/edit", user.Auth(configUsersEditHandler))
diff --git a/system/db/user.go b/system/db/user.go
index d2dc3a9..3386dc2 100644
--- a/system/db/user.go
+++ b/system/db/user.go
@@ -11,6 +11,7 @@ import (
"github.com/boltdb/bolt"
"github.com/nilslice/jwt"
+ "github.com/nilslice/rand"
)
// ErrUserExists is used for the db to report to admin user of existing user
@@ -135,6 +136,10 @@ func User(email string) ([]byte, error) {
return nil, err
}
+ if val.Bytes() == nil {
+ return nil, ErrNoUserExists
+ }
+
return val.Bytes(), nil
}
@@ -184,3 +189,53 @@ func CurrentUser(req *http.Request) ([]byte, error) {
return usr, nil
}
+
+// SetRecoveryKey generates and saves a random secret key to verify an email
+// address submitted in order to recover/reset an account password
+func SetRecoveryKey(email string) (string, error) {
+ key := fmt.Sprintf("%d", rand.Int63())
+
+ err := store.Update(func(tx *bolt.Tx) error {
+ b, err := tx.CreateBucketIfNotExists([]byte("_recoveryKeys"))
+ if err != nil {
+ return err
+ }
+
+ err = b.Put([]byte(email), []byte(key))
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+
+ return key, nil
+}
+
+// RecoveryKey generates and saves a random secret key to verify an email
+// address submitted in order to recover/reset an account password
+func RecoveryKey(email string) (string, error) {
+ key := &bytes.Buffer{}
+
+ err := store.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("_recoveryKeys"))
+ if b == nil {
+ return errors.New("No database found for checking keys.")
+ }
+
+ _, err := key.Write(b.Get([]byte("email")))
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+
+ return key.String(), nil
+}