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

@@ -1,16 +1,63 @@
package controllers package controllers
import ( import (
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"nearle/models" "nearle/models"
"nearle/services" "nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// attachWebSession hands a signed-in console user their session token.
//
// Added to the login response rather than served from a second endpoint, so the
// console receives it on the call it already makes and nothing changes about
// when or how it signs in.
//
// The claims come from the user's own record, which is the whole point: until
// now the console asserted its tenant on every request and was believed, and
// sealing it under a signature here is what makes `middleware.WebAuth` able to
// refuse a request naming somebody else's.
//
// `Issuperadmin` is copied across as the ONLY source of cross-tenant access.
// Not the role — `app_roles` calls roleid 1 "Super admin" and tenant onboarding
// wrote 1 for every shop owner, so trusting the role would promote every
// merchant on the platform.
//
// A failure to mint is logged and swallowed, deliberately, while
// WEB_AUTH_REQUIRED is off: a deployment that has not set a signing key yet must
// still be able to sign in, or shipping this takes the console down everywhere
// the secret is missing. Once enforcement is on, no token means no session —
// which is then the correct and loud failure.
//
// The parameter is the underlying map type rather than `fiber.Map`, because the
// two login paths do not agree on which fiber that is: `AppLogin` returns the
// v1 package's `Map` and `TenantWebLogin` the v2 one. Both are
// `map[string]any`, so taking that accepts either without dragging the
// old import into this file.
func attachWebSession(resp map[string]any, info models.TenantUserInfo) {
token, expires, err := utils.MintWebToken(utils.WebClaims{
Userid: info.Userid,
Tenantid: info.Tenantid,
Locationid: info.Locationid,
Roleid: info.Roleid,
Configid: info.Configid,
Superadmin: info.Issuperadmin,
}, time.Now())
if err != nil {
log.Printf("login: could not issue a console session for user %d: %v", info.Userid, err)
return
}
resp["token"] = token
resp["tokenexpiresat"] = expires.Unix()
}
type UserController struct { type UserController struct {
userService services.UserService userService services.UserService
} }
@@ -179,7 +226,7 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
}) })
} }
_, resp, err := ctl.userService.AppLogin(user) info, resp, err := ctl.userService.AppLogin(user)
if err != nil { if err != nil {
// Use resp.Code if present, fallback to 409 // Use resp.Code if present, fallback to 409
code := http.StatusConflict code := http.StatusConflict
@@ -189,6 +236,8 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
return c.Status(code).JSON(resp) return c.Status(code).JSON(resp)
} }
attachWebSession(resp, info)
// ✅ Always return resp // ✅ Always return resp
return c.Status(http.StatusOK).JSON(resp) return c.Status(http.StatusOK).JSON(resp)
} }
@@ -244,6 +293,7 @@ func (ctl *UserController) TenantWebLogin(c *fiber.Ctx) error {
// Include tenant user info if login successful (code 200) // Include tenant user info if login successful (code 200)
if code == fiber.StatusOK { if code == fiber.StatusOK {
resp["details"] = info resp["details"] = info
attachWebSession(resp, info)
} }
return c.Status(code).JSON(resp) return c.Status(code).JSON(resp)

312
middleware/webauth.go Normal file
View File

@@ -0,0 +1,312 @@
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. It does not yet make handlers derive their
// scope from the session instead of from the wire, and it does not validate
// `partnerid`, `customerid` or `appuserid`, which are the other scoping ids
// some list endpoints accept. Those are the next step, and until they land a
// handler that scopes on one of them is still trusting the caller.
// 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 OFF, for the same reason POS enforcement does: the console is in
// use by real merchants right now, and its sign-in does not yet hand back a
// token. Switching enforcement on before the console sends one would lock every
// user out of a working product.
//
// So the order is: this middleware ships, sign-in starts issuing tokens, the
// console starts sending them, and `WEB_AUTH_REQUIRED=true` closes the door.
// 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.
//
// This is a temporary state and should be short. An unauthenticated `/web`
// surface is the most serious thing in this codebase.
func webAuthRequired() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED")), "true")
}
// 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
}

273
middleware/webauth_test.go Normal file
View File

@@ -0,0 +1,273 @@
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")
}
}

View File

