Files
backend_fiesta/middleware/posauth.go
2026-09-30 18:01:59 +05:30

232 lines
8.3 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 == "" {
// Counted before anything else happens to it. This is the number
// that decides when POS_AUTH_REQUIRED can be switched on, and
// nothing else in the system was recording it — the request was
// simply waved through in silence. See posauthadoption.go.
recordPosUntokened(requestedLocation(c), c.Path())
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),
})
}
}
// A till that has adopted the new sign-in. Counted only once the token
// has verified AND the outlet check has passed, so the figure means
// "requests this guard would still serve with enforcement on" rather
// than "requests that carried something token-shaped".
recordPosToken()
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
}