auto mail generation
This commit is contained in:
152
utils/invitetoken.go
Normal file
152
utils/invitetoken.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The invitation a newly onboarded merchant receives by email.
|
||||
//
|
||||
// ── Why this is a token and not a userid ────────────────────────────────────
|
||||
//
|
||||
// `setpassword` used to take a bare `userid`, which was safe only because the
|
||||
// caller had to reach it through a sign-in: `applogin` answers 409 with the
|
||||
// userid for an account that has no password, and nothing else hands one out.
|
||||
//
|
||||
// Putting that userid in a link and mailing it changes the threat entirely.
|
||||
// Userids are sequential, so a link is a guessable capability: walk low numbers
|
||||
// and claim any merchant that has been onboarded and not yet set up. The
|
||||
// attacker would own a real business's admin account — the empty-password check
|
||||
// does not help, because an un-set-up account is exactly what they are hunting.
|
||||
//
|
||||
// So the invitation carries a signature instead. The userid is read out of the
|
||||
// payload the server signed, never out of the request, which makes a forged or
|
||||
// edited link fail before anything is looked up.
|
||||
//
|
||||
// ── Why the same secret ─────────────────────────────────────────────────────
|
||||
//
|
||||
// One signing key for the deployment, one place it can be missing, one error
|
||||
// when it is — the same argument `MintWebToken` makes for sharing with the POS
|
||||
// token. The prefix is what keeps the three kinds apart, and it is checked
|
||||
// before the signature so an invitation can never be presented as a session.
|
||||
|
||||
// InviteClaims is who an invitation is for.
|
||||
//
|
||||
// Deliberately thin. A session carries a role, a branch and a config because
|
||||
// requests are authorised against them; an invitation authorises exactly one
|
||||
// act — setting a first password — and the account it names already holds
|
||||
// everything else. Claims it does not need are claims that cannot be wrong.
|
||||
type InviteClaims struct {
|
||||
Userid int `json:"uid"`
|
||||
// Carried for the audit line, not for the decision. `SetInitialPassword`
|
||||
// re-derives everything it enforces from the account itself.
|
||||
Tenantid int `json:"tid,omitempty"`
|
||||
Issuedat int64 `json:"iat"`
|
||||
Expiresat int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// InviteTokenTTL is how long an invitation stays usable.
|
||||
//
|
||||
// Seven days: long enough to survive a weekend, a holiday and an email that
|
||||
// went to spam, short enough that a forwarded invitation found in a mailbox
|
||||
// months later is no longer a way into the account. Merchants who miss it get
|
||||
// a fresh one — a resend is cheap and an eternal link is not.
|
||||
const InviteTokenTTL = 7 * 24 * time.Hour
|
||||
|
||||
// inviteTokenPrefix keeps an invitation from being mistaken for a session.
|
||||
//
|
||||
// Without it the two are the same shape signed with the same key, so an
|
||||
// invitation would verify as a console session — and it names a userid with no
|
||||
// role, no tenant check and a seven-day life. That is a far weaker credential
|
||||
// than a session, and it must not be usable as one.
|
||||
const inviteTokenPrefix = "i1."
|
||||
|
||||
// MintInviteToken issues the link a new merchant is emailed.
|
||||
func MintInviteToken(claims InviteClaims, now time.Time) (string, time.Time, error) {
|
||||
secret, err := posTokenSecret()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
if claims.Userid <= 0 {
|
||||
return "", time.Time{}, fmt.Errorf("an invitation must name a user")
|
||||
}
|
||||
|
||||
expires := now.Add(InviteTokenTTL)
|
||||
claims.Issuedat = now.Unix()
|
||||
claims.Expiresat = expires.Unix()
|
||||
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return inviteTokenPrefix + encoded + "." + sign(encoded, secret), expires, nil
|
||||
}
|
||||
|
||||
// ParseInviteToken verifies an invitation and returns who it is for.
|
||||
//
|
||||
// Same order as the session parser, for the same reason: nothing in the payload
|
||||
// is trusted — not the expiry, not the user — until the signature has been
|
||||
// checked. Reading `exp` from an unverified payload is taking the caller's word
|
||||
// for when their own link runs out.
|
||||
// Every refusal below is written as a sentence, capital letter and full stop,
|
||||
// against Go's convention for error strings — because these are not read by a
|
||||
// developer. `SetPassword` puts them straight into the `message` a merchant sees
|
||||
// on `/set-password`, and they are the only explanation that screen has. A
|
||||
// lowercase fragment in a red banner reads as something that leaked out of the
|
||||
// machine rather than something anybody meant to say.
|
||||
//
|
||||
// They also all say what to do next, because every one of them is a dead end
|
||||
// otherwise: the person is holding a link that does not work and has no password
|
||||
// to sign in with instead.
|
||||
func ParseInviteToken(token string, now time.Time) (InviteClaims, error) {
|
||||
secret, err := posTokenSecret()
|
||||
if err != nil {
|
||||
return InviteClaims{}, err
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(token)
|
||||
after, found := strings.CutPrefix(raw, inviteTokenPrefix)
|
||||
if !found {
|
||||
return InviteClaims{}, fmt.Errorf("This is not an invitation link.")
|
||||
}
|
||||
|
||||
encoded, signature, found := strings.Cut(after, ".")
|
||||
if !found || encoded == "" || signature == "" {
|
||||
return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.")
|
||||
}
|
||||
|
||||
// Constant time, so the right signature cannot be learned a byte at a time
|
||||
// from how long the comparison took.
|
||||
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
|
||||
return InviteClaims{}, fmt.Errorf("This invitation link is not valid. Ask whoever set you up to send another.")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.")
|
||||
}
|
||||
|
||||
var claims InviteClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.")
|
||||
}
|
||||
|
||||
if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
|
||||
// Says what to do about it. An expired invitation is the one failure
|
||||
// here somebody can resolve themselves, and "invalid" would send them
|
||||
// to support instead of to whoever onboarded them.
|
||||
return InviteClaims{}, fmt.Errorf("This invitation has expired. Ask whoever set you up to send another.")
|
||||
}
|
||||
|
||||
if claims.Userid <= 0 {
|
||||
return InviteClaims{}, fmt.Errorf("This invitation names no account. Ask whoever set you up to send another.")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
191
utils/invitetoken_test.go
Normal file
191
utils/invitetoken_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
The invitation a newly onboarded merchant is emailed.
|
||||
|
||||
`setpassword` took a bare userid, which was safe only because the caller had to
|
||||
reach it through a sign-in — `applogin` answers 409 with the userid for an
|
||||
account that has no password, and nothing else hands one out.
|
||||
|
||||
Mailing that userid as a link changes the threat completely: userids are
|
||||
sequential, so the link becomes a guessable capability. Walk low numbers and
|
||||
claim any merchant onboarded but not yet set up, and you own a real business's
|
||||
admin account. The empty-password check is no defence — an un-set-up account is
|
||||
precisely what such an attacker is looking for.
|
||||
|
||||
So these tests are mostly about what the token REFUSES.
|
||||
*/
|
||||
|
||||
const inviteSecret = "an-invitation-signing-secret-long-enough"
|
||||
|
||||
func inviteEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("POS_TOKEN_SECRET", inviteSecret)
|
||||
}
|
||||
|
||||
func TestAnInvitationNamesTheAccountItWasIssuedFor(t *testing.T) {
|
||||
inviteEnv(t)
|
||||
now := time.Now()
|
||||
|
||||
token, expires, err := MintInviteToken(InviteClaims{Userid: 904, Tenantid: 1147}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
claims, err := ParseInviteToken(token, now)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing: %v", err)
|
||||
}
|
||||
if claims.Userid != 904 || claims.Tenantid != 1147 {
|
||||
t.Fatalf("claims came back as %+v", claims)
|
||||
}
|
||||
if expires.Sub(now) != InviteTokenTTL {
|
||||
t.Fatalf("expiry is %v, want %v", expires.Sub(now), InviteTokenTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEditedInvitationIsRefused(t *testing.T) {
|
||||
// The whole point. If the payload could be edited, the link would be a
|
||||
// userid in a longer coat and every account would be one base64 edit away.
|
||||
inviteEnv(t)
|
||||
now := time.Now()
|
||||
|
||||
token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
// Re-sign nothing; just change the payload, which is what an attacker who
|
||||
// decoded the link and wanted a different userid would do.
|
||||
forged, _, err := MintInviteToken(InviteClaims{Userid: 905}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
other := strings.Split(forged, ".")
|
||||
swapped := parts[0] + "." + other[1] + "." + parts[2]
|
||||
|
||||
if _, err := ParseInviteToken(swapped, now); err == nil {
|
||||
t.Fatal("a payload swapped under an old signature was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationSignedWithAnotherSecretIsRefused(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "one-secret-that-is-long-enough-here")
|
||||
token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-different-secret-also-long-enough")
|
||||
if _, err := ParseInviteToken(token, now); err == nil {
|
||||
t.Fatal("an invitation from another deployment was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredInvitationSaysWhatToDo(t *testing.T) {
|
||||
// The one failure here somebody can resolve themselves. "Not valid" would
|
||||
// send them to support; naming a resend sends them to whoever onboarded
|
||||
// them, which is where the fix actually is.
|
||||
inviteEnv(t)
|
||||
now := time.Now()
|
||||
|
||||
token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
_, err = ParseInviteToken(token, now.Add(InviteTokenTTL+time.Second))
|
||||
if err == nil {
|
||||
t.Fatal("an expired invitation was accepted")
|
||||
}
|
||||
// An expired invitation is the one failure here somebody can resolve
|
||||
// themselves, so it has to say how. "Invalid" would send them to support
|
||||
// instead of to whoever onboarded them.
|
||||
if !strings.Contains(err.Error(), "send another") {
|
||||
t.Fatalf("the refusal does not say what to do: %v", err)
|
||||
}
|
||||
// Read by a merchant on `/set-password`, not by a developer in a log. A
|
||||
// lowercase fragment in a red banner reads as something that leaked out.
|
||||
if !strings.HasPrefix(err.Error(), "This") || !strings.HasSuffix(err.Error(), ".") {
|
||||
t.Errorf("the refusal is not written as a sentence: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationIsNotASession(t *testing.T) {
|
||||
// They are the same shape signed with the same key. Without the prefix
|
||||
// check an invitation would verify as a console session — and it names a
|
||||
// userid with no role, no tenant check and a seven-day life, which is a far
|
||||
// weaker credential than a session and must never be usable as one.
|
||||
inviteEnv(t)
|
||||
now := time.Now()
|
||||
|
||||
invite, _, err := MintInviteToken(InviteClaims{Userid: 904, Tenantid: 1147}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseWebToken(invite, now); err == nil {
|
||||
t.Fatal("an invitation was accepted as a console session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASessionIsNotAnInvitation(t *testing.T) {
|
||||
// The other direction, which matters less but costs nothing to close: a
|
||||
// stolen session should not double as a password-reset link.
|
||||
inviteEnv(t)
|
||||
now := time.Now()
|
||||
|
||||
session, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseInviteToken(session, now); err == nil {
|
||||
t.Fatal("a console session was accepted as an invitation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRubbishIsRefusedWithoutPanicking(t *testing.T) {
|
||||
inviteEnv(t)
|
||||
now := time.Now()
|
||||
|
||||
for _, bad := range []string{
|
||||
"", " ", "i1.", "i1..", "i1.onlyonepart", "not-a-token",
|
||||
"i1.!!!not-base64!!!.signature", "w1.something.else",
|
||||
} {
|
||||
if _, err := ParseInviteToken(bad, now); err == nil {
|
||||
t.Fatalf("%q was accepted as an invitation", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationMustNameSomebody(t *testing.T) {
|
||||
// A token naming nobody authorises nothing, and must not be mistaken for
|
||||
// one authorising everything — the same rule the session parser applies.
|
||||
inviteEnv(t)
|
||||
|
||||
if _, _, err := MintInviteToken(InviteClaims{Userid: 0}, time.Now()); err == nil {
|
||||
t.Fatal("an invitation was minted for user 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSigningSecretMeansNoInvitations(t *testing.T) {
|
||||
// Rather than issuing something unverifiable. A deployment that cannot sign
|
||||
// cannot invite, and saying so at the point of minting is better than an
|
||||
// email whose link never works.
|
||||
t.Setenv("POS_TOKEN_SECRET", "")
|
||||
t.Setenv("JWT_SECRET_KEY", "")
|
||||
|
||||
if _, _, err := MintInviteToken(InviteClaims{Userid: 904}, time.Now()); err == nil {
|
||||
t.Fatal("an invitation was minted with no signing secret")
|
||||
}
|
||||
}
|
||||
154
utils/mail.go
Normal file
154
utils/mail.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/config"
|
||||
)
|
||||
|
||||
// Sending mail.
|
||||
//
|
||||
// ── Why the standard library and not a client ───────────────────────────────
|
||||
//
|
||||
// `net/smtp` is enough for what this sends: a handful of invitations a day, one
|
||||
// at a time, to addresses a person typed. Every transactional provider speaks
|
||||
// SMTP — SES, SendGrid, Resend, a company relay — so the choice of provider is
|
||||
// a host and a password rather than a dependency and a rewrite. Adding an SDK
|
||||
// would buy templating and analytics that nothing here wants yet, in exchange
|
||||
// for a supply chain.
|
||||
//
|
||||
// ── Nil is a configuration, not a failure ───────────────────────────────────
|
||||
//
|
||||
// `NewMailer` returns nil when no host is set, matching `NewChat` and
|
||||
// `NewEmbedder`. A deployment without mail still boots, still onboards tenants,
|
||||
// and records the invitation as unsent. The alternative — refusing to start —
|
||||
// would make mail a hard dependency of creating a merchant, which it is not.
|
||||
|
||||
// Mailer sends one message.
|
||||
//
|
||||
// One method, on purpose. Everything this server sends is a short transactional
|
||||
// note to one recipient; a richer interface would be describing a mail product
|
||||
// nobody asked for.
|
||||
type Mailer interface {
|
||||
Send(to, subject, body string) error
|
||||
}
|
||||
|
||||
// mailTimeout bounds a send.
|
||||
//
|
||||
// Onboarding waits on this, so it cannot be generous. A relay that has not
|
||||
// answered in ten seconds is not going to, and the invitation is better
|
||||
// recorded as unsent — and resent — than holding a tenant creation open.
|
||||
const mailTimeout = 10 * time.Second
|
||||
|
||||
type smtpMailer struct {
|
||||
cfg config.MailConfig
|
||||
}
|
||||
|
||||
// NewMailer builds a sender, or nil when none is configured.
|
||||
func NewMailer(cfg config.MailConfig) (Mailer, error) {
|
||||
if !cfg.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := mail.ParseAddress(cfg.FromAddress); err != nil {
|
||||
// Caught here rather than at the first send, because a malformed
|
||||
// sender fails every message and should stop the deployment being
|
||||
// described as able to send.
|
||||
return nil, fmt.Errorf("MAIL_FROM is not a valid address: %w", err)
|
||||
}
|
||||
return &smtpMailer{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (m *smtpMailer) Send(to, subject, body string) error {
|
||||
recipient, err := mail.ParseAddress(strings.TrimSpace(to))
|
||||
if err != nil {
|
||||
// A merchant's primary email is typed by whoever onboarded them, so a
|
||||
// typo here is ordinary. Named clearly, because the fix is to correct
|
||||
// the tenant's record and resend.
|
||||
return fmt.Errorf("%q is not a valid email address", to)
|
||||
}
|
||||
|
||||
from := mail.Address{Name: m.cfg.FromName, Address: m.cfg.FromAddress}
|
||||
message := buildMessage(from, *recipient, subject, body)
|
||||
|
||||
client, err := m.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if m.cfg.Username != "" {
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("the mail server refused our credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(m.cfg.FromAddress); err != nil {
|
||||
return fmt.Errorf("the mail server refused the sender: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(recipient.Address); err != nil {
|
||||
return fmt.Errorf("the mail server refused %s: %w", recipient.Address, err)
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write([]byte(message)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// dial opens a connection, upgrading to TLS where the server offers it.
|
||||
//
|
||||
// STARTTLS rather than implicit TLS, because 587 is the submission port every
|
||||
// provider documents and it begins in the clear. The upgrade is attempted
|
||||
// whenever the server advertises it and the connection is abandoned if it fails
|
||||
// — an invitation is a credential, and sending one over plaintext to a server
|
||||
// that offered encryption would be choosing not to use it.
|
||||
func (m *smtpMailer) dial() (*smtp.Client, error) {
|
||||
client, err := smtp.Dial(m.cfg.Address())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not reach the mail server at %s: %w", m.cfg.Address(), err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("the mail server offered TLS and then refused it: %w", err)
|
||||
}
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// buildMessage assembles the wire format.
|
||||
//
|
||||
// Headers then a blank line then the body, with CRLF line endings — SMTP is
|
||||
// specified in terms of CRLF and some servers reject bare newlines, which
|
||||
// presents as mail that works locally and vanishes in production.
|
||||
func buildMessage(from, to mail.Address, subject, body string) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("From: " + from.String() + "\r\n")
|
||||
b.WriteString("To: " + to.String() + "\r\n")
|
||||
// Folded and encoded by `mail.Address`'s rules for the addresses; the
|
||||
// subject is plain ASCII by construction in this codebase, so it needs no
|
||||
// MIME word encoding. If a subject ever carries a merchant's name, that
|
||||
// changes and this needs `mime.QEncoding`.
|
||||
b.WriteString("Subject: " + strings.ReplaceAll(subject, "\n", " ") + "\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(strings.ReplaceAll(body, "\n", "\r\n"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user