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
7 changed files with 1249 additions and 5 deletions
|
|
@ -134,10 +134,7 @@ func ValidateXVeraeKey(key string) (string, string) {
|
|||
func MaskKey(k string) string {
|
||||
k = strings.TrimSpace(k)
|
||||
if len(k) <= 12 {
|
||||
if len(k) > 4 {
|
||||
return k[:4] + "..."
|
||||
}
|
||||
return "..."
|
||||
return "***" // too short to reveal any part safely
|
||||
}
|
||||
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