Files
backend_fiesta/utils/webtoken.go
abhishek c516c224e5 Authenticate the console's /web surface
The /web endpoints have never had authentication. The console keeps its
login record in per-tab sessionStorage and sends no Authorization header,
so every endpoint under /v1/web read `tenantid` off the query string and
believed it — one number in a URL reached another merchant's orders,
stock, staff and takings. `createposuser` under /v1/web/tenants minted
till credentials on the strength of an unauthenticated request, which the
route file already flagged in as many words.

Closed the same way posauth.go closed it for the terminals, in the same
order: the caller holds a token this server signed, and the tenant they
name is the tenant inside that token.

- utils/webtoken.go   same HMAC construction as the POS token, 12h TTL,
                      a `w1.` prefix so the two kinds cannot verify as
                      each other
- middleware/webauth.go  verifies the token, pins the tenant, and checks
                      a named branch belongs to it; reads the tenant from
                      the query, the body, and inside a JSON array, since
                      createdeliveries posts one
- login now issues the token; the console sends it as Bearer

Platform access rides on issuperadmin and nothing else. Not the role —
app_roles calls roleid 1 "Super admin" and tenant onboarding wrote 1 for
every shop owner, so a role test would promote every merchant on the
platform. Not a zero tenant either, or a user row with the field unset
becomes the one session that reads everything. Both near-misses have
tests.

WEB_AUTH_REQUIRED defaults to off. The console in production does not
send a token yet, and enforcing before it does would lock every merchant
out of a working product. A token that IS sent is always verified, and
one naming the wrong tenant is always refused; the flag only decides what
happens to a request carrying none. This should be a short-lived state.

Still trusting the caller: partnerid, customerid and appuserid, which
some list endpoints also scope on. Noted in the middleware header.

25 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:22:20 +05:30

181 lines
7.5 KiB
Go

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