The /web endpoints have never had authentication. The console keeps its
login record in per-tab sessionStorage and sends no Authorization header,
so every endpoint under /v1/web read `tenantid` off the query string and
believed it — one number in a URL reached another merchant's orders,
stock, staff and takings. `createposuser` under /v1/web/tenants minted
till credentials on the strength of an unauthenticated request, which the
route file already flagged in as many words.
Closed the same way posauth.go closed it for the terminals, in the same
order: the caller holds a token this server signed, and the tenant they
name is the tenant inside that token.
- utils/webtoken.go same HMAC construction as the POS token, 12h TTL,
a `w1.` prefix so the two kinds cannot verify as
each other
- middleware/webauth.go verifies the token, pins the tenant, and checks
a named branch belongs to it; reads the tenant from
the query, the body, and inside a JSON array, since
createdeliveries posts one
- login now issues the token; the console sends it as Bearer
Platform access rides on issuperadmin and nothing else. Not the role —
app_roles calls roleid 1 "Super admin" and tenant onboarding wrote 1 for
every shop owner, so a role test would promote every merchant on the
platform. Not a zero tenant either, or a user row with the field unset
becomes the one session that reads everything. Both near-misses have
tests.
WEB_AUTH_REQUIRED defaults to off. The console in production does not
send a token yet, and enforcing before it does would lock every merchant
out of a working product. A token that IS sent is always verified, and
one naming the wrong tenant is always refused; the flag only decides what
happens to a request carrying none. This should be a short-lived state.
Still trusting the caller: partnerid, customerid and appuserid, which
some list endpoints also scope on. Noted in the middleware header.
25 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
10 KiB
Go
274 lines
10 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"nearle/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
const webTestSecret = "a-test-signing-key-long-enough"
|
|
|
|
// fakeLocations answers the tenant-owns-branch question without a database.
|
|
//
|
|
// `owned` is the branch the tenant genuinely has; anything else is refused, and
|
|
// `fails` makes the lookup itself error so the unavailable path can be reached.
|
|
type fakeLocations struct {
|
|
tenant int
|
|
owned int
|
|
fails bool
|
|
}
|
|
|
|
func (f fakeLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
|
|
if f.fails {
|
|
return false, errFakeLookup
|
|
}
|
|
return tenantID == f.tenant && locationID == f.owned, nil
|
|
}
|
|
|
|
type fakeErr struct{}
|
|
|
|
func (fakeErr) Error() string { return "lookup unavailable" }
|
|
|
|
var errFakeLookup = fakeErr{}
|
|
|
|
// call runs one request through the middleware and reports the status.
|
|
//
|
|
// The handler behind it always succeeds, so any non-200 came from the guard.
|
|
func call(t *testing.T, locations webLocationChecker, token, method, target, body string) int {
|
|
t.Helper()
|
|
|
|
app := fiber.New()
|
|
app.Use("/live/api/v1/web", webAuthWith(locations))
|
|
app.All("/live/api/v1/web/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
|
|
|
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("calling: %v", err)
|
|
}
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func tokenFor(t *testing.T, claims utils.WebClaims) string {
|
|
t.Helper()
|
|
token, _, err := utils.MintWebToken(claims, time.Now())
|
|
if err != nil {
|
|
t.Fatalf("minting: %v", err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
/* ── The hole this exists to close ─────────────────────────────────────── */
|
|
|
|
func TestASessionCannotNameAnotherTenant(t *testing.T) {
|
|
// One number in a URL. Before this middleware it read another merchant's
|
|
// orders, stock, staff and takings.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
|
|
|
own := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
|
if own != fiber.StatusOK {
|
|
t.Fatalf("a session was refused its own tenant: %d", own)
|
|
}
|
|
|
|
other := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
|
if other != fiber.StatusForbidden {
|
|
t.Fatalf("tenant 916 was readable with a tenant 1147 session: %d", other)
|
|
}
|
|
}
|
|
|
|
func TestAWriteCannotNameAnotherTenantInItsBody(t *testing.T) {
|
|
// The half that would be easy to skip. Reads carry `tenantid` in the query;
|
|
// the calls that CHANGE things post JSON, so a query-only check leaves every
|
|
// write unguarded.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
|
|
|
body := `{"tenantid":916,"productname":"Milk Bikis"}`
|
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", body)
|
|
if got != fiber.StatusForbidden {
|
|
t.Fatalf("a write into tenant 916 was allowed: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestABatchCannotSmuggleAnotherTenantInAnArray(t *testing.T) {
|
|
// `createdeliveries` posts an array. A probe that only understood objects
|
|
// would wave through exactly the call that creates work in another
|
|
// merchant's shop.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
|
|
|
body := `[{"orderheaderid":1,"tenantid":916}]`
|
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/deliveries/createdeliveries", body)
|
|
if got != fiber.StatusForbidden {
|
|
t.Fatalf("a batch naming tenant 916 was allowed: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestATenantSentAsAStringIsStillChecked(t *testing.T) {
|
|
// Both spellings are on the wire. A probe that understood only numbers
|
|
// returns 0 for `"916"`, which reads as "named no tenant" and passes.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
|
|
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", `{"tenantid":"916"}`)
|
|
if got != fiber.StatusForbidden {
|
|
t.Fatalf("a string tenant id slipped past: %d", got)
|
|
}
|
|
}
|
|
|
|
/* ── Scoping by branch alone ───────────────────────────────────────────── */
|
|
|
|
func TestABranchMustBelongToTheSessionsTenant(t *testing.T) {
|
|
// A request can scope by branch and name no tenant at all, so pinning the
|
|
// tenant is not sufficient on its own.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
|
locations := fakeLocations{tenant: 1147, owned: 1173}
|
|
|
|
mine := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1173", "")
|
|
if mine != fiber.StatusOK {
|
|
t.Fatalf("a second branch of my own tenant was refused: %d", mine)
|
|
}
|
|
|
|
theirs := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
|
if theirs != fiber.StatusForbidden {
|
|
t.Fatalf("another tenant's branch was readable: %d", theirs)
|
|
}
|
|
}
|
|
|
|
func TestTheSessionsOwnBranchNeedsNoLookup(t *testing.T) {
|
|
// `fails: true` errors on any lookup, so reaching OK proves the home branch
|
|
// short-circuits before asking.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
|
|
|
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1172", "")
|
|
if got != fiber.StatusOK {
|
|
t.Fatalf("the session's own branch was refused: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestAFailedBranchLookupIsNotAPass(t *testing.T) {
|
|
// If the check cannot run, the answer is "cannot verify", never "allowed".
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
|
|
|
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
|
if got != fiber.StatusServiceUnavailable {
|
|
t.Fatalf("a broken lookup did not refuse: %d", got)
|
|
}
|
|
}
|
|
|
|
/* ── Tokens ────────────────────────────────────────────────────────────── */
|
|
|
|
func TestABrokenTokenIsAlwaysRefused(t *testing.T) {
|
|
// Refused whatever the flag says. Nothing sends a broken token by accident.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
|
|
|
got := call(t, fakeLocations{}, "w1.rubbish.signature", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
|
if got != fiber.StatusUnauthorized {
|
|
t.Fatalf("a forged token was not refused: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestATillsTokenIsNotAConsoleSessionHere(t *testing.T) {
|
|
// A POS token is the same shape signed with the same key. If it verified
|
|
// here its `Locationid` would land where `Tenantid` is read.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
pos, _, err := utils.MintPosToken(utils.PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, time.Now())
|
|
if err != nil {
|
|
t.Fatalf("minting a POS token: %v", err)
|
|
}
|
|
|
|
got := call(t, fakeLocations{}, pos, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
|
if got != fiber.StatusUnauthorized {
|
|
t.Fatalf("a cashier's token was accepted on the console: %d", got)
|
|
}
|
|
}
|
|
|
|
/* ── The staged rollout ────────────────────────────────────────────────── */
|
|
|
|
func TestWithoutTheFlagAnUntokenedRequestStillWorks(t *testing.T) {
|
|
// The console in production sends no token yet. Locking it out before
|
|
// sign-in issues one would break a working product.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
|
|
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
|
if got != fiber.StatusOK {
|
|
t.Fatalf("an untokened request was refused while enforcement is off: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestWithTheFlagAnUntokenedRequestIsRefused(t *testing.T) {
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
|
|
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
|
if got != fiber.StatusUnauthorized {
|
|
t.Fatalf("enforcement is on and an untokened request passed: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSignInStillWorksWithEnforcementOn(t *testing.T) {
|
|
// Guarding the login route with a session token means nobody can ever get
|
|
// one. This is the test that catches a locked-out deployment.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
|
|
|
for _, path := range []string{
|
|
"/live/api/v1/web/users/applogin",
|
|
"/live/api/v1/web/tenant/weblogin",
|
|
} {
|
|
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
|
|
t.Fatalf("%s was locked behind a session: %d", path, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── The platform account ──────────────────────────────────────────────── */
|
|
|
|
func TestPlatformStaffMayNameAnyTenant(t *testing.T) {
|
|
// Nearle's own staff work across tenants and the console's /nearle pages
|
|
// depend on it.
|
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
|
session := tokenFor(t, utils.WebClaims{Userid: 12, Superadmin: true, Roleid: 1})
|
|
|
|
got := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
|
if got != fiber.StatusOK {
|
|
t.Fatalf("a platform session was refused tenant 916: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestNoTokenIsNotAPlatformAccount(t *testing.T) {
|
|
// Tenant 0 is the session that reads everything, and Go's zero value is 0.
|
|
// A handler reading claims off a request that carried none would hand an
|
|
// anonymous caller exactly that session.
|
|
app := fiber.New()
|
|
var found bool
|
|
app.Get("/probe", func(c *fiber.Ctx) error {
|
|
_, found = WebClaimsFrom(c)
|
|
return c.SendStatus(fiber.StatusOK)
|
|
})
|
|
if _, err := app.Test(httptest.NewRequest("GET", "/probe", nil)); err != nil {
|
|
t.Fatalf("probing: %v", err)
|
|
}
|
|
if found {
|
|
t.Fatal("claims were reported present on a request that carried none")
|
|
}
|
|
}
|