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) if kind == "email" { if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil { utils.Warn("cx auth: failed to send OTP email", "error", merr) return 0, merr } } else { if serr := sms.SendOTP(identifier, code); serr != nil { utils.Warn("cx auth: failed to send OTP sms", "error", serr) 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}) } } // ── 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 { var req struct { Identifier string `json:"identifier"` Code string `json:"code"` Name string `json:"name"` } 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 || strings.TrimSpace(req.Code) == "" { return utils.CxBadRequest(c, "Enter the code we sent you") } if !consumeCxOtp(identifier, strings.TrimSpace(req.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) != "" { // A failed revoke must not answer signedOut — the 60-day refresh token // would stay valid while the customer believes they logged out. if err := db.DB.Model(&models.CustomerRefreshToken{}). Where("tokenhash = ? AND appcustomerid = ?", hashToken(req.RefreshToken), customerID). Update("revokedat", now).Error; err != nil { utils.Error("CxLogout: could not revoke the presented token", "customer_id", customerID, "error", err) return utils.CxInternal(c) } } else { // No token supplied — sign out everywhere rather than leave a session // the customer believes they ended. revokeCxSessions(customerID) } if strings.TrimSpace(req.DeviceToken) != "" { if err := db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken). Delete(&models.CustomerDevice{}).Error; err != nil { // The session is already revoked above; a stuck device row only means a // stray push, so log and still report signed out rather than fail. utils.Warn("CxLogout: could not remove device token", "customer_id", customerID, "error", err) } } 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) }