192 lines
6.1 KiB
Go
192 lines
6.1 KiB
Go
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")
|
|
}
|
|
}
|