feat: interim customer PIN auth (login/set-pin/verify-pin), mirrors miler flow

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
This commit is contained in:
2026-09-21 16:25:03 +05:30
parent e714e1de73
commit f1dbf7edc9
3 changed files with 167 additions and 0 deletions

View File

@@ -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. // CxVerifyOtp exchanges a code for a session.
func CxVerifyOtp(cfg *config.Config) fiber.Handler { func CxVerifyOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {

View File

@@ -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/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg))
customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(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 // Serviceability and configuration are read before sign-in: the booking
// form is explorable without an account, and gating the state picker behind // form is explorable without an account, and gating the state picker behind
// auth would make the app's first screen a login wall. // auth would make the app's first screen a login wall.

View File

@@ -20,6 +20,9 @@ const (
CxErrInvalid = "invalid" CxErrInvalid = "invalid"
CxErrInvalidName = "invalid_name" CxErrInvalidName = "invalid_name"
CxErrInvalidOtp = "invalid_otp" CxErrInvalidOtp = "invalid_otp"
CxErrInvalidPin = "invalid_pin"
CxErrPinNotSet = "pin_not_set"
CxErrPinAlreadySet = "pin_already_set"
CxErrUnauthorized = "unauthorized" CxErrUnauthorized = "unauthorized"
CxErrForbidden = "forbidden" CxErrForbidden = "forbidden"
CxErrNotFound = "not_found" CxErrNotFound = "not_found"