installer API: reference handler (internal/installapi), SMTP mailer with STARTTLS, plain+HTML code template, rate limits; verae-mailtest; user-token.ps1 validates via Go; stricter key masking
This commit is contained in:
parent
6e76bc7b0e
commit
1317bea604
9 changed files with 1249 additions and 5 deletions
59
cmd/verae-mailtest/main.go
Normal file
59
cmd/verae-mailtest/main.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
// verae-mailtest sends ONE verification-code email using SMTP_* env settings,
|
||||||
|
// to check delivery of the installer registration mailer. Server-side tool;
|
||||||
|
// not shipped in the Windows/macOS client.
|
||||||
|
//
|
||||||
|
// SMTP_USER=veraetimestamp@gmail.com SMTP_PASS=... verae-mailtest --to veraetimestamp@gmail.com --send
|
||||||
|
//
|
||||||
|
// Without --send it only renders the message (--preview DIR writes .txt/.html).
|
||||||
|
// The password and the generated code are never printed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
to := flag.String("to", "", "recipient (default: SMTP_USER, i.e. a self-send)")
|
||||||
|
name := flag.String("name", "George", "name used in the greeting")
|
||||||
|
send := flag.Bool("send", false, "actually send (otherwise preview only)")
|
||||||
|
preview := flag.String("preview", "", "write preview .txt/.html into this directory")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := mailer.FromEnv()
|
||||||
|
if err != nil && *send {
|
||||||
|
fmt.Fprintln(os.Stderr, "config:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if *to == "" {
|
||||||
|
*to = cfg.User
|
||||||
|
}
|
||||||
|
code, err := mailer.NewCode()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
m := mailer.CodeEmail(*to, *name, code, 15, "the Verae installer (mailer test)")
|
||||||
|
if *preview != "" {
|
||||||
|
_ = os.MkdirAll(*preview, 0o755)
|
||||||
|
_ = os.WriteFile(filepath.Join(*preview, "code-email.txt"), []byte(m.Text), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(*preview, "code-email.html"), []byte(m.HTML), 0o644)
|
||||||
|
fmt.Println("preview written to", *preview, "(contains a random, unused test code)")
|
||||||
|
}
|
||||||
|
if !*send {
|
||||||
|
fmt.Println("not sending (pass --send).", cfg.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("sending one test code email to %s via %s\n", mailer.MaskEmail(*to), cfg.String())
|
||||||
|
t0 := time.Now()
|
||||||
|
if err := (&mailer.SMTPSender{Cfg: cfg}).Send(m); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "send failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("sent OK in %.1fs (subject \"Your Verae verification code: *** ***\"; code not printed)\n", time.Since(t0).Seconds())
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Verae installer registration API (proposed)
|
# Verae installer registration API (proposed)
|
||||||
|
|
||||||
Status: **proposal + client implementation**. The client side is implemented in
|
Status: **proposal + client implementation + reference server pieces**
|
||||||
|
(`internal/installapi` handler, `internal/mailer` SMTP mailer; §7). The client side is implemented in
|
||||||
`internal/auth/portal_install.go` and driven by the Windows installer
|
`internal/auth/portal_install.go` and driven by the Windows installer
|
||||||
(`platform/windows/installer-debug.nsi` → `verae-print installer-account`).
|
(`platform/windows/installer-debug.nsi` → `verae-print installer-account`).
|
||||||
The server side does **not exist yet**. The portal backend source was not
|
The server side does **not exist yet**. The portal backend source was not
|
||||||
|
|
@ -202,3 +203,33 @@ components:
|
||||||
| Verify email | `--op verify`; "Send a new code" → `--op resend` |
|
| Verify email | `--op verify`; "Send a new code" → `--op resend` |
|
||||||
| Paste key | button opens customer.verae.ai; `validate-key` before Next |
|
| Paste key | button opens customer.verae.ai; `validate-key` before Next |
|
||||||
| Skip | warning; the helper shows a key dialog on first print and holds the job until a key is saved |
|
| Skip | warning; the helper shows a key dialog on first print and holds the job until a key is saved |
|
||||||
|
|
||||||
|
## 7. Reference server implementation and email delivery
|
||||||
|
|
||||||
|
* `internal/installapi`: `Server.Handler()` serves `/install/capabilities|register|verify|resend|login`. Mount it under `/portal/api` in the portal backend. Account storage goes through the `Accounts` interface (`EmailExists`, `HashPassword`, `CreateAccount`, `Authenticate` → `ErrTOTPRequired`/`ErrTOTPInvalid`/`ErrInvalidCredentials`, `MintToken`), so it plugs into the existing portal DB and token code.
|
||||||
|
* At `/register` the password is hashed immediately. The plaintext is never kept in the pending record and is never logged.
|
||||||
|
* Codes are stored as salted SHA-256 and compared in constant time. They are single use, with a 15 min TTL and 5 attempts.
|
||||||
|
* An existing email gets a 202 plus an "you already have an account" email, so the endpoint reveals nothing.
|
||||||
|
* Logs contain masked emails only. Responses use `Cache-Control: no-store`.
|
||||||
|
* Pending sign-ups and rate-limit counters live in memory. Use a shared store (Redis/DB) when running more than one replica.
|
||||||
|
* `internal/mailer`: SMTP sender with mandatory STARTTLS (port 587) or implicit TLS (465), TLS ≥ 1.2, and AUTH PLAIN only after TLS. The SMTP password is scrubbed from every error and is never shown by `Config.String()`. Recipients and subjects are checked against header injection.
|
||||||
|
* Template: `CodeEmail` (multipart/alternative: plain text + responsive HTML, code shown as `123 456`, expiry, "ignore if this wasn't you") and `AccountExistsEmail`.
|
||||||
|
* Rate limits (`DefaultLimits`, sliding window): 60 s resend cooldown per address, 5 codes/h per address, 10/h per client IP, 20/min and 400/day globally. Gmail caps a normal account at about 500 recipients/day, so move to a transactional provider before real volume.
|
||||||
|
|
||||||
|
### Mailer configuration (environment only; never commit credentials)
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `SMTP_HOST` | `smtp.gmail.com` (default) |
|
||||||
|
| `SMTP_PORT` | `587` (default; STARTTLS) |
|
||||||
|
| `SMTP_USER` | `veraetimestamp@gmail.com` |
|
||||||
|
| `SMTP_PASS` | Gmail **app password** for that account, from your secret store. Never put it in files, logs or the repo. |
|
||||||
|
| `SMTP_FROM` | default `Verae TimeStamp <veraetimestamp@gmail.com>` (Gmail rewrites a From that differs from the authenticated account unless it is a verified alias) |
|
||||||
|
|
||||||
|
Delivery check (sends exactly one email; the code is not printed):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SMTP_USER=veraetimestamp@gmail.com SMTP_PASS="$VERAE_GMAIL_APP_PASSWORD" \
|
||||||
|
go run ./cmd/verae-mailtest --to veraetimestamp@gmail.com --send
|
||||||
|
go run ./cmd/verae-mailtest --preview /tmp/mailprev # render only, no send
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -134,10 +134,7 @@ func ValidateXVeraeKey(key string) (string, string) {
|
||||||
func MaskKey(k string) string {
|
func MaskKey(k string) string {
|
||||||
k = strings.TrimSpace(k)
|
k = strings.TrimSpace(k)
|
||||||
if len(k) <= 12 {
|
if len(k) <= 12 {
|
||||||
if len(k) > 4 {
|
return "***" // too short to reveal any part safely
|
||||||
return k[:4] + "..."
|
|
||||||
}
|
|
||||||
return "..."
|
|
||||||
}
|
}
|
||||||
return k[:7] + "..." + k[len(k)-4:]
|
return k[:7] + "..." + k[len(k)-4:]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
382
internal/installapi/installapi.go
Normal file
382
internal/installapi/installapi.go
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
// Package installapi is a reference implementation of the installer
|
||||||
|
// registration API (docs/installer-registration-api.md), meant to be mounted
|
||||||
|
// by the portal backend under /portal/api. Account storage is behind the
|
||||||
|
// Accounts interface so the real portal database can be plugged in.
|
||||||
|
//
|
||||||
|
// Secrets: passwords are hashed by Accounts.HashPassword during /register and
|
||||||
|
// the plaintext is never retained or logged; codes are stored only as salted
|
||||||
|
// SHA-256 and compared in constant time; logs carry masked emails only.
|
||||||
|
package installapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid_credentials")
|
||||||
|
ErrTOTPRequired = errors.New("totp_required")
|
||||||
|
ErrTOTPInvalid = errors.New("totp_invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Accounts is implemented by the portal backend.
|
||||||
|
type Accounts interface {
|
||||||
|
EmailExists(email string) (bool, error)
|
||||||
|
HashPassword(password string) (string, error)
|
||||||
|
CreateAccount(name, email, passwordHash string) (userID string, err error)
|
||||||
|
Authenticate(email, password, totp string) (userID string, err error)
|
||||||
|
MintToken(userID, name string, client map[string]string) (token string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Policy mirrors capabilities.passwordPolicy.
|
||||||
|
type Policy struct {
|
||||||
|
MinLength int `json:"minLength"`
|
||||||
|
RequireLetter bool `json:"requireLetter"`
|
||||||
|
RequireDigit bool `json:"requireDigit"`
|
||||||
|
RequireSymbol bool `json:"requireSymbol"`
|
||||||
|
DisallowEmail bool `json:"disallowEmail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultPolicy = Policy{MinLength: 10, RequireLetter: true, RequireDigit: true, DisallowEmail: true}
|
||||||
|
|
||||||
|
func (p Policy) check(pw, email string) string {
|
||||||
|
if len([]rune(pw)) < p.MinLength {
|
||||||
|
return "Password must be at least " + itoa(p.MinLength) + " characters."
|
||||||
|
}
|
||||||
|
var l, d, s bool
|
||||||
|
for _, r := range pw {
|
||||||
|
switch {
|
||||||
|
case unicode.IsLetter(r):
|
||||||
|
l = true
|
||||||
|
case unicode.IsDigit(r):
|
||||||
|
d = true
|
||||||
|
case !unicode.IsSpace(r):
|
||||||
|
s = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case p.RequireLetter && !l:
|
||||||
|
return "Password must contain a letter."
|
||||||
|
case p.RequireDigit && !d:
|
||||||
|
return "Password must contain a number."
|
||||||
|
case p.RequireSymbol && !s:
|
||||||
|
return "Password must contain a symbol."
|
||||||
|
}
|
||||||
|
if p.DisallowEmail {
|
||||||
|
if i := strings.Index(email, "@"); i >= 3 && strings.Contains(strings.ToLower(pw), strings.ToLower(email[:i])) {
|
||||||
|
return "Password must not contain your email address."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string { return strconv.Itoa(n) }
|
||||||
|
|
||||||
|
type pending struct {
|
||||||
|
name, email, hash string
|
||||||
|
exists bool
|
||||||
|
salt []byte
|
||||||
|
codeHash [32]byte
|
||||||
|
expires time.Time
|
||||||
|
attempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server holds the API state.
|
||||||
|
type Server struct {
|
||||||
|
Accounts Accounts
|
||||||
|
Mail mailer.Sender
|
||||||
|
Limiter *mailer.Limiter
|
||||||
|
Policy Policy
|
||||||
|
CodeTTL time.Duration
|
||||||
|
MaxAttempt int
|
||||||
|
TrustProxy bool // honour X-Forwarded-For (only behind your own proxy)
|
||||||
|
Log func(format string, args ...any)
|
||||||
|
Now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]*pending
|
||||||
|
loginRL *mailer.Limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(a Accounts, m mailer.Sender) *Server {
|
||||||
|
return &Server{Accounts: a, Mail: m, Limiter: mailer.NewLimiter(mailer.DefaultLimits), Policy: DefaultPolicy,
|
||||||
|
CodeTTL: 15 * time.Minute, MaxAttempt: 5, Log: func(string, ...any) {}, Now: time.Now,
|
||||||
|
pending: map[string]*pending{},
|
||||||
|
loginRL: mailer.NewLimiter(mailer.Limits{PerEmailHour: 20, PerIPHour: 60})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves /install/* (mount under /portal/api).
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/install/capabilities", s.capabilities)
|
||||||
|
mux.HandleFunc("/install/register", s.post(s.register))
|
||||||
|
mux.HandleFunc("/install/verify", s.post(s.verify))
|
||||||
|
mux.HandleFunc("/install/resend", s.post(s.resend))
|
||||||
|
mux.HandleFunc("/install/login", s.post(s.login))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiErr struct {
|
||||||
|
status int
|
||||||
|
body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(status int, code, msg string) *apiErr {
|
||||||
|
return &apiErr{status, map[string]any{"error": code, "message": msg}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) post(fn func(r *http.Request, body map[string]any) (int, any, *apiErr)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, 405, map[string]any{"error": "method_not_allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, 400, map[string]any{"error": "bad_json", "message": "Invalid request."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, out, e := fn(r, body)
|
||||||
|
if e != nil {
|
||||||
|
writeJSON(w, e.status, e.body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, st, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(b map[string]any, k string) string { v, _ := b[k].(string); return v }
|
||||||
|
|
||||||
|
func (s *Server) ip(r *http.Request) string {
|
||||||
|
if s.TrustProxy {
|
||||||
|
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func limited(err error) *apiErr {
|
||||||
|
var le *mailer.LimitError
|
||||||
|
if errors.As(err, &le) {
|
||||||
|
e := fail(429, "rate_limited", "Too many attempts. Please wait and try again.")
|
||||||
|
e.body["retryAfterSeconds"] = int(le.RetryAfter.Seconds() + 0.999)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) capabilities(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, 200, map[string]any{"installApi": true, "emailVerification": true, "passwordPolicy": s.Policy})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashCode(salt []byte, code string) [32]byte {
|
||||||
|
return sha256.Sum256(append(append([]byte{}, salt...), []byte(code)...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normEmail(e string) (string, bool) {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
a, err := mail.ParseAddress(e)
|
||||||
|
if err != nil || a.Address != e || len(e) > 254 || strings.ContainsAny(e, "\r\n") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.ToLower(e), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueCode creates/refreshes a code for p and emails it.
|
||||||
|
func (s *Server) issueCode(p *pending, ip string) *apiErr {
|
||||||
|
if err := s.Limiter.Allow(p.email, ip); err != nil {
|
||||||
|
s.Log("install: code email to %s rate-limited: %v", mailer.MaskEmail(p.email), err)
|
||||||
|
return limited(err)
|
||||||
|
}
|
||||||
|
code, err := mailer.NewCode()
|
||||||
|
if err != nil {
|
||||||
|
return fail(500, "internal", "Please try again.")
|
||||||
|
}
|
||||||
|
p.salt = []byte(newID())
|
||||||
|
p.codeHash = hashCode(p.salt, code)
|
||||||
|
p.expires = s.Now().Add(s.CodeTTL)
|
||||||
|
p.attempts = 0
|
||||||
|
var m mailer.Message
|
||||||
|
if p.exists {
|
||||||
|
m = mailer.AccountExistsEmail(p.email)
|
||||||
|
} else {
|
||||||
|
m = mailer.CodeEmail(p.email, p.name, code, int(s.CodeTTL.Minutes()), "the Verae installer")
|
||||||
|
}
|
||||||
|
if err := s.Mail.Send(m); err != nil {
|
||||||
|
s.Log("install: send to %s failed: %v", mailer.MaskEmail(p.email), err)
|
||||||
|
return fail(502, "email_failed", "We could not send the email. Please try again later.")
|
||||||
|
}
|
||||||
|
s.Log("install: code email sent to %s (exists=%v)", mailer.MaskEmail(p.email), p.exists)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) register(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
||||||
|
name := strings.TrimSpace(str(b, "name"))
|
||||||
|
email, ok := normEmail(str(b, "email"))
|
||||||
|
pw := str(b, "password")
|
||||||
|
if name == "" || len(name) > 200 {
|
||||||
|
return 0, nil, fail(400, "name_required", "Please enter your name.")
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return 0, nil, fail(400, "invalid_email", "Please enter a valid email address.")
|
||||||
|
}
|
||||||
|
if msg := s.Policy.check(pw, email); msg != "" {
|
||||||
|
return 0, nil, fail(400, "weak_password", msg)
|
||||||
|
}
|
||||||
|
exists, err := s.Accounts.EmailExists(email)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fail(500, "internal", "Please try again.")
|
||||||
|
}
|
||||||
|
p := &pending{name: name, email: email, exists: exists}
|
||||||
|
if !exists {
|
||||||
|
if p.hash, err = s.Accounts.HashPassword(pw); err != nil {
|
||||||
|
return 0, nil, fail(500, "internal", "Please try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw = ""
|
||||||
|
if e := s.issueCode(p, s.ip(r)); e != nil {
|
||||||
|
return 0, nil, e
|
||||||
|
}
|
||||||
|
id := newID()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.gc()
|
||||||
|
s.pending[id] = p
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 202, map[string]any{"pendingId": id, "expiresInSeconds": int(s.CodeTTL.Seconds()), "resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) gc() {
|
||||||
|
now := s.Now()
|
||||||
|
for k, p := range s.pending {
|
||||||
|
if now.After(p.expires.Add(time.Hour)) {
|
||||||
|
delete(s.pending, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resend(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.pending[str(b, "pendingId")]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return 0, nil, fail(410, "expired", "This sign-up has expired. Please start again.")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if e := s.issueCode(p, s.ip(r)); e != nil {
|
||||||
|
return 0, nil, e
|
||||||
|
}
|
||||||
|
return 202, map[string]any{"resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) verify(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
||||||
|
id := str(b, "pendingId")
|
||||||
|
code := strings.ReplaceAll(strings.TrimSpace(str(b, "code")), " ", "")
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.pending[id]
|
||||||
|
if p == nil || s.Now().After(p.expires) {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, fail(410, "expired", "This code has expired. Click 'Send a new code'.")
|
||||||
|
}
|
||||||
|
h := hashCode(p.salt, code)
|
||||||
|
if p.exists || subtle.ConstantTimeCompare(h[:], p.codeHash[:]) != 1 {
|
||||||
|
p.attempts++
|
||||||
|
left := s.MaxAttempt - p.attempts
|
||||||
|
if left <= 0 {
|
||||||
|
delete(s.pending, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, fail(410, "too_many_attempts", "Too many wrong codes. Please start again.")
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
e := fail(400, "invalid_code", "That code is not correct.")
|
||||||
|
e.body["attemptsLeft"] = left
|
||||||
|
return 0, nil, e
|
||||||
|
}
|
||||||
|
delete(s.pending, id) // single use
|
||||||
|
s.mu.Unlock()
|
||||||
|
uid, err := s.Accounts.CreateAccount(p.name, p.email, p.hash)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fail(500, "internal", "Could not create the account.")
|
||||||
|
}
|
||||||
|
tn := str(b, "tokenName")
|
||||||
|
if tn == "" {
|
||||||
|
tn = "Verae installer"
|
||||||
|
}
|
||||||
|
tok, err := s.Accounts.MintToken(uid, tn, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fail(500, "internal", "Account created; sign in to get a key.")
|
||||||
|
}
|
||||||
|
s.Log("install: account created for %s", mailer.MaskEmail(p.email))
|
||||||
|
return 201, map[string]any{"token": tok, "name": tn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) login(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
||||||
|
email, ok := normEmail(str(b, "email"))
|
||||||
|
if !ok {
|
||||||
|
return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.")
|
||||||
|
}
|
||||||
|
if err := s.loginRL.Allow(email, s.ip(r)); err != nil {
|
||||||
|
return 0, nil, limited(err)
|
||||||
|
}
|
||||||
|
uid, err := s.Accounts.Authenticate(email, str(b, "password"), strings.TrimSpace(str(b, "totpCode")))
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrTOTPRequired):
|
||||||
|
return 0, nil, fail(401, "totp_required", "Enter the code from your authenticator app.")
|
||||||
|
case errors.Is(err, ErrTOTPInvalid):
|
||||||
|
return 0, nil, fail(401, "totp_invalid", "The authenticator code is not correct.")
|
||||||
|
case err != nil:
|
||||||
|
s.Log("install: login failed for %s", mailer.MaskEmail(email))
|
||||||
|
return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.")
|
||||||
|
}
|
||||||
|
tn := str(b, "tokenName")
|
||||||
|
if tn == "" {
|
||||||
|
tn = "Verae installer"
|
||||||
|
}
|
||||||
|
var client map[string]string
|
||||||
|
if c, ok := b["client"].(map[string]any); ok {
|
||||||
|
client = map[string]string{}
|
||||||
|
for k, v := range c {
|
||||||
|
if sv, ok := v.(string); ok && len(sv) < 200 {
|
||||||
|
client[k] = sv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tok, err := s.Accounts.MintToken(uid, tn, client)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fail(500, "internal", "Please try again.")
|
||||||
|
}
|
||||||
|
s.Log("install: key issued for %s", mailer.MaskEmail(email))
|
||||||
|
return 201, map[string]any{"token": tok, "name": tn}, nil
|
||||||
|
}
|
||||||
125
internal/installapi/installapi_test.go
Normal file
125
internal/installapi/installapi_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package installapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAccounts struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
users map[string]string // email -> hash
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAccounts) EmailExists(e string) (bool, error) { _, ok := f.users[e]; return ok, nil }
|
||||||
|
func (f *fakeAccounts) HashPassword(p string) (string, error) {
|
||||||
|
return "h:" + strings.Repeat("*", len(p)), nil
|
||||||
|
}
|
||||||
|
func (f *fakeAccounts) CreateAccount(n, e, h string) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.users[e] = h
|
||||||
|
return "u-" + e, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAccounts) Authenticate(e, p, totp string) (string, error) {
|
||||||
|
if e == "twofa@x.com" && totp == "" {
|
||||||
|
return "", ErrTOTPRequired
|
||||||
|
}
|
||||||
|
if _, ok := f.users[e]; ok && p == "CorrectHorse9" {
|
||||||
|
return "u-" + e, nil
|
||||||
|
}
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
func (f *fakeAccounts) MintToken(uid, n string, c map[string]string) (string, error) {
|
||||||
|
if uid == "" {
|
||||||
|
return "", errors.New("x")
|
||||||
|
}
|
||||||
|
return "xVerae-TEST" + strings.Repeat("a", 20), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type capture struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
msgs []mailer.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capture) Send(m mailer.Message) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.msgs = append(c.msgs, m)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(t *testing.T, h http.Handler, path string, body any) (int, map[string]any) {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
r := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||||
|
return w.Code, out
|
||||||
|
}
|
||||||
|
|
||||||
|
var codeRe = regexp.MustCompile(`(\d{3}) (\d{3})`)
|
||||||
|
|
||||||
|
func TestRegisterVerifyLogin(t *testing.T) {
|
||||||
|
acc := &fakeAccounts{users: map[string]string{"old@x.com": "h"}}
|
||||||
|
mc := &capture{}
|
||||||
|
var logs bytes.Buffer
|
||||||
|
s := New(acc, mc)
|
||||||
|
s.Log = func(f string, a ...any) { logs.WriteString(strings.TrimSpace(sprintf(f, a...)) + "\n") }
|
||||||
|
h := s.Handler()
|
||||||
|
|
||||||
|
if st, out := call(t, h, "/install/register", map[string]any{"name": "G", "email": "new@x.com", "password": "short"}); st != 400 || out["error"] != "weak_password" {
|
||||||
|
t.Fatalf("weak: %d %v", st, out)
|
||||||
|
}
|
||||||
|
st, out := call(t, h, "/install/register", map[string]any{"name": "George", "email": "new@x.com", "password": "CorrectHorse9"})
|
||||||
|
if st != 202 {
|
||||||
|
t.Fatalf("register %d %v", st, out)
|
||||||
|
}
|
||||||
|
pid := out["pendingId"].(string)
|
||||||
|
m := codeRe.FindStringSubmatch(mc.msgs[0].Text)
|
||||||
|
code := m[1] + m[2]
|
||||||
|
if st, out := call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": "000000"}); st != 400 || out["attemptsLeft"].(float64) != 4 {
|
||||||
|
if code != "000000" {
|
||||||
|
t.Fatalf("bad code: %d %v", st, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st, out = call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": code, "tokenName": "PC"})
|
||||||
|
if st != 201 || !strings.HasPrefix(out["token"].(string), "xVerae-") {
|
||||||
|
t.Fatalf("verify %d %v", st, out)
|
||||||
|
}
|
||||||
|
if st, _ := call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": code}); st != 410 {
|
||||||
|
t.Fatal("code must be single use")
|
||||||
|
}
|
||||||
|
// existing email: 202 + "already have an account" email, verify never succeeds
|
||||||
|
st, out = call(t, h, "/install/register", map[string]any{"name": "X", "email": "old@x.com", "password": "CorrectHorse9"})
|
||||||
|
if st != 202 || !strings.Contains(mc.msgs[1].Subject, "already have") {
|
||||||
|
t.Fatalf("exists: %d %v", st, out)
|
||||||
|
}
|
||||||
|
// login
|
||||||
|
if st, out := call(t, h, "/install/login", map[string]any{"email": "new@x.com", "password": "CorrectHorse9"}); st != 201 || out["token"] == nil {
|
||||||
|
t.Fatalf("login %d %v", st, out)
|
||||||
|
}
|
||||||
|
if st, out := call(t, h, "/install/login", map[string]any{"email": "twofa@x.com", "password": "x"}); st != 401 || out["error"] != "totp_required" {
|
||||||
|
t.Fatalf("totp %d %v", st, out)
|
||||||
|
}
|
||||||
|
// resend cooldown → 429
|
||||||
|
st, out = call(t, h, "/install/register", map[string]any{"name": "R", "email": "r@x.com", "password": "CorrectHorse9"})
|
||||||
|
if st, out := call(t, h, "/install/resend", map[string]any{"pendingId": out["pendingId"]}); st != 429 || out["retryAfterSeconds"] == nil {
|
||||||
|
t.Fatalf("resend %d %v", st, out)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), "CorrectHorse9") || strings.Contains(logs.String(), "new@x.com") || strings.Contains(logs.String(), code) {
|
||||||
|
t.Fatalf("secret/PII in logs:\n%s", logs.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sprintf(f string, a ...any) string { return fmt.Sprintf(f, a...) }
|
||||||
266
internal/mailer/mailer.go
Normal file
266
internal/mailer/mailer.go
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
// Package mailer sends Verae verification-code emails over SMTP (STARTTLS).
|
||||||
|
//
|
||||||
|
// Configuration comes only from the environment (never from files in the repo):
|
||||||
|
//
|
||||||
|
// SMTP_HOST default smtp.gmail.com
|
||||||
|
// SMTP_PORT default 587 (STARTTLS is required; 465 implicit TLS also works)
|
||||||
|
// SMTP_USER e.g. veraetimestamp@gmail.com
|
||||||
|
// SMTP_PASS Gmail app password (never logged; scrubbed from errors)
|
||||||
|
// SMTP_FROM default "Verae TimeStamp <SMTP_USER>"
|
||||||
|
//
|
||||||
|
// The password is held only in memory and is scrubbed from every error string.
|
||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds SMTP settings. Pass is unexported-by-convention: never print a Config.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Pass string
|
||||||
|
From string
|
||||||
|
// AllowInsecureLocal permits plaintext SMTP to 127.0.0.1/localhost (tests only).
|
||||||
|
AllowInsecureLocal bool
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// String never reveals the password.
|
||||||
|
func (c Config) String() string {
|
||||||
|
p := "unset"
|
||||||
|
if c.Pass != "" {
|
||||||
|
p = "set"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("smtp://%s@%s:%d from=%q pass=%s", c.User, c.Host, c.Port, c.From, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromEnv reads SMTP_* variables.
|
||||||
|
func FromEnv() (Config, error) {
|
||||||
|
c := Config{
|
||||||
|
Host: envOr("SMTP_HOST", "smtp.gmail.com"),
|
||||||
|
User: strings.TrimSpace(os.Getenv("SMTP_USER")),
|
||||||
|
Pass: os.Getenv("SMTP_PASS"),
|
||||||
|
From: strings.TrimSpace(os.Getenv("SMTP_FROM")),
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(envOr("SMTP_PORT", "587"))
|
||||||
|
if err != nil || port <= 0 || port > 65535 {
|
||||||
|
return c, errors.New("SMTP_PORT is not a valid port")
|
||||||
|
}
|
||||||
|
c.Port = port
|
||||||
|
if c.User == "" {
|
||||||
|
return c, errors.New("SMTP_USER is not set")
|
||||||
|
}
|
||||||
|
if c.Pass == "" {
|
||||||
|
return c, errors.New("SMTP_PASS is not set")
|
||||||
|
}
|
||||||
|
if c.From == "" {
|
||||||
|
c.From = "Verae TimeStamp <" + c.User + ">"
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(c.From); err != nil {
|
||||||
|
return c, fmt.Errorf("SMTP_FROM is not a valid address: %v", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, d string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is a rendered email.
|
||||||
|
type Message struct {
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Text string
|
||||||
|
HTML string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender is what the install API needs (mockable).
|
||||||
|
type Sender interface {
|
||||||
|
Send(m Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPSender implements Sender.
|
||||||
|
type SMTPSender struct{ Cfg Config }
|
||||||
|
|
||||||
|
func (s *SMTPSender) scrub(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if s.Cfg.Pass != "" {
|
||||||
|
msg = strings.ReplaceAll(msg, s.Cfg.Pass, "[redacted]")
|
||||||
|
msg = strings.ReplaceAll(msg, base64.StdEncoding.EncodeToString([]byte("\x00"+s.Cfg.User+"\x00"+s.Cfg.Pass)), "[redacted]")
|
||||||
|
}
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocal(h string) bool { return h == "localhost" || h == "127.0.0.1" || h == "::1" }
|
||||||
|
|
||||||
|
// Send delivers m. STARTTLS (or implicit TLS on 465) is mandatory except for
|
||||||
|
// AllowInsecureLocal test servers.
|
||||||
|
func (s *SMTPSender) Send(m Message) error { return s.scrub(s.send(m)) }
|
||||||
|
|
||||||
|
func (s *SMTPSender) send(m Message) error {
|
||||||
|
c := s.Cfg
|
||||||
|
to, err := mail.ParseAddress(m.To)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bad recipient: %v", err)
|
||||||
|
}
|
||||||
|
from, err := mail.ParseAddress(c.From)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bad sender: %v", err)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(m.Subject, "\r\n") {
|
||||||
|
return errors.New("bad subject")
|
||||||
|
}
|
||||||
|
raw, err := Build(from, to, m, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
||||||
|
tlsCfg := &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12}
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 20 * time.Second
|
||||||
|
}
|
||||||
|
var conn net.Conn
|
||||||
|
if c.Port == 465 {
|
||||||
|
conn, err = tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, tlsCfg)
|
||||||
|
} else {
|
||||||
|
conn, err = net.DialTimeout("tcp", addr, timeout)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(2 * timeout))
|
||||||
|
cl, err := smtp.NewClient(conn, c.Host)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("smtp hello: %v", err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
if err := cl.Hello("verae-mailer"); err != nil {
|
||||||
|
return fmt.Errorf("ehlo: %v", err)
|
||||||
|
}
|
||||||
|
if c.Port != 465 {
|
||||||
|
if ok, _ := cl.Extension("STARTTLS"); ok {
|
||||||
|
if err := cl.StartTLS(tlsCfg); err != nil {
|
||||||
|
return fmt.Errorf("starttls: %v", err)
|
||||||
|
}
|
||||||
|
} else if !(c.AllowInsecureLocal && isLocal(c.Host)) {
|
||||||
|
return errors.New("server does not offer STARTTLS; refusing to send credentials in clear text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok, _ := cl.Extension("AUTH"); ok {
|
||||||
|
if err := cl.Auth(smtp.PlainAuth("", c.User, c.Pass, c.Host)); err != nil {
|
||||||
|
return fmt.Errorf("auth as %s failed: %v", c.User, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := cl.Mail(from.Address); err != nil {
|
||||||
|
return fmt.Errorf("mail from: %v", err)
|
||||||
|
}
|
||||||
|
if err := cl.Rcpt(to.Address); err != nil {
|
||||||
|
return fmt.Errorf("rcpt: %v", err)
|
||||||
|
}
|
||||||
|
w, err := cl.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("data: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(raw); err != nil {
|
||||||
|
return fmt.Errorf("write: %v", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("data end: %v", err)
|
||||||
|
}
|
||||||
|
return cl.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build renders a multipart/alternative RFC 5322 message.
|
||||||
|
func Build(from, to *mail.Address, m Message, now time.Time) ([]byte, error) {
|
||||||
|
bnd, err := randHex(16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, _ := randHex(12)
|
||||||
|
dom := "verae.local"
|
||||||
|
if i := strings.LastIndex(from.Address, "@"); i >= 0 {
|
||||||
|
dom = from.Address[i+1:]
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
h := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) }
|
||||||
|
h("From", from.String())
|
||||||
|
h("To", to.String())
|
||||||
|
h("Subject", mimeHeader(m.Subject))
|
||||||
|
h("Date", now.Format(time.RFC1123Z))
|
||||||
|
h("Message-ID", "<"+id+"@"+dom+">")
|
||||||
|
h("MIME-Version", "1.0")
|
||||||
|
h("Auto-Submitted", "auto-generated")
|
||||||
|
h("Content-Type", `multipart/alternative; boundary="`+bnd+`"`)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
part := func(ct, body string) {
|
||||||
|
fmt.Fprintf(&b, "--%s\r\nContent-Type: %s; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", bnd, ct)
|
||||||
|
qp := quotedprintable.NewWriter(&b)
|
||||||
|
_, _ = qp.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n")))
|
||||||
|
_ = qp.Close()
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
part("text/plain", m.Text)
|
||||||
|
part("text/html", m.HTML)
|
||||||
|
fmt.Fprintf(&b, "--%s--\r\n", bnd)
|
||||||
|
return b.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeHeader(s string) string {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(s)) + "?="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func randHex(n int) (string, error) {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCode returns a uniformly random 6-digit code (crypto/rand).
|
||||||
|
func NewCode() (string, error) {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%06d", n.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskEmail: george@example.com → g***@example.com (for logs).
|
||||||
|
func MaskEmail(e string) string {
|
||||||
|
i := strings.LastIndex(e, "@")
|
||||||
|
if i <= 0 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return e[:1] + "***" + e[i:]
|
||||||
|
}
|
||||||
196
internal/mailer/mailer_test.go
Normal file
196
internal/mailer/mailer_test.go
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/base64"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSMTP: minimal server without STARTTLS, records AUTH and DATA.
|
||||||
|
type fakeSMTP struct {
|
||||||
|
ln net.Listener
|
||||||
|
mu sync.Mutex
|
||||||
|
auth string
|
||||||
|
data string
|
||||||
|
rcpt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func startFake(t *testing.T) *fakeSMTP {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := &fakeSMTP{ln: ln}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go f.serve(c)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSMTP) serve(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
r := bufio.NewReader(c)
|
||||||
|
w := func(s string) { c.Write([]byte(s + "\r\n")) }
|
||||||
|
w("220 fake")
|
||||||
|
for {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
up := strings.ToUpper(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(up, "EHLO"):
|
||||||
|
w("250-fake")
|
||||||
|
w("250 AUTH PLAIN")
|
||||||
|
case strings.HasPrefix(up, "AUTH PLAIN"):
|
||||||
|
f.mu.Lock()
|
||||||
|
f.auth = strings.TrimSpace(line[len("AUTH PLAIN"):])
|
||||||
|
f.mu.Unlock()
|
||||||
|
w("235 ok")
|
||||||
|
case strings.HasPrefix(up, "MAIL FROM"):
|
||||||
|
w("250 ok")
|
||||||
|
case strings.HasPrefix(up, "RCPT TO"):
|
||||||
|
f.mu.Lock()
|
||||||
|
f.rcpt = line
|
||||||
|
f.mu.Unlock()
|
||||||
|
w("250 ok")
|
||||||
|
case up == "DATA":
|
||||||
|
w("354 go")
|
||||||
|
var b strings.Builder
|
||||||
|
for {
|
||||||
|
l, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l == ".\r\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(l)
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.data = b.String()
|
||||||
|
f.mu.Unlock()
|
||||||
|
w("250 queued")
|
||||||
|
case up == "QUIT":
|
||||||
|
w("221 bye")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
w("250 ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSMTP) port() int { return f.ln.Addr().(*net.TCPAddr).Port }
|
||||||
|
|
||||||
|
func TestSendViaFakeSMTP(t *testing.T) {
|
||||||
|
f := startFake(t)
|
||||||
|
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "veraetimestamp@gmail.com", Pass: "s3cret-app-pw", From: "Verae TimeStamp <veraetimestamp@gmail.com>", AllowInsecureLocal: true, Timeout: 5 * time.Second}}
|
||||||
|
m := CodeEmail("george@example.com", "George", "123456", 15, "")
|
||||||
|
if err := s.Send(m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
dec, _ := base64.StdEncoding.DecodeString(f.auth)
|
||||||
|
if string(dec) != "\x00veraetimestamp@gmail.com\x00s3cret-app-pw" {
|
||||||
|
t.Fatalf("auth %q", dec)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"multipart/alternative", "text/plain", "text/html", "Subject: Your Verae verification code: 123 456", "To: <george@example.com>"} {
|
||||||
|
if !strings.Contains(f.data, want) {
|
||||||
|
t.Errorf("message missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(f.rcpt, "george@example.com") {
|
||||||
|
t.Errorf("rcpt %q", f.rcpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefusesPlaintextWithoutOptIn(t *testing.T) {
|
||||||
|
f := startFake(t)
|
||||||
|
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "u@x.com", Pass: "pw-should-not-leak", From: "u@x.com", Timeout: 5 * time.Second}}
|
||||||
|
err := s.Send(CodeEmail("a@b.com", "", "000111", 15, ""))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "STARTTLS") {
|
||||||
|
t.Fatalf("want STARTTLS refusal, got %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "pw-should-not-leak") {
|
||||||
|
t.Fatal("password leaked into error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigNeverPrintsPassword(t *testing.T) {
|
||||||
|
t.Setenv("SMTP_USER", "veraetimestamp@gmail.com")
|
||||||
|
t.Setenv("SMTP_PASS", "abcd efgh ijkl mnop")
|
||||||
|
t.Setenv("SMTP_HOST", "")
|
||||||
|
t.Setenv("SMTP_PORT", "")
|
||||||
|
t.Setenv("SMTP_FROM", "")
|
||||||
|
c, err := FromEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.Host != "smtp.gmail.com" || c.Port != 587 || c.From != "Verae TimeStamp <veraetimestamp@gmail.com>" {
|
||||||
|
t.Fatalf("defaults: %v", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c.String(), "abcd") {
|
||||||
|
t.Fatal("String() leaked password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsHeaderInjection(t *testing.T) {
|
||||||
|
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: 1, User: "u@x.com", Pass: "p", From: "u@x.com"}}
|
||||||
|
if err := s.Send(Message{To: "a@b.com\r\nBcc: evil@x.com", Subject: "x"}); err == nil {
|
||||||
|
t.Fatal("expected bad recipient")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateEscapesAndCode(t *testing.T) {
|
||||||
|
m := CodeEmail("a@b.com", "<script>", "987654", 15, "")
|
||||||
|
if strings.Contains(m.HTML, "<script>") || !strings.Contains(m.HTML, "987654") || !strings.Contains(m.Text, "987 654") {
|
||||||
|
t.Fatal("template")
|
||||||
|
}
|
||||||
|
c, err := NewCode()
|
||||||
|
if err != nil || len(c) != 6 {
|
||||||
|
t.Fatal(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimiter(t *testing.T) {
|
||||||
|
now := time.Unix(1_800_000_000, 0)
|
||||||
|
l := NewLimiter(Limits{Cooldown: time.Minute, PerEmailHour: 3, PerIPHour: 4, GlobalMinute: 100, GlobalDay: 1000})
|
||||||
|
l.Now = func() time.Time { return now }
|
||||||
|
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "cooldown") {
|
||||||
|
t.Fatalf("want cooldown, got %v", err)
|
||||||
|
}
|
||||||
|
now = now.Add(61 * time.Second)
|
||||||
|
_ = l.Allow("a@x", "1.1.1.1")
|
||||||
|
now = now.Add(61 * time.Second)
|
||||||
|
_ = l.Allow("a@x", "1.1.1.1")
|
||||||
|
now = now.Add(61 * time.Second)
|
||||||
|
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "too many codes") {
|
||||||
|
t.Fatalf("want per-email cap, got %v", err)
|
||||||
|
}
|
||||||
|
if err := l.Allow("b@x", "1.1.1.1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := l.Allow("c@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "network") {
|
||||||
|
t.Fatalf("want per-IP cap, got %v", err)
|
||||||
|
}
|
||||||
|
now = now.Add(time.Hour)
|
||||||
|
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
|
||||||
|
t.Fatalf("window should reset: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
internal/mailer/ratelimit.go
Normal file
96
internal/mailer/ratelimit.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limits for verification emails (defaults match docs/installer-registration-api.md).
|
||||||
|
type Limits struct {
|
||||||
|
Cooldown time.Duration // min gap between emails to one address (resend)
|
||||||
|
PerEmailHour int // emails per address per hour
|
||||||
|
PerIPHour int // emails triggered per client IP per hour
|
||||||
|
GlobalMinute int // all emails per minute (protects the Gmail account: ~500/day cap)
|
||||||
|
GlobalDay int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLimits keeps well under Gmail's sending limits.
|
||||||
|
var DefaultLimits = Limits{Cooldown: 60 * time.Second, PerEmailHour: 5, PerIPHour: 10, GlobalMinute: 20, GlobalDay: 400}
|
||||||
|
|
||||||
|
// Limiter is an in-memory sliding-window limiter (single instance; use a shared
|
||||||
|
// store such as Redis when running several API replicas).
|
||||||
|
type Limiter struct {
|
||||||
|
L Limits
|
||||||
|
Now func() time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
ev map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLimiter(l Limits) *Limiter {
|
||||||
|
return &Limiter{L: l, Now: time.Now, ev: map[string][]time.Time{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitError carries a retry hint.
|
||||||
|
type LimitError struct {
|
||||||
|
Reason string
|
||||||
|
RetryAfter time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LimitError) Error() string {
|
||||||
|
return fmt.Sprintf("rate limited (%s); retry in %ds", e.Reason, int(e.RetryAfter.Seconds()+0.999))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) prune(k string, win time.Duration, now time.Time) []time.Time {
|
||||||
|
v := l.ev[k]
|
||||||
|
i := 0
|
||||||
|
for i < len(v) && now.Sub(v[i]) >= win {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
v = v[i:]
|
||||||
|
l.ev[k] = v
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow checks every limit for (email, ip) and records the event only if all pass.
|
||||||
|
func (l *Limiter) Allow(email, ip string) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := l.Now()
|
||||||
|
type rule struct {
|
||||||
|
key string
|
||||||
|
win time.Duration
|
||||||
|
max int
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
rules := []rule{
|
||||||
|
{"e:" + email, l.L.Cooldown, 1, "resend cooldown"},
|
||||||
|
{"e:" + email, time.Hour, l.L.PerEmailHour, "too many codes for this email"},
|
||||||
|
{"i:" + ip, time.Hour, l.L.PerIPHour, "too many requests from this network"},
|
||||||
|
{"g", time.Minute, l.L.GlobalMinute, "service busy"},
|
||||||
|
{"g", 24 * time.Hour, l.L.GlobalDay, "daily email limit reached"},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.max <= 0 || r.win <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := l.prune(r.key, 24*time.Hour, now)
|
||||||
|
n := 0
|
||||||
|
var oldest time.Time
|
||||||
|
for _, t := range v {
|
||||||
|
if now.Sub(t) < r.win {
|
||||||
|
if n == 0 {
|
||||||
|
oldest = t
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n >= r.max {
|
||||||
|
return &LimitError{Reason: r.reason, RetryAfter: r.win - now.Sub(oldest)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range []string{"e:" + email, "i:" + ip, "g"} {
|
||||||
|
l.ev[k] = append(l.ev[k], now)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
92
internal/mailer/template.go
Normal file
92
internal/mailer/template.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodeEmail renders the verification-code email (plain text + HTML).
|
||||||
|
func CodeEmail(to, name, code string, ttlMinutes int, client string) Message {
|
||||||
|
greet := "Hello"
|
||||||
|
if n := strings.TrimSpace(name); n != "" {
|
||||||
|
greet = "Hello " + n
|
||||||
|
}
|
||||||
|
if client == "" {
|
||||||
|
client = "the Verae installer"
|
||||||
|
}
|
||||||
|
spaced := code
|
||||||
|
if len(code) == 6 {
|
||||||
|
spaced = code[:3] + " " + code[3:]
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf(`%s,
|
||||||
|
|
||||||
|
Your Verae verification code is:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Enter it in %s to finish creating your Verae account.
|
||||||
|
The code expires in %d minutes and can be used once.
|
||||||
|
|
||||||
|
If you did not try to create a Verae account, you can ignore this email;
|
||||||
|
no account is created without this code.
|
||||||
|
|
||||||
|
Verae TimeStamp - https://customer.verae.ai/portal/
|
||||||
|
This is an automated message; replies are not monitored.
|
||||||
|
`, greet, spaced, client, ttlMinutes)
|
||||||
|
|
||||||
|
e := html.EscapeString
|
||||||
|
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Your Verae verification code</title></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;">
|
||||||
|
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:32px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;width:100%%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
|
||||||
|
<tr><td style="background:#0b3d91;padding:20px 28px;color:#ffffff;font-size:20px;font-weight:600;letter-spacing:.3px;">Verae TimeStamp</td></tr>
|
||||||
|
<tr><td style="padding:28px;">
|
||||||
|
<p style="margin:0 0 16px;font-size:16px;">%s,</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:15px;line-height:1.5;">Enter this code in %s to finish creating your Verae account:</p>
|
||||||
|
<div style="margin:0 0 20px;padding:18px 0;text-align:center;background:#eef3fb;border-radius:8px;font-family:Consolas,Menlo,monospace;font-size:34px;font-weight:700;letter-spacing:8px;color:#0b3d91;">%s</div>
|
||||||
|
<p style="margin:0 0 8px;font-size:14px;color:#52606d;">The code expires in <b>%d minutes</b> and can be used once.</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:#52606d;">If you did not try to create a Verae account, ignore this email — no account is created without this code.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:16px 28px;border-top:1px solid #e4e7eb;font-size:12px;color:#7b8794;">
|
||||||
|
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;text-decoration:none;">customer.verae.ai</a> · Automated message, replies are not monitored.
|
||||||
|
</td></tr></table>
|
||||||
|
</td></tr></table>
|
||||||
|
</body></html>
|
||||||
|
`, e(greet), e(client), e(code), ttlMinutes)
|
||||||
|
|
||||||
|
return Message{
|
||||||
|
To: to,
|
||||||
|
Subject: "Your Verae verification code: " + spaced,
|
||||||
|
Text: text,
|
||||||
|
HTML: htmlBody,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountExistsEmail is sent instead of a code when someone registers an
|
||||||
|
// address that already has an account (so /install/register reveals nothing).
|
||||||
|
func AccountExistsEmail(to string) Message {
|
||||||
|
text := `Hello,
|
||||||
|
|
||||||
|
Someone (hopefully you) tried to create a new Verae account with this email
|
||||||
|
address, but you already have one.
|
||||||
|
|
||||||
|
To use the Verae printer, choose "Sign in to my Verae account" in the installer.
|
||||||
|
Forgot your password? Reset it at https://customer.verae.ai/portal/
|
||||||
|
|
||||||
|
If this wasn't you, you can ignore this email.
|
||||||
|
|
||||||
|
Verae TimeStamp - automated message; replies are not monitored.
|
||||||
|
`
|
||||||
|
htmlBody := `<!DOCTYPE html><html><body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;background:#f4f5f7;padding:32px;">
|
||||||
|
<div style="max-width:480px;margin:auto;background:#fff;border-radius:12px;padding:28px;">
|
||||||
|
<h2 style="margin-top:0;color:#0b3d91;">You already have a Verae account</h2>
|
||||||
|
<p>Someone (hopefully you) tried to create a new Verae account with this email address.</p>
|
||||||
|
<p>In the installer choose <b>Sign in to my Verae account</b>. Forgot your password? Reset it at
|
||||||
|
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;">customer.verae.ai</a>.</p>
|
||||||
|
<p style="color:#52606d;font-size:14px;">If this wasn't you, ignore this email.</p></div></body></html>`
|
||||||
|
return Message{To: to, Subject: "You already have a Verae account", Text: text, HTML: htmlBody}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue