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>
220 lines
7.7 KiB
Go
220 lines
7.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/services"
|
|
"nearle/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// Authorisation for the POS terminal.
|
|
//
|
|
// Before this, the whole POS surface was open. A till named its own outlet on
|
|
// the wire and was believed, so `store_id=1185` in a URL was enough to read
|
|
// another tenant's catalogue or post 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.
|
|
//
|
|
// The fix is two checks, in this order:
|
|
//
|
|
// 1. the caller holds a token this server signed, and
|
|
// 2. the outlet they are naming belongs to the tenant inside that token.
|
|
//
|
|
// The second is the one that matters. A valid token is not a licence to name
|
|
// any location — it is a licence to name *your* locations, and without the
|
|
// cross-check a real terminal at one shop could still read the shop next door.
|
|
|
|
// PosLocalsKey names where the verified claims are parked for handlers.
|
|
const PosLocalsKey = "posclaims"
|
|
|
|
// posAuthRequired reports whether a request without a valid token is refused.
|
|
//
|
|
// Defaults to OFF, and that is a deliberate, temporary choice rather than an
|
|
// oversight. Terminals are already in shops billing real customers against the
|
|
// unauthenticated endpoints; switching enforcement on at deploy would stop
|
|
// every one of them mid-trade. So the endpoint ships first, tills adopt it, and
|
|
// `POS_AUTH_REQUIRED=true` closes the door once the fleet is carrying tokens.
|
|
//
|
|
// While it is off a token is still *verified* when one is sent, and a request
|
|
// carrying a token for the wrong tenant is still refused — the flag only
|
|
// decides what happens to a request carrying none.
|
|
func posAuthRequired() bool {
|
|
return strings.EqualFold(strings.TrimSpace(os.Getenv("POS_AUTH_REQUIRED")), "true")
|
|
}
|
|
|
|
// PosAuth verifies the session token and pins the request to its outlet.
|
|
func PosAuth(pos services.PosService) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
token := bearerToken(c)
|
|
|
|
if token == "" {
|
|
if posAuthRequired() {
|
|
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
|
|
}
|
|
// Legacy till. Allowed through un-pinned, which is exactly the state
|
|
// this middleware exists to end — see posAuthRequired.
|
|
return c.Next()
|
|
}
|
|
|
|
claims, err := utils.ParsePosToken(token, time.Now())
|
|
if err != nil {
|
|
// Always refused, flag or no flag. A token that does not verify is
|
|
// a stronger signal than no token at all: nothing sends a broken
|
|
// one by accident.
|
|
return posUnauthorized(c, err.Error())
|
|
}
|
|
|
|
// The outlet named in the request, if it named one. Every POS route
|
|
// spells this differently — `store_id` on catalogue, `locationid` on
|
|
// sales, `location_id` on health — so all three are read rather than
|
|
// the caller being asked to change.
|
|
requested := requestedLocation(c)
|
|
|
|
if requested > 0 && requested != claims.Locationid {
|
|
// A different outlet than the token was issued for. Permitted only
|
|
// if the tenant genuinely owns it — a proprietor with six shops
|
|
// should be able to look at all six from one signed-in session.
|
|
allowed, err := pos.LocationAllowed(claims.Tenantid, requested)
|
|
if err != nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
|
"code": http.StatusServiceUnavailable, "status": false,
|
|
"message": "could not verify outlet access",
|
|
})
|
|
}
|
|
if !allowed {
|
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
|
"code": http.StatusForbidden, "status": false,
|
|
"message": "this session cannot reach outlet " + strconv.Itoa(requested),
|
|
})
|
|
}
|
|
}
|
|
|
|
c.Locals(PosLocalsKey, claims)
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// bearerToken reads the session out of the request.
|
|
//
|
|
// `Authorization: Bearer …` is the form to use. `X-Pos-Token` is accepted as
|
|
// well because some of the shop routers between a till and this server strip
|
|
// Authorization headers on plain HTTP, and a terminal that cannot authenticate
|
|
// is a shop that cannot trade.
|
|
func bearerToken(c *fiber.Ctx) string {
|
|
header := strings.TrimSpace(c.Get("Authorization"))
|
|
if header != "" {
|
|
if after, found := strings.CutPrefix(header, "Bearer "); found {
|
|
return strings.TrimSpace(after)
|
|
}
|
|
if !strings.Contains(header, " ") {
|
|
// Tolerates a bare token. Terminals in the field get this wrong and
|
|
// the alternative is a shop that cannot sell.
|
|
return header
|
|
}
|
|
}
|
|
return strings.TrimSpace(c.Get("X-Pos-Token"))
|
|
}
|
|
|
|
// requestedLocation reads whichever outlet parameter this route happens to use.
|
|
//
|
|
// The three spellings are a wart — `store_id`, `locationid` and `location_id`
|
|
// all mean the same thing across the POS routes. Normalising them is a breaking
|
|
// change for terminals already in the field, so this reads all three instead
|
|
// and leaves the naming alone.
|
|
//
|
|
// The body is searched as well as the query, and that is not an optional extra:
|
|
// the two routes that *write* — order and customer ingest — carry `store_id` in
|
|
// a JSON batch and never in the URL. Checking only the query string would leave
|
|
// the exact call that posts bills into another tenant's books unguarded, which
|
|
// is the hole this middleware exists to close.
|
|
func requestedLocation(c *fiber.Ctx) int {
|
|
for _, key := range []string{"store_id", "locationid", "location_id"} {
|
|
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
|
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
return bodyLocation(c)
|
|
}
|
|
|
|
// bodyLocation pulls the outlet out of a JSON request body.
|
|
//
|
|
// Decoded into a loose map rather than the batch type on purpose. This runs
|
|
// before the handler and must not reject anything the handler would have
|
|
// accepted — a body that fails to parse here is left to the handler to refuse
|
|
// with its own message, and a batch shape that changes later must not silently
|
|
// stop being authorised.
|
|
//
|
|
// `c.Body()` returns the buffered bytes, so reading it here does not consume
|
|
// the stream the handler goes on to parse.
|
|
func bodyLocation(c *fiber.Ctx) int {
|
|
body := c.Body()
|
|
if len(body) == 0 || len(body) > 8<<20 {
|
|
return 0
|
|
}
|
|
|
|
var probe struct {
|
|
Storeid json.RawMessage `json:"store_id"`
|
|
Locationid json.RawMessage `json:"location_id"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err != nil {
|
|
return 0
|
|
}
|
|
|
|
for _, raw := range []json.RawMessage{probe.Storeid, probe.Locationid} {
|
|
if id := asLocationID(raw); id > 0 {
|
|
return id
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// asLocationID reads an id that may have been sent as a number or as a string.
|
|
//
|
|
// The till sends `"store_id": "1135"` and the health payload sends
|
|
// `"location_id": "1135"`, both quoted, while other callers send it bare.
|
|
// Accepting only one shape would silently skip the check for the other — and a
|
|
// skipped check here reads exactly like a passed one.
|
|
func asLocationID(raw json.RawMessage) int {
|
|
if len(raw) == 0 {
|
|
return 0
|
|
}
|
|
|
|
var asString string
|
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
|
if id, err := strconv.Atoi(strings.TrimSpace(asString)); err == nil && id > 0 {
|
|
return id
|
|
}
|
|
return 0
|
|
}
|
|
|
|
var asNumber int
|
|
if err := json.Unmarshal(raw, &asNumber); err == nil && asNumber > 0 {
|
|
return asNumber
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func posUnauthorized(c *fiber.Ctx, message string) error {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
|
"code": http.StatusUnauthorized, "status": false, "message": message,
|
|
})
|
|
}
|
|
|
|
// PosClaimsFrom returns the verified session on a request, if it carried one.
|
|
//
|
|
// The second return distinguishes "no token" from "a token claiming tenant 0",
|
|
// which a caller acting on the tenant id must not confuse.
|
|
func PosClaimsFrom(c *fiber.Ctx) (utils.PosClaims, bool) {
|
|
claims, ok := c.Locals(PosLocalsKey).(utils.PosClaims)
|
|
return claims, ok
|
|
}
|