Email verification progress
This commit is contained in:
parent
ac845e8fe7
commit
26d1437aa5
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
notes/
|
||||
./run.sh
|
||||
./dethnote
|
||||
./dethnote.exe
|
||||
|
|
13
main.go
13
main.go
|
@ -30,10 +30,8 @@ var (
|
|||
root string
|
||||
rootDefault = "./notes"
|
||||
|
||||
// -mailgun-url <url>: mailgun API URL for sending out emails.
|
||||
// -mg <url> Takes the format "<api_key>@<api_base_url>"
|
||||
// Example: "key-a1b23c@https://api.mailgun.net/v3/mg.example.com"
|
||||
mailgunURL string
|
||||
// -smtp <url>: mail server settings, like "user:password@localhost:25"
|
||||
smtpURL string
|
||||
|
||||
// -listen <address>: listen on an HTTP port at this address.
|
||||
// -l <address>
|
||||
|
@ -68,8 +66,7 @@ func init() {
|
|||
flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on")
|
||||
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(&mailgunURL, "mg", "", "Mailgun API URL for sending out emails (shorthand)")
|
||||
flag.StringVar(&smtpURL, "smtp", "localhost:22", "SMTP address for sending mail, in the format `[login:password@]server:port`")
|
||||
|
||||
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.")
|
||||
|
@ -110,7 +107,9 @@ func main() {
|
|||
}
|
||||
|
||||
app := dethnote.NewServer(root, debug)
|
||||
app.MailgunURL = mailgunURL
|
||||
if err := app.SetSMTP(smtpURL); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
app.SetupHTTP()
|
||||
app.Run(listen)
|
||||
}
|
||||
|
|
|
@ -43,6 +43,9 @@ func (s *Server) CreateHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Send them a verification email.
|
||||
s.SendVerificationEmail(r, m)
|
||||
|
||||
// Store the hash path in the browser's cookies.
|
||||
s.SetCookie(w, "hash_path", vault.HashToFilename(m.PasswordHash))
|
||||
|
||||
|
|
125
src/mail.go
Normal file
125
src/mail.go
Normal 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
72
src/mail_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,8 +9,8 @@ import (
|
|||
|
||||
// Server is the master struct for the web app.
|
||||
type Server struct {
|
||||
// Public configurable fields.
|
||||
MailgunURL string // format is like "key-1a2b3c@https://api.mailgun.net/v3/mg.example.com"
|
||||
// smtp server config
|
||||
smtp MailConfig
|
||||
|
||||
root string
|
||||
debug bool
|
||||
|
@ -45,6 +45,17 @@ func (s *Server) SetupHTTP() {
|
|||
"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(
|
||||
negroni.NewRecovery(),
|
||||
|
|
|
@ -3,14 +3,23 @@ package dethnote
|
|||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
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.
|
||||
func (s *Server) IncludeFunc(name string, v ...interface{}) template.HTML {
|
||||
buf := bytes.NewBuffer([]byte{})
|
||||
|
@ -36,13 +45,16 @@ func (s *Server) Template(w io.Writer, name string, v interface{}) error {
|
|||
"Include": s.IncludeFunc,
|
||||
}
|
||||
}
|
||||
|
||||
basename := filepath.Base(name)
|
||||
|
||||
// Find the template file locally.
|
||||
filename := "./templates/" + name
|
||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||
return errors.New("template not found: " + name)
|
||||
}
|
||||
|
||||
t := template.New(name).Funcs(functions)
|
||||
t := template.New(basename).Funcs(functions)
|
||||
t, err := t.ParseFiles(filename)
|
||||
if err != nil {
|
||||
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
|
||||
// control the outgoing HTTP status code.
|
||||
buf := bytes.NewBuffer([]byte{})
|
||||
err = t.ExecuteTemplate(buf, name, v)
|
||||
err = t.ExecuteTemplate(buf, basename, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package vault
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
@ -12,6 +13,7 @@ import (
|
|||
type Message struct {
|
||||
Email string `json:"email"` // owner email address
|
||||
Verified bool `json:"verified"` // verified owner email?
|
||||
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
|
||||
|
@ -34,8 +36,14 @@ func NewMessage(email string, timeout int, message string, passwordLength int) (
|
|||
return nil, err
|
||||
}
|
||||
|
||||
verify, err := GenerateHash(password + email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Message{
|
||||
Email: email,
|
||||
VerifyToken: hex.EncodeToString(verify),
|
||||
Timeout: timeout,
|
||||
Message: message,
|
||||
Password: password,
|
||||
|
|
48
src/verify_email.go
Normal file
48
src/verify_email.go
Normal 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
|
||||
}
|
16
templates/mail/footer.gohtml
Normal file
16
templates/mail/footer.gohtml
Normal 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>
|
23
templates/mail/header.gohtml
Normal file
23
templates/mail/header.gohtml
Normal 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">
|
33
templates/mail/verify-email.gohtml
Normal file
33
templates/mail/verify-email.gohtml
Normal 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" }}
|
Loading…
Reference in New Issue
Block a user