Authenticate the console's /web surface

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>
This commit is contained in:
2026-09-23 11:22:20 +05:30
parent 771d6a51cf
commit c516c224e5
6 changed files with 1049 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ package routes
import (
"nearle/facade"
"nearle/middleware"
"github.com/gofiber/fiber/v2"
)
@@ -10,6 +11,22 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
api := app.Group("/live/api")
// Console sessions.
//
// Mounted by PATH rather than on a group object, because the `/v1/web`
// routes are not one group — a dozen files each create their own
// (`/v1/web/users`, `/v1/web/orders`, `/v1/web/products`, …). Registered
// here, ahead of all of them, so a route added later is guarded by default
// rather than by somebody remembering to.
//
// `/v1/pos` is deliberately NOT covered: that is the terminal surface, it
// carries a different kind of token, and it has its own guard. But
// `/v1/web/pos` and `/v1/web/tenants` ARE, despite their names — both are
// console callers, and `createposuser` on the second mints till credentials,
// which until now it did on the strength of an unauthenticated request. The
// note above registerPosStaffConsoleRoutes asked for exactly this.
api.Use("/v1/web", middleware.WebAuth(f.PosService()))
RegisterUserRoutes(api, f)
RegisterProductRoutes(api, f)
RegisterOrderRoutes(api, f)