Email verification progress

This commit is contained in:
Noah 2018-06-30 18:39:08 -07:00
parent ac845e8fe7
commit 26d1437aa5
12 changed files with 365 additions and 14 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
notes/ notes/
./run.sh
./dethnote ./dethnote
./dethnote.exe ./dethnote.exe

13
main.go
View File

@ -30,10 +30,8 @@ var (
root string root string
rootDefault = "./notes" rootDefault = "./notes"
// -mailgun-url <url>: mailgun API URL for sending out emails. // -smtp <url>: mail server settings, like "user:password@localhost:25"
// -mg <url> Takes the format "<api_key>@<api_base_url>" smtpURL string
// Example: "key-a1b23c@https://api.mailgun.net/v3/mg.example.com"
mailgunURL string
// -listen <address>: listen on an HTTP port at this address. // -listen <address>: listen on an HTTP port at this address.
// -l <address> // -l <address>
@ -68,8 +66,7 @@ func init() {
flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on") flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on")
flag.StringVar(&listen, "l", listenDefault, "HTTP address to listen on (shorthand)") flag.StringVar(&listen, "l", listenDefault, "HTTP address to listen on (shorthand)")
flag.StringVar(&mailgunURL, "mailgun-url", "", "Mailgun API URL for sending out emails") flag.StringVar(&smtpURL, "smtp", "localhost:22", "SMTP address for sending mail, in the format `[login:password@]server:port`")
flag.StringVar(&mailgunURL, "mg", "", "Mailgun API URL for sending out emails (shorthand)")
flag.BoolVar(&cmdOpen, "open", false, "Immediately open and print a secure note. The passphrase is provided via CLI arguments.") flag.BoolVar(&cmdOpen, "open", false, "Immediately open and print a secure note. The passphrase is provided via CLI arguments.")
flag.BoolVar(&cmdDelete, "delete", false, "Immediately delete a secure note. The passphrase is provided via CLI arguments.") flag.BoolVar(&cmdDelete, "delete", false, "Immediately delete a secure note. The passphrase is provided via CLI arguments.")
@ -110,7 +107,9 @@ func main() {
} }
app := dethnote.NewServer(root, debug) app := dethnote.NewServer(root, debug)
app.MailgunURL = mailgunURL if err := app.SetSMTP(smtpURL); err != nil {
panic(err)
}
app.SetupHTTP() app.SetupHTTP()
app.Run(listen) app.Run(listen)
} }

View File

@ -43,6 +43,9 @@ func (s *Server) CreateHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Send them a verification email.
s.SendVerificationEmail(r, m)
// Store the hash path in the browser's cookies. // Store the hash path in the browser's cookies.
s.SetCookie(w, "hash_path", vault.HashToFilename(m.PasswordHash)) s.SetCookie(w, "hash_path", vault.HashToFilename(m.PasswordHash))

125
src/mail.go Normal file
View File

@ -0,0 +1,125 @@
package dethnote
import (
"bytes"
"errors"
"fmt"
"net/mail"
"net/smtp"
"net/textproto"
"regexp"
"strconv"
)
var smtpURLRegexp = regexp.MustCompile(
`^` + // bind to beginning
`(?P<proto>(?:ssl|smtps)://)?` + // optional ssl:// protocol prefix
`(?:(?P<user>[^:]+):` + // `username:password@` syntax either
`(?P<pass>[^@]+)@)?` + // must exist or be entirely absent.
`(?P<host>[^:]+):?` + // `host[:port]` standard case
`(?P<port>[0-9]+)?` +
`$`,
)
// MailConfig holds SMTP settings.
type MailConfig struct {
Username string
Password string
Hostname string
Port int
SSL bool
}
func (m MailConfig) String() string {
username := ""
if m.Username != "" {
username = fmt.Sprintf("'%s'@", m.Username)
}
return fmt.Sprintf("%s%s:%d", username, m.Hostname, m.Port)
}
// ParseSMTPUrl validates and parses out the SMTP URL.
func ParseSMTPUrl(url string) (MailConfig, error) {
m := smtpURLRegexp.FindStringSubmatch(url)
if len(m) == 0 {
return MailConfig{}, errors.New("failed to match regexp")
}
ssl := m[1] != ""
port, _ := strconv.Atoi(m[5])
if port == 0 && ssl {
port = 465
} else if port == 0 {
port = 25
}
return MailConfig{
Username: m[2],
Password: m[3],
Hostname: m[4],
Port: port,
SSL: ssl,
}, nil
}
// Mail is an email message to be delivered.
type Mail struct {
From string
To string
Subject string
HTML string
}
// SetSMTP sets the SMTP config by URL.
func (s *Server) SetSMTP(url string) error {
config, err := ParseSMTPUrl(url)
if err != nil {
return err
}
s.smtp = config
Log.Info("Email server configuration: %s", config)
return nil
}
// SendMail sends an email out.
func (s *Server) SendMail(m Mail) error {
// Server config.
cfg := s.smtp
// validate from address
from, err := mail.ParseAddress(m.From)
if err != nil {
return err
}
// validate to address
to, err := mail.ParseAddress(m.To)
if err != nil {
return err
}
// set headers for html email
header := textproto.MIMEHeader{}
header.Set(textproto.CanonicalMIMEHeaderKey("from"), from.Address)
header.Set(textproto.CanonicalMIMEHeaderKey("to"), to.Address)
header.Set(textproto.CanonicalMIMEHeaderKey("content-type"), "text/html; charset=UTF-8")
header.Set(textproto.CanonicalMIMEHeaderKey("mime-version"), "1.0")
header.Set(textproto.CanonicalMIMEHeaderKey("subject"), m.Subject)
// init empty message
var buffer bytes.Buffer
// write header
for key, value := range header {
buffer.WriteString(fmt.Sprintf("%s: %s\r\n", key, value[0]))
}
// write body
buffer.WriteString(fmt.Sprintf("\r\n%s", m.HTML))
// send email
addr := fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port)
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Hostname)
return smtp.SendMail(addr, auth, from.Address, []string{to.Address}, buffer.Bytes())
}

