Files
backend_fiesta/middleware/webauth.go
2026-09-24 11:38:46 +05:30

333 lines
12 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 console.
//
// The `/web` surface has never had any. The console keeps its login record in
// per-tab `sessionStorage` and sends no `Authorization` header, so every
// endpoint under `/v1/web` reads `tenantid` off the query string and believes
// it. Changing one number in a URL reads another merchant's orders, stock,
// staff and takings.
//
// This is the same hole `posauth.go` was written to close on the POS surface,
// and it is closed the same way, in the same order:
//
// 1. the caller holds a token this server signed, and
// 2. the tenant they are naming is the tenant inside that token.
//
// The second is the one that matters. A valid session is not a licence to name
// any tenant — it is a licence to name *your* tenant.
//
// ── Why this could not wait for the assistant ───────────────────────────────
//
// Nearle Buddy answers questions over this same data. Behind REST, reading
// another merchant's books takes knowing the endpoints, knowing the fields and
// iterating. Behind an assistant it is one sentence — "summarise the top ten
// tenants by revenue" — and the model assembles the cross-tenant answer itself,
// accurately and helpfully, because the data was in scope. The permission rules
// the assistant needs have nothing to stand on until this exists.
//
// ── What this does NOT yet do ───────────────────────────────────────────────
//
// It verifies what a request NAMES: the tenant, and the branch. It does not yet
// make handlers derive their scope from the session rather than from the wire.
//
// It also does not validate `partnerid`, `customerid` or `appuserid`, and that
// one is not an oversight — it is blocked. A delivery partner serves several
// merchants at once (`insights.ts` records partner 60 answering with deliveries
// spanning twelve shops), so scoping a read by partner is a cross-tenant read by
// design. Refusing the parameter outright would be wrong: `RiderDrawer` and
// `AssignBar` are merchant screens and both send it legitimately, for a partner
// assigned to that merchant.
//
// Closing it properly needs a check this codebase does not have — "is this
// partner assigned to this tenant?" — in the shape of `LocationAllowed`, which
// answers the same question for branches. Until that exists, a handler scoping
// on one of these three is trusting the caller, and the assistant is kept away
// from them entirely: no tool accepts any of these as an argument, and the
// registry refuses to register one that tries.
// WebLocalsKey names where the verified claims are parked for handlers.
const WebLocalsKey = "webclaims"
// webAuthRequired reports whether a request without a valid token is refused.
//
// Defaults to ON. It did not always: this shipped defaulting to off, because
// the console was live and its sign-in did not yet hand back a token, so
// enforcing first would have locked every merchant out of a working product.
//
// That rollout is finished. Sign-in mints a token, the console sends it on
// every call, and it expires cleanly. Leaving the default off after that point
// was not caution, it was an open door nobody had got round to shutting — and
// it was measured wide open: a `getorders` with no credential at all returned a
// real merchant's orders to anyone on the internet.
//
// ── The way out, if this goes wrong ─────────────────────────────────────────
//
// `WEB_AUTH_REQUIRED=false` restores the old behaviour, immediately and without
// a deploy. That is the escape hatch, and it exists because flipping a default
// that can lock people out should always be reversible by one person in one
// minute. A token that is SENT is still always verified either way — the flag
// only decides what happens to a request carrying none.
func webAuthRequired() bool {
setting := strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED"))
if setting == "" {
return true
}
return !strings.EqualFold(setting, "false")
}
// publicWebPaths are the endpoints that must work before anybody has a token.
//
// Sign-in, chiefly: guarding the login route with a session token means nobody
// can ever obtain one. Kept as suffixes rather than full paths so the group
// prefix can move without silently locking the door.
var publicWebPaths = []string{
"/users/applogin",
"/users/weblogin",
"/tenant/weblogin",
// First-password-set runs before a session exists, from a link in the
// invitation mail.
"/users/setpassword",
}
func isPublicWebPath(path string) bool {
lower := strings.ToLower(path)
for _, suffix := range publicWebPaths {
if strings.HasSuffix(lower, suffix) {
return true
}
}
return false
}
// webLocationChecker is the only question this middleware asks of the database:
// does this tenant own this branch? Narrowed to one method so the guard can be
// tested without a database, and so it cannot quietly grow a second dependency.
type webLocationChecker interface {
LocationAllowed(tenantID, locationID int) (bool, error)
}
// WebAuth verifies the console session and pins the request to its tenant.
func WebAuth(pos services.PosService) fiber.Handler { return webAuthWith(pos) }
func webAuthWith(locations webLocationChecker) fiber.Handler {
return func(c *fiber.Ctx) error {
if isPublicWebPath(c.Path()) {
return c.Next()
}
token := webBearerToken(c)
if token == "" {
if webAuthRequired() {
return webUnauthorized(c, "a session token is required; sign in again")
}
// A console that predates tokens. Allowed through unpinned, which is
// exactly the state this middleware exists to end — see
// webAuthRequired.
return c.Next()
}
claims, err := utils.ParseWebToken(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 webUnauthorized(c, err.Error())
}
// Nearle's own staff work across every tenant and legitimately name any
// of them. Checked once, here, rather than at each test below, so the
// exemption is a single visible branch instead of three.
if !claims.IsPlatformAccount() {
if requested := requestedTenant(c); requested > 0 && requested != claims.Tenantid {
return webForbidden(c, "this session cannot reach tenant "+strconv.Itoa(requested))
}
// A request can also scope by branch alone, naming no tenant at all,
// so pinning the tenant is not enough on its own.
if requested := requestedWebLocation(c); requested > 0 && requested != claims.Locationid {
allowed, err := locations.LocationAllowed(claims.Tenantid, requested)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "status": false,
"message": "could not verify branch access",
})
}
if !allowed {
return webForbidden(c, "this session cannot reach branch "+strconv.Itoa(requested))
}
}
}
c.Locals(WebLocalsKey, claims)
return c.Next()
}
}
// webBearerToken reads the session out of the request.
//
// `Authorization: Bearer …` only. The POS reader next door also accepts
// `X-Pos-Token`, because 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. The console has no such problem — it is a
// browser on HTTPS — so it gets the one form, and a second accepted header is
// a second thing to get wrong.
func webBearerToken(c *fiber.Ctx) string {
header := strings.TrimSpace(c.Get("Authorization"))
if header == "" {
return ""
}
if after, found := strings.CutPrefix(header, "Bearer "); found {
return strings.TrimSpace(after)
}
if !strings.Contains(header, " ") {
return header
}
return ""
}
// requestedTenant reads the tenant a request is naming, from wherever it put it.
//
// Query first, because that is where every `/web` list endpoint carries it, then
// the body, because the writes do not: `createdeliveries`, `publishproduct` and
// the rest post JSON. Checking only the query would leave every call that
// CHANGES another tenant's data unguarded, which is the wrong half to skip.
func requestedTenant(c *fiber.Ctx) int {
for _, key := range []string{"tenantid", "tenant_id"} {
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
return id
}
}
}
return bodyScopeID(c, "tenantid", "tenant_id")
}
// requestedWebLocation reads the branch a request is naming.
//
// Separate from the POS reader's `requestedLocation` because the two surfaces
// spell it differently: POS routes use `store_id`, the console uses
// `locationid`. Both spellings are read here anyway — a shared endpoint is
// cheaper to allow for than to discover.
func requestedWebLocation(c *fiber.Ctx) int {
for _, key := range []string{"locationid", "location_id", "store_id"} {
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
return id
}
}
}
return bodyScopeID(c, "locationid", "location_id", "store_id")
}
// bodyScopeID pulls a scoping id out of a JSON request body.
//
// Decoded loosely rather than into a request type, on purpose: this runs before
// the handler and must not refuse anything the handler would have accepted. A
// body that will not parse here is left for the handler to reject with its own
// message, and a request shape that changes later must not silently stop being
// authorised.
//
// `c.Body()` returns buffered bytes, so reading here does not consume the
// stream the handler goes on to parse.
//
// An ARRAY body — `createdeliveries` posts one — is walked too. A batch naming
// another tenant in its elements is precisely the call worth guarding, and a
// probe that only understood objects would wave it through.
func bodyScopeID(c *fiber.Ctx, keys ...string) int {
body := c.Body()
if len(body) == 0 || len(body) > 8<<20 {
return 0
}
var raw json.RawMessage = body
trimmed := strings.TrimLeft(string(body), " \t\r\n")
if strings.HasPrefix(trimmed, "[") {
var elements []json.RawMessage
if err := json.Unmarshal(body, &elements); err != nil {
return 0
}
for _, element := range elements {
if id := scopeIDFromObject(element, keys); id > 0 {
return id
}
}
return 0
}
return scopeIDFromObject(raw, keys)
}
func scopeIDFromObject(raw json.RawMessage, keys []string) int {
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return 0
}
for _, key := range keys {
if id := asScopeID(fields[key]); id > 0 {
return id
}
}
return 0
}
// asScopeID reads an id that may have been sent as a number or as a string.
//
// Both spellings are on the wire today — the console sends numbers, some app
// callers send strings — and a probe that understood only one would return 0
// for the other, which reads as "named no tenant" and waves the request past
// the check.
func asScopeID(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
var number int
if err := json.Unmarshal(raw, &number); err == nil {
return number
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
if id, err := strconv.Atoi(strings.TrimSpace(text)); err == nil {
return id
}
}
return 0
}
func webUnauthorized(c *fiber.Ctx, message string) error {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false, "message": message,
})
}
func webForbidden(c *fiber.Ctx, message string) error {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false, "message": message,
})
}
// WebClaimsFrom returns the verified session on a request, if it carried one.
//
// The second return distinguishes "no token" from "a token claiming tenant 0",
// which is a platform account and a real answer. A handler that treated the two
// alike would give an unauthenticated caller the one session that reads
// everything.
func WebClaimsFrom(c *fiber.Ctx) (utils.WebClaims, bool) {
claims, ok := c.Locals(WebLocalsKey).(utils.WebClaims)
return claims, ok
}