Files
doormile_backend/controllers/otpController.go
Suriya c8a9b5d797 fix: customer PIN-reset takeover, booking-quote and consignment-log IDORs
- POST /customer/reset-pin was unauthenticated and overwrote a customer's PIN
  given only their phone number — which is the login identifier, not a secret —
  so reset-pin followed by verify-pin took over any customer account. Exactly
  the miler flaw fixed in fd7cf3e, on the B2C side. It now requires the account's
  registered email to have been verified through the existing
  send-email-otp/verify-email-otp flow; the verification is recorded in Redis
  for 10 minutes and consumed on use, so one verification authorises one reset.
  Accounts with no email on file are directed to support rather than left open.

- GET /customer/bookings/:id/price had no ownership check, unlike every other
  customer booking route, so any signed-in customer could read the price quoted
  on anyone else's booking by walking the id.

- GET /miler/consignments/userlogs/:userid took the rider from the URL and never
  compared it to the caller, letting any miler read another miler's movement
  history.

Verified as already correct while sweeping: miler assignment and booking-flow
handlers all scope by mileruserid/assignedmileruserid, customer booking detail
and cancel scope by appcustomerid, /internal sits behind InternalKeyAuth, and
CreateHubStaffAccount already refuses non-Doormile staff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:20:47 +05:30

129 lines
3.9 KiB
Go

package controllers
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"doormile/config"
"doormile/db"
"doormile/dto"
"doormile/internal/mail"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
const (
otpTTL = 5 * time.Minute
otpMaxAttempts = 5
// otpVerifiedTTL is how long a successful email verification stays usable as
// proof of identity for a follow-up action such as a PIN reset. Long enough
// to type a new PIN, short enough that a stale verification can't be
// redeemed later.
otpVerifiedTTL = 10 * time.Minute
)
func generateOtpCode() string {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "000000"
}
return fmt.Sprintf("%06d", n.Int64())
}
func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) }
func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) }
// otpVerifiedKey marks an email as recently proven. Verification previously
// left no trace at all, so nothing downstream could require it — which is why
// ResetCustomerPin was able to overwrite a PIN on nothing but a phone number.
func otpVerifiedKey(email string) string { return fmt.Sprintf("otp:email:%s:verified", email) }
// ConsumeEmailVerification reports whether the email was verified recently, and
// clears the marker so a single verification can authorise exactly one action.
func ConsumeEmailVerification(email string) bool {
if db.Rdb == nil || email == "" {
return false
}
ctx := context.Background()
n, err := db.Rdb.Del(ctx, otpVerifiedKey(email)).Result()
return err == nil && n > 0
}
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.SendEmailOtpRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Email == "" {
return utils.BadRequest(c, "email is required")
}
if db.Rdb == nil {
return utils.Internal(c, "verification service unavailable")
}
code := generateOtpCode()
ctx := context.Background()
if err := db.Rdb.Set(ctx, otpKey(req.Email), code, otpTTL).Err(); err != nil {
return utils.Internal(c, "failed to generate verification code")
}
db.Rdb.Del(ctx, otpAttemptsKey(req.Email))
if err := mail.SendOTPEmail(cfg, req.Email, code); err != nil {
utils.Warn("failed to send OTP email", "email", req.Email, "error", err)
return utils.Internal(c, "failed to send verification email")
}
return utils.Message(c, "verification code sent")
}
}
func VerifyCustomerEmailOtp() fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.VerifyEmailOtpRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Email == "" || req.Otp == "" {
return utils.BadRequest(c, "email and otp are required")
}
if db.Rdb == nil {
return utils.Internal(c, "verification service unavailable")
}
ctx := context.Background()
key := otpKey(req.Email)
stored, err := db.Rdb.Get(ctx, key).Result()
if err == redis.Nil {
return utils.BadRequest(c, "verification code expired or not found, please resend")
} else if err != nil {
return utils.Internal(c, "failed to verify code")
}
if stored != req.Otp {
attemptsKey := otpAttemptsKey(req.Email)
attempts, _ := db.Rdb.Incr(ctx, attemptsKey).Result()
db.Rdb.Expire(ctx, attemptsKey, otpTTL)
if attempts >= otpMaxAttempts {
db.Rdb.Del(ctx, key, attemptsKey)
return utils.BadRequest(c, "too many incorrect attempts, please request a new code")
}
return utils.Unauthorized(c, "incorrect verification code")
}
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
// Recorded so a follow-up PIN reset can prove this email was verified.
db.Rdb.Set(ctx, otpVerifiedKey(req.Email), "1", otpVerifiedTTL)
return utils.Message(c, "email verified successfully")
}
}