72
src/mail_test.go Normal file
View File

@ -0,0 +1,72 @@
package dethnote_test
import (
"testing"
dethnote "git.kirsle.net/apps/dethnote/src"
)
func TestParseSMTPUrl(t *testing.T) {
type testPair struct {
url string
expect dethnote.MailConfig
}
tests := []testPair{
testPair{
url: "localhost:25",
expect: dethnote.MailConfig{
Hostname: "localhost",
Port: 25,
},
},
testPair{
url: "ssl://mail.example.com:465",
expect: dethnote.MailConfig{
Hostname: "mail.example.com",
Port: 465,
SSL: true,
},
},
testPair{
url: "user:pass@localhost:25",
expect: dethnote.MailConfig{
Username: "user",
Password: "pass",
Hostname: "localhost",
Port: 25,
},
},
testPair{
url: "smtps://user:pass@mail.example.com",
expect: dethnote.MailConfig{
Hostname: "mail.example.com",
Port: 465,
Username: "user",
Password: "pass",
SSL: true,
},
},
testPair{
url: "localhost",
expect: dethnote.MailConfig{
Hostname: "localhost",
Port: 25,
},
},
}
for _, test := range tests {
actual, _ := dethnote.ParseSMTPUrl(test.url)
if actual != test.expect {
t.Errorf(
"Unexpected parsing of SMTP URL\n"+
"URL: %s\n"+
"Expect: %+v\n"+
"Actual: %+v",
test.url,
test.expect,
actual,
)
}
}
}

View File

