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
|
||||
}
|
||||
Reference in New Issue
Block a user