555 lines
21 KiB
Go
555 lines
21 KiB
Go
package repositories
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"nearle/models"
|
|
)
|
|
|
|
// Sign-in for the POS terminal.
|
|
//
|
|
// Deliberately reads the same `app_users` rows the web console authenticates
|
|
// against rather than introducing a terminal-specific credential table. A shop
|
|
// manager who can sign into the back office should be able to open the till
|
|
// with the same details, and one account store means deactivating a leaver
|
|
// closes both doors at once instead of one and a half.
|
|
//
|
|
// Kept in its own file because the rest of posRepository is about moving bills
|
|
// and stock, and mixing authorisation into that made the one thing nobody
|
|
// should have to hunt for the hardest thing to find.
|
|
|
|
// posLoginRow is the credential check's raw answer.
|
|
type posLoginRow struct {
|
|
Userid int
|
|
Password string
|
|
Pin int64
|
|
Status string
|
|
Roleid int
|
|
Configid int
|
|
Tenantid int
|
|
Locationid int
|
|
Firstname string
|
|
Lastname string
|
|
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, err := posLoginIdentity(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
secret, err := newPosLoginSecret(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := r.posLoginCandidates(field, value, req.Configid, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// One message for "no such account" and for "wrong password", on purpose.
|
|
// Distinguishing them turns the login into a directory of who banks here.
|
|
if len(rows) == 0 {
|
|
// Nothing eligible under that credential. Before answering with the
|
|
// deliberately vague rejection, look again without the role filter — a
|
|
// back-office account typing its own password at a till deserves to be
|
|
// told that is the problem, rather than sent hunting for a password
|
|
// that was never wrong.
|
|
//
|
|
// Only ever reached after that account's own password verifies, so it
|
|
// 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 && secret.matches(others[0]) {
|
|
return nil, errPosRoleIneligible
|
|
}
|
|
return nil, errPosLoginRejected
|
|
}
|
|
|
|
// `authname` is not unique in this schema — live data has the same address
|
|
// twice under one configid — so more than one row can come back. Resolving
|
|
// that by taking the first would let the account a person *meant* be
|
|
// shadowed by a stranger's, and on a POS that means billing into the wrong
|
|
// tenant's books. Refused instead, with the fix the caller can act on.
|
|
if len(rows) > 1 {
|
|
return nil, fmt.Errorf(
|
|
"more than one account uses these sign-in details; ask your administrator for the configid and send it with the login")
|
|
}
|
|
|
|
// Inactive accounts never reach here — posLoginCandidates excludes them, so
|
|
// that a deactivated duplicate cannot make a live login ambiguous.
|
|
row := rows[0]
|
|
|
|
// 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 !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
|
|
// terminal. Extracted rather than duplicated because everything after the
|
|
// credential check is authorisation, and two copies of an authorisation rule
|
|
// is one copy too many.
|
|
//
|
|
// [requestedLocation] is optional and only means anything for an account that
|
|
// reaches more than one outlet. It is checked against that set, never trusted
|
|
// on its own.
|
|
func (r *posRepository) sessionFor(row posLoginRow, requestedLocation int) (*models.PosSession, error) {
|
|
// The till is not the back office, and one account is never both. An
|
|
// account reaches a terminal only by having been provisioned for one —
|
|
// Supervisor or Cashier, created from the console — and never by carrying a
|
|
// Nearle Daily role that happens to sound senior.
|
|
//
|
|
// Checked here rather than in PosLogin so that the PIN route is covered by
|
|
// the same line. Both ways in build their session through this function, and
|
|
// a gate on only one of them would be a gate on neither.
|
|
if !models.PosRoleEligible(row.Roleid) {
|
|
return nil, errPosRoleIneligible
|
|
}
|
|
|
|
if row.Tenantid <= 0 {
|
|
return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till")
|
|
}
|
|
|
|
locations, err := r.posLoginLocations(row.Tenantid, row.Locationid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(locations) == 0 {
|
|
return nil, fmt.Errorf("no active outlet is registered for this account")
|
|
}
|
|
|
|
// Which outlet this terminal is standing in. A request may ask for one, but
|
|
// only from the set the account already reaches.
|
|
chosen := locations[0]
|
|
if requestedLocation > 0 {
|
|
match := false
|
|
for _, loc := range locations {
|
|
if loc.Locationid == requestedLocation {
|
|
chosen, match = loc, true
|
|
break
|
|
}
|
|
}
|
|
if !match {
|
|
return nil, fmt.Errorf("this account cannot open a till at outlet %d", requestedLocation)
|
|
}
|
|
}
|
|
|
|
session := &models.PosSession{
|
|
Userid: row.Userid,
|
|
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
|
|
Email: row.Email,
|
|
Roleid: row.Roleid,
|
|
Role: posRoleLabel(row.Roleid),
|
|
Configid: row.Configid,
|
|
Canmanagestaff: models.PosRoleCanManageStaff(row.Roleid),
|
|
Tenantid: row.Tenantid,
|
|
Storeid: fmt.Sprintf("%d", chosen.Locationid),
|
|
Locationid: chosen.Locationid,
|
|
Locationname: chosen.Locationname,
|
|
Address: chosen.Address,
|
|
Locations: locations,
|
|
}
|
|
|
|
r.decoratePosSession(session)
|
|
|
|
// Staff come down with the session so a till is ready to trade the moment
|
|
// it signs in. A failure here is not a failed sign-in: a shop with no staff
|
|
// recorded — which is almost all of them today — must still be able to open
|
|
// its terminal.
|
|
if staff, err := r.PosStaff(session.Tenantid, session.Locationid); err == nil {
|
|
session.Staff = staff
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// posRoleLabel names a role for the terminal.
|
|
//
|
|
// Prefers the two POS roles this codebase defines, then falls back to whatever
|
|
// `app_roles` calls it — which is blank for a great many accounts, because most
|
|
// carry a roleid that is not in that table at all.
|
|
func posRoleLabel(roleID int) string {
|
|
if name := models.PosRoleName(roleID); name != "" {
|
|
return name
|
|
}
|
|
switch roleID {
|
|
case 1:
|
|
return "Super admin"
|
|
case 2:
|
|
return "Operations"
|
|
case 3, 5:
|
|
return "Admin"
|
|
case 4, 6:
|
|
return "Manager"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// posLoginCandidates finds the accounts matching a set of sign-in details.
|
|
//
|
|
// Returns a list rather than a row because `app_users` does not constrain
|
|
// `authname` to be unique — not globally and not per configid. The caller
|
|
// decides what an ambiguous match means; silently picking one here would bury
|
|
// the decision in a LIMIT 1.
|
|
//
|
|
// The configid handling is the part worth explaining. The web console asks for
|
|
// it because the browser knows which tenant portal it is on. A till does not:
|
|
// somebody is standing at a counter typing an email and a password, and
|
|
// demanding a number they have never seen would make the login unusable. So it
|
|
// is honoured when sent and inferred when not — and inference that finds more
|
|
// than one candidate is reported, never guessed.
|
|
func (r *posRepository) posLoginCandidates(field, value string, configID int, tillOnly bool) ([]posLoginRow, error) {
|
|
rows := make([]posLoginRow, 0, 2)
|
|
|
|
query := fmt.Sprintf(`
|
|
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,
|
|
COALESCE(email, '') AS email
|
|
FROM app_users
|
|
WHERE LOWER(TRIM(%s)) = LOWER(TRIM(?))`, field)
|
|
params := []interface{}{value}
|
|
|
|
if configID > 0 {
|
|
query += ` AND configid = ?`
|
|
params = append(params, configID)
|
|
}
|
|
|
|
// Only till accounts are candidates.
|
|
//
|
|
// This matters most for signing in by phone. `contactno` is not unique in
|
|
// this schema — 34 numbers are shared by 104 active accounts, one of them
|
|
// by eleven — and the caller refuses any lookup returning more than one
|
|
// row, because choosing between them could bill into the wrong tenant's
|
|
// books. Without this clause a cashier whose number also sits on a tenant
|
|
// admin's record simply cannot log in.
|
|
//
|
|
// Narrowing here means a till phone number only has to be unique among
|
|
// till accounts, not across all 608 users. An eligible-but-wrong-role
|
|
// account still gets the specific errPosRoleIneligible answer, because the
|
|
// caller checks the role again after the password verifies — this clause
|
|
// removes ambiguity, it does not replace that check.
|
|
if tillOnly {
|
|
query += fmt.Sprintf(` AND COALESCE(roleid, 0) IN (%d, %d)`,
|
|
models.PosRoleSupervisor, models.PosRoleCashier)
|
|
}
|
|
|
|
// Inactive accounts are excluded from the match rather than matched and
|
|
// then refused. A deactivated duplicate would otherwise make a working
|
|
// login ambiguous, which turns "this person left" into "nobody can open
|
|
// the till".
|
|
query += ` AND LOWER(COALESCE(status, 'active')) <> 'inactive' ORDER BY userid`
|
|
|
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// posLoginLocations lists the outlets an account may open a till at.
|
|
//
|
|
// A user pinned to one location gets that one alone; a tenant-level account
|
|
// with locationid 0 — a proprietor with several shops — gets all of the
|
|
// tenant's active outlets and picks at sign-in.
|
|
//
|
|
// Inactive outlets are excluded rather than listed and disabled: a till cannot
|
|
// usefully trade at a closed shop, and offering it is an invitation to a
|
|
// support call.
|
|
func (r *posRepository) posLoginLocations(tenantID, pinned int) ([]models.PosLoginLocation, error) {
|
|
rows := make([]models.PosLoginLocation, 0)
|
|
|
|
query := `
|
|
SELECT locationid,
|
|
COALESCE(locationname, '') AS locationname,
|
|
COALESCE(address, '') AS address,
|
|
COALESCE(city, '') AS city,
|
|
COALESCE(status, '') AS status
|
|
FROM tenantlocations
|
|
WHERE tenantid = ? AND LOWER(COALESCE(status, 'active')) <> 'inactive'`
|
|
params := []interface{}{tenantID}
|
|
|
|
if pinned > 0 {
|
|
query += ` AND locationid = ?`
|
|
params = append(params, pinned)
|
|
}
|
|
query += ` ORDER BY locationid`
|
|
|
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// decoratePosSession fills in what a receipt needs.
|
|
//
|
|
// The store name, GSTIN and address printed on a bill are a legal requirement
|
|
// on a GST invoice, and the till had them as compile-time constants. Sending
|
|
// them down with the session means a shop that corrects its GSTIN in the back
|
|
// office sees the correction on its next receipt rather than at the next
|
|
// rebuild.
|
|
//
|
|
// Failures here are swallowed: a missing tenant name is a cosmetic problem, and
|
|
// refusing a sign-in over it would close a shop.
|
|
func (r *posRepository) decoratePosSession(session *models.PosSession) {
|
|
var tenant struct {
|
|
Tenantname string
|
|
Gstin string
|
|
Contactno string
|
|
Address string
|
|
}
|
|
|
|
// `registrationno` is where this schema keeps the GST number — there is no
|
|
// `gstin` column. Aliased rather than renamed through the stack so the till
|
|
// receives it under the name it prints on a receipt.
|
|
err := r.db.Raw(`
|
|
SELECT COALESCE(tenantname, '') AS tenantname,
|
|
COALESCE(registrationno, '') AS gstin,
|
|
COALESCE(primarycontact, '') AS contactno,
|
|
COALESCE(address, '') AS address
|
|
FROM tenants WHERE tenantid = ? LIMIT 1`, session.Tenantid).Scan(&tenant).Error
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
session.Tenantname = tenant.Tenantname
|
|
session.Gstin = tenant.Gstin
|
|
session.Phone = tenant.Contactno
|
|
|
|
// The outlet's own address wins — a chain's receipts must name the shop the
|
|
// customer is standing in, not head office. The tenant address is only a
|
|
// fallback for an outlet that has none recorded.
|
|
if strings.TrimSpace(session.Address) == "" {
|
|
session.Address = tenant.Address
|
|
}
|
|
}
|
|
|
|
// PosLocationAllowed reports whether a tenant owns an outlet.
|
|
//
|
|
// The check the whole session model rests on. Everything a terminal asks for
|
|
// names a location, and this is what stops a valid token for one shop being
|
|
// replayed against another.
|
|
func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, error) {
|
|
if tenantID <= 0 || locationID <= 0 {
|
|
return false, nil
|
|
}
|
|
|
|
var count int64
|
|
err := r.db.Raw(
|
|
`SELECT COUNT(1) FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
|
tenantID, locationID,
|
|
).Scan(&count).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// errPosLoginRejected is the single answer to a bad email and a bad password.
|
|
var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised")
|
|
|
|
// errPosRoleIneligible is the answer to a correct credential on an account that
|
|
// is not a till account.
|
|
//
|
|
// Deliberately specific, where a bad password is deliberately vague. By the
|
|
// time this fires the caller has already proved the credential, so naming the
|
|
// reason leaks nothing they did not just demonstrate — and the vague answer
|
|
// would send a shop owner hunting for a password that was never wrong. It
|
|
// names the fix, because the fix is somebody else's screen.
|
|
var errPosRoleIneligible = fmt.Errorf(
|
|
"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")
|
|
|
|
// PosLoginRejected reports whether an error is a failed credential check, so
|
|
// the controller can answer 401 for those and 500 for a database fault without
|
|
// matching on message text.
|
|
func PosLoginRejected(err error) bool { return err == errPosLoginRejected }
|
|
|
|
// constantTimeEqual compares two secrets without leaking their contents through
|
|
// how long it took.
|
|
//
|
|
// Length is compared first and is deliberately allowed to leak — a password's
|
|
// length is not the secret, and hashing to a fixed width just to hide it would
|
|
// be more machinery than the exposure justifies.
|
|
func constantTimeEqual(a, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
var diff byte
|
|
for i := 0; i < len(a); i++ {
|
|
diff |= a[i] ^ b[i]
|
|
}
|
|
return diff == 0
|
|
}
|
|
|
|
// PosStaff lists the people who may ring a bill at an outlet.
|
|
//
|
|
// Two sources, unioned, because the schema has two and neither is complete.
|
|
// `tenantstaffs` is the table built for this and holds 12 rows on the entire
|
|
// platform; `app_users.locationid` is where staff actually ended up. Reading
|
|
// only the purpose-built table would return nothing for almost every shop, and
|
|
// reading only `app_users` would miss anyone assigned through the back office's
|
|
// staff screen. So both.
|
|
//
|
|
// Only people with a PIN come back. A row with `pin = 0` cannot ring anything —
|
|
// offering it to the till would put a name on screen that no one can sign in
|
|
// as, which reads as a broken terminal rather than as an unfinished setup.
|
|
func (r *posRepository) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) {
|
|
rows := make([]models.PosStaffMember, 0)
|
|
|
|
query := `
|
|
SELECT DISTINCT
|
|
a.userid,
|
|
TRIM(CONCAT(COALESCE(a.firstname,''), ' ', COALESCE(a.lastname,''))) AS fullname,
|
|
COALESCE(r.rolename, '') AS role,
|
|
CAST(a.pin AS TEXT) AS pin,
|
|
COALESCE(a.status, '') AS status
|
|
FROM app_users a
|
|
LEFT JOIN app_roles r ON r.roleid = a.roleid
|
|
WHERE a.tenantid = ?
|
|
AND COALESCE(a.pin, 0) > 0
|
|
AND LOWER(COALESCE(a.status, 'active')) <> 'inactive'
|
|
AND (
|
|
a.locationid = ?
|
|
OR EXISTS (SELECT 1 FROM tenantstaffs s
|
|
WHERE s.userid = a.userid
|
|
AND s.tenantid = a.tenantid
|
|
AND s.locationid = ?
|
|
AND LOWER(COALESCE(s.status, 'active')) <> 'inactive')
|
|
)
|
|
ORDER BY fullname`
|
|
|
|
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// A PIN shared by two people at one outlet would make the till attribute a
|
|
// bill to whichever row it happened to check first — so the second one is
|
|
// dropped rather than sent. Live data has 1234 on eleven accounts and 1111
|
|
// on nine, so this is not hypothetical.
|
|
seen := make(map[string]bool, len(rows))
|
|
unique := rows[:0]
|
|
for _, row := range rows {
|
|
if seen[row.Pin] {
|
|
continue
|
|
}
|
|
seen[row.Pin] = true
|
|
unique = append(unique, row)
|
|
}
|
|
|
|
return unique, nil
|
|
}
|