@@ -2,6 +2,7 @@ package routes
import ( import (
"nearle/facade" "nearle/facade"
"nearle/middleware"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -10,6 +11,22 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
api := app.Group("/live/api") 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) RegisterUserRoutes(api, f)
RegisterProductRoutes(api, f) RegisterProductRoutes(api, f)
RegisterOrderRoutes(api, f) RegisterOrderRoutes(api, f)

180
utils/webtoken.go Normal file
View File

@@ -0,0 +1,180 @@
package utils
import (
"crypto/hmac"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// Session tokens for the console.
//
// The same construction as the POS token next door — `base64url(payload).
// base64url(hmac-sha256)`, signed with the same key, deliberately not JWT —
// and for the same reasons, which `postoken.go` sets out in full. What differs
// is who is carrying it and what it is allowed to say.
//
// ── Why the console needs one at all ────────────────────────────────────────
//
// It has never had one. The console keeps its login record in `sessionStorage`
// and sends no `Authorization` header, so every `/web` endpoint has been taking
// `tenantid` off the query string and believing it. That is the same hole
// `middleware/posauth.go` was written to close on the POS surface — its own
// header describes a till naming another shop's id in a URL and being trusted —
// except that on the web surface nothing has closed it yet.
//
// ── A browser is not a till ─────────────────────────────────────────────────
//
// The POS token lasts thirty days because a shop signs a terminal in once and
// expects it to keep billing through reboots and dead networks. A browser tab
// is the opposite: the console already drops its session when the tab closes,
// because `sessionStorage` is per-tab by design. So the expiry here is a
// backstop for a tab left open, not the thing that ends the session, and a
// working day is the right order of magnitude.
//
// ── What the claims may say ─────────────────────────────────────────────────
//
// `Tenantid` is the load-bearing field, as `Locationid` is for POS. It is taken
// from the user's own record at sign-in and sealed under the signature, so a
// request can no longer name whichever tenant it likes.
//
// Platform access — Nearle's own staff, who work across every tenant and
// legitimately need to — rides on `Superadmin`, and NOT on the tenant being
// zero, nor on any role id.
//
// Both of those shortcuts are wrong, and the console learned it the hard way.
// `app_roles` calls roleid 1 "Super admin" and tenant onboarding wrote 1 for
// every shop owner, so a role test hands platform access to every merchant on
// the system. And a `Tenantid == 0` test promotes any user row whose tenant was
// never filled in — a missing field becoming the one session that reads
// everything. The real signal is `app_users.issuperadmin`, a column somebody
// has to deliberately set.
type WebClaims struct {
Userid int `json:"uid"`
// The tenant this session is pinned to. Every read and write stays inside
// it unless Superadmin says otherwise.
Tenantid int `json:"tid"`
// Nearle staff, from `app_users.issuperadmin`. The only thing that lifts
// the tenant pin; see above for the two tests that look equivalent and are
// not.
Superadmin bool `json:"sa,omitempty"`
// The user's home branch, where they have one. Not a restriction on its
// own: a tenant admin with six shops reads all six, and the check that
// decides which is `LocationAllowed` against the tenant, not this field.
Locationid int `json:"lid,omitempty"`
Roleid int `json:"rid"`
Configid int `json:"cid,omitempty"`
Issuedat int64 `json:"iat"`
Expiresat int64 `json:"exp"`
}
// WebTokenTTL is how long a console session stays valid.
//
// Twelve hours: longer than a shift, shorter than a week. The tab closing is
// what normally ends the session, so this only decides how long a tab left open
// overnight keeps working — and a person coming back the next morning signing
// in again is a reasonable thing to ask, where the same demand of a till
// mid-trade is not.
const WebTokenTTL = 12 * time.Hour
// IsPlatformAccount reports whether these claims may read across tenants.
//
// One function rather than `claims.Tenantid == 0` written out at each call
// site, so the rule can be found, tested, and changed in one place. Every
// cross-tenant decision in the middleware goes through it.
func (c WebClaims) IsPlatformAccount() bool { return c.Superadmin }
// MintWebToken issues a session for a signed-in console user.
//
// Shares `posTokenSecret` with the POS token: one signing key for the
// deployment, one place it can be missing, one error when it is. A second
// variable would be a second thing to forget.
func MintWebToken(claims WebClaims, now time.Time) (string, time.Time, error) {
secret, err := posTokenSecret()
if err != nil {
return "", time.Time{}, err
}
expires := now.Add(WebTokenTTL)
claims.Issuedat = now.Unix()
claims.Expiresat = expires.Unix()
payload, err := json.Marshal(claims)
if err != nil {
return "", time.Time{}, err
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return webTokenPrefix + encoded + "." + sign(encoded, secret), expires, nil
}
// webTokenPrefix keeps the two token kinds apart on the wire.
//
// Without it a POS token and a console token are the same shape signed with the
// same key, so one would verify as the other and a cashier's token would parse
// into web claims with `Locationid` in the seat `Tenantid` should occupy. The
// prefix is checked before the signature and is the reason `ParseWebToken`
// cannot accept a till's session.
const webTokenPrefix = "w1."
// ParseWebToken verifies a console token and returns what it claims.
//
// The order is the same as the POS parser's and matters for the same reason:
// nothing in the payload is trusted — not the expiry, not the tenant — until
// the signature has been checked. Reading `exp` from an unverified payload is
// taking the caller's word for when their own token runs out.
func ParseWebToken(token string, now time.Time) (WebClaims, error) {
secret, err := posTokenSecret()
if err != nil {
return WebClaims{}, err
}
raw := strings.TrimSpace(token)
after, found := strings.CutPrefix(raw, webTokenPrefix)
if !found {
return WebClaims{}, fmt.Errorf("not a console session token")
}
encoded, signature, found := strings.Cut(after, ".")
if !found || encoded == "" || signature == "" {
return WebClaims{}, fmt.Errorf("malformed session token")
}
// Constant time, so the right signature cannot be learned a byte at a time
// from how long the comparison took.
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
return WebClaims{}, fmt.Errorf("session token signature does not verify")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return WebClaims{}, fmt.Errorf("malformed session token")
}
var claims WebClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return WebClaims{}, fmt.Errorf("malformed session token")
}
if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
return WebClaims{}, fmt.Errorf("session has expired; sign in again")
}
// A token naming nobody authorises nothing, and must not be mistaken for one
// authorising everything.
if claims.Userid <= 0 {
return WebClaims{}, fmt.Errorf("session token names no user")
}
// A tenant session must name its tenant. Staff are the only accounts that
// may carry none, and they have to say so explicitly.
if claims.Tenantid <= 0 && !claims.Superadmin {
return WebClaims{}, fmt.Errorf("session token names no tenant")
}
return claims, nil
}
// WebTokenConfigured reports whether console sessions can be issued at all.
func WebTokenConfigured() bool { return PosTokenConfigured() }

