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

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