333 lines
12 KiB
Go
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
|
|
}
|