diff --git a/controllers/userController.go b/controllers/userController.go index 9150272..9de475e 100644 --- a/controllers/userController.go +++ b/controllers/userController.go @@ -1,16 +1,63 @@ package controllers import ( + "log" "net/http" "strconv" "strings" + "time" "nearle/models" "nearle/services" + "nearle/utils" "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 { 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 { // Use resp.Code if present, fallback to 409 code := http.StatusConflict @@ -189,6 +236,8 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error { return c.Status(code).JSON(resp) } + attachWebSession(resp, info) + // ✅ Always return 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) if code == fiber.StatusOK { resp["details"] = info + attachWebSession(resp, info) } return c.Status(code).JSON(resp) diff --git a/middleware/webauth.go b/middleware/webauth.go new file mode 100644 index 0000000..efd1753 --- /dev/null +++ b/middleware/webauth.go @@ -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 +} diff --git a/middleware/webauth_test.go b/middleware/webauth_test.go new file mode 100644 index 0000000..73aa604 --- /dev/null +++ b/middleware/webauth_test.go @@ -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") + } +} diff --git a/routes/routes.go b/routes/routes.go index e7c8e57..fa68905 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -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) diff --git a/utils/webtoken.go b/utils/webtoken.go new file mode 100644 index 0000000..0cb1dba --- /dev/null +++ b/utils/webtoken.go @@ -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() } diff --git a/utils/webtoken_test.go b/utils/webtoken_test.go new file mode 100644 index 0000000..7ef385c --- /dev/null +++ b/utils/webtoken_test.go @@ -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) + } + } +}