@ -9,8 +9,8 @@ import (
// Server is the master struct for the web app. // Server is the master struct for the web app.
type Server struct { type Server struct {
// Public configurable fields. // smtp server config
MailgunURL string // format is like "key-1a2b3c@https://api.mailgun.net/v3/mg.example.com" smtp MailConfig
root string root string
debug bool debug bool
@ -45,6 +45,17 @@ func (s *Server) SetupHTTP() {
"HashPath": s.GetCookie(r, "hash_path"), "HashPath": s.GetCookie(r, "hash_path"),
}) })
}) })
r.HandleFunc("/verify/{token}/{path:[A-Fa-f0-9/]+}", s.VerifyHandler)
r.HandleFunc("/tmp", func(w http.ResponseWriter, r *http.Request) {
err := s.Template(w, "mail/verify-email.gohtml", map[string]interface{}{
"Subject": "Verify Your Email",
"URLBase": URLBase(r),
"HashPath": s.GetCookie(r, "hash_path"),
})
if err != nil {
WriteError(w, err)
}
})
n := negroni.New( n := negroni.New(
negroni.NewRecovery(), negroni.NewRecovery(),

View File

@ -3,14 +3,23 @@ package dethnote
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"html/template" "html/template"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath"
) )
var functions template.FuncMap var functions template.FuncMap
// URLBase returns the base URL of the current HTTP request.
func URLBase(r *http.Request) string {
return fmt.Sprintf("https://%s",
r.Host,
)
}
// IncludeFunc implements the "Include" command for the templates. // IncludeFunc implements the "Include" command for the templates.
func (s *Server) IncludeFunc(name string, v ...interface{}) template.HTML { func (s *Server) IncludeFunc(name string, v ...interface{}) template.HTML {
buf := bytes.NewBuffer([]byte{}) buf := bytes.NewBuffer([]byte{})
@ -36,13 +45,16 @@ func (s *Server) Template(w io.Writer, name string, v interface{}) error {
"Include": s.IncludeFunc, "Include": s.IncludeFunc,
} }
} }
basename := filepath.Base(name)
// Find the template file locally. // Find the template file locally.
filename := "./templates/" + name filename := "./templates/" + name
if _, err := os.Stat(filename); os.IsNotExist(err) { if _, err := os.Stat(filename); os.IsNotExist(err) {
return errors.New("template not found: " + name) return errors.New("template not found: " + name)
} }
t := template.New(name).Funcs(functions) t := template.New(basename).Funcs(functions)
t, err := t.ParseFiles(filename) t, err := t.ParseFiles(filename)
if err != nil { if err != nil {
return err return err
@ -51,7 +63,7 @@ func (s *Server) Template(w io.Writer, name string, v interface{}) error {
// Write the template into a buffer in case of errors, so we can still // Write the template into a buffer in case of errors, so we can still
// control the outgoing HTTP status code. // control the outgoing HTTP status code.
buf := bytes.NewBuffer([]byte{}) buf := bytes.NewBuffer([]byte{})
err = t.ExecuteTemplate(buf, name, v) err = t.ExecuteTemplate(buf, basename, v)
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,6 +1,7 @@
package vault package vault
import ( import (
"encoding/hex"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
@ -10,9 +11,10 @@ import (
// Message is an encrypted file that contains the settings for a secure note, // Message is an encrypted file that contains the settings for a secure note,
// but does not contain the note itself. // but does not contain the note itself.
type Message struct { type Message struct {
Email string `json:"email"` // owner email address Email string `json:"email"` // owner email address
Verified bool `json:"verified"` // verified owner email? Verified bool `json:"verified"` // verified owner email?
Timeout int `json:"timeout"` // hours for the unlock window VerifyToken string `json:"token"` // verification token for confirmation link
Timeout int `json:"timeout"` // hours for the unlock window
PasswordHash []byte `json:"hash"` // to verify the password is correct PasswordHash []byte `json:"hash"` // to verify the password is correct
Created time.Time `json:"created"` Created time.Time `json:"created"`
@ -34,8 +36,14 @@ func NewMessage(email string, timeout int, message string, passwordLength int) (
return nil, err return nil, err
} }
verify, err := GenerateHash(password + email)
if err != nil {
return nil, err
}
return &Message{ return &Message{
Email: email, Email: email,
VerifyToken: hex.EncodeToString(verify),
Timeout: timeout, Timeout: timeout,
Message: message, Message: message,
Password: password, Password: password,

48
src/verify_email.go Normal file
View File

@ -0,0 +1,48 @@
package dethnote
import (
"bytes"
"fmt"
"net/http"
"git.kirsle.net/apps/dethnote/src/vault"
"github.com/gorilla/mux"
)
// VerifyHandler is the callback when a user verifies their email.
func (s *Server) VerifyHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
token := vars["token"]
path := vars["path"]
_, _ = token, path
}
// SendVerificationEmail sends a verification email.
func (s *Server) SendVerificationEmail(r *http.Request, m *vault.Message) error {
base := URLBase(r)
hashPath := vault.HashToFilename(m.PasswordHash)
v := map[string]interface{}{
"Subject": "Verify Your Email",
"VerifyLink": fmt.Sprintf("%s/verify/%s/%s",
base,
m.VerifyToken,
hashPath,
),
"DeleteLink": fmt.Sprintf("%s/delete/%s",
base,
hashPath,
),
}
html := bytes.NewBuffer([]byte{})
s.Template(html, "mail/verify-email.gohtml", v)
err := s.SendMail(Mail{
From: "noreply@kirsle.net", // TODO
To: m.Email,
Subject: v["Subject"].(string),
HTML: html.String(),
})
return err
}

View File

@ -0,0 +1,16 @@
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
This e-mail was automatically generated; do not reply to it.
</font>
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
<title>{{ .Subject }}</title>
</head>
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 20px 0; mso-line-height-rule: exactly;">
<center>
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
<b>Dethnote: {{ .Subject }}</b>
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#FEFEFE">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">

View File

@ -0,0 +1,33 @@
{{ Include "mail/header.gohtml" . }}
<p>
Your secure note has been encrypted and stored on the server. Next, you
must verify your e-mail address.
</p>
<p>
Please click on the link below to verify your email:
</p>
<p>
<a href="{{ .VerifyLink }}">{{ .VerifyLink }}</a>
</p>
<h3>Deletion Link</h3>
<p>
Changed your mind? The link below can be used to completely delete the
secure note, without needing to enter a password for it. Keep this link
safe in case you need it.
</p>
<p>
If the deletion link is ever used, you will be sent one last confirmation
e-mail telling you that the note has been deleted.
</p>
<p>
<a href="{{ .DeleteLink }}">{{ .DeleteLink }}</a>
</p>
{{ Include "mail/footer.gohtml" }}