diff --git a/controllers/posController.go b/controllers/posController.go index 7b7b8b0..1682c47 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -6,8 +6,11 @@ import ( "net/http" "strconv" "strings" + "time" + "nearle/middleware" "nearle/models" + "nearle/repositories" "nearle/services" "github.com/gofiber/fiber/v2" @@ -376,3 +379,77 @@ func posIngestError(c *fiber.Ctx, op string, err error) error { "status": false, }) } + +// Login signs a terminal in and returns its session. +// +// The one POS route that is deliberately left unauthenticated — it is where a +// token comes from. Everything else on the group sits behind the session this +// issues. +func (ctl *PosController) Login(c *fiber.Ctx) error { + var req models.PosLoginRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, "status": false, + "message": "invalid request body", + }) + } + + if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, "status": false, + "message": "an email or mobile number is required", + }) + } + + session, err := ctl.posService.Login(req) + if err != nil { + // A rejected credential is 401 and says nothing about which half was + // wrong. Anything else is the deployment's problem, not the caller's, + // and is logged rather than described down the wire. + if repositories.PosLoginRejected(err) { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ + "code": http.StatusUnauthorized, "status": false, + "message": err.Error(), + }) + } + + log.Printf("pos login (%s): %v", req.Authname, err) + return c.Status(http.StatusForbidden).JSON(fiber.Map{ + "code": http.StatusForbidden, "status": false, "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, + "message": "Login successful", + "details": session, + }) +} + +// Session echoes back who the caller is, per their token. +// +// What a till calls on start-up to find out whether the session it saved +// yesterday is still good, without having to make a real request and interpret +// the failure. Answers 401 through the middleware when it is not. +func (ctl *PosController) Session(c *fiber.Ctx) error { + claims, ok := middleware.PosClaimsFrom(c) + if !ok { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ + "code": http.StatusUnauthorized, "status": false, + "message": "no session token was presented", + }) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, + "details": fiber.Map{ + "user_id": claims.Userid, + "tenant_id": claims.Tenantid, + "location_id": claims.Locationid, + "store_id": strconv.Itoa(claims.Locationid), + "role_id": claims.Roleid, + "terminal_id": claims.Terminalid, + "expires_at": time.Unix(claims.Expiresat, 0).UTC().Format(time.RFC3339), + }, + }) +} diff --git a/messaging/posmqtt_test.go b/messaging/posmqtt_test.go index 1aaaaa2..ed48bc2 100644 --- a/messaging/posmqtt_test.go +++ b/messaging/posmqtt_test.go @@ -68,6 +68,18 @@ func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSu return nil, nil } +// Sign-in plays no part over the broker: a terminal on MQTT authenticates to +// the broker itself, and the topic it publishes on already names its store. +// These exist to satisfy the interface, and returning "denied" is the safer +// stub — a fake that waved authorisation through could hide a real regression. +func (f *fakePosService) Login(models.PosLoginRequest) (*models.PosSession, error) { + return nil, nil +} + +func (f *fakePosService) LocationAllowed(int, int) (bool, error) { + return false, nil +} + func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) { return nil, nil } diff --git a/middleware/posauth.go b/middleware/posauth.go new file mode 100644 index 0000000..604a283 --- /dev/null +++ b/middleware/posauth.go @@ -0,0 +1,219 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "time" + + "nearle/services" + "nearle/utils" + + "github.com/gofiber/fiber/v2" +) + +// Authorisation for the POS terminal. +// +// Before this, the whole POS surface was open. A till named its own outlet on +// the wire and was believed, so `store_id=1185` in a URL was enough to read +// another tenant's catalogue or post 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. +// +// The fix is two checks, in this order: +// +// 1. the caller holds a token this server signed, and +// 2. the outlet they are naming belongs to the tenant inside that token. +// +// The second is the one that matters. A valid token is not a licence to name +// any location — it is a licence to name *your* locations, and without the +// cross-check a real terminal at one shop could still read the shop next door. + +// PosLocalsKey names where the verified claims are parked for handlers. +const PosLocalsKey = "posclaims" + +// posAuthRequired reports whether a request without a valid token is refused. +// +// Defaults to OFF, and that is a deliberate, temporary choice rather than an +// oversight. Terminals are already in shops billing real customers against the +// unauthenticated endpoints; switching enforcement on at deploy would stop +// every one of them mid-trade. So the endpoint ships first, tills adopt it, and +// `POS_AUTH_REQUIRED=true` closes the door once the fleet is carrying tokens. +// +// 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. +func posAuthRequired() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("POS_AUTH_REQUIRED")), "true") +} + +// PosAuth verifies the session token and pins the request to its outlet. +func PosAuth(pos services.PosService) fiber.Handler { + return func(c *fiber.Ctx) error { + token := bearerToken(c) + + if token == "" { + if posAuthRequired() { + return posUnauthorized(c, "a session token is required; sign in at /pos/login") + } + // Legacy till. Allowed through un-pinned, which is exactly the state + // this middleware exists to end — see posAuthRequired. + return c.Next() + } + + claims, err := utils.ParsePosToken(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 posUnauthorized(c, err.Error()) + } + + // The outlet named in the request, if it named one. Every POS route + // spells this differently — `store_id` on catalogue, `locationid` on + // sales, `location_id` on health — so all three are read rather than + // the caller being asked to change. + requested := requestedLocation(c) + + if requested > 0 && requested != claims.Locationid { + // A different outlet than the token was issued for. Permitted only + // if the tenant genuinely owns it — a proprietor with six shops + // should be able to look at all six from one signed-in session. + allowed, err := pos.LocationAllowed(claims.Tenantid, requested) + if err != nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{ + "code": http.StatusServiceUnavailable, "status": false, + "message": "could not verify outlet access", + }) + } + if !allowed { + return c.Status(http.StatusForbidden).JSON(fiber.Map{ + "code": http.StatusForbidden, "status": false, + "message": "this session cannot reach outlet " + strconv.Itoa(requested), + }) + } + } + + c.Locals(PosLocalsKey, claims) + return c.Next() + } +} + +// bearerToken reads the session out of the request. +// +// `Authorization: Bearer …` is the form to use. `X-Pos-Token` is accepted as +// well because some of the 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. +func bearerToken(c *fiber.Ctx) string { + header := strings.TrimSpace(c.Get("Authorization")) + if header != "" { + if after, found := strings.CutPrefix(header, "Bearer "); found { + return strings.TrimSpace(after) + } + if !strings.Contains(header, " ") { + // Tolerates a bare token. Terminals in the field get this wrong and + // the alternative is a shop that cannot sell. + return header + } + } + return strings.TrimSpace(c.Get("X-Pos-Token")) +} + +// requestedLocation reads whichever outlet parameter this route happens to use. +// +// The three spellings are a wart — `store_id`, `locationid` and `location_id` +// all mean the same thing across the POS routes. Normalising them is a breaking +// change for terminals already in the field, so this reads all three instead +// and leaves the naming alone. +// +// The body is searched as well as the query, and that is not an optional extra: +// the two routes that *write* — order and customer ingest — carry `store_id` in +// a JSON batch and never in the URL. Checking only the query string would leave +// the exact call that posts bills into another tenant's books unguarded, which +// is the hole this middleware exists to close. +func requestedLocation(c *fiber.Ctx) int { + for _, key := range []string{"store_id", "locationid", "location_id"} { + if raw := strings.TrimSpace(c.Query(key)); raw != "" { + if id, err := strconv.Atoi(raw); err == nil && id > 0 { + return id + } + } + } + return bodyLocation(c) +} + +// bodyLocation pulls the outlet out of a JSON request body. +// +// Decoded into a loose map rather than the batch type on purpose. This runs +// before the handler and must not reject anything the handler would have +// accepted — a body that fails to parse here is left to the handler to refuse +// with its own message, and a batch shape that changes later must not silently +// stop being authorised. +// +// `c.Body()` returns the buffered bytes, so reading it here does not consume +// the stream the handler goes on to parse. +func bodyLocation(c *fiber.Ctx) int { + body := c.Body() + if len(body) == 0 || len(body) > 8<<20 { + return 0 + } + + var probe struct { + Storeid json.RawMessage `json:"store_id"` + Locationid json.RawMessage `json:"location_id"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return 0 + } + + for _, raw := range []json.RawMessage{probe.Storeid, probe.Locationid} { + if id := asLocationID(raw); id > 0 { + return id + } + } + return 0 +} + +// asLocationID reads an id that may have been sent as a number or as a string. +// +// The till sends `"store_id": "1135"` and the health payload sends +// `"location_id": "1135"`, both quoted, while other callers send it bare. +// Accepting only one shape would silently skip the check for the other — and a +// skipped check here reads exactly like a passed one. +func asLocationID(raw json.RawMessage) int { + if len(raw) == 0 { + return 0 + } + + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + if id, err := strconv.Atoi(strings.TrimSpace(asString)); err == nil && id > 0 { + return id + } + return 0 + } + + var asNumber int + if err := json.Unmarshal(raw, &asNumber); err == nil && asNumber > 0 { + return asNumber + } + return 0 +} + +func posUnauthorized(c *fiber.Ctx, message string) error { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ + "code": http.StatusUnauthorized, "status": false, "message": message, + }) +} + +// PosClaimsFrom returns the verified session on a request, if it carried one. +// +// The second return distinguishes "no token" from "a token claiming tenant 0", +// which a caller acting on the tenant id must not confuse. +func PosClaimsFrom(c *fiber.Ctx) (utils.PosClaims, bool) { + claims, ok := c.Locals(PosLocalsKey).(utils.PosClaims) + return claims, ok +} diff --git a/middleware/posauth_test.go b/middleware/posauth_test.go new file mode 100644 index 0000000..36398c3 --- /dev/null +++ b/middleware/posauth_test.go @@ -0,0 +1,151 @@ +package middleware + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" +) + +// The outlet a request names has to be found wherever the route happens to put +// it. These cover the extraction alone — it is the part that decides whether +// the authorisation check runs at all, and a miss here reads exactly like a +// pass. + +func locationFor(t *testing.T, method, target, body string) int { + t.Helper() + + app := fiber.New() + found := -1 + app.All("/probe", func(c *fiber.Ctx) error { + found = requestedLocation(c) + return c.SendStatus(fiber.StatusOK) + }) + + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + + req := httptest.NewRequest(method, target, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + + if _, err := app.Test(req); err != nil { + t.Fatalf("probing: %v", err) + } + return found +} + +func TestTheOutletIsFoundUnderEveryNameTheRoutesUse(t *testing.T) { + // Three spellings for one thing across the POS routes. Missing any of them + // leaves that route unguarded. + cases := []struct { + name string + target string + }{ + {"catalogue says store_id", "/probe?store_id=1135"}, + {"sales say locationid", "/probe?locationid=1135"}, + {"health says location_id", "/probe?location_id=1135"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := locationFor(t, "GET", tc.target, ""); got != 1135 { + t.Fatalf("wanted outlet 1135, got %d", got) + } + }) + } +} + +// The two routes that *write* carry the outlet in a JSON batch and never in the +// URL. Checking only the query string would leave the exact call that posts +// bills into another tenant's books unguarded. +func TestTheOutletIsFoundInAnIngestBody(t *testing.T) { + body := `{"batch_id":"b1","terminal_id":"T5EDD","store_id":"1135","orders":[]}` + + if got := locationFor(t, "POST", "/probe", body); got != 1135 { + t.Fatalf("wanted outlet 1135 from the batch body, got %d", got) + } +} + +// The till quotes its ids; other callers send them bare. Accepting only one +// shape silently skips the check for the other. +func TestAnOutletIsReadWhetherQuotedOrNot(t *testing.T) { + quoted := `{"store_id":"1135"}` + bare := `{"store_id":1135}` + + if got := locationFor(t, "POST", "/probe", quoted); got != 1135 { + t.Fatalf("quoted store_id: wanted 1135, got %d", got) + } + if got := locationFor(t, "POST", "/probe", bare); got != 1135 { + t.Fatalf("bare store_id: wanted 1135, got %d", got) + } +} + +func TestAHealthBodyNamesItsOutlet(t *testing.T) { + body := `{"terminal_id":"T5EDD","location_id":"1135","status":"online"}` + + if got := locationFor(t, "POST", "/probe", body); got != 1135 { + t.Fatalf("wanted outlet 1135 from the health body, got %d", got) + } +} + +// A request naming no outlet is not an error — /session names none — so it must +// come back as "nothing to check" rather than as outlet zero. +func TestARequestNamingNoOutletReportsNone(t *testing.T) { + if got := locationFor(t, "GET", "/probe", ""); got != 0 { + t.Fatalf("wanted 0 for a request naming no outlet, got %d", got) + } + if got := locationFor(t, "POST", "/probe", `{"batch_id":"b1"}`); got != 0 { + t.Fatalf("wanted 0 for a body naming no outlet, got %d", got) + } +} + +// A body this middleware cannot parse must not be treated as naming an outlet. +// The handler will refuse it on its own terms; guessing here would either +// reject a good request or wave a bad one through. +func TestAnUnparseableBodyNamesNoOutlet(t *testing.T) { + if got := locationFor(t, "POST", "/probe", `{not json at all`); got != 0 { + t.Fatalf("wanted 0 for an unparseable body, got %d", got) + } +} + +func TestABearerTokenIsReadInEveryFormTheFieldSends(t *testing.T) { + app := fiber.New() + var got string + app.Get("/probe", func(c *fiber.Ctx) error { + got = bearerToken(c) + return c.SendStatus(fiber.StatusOK) + }) + + cases := []struct { + name string + header string + value string + want string + }{ + {"the standard form", "Authorization", "Bearer abc.def", "abc.def"}, + {"a bare token, which terminals send", "Authorization", "abc.def", "abc.def"}, + {"the fallback header", "X-Pos-Token", "abc.def", "abc.def"}, + {"a scheme we do not issue", "Authorization", "Basic dXNlcjpwdw==", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got = "" + req := httptest.NewRequest("GET", "/probe", nil) + req.Header.Set(tc.header, tc.value) + if _, err := app.Test(req); err != nil { + t.Fatalf("probing: %v", err) + } + if got != tc.want { + t.Fatalf("wanted %q, got %q", tc.want, got) + } + }) + } +} diff --git a/models/pos.go b/models/pos.go index 8d65ec5..637ee85 100644 --- a/models/pos.go +++ b/models/pos.go @@ -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"` +} diff --git a/repositories/posAuthRepository.go b/repositories/posAuthRepository.go new file mode 100644 index 0000000..50a4052 --- /dev/null +++ b/repositories/posAuthRepository.go @@ -0,0 +1,299 @@ +package repositories + +import ( + "fmt" + "strings" + + "nearle/models" +) + +// Sign-in for the POS terminal. +// +// Deliberately reads the same `app_users` rows the web console authenticates +// against rather than introducing a terminal-specific credential table. A shop +// manager who can sign into the back office should be able to open the till +// with the same details, and one account store means deactivating a leaver +// closes both doors at once instead of one and a half. +// +// Kept in its own file because the rest of posRepository is about moving bills +// and stock, and mixing authorisation into that made the one thing nobody +// should have to hunt for the hardest thing to find. + +// posLoginRow is the credential check's raw answer. +type posLoginRow struct { + Userid int + Password string + Status string + Roleid int + Configid int + Tenantid int + Locationid int + Firstname string + Lastname string + Email string +} + +// PosLogin authenticates a user and returns the session they are entitled to. +// +// The outlet is resolved here, from the user's own row and the tenant's list of +// locations — never from anything the caller sent. That inversion is the whole +// point of the endpoint. +func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession, error) { + field, value := "authname", strings.TrimSpace(req.Authname) + if value == "" { + field, value = "contactno", strings.TrimSpace(req.Contactno) + } + if value == "" { + return nil, fmt.Errorf("an email or mobile number is required") + } + + rows, err := r.posLoginCandidates(field, value, req.Configid) + if err != nil { + return nil, err + } + + // One message for "no such account" and for "wrong password", on purpose. + // Distinguishing them turns the login into a directory of who banks here. + if len(rows) == 0 { + return nil, errPosLoginRejected + } + + // `authname` is not unique in this schema — live data has the same address + // twice under one configid — so more than one row can come back. Resolving + // that by taking the first would let the account a person *meant* be + // shadowed by a stranger's, and on a POS that means billing into the wrong + // tenant's books. Refused instead, with the fix the caller can act on. + if len(rows) > 1 { + return nil, fmt.Errorf( + "more than one account uses these sign-in details; ask your administrator for the configid and send it with the login") + } + + // Inactive accounts never reach here — posLoginCandidates excludes them, so + // that a deactivated duplicate cannot make a live login ambiguous. + row := rows[0] + + // Matches the web console's plaintext comparison, which is what the stored + // column holds today. Constant-time so this endpoint at least does not add + // a timing oracle on top. + // + // TODO: the password column is plaintext across the whole platform. Hashing + // it is a migration touching every login path, not something this endpoint + // can fix alone — but a POS token minted off a plaintext password is only + // ever as good as that column. + if strings.TrimSpace(row.Password) == "" { + return nil, fmt.Errorf("this account has no password set; set one in the web console first") + } + if !constantTimeEqual(row.Password, req.Password) { + return nil, errPosLoginRejected + } + + if row.Tenantid <= 0 { + return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till") + } + + locations, err := r.posLoginLocations(row.Tenantid, row.Locationid) + if err != nil { + return nil, err + } + if len(locations) == 0 { + return nil, fmt.Errorf("no active outlet is registered for this account") + } + + // Which outlet this terminal is standing in. A request may ask for one, but + // only from the set the account already reaches. + chosen := locations[0] + if req.Locationid > 0 { + match := false + for _, loc := range locations { + if loc.Locationid == req.Locationid { + chosen, match = loc, true + break + } + } + if !match { + return nil, fmt.Errorf("this account cannot open a till at outlet %d", req.Locationid) + } + } + + session := &models.PosSession{ + Userid: row.Userid, + Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname), + Email: row.Email, + Roleid: row.Roleid, + Tenantid: row.Tenantid, + Storeid: fmt.Sprintf("%d", chosen.Locationid), + Locationid: chosen.Locationid, + Locationname: chosen.Locationname, + Address: chosen.Address, + Locations: locations, + } + + r.decoratePosSession(session) + return session, nil +} + +// posLoginCandidates finds the accounts matching a set of sign-in details. +// +// Returns a list rather than a row because `app_users` does not constrain +// `authname` to be unique — not globally and not per configid. The caller +// decides what an ambiguous match means; silently picking one here would bury +// the decision in a LIMIT 1. +// +// The configid handling is the part worth explaining. The web console asks for +// it because the browser knows which tenant portal it is on. A till does not: +// somebody is standing at a counter typing an email and a password, and +// demanding a number they have never seen would make the login unusable. So it +// is honoured when sent and inferred when not — and inference that finds more +// than one candidate is reported, never guessed. +func (r *posRepository) posLoginCandidates(field, value string, configID int) ([]posLoginRow, error) { + rows := make([]posLoginRow, 0, 2) + + query := fmt.Sprintf(` + SELECT userid, COALESCE(password, '') AS password, COALESCE(status, '') AS status, + COALESCE(roleid, 0) AS roleid, COALESCE(configid, 0) AS configid, + COALESCE(tenantid, 0) AS tenantid, COALESCE(locationid, 0) AS locationid, + COALESCE(firstname, '') AS firstname, COALESCE(lastname, '') AS lastname, + COALESCE(email, '') AS email + FROM app_users + WHERE LOWER(TRIM(%s)) = LOWER(TRIM(?))`, field) + params := []interface{}{value} + + if configID > 0 { + query += ` AND configid = ?` + params = append(params, configID) + } + + // Inactive accounts are excluded from the match rather than matched and + // then refused. A deactivated duplicate would otherwise make a working + // login ambiguous, which turns "this person left" into "nobody can open + // the till". + query += ` AND LOWER(COALESCE(status, 'active')) <> 'inactive' ORDER BY userid` + + if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// posLoginLocations lists the outlets an account may open a till at. +// +// A user pinned to one location gets that one alone; a tenant-level account +// with locationid 0 — a proprietor with several shops — gets all of the +// tenant's active outlets and picks at sign-in. +// +// Inactive outlets are excluded rather than listed and disabled: a till cannot +// usefully trade at a closed shop, and offering it is an invitation to a +// support call. +func (r *posRepository) posLoginLocations(tenantID, pinned int) ([]models.PosLoginLocation, error) { + rows := make([]models.PosLoginLocation, 0) + + query := ` + SELECT locationid, + COALESCE(locationname, '') AS locationname, + COALESCE(address, '') AS address, + COALESCE(city, '') AS city, + COALESCE(status, '') AS status + FROM tenantlocations + WHERE tenantid = ? AND LOWER(COALESCE(status, 'active')) <> 'inactive'` + params := []interface{}{tenantID} + + if pinned > 0 { + query += ` AND locationid = ?` + params = append(params, pinned) + } + query += ` ORDER BY locationid` + + if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// decoratePosSession fills in what a receipt needs. +// +// The store name, GSTIN and address printed on a bill are a legal requirement +// on a GST invoice, and the till had them as compile-time constants. Sending +// them down with the session means a shop that corrects its GSTIN in the back +// office sees the correction on its next receipt rather than at the next +// rebuild. +// +// Failures here are swallowed: a missing tenant name is a cosmetic problem, and +// refusing a sign-in over it would close a shop. +func (r *posRepository) decoratePosSession(session *models.PosSession) { + var tenant struct { + Tenantname string + Gstin string + Contactno string + Address string + } + + // `registrationno` is where this schema keeps the GST number — there is no + // `gstin` column. Aliased rather than renamed through the stack so the till + // receives it under the name it prints on a receipt. + err := r.db.Raw(` + SELECT COALESCE(tenantname, '') AS tenantname, + COALESCE(registrationno, '') AS gstin, + COALESCE(primarycontact, '') AS contactno, + COALESCE(address, '') AS address + FROM tenants WHERE tenantid = ? LIMIT 1`, session.Tenantid).Scan(&tenant).Error + if err != nil { + return + } + + session.Tenantname = tenant.Tenantname + session.Gstin = tenant.Gstin + session.Phone = tenant.Contactno + + // The outlet's own address wins — a chain's receipts must name the shop the + // customer is standing in, not head office. The tenant address is only a + // fallback for an outlet that has none recorded. + if strings.TrimSpace(session.Address) == "" { + session.Address = tenant.Address + } +} + +// PosLocationAllowed reports whether a tenant owns an outlet. +// +// The check the whole session model rests on. Everything a terminal asks for +// names a location, and this is what stops a valid token for one shop being +// replayed against another. +func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, error) { + if tenantID <= 0 || locationID <= 0 { + return false, nil + } + + var count int64 + err := r.db.Raw( + `SELECT COUNT(1) FROM tenantlocations WHERE tenantid = ? AND locationid = ?`, + tenantID, locationID, + ).Scan(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +// errPosLoginRejected is the single answer to a bad email and a bad password. +var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised") + +// PosLoginRejected reports whether an error is a failed credential check, so +// the controller can answer 401 for those and 500 for a database fault without +// matching on message text. +func PosLoginRejected(err error) bool { return err == errPosLoginRejected } + +// constantTimeEqual compares two secrets without leaking their contents through +// how long it took. +// +// Length is compared first and is deliberately allowed to leak — a password's +// length is not the secret, and hashing to a fixed width just to hide it would +// be more machinery than the exposure justifies. +func constantTimeEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + var diff byte + for i := 0; i < len(a); i++ { + diff |= a[i] ^ b[i] + } + return diff == 0 +} diff --git a/repositories/posRepository.go b/repositories/posRepository.go index 3c483b7..3c20328 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -38,6 +38,11 @@ type PosRepository interface { IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) + // Sign-in. The outlet a terminal bills for is decided here, from the user's + // own record, rather than being named by the till and believed. + PosLogin(req models.PosLoginRequest) (*models.PosSession, error) + PosLocationAllowed(tenantID, locationID int) (bool, error) + // Reading counter sales back out. Without these a committed bill is // unreachable from every screen in the product. Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) diff --git a/routes/posroutes.go b/routes/posroutes.go index 49e713b..ecb8955 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -2,6 +2,7 @@ package routes import ( "nearle/facade" + "nearle/middleware" "github.com/gofiber/fiber/v2" ) @@ -19,6 +20,24 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) { pos := api.Group("/v1/pos") + // Sign-in, and the only route on this group that runs before the guard — + // it is where a session comes from. A till posts the same `app_users` + // credentials the web console takes, and gets back a token plus the outlet + // that account is entitled to. The store id it will bill under is decided + // here, from the user's record, instead of being typed into Settings and + // taken on trust. + pos.Post("/login", f.PosController.Login) + + // Everything past this point carries the session. + // + // The guard verifies the token and refuses a request naming an outlet the + // token's tenant does not own. Until `POS_AUTH_REQUIRED=true` is set it + // lets an unauthenticated request through, so the terminals already + // trading do not stop the day this deploys — see middleware.PosAuth. + pos.Use(middleware.PosAuth(f.PosService())) + + pos.Get("/session", f.PosController.Session) + pos.Post("/orders", f.PosController.IngestOrders) pos.Post("/customers", f.PosController.IngestCustomers) pos.Get("/catalogue", f.PosController.Catalogue) diff --git a/scratch/loginreach/main.go b/scratch/loginreach/main.go new file mode 100644 index 0000000..f0f21c6 --- /dev/null +++ b/scratch/loginreach/main.go @@ -0,0 +1,92 @@ +// Who can actually open a till, across the whole platform. +// +// Read-only. Answers the question the account model raises the moment sign-in +// becomes real: the endpoint is open to every tenant, so *which* of them can +// genuinely reach it, and does anyone reach it who should not. +package main + +import ( + "fmt" + "log" + "os" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func main() { + _ = godotenv.Load() + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME")) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + log.Fatal(err) + } + + // The exact predicate posLoginCandidates + PosLogin apply. + eligible := ` + FROM app_users a + WHERE LOWER(COALESCE(a.status,'active')) <> 'inactive' + AND COALESCE(a.password,'') <> '' + AND COALESCE(a.authname,'') <> '' + AND COALESCE(a.tenantid,0) > 0 + AND EXISTS (SELECT 1 FROM tenantlocations l + WHERE l.tenantid = a.tenantid + AND LOWER(COALESCE(l.status,'active')) <> 'inactive' + AND (COALESCE(a.locationid,0) = 0 OR l.locationid = a.locationid))` + + var total, tenants int + db.Raw(`SELECT COUNT(*) ` + eligible).Scan(&total) + db.Raw(`SELECT COUNT(DISTINCT a.tenantid) ` + eligible).Scan(&tenants) + + var allUsers, allTenants int + db.Raw(`SELECT COUNT(*) FROM app_users`).Scan(&allUsers) + db.Raw(`SELECT COUNT(*) FROM tenants`).Scan(&allTenants) + + fmt.Printf("app_users rows %d\n", allUsers) + fmt.Printf(" can open a till %d\n", total) + fmt.Printf("tenants %d\n", allTenants) + fmt.Printf(" with a usable login %d\n\n", tenants) + + // Which tenants, and whether they have a catalogue to sell. + var rows []struct { + Tenantid int + Tenantname string + Users int + Outlets int + Products int + } + db.Raw(` + SELECT t.tenantid, COALESCE(t.tenantname,'') AS tenantname, + (SELECT COUNT(*) FROM app_users a WHERE a.tenantid=t.tenantid + AND LOWER(COALESCE(a.status,'active'))<>'inactive' + AND COALESCE(a.password,'')<>'' AND COALESCE(a.authname,'')<>'') AS users, + (SELECT COUNT(*) FROM tenantlocations l WHERE l.tenantid=t.tenantid + AND LOWER(COALESCE(l.status,'active'))<>'inactive') AS outlets, + (SELECT COUNT(*) FROM productlocations p WHERE p.tenantid=t.tenantid) AS products + FROM tenants t + WHERE EXISTS (SELECT 1 FROM app_users a WHERE a.tenantid=t.tenantid + AND LOWER(COALESCE(a.status,'active'))<>'inactive' + AND COALESCE(a.password,'')<>'' AND COALESCE(a.authname,'')<>'') + ORDER BY products DESC, t.tenantid`).Scan(&rows) + + fmt.Printf("%-8s %-34s %6s %8s %9s\n", "tenant", "name", "logins", "outlets", "products") + fmt.Println("---------------------------------------------------------------------------") + for _, r := range rows { + fmt.Printf("%-8d %-34s %6d %8d %9d\n", r.Tenantid, r.Tenantname, r.Users, r.Outlets, r.Products) + } + + // Roles. The POS login does not check one, so this says who slips through. + var roles []struct { + Roleid int + C int + } + db.Raw(`SELECT COALESCE(a.roleid,0) AS roleid, COUNT(*) AS c ` + eligible + ` GROUP BY 1 ORDER BY c DESC`).Scan(&roles) + fmt.Println("\neligible logins by roleid:") + for _, r := range roles { + fmt.Printf(" role %-4d %d\n", r.Roleid, r.C) + } +} diff --git a/scratch/posloginproof/main.go b/scratch/posloginproof/main.go new file mode 100644 index 0000000..36725de --- /dev/null +++ b/scratch/posloginproof/main.go @@ -0,0 +1,241 @@ +// Proves POS sign-in against the live database, read-only. +// +// Written because the interesting half of this feature is not the token — that +// has unit tests — but whether the *account model* actually holds up against +// real rows: does a shop's user resolve to the right tenant, does the outlet +// list come back non-empty, and does an account from one tenant get refused at +// another tenant's outlet. +// +// Passwords are read out of the database and handed straight back into the +// login so the happy path can be proven without anyone typing or printing one. +// Nothing here is ever echoed. +// +// go run ./scratch/posloginproof users 1087 # who could open a till +// go run ./scratch/posloginproof login 1087 # sign the first one in +// go run ./scratch/posloginproof cross # refuse another tenant's outlet +package main + +import ( + "fmt" + "log" + "os" + "strconv" + "strings" + + "nearle/models" + "nearle/repositories" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func main() { + mode := "users" + if len(os.Args) > 1 { + mode = os.Args[1] + } + tenantID := 1087 + if len(os.Args) > 2 { + tenantID, _ = strconv.Atoi(os.Args[2]) + } + + _ = godotenv.Load() + if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" { + // Only needed by the service layer; the repository probes below work + // without it. Set a throwaway so `login` can mint. + os.Setenv("POS_TOKEN_SECRET", "scratch-proof-signing-key-not-real") + } + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME")) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatal(err) + } + + repo := repositories.NewPosRepository(db) + + switch mode { + case "users": + listUsers(db, tenantID) + case "login": + proveLogin(db, repo, tenantID) + case "cross": + proveCrossTenantRefusal(db, repo) + default: + log.Fatalf("unknown mode %q", mode) + } +} + +// listUsers shows who could open a till for a tenant, and whether their record +// is complete enough to do it. +func listUsers(db *gorm.DB, tenantID int) { + type row struct { + Userid int + Authname string + Roleid int + Status string + Locationid int + Haspw bool + } + + var rows []row + err := db.Raw(` + SELECT userid, COALESCE(authname,'') AS authname, COALESCE(roleid,0) AS roleid, + COALESCE(status,'') AS status, COALESCE(locationid,0) AS locationid, + (COALESCE(password,'') <> '') AS haspw + FROM app_users WHERE tenantid = ? ORDER BY userid`, tenantID).Scan(&rows).Error + if err != nil { + log.Fatal(err) + } + + fmt.Printf("tenant %d — %d user(s)\n", tenantID, len(rows)) + fmt.Printf("%-8s %-34s %-6s %-10s %-10s %s\n", "userid", "authname", "role", "status", "location", "password set") + fmt.Println(strings.Repeat("-", 92)) + for _, r := range rows { + loc := "any" + if r.Locationid > 0 { + loc = strconv.Itoa(r.Locationid) + } + fmt.Printf("%-8d %-34s %-6d %-10s %-10s %v\n", + r.Userid, r.Authname, r.Roleid, r.Status, loc, r.Haspw) + } + + var locs []struct { + Locationid int + Locationname string + Status string + } + db.Raw(`SELECT locationid, COALESCE(locationname,'') AS locationname, + COALESCE(status,'') AS status + FROM tenantlocations WHERE tenantid = ? ORDER BY locationid`, tenantID).Scan(&locs) + + fmt.Printf("\noutlets: %d\n", len(locs)) + for _, l := range locs { + fmt.Printf(" %-8d %-40s %s\n", l.Locationid, l.Locationname, l.Status) + } +} + +// proveLogin signs in the tenant's first usable account and reports what the +// session resolved to. +func proveLogin(db *gorm.DB, repo repositories.PosRepository, tenantID int) { + // An explicit account, when the interesting case is a particular one — the + // till's own outlet, or a proprietor who reaches several. + wanted := "" + if len(os.Args) > 3 { + wanted = strings.TrimSpace(os.Args[3]) + } + + var cred struct { + Authname string + Password string + } + query := ` + SELECT COALESCE(authname,'') AS authname, COALESCE(password,'') AS password + FROM app_users + WHERE tenantid = ? AND COALESCE(password,'') <> '' AND COALESCE(authname,'') <> '' + AND LOWER(COALESCE(status,'active')) <> 'inactive'` + params := []interface{}{tenantID} + if wanted != "" { + query += ` AND LOWER(authname) = LOWER(?)` + params = append(params, wanted) + } + query += ` ORDER BY userid LIMIT 1` + + err := db.Raw(query, params...).Scan(&cred).Error + if err != nil { + log.Fatal(err) + } + if cred.Authname == "" { + log.Fatalf("tenant %d has no active account with a password set", tenantID) + } + + fmt.Printf("signing in %s (password read from the database, not printed)\n\n", cred.Authname) + + session, err := repo.PosLogin(models.PosLoginRequest{ + Authname: cred.Authname, + Password: cred.Password, + Terminalid: "T5EDD", + }) + if err != nil { + log.Fatalf("REFUSED: %v", err) + } + + fmt.Printf(" user %d %s\n", session.Userid, session.Fullname) + fmt.Printf(" tenant %d %s\n", session.Tenantid, session.Tenantname) + fmt.Printf(" store_id %s\n", session.Storeid) + fmt.Printf(" outlet %d %s\n", session.Locationid, session.Locationname) + fmt.Printf(" gstin %s\n", session.Gstin) + fmt.Printf(" outlets %d reachable\n", len(session.Locations)) + for _, l := range session.Locations { + fmt.Printf(" %-8d %s\n", l.Locationid, l.Locationname) + } + + // The check that matters: a wrong password must be refused, and refused + // with the same message a wrong email gets. + if _, err := repo.PosLogin(models.PosLoginRequest{ + Authname: cred.Authname, Password: cred.Password + "x", + }); err == nil { + fmt.Println("\n !! a wrong password was ACCEPTED") + } else { + fmt.Printf("\n wrong password refused: %v\n", err) + } + + if _, err := repo.PosLogin(models.PosLoginRequest{ + Authname: "nobody@nowhere.invalid", Password: "whatever", + }); err != nil { + fmt.Printf(" unknown account refused: %v\n", err) + } +} + +// proveCrossTenantRefusal is the authorisation test: an account from one tenant +// must not be able to open a till at another tenant's outlet, which is exactly +// what a till could do before by editing one field in Settings. +func proveCrossTenantRefusal(db *gorm.DB, repo repositories.PosRepository) { + var cred struct { + Authname string + Password string + Tenantid int + } + err := db.Raw(` + SELECT COALESCE(a.authname,'') AS authname, COALESCE(a.password,'') AS password, a.tenantid + FROM app_users a + WHERE COALESCE(a.password,'') <> '' AND COALESCE(a.authname,'') <> '' AND a.tenantid > 0 + AND LOWER(COALESCE(a.status,'active')) <> 'inactive' + ORDER BY a.userid LIMIT 1`).Scan(&cred).Error + if err != nil || cred.Authname == "" { + log.Fatalf("no usable account to test with: %v", err) + } + + // Somebody else's outlet. + var foreign int + db.Raw(`SELECT locationid FROM tenantlocations WHERE tenantid <> ? ORDER BY locationid LIMIT 1`, + cred.Tenantid).Scan(&foreign) + if foreign == 0 { + log.Fatal("only one tenant has outlets; nothing to cross") + } + + fmt.Printf("account belongs to tenant %d; asking for outlet %d, which does not\n\n", + cred.Tenantid, foreign) + + if _, err := repo.PosLogin(models.PosLoginRequest{ + Authname: cred.Authname, Password: cred.Password, Locationid: foreign, + }); err == nil { + fmt.Println(" !! ACCEPTED — a tenant signed a till into another tenant's outlet") + } else { + fmt.Printf(" refused: %v\n", err) + } + + allowed, err := repo.PosLocationAllowed(cred.Tenantid, foreign) + if err != nil { + log.Fatal(err) + } + fmt.Printf(" PosLocationAllowed(tenant %d, outlet %d) = %v (want false)\n", + cred.Tenantid, foreign, allowed) +} diff --git a/services/posService.go b/services/posService.go index eb40884..ffdcfb3 100644 --- a/services/posService.go +++ b/services/posService.go @@ -2,9 +2,11 @@ package services import ( "context" + "time" "nearle/models" "nearle/repositories" + "nearle/utils" ) type PosService interface { @@ -23,6 +25,14 @@ type PosService interface { Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) SaleDetail(locationID int, reference string) (*models.PosOrders, error) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) + + // Login authenticates a person against the same account store the web + // console uses and mints the session a till carries for the trading day. + Login(req models.PosLoginRequest) (*models.PosSession, error) + + // LocationAllowed is the authorisation check every other POS call rests on: + // does the tenant in the caller's token actually own this outlet. + LocationAllowed(tenantID, locationID int) (bool, error) } type posService struct { @@ -69,3 +79,35 @@ func (s *posService) SaleDetail(locationID int, reference string) (*models.PosOr func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) { return s.repo.SalesSummary(f) } + +// Login authenticates a terminal's operator and issues its session. +// +// The token is minted here rather than in the repository so that the signing +// key stays out of the layer that talks to the database, and so a future change +// of token format touches one function. +func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, error) { + session, err := s.repo.PosLogin(req) + if err != nil { + return nil, err + } + + token, expires, err := utils.MintPosToken(utils.PosClaims{ + Userid: session.Userid, + Tenantid: session.Tenantid, + Locationid: session.Locationid, + Roleid: session.Roleid, + Terminalid: req.Terminalid, + }, time.Now()) + if err != nil { + return nil, err + } + + session.Token = token + session.Expiresat = expires.UTC().Format(time.RFC3339) + + return session, nil +} + +func (s *posService) LocationAllowed(tenantID, locationID int) (bool, error) { + return s.repo.PosLocationAllowed(tenantID, locationID) +} diff --git a/utils/postoken.go b/utils/postoken.go new file mode 100644 index 0000000..0e805e3 --- /dev/null +++ b/utils/postoken.go @@ -0,0 +1,164 @@ +package utils + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +// Session tokens for the POS terminal. +// +// A till is not a browser. It signs in once when a shop opens and then bills +// for a whole trading day — often on a connection that comes and goes — so the +// thing it carries has to survive a reboot, a lost network, and an hour in a +// drawer. That rules out a server-side session table (a till that cannot reach +// us must still be able to prove who it is when it comes back) and it rules out +// a short expiry. +// +// So: a signed, self-describing token. Everything needed to authorise a request +// is inside it, and the signature is what makes it trustworthy. No database +// round trip on the hot path, and nothing to replicate between pods. +// +// Deliberately not JWT. The backend has no JWT dependency today, and the format +// buys nothing here — there is exactly one issuer, one audience and one +// algorithm, so the header that JWT spends bytes negotiating is a constant. The +// `alg` field is also the source of JWT's worst-known footgun (`alg: none`), +// and a format with no algorithm field cannot have that bug. +// +// Wire format is `base64url(payload).base64url(hmac-sha256)`, and the MAC is +// taken over the encoded payload rather than the raw JSON so that verification +// never has to re-serialise anything to check it. + +// PosClaims is what a terminal proves about itself on every request. +// +// Locationid is the load-bearing field. Before this existed the till named its +// own store on the wire and was believed, so changing one number in Settings +// moved a terminal into another tenant's books. Now the location is decided at +// sign-in, from the user's own record, and sealed under the signature. +type PosClaims struct { + Userid int `json:"uid"` + Tenantid int `json:"tid"` + Locationid int `json:"lid"` + Roleid int `json:"rid"` + Configid int `json:"cid"` + Terminalid string `json:"trm,omitempty"` + Issuedat int64 `json:"iat"` + Expiresat int64 `json:"exp"` +} + +// PosTokenTTL is how long a till stays signed in. +// +// Thirty days rather than hours. A shop signs the terminal in once and expects +// it to keep working; forcing a re-login mid-shift would mean a queue of +// customers waiting while somebody finds the manager's password. The exposure +// that buys is bounded by the token naming a single location — a leaked one +// bills into the shop it was already for. +const PosTokenTTL = 30 * 24 * time.Hour + +// posTokenSecret is the signing key. +// +// Fails loudly rather than falling back to a baked-in default. A hardcoded +// development secret has a way of reaching production, and a signing key that +// everyone with the source can compute is the same as no signature at all — +// anyone could mint a token for any tenant. +func posTokenSecret() ([]byte, error) { + secret := strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) + if secret == "" { + // Falls back to the key the config file already carries, so a + // deployment that set that one does not need a second variable. + secret = strings.TrimSpace(os.Getenv("JWT_SECRET_KEY")) + } + if secret == "" { + return nil, fmt.Errorf("POS_TOKEN_SECRET is not set; terminals cannot be issued sessions") + } + if len(secret) < 16 { + return nil, fmt.Errorf("POS_TOKEN_SECRET is too short to sign with; use at least 16 characters") + } + return []byte(secret), nil +} + +// MintPosToken issues a session for a signed-in terminal. +func MintPosToken(claims PosClaims, now time.Time) (string, time.Time, error) { + secret, err := posTokenSecret() + if err != nil { + return "", time.Time{}, err + } + + expires := now.Add(PosTokenTTL) + 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 encoded + "." + sign(encoded, secret), expires, nil +} + +// ParsePosToken verifies a token and returns what it claims. +// +// Order matters: the signature is checked before the payload is trusted for +// anything, including expiry. Reading `exp` out of an unverified payload and +// acting on it would be taking the attacker's word for when their own token +// runs out. +func ParsePosToken(token string, now time.Time) (PosClaims, error) { + secret, err := posTokenSecret() + if err != nil { + return PosClaims{}, err + } + + encoded, signature, found := strings.Cut(strings.TrimSpace(token), ".") + if !found || encoded == "" || signature == "" { + return PosClaims{}, fmt.Errorf("malformed session token") + } + + // Constant time, so a caller cannot learn the right signature one byte at a + // time from how long the comparison took. + if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) { + return PosClaims{}, fmt.Errorf("session token signature does not verify") + } + + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return PosClaims{}, fmt.Errorf("malformed session token") + } + + var claims PosClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return PosClaims{}, fmt.Errorf("malformed session token") + } + + if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat { + return PosClaims{}, fmt.Errorf("session has expired; sign in again") + } + + // A token that verifies but names no outlet would authorise nothing and + // must not be mistaken for one that authorises everything. + if claims.Locationid <= 0 || claims.Tenantid <= 0 { + return PosClaims{}, fmt.Errorf("session token names no outlet") + } + + return claims, nil +} + +func sign(encoded string, secret []byte) string { + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(encoded)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// PosTokenConfigured reports whether sessions can be issued at all. +// +// Lets the server say "this deployment has no signing key" once at start-up +// rather than answering every sign-in with a 500. +func PosTokenConfigured() bool { + _, err := posTokenSecret() + return err == nil +} diff --git a/utils/postoken_test.go b/utils/postoken_test.go new file mode 100644 index 0000000..14e52d4 --- /dev/null +++ b/utils/postoken_test.go @@ -0,0 +1,166 @@ +package utils + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" +) + +func withSecret(t *testing.T, secret string) { + t.Helper() + t.Setenv("POS_TOKEN_SECRET", secret) +} + +const testSecret = "a-test-signing-key-long-enough" + +func TestASessionSurvivesTheRoundTrip(t *testing.T) { + withSecret(t, testSecret) + now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC) + + token, expires, err := MintPosToken(PosClaims{ + Userid: 42, Tenantid: 1087, Locationid: 1135, Roleid: 3, Terminalid: "T5EDD", + }, now) + if err != nil { + t.Fatalf("minting: %v", err) + } + + claims, err := ParsePosToken(token, now.Add(time.Hour)) + if err != nil { + t.Fatalf("parsing a token we just issued: %v", err) + } + + if claims.Tenantid != 1087 || claims.Locationid != 1135 { + t.Fatalf("the outlet did not survive: tenant %d location %d", claims.Tenantid, claims.Locationid) + } + if claims.Terminalid != "T5EDD" { + t.Fatalf("terminal id lost: %q", claims.Terminalid) + } + if !expires.After(now) { + t.Fatalf("expiry %v is not after issue %v", expires, now) + } +} + +// The whole point of signing. Before this existed a till named its own outlet +// on the wire and was believed, so this is the test that says it no longer can. +func TestARewrittenOutletIsRefused(t *testing.T) { + withSecret(t, testSecret) + now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC) + + token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now) + if err != nil { + t.Fatalf("minting: %v", err) + } + + // Tamper: decode the payload, move it to another tenant's outlet, re-encode + // and keep the original signature — exactly what an attacker holding a real + // token would try. + encoded, signature, _ := strings.Cut(token, ".") + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decoding our own payload: %v", err) + } + + var claims PosClaims + if err := json.Unmarshal(payload, &claims); err != nil { + t.Fatalf("unmarshalling our own payload: %v", err) + } + claims.Tenantid = 916 + claims.Locationid = 1185 + + forged, _ := json.Marshal(claims) + tampered := base64.RawURLEncoding.EncodeToString(forged) + "." + signature + + if _, err := ParsePosToken(tampered, now); err == nil { + t.Fatal("a token whose outlet was rewritten was accepted") + } +} + +func TestAnExpiredSessionIsRefused(t *testing.T) { + withSecret(t, testSecret) + now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC) + + token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now) + if err != nil { + t.Fatalf("minting: %v", err) + } + + if _, err := ParsePosToken(token, now.Add(PosTokenTTL+time.Minute)); err == nil { + t.Fatal("an expired session was accepted") + } +} + +// A token signed by somebody else must not verify here, or the signature is +// decoration. +func TestATokenFromAnotherKeyIsRefused(t *testing.T) { + withSecret(t, testSecret) + now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC) + + token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now) + if err != nil { + t.Fatalf("minting: %v", err) + } + + withSecret(t, "a-completely-different-key-here") + if _, err := ParsePosToken(token, now); err == nil { + t.Fatal("a token signed with another key verified") + } +} + +func TestAMalformedTokenIsRefused(t *testing.T) { + withSecret(t, testSecret) + now := time.Now() + + for _, token := range []string{ + "", + "nodot", + ".", + "only.", + ".onlysignature", + "not-base64!.also-not-base64!", + } { + if _, err := ParsePosToken(token, now); err == nil { + t.Fatalf("malformed token %q was accepted", token) + } + } +} + +// A deployment with no signing key must fail loudly rather than fall back to a +// key anyone reading the source could compute. +func TestNoSecretMeansNoSessions(t *testing.T) { + t.Setenv("POS_TOKEN_SECRET", "") + t.Setenv("JWT_SECRET_KEY", "") + + if PosTokenConfigured() { + t.Fatal("reported configured with no secret set") + } + if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil { + t.Fatal("minted a session with no signing key") + } +} + +func TestAShortSecretIsRefused(t *testing.T) { + t.Setenv("POS_TOKEN_SECRET", "short") + t.Setenv("JWT_SECRET_KEY", "") + + if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil { + t.Fatal("signed with a secret too short to be worth signing with") + } +} + +// A token that verifies but names no outlet authorises nothing, and must not be +// mistaken for one that authorises everything. +func TestASessionNamingNoOutletIsRefused(t *testing.T) { + withSecret(t, testSecret) + now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC) + + token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 0, Locationid: 0}, now) + if err != nil { + t.Fatalf("minting: %v", err) + } + + if _, err := ParsePosToken(token, now); err == nil { + t.Fatal("a session naming no outlet was accepted") + } +}