pos login with the ph number and pin
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user