From d566ca559110e00afa7223c5392c5facb4923a52 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 12 Aug 2026 17:10:27 +0530 Subject: [PATCH] pos login with the ph number and pin --- controllers/posController.go | 26 ++- docs/POS_LOGIN.md | 91 ++++---- docs/POS_PHONE_LOGIN_HANDOVER.md | 12 ++ docs/POS_PHONE_PIN_LOGIN_HANDOVER.md | 311 +++++++++++++++++++++++++++ models/pos.go | 64 ++++-- repositories/posAuthRepository.go | 132 ++++++++++-- repositories/posLogin_test.go | 197 +++++++++++++++++ repositories/posUserRepository.go | 40 +++- scratch/posphonepinproof/main.go | 225 +++++++++++++++++++ 9 files changed, 1007 insertions(+), 91 deletions(-) create mode 100644 docs/POS_PHONE_PIN_LOGIN_HANDOVER.md create mode 100644 repositories/posLogin_test.go create mode 100644 scratch/posphonepinproof/main.go diff --git a/controllers/posController.go b/controllers/posController.go index 1ef4bf3..a305366 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -443,6 +443,11 @@ func posIngestError(c *fiber.Ctx, op string, err error) error { // 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. +// +// A mobile number and a PIN. Because it is unauthenticated and the PIN is four +// digits, this is the one route on the group that needs a rate limit in front +// of it — the pair is only strong while an attacker cannot try ten thousand +// times. That belongs at the edge, not here. func (ctl *PosController) Login(c *fiber.Ctx) error { var req models.PosLoginRequest if err := c.BodyParser(&req); err != nil { @@ -452,10 +457,23 @@ func (ctl *PosController) Login(c *fiber.Ctx) error { }) } - if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" { + // Which account, and which of the two credentials was offered. Both are + // checked here so an empty field is answered as the malformed request it is, + // rather than spending a database round trip to say the same thing. + identity := strings.TrimSpace(req.Contactno) + if identity == "" { + identity = strings.TrimSpace(req.Authname) + } + if identity == "" { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, - "message": "an email or mobile number is required", + "message": "a mobile number is required", + }) + } + if strings.TrimSpace(req.Pin) == "" && strings.TrimSpace(req.Password) == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, "status": false, + "message": "a PIN is required", }) } @@ -471,7 +489,7 @@ func (ctl *PosController) Login(c *fiber.Ctx) error { }) } - log.Printf("pos login (%s): %v", req.Authname, err) + log.Printf("pos login (%s): %v", identity, err) return c.Status(http.StatusForbidden).JSON(fiber.Map{ "code": http.StatusForbidden, "status": false, "message": err.Error(), }) @@ -693,7 +711,7 @@ func (ctl *PosController) PinLogin(c *fiber.Ctx) error { if !ok { return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ "code": http.StatusUnauthorized, "status": false, - "message": "sign the terminal in with an email and password before using PIN sign-in", + "message": "sign the terminal in with a mobile number and PIN before switching operator", }) } diff --git a/docs/POS_LOGIN.md b/docs/POS_LOGIN.md index 9beef8d..63b70db 100644 --- a/docs/POS_LOGIN.md +++ b/docs/POS_LOGIN.md @@ -29,10 +29,10 @@ told.** ```bash BASE=https://fiesta.nearle.app/live/api/v1/pos -# 1. Sign in +# 1. Sign in — a mobile number and a 4-digit PIN curl -s -X POST $BASE/login \ -H 'Content-Type: application/json' \ - -d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}' + -d '{"contactno":"9876543210","pin":"4821","terminal_id":"T5EDD"}' # 2. Use the token on everything else curl -s $BASE/session -H "Authorization: Bearer $TOKEN" @@ -44,9 +44,9 @@ curl -s $BASE/session -H "Authorization: Bearer $TOKEN" These steps are in order, and the order matters. -**1. Sign in.** `POST /login` with the operator's own credentials — the same -`app_users` account they use for the web console. There is no separate POS -password. +**1. Sign in.** `POST /login` with the operator's **mobile number and 4-digit +PIN** — the pair the back office issued them. Both are held on their own +`app_users` row; there is no separate POS credential store. **2. Read `store_id` out of the response.** Do not ask anyone to type it. It is whatever the back office says that account's outlet is. @@ -74,8 +74,8 @@ The only unauthenticated route. It is where a token comes from. ```json { - "authname": "rsselvapuram@gmail.com", - "password": "…", + "contactno": "9876543210", + "pin": "4821", "terminal_id": "T5EDD", "device_id": "a5f3…", "location_id": 1135, @@ -85,15 +85,15 @@ The only unauthenticated route. It is where a token comes from. | Field | Required | Notes | |---|---|---| -| `authname` | yes* | Email. **Or** send `contactno` instead. | -| `contactno` | yes* | Mobile number, as an alternative to `authname`. | -| `password` | yes | | +| `contactno` | yes | Mobile number. Send it as typed — `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account. | +| `pin` | yes | Exactly 4 digits, never starting with `0`. | | `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. | | `device_id` | no | The device's stable UUID. | | `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. | | `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. | +| `authname` + `password` | no | The previous way in. Still accepted, so a shop whose numbers have not been backfilled is not stranded — see [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md). | -\* one of `authname` or `contactno`. +`pin` wins if a password is sent as well; `authname` wins over `contactno`. ### Response — `200` @@ -128,7 +128,7 @@ The only unauthenticated route. It is where a token comes from. "staff": [ { "user_id": 1148, "full_name": "Ragul Kannan", - "role": "Super admin", "pin": "1111", "status": "Active" } + "role": "Super admin", "status": "Active" } ] } } @@ -156,7 +156,10 @@ sign-in. **`locations`** — every outlet this account may open a till at. Length 1 is the normal case. -**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty.** +**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty**, and it +**no longer carries `pin`**: a PIN is now half of the sign-in, so a list of them +is a list of working credentials for the outlet. Switch operator through +`POST /login/pin` instead. --- @@ -191,9 +194,13 @@ Requires the token. Returns `401` when there isn't one. Who may ring a bill at this terminal's outlet. For pulling down somebody hired mid-shift without signing the terminal out. -**Takes no parameters.** The answer carries PINs, so the outlet comes from the -caller's own token — a till must not be able to ask who works at the shop next -door. A request without a token is refused whatever the enforcement setting is. +**Takes no parameters.** The outlet comes from the caller's own token — a till +must not be able to ask who works at the shop next door. A request without a +token is refused whatever the enforcement setting is. + +Names and roles only; **`pin` is not returned here either**, for the same reason +it left the login session. A supervisor who needs to see or change one uses +`GET /pos/users`, which is role-gated. ```json { @@ -258,14 +265,18 @@ login, it is simply not found. one, 22 live accounts have it including a delivery rider, and it grants nothing on either side. -### Every till account gets its own username and password +### Every till account gets its own number and PIN -Both roles. A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a -session that already exists — so a PIN-only account works only while somebody -else is standing there to unlock the till first. For a Supervisor that was an -outright deadlock; for a Cashier it meant a shop that could not open until two -people had arrived, and whoever gets in at seven is as often the cashier as the -supervisor. +Both roles. A PIN now opens a *closed* terminal too, paired with the account's +mobile number — which is what removed the old deadlock: `/pos/login/pin` needs a +session that already exists, so before this a PIN-only account could not unlock +a till at all. For a Supervisor that was an outright deadlock; for a Cashier it +meant a shop that could not open until two people had arrived, and whoever gets +in at seven is as often the cashier as the supervisor. + +**So a till account needs both `contactno` and `pin` set.** One without the +other cannot sign in. A username and password still work, and every account +created before this still has them. So a Cashier signs in exactly like a Supervisor does, and the *role* decides what they get — not which credential they used: @@ -293,9 +304,10 @@ outlet — becomes `cashier2.1185@pos.nearle.in`; a name **you** supplied is nev adjusted, it is refused, because silently signing somebody in as another person's address is worse than an error. -The PIN stays optional. It switches operator at an open counter, which not every -shop does, and it is the one credential the till holds in plaintext to hand -around — so it is set deliberately, never by default. +**The PIN is no longer optional in practice.** The field still is — creation +accepts an account without one — but an account with no PIN cannot sign a +terminal in, and is told so by name: *"this account has no PIN set; ask your +supervisor to set one in the web console first."* --- @@ -303,11 +315,12 @@ around — so it is set deliberately, never by default. For a cashier taking over a counter a supervisor has already opened. -**Requires an existing valid token.** That is the security model, not an -oversight: four digits is ten thousand guesses, which is no barrier at all to an -anonymous caller. Tying it to a session means a supervisor has opened the -terminal with a real password first, and the guesses are confined to that one -outlet's staff. +**Requires an existing valid token.** A PIN alone is four digits — ten thousand +guesses, and no barrier to an anonymous caller. Tying it to a session confines +the guesses to one outlet's staff, at a terminal somebody has already opened. +That is why this route takes a bare PIN and `/login` does not: there, the PIN is +checked against one mobile number, and the number is what makes the pair worth +anything. ```bash curl -s -X POST $BASE/login/pin \ @@ -486,23 +499,29 @@ GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach o | Code | Meaning | What the till should do | |---|---|---| -| `400` | Body unreadable, or neither `authname` nor `contactno` sent | Fix the request | -| `401` | `those sign-in details were not recognised` | Ask them to re-type. **Wrong email and wrong password give the same message** — deliberately, so the endpoint isn't a directory of who banks here | +| `400` | Body unreadable, or `a mobile number is required` / `a PIN is required` — one of the two fields is empty | Fix the request; do not send it again unchanged | +| `401` | `those sign-in details were not recognised` | Ask them to re-type. **A wrong number and a wrong PIN give the same message** — deliberately, so the endpoint isn't a directory of who banks here. A number that cannot be ten digits, and a PIN that is not four, answer the same way | | `403` | Real account, but it can't open this till | Show the message; re-typing won't help | The `403` messages, verbatim: - `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console` - `this account is inactive; contact your administrator` +- `this account has no PIN set; ask your supervisor to set one in the web console first` - `this account has no password set; set one in the web console first` - `this account is not attached to a tenant and cannot open a till` - `no active outlet is registered for this account` - `this account cannot open a till at outlet 1185` - `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login` -That last one is real, not theoretical: `authname` is not unique in this schema. -Live data has the same address twice. We refuse rather than pick one, because -picking wrong means billing into another tenant's books. +That last one is real, not theoretical: neither `authname` nor `contactno` is +unique in this schema. Live data has the same address twice, and 34 mobile +numbers shared by 104 accounts. We refuse rather than pick one, because picking +wrong means billing into another tenant's books. + +Sign-in only ever looks at **till accounts** (roleid 7 and 8), which is what +makes a number workable as a credential: it has to be unique among a tenant's +own till staff, not across all 608 users on the platform. The **first** one is the common case now, and it is deliberately specific where a bad password is deliberately vague. By the time it fires the caller has already diff --git a/docs/POS_PHONE_LOGIN_HANDOVER.md b/docs/POS_PHONE_LOGIN_HANDOVER.md index af40647..5237a4d 100644 --- a/docs/POS_PHONE_LOGIN_HANDOVER.md +++ b/docs/POS_PHONE_LOGIN_HANDOVER.md @@ -1,5 +1,17 @@ # POS sign-in by mobile number, and shift assignment — handover to the terminal team +> ## ⚠️ Superseded by [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md) +> +> `POST /pos/login` now takes a mobile number and a **PIN**, not a mobile number +> and a password, and the session no longer carries `staff[].pin`. Read the new +> handover instead — build against this one and the sign-in screen will be +> wrong. +> +> Still accurate here, and not repeated there: the `createposuser` / +> `updateposuser` / `getposusers` field changes (§2), the number format rules +> (§3), and shifts (§4). **Not** accurate here: §2's claim that `shift_*` +> appears on `staff[]` in the `/pos/login` session — it never did. + Backend is done and builds clean. **Nothing about this breaks the current app** — username sign-in keeps working exactly as it does today. Read §5 before you ship anything, because the switch has one ordering rule that will lock out every diff --git a/docs/POS_PHONE_PIN_LOGIN_HANDOVER.md b/docs/POS_PHONE_PIN_LOGIN_HANDOVER.md new file mode 100644 index 0000000..565486b --- /dev/null +++ b/docs/POS_PHONE_PIN_LOGIN_HANDOVER.md @@ -0,0 +1,311 @@ +# POS sign-in by mobile number and PIN — handover to the terminal team + +`POST /pos/login` now takes a **mobile number and a four-digit PIN**. Backend is +done, builds clean, and is proved end to end against a real database (§8). + +Two things need reading before you ship: **§5**, because shipping the new screen +before the back office has filled in numbers and PINs locks out every cashier in +every shop; and **§4**, because the login response no longer carries staff PINs +and any terminal switching operator from that array will stop working. + +Supersedes `POS_PHONE_LOGIN_HANDOVER.md`, which described the same endpoint +taking a number and a *password*. + +--- + +## 1. What changed, in one line + +A till signs in with the two things a person standing at a counter can actually +type — their number and their PIN — and the response stops handing out everyone +else's PIN. + +--- + +## 2. The endpoint + +`POST https://fiesta.nearle.app/live/api/v1/pos/login` + +Unauthenticated. It is where a token comes from. Everything else on the POS +group sits behind the session it issues. + +### Payload + +```jsonc +{ + "contactno": "9876543210", // required + "pin": "4821", // required — exactly 4 digits, never starts with 0 + "terminal_id": "T5EDD", // optional, recorded on the session + "device_id": "a5f3…", // optional + "location_id": 1135, // optional, multi-outlet accounts only + "configid": 1 // optional, only if you are told to send it +} +``` + +| Field | Required | Notes | +|---|---|---| +| `contactno` | **yes** | Send it as typed. `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account — the server reduces every number to ten digits before matching. | +| `pin` | **yes** | Exactly 4 digits. `0451` is not a PIN here and never was — see §3. | +| `terminal_id` | no | This till's short code. Recorded on the session so a stolen token can be told apart from the terminal it was issued to. | +| `device_id` | no | The device's stable UUID. | +| `location_id` | no | Means something **only** for an account entitled to more than one outlet. It is a request, not an assertion: checked against what the account may reach, and refused if it is not one of them. | +| `configid` | no | Inferred when absent. Send it only after the ambiguity error in §6. | +| `authname` + `password` | no | The previous way in. **Still works** — see §5. | + +If both are sent, `pin` is used over `password` and `authname` over `contactno`. + +### Response — `200`, unchanged in shape + +```jsonc +{ + "code": 200, + "status": true, + "message": "Login successful", + "details": { + "token": "eyJ1aWQiOjQwMDEsInRpZCI6MTA4Nywi….PKmoMn92BMs", + "expires_at": "2026-09-11T11:31:08Z", + + "user_id": 4001, + "full_name": "Meena Sundaram", + "email": "meena@example.com", + "role_id": 7, + "role": "Supervisor", + "can_manage_staff": true, + + "tenant_id": 1087, + "tenant_name": "R Mart", + + "store_id": "1135", + "location_id": 1135, + "location_name": "Selvapuram", + "gstin": "33AABCU9603R1ZM", + "address": "4 Trichy Road", + "phone": "04422334455", + + "locations": [ + { "location_id": 1135, "location_name": "Selvapuram", + "address": "4 Trichy Road", "city": "Coimbatore", "status": "Active" } + ], + + "staff": [ + { "user_id": 4002, "full_name": "Priya Raman", + "role": "Cashier", "status": "Active" } + ] + } +} +``` + +**The only change to the response is that `staff[].pin` is gone.** Everything +else — `token`, `expires_at`, `store_id`, `locations[]`, `can_manage_staff` — +is byte-for-byte what it was. §4 is why, and what to do instead. + +Two corrections to what the previous handover promised about this response: +`staff[]` has **no** `shift_id` / `shift_name` / `shift_start` / `shift_end` +fields, and never did. The shift fields exist on `GET /web/tenants/getposusers`. +If the terminal needs the shift on the sign-in screen, say so and it can be +added — do not write code against it today. + +--- + +## 3. What counts as a PIN, and the trap in it + +Four digits, and **never a leading zero**. `app_users.pin` is a `bigint`, so +`0451` is stored as `451` and read back as three digits — somebody would type +four and be refused for ever. Creation refuses those, so this only matters for +what you let a person type: accept four digits, send them as a string. + +**Do not add a client-side guessable-PIN check.** The console refuses to *issue* +`1234`, `1111`, `9999` and a handful more, but live data already holds `1234` on +eleven accounts and `1111` on nine — issued before that rule existed. Sign-in +deliberately accepts them, because refusing them would lock twenty real people +out of terminals this platform signed them up to. A rule about what may be +created is not a rule about what may be typed. + +**Number format.** Non-digits are stripped, then a leading `91` or `0` is +dropped once. Anything that is not ten digits afterwards is rejected. Do not +add a country code the user did not type, and do not reject `+91` locally — let +it through and let the server reduce it. + +--- + +## 4. 🔴 `staff[].pin` is gone — read this if you switch operators offline + +The login session used to carry every colleague's PIN so the till could switch +operator without a round trip. That was defensible while a PIN was *shift +attribution*: the token decided which books a terminal could reach, and the PIN +only decided whose name went on the bill. + +That stopped being true the moment the PIN became half of the sign-in. The array +would now be a list of working credentials for the whole outlet — including the +supervisor's, which carries `can_manage_staff`. Any cashier could read it and +sign back in as their own manager. + +So `pin` no longer appears in `staff[]`, and no longer appears in `GET +/pos/staff` either. `staff[]` still carries `user_id`, `full_name`, `role` and +`status`, so the operator picker still works. + +**Switch operator through `POST /pos/login/pin`** — an existing session, a bare +PIN, and you get a fresh session with the new person's role: + +```bash +curl -s -X POST "$B/pos/login/pin" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"pin":"7391"}' +``` + +**The cost, stated plainly: this needs the network.** A till that switched +operators offline against the cached array can no longer do so. If that matters +for your shops, tell us — the options are a short-lived cache of hashed PINs on +the device, or a separate endpoint a supervisor calls once to warm one. Neither +is built, and neither should be built on a guess about how shops actually work. + +One wart we did **not** change: `staff[]` can contain a back-office account that +has a PIN and sits at that location — the query filters on having a PIN, not on +role. Tapping them and typing their PIN answers *"this account is not set up for +the till"*. Filtering by role would look tidier and would empty the staff list +for a great many real shops, whose people carry role ids that are not POS roles +at all. Show the message; it names the fix. + +--- + +## 5. 🔴 Sequencing — read this before shipping + +**Every till account on the platform predates the number it now signs in with.** +When the previous handover was written, all 12 live POS accounts had +`contactno = ""`. Some also have no PIN. An account needs **both** to sign in. + +If the app ships number-and-PIN **only**, every cashier in every shop is locked +out on the next update. + +**Required order:** + +1. **Backend deploys.** Nothing changes for the app — username and password + still work, unchanged. +2. **Back office fills in a mobile number and a PIN on every existing till + account** through the console (`PUT /web/tenants/updateposuser`). New + accounts already require a number. +3. **Only then** does the app make number-and-PIN the primary sign-in. + +**Recommendation for the app:** ship the new screen, and keep a small +"sign in with a username instead" link behind it until step 2 is confirmed +finished for every shop. One un-backfilled account then means one awkward login, +not a shop that cannot open. + +**Verify step 2 is actually done** before you flip anything — a shop is ready +only when every one of its till accounts has both fields: + +```bash +curl -s "$B/web/tenants/getposusers?tenantid=1087&locationid=1135" \ + -H "Authorization: Bearer $CONSOLE_TOKEN" | jq '.details.users[] | {full_name, contactno, pin}' +``` + +--- + +## 6. Errors you should handle + +| Code | Message | What it means | What the app should do | +|---|---|---|---| +| `400` | `a mobile number is required` | The field was empty, or held no digits at all | Fix locally; do not resend unchanged | +| `400` | `a PIN is required` | Neither `pin` nor `password` was sent | Fix locally | +| `401` | `those sign-in details were not recognised` | Wrong number, wrong PIN, a number that cannot be ten digits, a PIN that is not four, or somebody who has left | *"Check your number and PIN"* — do **not** say which was wrong | +| `403` | `this account has no PIN set; ask your supervisor to set one in the web console first` | Provisioned without one | Show verbatim; it names the fix | +| `403` | `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console` | A back-office login was used | Show verbatim | +| `403` | `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login` | Ambiguous match | Show verbatim; it needs the back office | +| `403` | `this account cannot open a till at outlet 1185` | `location_id` named an outlet this account cannot reach | Drop `location_id` and retry, then show the picker from `locations[]` | + +The `401` is deliberately one message for several causes. Distinguishing them +turns an unauthenticated endpoint into a directory of who banks here. + +--- + +## 7. 🔴 One thing this endpoint cannot fix: rate limiting + +A four-digit PIN is ten thousand guesses. On `/pos/login/pin` that is contained, +because the route needs a valid session and can only reach one outlet's staff. +On `/pos/login` it is **not** — the route is unauthenticated by necessity, and +the pair is only strong while an attacker cannot sit and try every PIN against a +number they know. + +Nothing in this change adds a limiter, and the endpoint is the wrong place for +one. **Before this becomes the only way into a till, `/pos/login` needs a rate +limit at the edge** — per source and per `contactno`, with a lockout after a +few failures. Please raise it with whoever owns the ingress; it is not the +terminal team's job, but shipping the screen without it is what would make it +somebody's incident. + +--- + +## 8. How this was verified + +Not against production — there are no live credentials in this working copy, so +nothing here was run against the real database. Proved instead against a +throwaway Postgres seeded with a shop and six accounts, running the **real** +repository, service and SQL: + +```bash +docker run -d --rm --name nearle-posproof -e POSTGRES_PASSWORD=proof \ + -e POSTGRES_DB=proof -p 55432:5432 postgres:16-alpine + +POS_PROOF_DSN='postgres://postgres:proof@localhost:55432/proof?sslmode=disable' \ +POS_TOKEN_SECRET=proof-secret-at-least-16 \ + go run ./scratch/posphonepinproof # 18 passed, 0 failed +``` + +Covered: sign-in by number and PIN; the same account typed four different ways; +a cashier getting a cashier's session; a weak-but-issued PIN still admitted; a +wrong PIN, an unknown number, an account with no PIN, a back-office account, a +malformed number, a malformed PIN and a leaver all refused with the right +answer; username-and-password still working; `POST /login/pin` still switching +operator; no PIN anywhere in the response; a token minted. + +Unit tests for the same rules: `go test ./repositories/` — +`repositories/posLogin_test.go`. + +**Still to do against live data, by whoever has the credentials:** confirm the +regression check below, and count how many till accounts still lack a number or +a PIN (§5, step 2). + +```bash +B=https://fiesta.nearle.app/live/api/v1 + +# regression: username sign-in must still work — run this first +curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \ + -d '{"authname":"supervisor.1135@pos.nearle.in","password":"…"}' \ + | grep -o '"can_manage_staff":[a-z]*' +# expect: "can_manage_staff":true + +# the new way in, once that account has a number and a PIN +curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \ + -d '{"contactno":"9876543210","pin":"4821"}' \ + | grep -o '"can_manage_staff":[a-z]*' + +# and no PIN in the response +curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \ + -d '{"contactno":"9876543210","pin":"4821"}' | grep -c '"pin"' +# expect: 0 +``` + +--- + +## 9. What did **not** change + +- The response shape, apart from `staff[].pin` leaving +- `token` format, TTL (30 days) and the `PosAuth` guard +- `POST /pos/login/pin` — same request, same answer, and it still needs a session +- `POST /pos/orders`, `/pos/customers`, `/pos/health`, `GET /pos/catalogue` +- `GET /pos/users`, which still shows PINs to a supervisor and blanks them for a + cashier — a supervisor sets those PINs, so seeing them tells them nothing they + could not already change +- `POS_AUTH_REQUIRED` still defaults to off + +--- + +## 10. Where this lives, if you need to read it + +| What | Where | +|---|---| +| Which credential was offered, and how a row is checked against it | `repositories/posAuthRepository.go` — `posLoginSecret` | +| Which column names the account, and number normalisation | `repositories/posAuthRepository.go` — `posLoginIdentity` | +| PIN format at sign-in vs. the rule for issuing one | `repositories/posUserRepository.go` — `posLoginPin`, `validatePosPin` | +| The staff list, and why `Pin` is `json:"-"` | `models/pos.go` — `PosStaffMember` | +| Request/response shapes | `models/pos.go` — `PosLoginRequest`, `PosSession` | +| Full endpoint reference | `docs/POS_LOGIN.md` | diff --git a/models/pos.go b/models/pos.go index 65b47ee..dda3506 100644 --- a/models/pos.go +++ b/models/pos.go @@ -226,24 +226,41 @@ type PosCatalogueResponse struct { // 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. +// A mobile number and a four-digit PIN. That is what a person standing at a +// counter can actually type between customers, and it is the pair the console +// issues them — anything longer gets written on the side of the terminal, which +// is worse than a short credential. // // 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"` + // The mobile number this person signs in with. Sent in whatever form they + // typed it — "+91 98765 43210", "098765-43210", "9876543210" — and reduced + // to ten digits by the server before it is matched. + Contactno string `json:"contactno"` - // A PIN, for signing on at a terminal a supervisor has already opened. Only - // honoured by the PIN route, which requires an existing session — four - // digits is no barrier to an anonymous caller. + // A four-digit PIN, and the credential this endpoint now checks. + // + // Four digits is ten thousand guesses, which would be no barrier at all on + // its own — it is a barrier here only because it is checked against one + // mobile number, and a mobile number is unique among a tenant's till + // accounts. Rate limiting at the edge is what stands between that and a + // patient attacker; this endpoint cannot supply it. Pin string `json:"pin"` + // A username and password, the way in before PINs. + // + // Kept working, not deprecated in place, because every till account on the + // platform predates the mobile number it now signs in with. Removing this + // before the back office has filled those in would close every shop on the + // same morning. See docs/POS_PHONE_PIN_LOGIN_HANDOVER.md §5. + Authname string `json:"authname,omitempty"` + Password string `json:"password,omitempty"` + + 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"` @@ -319,24 +336,25 @@ type PosSession struct { // what gets stamped on a bill as `cashiername` and settled against at the end // of a shift. // -// The PIN travels in the clear, over TLS, and that is a considered choice -// rather than an oversight. A four-digit PIN is brute-forceable in microseconds -// whatever it is wrapped in, so hashing it here would buy the appearance of -// strength and not the substance. What it would cost is real: the terminal -// salts every PIN with its own random salt before storing it, so a hash -// computed here could never be verified there without inventing a shared -// scheme and keeping two codebases agreeing about it for ever. +// The PIN used to travel down with this list, on the reasoning that a PIN was +// *shift attribution* rather than a security boundary: the token decided which +// books a till could reach, and the PIN only decided which of the people +// already inside a shop got credited with a sale. // -// The honest framing is that a PIN is *shift attribution*, not a security -// boundary. The boundary is the session token — which is what stops a till -// reaching another tenant's books at all. The PIN decides which of the people -// already inside a shop gets credited with a sale, and the terminal still -// stores it hashed at rest. +// That reasoning ended when the PIN became half of the sign-in. A list of PINs +// is now a list of working credentials for the outlet — including the +// supervisor's, which carries `can_manage_staff` — so a cashier handed this +// array could sign back in as their own manager. Hence `json:"-"`: the field is +// still read from the database, because the query needs it to drop two people +// who share a PIN, but it cannot reach the wire from here. +// +// Switching operator at an open terminal goes through `POST /pos/login/pin`, +// which checks the PIN against the outlet the caller's token already names. type PosStaffMember struct { Userid int `json:"user_id"` Fullname string `json:"full_name"` Role string `json:"role"` - Pin string `json:"pin,omitempty"` + Pin string `json:"-"` Status string `json:"status,omitempty"` } diff --git a/repositories/posAuthRepository.go b/repositories/posAuthRepository.go index 3d212b5..2b859f2 100644 --- a/repositories/posAuthRepository.go +++ b/repositories/posAuthRepository.go @@ -23,6 +23,7 @@ import ( type posLoginRow struct { Userid int Password string + Pin int64 Status string Roleid int Configid int @@ -33,18 +34,91 @@ type posLoginRow struct { Email string } +// posLoginSecret is the credential a sign-in offered. +// +// Resolved once, up front, so that the eligible-row check and the wrong-role +// diagnostic ask the same question of a row. Two places deciding separately +// what counts as a correct PIN is how one of them ends up admitting an account +// the other refuses. +type posLoginSecret struct { + // byPin says which of the two ways in this is. A till signs in with a + // mobile number and a PIN; a password is only still read so that terminals + // which have not shipped the new screen keep working through the backfill. + byPin bool + pin int64 + password string +} + +// newPosLoginSecret reads the credential out of a request. +// +// A malformed PIN is the same answer as a wrong one. Saying "a PIN is four +// digits" to an unauthenticated caller would confirm that the *number* they +// typed exists, which is the one thing this endpoint must not do. +func newPosLoginSecret(req models.PosLoginRequest) (posLoginSecret, error) { + if pin := strings.TrimSpace(req.Pin); pin != "" { + value, err := posLoginPin(pin) + if err != nil { + return posLoginSecret{}, errPosLoginRejected + } + return posLoginSecret{byPin: true, pin: value}, nil + } + + if strings.TrimSpace(req.Password) == "" { + return posLoginSecret{}, fmt.Errorf("a PIN is required") + } + return posLoginSecret{password: req.Password}, nil +} + +// set reports whether the account carries a credential of the kind offered. +// +// Distinguished from a wrong one so that somebody provisioned without a PIN is +// told to go and get one, rather than left retyping four digits that were never +// going to work. +func (s posLoginSecret) set(row posLoginRow) bool { + if s.byPin { + return row.Pin >= PosPinMin && row.Pin <= PosPinMax + } + return strings.TrimSpace(row.Password) != "" +} + +// missing names the credential this account has not been given. +func (s posLoginSecret) missing() error { + if s.byPin { + return fmt.Errorf("this account has no PIN set; ask your supervisor to set one in the web console first") + } + return fmt.Errorf("this account has no password set; set one in the web console first") +} + +// matches checks the offered credential against the account's own. +// +// The PIN is compared as an integer because that is what the column holds, and +// there is nothing to leak through timing: the value was already reduced to a +// number by [posLoginPin], so the comparison sees a machine word rather than +// the digits somebody typed. +func (s posLoginSecret) matches(row posLoginRow) bool { + if s.byPin { + return s.set(row) && row.Pin == s.pin + } + // 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. + return s.set(row) && constantTimeEqual(row.Password, s.password) +} + // 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) + field, value, err := posLoginIdentity(req) + if err != nil { + return nil, err } - if value == "" { - return nil, fmt.Errorf("an email or mobile number is required") + + secret, err := newPosLoginSecret(req) + if err != nil { + return nil, err } rows, err := r.posLoginCandidates(field, value, req.Configid, true) @@ -65,9 +139,7 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession // discloses nothing the caller has not just proved. An ambiguous match // falls through to the vague answer rather than naming anything. if others, oerr := r.posLoginCandidates(field, value, req.Configid, false); oerr == nil && - len(others) == 1 && - strings.TrimSpace(others[0].Password) != "" && - constantTimeEqual(others[0].Password, req.Password) { + len(others) == 1 && secret.matches(others[0]) { return nil, errPosRoleIneligible } return nil, errPosLoginRejected @@ -87,24 +159,43 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession // 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") + // TODO: the password column is plaintext across the whole platform, and the + // PIN column is a bare integer. Hashing either is a migration touching every + // login path, not something this endpoint can fix alone — but a POS token + // minted off them is only ever as good as those columns. + if !secret.set(row) { + return nil, secret.missing() } - if !constantTimeEqual(row.Password, req.Password) { + if !secret.matches(row) { return nil, errPosLoginRejected } return r.sessionFor(row, req.Locationid) } +// posLoginIdentity decides which column a sign-in is naming an account by. +// +// A mobile number is normalised to the ten digits the row holds before it is +// matched, because that is the only form the console ever stores. Without this +// a cashier certain of their own number types "+91 98765 43210" and is refused +// — the row says "9876543210" and the comparison is exact. +func posLoginIdentity(req models.PosLoginRequest) (string, string, error) { + if name := strings.TrimSpace(req.Authname); name != "" { + return "authname", name, nil + } + + phone, err := normalisePosPhone(req.Contactno) + if err != nil { + // A number that cannot be reduced to ten digits matches no row, so this + // is a rejection rather than a hint about who banks here. + return "", "", errPosLoginRejected + } + if phone == "" { + return "", "", fmt.Errorf("a mobile number is required") + } + return "contactno", phone, nil +} + // sessionFor turns an authenticated account into the session it is entitled to. // // Shared by both ways in — an email and password, or a PIN at an already-open @@ -224,7 +315,8 @@ func (r *posRepository) posLoginCandidates(field, value string, configID int, ti rows := make([]posLoginRow, 0, 2) query := fmt.Sprintf(` - SELECT userid, COALESCE(password, '') AS password, COALESCE(status, '') AS status, + SELECT userid, COALESCE(password, '') AS password, COALESCE(pin, 0) AS pin, + 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, diff --git a/repositories/posLogin_test.go b/repositories/posLogin_test.go new file mode 100644 index 0000000..787f110 --- /dev/null +++ b/repositories/posLogin_test.go @@ -0,0 +1,197 @@ +package repositories + +import ( + "encoding/json" + "strings" + "testing" + + "nearle/models" +) + +// Sign-in at a till is a mobile number and a four-digit PIN. These cover the +// two halves separately — which account, and which secret — because the failure +// that matters is not "a wrong PIN is refused" but "a right one is refused", +// and every way that happens is a shop that cannot open. + +// The rule that decides whether a PIN may be *issued* is not the rule that +// decides whether one may be *typed*. Live data holds 1234 on eleven accounts +// and 1111 on nine; running the creation rule at sign-in would lock all twenty +// out of the terminal this system signed them up to. +func TestAPinTooWeakToIssueStillSignsIn(t *testing.T) { + for _, pin := range []string{"1234", "1111", "9999", "4321"} { + if _, err := validatePosPin(pin); err == nil { + t.Errorf("PIN %q may now be issued; this test is checking the wrong rule", pin) + } + + value, err := posLoginPin(pin) + if err != nil { + t.Errorf("PIN %q was refused at sign-in: %v — that account is locked out", pin, err) + } + if got := strings.TrimSpace(pin); value == 0 { + t.Errorf("PIN %q parsed to 0", got) + } + } +} + +func TestAPinOfferedAtSignInIsFourDigitsTheColumnCanHold(t *testing.T) { + // Empty is a refusal here, unlike at creation where it means "this person + // gets a password instead". An empty PIN reaching the comparison would ask + // the database for `pin = 0`, which is what every account without one holds. + for _, pin := range []string{"", " ", "123", "12345", "abcd", "12a4", "0451"} { + if _, err := posLoginPin(pin); err == nil { + t.Errorf("PIN %q was accepted at sign-in", pin) + } + } + + value, err := posLoginPin(" 4821 ") + if err != nil { + t.Fatalf("a good PIN was refused: %v", err) + } + if value != 4821 { + t.Fatalf("PIN parsed to %d, want 4821", value) + } +} + +// A number is typed by a person, not generated. It has to match the ten digits +// the console stored however they wrote it down. +func TestAMobileNumberIsMatchedInTheFormItIsStored(t *testing.T) { + for _, typed := range []string{"9876543210", "+91 98765 43210", "098765-43210", " 91-9876543210 "} { + field, value, err := posLoginIdentity(models.PosLoginRequest{Contactno: typed, Pin: "4821"}) + if err != nil { + t.Errorf("number %q was refused: %v", typed, err) + continue + } + if field != "contactno" { + t.Errorf("number %q was looked up by %q", typed, field) + } + if value != "9876543210" { + t.Errorf("number %q normalised to %q, want 9876543210", typed, value) + } + } +} + +func TestAnUnusableNumberIsAPlainRejection(t *testing.T) { + // Not "that is not a mobile number" — this endpoint is unauthenticated, and + // a distinct answer for a well-formed number is the first half of a + // directory of who banks here. + for _, typed := range []string{"12345", "98765432101234", "9876543210123"} { + _, _, err := posLoginIdentity(models.PosLoginRequest{Contactno: typed, Pin: "4821"}) + if err != errPosLoginRejected { + t.Errorf("number %q answered %v, want the standard rejection", typed, err) + } + } + + // A field holding no digits at all is not a wrong number, it is an empty + // one — and saying so is more use to somebody who fumbled the keyboard than + // "those sign-in details were not recognised". + for _, typed := range []string{"", " ", "abcdefghij"} { + _, _, err := posLoginIdentity(models.PosLoginRequest{Contactno: typed, Pin: "4821"}) + if err == nil || err == errPosLoginRejected { + t.Errorf("number %q answered %v, want a request error naming the missing field", typed, err) + } + } +} + +// A username still resolves an account, so terminals that have not shipped the +// new screen keep working through the backfill. +func TestAUsernameStillNamesAnAccount(t *testing.T) { + field, value, err := posLoginIdentity(models.PosLoginRequest{ + Authname: " supervisor.1135@pos.nearle.in ", Contactno: "9876543210", + }) + if err != nil { + t.Fatalf("a username was refused: %v", err) + } + if field != "authname" || value != "supervisor.1135@pos.nearle.in" { + t.Fatalf("looked up by %q = %q, want authname", field, value) + } +} + +func TestThePinIsCheckedAgainstTheAccountsOwn(t *testing.T) { + secret, err := newPosLoginSecret(models.PosLoginRequest{Contactno: "9876543210", Pin: "4821"}) + if err != nil { + t.Fatalf("a well-formed PIN was refused: %v", err) + } + if !secret.byPin { + t.Fatal("a request carrying a PIN was read as a password sign-in") + } + + if !secret.matches(posLoginRow{Pin: 4821}) { + t.Error("the right PIN was refused") + } + if secret.matches(posLoginRow{Pin: 4822}) { + t.Error("a wrong PIN was accepted") + } + + // The one that would matter most: an account with no PIN holds 0 in that + // column, and every account on the platform did until this shipped. + if secret.set(posLoginRow{Pin: 0}) { + t.Error("an account with no PIN was treated as having one") + } + if secret.matches(posLoginRow{Pin: 0}) { + t.Error("an account with no PIN was signed in") + } + // A password on the row is not a PIN, and must not stand in for one. + if secret.matches(posLoginRow{Pin: 0, Password: "4821"}) { + t.Error("a password was accepted as a PIN") + } + if !strings.Contains(secret.missing().Error(), "PIN") { + t.Errorf("an account without a PIN was told %q", secret.missing()) + } +} + +func TestAPasswordStillOpensATillWhileNumbersAreBackfilled(t *testing.T) { + secret, err := newPosLoginSecret(models.PosLoginRequest{ + Authname: "supervisor.1135@pos.nearle.in", Password: "xHegDaH55ccWic", + }) + if err != nil { + t.Fatalf("a password sign-in was refused: %v", err) + } + if secret.byPin { + t.Fatal("a request carrying no PIN was read as a PIN sign-in") + } + + if !secret.matches(posLoginRow{Password: "xHegDaH55ccWic"}) { + t.Error("the right password was refused") + } + if secret.matches(posLoginRow{Password: "xHegDaH55ccWid"}) { + t.Error("a wrong password was accepted") + } + if secret.matches(posLoginRow{Password: ""}) { + t.Error("an account with no password was signed in") + } + // A PIN on the row is not a password. Symmetric to the check above, and the + // reason both live in one function: two credentials checked in two places + // is how one of them ends up satisfying the other. + if secret.matches(posLoginRow{Pin: 4821}) { + t.Error("a PIN was accepted as a password") + } +} + +func TestASignInWithNoCredentialAtAllIsRefused(t *testing.T) { + if _, err := newPosLoginSecret(models.PosLoginRequest{Contactno: "9876543210"}); err == nil { + t.Fatal("a sign-in offering neither a PIN nor a password was accepted") + } + + // A malformed PIN answers the same as a wrong one, rather than confirming + // that the number it was sent with exists. + if _, err := newPosLoginSecret(models.PosLoginRequest{Contactno: "9876543210", Pin: "12"}); err != errPosLoginRejected { + t.Errorf("a malformed PIN answered %v, want the standard rejection", err) + } +} + +// The session hands the terminal its outlet's staff so it can trade at once. +// Now that a PIN is half of the sign-in, that list must not carry them: it +// would hand every cashier their supervisor's credentials, and a supervisor +// carries can_manage_staff. +func TestTheStaffListDoesNotCarryPins(t *testing.T) { + body, err := json.Marshal(models.PosSession{ + Staff: []models.PosStaffMember{{Userid: 42, Fullname: "Priya Raman", Role: "Cashier", Pin: "4821"}}, + }) + if err != nil { + t.Fatalf("session did not marshal: %v", err) + } + + if strings.Contains(string(body), "4821") || strings.Contains(string(body), `"pin"`) { + t.Fatalf("the login response carried a staff PIN: %s", body) + } +} diff --git a/repositories/posUserRepository.go b/repositories/posUserRepository.go index 8cfc1d1..a9e4285 100644 --- a/repositories/posUserRepository.go +++ b/repositories/posUserRepository.go @@ -529,14 +529,19 @@ func (r *posRepository) DeactivatePosUser(tenantID, locationID, userID int) erro // supervisor has opened the terminal with a real password first and the guesses // are confined to one outlet's own staff. func (r *posRepository) PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error) { - value, err := validatePosPin(pin) + // posLoginPin, not validatePosPin: the latter also refuses the PINs nobody + // should be *given*, and applying a creation rule on the way in would lock + // out every account issued before it existed. Live data has 1234 on eleven + // accounts and 1111 on nine. + value, err := posLoginPin(pin) if err != nil { return nil, errPosLoginRejected } var rows []posLoginRow err = r.db.Raw(` - SELECT userid, COALESCE(password,'') AS password, COALESCE(status,'') AS status, + SELECT userid, COALESCE(password,'') AS password, COALESCE(pin,0) AS pin, + 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, @@ -635,12 +640,16 @@ func posPhoneTaken(tx *gorm.DB, tenantID int, phone string, exceptUser int) (boo return count > 0, err } -// validatePosPin checks a PIN is one this schema can store faithfully. -func validatePosPin(raw string) (int64, error) { +// posLoginPin reads a PIN somebody has just typed at a terminal. +// +// Format only — four digits the column can hold, and nothing about whether the +// PIN was a wise one to issue. That distinction is the whole reason this is +// separate from [validatePosPin]: a rule about what may be *created* must never +// run on the way *in*. Applied at sign-in, the guessable-PIN list below would +// permanently lock out the eleven live accounts holding 1234 and the nine +// holding 1111 — accounts this system itself issued before the rule existed. +func posLoginPin(raw string) (int64, error) { pin := strings.TrimSpace(raw) - if pin == "" { - return 0, nil - } if len(pin) != 4 { return 0, fmt.Errorf("a PIN is exactly 4 digits") @@ -654,9 +663,24 @@ func validatePosPin(raw string) (int64, error) { // is 4 digits" would be baffling to somebody who just typed four. return 0, fmt.Errorf("a PIN cannot start with 0") } + return value, nil +} + +// validatePosPin checks a PIN is one this schema can store faithfully, and one +// worth issuing. +func validatePosPin(raw string) (int64, error) { + pin := strings.TrimSpace(raw) + if pin == "" { + return 0, nil + } + + value, err := posLoginPin(pin) + if err != nil { + return 0, err + } // The first thing anyone tries, and live data already has 1234 on eleven - // accounts and 1111 on nine. + // accounts and 1111 on nine. Refused at creation only — see posLoginPin. switch pin { case "1234", "1111", "0000", "2345", "3456", "4321", "9999", "2222": return 0, fmt.Errorf("that PIN is too easy to guess; choose another") diff --git a/scratch/posphonepinproof/main.go b/scratch/posphonepinproof/main.go new file mode 100644 index 0000000..a1f46ff --- /dev/null +++ b/scratch/posphonepinproof/main.go @@ -0,0 +1,225 @@ +// Proof that a till signs in with a mobile number and a PIN. +// +// Runs the real repository and service against a real Postgres, because the +// part most likely to be wrong is the SQL, and no amount of unit testing around +// it proves a column name. Seeds a shop, a supervisor, a cashier and a +// back-office account, then works through every answer the endpoint can give. +// +// Throwaway database, created and populated by this program: +// +// docker run -d --rm --name nearle-posproof -e POSTGRES_PASSWORD=proof \ +// -e POSTGRES_DB=proof -p 55432:5432 postgres:16-alpine +// POS_PROOF_DSN='postgres://postgres:proof@localhost:55432/proof?sslmode=disable' \ +// POS_TOKEN_SECRET=proof-secret-at-least-16 go run ./scratch/posphonepinproof +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "nearle/models" + "nearle/repositories" + "nearle/services" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const ( + tenantID = 1087 + locationID = 1135 +) + +func main() { + dsn := strings.TrimSpace(os.Getenv("POS_PROOF_DSN")) + if dsn == "" { + log.Fatal("POS_PROOF_DSN is not set; this never points at production") + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatalf("connect: %v", err) + } + + seed(db) + + repo := repositories.NewPosRepository(db) + svc := services.NewPosService(repo, nil) + + pass, fail := 0, 0 + check := func(name string, ok bool, detail string) { + if ok { + pass++ + fmt.Printf(" PASS %-52s %s\n", name, detail) + return + } + fail++ + fmt.Printf(" FAIL %-52s %s\n", name, detail) + } + + fmt.Println("\nSigning in ------------------------------------------------------") + + // The whole point of the change. + session, err := svc.Login(models.PosLoginRequest{Contactno: "9876543210", Pin: "4821"}) + check("mobile number and PIN", err == nil && session != nil, answer(session, err)) + + // The console stores ten digits. A person types whatever they write down. + for _, typed := range []string{"+91 98765 43210", "098765-43210", " 9876543210 "} { + s, e := svc.Login(models.PosLoginRequest{Contactno: typed, Pin: "4821"}) + check(fmt.Sprintf("the same account typed as %q", typed), e == nil && s != nil, answer(s, e)) + } + + // A cashier gets a cashier's session, not whatever the last person had. + cashier, err := svc.Login(models.PosLoginRequest{Contactno: "9000000002", Pin: "7391"}) + check("a cashier signs in as a cashier", + err == nil && cashier != nil && cashier.Roleid == models.PosRoleCashier && !cashier.Canmanagestaff, + answer(cashier, err)) + + // Live data holds this PIN on eleven accounts. The creation rule refuses to + // issue it; the sign-in rule must still admit it or those eleven are locked + // out of the terminal they were signed up to. + weak, err := svc.Login(models.PosLoginRequest{Contactno: "9000000003", Pin: "1234"}) + check("a PIN too weak to issue still signs in", err == nil && weak != nil, answer(weak, err)) + + fmt.Println("\nBeing refused ---------------------------------------------------") + + _, err = svc.Login(models.PosLoginRequest{Contactno: "9876543210", Pin: "4822"}) + check("a wrong PIN", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) + + _, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"}) + check("a number nobody signs in with", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) + + // Everybody on the platform was in this state until the console started + // asking for a PIN, so the message has to name the fix. + _, err = svc.Login(models.PosLoginRequest{Contactno: "9000000004", Pin: "4821"}) + check("an account with no PIN set", err != nil && !repositories.PosLoginRejected(err), answer(nil, err)) + + // A shop owner typing their back-office details at the till. + _, err = svc.Login(models.PosLoginRequest{Contactno: "9000000005", Pin: "5150"}) + check("a back-office account", err != nil && strings.Contains(err.Error(), "not set up for the till"), answer(nil, err)) + + // A number that cannot be ten digits is answered the same as a wrong one. + _, err = svc.Login(models.PosLoginRequest{Contactno: "12345", Pin: "4821"}) + check("a number that is not a number", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) + + _, err = svc.Login(models.PosLoginRequest{Contactno: "9876543210", Pin: "12"}) + check("a PIN that is not four digits", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) + + // A deactivated cashier keeps their number and PIN and must still be shut out. + _, err = svc.Login(models.PosLoginRequest{Contactno: "9000000006", Pin: "6120"}) + check("somebody who has left", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) + + fmt.Println("\nStill working ---------------------------------------------------") + + // Every account on the platform predates the number it now signs in with. + old, err := svc.Login(models.PosLoginRequest{ + Authname: "supervisor.1135@pos.nearle.in", Password: "xHegDaH55ccWic", + }) + check("username and password, through the backfill", err == nil && old != nil, answer(old, err)) + + // Switching operator at an already-open terminal. + switched, err := svc.LoginWithPin(tenantID, locationID, "7391") + check("PIN switch at an open terminal", + err == nil && switched != nil && switched.Roleid == models.PosRoleCashier, answer(switched, err)) + + fmt.Println("\nThe response ----------------------------------------------------") + + body, _ := json.Marshal(session) + check("no staff PIN reaches the wire", + !strings.Contains(string(body), `"pin"`) && !strings.Contains(string(body), "7391"), + fmt.Sprintf("%d staff in the session", len(session.Staff))) + check("the terminal still gets its people", len(session.Staff) > 0, + fmt.Sprintf("%v", staffNames(session.Staff))) + check("a token was minted", session.Token != "" && session.Expiresat != "", + "expires "+session.Expiresat) + + pretty, _ := json.MarshalIndent(session, "", " ") + fmt.Printf("\nPOST /pos/login {\"contactno\":\"9876543210\",\"pin\":\"4821\"}\n\n%s\n", pretty) + + fmt.Printf("\n%d passed, %d failed\n", pass, fail) + if fail > 0 { + os.Exit(1) + } +} + +func answer(session *models.PosSession, err error) string { + if err != nil { + return "→ " + err.Error() + } + if session == nil { + return "→ no session and no error" + } + return fmt.Sprintf("→ %s (%s) at %s", session.Fullname, session.Role, session.Locationname) +} + +func staffNames(staff []models.PosStaffMember) []string { + names := make([]string, 0, len(staff)) + for _, s := range staff { + names = append(names, s.Fullname) + } + return names +} + +// seed builds the smallest shop the login path can read: the columns these +// queries actually name, and nothing else. +func seed(db *gorm.DB) { + statements := []string{ + `DROP TABLE IF EXISTS app_users, app_roles, tenants, tenantlocations, tenantstaffs`, + + `CREATE TABLE app_roles (roleid int PRIMARY KEY, rolename text)`, + `CREATE TABLE tenants ( + tenantid int PRIMARY KEY, tenantname text, registrationno text, + primarycontact text, address text)`, + `CREATE TABLE tenantlocations ( + locationid int PRIMARY KEY, tenantid int, locationname text, + address text, city text, status text)`, + `CREATE TABLE tenantstaffs (userid int, tenantid int, locationid int, status text)`, + `CREATE TABLE app_users ( + userid int PRIMARY KEY, authname text, contactno text, password text, + pin bigint, status text, roleid int, configid int, tenantid int, + locationid int, firstname text, lastname text, email text)`, + + fmt.Sprintf(`INSERT INTO app_roles VALUES (%d,'Supervisor'), (%d,'Cashier'), (3,'Admin')`, + models.PosRoleSupervisor, models.PosRoleCashier), + + `INSERT INTO tenants VALUES (1087,'R Mart','33AABCU9603R1ZM','04422334455','12 Mount Road, Chennai')`, + `INSERT INTO tenantlocations VALUES (1135,1087,'Selvapuram','4 Trichy Road','Coimbatore','Active')`, + } + + // One shop, six people, each standing for one answer the endpoint gives. + people := []struct { + id int + authname, contactno string + password string + pin int64 + role int + status string + first, last string + }{ + {4001, "supervisor.1135@pos.nearle.in", "9876543210", "xHegDaH55ccWic", 4821, models.PosRoleSupervisor, "Active", "Meena", "Sundaram"}, + {4002, "cashier.1135@pos.nearle.in", "9000000002", "", 7391, models.PosRoleCashier, "Active", "Priya", "Raman"}, + {4003, "cashier2.1135@pos.nearle.in", "9000000003", "", 1234, models.PosRoleCashier, "Active", "Karthik", "Velu"}, + {4004, "cashier3.1135@pos.nearle.in", "9000000004", "", 0, models.PosRoleCashier, "Active", "Anitha", "Ravi"}, + {4005, "owner@rmart.example", "9000000005", "", 5150, 3, "Active", "Suresh", "Kumar"}, + {4006, "cashier4.1135@pos.nearle.in", "9000000006", "", 6120, models.PosRoleCashier, "InActive", "Divya", "R"}, + } + for _, p := range people { + statements = append(statements, fmt.Sprintf( + `INSERT INTO app_users VALUES (%d,'%s','%s','%s',%d,'%s',%d,1,%d,%d,'%s','%s','%s@example.com')`, + p.id, p.authname, p.contactno, p.password, p.pin, p.status, p.role, + tenantID, locationID, p.first, p.last, strings.ToLower(p.first))) + } + + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + log.Fatalf("seed: %v\n%s", err, statement) + } + } + fmt.Printf("Seeded %d till accounts at outlet %d.\n", len(people), locationID) +}