216
utils/webtoken_test.go Normal file
View File

@@ -0,0 +1,216 @@
package utils
import (
"strings"
"testing"
"time"
)
func TestAConsoleSessionSurvivesTheRoundTrip(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, expires, err := MintWebToken(WebClaims{
Userid: 904, Tenantid: 1147, Locationid: 1172, Roleid: 3, Configid: 2,
}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
claims, err := ParseWebToken(token, now.Add(time.Hour))
if err != nil {
t.Fatalf("parsing a token we just issued: %v", err)
}
if claims.Tenantid != 1147 || claims.Userid != 904 {
t.Fatalf("the identity did not survive: user %d tenant %d", claims.Userid, claims.Tenantid)
}
if claims.Locationid != 1172 || claims.Roleid != 3 {
t.Fatalf("branch or role lost: location %d role %d", claims.Locationid, claims.Roleid)
}
if !expires.After(now) {
t.Fatalf("expiry is not in the future: %v", expires)
}
}
/* ── The two token kinds must not be interchangeable ────────────────────── */
func TestATillsTokenIsNotAConsoleSession(t *testing.T) {
// The whole reason `webTokenPrefix` exists. Both tokens are the same shape
// signed with the same key, so without the prefix a POS token verifies as a
// web one — and its `Locationid` would land where `Tenantid` is read, which
// is the field every permission decision is made on.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
pos, _, err := MintPosToken(PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, now)
if err != nil {
t.Fatalf("minting a POS token: %v", err)
}
if _, err := ParseWebToken(pos, now); err == nil {
t.Fatal("a cashier's token was accepted as a console session")
}
}
func TestAConsoleSessionIsNotATillsToken(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
web, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParsePosToken(web, now); err == nil {
t.Fatal("a console session was accepted at the till")
}
}
/* ── Forgery and tampering ─────────────────────────────────────────────── */
func TestATamperedTenantDoesNotVerify(t *testing.T) {
// The attack this is all for: take a valid session, change the tenant, read
// somebody else's shop.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
body, signature, _ := strings.Cut(strings.TrimPrefix(token, webTokenPrefix), ".")
forged := webTokenPrefix + body[:len(body)-1] + "X" + "." + signature
if _, err := ParseWebToken(forged, now); err == nil {
t.Fatal("an edited payload verified")
}
}
func TestATokenSignedWithAnotherKeyIsRefused(t *testing.T) {
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
withSecret(t, "a-completely-different-signing-key")
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
withSecret(t, testSecret)
if _, err := ParseWebToken(token, now); err == nil {
t.Fatal("a token signed with someone else's key verified")
}
}
func TestNoSigningKeyMeansNoSessions(t *testing.T) {
t.Setenv("POS_TOKEN_SECRET", "")
t.Setenv("JWT_SECRET_KEY", "")
if _, _, err := MintWebToken(WebClaims{Userid: 1, Tenantid: 1}, time.Now()); err == nil {
t.Fatal("a session was issued with no signing key")
}
if WebTokenConfigured() {
t.Fatal("reported configured with no signing key")
}
}
/* ── Expiry ────────────────────────────────────────────────────────────── */
func TestASessionExpires(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseWebToken(token, now.Add(WebTokenTTL-time.Minute)); err != nil {
t.Fatalf("refused inside its life: %v", err)
}
if _, err := ParseWebToken(token, now.Add(WebTokenTTL+time.Minute)); err == nil {
t.Fatal("a tab left open overnight still authorised")
}
}
/* ── The platform account ──────────────────────────────────────────────── */
func TestPlatformAccessComesFromSuperadminAndNothingElse(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 12, Superadmin: true, Roleid: 1}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
claims, err := ParseWebToken(token, now)
if err != nil {
t.Fatalf("a staff session was refused: %v", err)
}
if !claims.IsPlatformAccount() {
t.Fatal("issuperadmin did not grant platform access")
}
}
func TestRoleid1IsAMerchantNotAPlatformOperator(t *testing.T) {
// `app_roles` calls roleid 1 "Super admin", and tenant onboarding wrote 1
// for every shop owner. A role test here would hand cross-tenant access to
// every merchant on the system — the console already had to fix this once.
claims := WebClaims{Userid: 904, Tenantid: 1147, Roleid: 1}
if claims.IsPlatformAccount() {
t.Fatal("roleid 1 claimed platform access")
}
}
func TestAMissingTenantIsNotAPlatformAccount(t *testing.T) {
// The other near-miss: a user row whose tenant was never filled in must not
// become the one session that reads everything. Go's zero value is 0, so
// this is exactly what a forgotten field looks like.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
claims := WebClaims{Userid: 904, Tenantid: 0}
if claims.IsPlatformAccount() {
t.Fatal("a missing tenant claimed platform access")
}
token, _, err := MintWebToken(claims, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseWebToken(token, now); err == nil {
t.Fatal("a session naming no tenant and claiming no staff status verified")
}
}
func TestATokenNamingNobodyIsRefused(t *testing.T) {
// A token that authorises nothing must not be mistaken for one that
// authorises everything.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 0, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseWebToken(token, now); err == nil {
t.Fatal("a token naming no user verified")
}
}
/* ── Shape ─────────────────────────────────────────────────────────────── */
func TestMalformedTokensAreRefusedWithoutPanicking(t *testing.T) {
withSecret(t, testSecret)
now := time.Now()
for _, token := range []string{
"", " ", "w1.", "w1..", "w1.onlyonepart",
"w1.!!!not-base64!!!.sig", "no-prefix.payload.sig",
} {
if _, err := ParseWebToken(token, now); err == nil {
t.Fatalf("accepted a malformed token: %q", token)
}
}
}