From f1dbf7edc9e4d3c4f03bfe909002f1c850bde10c Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Mon, 21 Sep 2026 16:25:03 +0530 Subject: [PATCH] feat: interim customer PIN auth (login/set-pin/verify-pin), mirrors miler flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No SMS/OTP gateway is live yet, so customers sign in with a self-set PIN like milers do. The OTP endpoints stay in place — the app switches back once a gateway is plugged in. - POST /customer/auth/login {phone} -> {registered, pin_set, name}: routes the app to register / set-PIN / enter-PIN. - POST /customer/auth/set-pin {phone, new_pin, name?}: first-time PIN. Creates the account (name required) or sets the first PIN on an account with none; refuses to overwrite an existing PIN (409); logs in on success. - POST /customer/auth/verify-pin {phone, pin}: returning login; same generic message for unknown phone and wrong PIN so it can't enumerate accounts. All three reuse issueCxSession (access + refresh + customer) and the /customer Cx* response envelope. Build + vet clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD --- controllers/cxAuthController.go | 156 ++++++++++++++++++++++++++++++++ routes/routes.go | 8 ++ utils/response_cx.go | 3 + 3 files changed, 167 insertions(+) diff --git a/controllers/cxAuthController.go b/controllers/cxAuthController.go index 83c6763..10a763f 100644 --- a/controllers/cxAuthController.go +++ b/controllers/cxAuthController.go @@ -362,6 +362,162 @@ func CxSignup(cfg *config.Config) fiber.Handler { } } +// ── PIN auth (interim — until the OTP/SMS gateway is live) ─────────────────── +// +// Mirrors the miler login flow (LoginMiler / SetMilerPin / VerifyMilerPin) for +// customers: a first-time customer sets their own PIN, a returning one enters +// it. This exists because no SMS/OTP provider is plugged in yet (see +// internal/sms). The OTP endpoints above stay in place — the app can switch back +// to them the moment a gateway is live. + +// validCxPin accepts exactly a 4-digit PIN, matching the app's keypad. +func validCxPin(pin string) bool { + pin = strings.TrimSpace(pin) + if len(pin) != cxOtpLength { + return false + } + for _, r := range pin { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// CxPinLogin resolves a phone number to the screen the app shows next: register +// (no account), set-PIN (account, no PIN yet) or enter-PIN (account with a PIN). +func CxPinLogin(c *fiber.Ctx) error { + var req struct { + Phone string `json:"phone"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + phone, _, ok := normalizePhone(req.Phone) + if !ok { + return utils.CxBadRequest(c, "Enter a valid phone number") + } + + var customer models.AppCustomer + if db.DB.Where("phone = ?", phone).First(&customer).Error != nil { + // No account yet — the app collects a name + a new PIN on the next screen. + return utils.CxOK(c, fiber.Map{"phone": phone, "registered": false, "pin_set": false}) + } + if customer.Status == constants.CustomerStatusBlocked { + return utils.CxForbidden(c, "You do not have access to this") + } + return utils.CxOK(c, fiber.Map{ + "phone": phone, + "registered": true, + "pin_set": customer.Loginpinhash != "", + "name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), + }) +} + +// CxSetPin creates a customer's PIN the first time — signing up a brand-new +// phone (name required) or setting the first PIN on an account that has none. It +// refuses to overwrite an existing PIN (409), so the phone-only unauthenticated +// path here cannot take over an account that already has one. On success the +// customer is logged in immediately. +func CxSetPin(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + Phone string `json:"phone"` + NewPin string `json:"new_pin"` + Name string `json:"name"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + phone, _, ok := normalizePhone(req.Phone) + if !ok { + return utils.CxBadRequest(c, "Enter a valid phone number") + } + if !validCxPin(req.NewPin) { + return utils.CxBadRequest(c, "Your PIN must be 4 digits") + } + pinHash, herr := utils.HashPassword(req.NewPin) + if herr != nil { + utils.Error("CxSetPin: could not hash PIN", "error", herr) + return utils.CxInternal(c) + } + + var customer models.AppCustomer + if db.DB.Where("phone = ?", phone).First(&customer).Error != nil { + // New account: a name is required, same as signup. + first, last := splitName(req.Name) + if first == "" { + return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name") + } + customer = models.AppCustomer{ + Firstname: first, + Lastname: last, + Phone: phone, + Loginpinhash: pinHash, + Status: constants.CustomerStatusActive, + Configid: cxDefaultConfig, + } + if cerr := db.DB.Create(&customer).Error; cerr != nil { + utils.Error("CxSetPin: could not create customer", "error", cerr) + return utils.CxInternal(c) + } + return issueCxSession(c, cfg, &customer) + } + + if customer.Status == constants.CustomerStatusBlocked { + return utils.CxForbidden(c, "You do not have access to this") + } + if customer.Loginpinhash != "" { + return utils.CxFail(c, fiber.StatusConflict, utils.CxErrPinAlreadySet, "A PIN is already set — enter it to sign in") + } + customer.Loginpinhash = pinHash + if err := db.DB.Save(&customer).Error; err != nil { + utils.Error("CxSetPin: could not set PIN", "error", err) + return utils.CxInternal(c) + } + return issueCxSession(c, cfg, &customer) + } +} + +// CxVerifyPin signs a returning customer in with their PIN. +func CxVerifyPin(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + Phone string `json:"phone"` + Pin string `json:"pin"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + phone, _, ok := normalizePhone(req.Phone) + if !ok || strings.TrimSpace(req.Pin) == "" { + return utils.CxBadRequest(c, "Enter your PIN") + } + + var customer models.AppCustomer + if db.DB.Where("phone = ?", phone).First(&customer).Error != nil { + // Same generic message for unknown phone and wrong PIN, so this can't + // be used to enumerate who is registered. + return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidPin, "That phone number or PIN is incorrect") + } + if customer.Status == constants.CustomerStatusBlocked { + return utils.CxForbidden(c, "You do not have access to this") + } + if customer.Loginpinhash == "" { + return utils.CxFail(c, fiber.StatusConflict, utils.CxErrPinNotSet, "No PIN set yet — create one to continue") + } + if !utils.CheckPasswordHash(strings.TrimSpace(req.Pin), customer.Loginpinhash) { + return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidPin, "That phone number or PIN is incorrect") + } + now := time.Now() + customer.Lastloginat = &now + if err := db.DB.Save(&customer).Error; err != nil { + utils.Warn("CxVerifyPin: could not stamp last login", "error", err) + } + return issueCxSession(c, cfg, &customer) + } +} + // CxVerifyOtp exchanges a code for a session. func CxVerifyOtp(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/routes/routes.go b/routes/routes.go index b9dae9e..9af64fa 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -114,6 +114,14 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { customer.Post("/auth/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg)) customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(cfg)) + // Interim PIN auth, until the SMS/OTP gateway is live (see internal/sms). + // Same shape as the miler flow: /login reports whether a PIN is set, then the + // app routes to /set-pin (first time / new account) or /verify-pin (returning). + // set-pin can't overwrite an existing PIN, so it's safe unauthenticated here. + customer.Post("/auth/login", authThrottle, controllers.CxPinLogin) + customer.Post("/auth/set-pin", authThrottle, controllers.CxSetPin(cfg)) + customer.Post("/auth/verify-pin", authThrottle, controllers.CxVerifyPin(cfg)) + // Serviceability and configuration are read before sign-in: the booking // form is explorable without an account, and gating the state picker behind // auth would make the app's first screen a login wall. diff --git a/utils/response_cx.go b/utils/response_cx.go index f90407e..cb34fd0 100644 --- a/utils/response_cx.go +++ b/utils/response_cx.go @@ -20,6 +20,9 @@ const ( CxErrInvalid = "invalid" CxErrInvalidName = "invalid_name" CxErrInvalidOtp = "invalid_otp" + CxErrInvalidPin = "invalid_pin" + CxErrPinNotSet = "pin_not_set" + CxErrPinAlreadySet = "pin_already_set" CxErrUnauthorized = "unauthorized" CxErrForbidden = "forbidden" CxErrNotFound = "not_found"