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:
Suriya
2026-08-06 15:46:38 +05:30
parent 5864204d32
commit 12165d5e58
13 changed files with 1559 additions and 0 deletions

View File

@@ -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),
},
})
}

View File

@@ -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
}

219
middleware/posauth.go Normal file
View File

@@ -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
}

151
middleware/posauth_test.go Normal file
View File

@@ -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)
}
})
}
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

164
utils/postoken.go Normal file
View File

@@ -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
}

166
utils/postoken_test.go Normal file
View File

@@ -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")
}
}