package services import ( "errors" "fmt" "log" "nearle/models" "nearle/repositories" "strings" "github.com/gofiber/fiber" ) // errLoginUnavailable is returned by lookupLogin when the database could not // answer the sign-in query. It is deliberately not "invalid user": the account // may well exist, and the person needs to be told to try again, not to check // their spelling. var errLoginUnavailable = errors.New("login lookup failed") // loginUnavailableResponse is the body for that case. 500 rather than 409, // because the console reads `409` as "this email does not exist" (see // daily_merchant_web/src/services/auth.ts) and would otherwise send a // perfectly good account to the sign-up form while the database was down. func loginUnavailableResponse() map[string]interface{} { return map[string]interface{}{ "status": false, "code": 500, "message": "Login is temporarily unavailable. Please try again in a moment.", } } // lookupLogin resolves who is signing in, by authname first and contact number // second, and separates "not found" from "could not look". // // Both login paths used to run their own copy of this and both discarded the // repository's error, so a database that was down came back as uid 0 and was // reported as "Invalid Email". That message now means exactly one thing: the // query ran and matched nobody. func (s *userService) lookupLogin(user models.User) (uid int, password, status string, roleid int, err error) { field, value := "authname", user.Authname if user.Authname == "" { field, value = "contactno", user.Contactno } uid, password, status, roleid, err = s.repo.GetUserLogin(field, value, user.Configid) if err != nil { // The value is what the caller typed — an email or a phone number — // and is safe to log; the password never reaches this function's // output. log.Printf("login: lookup by %s=%q configid=%d failed: %v", field, value, user.Configid, err) return 0, "", "", 0, errLoginUnavailable } return uid, password, status, roleid, nil } type UserService interface { GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) GetUserByID(uid int) (models.UserInfo, error) Login(user models.User) (models.UserInfo, error) TenantLogin(user models.User) (models.TenantUserInfo, error) UpdateStaff(user models.User) error // SetInitialPassword is the one write reachable without a session — see // the repository for what makes that safe. SetInitialPassword(userid int, password string) error AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error) // Creates a back-office account and emails its first-password invitation. // // The outcome travels beside the user rather than as an error: the person is // hired either way, and whether the mail left is something the console shows // so somebody can resend it. CreateUser(user models.User) (models.UserInfo, InviteOutcome, error) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) DeleteUser(userid int) error } type userService struct { repo repositories.UserRepository // May be nil, like the tenant service's. A deployment with no mail still // creates accounts; the outcome names the missing variable. invites InviteService } func NewUserService(repo repositories.UserRepository, invites InviteService) UserService { return &userService{repo: repo, invites: invites} } func (s *userService) GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) { return s.repo.GetAllUsers(roleID, tenantID, pageno, pagesize, keyword) } func (s *userService) GetUserByID(uid int) (models.UserInfo, error) { return s.repo.GetUserByID(uid) } func (s *userService) Login(user models.User) (models.UserInfo, error) { return s.repo.Login(user) } func (s *userService) TenantLogin(user models.User) (models.TenantUserInfo, error) { uid, err := s.repo.FindUserID(user.Authname, user.Contactno, user.Configid) if err != nil { return models.TenantUserInfo{}, err } if uid == 0 { return models.TenantUserInfo{}, errors.New("user not found") } // `GetTenantUserById`, NOT `GetTenantUserByID`. // // The two differ by one letter and by twenty-eight columns. The capital-ID // one selects five — userid, authname, contactno, tenantid, tenantname — // so this function used to answer with a record whose name, branch, region // and coordinates were all blank. That is why routing // `/mob/users/tenant/login` at it was never as simple as swapping the // handler: the app would have received a mostly-empty object. // // The lowercase-d one is the query `AppLogin` and `TenantWebLogin` already // use, and it fills the whole record. It is now the only one anything calls. // // The FCM token is stored here rather than left to the repository, because // the full read does not write. Only a real token is stored: a login that // omits it must not wipe the device already registered, which is exactly // what "userfcmtoken": "your_fcm_token_here" did to a live account during // this investigation. if strings.TrimSpace(user.Userfcmtoken) != "" { _ = s.repo.UpdateUserFcmToken(uid, user.Userfcmtoken) } return s.repo.GetTenantUserById(uid), nil } func (s *userService) UpdateStaff(user models.User) error { return s.repo.UpdateStaff(user) } func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error) { if user.Authname == "" && user.Contactno == "" { resp := fiber.Map{ "code": 400, "status": false, "message": "authname or contactno required", } return models.TenantUserInfo{}, resp, errors.New("missing authname or contactno") } uid, dbPassword, status, _, err := s.lookupLogin(user) if err != nil { return models.TenantUserInfo{}, fiber.Map(loginUnavailableResponse()), err } // Nobody matched. This is the only way to reach "Invalid Email" now. if uid == 0 { resp := fiber.Map{ "status": false, "code": 409, "message": "Invalid Email", "tenantform": true, } return models.TenantUserInfo{}, resp, errors.New("invalid user") } // Inactive account if strings.EqualFold(status, "InActive") { resp := fiber.Map{ "status": false, "code": 403, "message": "Inactive Account. Contact admin.", } return models.TenantUserInfo{}, resp, errors.New("inactive account") } // No password set. // // ── The userid used to be in here, and that was the whole exploit ─────── // // This branch is reached by a POST carrying an email and NO password, so // anyone could ask it about any account. It answered with the userid, and // `setpassword` then took a bare userid — so the recipe was: read a // merchant's primary email off their shopfront, POST it here, receive their // userid, set their password, own the business's admin account. No guessing // at any step. // // `setpassword` now requires a signed invitation, so the userid alone is no // longer a way in. It is still removed, because handing it out told an // unauthenticated caller which businesses exist and which have never been // set up — a list worth having if you are the one sending the phishing // email that arrives before the real invitation does. // // The message is kept deliberately vague for the same reason. "Please set // up a password" invited the caller to do exactly that; this says where the // link comes from instead, which is true for the person who belongs here // and useless to anyone else. if strings.TrimSpace(dbPassword) == "" { resp := fiber.Map{ "status": true, "code": 409, "message": "This account has not been set up yet. Use the invitation link that was emailed to you.", "tenantform": true, "details": fiber.Map{ "setup": true, }, } return models.TenantUserInfo{}, resp, nil } // Empty request password if strings.TrimSpace(user.Password) == "" { resp := fiber.Map{ "status": true, "code": 401, "message": "Password is required", "tenantform": true, } return models.TenantUserInfo{}, resp, nil } // Incorrect password if user.Password != dbPassword { resp := fiber.Map{ "status": false, "code": 401, "message": "Incorrect password", "tenantform": true, } return models.TenantUserInfo{}, resp, errors.New("incorrect password") } // Update FCM token if user.Userfcmtoken != "" { _ = s.repo.UpdateFCMToken(uid, user.Userfcmtoken) } // ✅ Fetch tenant user info info := s.repo.GetTenantUserById(uid) // ✅ Check if assigned store is inactive if info.Locationid > 0 { storeStatus := s.repo.GetLocationStatus(info.Locationid) if strings.EqualFold(storeStatus, "InActive") { resp := fiber.Map{ "status": false, "code": 403, "message": "Assigned store is inactive. Contact admin.", } return models.TenantUserInfo{}, resp, errors.New("inactive store") } } // ✅ Return success response resp := fiber.Map{ "status": true, "code": 200, "message": "Login successful", "details": info, } return info, resp, nil } func (s *userService) CreateUser(user models.User) (models.UserInfo, InviteOutcome, error) { // Without an authname and a console configid the account is created, // listed, and then refused at the login screen: `weblogin` matches // `WHERE authname = ? AND configid = ?` and never looks at the email // column. See PrepareNewAccount. user = PrepareNewAccount(user) // Call repository to create user userid, err := s.repo.CreateUser(user) if err != nil { return models.UserInfo{}, InviteOutcome{}, err } // Get user info by id info, err := s.repo.GetUserById(userid) if err != nil { return models.UserInfo{}, InviteOutcome{}, err } // The invitation, after the write and outside it. // // This account is created with NO password — nothing on this path sets one — // and since the sign-in screen stopped offering to set a first password, the // emailed link is the only way in. Without this the person is added to the // directory, appears in every branch picker, and cannot sign in, with nothing // anywhere to say why. // // `info.Userid` rather than `userid`: identical, but this is the row that was // actually read back, so an invitation is never addressed to an id the // database did not confirm. return info, s.inviteNewAccount(info, user), nil } // inviteNewAccount emails the person who was just hired. // // Never an error. A failure is the operator's task — resend, or fix the address — // and not a reason to unwind a hire that has already happened. func (s *userService) inviteNewAccount(info models.UserInfo, user models.User) InviteOutcome { if s.invites == nil { return InviteOutcome{Reason: "invitations are not configured on this server"} } if info.Userid <= 0 { return InviteOutcome{Reason: "the new account could not be read back to invite it"} } // The authname IS the email on a back-office account — `PrepareNewAccount` // copies one to the other — so this is a fallback for a caller that filled in // only one of the two, never a second address. address := strings.TrimSpace(user.Email) if address == "" { address = strings.TrimSpace(user.Authname) } // Empty business name: the invite service reads it from the tenantid. A staff // row carries the id and nothing else about the business. sent, reason := s.invites.Invite(info.Userid, user.Tenantid, address, "") return InviteOutcome{Sent: sent, Reason: reason} } func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) { tenantFormExists := true // Step 1: Login by authname or contactno if user.Authname == "" && user.Contactno == "" { return models.TenantUserInfo{}, map[string]interface{}{ "status": true, "code": 400, "message": "authname or contactno required", } } uid, dbPassword, status, roleid, err := s.lookupLogin(user) if err != nil { return models.TenantUserInfo{}, loginUnavailableResponse() } // Step 2: Validate user. Nobody matched — the only way to reach // "Invalid Email" now; a database that could not answer is a 500 above. if uid == 0 { return models.TenantUserInfo{}, map[string]interface{}{ "status": false, "code": 409, "message": "Invalid Email", "tenantform": tenantFormExists, } } if strings.EqualFold(status, "InActive") { return models.TenantUserInfo{}, map[string]interface{}{ "status": false, "code": 403, "message": "Inactive Account. Contact admin.", } } if user.Roleid != roleid { return models.TenantUserInfo{}, map[string]interface{}{ "status": false, "code": 403, "message": "Unauthorized email.", } } // Step 3: Password checks. // // The userid is withheld here for the same reason as in `AppLogin` above: // this branch answers an unauthenticated caller asking about an email, and // the userid was half of an account takeover. See the long note there. if strings.TrimSpace(dbPassword) == "" { return models.TenantUserInfo{}, map[string]interface{}{ "status": true, "code": 409, "message": "This account has not been set up yet. Use the invitation link that was emailed to you.", "tenantform": tenantFormExists, "details": map[string]interface{}{ "setup": true, }, } } if strings.TrimSpace(user.Password) == "" { return models.TenantUserInfo{}, map[string]interface{}{ "status": true, "code": 401, "message": "Password is required", "tenantform": tenantFormExists, } } if user.Password != dbPassword { return models.TenantUserInfo{}, map[string]interface{}{ "status": false, "code": 401, "message": "Incorrect password", "tenantform": tenantFormExists, } } // Step 4: Update FCM if provided if user.Userfcmtoken != "" { _ = s.repo.UpdateUserFcmToken(uid, user.Userfcmtoken) } // Step 5: Get full tenant info info := s.repo.GetTenantUserById(uid) // Step 6: Check if assigned store is inactive if info.Locationid > 0 { storeStatus := s.repo.GetLocationStatus(info.Locationid) if strings.EqualFold(storeStatus, "InActive") { return models.TenantUserInfo{}, map[string]interface{}{ "status": false, "code": 403, "message": "Assigned store is inactive. Contact admin.", } } } return info, map[string]interface{}{ "status": true, "code": 200, "message": "Login successful", } } func (s *userService) DeleteUser(userid int) error { return s.repo.DeleteUser(userid) } // SetInitialPassword gives a never-used account its first password. // // The minimum length is enforced here as well as at the edge: this is the only // write in the product reachable without a session, so the rule cannot live // only in a handler that a second caller might not go through. func (s *userService) SetInitialPassword(userid int, password string) error { if userid <= 0 { return errors.New("which account?") } password = strings.TrimSpace(password) if len(password) < MinPasswordLength { return fmt.Errorf("the password must be at least %d characters", MinPasswordLength) } return s.repo.SetInitialPassword(userid, password) } // MinPasswordLength is the floor for a console password. // // Six, matching the check `UpdateStaff` already applied at the controller — not // a new rule, the same one stated where both callers can reach it. const MinPasswordLength = 6