pos login with the ph number and pin
This commit is contained in:
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
311
docs/POS_PHONE_PIN_LOGIN_HANDOVER.md
Normal file
311
docs/POS_PHONE_PIN_LOGIN_HANDOVER.md
Normal 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` |
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
197
repositories/posLogin_test.go
Normal file
197
repositories/posLogin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
225
scratch/posphonepinproof/main.go
Normal file
225
scratch/posphonepinproof/main.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user