Compare commits

...

2 Commits

Author SHA1 Message Date
29e189b7e0 chore: seed a PIN-ready test customer (+919000001234 / 1234)
scratch/seed_pin_customer.go (//go:build ignore, idempotent) — one customer with
PIN 1234 already set, for testing the interim /customer/auth/verify-pin flow.
Phone stored in normalised +91 form so CxPinLogin/CxVerifyPin find it. Run
against live: appcustomerid 300.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
2026-09-21 16:26:49 +05:30
f1dbf7edc9 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
2026-09-21 16:25:03 +05:30
4 changed files with 237 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

@@ -0,0 +1,70 @@
//go:build ignore
// seed_pin_customer.go — one ready-to-use customer for testing the interim PIN
// login flow: phone +919000001234, PIN 1234, already set. Idempotent (re-running
// just re-asserts the PIN to 1234).
//
// The phone is stored in NORMALISED +91 form, because CxPinLogin/CxVerifyPin
// normalise the entered number to +91 before the DB lookup — a bare 10-digit
// row would never be found.
//
// Run: go run scratch/seed_pin_customer.go
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("DB connection is nil")
}
const phone = "+919000001234" // customer types 9000001234; it normalises to this
pinHash, err := utils.HashPassword("1234")
if err != nil {
log.Fatalf("hash: %v", err)
}
var c models.AppCustomer
if db.DB.Where("phone = ?", phone).First(&c).Error != nil {
c = models.AppCustomer{
Firstname: "Test",
Lastname: "PinCustomer",
Phone: phone,
Email: "test.pin@doormile.in",
Loginpinhash: pinHash,
Defaultlatitude: 11.0168,
Defaultlongitude: 76.9558,
Defaultpincode: "641012",
Status: "Active",
Configid: 1001,
}
if err := db.DB.Create(&c).Error; err != nil {
log.Fatalf("create: %v", err)
}
fmt.Println("created customer with PIN 1234")
} else {
c.Loginpinhash = pinHash
c.Status = "Active"
if err := db.DB.Save(&c).Error; err != nil {
log.Fatalf("update: %v", err)
}
fmt.Println("customer already existed — PIN re-asserted to 1234")
}
fmt.Printf("\n✅ ready: appcustomerid=%d phone=%s login-as=9000001234 pin=1234\n",
c.Appcustomerid, c.Phone)
}

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"