pos login with the ph number and pin

This commit is contained in:
2026-08-12 17:10:27 +05:30
parent ff72af9a8a
commit d566ca5591
9 changed files with 1007 additions and 91 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`
// 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"`
Password string `json:"password"`
// 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"`
// 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.
Pin string `json:"pin"`
// 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"`
}

View File

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

View File

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

View File

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

View File

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