153 lines
6.4 KiB
Go
153 lines
6.4 KiB
Go
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
|
|
}
|