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 }