232 lines
8.3 KiB
Go
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
|
|
}
|