Files
doormile_backend/controllers/cxAuthController.go

658 lines
23 KiB
Go

package controllers
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
"time"
"doormile/config"
"doormile/constants"
"doormile/db"
"doormile/internal/mail"
"doormile/internal/sms"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
goredis "github.com/redis/go-redis/v9"
)
// Customer authentication — §4 of the contract.
//
// A 4-digit code to a phone or an email address, and no password anywhere. This
// is deliberately NOT the miler's phone+PIN flow: /miler/verify-pin exists and
// is not reused. A PIN is a stored secret a rider sets once and a console can
// reset, which is appropriate for a fleet of known employees; a customer base is
// not that, and the previous customer PIN flow shipped a reset endpoint that
// took over any account from a phone number alone.
//
// Every response here goes in `data`, auth included. /miler/verify-pin returns
// its payload outside the envelope and that inconsistency cost the miler client
// a release to discover — it is not repeated.
const (
cxOtpTTL = 5 * time.Minute
cxOtpLength = 4
cxResendWait = 30 * time.Second
// cxOtpMaxVerify is attempts per issued code. Three, then the code dies —
// a 4-digit code is 10,000 combinations and generous retries make it
// walkable.
cxOtpMaxVerify = 3
// cxOtpMaxRequests is codes per identifier per hour, so an attacker cannot
// mint fresh codes to reset the attempt counter, and a victim cannot be
// flooded with texts.
cxOtpMaxRequests = 5
cxOtpRequestWin = time.Hour
cxAccessTTL = time.Hour
cxRefreshTTL = 60 * 24 * time.Hour
// cxCustomerRoleID is role 9 across this codebase.
cxCustomerRoleID = 9
cxDefaultConfig = 1001
)
// ── Identifier handling ──────────────────────────────────────────────────────
// normalizeIdentifier canonicalises what the customer typed into either an
// E.164 phone number or a lowercased email address.
//
// The app currently sends "+91 98765 43210" with spaces and is being tightened
// to send E.164; both are accepted, and both must land on the SAME stored
// value, or a customer signing in from a newer build gets a second account.
func normalizeIdentifier(raw string) (identifier, kind string, ok bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", false
}
if strings.Contains(raw, "@") {
email := strings.ToLower(raw)
// Cheap structural check only. Deliverability is proven by the code
// arriving, not by a regex.
at := strings.Index(email, "@")
if at < 1 || at == len(email)-1 || !strings.Contains(email[at:], ".") {
return "", "", false
}
return email, "email", true
}
return normalizePhone(raw)
}
// normalizePhone reduces any of the shapes the app and support staff use to
// E.164 for India.
func normalizePhone(raw string) (phone, kind string, ok bool) {
var digits strings.Builder
plus := strings.HasPrefix(strings.TrimSpace(raw), "+")
for _, r := range raw {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
d := digits.String()
switch {
case plus && len(d) >= 11 && len(d) <= 15:
// Already international, spaces and dashes removed.
return "+" + d, "phone", true
case len(d) == 10:
// Bare national number, the common case from the keypad.
return "+91" + d, "phone", true
case len(d) == 12 && strings.HasPrefix(d, "91"):
return "+" + d, "phone", true
case len(d) == 11 && strings.HasPrefix(d, "0"):
return "+91" + d[1:], "phone", true
}
return "", "", false
}
// splitName turns the single name field the app collects into the first/last
// columns appcustomers already has. A one-word name keeps an empty last name
// rather than being rejected — plenty of people have one.
func splitName(full string) (first, last string) {
full = strings.Join(strings.Fields(full), " ")
if len([]rune(full)) < 2 {
return "", ""
}
if i := strings.LastIndex(full, " "); i > 0 {
return full[:i], full[i+1:]
}
return full, ""
}
func fullName(c *models.AppCustomer) string {
return strings.TrimSpace(c.Firstname + " " + c.Lastname)
}
// renderCustomer is the one shape the customer object is returned in — from
// verify, from refresh and from /auth/me — so a cold-start session restore
// cannot disagree with what sign-in returned.
//
// email is never null. The client types it as a non-nullable String and a null
// throws in the parser; an unknown address is the empty string.
func renderCustomer(c *models.AppCustomer) fiber.Map {
return fiber.Map{
"id": fmt.Sprintf("cust_%d", c.Appcustomerid),
"name": fullName(c),
"phone": c.Phone,
"email": c.Email,
}
}
// ── OTP storage ──────────────────────────────────────────────────────────────
func cxOtpKey(id string) string { return "cx:otp:" + id }
func cxOtpTriesKey(id string) string { return "cx:otp:" + id + ":tries" }
func cxOtpSentKey(id string) string { return "cx:otp:" + id + ":sent" }
func cxOtpRateKey(id string) string { return "cx:otp:" + id + ":requests" }
func cxGenerateCode() string {
max := big.NewInt(1)
for i := 0; i < cxOtpLength; i++ {
max.Mul(max, big.NewInt(10))
}
n, err := rand.Int(rand.Reader, max)
if err != nil {
return ""
}
return fmt.Sprintf("%0*d", cxOtpLength, n.Int64())
}
// issueCxOtp mints, stores and delivers a code, enforcing both the per-hour
// request cap and the resend cooldown. Returns the seconds the client must wait
// before it may ask again — the countdown is server-driven so it can be changed
// without an app release.
func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, err error) {
if db.Rdb == nil {
return 0, fmt.Errorf("verification service unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Cooldown first: a customer hammering Resend should be told to wait, not
// spend one of their five hourly codes on a request that sends nothing.
if ttl, terr := db.Rdb.TTL(ctx, cxOtpSentKey(identifier)).Result(); terr == nil && ttl > 0 {
return int(ttl.Seconds()) + 1, errCxResendTooSoon
}
count, ierr := db.Rdb.Incr(ctx, cxOtpRateKey(identifier)).Result()
if ierr == nil && count == 1 {
db.Rdb.Expire(ctx, cxOtpRateKey(identifier), cxOtpRequestWin)
}
if count > cxOtpMaxRequests {
return int(cxOtpRequestWin.Seconds()), errCxTooManyRequests
}
code := sms.StagingCode()
if code == "" {
code = cxGenerateCode()
}
if code == "" {
return 0, fmt.Errorf("could not generate a verification code")
}
if serr := db.Rdb.Set(ctx, cxOtpKey(identifier), code, cxOtpTTL).Err(); serr != nil {
return 0, serr
}
db.Rdb.Del(ctx, cxOtpTriesKey(identifier))
db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait)
// A code that was never delivered must leave nothing behind.
//
// The stored code, the resend cooldown and the rate-limit slot are all
// written BEFORE delivery is attempted, because they have to be — the code
// has to exist before it can be sent. But when sending fails, keeping them
// punishes the customer for the gateway's failure: they are told something
// went wrong, cannot resend until the cooldown expires, and have spent one
// of their five hourly codes — while a valid code they never received sits
// live in Redis for its full TTL.
//
// So unwind all three on failure. The customer can retry immediately, and
// nothing usable is left in Redis.
rollback := func() {
rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second)
defer rcancel()
db.Rdb.Del(rctx, cxOtpKey(identifier))
db.Rdb.Del(rctx, cxOtpSentKey(identifier))
// Give the slot back rather than deleting the window: DECR keeps the
// hourly window honest for codes that DID go out.
db.Rdb.Decr(rctx, cxOtpRateKey(identifier))
}
if kind == "email" {
if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil {
utils.Warn("cx auth: failed to send OTP email — rolling back the stored code", "error", merr)
rollback()
return 0, merr
}
} else {
if serr := sms.SendOTP(identifier, code); serr != nil {
utils.Warn("cx auth: failed to send OTP sms — rolling back the stored code", "error", serr)
rollback()
return 0, serr
}
}
return int(cxResendWait.Seconds()), nil
}
var (
errCxResendTooSoon = fmt.Errorf("resend too soon")
errCxTooManyRequests = fmt.Errorf("too many requests")
)
// consumeCxOtp checks a submitted code and burns it. A code is single-use, and
// a wrong answer costs one of three attempts before the code is destroyed
// outright — otherwise a 4-digit space is walkable.
func consumeCxOtp(identifier, submitted string) bool {
if db.Rdb == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stored, err := db.Rdb.Get(ctx, cxOtpKey(identifier)).Result()
if err == goredis.Nil || err != nil {
return false
}
if stored != submitted {
tries, _ := db.Rdb.Incr(ctx, cxOtpTriesKey(identifier)).Result()
db.Rdb.Expire(ctx, cxOtpTriesKey(identifier), cxOtpTTL)
if tries >= cxOtpMaxVerify {
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
}
return false
}
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
return true
}
// ── Handlers ─────────────────────────────────────────────────────────────────
// CxRequestOtp sends a sign-in code to a phone or an email address.
//
// It answers the same way whether or not the identifier has an account. Telling
// an anonymous caller "no account found" — which the old /customer/login did —
// turns this endpoint into a directory of who is registered.
func CxRequestOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Identifier string `json:"identifier"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
identifier, kind, ok := normalizeIdentifier(req.Identifier)
if !ok {
return utils.CxBadRequest(c, "Enter a valid phone number or email address")
}
resendAfter, err := issueCxOtp(cfg, identifier, kind)
switch {
case err == errCxResendTooSoon:
// Not an error to the customer — they simply have to wait, and the
// screen already renders a countdown.
return utils.CxOK(c, fiber.Map{
"sent": false,
"resendAfterSeconds": resendAfter,
"codeLength": cxOtpLength,
})
case err == errCxTooManyRequests:
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
"Too many attempts. Try again in a minute")
case err != nil:
utils.Error("CxRequestOtp: could not issue code", "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{
"sent": true,
"resendAfterSeconds": resendAfter,
"codeLength": cxOtpLength,
})
}
}
// CxSignup creates the account and sends the code in one call.
//
// An existing phone number is NOT an error: it is treated as a sign-in and a
// code is sent. The app has no "account already exists" screen, and inventing
// one here would strand a returning customer who tapped Sign up out of habit.
func CxSignup(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
first, last := splitName(req.Name)
if first == "" {
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
}
phone, _, ok := normalizePhone(req.Phone)
if !ok {
return utils.CxBadRequest(c, "Enter a valid phone number")
}
email := strings.ToLower(strings.TrimSpace(req.Email))
var existing models.AppCustomer
err := db.DB.Where("phone = ?", phone).First(&existing).Error
if err != nil {
// New account. It is created unverified in the sense that nothing
// is signed in yet — the token is only issued once the code comes
// back, so an unfinished signup leaves a row and no session.
customer := models.AppCustomer{
Firstname: first,
Lastname: last,
Phone: phone,
Email: email,
Status: constants.CustomerStatusActive,
Configid: cxDefaultConfig,
}
if cerr := db.DB.Create(&customer).Error; cerr != nil {
utils.Error("CxSignup: could not create customer", "error", cerr)
return utils.CxInternal(c)
}
} else if existing.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
resendAfter, ierr := issueCxOtp(cfg, phone, "phone")
switch {
case ierr == errCxResendTooSoon:
return utils.CxOK(c, fiber.Map{"sent": false, "resendAfterSeconds": resendAfter})
case ierr == errCxTooManyRequests:
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
"Too many attempts. Try again in a minute")
case ierr != nil:
utils.Error("CxSignup: could not issue code", "error", ierr)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{"sent": true, "resendAfterSeconds": resendAfter})
}
}
// CxVerifyOtp exchanges a code for a session.
func CxVerifyOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Identifier string `json:"identifier"`
Code string `json:"code"`
// Otp is a DEPRECATED alias for Code, and the only reason sign-in
// works for anyone on an already-installed build.
//
// The customer app was written against a spec that named this
// field "otp". The server only ever read "code", so req.Code was
// always empty, the empty-code branch below always fired, and
// EVERY sign-in failed with a 400 — a correct code failed exactly
// like a wrong one. Fixing the app alone would have left every
// customer locked out until they updated; accepting both keys
// fixes them all without a release.
//
// "code" stays the documented field. Remove this once the install
// base has moved on.
Otp string `json:"otp"`
Name string `json:"name"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
code := strings.TrimSpace(req.Code)
if code == "" {
code = strings.TrimSpace(req.Otp)
}
identifier, kind, ok := normalizeIdentifier(req.Identifier)
if !ok || code == "" {
return utils.CxBadRequest(c, "Enter the code we sent you")
}
if !consumeCxOtp(identifier, code) {
return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match")
}
var customer models.AppCustomer
column := "phone"
if kind == "email" {
column = "email"
}
lookupErr := db.DB.Where(column+" = ?", identifier).First(&customer).Error
if lookupErr != nil {
// Verified an identifier with no account behind it. That is a
// signup completing, and it needs a name — the account is worth
// nothing without one and the app collects it on the same screen.
if kind != "phone" {
return utils.CxNotFound(c, "We could not find an account for that address")
}
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: identifier,
Status: constants.CustomerStatusActive,
Configid: cxDefaultConfig,
}
if cerr := db.DB.Create(&customer).Error; cerr != nil {
utils.Error("CxVerifyOtp: could not create customer", "error", cerr)
return utils.CxInternal(c)
}
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
// A name supplied on a verify for an existing account that has none
// (possible for a row created by ops or migrated in) is accepted; it is
// never allowed to overwrite a name already on file from a request that
// only proves possession of the phone.
if first, last := splitName(req.Name); first != "" && customer.Firstname == "" {
customer.Firstname, customer.Lastname = first, last
}
now := time.Now()
customer.Lastloginat = &now
if err := db.DB.Save(&customer).Error; err != nil {
utils.Warn("CxVerifyOtp: could not stamp last login", "error", err)
}
return issueCxSession(c, cfg, &customer)
}
}
// CxRefresh rotates a refresh token for a new pair.
//
// Rotation, not reuse: the presented token is revoked and a new one issued, so
// a token captured from an old device stops working the moment the real device
// refreshes. The chain is recorded via Replacedbyid, which is what makes a
// replayed old token identifiable rather than merely rejected.
func CxRefresh(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
RefreshToken string `json:"refreshToken"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
presented := strings.TrimSpace(req.RefreshToken)
if presented == "" {
return utils.CxUnauthorized(c, "Please sign in again")
}
var row models.CustomerRefreshToken
if err := db.DB.Where("tokenhash = ?", hashToken(presented)).First(&row).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if row.Revokedat != nil {
// A revoked token coming back means either a stale client or a
// stolen one, and there is no way to tell them apart. Killing the
// whole chain costs the honest customer one sign-in and costs an
// attacker the session.
utils.Warn("cx auth: revoked refresh token replayed — revoking the customer's sessions",
"customer_id", row.Appcustomerid)
revokeCxSessions(row.Appcustomerid)
return utils.CxUnauthorized(c, "Please sign in again")
}
if utils.IST(row.Expiresat).Before(time.Now()) {
return utils.CxUnauthorized(c, "Please sign in again")
}
var customer models.AppCustomer
if err := db.DB.First(&customer, row.Appcustomerid).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
revoked := time.Now()
row.Revokedat = &revoked
if err := db.DB.Save(&row).Error; err != nil {
utils.Error("CxRefresh: could not revoke the presented token", "error", err)
return utils.CxInternal(c)
}
return issueCxSession(c, cfg, &customer)
}
}
// CxLogout revokes the session and unregisters the device's push token, so a
// signed-out phone stops receiving another person's parcel updates.
func CxLogout(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
RefreshToken string `json:"refreshToken"`
DeviceToken string `json:"deviceToken"`
}
_ = c.BodyParser(&req)
now := time.Now()
if strings.TrimSpace(req.RefreshToken) != "" {
db.DB.Model(&models.CustomerRefreshToken{}).
Where("tokenhash = ? AND appcustomerid = ?", hashToken(req.RefreshToken), customerID).
Update("revokedat", now)
} else {
// No token supplied — sign out everywhere rather than leave a session
// the customer believes they ended.
revokeCxSessions(customerID)
}
if strings.TrimSpace(req.DeviceToken) != "" {
db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken).
Delete(&models.CustomerDevice{})
}
return utils.CxOK(c, fiber.Map{"signedOut": true})
}
// CxMe returns the signed-in customer, for cold-start session restore.
func CxMe(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var customer models.AppCustomer
if err := db.DB.First(&customer, customerID).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
return utils.CxOK(c, renderCustomer(&customer))
}
// ── Session issuing ──────────────────────────────────────────────────────────
// issueCxSession mints the access/refresh pair and answers in the shape verify
// and refresh both promise.
func issueCxSession(c *fiber.Ctx, cfg *config.Config, customer *models.AppCustomer) error {
// The JWT carries tenantid like every other token in this system. B2C
// bookings are not attributed to a tenant yet (that is an open business
// decision, not something to guess), so it is 0 here — but the claim is
// present, and every customer read is scoped by appcustomerid regardless.
access, err := utils.GenerateTokenWithTTL(
customer.Appcustomerid, customer.Phone, cxCustomerRoleID, 0,
customer.Configid, cfg.JWTSecret, cxAccessTTL)
if err != nil {
utils.Error("issueCxSession: could not mint access token", "error", err)
return utils.CxInternal(c)
}
refresh, err := newRefreshToken()
if err != nil {
utils.Error("issueCxSession: could not mint refresh token", "error", err)
return utils.CxInternal(c)
}
row := models.CustomerRefreshToken{
Appcustomerid: customer.Appcustomerid,
Tokenhash: hashToken(refresh),
Expiresat: utils.DBNow().Add(cxRefreshTTL),
}
if err := db.DB.Create(&row).Error; err != nil {
utils.Error("issueCxSession: could not store refresh token", "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{
"accessToken": access,
"refreshToken": refresh,
"expiresIn": int(cxAccessTTL.Seconds()),
"customer": renderCustomer(customer),
})
}
// newRefreshToken returns 32 bytes of entropy, hex encoded. Long enough that
// guessing is not a threat model.
func newRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// hashToken is what actually lands in the database. A refresh token is a
// bearer credential valid for sixty days; storing it in plaintext would make a
// database read equivalent to sixty days of account access.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func revokeCxSessions(customerID int) {
if err := db.DB.Model(&models.CustomerRefreshToken{}).
Where("appcustomerid = ? AND revokedat IS NULL", customerID).
Update("revokedat", time.Now()).Error; err != nil {
utils.Error("revokeCxSessions: failed", "customer_id", customerID, "error", err)
}
}
// joinNonEmpty builds a display line from the parts that actually exist, so a
// missing landmark does not leave ", , " in the middle of an address.
func joinNonEmpty(sep string, parts ...string) string {
kept := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
kept = append(kept, s)
}
}
return strings.Join(kept, sep)
}