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,