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,
|
||||
|
||||
Reference in New Issue
Block a user