Give the POS a real sign-in, and stop believing the store id on the wire
The POS surface was open. A till named its own outlet — `store_id` in a query or in an ingest batch — and was believed, so one number changed in Settings read another tenant's catalogue or posted bills into their books. There was no middleware in the codebase at all, and the `JWT_SECRET_KEY` in the config was read and never used. Products were never mis-scoped: `resolvePosStore` already derived the tenant from the location and the catalogue query already filtered on both. The tenant was never taken from the wire. What was missing was any check that the caller was entitled to the location they named. So the outlet now comes *out* of a sign-in rather than going *in* from the till. `POST /pos/login` authenticates against the same `app_users` rows the web console uses — one account store, so deactivating a leaver closes both doors — and answers with the outlets that account may reach, sealed in an HMAC-SHA256 token the terminal cannot edit. Two checks then guard everything else, in order: the token verifies, and the outlet named in the request belongs to the token's tenant. The second is the one that matters — a valid token is a licence to name *your* outlets, not any. Notes on the awkward parts: - The guard reads the outlet from the body as well as the query. The two routes that write carry `store_id` in a JSON batch and never in the URL, so a query-only check would have left exactly the dangerous call unguarded. - Three spellings of one thing survive — `store_id`, `locationid`, `location_id`. All three are read rather than normalised, because renaming them breaks terminals already in the field. - `POS_AUTH_REQUIRED` defaults to false. Tills are billing real customers against the open endpoints right now and enforcing at deploy would stop every one mid-trade. A token is still verified when sent, and a wrong-tenant token still refused; the flag only governs requests carrying none. - `POS_TOKEN_SECRET` has no baked-in fallback and fails loudly. A development secret in source is the same as no signature at all. - `configid` is inferred when the till does not send it, because a person at a counter has no way to know theirs. `authname` is not unique in this schema — live data has one address twice under one configid — so an ambiguous match is refused rather than resolved by LIMIT 1, which could bill into the wrong tenant's books. Verified against live data: 58 accounts across 34 tenants can open a till, an account pinned to a location resolves to it alone, a tenant-level account gets all six of its outlets, and a cross-tenant outlet request is refused. Passwords are still plaintext platform-wide. Flagged at the comparison site; fixing it is a migration touching every login path, not this endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
164
utils/postoken.go
Normal file
164
utils/postoken.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session tokens for the POS terminal.
|
||||
//
|
||||
// A till is not a browser. It signs in once when a shop opens and then bills
|
||||
// for a whole trading day — often on a connection that comes and goes — so the
|
||||
// thing it carries has to survive a reboot, a lost network, and an hour in a
|
||||
// drawer. That rules out a server-side session table (a till that cannot reach
|
||||
// us must still be able to prove who it is when it comes back) and it rules out
|
||||
// a short expiry.
|
||||
//
|
||||
// So: a signed, self-describing token. Everything needed to authorise a request
|
||||
// is inside it, and the signature is what makes it trustworthy. No database
|
||||
// round trip on the hot path, and nothing to replicate between pods.
|
||||
//
|
||||
// Deliberately not JWT. The backend has no JWT dependency today, and the format
|
||||
// buys nothing here — there is exactly one issuer, one audience and one
|
||||
// algorithm, so the header that JWT spends bytes negotiating is a constant. The
|
||||
// `alg` field is also the source of JWT's worst-known footgun (`alg: none`),
|
||||
// and a format with no algorithm field cannot have that bug.
|
||||
//
|
||||
// Wire format is `base64url(payload).base64url(hmac-sha256)`, and the MAC is
|
||||
// taken over the encoded payload rather than the raw JSON so that verification
|
||||
// never has to re-serialise anything to check it.
|
||||
|
||||
// PosClaims is what a terminal proves about itself on every request.
|
||||
//
|
||||
// Locationid is the load-bearing field. Before this existed the till named its
|
||||
// own store on the wire and was believed, so changing one number in Settings
|
||||
// moved a terminal into another tenant's books. Now the location is decided at
|
||||
// sign-in, from the user's own record, and sealed under the signature.
|
||||
type PosClaims struct {
|
||||
Userid int `json:"uid"`
|
||||
Tenantid int `json:"tid"`
|
||||
Locationid int `json:"lid"`
|
||||
Roleid int `json:"rid"`
|
||||
Configid int `json:"cid"`
|
||||
Terminalid string `json:"trm,omitempty"`
|
||||
Issuedat int64 `json:"iat"`
|
||||
Expiresat int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// PosTokenTTL is how long a till stays signed in.
|
||||
//
|
||||
// Thirty days rather than hours. A shop signs the terminal in once and expects
|
||||
// it to keep working; forcing a re-login mid-shift would mean a queue of
|
||||
// customers waiting while somebody finds the manager's password. The exposure
|
||||
// that buys is bounded by the token naming a single location — a leaked one
|
||||
// bills into the shop it was already for.
|
||||
const PosTokenTTL = 30 * 24 * time.Hour
|
||||
|
||||
// posTokenSecret is the signing key.
|
||||
//
|
||||
// Fails loudly rather than falling back to a baked-in default. A hardcoded
|
||||
// development secret has a way of reaching production, and a signing key that
|
||||
// everyone with the source can compute is the same as no signature at all —
|
||||
// anyone could mint a token for any tenant.
|
||||
func posTokenSecret() ([]byte, error) {
|
||||
secret := strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET"))
|
||||
if secret == "" {
|
||||
// Falls back to the key the config file already carries, so a
|
||||
// deployment that set that one does not need a second variable.
|
||||
secret = strings.TrimSpace(os.Getenv("JWT_SECRET_KEY"))
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("POS_TOKEN_SECRET is not set; terminals cannot be issued sessions")
|
||||
}
|
||||
if len(secret) < 16 {
|
||||
return nil, fmt.Errorf("POS_TOKEN_SECRET is too short to sign with; use at least 16 characters")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
}
|
||||
|
||||
// MintPosToken issues a session for a signed-in terminal.
|
||||
func MintPosToken(claims PosClaims, now time.Time) (string, time.Time, error) {
|
||||
secret, err := posTokenSecret()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
expires := now.Add(PosTokenTTL)
|
||||
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 encoded + "." + sign(encoded, secret), expires, nil
|
||||
}
|
||||
|
||||
// ParsePosToken verifies a token and returns what it claims.
|
||||
//
|
||||
// Order matters: the signature is checked before the payload is trusted for
|
||||
// anything, including expiry. Reading `exp` out of an unverified payload and
|
||||
// acting on it would be taking the attacker's word for when their own token
|
||||
// runs out.
|
||||
func ParsePosToken(token string, now time.Time) (PosClaims, error) {
|
||||
secret, err := posTokenSecret()
|
||||
if err != nil {
|
||||
return PosClaims{}, err
|
||||
}
|
||||
|
||||
encoded, signature, found := strings.Cut(strings.TrimSpace(token), ".")
|
||||
if !found || encoded == "" || signature == "" {
|
||||
return PosClaims{}, fmt.Errorf("malformed session token")
|
||||
}
|
||||
|
||||
// Constant time, so a caller cannot learn the right signature one byte at a
|
||||
// time from how long the comparison took.
|
||||
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
|
||||
return PosClaims{}, fmt.Errorf("session token signature does not verify")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return PosClaims{}, fmt.Errorf("malformed session token")
|
||||
}
|
||||
|
||||
var claims PosClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return PosClaims{}, fmt.Errorf("malformed session token")
|
||||
}
|
||||
|
||||
if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
|
||||
return PosClaims{}, fmt.Errorf("session has expired; sign in again")
|
||||
}
|
||||
|
||||
// A token that verifies but names no outlet would authorise nothing and
|
||||
// must not be mistaken for one that authorises everything.
|
||||
if claims.Locationid <= 0 || claims.Tenantid <= 0 {
|
||||
return PosClaims{}, fmt.Errorf("session token names no outlet")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func sign(encoded string, secret []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(encoded))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// PosTokenConfigured reports whether sessions can be issued at all.
|
||||
//
|
||||
// Lets the server say "this deployment has no signing key" once at start-up
|
||||
// rather than answering every sign-in with a 500.
|
||||
func PosTokenConfigured() bool {
|
||||
_, err := posTokenSecret()
|
||||
return err == nil
|
||||
}
|
||||
166
utils/postoken_test.go
Normal file
166
utils/postoken_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withSecret(t *testing.T, secret string) {
|
||||
t.Helper()
|
||||
t.Setenv("POS_TOKEN_SECRET", secret)
|
||||
}
|
||||
|
||||
const testSecret = "a-test-signing-key-long-enough"
|
||||
|
||||
func TestASessionSurvivesTheRoundTrip(t *testing.T) {
|
||||
withSecret(t, testSecret)
|
||||
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
token, expires, err := MintPosToken(PosClaims{
|
||||
Userid: 42, Tenantid: 1087, Locationid: 1135, Roleid: 3, Terminalid: "T5EDD",
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
claims, err := ParsePosToken(token, now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("parsing a token we just issued: %v", err)
|
||||
}
|
||||
|
||||
if claims.Tenantid != 1087 || claims.Locationid != 1135 {
|
||||
t.Fatalf("the outlet did not survive: tenant %d location %d", claims.Tenantid, claims.Locationid)
|
||||
}
|
||||
if claims.Terminalid != "T5EDD" {
|
||||
t.Fatalf("terminal id lost: %q", claims.Terminalid)
|
||||
}
|
||||
if !expires.After(now) {
|
||||
t.Fatalf("expiry %v is not after issue %v", expires, now)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of signing. Before this existed a till named its own outlet
|
||||
// on the wire and was believed, so this is the test that says it no longer can.
|
||||
func TestARewrittenOutletIsRefused(t *testing.T) {
|
||||
withSecret(t, testSecret)
|
||||
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
// Tamper: decode the payload, move it to another tenant's outlet, re-encode
|
||||
// and keep the original signature — exactly what an attacker holding a real
|
||||
// token would try.
|
||||
encoded, signature, _ := strings.Cut(token, ".")
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding our own payload: %v", err)
|
||||
}
|
||||
|
||||
var claims PosClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
t.Fatalf("unmarshalling our own payload: %v", err)
|
||||
}
|
||||
claims.Tenantid = 916
|
||||
claims.Locationid = 1185
|
||||
|
||||
forged, _ := json.Marshal(claims)
|
||||
tampered := base64.RawURLEncoding.EncodeToString(forged) + "." + signature
|
||||
|
||||
if _, err := ParsePosToken(tampered, now); err == nil {
|
||||
t.Fatal("a token whose outlet was rewritten was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredSessionIsRefused(t *testing.T) {
|
||||
withSecret(t, testSecret)
|
||||
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParsePosToken(token, now.Add(PosTokenTTL+time.Minute)); err == nil {
|
||||
t.Fatal("an expired session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A token signed by somebody else must not verify here, or the signature is
|
||||
// decoration.
|
||||
func TestATokenFromAnotherKeyIsRefused(t *testing.T) {
|
||||
withSecret(t, testSecret)
|
||||
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
withSecret(t, "a-completely-different-key-here")
|
||||
if _, err := ParsePosToken(token, now); err == nil {
|
||||
t.Fatal("a token signed with another key verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMalformedTokenIsRefused(t *testing.T) {
|
||||
withSecret(t, testSecret)
|
||||
now := time.Now()
|
||||
|
||||
for _, token := range []string{
|
||||
"",
|
||||
"nodot",
|
||||
".",
|
||||
"only.",
|
||||
".onlysignature",
|
||||
"not-base64!.also-not-base64!",
|
||||
} {
|
||||
if _, err := ParsePosToken(token, now); err == nil {
|
||||
t.Fatalf("malformed token %q was accepted", token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no signing key must fail loudly rather than fall back to a
|
||||
// key anyone reading the source could compute.
|
||||
func TestNoSecretMeansNoSessions(t *testing.T) {
|
||||
t.Setenv("POS_TOKEN_SECRET", "")
|
||||
t.Setenv("JWT_SECRET_KEY", "")
|
||||
|
||||
if PosTokenConfigured() {
|
||||
t.Fatal("reported configured with no secret set")
|
||||
}
|
||||
if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil {
|
||||
t.Fatal("minted a session with no signing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShortSecretIsRefused(t *testing.T) {
|
||||
t.Setenv("POS_TOKEN_SECRET", "short")
|
||||
t.Setenv("JWT_SECRET_KEY", "")
|
||||
|
||||
if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil {
|
||||
t.Fatal("signed with a secret too short to be worth signing with")
|
||||
}
|
||||
}
|
||||
|
||||
// A token that verifies but names no outlet authorises nothing, and must not be
|
||||
// mistaken for one that authorises everything.
|
||||
func TestASessionNamingNoOutletIsRefused(t *testing.T) {
|
||||
withSecret(t, testSecret)
|
||||
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 0, Locationid: 0}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParsePosToken(token, now); err == nil {
|
||||
t.Fatal("a session naming no outlet was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user