615 lines
21 KiB
Go
615 lines
21 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)
|
|
|
|
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})
|
|
}
|
|
}
|
|
|
|
// 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) != "" {
|
|
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)
|
|
}
|