Give the POS a real sign-in, and stop believing the store id on the wire
The POS surface was open. A till named its own outlet — `store_id` in a query or in an ingest batch — and was believed, so one number changed in Settings read another tenant's catalogue or posted bills into their books. There was no middleware in the codebase at all, and the `JWT_SECRET_KEY` in the config was read and never used. Products were never mis-scoped: `resolvePosStore` already derived the tenant from the location and the catalogue query already filtered on both. The tenant was never taken from the wire. What was missing was any check that the caller was entitled to the location they named. So the outlet now comes *out* of a sign-in rather than going *in* from the till. `POST /pos/login` authenticates against the same `app_users` rows the web console uses — one account store, so deactivating a leaver closes both doors — and answers with the outlets that account may reach, sealed in an HMAC-SHA256 token the terminal cannot edit. Two checks then guard everything else, in order: the token verifies, and the outlet named in the request belongs to the token's tenant. The second is the one that matters — a valid token is a licence to name *your* outlets, not any. Notes on the awkward parts: - The guard reads the outlet from the body as well as the query. The two routes that write carry `store_id` in a JSON batch and never in the URL, so a query-only check would have left exactly the dangerous call unguarded. - Three spellings of one thing survive — `store_id`, `locationid`, `location_id`. All three are read rather than normalised, because renaming them breaks terminals already in the field. - `POS_AUTH_REQUIRED` defaults to false. Tills are billing real customers against the open endpoints right now and enforcing at deploy would stop every one mid-trade. A token is still verified when sent, and a wrong-tenant token still refused; the flag only governs requests carrying none. - `POS_TOKEN_SECRET` has no baked-in fallback and fails loudly. A development secret in source is the same as no signature at all. - `configid` is inferred when the till does not send it, because a person at a counter has no way to know theirs. `authname` is not unique in this schema — live data has one address twice under one configid — so an ambiguous match is refused rather than resolved by LIMIT 1, which could bill into the wrong tenant's books. Verified against live data: 58 accounts across 34 tenants can open a till, an account pinned to a location resolves to it alone, a tenant-level account gets all six of its outlets, and a cross-tenant outlet request is refused. Passwords are still plaintext platform-wide. Flagged at the comparison site; fixing it is a migration touching every login path, not this endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -210,3 +210,75 @@ type PosCatalogueResponse struct {
|
||||
Customers []PosCatalogueCustomer `json:"customers"`
|
||||
Retiredids []string `json:"retired_product_ids"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Sign-in
|
||||
//
|
||||
// A terminal used to hold a store id typed into Settings and a password
|
||||
// compiled into the app. That made the store id a *claim* rather than a fact:
|
||||
// any till could name any outlet and be believed, and one leaked build opened
|
||||
// every tenant on the platform.
|
||||
//
|
||||
// These types replace it with the account model the web console already uses.
|
||||
// A person signs in with their own `app_users` credentials, and the outlet
|
||||
// comes out of their record instead of going in from the wire.
|
||||
|
||||
// PosLoginRequest is what a till sends to sign in.
|
||||
//
|
||||
// Authname or Contactno, matching the web console's own login — a shop should
|
||||
// not need a second set of credentials just because the screen is a till.
|
||||
//
|
||||
// Locationid is optional and only means anything for a user entitled to more
|
||||
// than one outlet: it says which of theirs this terminal is standing in. It is
|
||||
// checked against what they may reach, never trusted on its own.
|
||||
type PosLoginRequest struct {
|
||||
Authname string `json:"authname"`
|
||||
Contactno string `json:"contactno"`
|
||||
Password string `json:"password"`
|
||||
Configid int `json:"configid"`
|
||||
Locationid int `json:"location_id"`
|
||||
|
||||
// Which physical till is asking. Recorded on the session so a stolen token
|
||||
// can be told apart from the terminal it was issued to.
|
||||
Terminalid string `json:"terminal_id"`
|
||||
Deviceid string `json:"device_id"`
|
||||
}
|
||||
|
||||
// PosLoginLocation is one outlet a signed-in user may bill for.
|
||||
type PosLoginLocation struct {
|
||||
Locationid int `json:"location_id"`
|
||||
Locationname string `json:"location_name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// PosSession is what a till holds for the rest of the trading day.
|
||||
//
|
||||
// Storeid is returned as a string because that is the shape the terminal's
|
||||
// configuration already stores and sends — handing it back in the form it will
|
||||
// be replayed in removes a conversion, and a conversion is where a store id
|
||||
// gets mangled.
|
||||
type PosSession struct {
|
||||
Token string `json:"token"`
|
||||
Expiresat string `json:"expires_at"`
|
||||
|
||||
Userid int `json:"user_id"`
|
||||
Fullname string `json:"full_name"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Roleid int `json:"role_id"`
|
||||
|
||||
Tenantid int `json:"tenant_id"`
|
||||
Tenantname string `json:"tenant_name"`
|
||||
|
||||
Storeid string `json:"store_id"`
|
||||
Locationid int `json:"location_id"`
|
||||
Locationname string `json:"location_name"`
|
||||
Gstin string `json:"gstin,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
|
||||
// Every outlet this account may sign a terminal into. A single-outlet user
|
||||
// gets a list of one, so the till has no special case: it shows a picker
|
||||
// when there is a choice and skips it when there is not.
|
||||
Locations []PosLoginLocation `json:"locations"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user