commit a9bafeef7c3a46a4372aaa240a902144c7bcc03c equwal <truex@equwal.com> 2026-09-22 16:05:08 -0700 Accounts confirm their email address when the server has SMTP With SBM_SMTP_HOST set, a new account gets an email with a link. The account syncs only after someone opens the link. Until then, /api/sync answers 403. The account page can send the email again, 5 times an hour. The store keeps only the SHA-256 of the code. Accounts from before have no code, so they count as confirmed. Without SBM_SMTP_HOST, the server sends no email and checks no address, as before. The mailer uses net/smtp: TLS from the start on port 465, else STARTTLS when the server offers it. The tests play an SMTP server, with and without TLS.
README.md | 27 +++++--- mail.go | 101 ++++++++++++++++++++++++++++++ mail_test.go | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 101 ++++++++++++++++++++++++++++-- pages.html | 12 +++- server_test.go | 112 +++++++++++++++++++++++++++++++++- store.go | 39 +++++++++++- 7 files changed, 567 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md index cfa7e65..91ae916 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ Plain HTTP, for any client with curl. POST /api/logout Authorization: Bearer <token> Errors come as text: 401 (sign in again), 402 (the subscription or the -trial has ended), 413 (the file is larger than 4 MB), 429 (too many sign-in -attempts). +trial has ended), 403 (confirm the email address first), 413 (the file is +larger than 4 MB), 429 (too many sign-in attempts). curl -d email=me@example.org --data-urlencode password=... https://sbm.subread.space/api/login curl -H "Authorization: Bearer $token" --data-binary @bookmarks \ @@ -75,10 +75,22 @@ Settings, all through the environment: SBM_TRIAL_DAYS days of sync before payment, with billing on (default 30) SBM_BILLING_START date when billing starts, as 2026-10-01: accounts from before it get the full trial from that date - -Accounts need no email check. To reset a password, the operator deletes the -account in `accounts.json` (with the server stopped), and the user signs up -again. The bookmark files stay on the devices. + SBM_SMTP_HOST SMTP server that sends email (optional) + SBM_SMTP_PORT its port (default 587, with STARTTLS; 465 uses TLS + from the start) + SBM_SMTP_USER user name on the SMTP server + SBM_SMTP_PASSWORD password on the SMTP server + SBM_MAIL_FROM sender, as "sbm Sync <sync@example.org>" (default + SBM_SMTP_USER) + +Without `SBM_SMTP_HOST`, the server sends no email, and accounts need no +email check. With it, each new account gets an email with a link. The +account syncs only after someone opens that link. Accounts from a time +without `SBM_SMTP_HOST` need no check. + +To reset a password, the operator deletes the account in `accounts.json` +(with the server stopped), and the user signs up again. The bookmark files +stay on the devices. ## Billing @@ -104,7 +116,8 @@ Keep the keys out of git: put them in the environment file of the service. ## Data data/accounts.json accounts: email, bcrypt hash, token hashes, - Stripe customer and subscription state + hash of the email check code, Stripe + customer and subscription state data/files/<account>/<sha256> the last 50 versions of each bookmark file data/files/<account>/HEAD name of the current version diff --git a/mail.go b/mail.go new file mode 100644 index 0000000..699ad17 --- /dev/null +++ b/mail.go @@ -0,0 +1,101 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "mime" + "net" + "net/mail" + "net/smtp" + "strings" + "time" +) + +// Mailer sends email through an SMTP server. With TLS set (port 465), the +// connection uses TLS from the start. Else the mailer starts TLS when the +// server offers STARTTLS. net/smtp never sends the password over a +// connection without TLS, except to localhost. +type Mailer struct { + Addr string // host:port of the SMTP server + TLS bool + User string // empty for a server that needs no sign-in + Password string + From *mail.Address + Hello string // name of this host in EHLO + Roots *x509.CertPool // nil for the roots of the system; tests replace it +} + +// send sends a plain text email. The lines of body end in "\n". +func (m *Mailer) send(to, subject, body string) error { + host, _, err := net.SplitHostPort(m.Addr) + if err != nil { + return err + } + tc := &tls.Config{ServerName: host, RootCAs: m.Roots} + d := &net.Dialer{Timeout: 20 * time.Second} + var conn net.Conn + if m.TLS { + conn, err = tls.DialWithDialer(d, "tcp", m.Addr, tc) + } else { + conn, err = d.Dial("tcp", m.Addr) + } + if err != nil { + return err + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + return err + } + c, err := smtp.NewClient(conn, host) + if err != nil { + return err + } + if err := c.Hello(m.Hello); err != nil { + return err + } + if ok, _ := c.Extension("STARTTLS"); ok && !m.TLS { + if err := c.StartTLS(tc); err != nil { + return err + } + } + if m.User != "" { + if err := c.Auth(smtp.PlainAuth("", m.User, m.Password, host)); err != nil { + return err + } + } + if err := c.Mail(m.From.Address); err != nil { + return err + } + if err := c.Rcpt(to); err != nil { + return err + } + w, err := c.Data() + if err != nil { + return err + } + if _, err := w.Write(m.message(to, subject, body)); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return c.Quit() +} + +// message gives the email with its header. The lines end in CRLF, as SMTP +// needs. +func (m *Mailer) message(to, subject, body string) []byte { + _, domain, _ := strings.Cut(m.From.Address, "@") + var b strings.Builder + fmt.Fprintf(&b, "From: %s\r\n", m.From) + fmt.Fprintf(&b, "To: %s\r\n", to) + fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)) + fmt.Fprintf(&b, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) + fmt.Fprintf(&b, "Message-ID: <%s@%s>\r\n", random(16), domain) + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n") + b.WriteString(strings.ReplaceAll(body, "\n", "\r\n")) + return []byte(b.String()) +} diff --git a/mail_test.go b/mail_test.go new file mode 100644 index 0000000..457647c --- /dev/null +++ b/mail_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "net" + "net/http" + "net/http/httptest" + "net/mail" + "net/textproto" + "strings" + "sync" + "testing" +) + +// fakeSMTP plays an SMTP server and records what the client sends. With +// tls set, it offers STARTTLS, or it uses TLS from the start when implicit +// is set. +type fakeSMTP struct { + ln net.Listener + tls *tls.Config + implicit bool + + mu sync.Mutex + auth string // "user password" of AUTH PLAIN + authTLS bool // the connection had TLS at AUTH + from, to string // arguments of MAIL and RCPT + data string +} + +func newFakeSMTP(t *testing.T, tc *tls.Config, implicit bool) *fakeSMTP { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + f := &fakeSMTP{ln: ln, tls: tc, implicit: implicit} + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go f.session(conn) + } + }() + return f +} + +func (f *fakeSMTP) session(conn net.Conn) { + defer func() { conn.Close() }() + secure := f.implicit + if secure { + conn = tls.Server(conn, f.tls) + } + tp := textproto.NewConn(conn) + tp.PrintfLine("220 fake ESMTP") + for { + line, err := tp.ReadLine() + if err != nil { + return + } + verb, arg, _ := strings.Cut(line, " ") + f.mu.Lock() + switch strings.ToUpper(verb) { + case "EHLO": + tp.PrintfLine("250-fake") + if f.tls != nil && !secure { + tp.PrintfLine("250-STARTTLS") + } + tp.PrintfLine("250 AUTH PLAIN") + case "STARTTLS": + tp.PrintfLine("220 go ahead") + conn = tls.Server(conn, f.tls) + tp = textproto.NewConn(conn) + secure = true + case "AUTH": + _, resp, _ := strings.Cut(arg, " ") + b, _ := base64.StdEncoding.DecodeString(resp) + f.auth = strings.TrimPrefix(strings.ReplaceAll(string(b), "\x00", " "), " ") + f.authTLS = secure + tp.PrintfLine("235 ok") + case "MAIL": + f.from = arg + tp.PrintfLine("250 ok") + case "RCPT": + f.to = arg + tp.PrintfLine("250 ok") + case "DATA": + tp.PrintfLine("354 go on") + b, _ := tp.ReadDotBytes() + f.data = string(b) + tp.PrintfLine("250 ok") + case "QUIT": + tp.PrintfLine("221 bye") + f.mu.Unlock() + return + default: + tp.PrintfLine("502 no such command") + } + f.mu.Unlock() + } +} + +// testTLS gives a certificate for 127.0.0.1 and a pool that trusts it. +func testTLS(t *testing.T) (*tls.Config, *x509.CertPool) { + ts := httptest.NewTLSServer(http.NotFoundHandler()) + t.Cleanup(ts.Close) + pool := x509.NewCertPool() + pool.AddCert(ts.Certificate()) + return &tls.Config{Certificates: ts.TLS.Certificates}, pool +} + +func testMailer(f *fakeSMTP) *Mailer { + return &Mailer{Addr: f.ln.Addr().String(), Hello: "localhost", + From: &mail.Address{Name: "sbm Sync", Address: "sync@example.org"}} +} + +func TestMailerSends(t *testing.T) { + f := newFakeSMTP(t, nil, false) + if err := testMailer(f).send("you@example.org", "Hello", "line 1\n.line 2\n"); err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.from != "FROM:<sync@example.org>" || f.to != "TO:<you@example.org>" { + t.Errorf("envelope: %q %q", f.from, f.to) + } + for _, want := range []string{ + "From: \"sbm Sync\" <sync@example.org>\n", + "To: you@example.org\n", + "Subject: Hello\n", + "Message-ID: <", + "@example.org>\n", + "Content-Type: text/plain; charset=utf-8\n", + "\n\nline 1\n.line 2\n", + } { + if !strings.Contains(f.data, want) { + t.Errorf("the email lacks %q:\n%s", want, f.data) + } + } +} + +func TestMailerStartTLS(t *testing.T) { + tc, pool := testTLS(t) + f := newFakeSMTP(t, tc, false) + m := testMailer(f) + m.User, m.Password, m.Roots = "me", "secret", pool + if err := m.send("you@example.org", "Hello", "hi\n"); err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.auth != "me secret" || !f.authTLS { + t.Errorf("auth %q, with TLS %v", f.auth, f.authTLS) + } +} + +func TestMailerTLSFromTheStart(t *testing.T) { + tc, pool := testTLS(t) + f := newFakeSMTP(t, tc, true) + m := testMailer(f) + m.TLS, m.User, m.Password, m.Roots = true, "me", "secret", pool + if err := m.send("you@example.org", "Hello", "hi\n"); err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.auth != "me secret" || !f.authTLS || !strings.Contains(f.data, "hi\n") { + t.Errorf("auth %q, with TLS %v, data %q", f.auth, f.authTLS, f.data) + } +} + +// A certificate that the system does not trust stops the email before the +// password goes out. +func TestMailerChecksTheCertificate(t *testing.T) { + tc, _ := testTLS(t) + f := newFakeSMTP(t, tc, false) + m := testMailer(f) + m.User, m.Password = "me", "secret" + if err := m.send("you@example.org", "Hello", "hi\n"); err == nil { + t.Error("the mailer trusts any certificate") + } + f.mu.Lock() + defer f.mu.Unlock() + if f.auth != "" { + t.Errorf("the password went out: %q", f.auth) + } +} diff --git a/main.go b/main.go index 44f4e7f..05f47b5 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,7 @@ // got last time. The server merges the changes of the device into its own // copy, keeps the result and sends it back. Accounts are an email address // and a password. Billing through Stripe is off unless STRIPE_SECRET_KEY is -// set. +// set. Accounts confirm their email address only when SBM_SMTP_HOST is set. package main import ( @@ -16,6 +16,7 @@ import ( "log" "net" "net/http" + "net/mail" "net/url" "os" "strconv" @@ -43,6 +44,9 @@ type server struct { prices map[string]string // plan ("month", "year"): Stripe price ID labels map[string]string // plan: price as text limit limiter + // mail sends an email. It is nil when the server has no SMTP server: + // then accounts need no email check. + mail func(to, subject, body string) error } func main() { @@ -92,8 +96,19 @@ func main() { } } } + if host := os.Getenv("SBM_SMTP_HOST"); host != "" { + from, err := mail.ParseAddress(env("SBM_MAIL_FROM", os.Getenv("SBM_SMTP_USER"))) + if err != nil { + log.Fatalf("SBM_MAIL_FROM: %v", err) + } + port := env("SBM_SMTP_PORT", "587") + m := &Mailer{Addr: net.JoinHostPort(host, port), TLS: port == "465", + User: os.Getenv("SBM_SMTP_USER"), Password: os.Getenv("SBM_SMTP_PASSWORD"), + From: from, Hello: u.Hostname()} + s.mail = m.send + } addr := env("SBM_ADDR", "127.0.0.1:8750") - log.Printf("sbm-sync on %s for %s, billing %v", addr, site, s.stripe != nil) + log.Printf("sbm-sync on %s for %s, billing %v, email check %v", addr, site, s.stripe != nil, s.mail != nil) hs := &http.Server{ Addr: addr, Handler: s.routes(), @@ -123,6 +138,8 @@ func (s *server) routes() http.Handler { m.HandleFunc("POST /login", s.login) m.HandleFunc("POST /logout", s.logout) m.HandleFunc("GET /account", s.account) + m.HandleFunc("POST /account/verify", s.resend) + m.HandleFunc("GET /verify", s.confirm) m.HandleFunc("POST /account/checkout", s.checkout) m.HandleFunc("POST /account/portal", s.portal) m.HandleFunc("POST /account/delete", s.remove) @@ -150,7 +167,7 @@ type page struct { Title, Error, Email, State, URL, Contact string Month, Year string TrialDays int - Billing, CanSubscribe bool + Billing, CanSubscribe, Unconfirmed bool Customer string } @@ -222,7 +239,11 @@ func (s *server) signup(w http.ResponseWriter, r *http.Request) { s.render(w, http.StatusTooManyRequests, "signup", p) return } - a, err := s.store.signup(email, r.PostFormValue("password")) + code := "" + if s.mail != nil { + code = random(16) + } + a, err := s.store.signup(email, r.PostFormValue("password"), code) if errors.Is(err, errTaken) || errors.Is(err, errEmail) || errors.Is(err, errPassword) { p.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "." s.render(w, http.StatusBadRequest, "signup", p) @@ -232,9 +253,76 @@ func (s *server) signup(w http.ResponseWriter, r *http.Request) { return } log.Printf("new account %s", a.ID) + if code != "" { + // The account page tells how to send the email again. + if err := s.sendCheck(a.Email, code); err != nil { + log.Printf("email check for %s: %v", a.ID, err) + } + } s.startSession(w, r, a) } +// verified tells if the account can sync: its email is confirmed, or the +// server sends no email and so checks no address. +func (s *server) verified(a Account) bool { + return s.mail == nil || a.Verify == "" +} + +// sendCheck sends the link that confirms the email address. +func (s *server) sendCheck(email, code string) error { + return s.mail(email, "Confirm your email address for sbm Sync", + "Open this link to confirm your email address for sbm Sync:\n\n"+ + s.site+"/verify?code="+code+"\n\n"+ + "Sync starts after you confirm. If you did not make an account on\n"+ + s.site+", ignore this email.\n") +} + +// resend sends the email check again, with a new code. +func (s *server) resend(w http.ResponseWriter, r *http.Request) { + u := s.user(r) + if !s.sameSite(w, r) { + return + } + if u == nil || s.verified(s.store.view(u)) { + http.Redirect(w, r, "/account", http.StatusSeeOther) + return + } + a := s.store.view(u) + if !s.limit.allow("mail "+a.ID, 5, time.Hour) { + s.showAccount(w, http.StatusTooManyRequests, a, "Too many emails in one hour. Try again later.") + return + } + code, err := s.store.newCode(u) + if err != nil { + s.fail(w, err) + return + } + if err := s.sendCheck(a.Email, code); err != nil { + log.Printf("email check for %s: %v", a.ID, err) + s.showAccount(w, http.StatusBadGateway, a, "The email did not go out. Try again later.") + return + } + http.Redirect(w, r, "/account", http.StatusSeeOther) +} + +// confirm takes the link of the email check. +func (s *server) confirm(w http.ResponseWriter, r *http.Request) { + a, err := s.store.confirm(r.URL.Query().Get("code")) + if errors.Is(err, errCode) { + p := s.page("Confirm your email address") + p.Error = "This link does not work: it is wrong, or a newer email replaced it. Sign in to send a new link." + s.render(w, http.StatusBadRequest, "confirmed", p) + return + } else if err != nil { + s.fail(w, err) + return + } + log.Printf("account %s: email confirmed", a.ID) + p := s.page("Email address confirmed") + p.Email = s.store.view(a).Email + s.render(w, http.StatusOK, "confirmed", p) +} + func (s *server) login(w http.ResponseWriter, r *http.Request) { if !s.sameSite(w, r) { return @@ -313,6 +401,7 @@ func (s *server) account(w http.ResponseWriter, r *http.Request) { func (s *server) showAccount(w http.ResponseWriter, status int, a Account, problem string) { p := s.page("Your account") p.Email, p.Error, p.Customer = a.Email, problem, a.Customer + p.Unconfirmed = !s.verified(a) left := s.trialLeft(a) switch { case s.stripe == nil: @@ -521,6 +610,10 @@ func (s *server) sync(w http.ResponseWriter, r *http.Request) { http.Error(w, "not signed in: sign in again", http.StatusUnauthorized) return } + if !s.verified(s.store.view(u)) { + http.Error(w, "confirm your email address first: open the link in the email from sbm Sync, or see "+s.site+"/account", http.StatusForbidden) + return + } if !s.active(s.store.view(u)) { http.Error(w, "sbm Sync is paused for this account: see "+s.site+"/account", http.StatusPaymentRequired) return diff --git a/pages.html b/pages.html index 31da8f4..efe47ce 100644 --- a/pages.html +++ b/pages.html @@ -76,6 +76,9 @@ Chrome.</p> <h1>Your account</h1> <p>Signed in as {{.Email}}.</p> {{with .Error}}<p class="error">{{.}}</p>{{end}} +{{if .Unconfirmed}}<p><b>Confirm your email address.</b> Open the link in the +email that this server sent to {{.Email}}. Sync starts after that.</p> +<form method="post" action="/account/verify"><button>Send the email again</button></form>{{end}} <p><b>{{.State}}</b></p> {{if .CanSubscribe}}<form method="post" action="/account/checkout"> <button name="plan" value="year">Subscribe: {{.Year}}</button> @@ -103,13 +106,20 @@ their bookmark files.</p> </form> {{template "foot" .}}{{end}} +{{define "confirmed"}}{{template "head" .}} +<h1>{{.Title}}</h1> +{{with .Error}}<p class="error">{{.}}</p>{{else}}<p>Sync is on for {{.Email}}.</p>{{end}} +<p><a href="/account">Your account</a></p> +{{template "foot" .}}{{end}} + {{define "privacy"}}{{template "head" .}} <h1>Privacy</h1> <p>This policy is for this sbm Sync server, the sbm app for Android and the sbm add-on for Firefox and Chrome.</p> <h2>What the server keeps, and why</h2> <ul> -<li>Your email address and a bcrypt hash of your password, never the password itself: to sign you in.</li> +<li>Your email address and a bcrypt hash of your password, never the password itself: to sign you in. +When this server checks email addresses, it also sends you an email with a link that confirms your address.</li> <li>Your bookmark file, the last 50 versions that your devices sent: to sync your devices.</li> <li>A hash of the sign-in token of each device: to know your devices.</li> <li>When you subscribe: your Stripe customer ID and the state of your subscription: to bill you.</li> diff --git a/server_test.go b/server_test.go index 08b35f9..197a714 100644 --- a/server_test.go +++ b/server_test.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -12,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "sync" "testing" @@ -58,6 +60,8 @@ type harness struct { s *server web *httptest.Server stripe *fakeStripe + mu sync.Mutex + mails []string // "to\nsubject\nbody" of each email } func newEnv(t *testing.T, billing bool) *harness { @@ -141,6 +145,44 @@ func (e *harness) body(c *http.Client, path string) string { return string(b) } +// withMail turns on the email check. The emails of the server go to +// e.mails. +func (e *harness) withMail() { + e.s.mail = func(to, subject, body string) error { + e.mu.Lock() + defer e.mu.Unlock() + e.mails = append(e.mails, to+"\n"+subject+"\n"+body) + return nil + } +} + +func (e *harness) sent() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.mails) +} + +var checkLink = regexp.MustCompile(`http\S+/verify\?code=[0-9a-f]+`) + +// link gives the link in the last email. +func (e *harness) link() string { + e.mu.Lock() + defer e.mu.Unlock() + if len(e.mails) == 0 { + e.t.Fatal("no email went out") + } + return checkLink.FindString(e.mails[len(e.mails)-1]) +} + +func (e *harness) open(link string) int { + resp, err := http.Get(link) + if err != nil { + e.t.Fatal(err) + } + resp.Body.Close() + return resp.StatusCode +} + func TestTwoDevicesSync(t *testing.T) { e := newEnv(t, false) e.signup("Me@Example.org") @@ -413,7 +455,7 @@ func TestMoney(t *testing.T) { func TestStoreSurvivesRestart(t *testing.T) { dir := t.TempDir() s, _ := openStore(dir) - a, err := s.signup("me@example.org", "password1") + a, err := s.signup("me@example.org", "password1", "") if err != nil { t.Fatal(err) } @@ -434,7 +476,7 @@ func TestStoreSurvivesRestart(t *testing.T) { func TestOldVersionsGo(t *testing.T) { s, _ := openStore(t.TempDir()) - a, _ := s.signup("me@example.org", "password1") + a, _ := s.signup("me@example.org", "password1", "") base := "" for i := range maxVersions + 10 { _, v, err := s.sync(a, base, fmt.Sprintf("line %d\n", i)) @@ -455,3 +497,69 @@ func TestOldVersionsGo(t *testing.T) { t.Errorf("%d versions kept, want %d", n, maxVersions) } } + +func TestEmailCheck(t *testing.T) { + e := newEnv(t, false) + e.withMail() + c := e.signup("Me@Example.org") + tok := e.token("me@example.org") + if e.sent() != 1 || !strings.HasPrefix(e.mails[0], "me@example.org\nConfirm your email address") { + t.Fatalf("emails: %q", e.mails) + } + first := e.link() + if code, text, _ := e.sync(tok, "", "a\n"); code != http.StatusForbidden || !strings.Contains(text, "confirm your email address") { + t.Fatalf("sync before the check: %d %q", code, text) + } + if !strings.Contains(e.body(c, "/account"), "Confirm your email address.") { + t.Error("the account page does not ask for the check") + } + // A new email replaces the link of the first one. + if resp := e.post(c, "/account/verify", nil); resp.StatusCode != http.StatusSeeOther || e.sent() != 2 { + t.Fatalf("send again: %s, %d emails", resp.Status, e.sent()) + } + if code := e.open(first); code != http.StatusBadRequest { + t.Errorf("the replaced link: %d", code) + } + if code := e.open(e.link()); code != http.StatusOK { + t.Fatalf("the new link: %d", code) + } + if code, _, _ := e.sync(tok, "", "a\n"); code != http.StatusOK { + t.Errorf("sync after the check: %d", code) + } + if strings.Contains(e.body(c, "/account"), "Confirm your email address.") { + t.Error("the account page still asks for the check") + } + if code := e.open(e.link()); code != http.StatusBadRequest { + t.Errorf("the link works twice: %d", code) + } + e.post(c, "/account/verify", nil) + if e.sent() != 2 { + t.Error("an email went out for a confirmed address") + } +} + +func TestEmailCheckLimit(t *testing.T) { + e := newEnv(t, false) + e.withMail() + c := e.signup("me@example.org") + for range 5 { + e.post(c, "/account/verify", nil) + } + if resp := e.post(c, "/account/verify", nil); resp.StatusCode != http.StatusTooManyRequests { + t.Errorf("the seventh email in an hour: %s", resp.Status) + } + if e.sent() != 6 { + t.Errorf("%d emails went out, want 6", e.sent()) + } +} + +func TestEmailThatDoesNotGoOut(t *testing.T) { + e := newEnv(t, false) + e.s.mail = func(string, string, string) error { return errors.New("no SMTP server") } + c := e.signup("me@example.org") + resp := e.post(c, "/account/verify", nil) + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(b), "The email did not go out.") { + t.Errorf("send again: %s", resp.Status) + } +} diff --git a/store.go b/store.go index 04fcd11..d391200 100644 --- a/store.go +++ b/store.go @@ -30,6 +30,7 @@ var ( errPassword = errors.New("the password must have 8 to 72 characters") errWrong = errors.New("wrong email or password") errGone = errors.New("the account does not exist") + errCode = errors.New("no account has this code") hashName = regexp.MustCompile(`^[0-9a-f]{64}$`) ) @@ -43,6 +44,7 @@ type Account struct { Customer string `json:"customer,omitempty"` Status string `json:"status,omitempty"` // of the Stripe subscription StatusAt int64 `json:"status_at,omitempty"` // time of the Stripe event that set Status + Verify string `json:"verify,omitempty"` // SHA-256 of the code that confirms the email; empty when confirmed } // Store keeps the accounts in one JSON file, accounts.json, and the versions @@ -151,7 +153,9 @@ func cleanEmail(email string) (string, error) { return email, nil } -func (s *Store) signup(email, password string) (*Account, error) { +// signup makes an account. With a code, the email of the account is not +// confirmed until someone opens the link with that code. +func (s *Store) signup(email, password, code string) (*Account, error) { email, err := cleanEmail(email) if err != nil { return nil, err @@ -169,6 +173,9 @@ func (s *Store) signup(email, password string) (*Account, error) { return nil, errTaken } a := &Account{ID: random(16), Email: email, Hash: string(hash), Created: time.Now().UTC()} + if code != "" { + a.Verify = digest(code) + } s.index(a) if err := s.save(); err != nil { delete(s.accounts, a.ID) @@ -270,6 +277,36 @@ func (s *Store) view(a *Account) Account { return c } +// newCode gives a new code that confirms the email of the account. It +// replaces the old code. Only its hash is kept. +func (s *Store) newCode(a *Account) (string, error) { + code := random(16) + s.mu.Lock() + defer s.mu.Unlock() + if s.accounts[a.ID] == nil { + return "", errGone + } + a.Verify = digest(code) + return code, s.save() +} + +// confirm marks the email of the account with the code as confirmed. +func (s *Store) confirm(code string) (*Account, error) { + if code == "" { + return nil, errCode + } + h := digest(code) + s.mu.Lock() + defer s.mu.Unlock() + for _, a := range s.accounts { + if a.Verify == h { + a.Verify = "" + return a, s.save() + } + } + return nil, errCode +} + func (s *Store) setCustomer(a *Account, customer string) error { s.mu.Lock() defer s.mu.Unlock()