Repository: vocdoni/saas-backend
Pull request #50: F/sanitize phones
Author: login: p4u
Created: 2025-03-05T11:10:56Z | Merged: 2025-03-05T11:20:53Z



Diff:
diff --git a/.gemini/config.yaml b/.gemini/config.yaml
index b232f11b..fc1f1d49 100644
--- a/.gemini/config.yaml
+++ b/.gemini/config.yaml
@@ -4,6 +4,6 @@ code_review:
   comment_severity_threshold: MEDIUM
   max_review_comments: -1
   pull_request_opened:
-    help: true
+    help: false
     summary: false
     code_review: true
diff --git a/api/census.go b/api/census.go
index 97d4a45e..20c4f444 100644
--- a/api/census.go
+++ b/api/census.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"time"
 
@@ -165,6 +166,9 @@ func (a *API) publishCensusHandler(w http.ResponseWriter, r *http.Request) {
 			URI:    a.serverURL + "/process",
 			Root:   a.account.PubKey.String(),
 		}
+	default:
+		ErrGenericInternalServerError.WithErr(fmt.Errorf("unsupported census type")).Write(w)
+		return
 	}
 
 	if err := a.db.SetPublishedCensus(pubCensus); err != nil {
diff --git a/api/notifications.go b/api/notifications.go
index 76a05782..40d925fe 100644
--- a/api/notifications.go
+++ b/api/notifications.go
@@ -51,8 +51,9 @@ func (a *API) sendSMS(ctx context.Context, to string, mail mailtemplates.MailTem
 		ctx, cancel := context.WithTimeout(ctx, time.Second*10)
 		defer cancel()
 		// check if the phone number is valid
-		if !internal.ValidPhoneNumber(to) {
-			return fmt.Errorf("invalid email address")
+		recipient, err := internal.SanitizeAndVerifyPhoneNumber(to)
+		if err != nil {
+			return err
 		}
 		// execute the mail template to get the notification
 		notification, err := mail.ExecPlain(data)
@@ -60,7 +61,7 @@ func (a *API) sendSMS(ctx context.Context, to string, mail mailtemplates.MailTem
 			return err
 		}
 		// set the recipient phone number
-		notification.ToNumber = to
+		notification.ToNumber = recipient
 		// send the mail notification
 		if err := a.sms.SendNotification(ctx, notification); err != nil {
 			return err
diff --git a/api/process_bundles.go b/api/process_bundles.go
index 6b8f710a..4c96d3b0 100644
--- a/api/process_bundles.go
+++ b/api/process_bundles.go
@@ -448,6 +448,9 @@ func (a *API) initiateBundleAuthRequest(r *http.Request, bundleId string, census
 		if req.Email == "" {
 			return nil, ErrUnauthorized.Withf("missing email")
 		}
+		if !internal.ValidEmail(req.Email) {
+			return nil, ErrUnauthorized.Withf("invalid email")
+		}
 		if !bytes.Equal(internal.HashOrgData(census.OrgAddress, req.Email), participant.HashedEmail) {
 			return nil, ErrUnauthorized.Withf("invalid user data")
 		}
@@ -456,21 +459,29 @@ func (a *API) initiateBundleAuthRequest(r *http.Request, bundleId string, census
 		if req.Phone == "" {
 			return nil, ErrUnauthorized.Withf("missing phone")
 		}
+		pn, err := internal.SanitizeAndVerifyPhoneNumber(req.Phone)
+		if err != nil {
+			return nil, ErrUnauthorized.Withf("invalid phone number: %v", err)
+		}
 		if !bytes.Equal(internal.HashOrgData(census.OrgAddress, req.Phone), participant.HashedPhone) {
 			return nil, ErrUnauthorized.Withf("invalid user data")
 		}
-		authResp = a.twofactor.InitiateAuth(bundleId, participant.ParticipantNo, req.Phone, notifications.SMS)
+		authResp = a.twofactor.InitiateAuth(bundleId, participant.ParticipantNo, pn, notifications.SMS)
 	case db.CensusTypeSMSorMail:
-		if req.Email != "" {
+		if internal.ValidEmail(req.Email) {
 			if !bytes.Equal(internal.HashOrgData(census.OrgAddress, req.Email), participant.HashedEmail) {
 				return nil, ErrUnauthorized.Withf("invalid user data")
 			}
 			authResp = a.twofactor.InitiateAuth(bundleId, participant.ParticipantNo, req.Email, notifications.Email)
 		} else if req.Phone != "" {
+			pn, err := internal.SanitizeAndVerifyPhoneNumber(req.Phone)
+			if err != nil {
+				return nil, ErrUnauthorized.Withf("invalid phone number: %v", err)
+			}
 			if !bytes.Equal(internal.HashOrgData(census.OrgAddress, req.Phone), participant.HashedPhone) {
 				return nil, ErrUnauthorized.Withf("invalid user data")
 			}
-			authResp = a.twofactor.InitiateAuth(bundleId, participant.ParticipantNo, req.Phone, notifications.SMS)
+			authResp = a.twofactor.InitiateAuth(bundleId, participant.ParticipantNo, pn, notifications.SMS)
 		} else {
 			return nil, ErrUnauthorized.Withf("missing email or phone")
 		}
diff --git a/db/census_membership.go b/db/census_membership.go
index b61faef4..bc2266bf 100644
--- a/db/census_membership.go
+++ b/db/census_membership.go
@@ -173,14 +173,19 @@ func (ms *MongoStorage) SetBulkCensusMembership(
 		}
 		participant.OrgAddress = census.OrgAddress
 		participant.CreatedAt = time
-		if participant.Email != "" {
+		if participant.Email != "" && internal.ValidEmail(participant.Email) {
 			// store only the hashed email
 			participant.HashedEmail = internal.HashOrgData(census.OrgAddress, participant.Email)
 			participant.Email = ""
 		}
 		if participant.Phone != "" {
+			pn, err := internal.SanitizeAndVerifyPhoneNumber(participant.Phone)
+			if err != nil {
+				log.Warnw("invalid phone number", "phone", participant.Phone)
+				continue
+			}
 			// store only the hashed phone
-			participant.HashedPhone = internal.HashOrgData(census.OrgAddress, participant.Phone)
+			participant.HashedPhone = internal.HashOrgData(census.OrgAddress, pn)
 			participant.Phone = ""
 		}
 		if participant.Password != "" {
diff --git a/db/mongo.go b/db/mongo.go
index 9be575f7..01ee9c55 100644
--- a/db/mongo.go
+++ b/db/mongo.go
@@ -100,12 +100,12 @@ func (ms *MongoStorage) Close() {
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
 	if err := ms.client.Disconnect(ctx); err != nil {
-		log.Warn(err)
+		log.Warnw("disconnect error", "error", err)
 	}
 }
 
 func (ms *MongoStorage) Reset() error {
-	log.Infof("resetting database")
+	log.Infow("resetting database")
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
 	// drop users collection
@@ -176,7 +176,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel()
 	userCur, err := ms.users.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding user", "error", err)
 		return "{}"
 	}
 	// append all users to the export data
@@ -187,7 +187,7 @@ func (ms *MongoStorage) String() string {
 		var user User
 		err := userCur.Decode(&user)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding users", "error", err)
 		}
 		users.Users = append(users.Users, user)
 	}
@@ -196,7 +196,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel3()
 	verCur, err := ms.verifications.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding verification", "error", err)
 		return "{}"
 	}
 	// append all user verifications to the export data
@@ -207,7 +207,7 @@ func (ms *MongoStorage) String() string {
 		var ver UserVerification
 		err := verCur.Decode(&ver)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding verifications", "error", err)
 		}
 		verifications.Verifications = append(verifications.Verifications, ver)
 	}
@@ -216,7 +216,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel5()
 	orgCur, err := ms.organizations.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding organization", "error", err)
 		return "{}"
 	}
 	// append all organizations to the export data
@@ -227,7 +227,7 @@ func (ms *MongoStorage) String() string {
 		var org Organization
 		err := orgCur.Decode(&org)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding organizations", "error", err)
 		}
 		organizations.Organizations = append(organizations.Organizations, org)
 	}
@@ -237,7 +237,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel7()
 	censusCur, err := ms.censuses.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding census", "error", err)
 		return "{}"
 	}
 	// append all censuses to the export data
@@ -248,7 +248,7 @@ func (ms *MongoStorage) String() string {
 		var census Census
 		err := censusCur.Decode(&census)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding censuses", "error", err)
 		}
 		censuses.Censuses = append(censuses.Censuses, census)
 	}
@@ -258,7 +258,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel9()
 	censusPartCur, err := ms.orgParticipants.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding census participant", "error", err)
 		return "{}"
 	}
 	// append all census participants to the export data
@@ -269,7 +269,7 @@ func (ms *MongoStorage) String() string {
 		var censusPart OrgParticipant
 		err := censusPartCur.Decode(&censusPart)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding census participants", "error", err)
 		}
 		orgParticipants.OrgParticipants = append(orgParticipants.OrgParticipants, censusPart)
 	}
@@ -279,7 +279,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel11()
 	censusMemCur, err := ms.censusMemberships.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding census membership", "error", err)
 		return "{}"
 	}
 	// append all census memberships to the export data
@@ -290,7 +290,7 @@ func (ms *MongoStorage) String() string {
 		var censusMem CensusMembership
 		err := censusMemCur.Decode(&censusMem)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding census memberships", "error", err)
 		}
 		censusMemberships.CensusMemberships = append(censusMemberships.CensusMemberships, censusMem)
 	}
@@ -300,7 +300,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel13()
 	pubCensusCur, err := ms.publishedCensuses.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding published census", "error", err)
 		return "{}"
 	}
 	// append all published censuses to the export data
@@ -311,7 +311,7 @@ func (ms *MongoStorage) String() string {
 		var pubCensus PublishedCensus
 		err := pubCensusCur.Decode(&pubCensus)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding published censuses", "error", err)
 		}
 		publishedCensuses.PublishedCensuses = append(publishedCensuses.PublishedCensuses, pubCensus)
 	}
@@ -321,7 +321,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel15()
 	processCur, err := ms.processes.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding process", "error", err)
 		return "{}"
 	}
 	// append all processes to the export data
@@ -332,7 +332,7 @@ func (ms *MongoStorage) String() string {
 		var process Process
 		err := processCur.Decode(&process)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding processes", "error", err)
 		}
 		processes.Processes = append(processes.Processes, process)
 	}
@@ -341,7 +341,7 @@ func (ms *MongoStorage) String() string {
 	defer cancel17()
 	invCur, err := ms.organizationInvites.Find(ctx, bson.D{{}})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error decoding organization invite", "error", err)
 		return "{}"
 	}
 	// append all organization invites to the export data
@@ -352,7 +352,7 @@ func (ms *MongoStorage) String() string {
 		var inv OrganizationInvite
 		err := invCur.Decode(&inv)
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("error finding organization invites", "error", err)
 		}
 		organizationInvites.OrganizationInvites = append(organizationInvites.OrganizationInvites, inv)
 	}
@@ -363,7 +363,7 @@ func (ms *MongoStorage) String() string {
 		orgParticipants, censusMemberships, publishedCensuses, processes,
 	})
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("error marshaling data", "error", err)
 	}
 	return string(data)
 }
@@ -373,7 +373,7 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 	ms.keysLock.RLock()
 	defer ms.keysLock.RUnlock()
 	// decode import data
-	log.Infof("importing database")
+	log.Infow("importing database")
 	var collection Collection
 	err := json.Unmarshal(jsonData, &collection)
 	if err != nil {
@@ -390,7 +390,7 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 		opts := options.Update().SetUpsert(true)
 		_, err := ms.users.UpdateOne(ctx, filter, update, opts)
 		if err != nil {
-			log.Warnw("error upserting user", "err", err, "user", user.ID)
+			log.Warnw("error upserting user", "error", err, "user", user.ID)
 		}
 	}
 	// upsert organizations collection
@@ -401,10 +401,10 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 		opts := options.Update().SetUpsert(true)
 		_, err := ms.organizations.UpdateOne(ctx, filter, update, opts)
 		if err != nil {
-			log.Warnw("error upserting organization", "err", err, "organization", org.Address)
+			log.Warnw("error upserting organization", "error", err, "organization", org.Address)
 		}
 	}
-	log.Infof("imported database!")
+	log.Infow("imported database")
 
 	// upsert censuses collection
 	log.Infow("importing censuses", "count", len(collection.Censuses))
@@ -414,7 +414,7 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 		opts := options.Update().SetUpsert(true)
 		_, err := ms.censuses.UpdateOne(ctx, filter, update, opts)
 		if err != nil {
-			log.Warnw("error upserting census", "err", err, "census", census.ID)
+			log.Warnw("error upserting census", "error", err, "census", census.ID)
 		}
 	}
 
@@ -426,7 +426,7 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 		opts := options.Update().SetUpsert(true)
 		_, err := ms.orgParticipants.UpdateOne(ctx, filter, update, opts)
 		if err != nil {
-			log.Warnw("error upserting census participant", "err", err, "orgParticipant", censusPart.ID)
+			log.Warnw("error upserting census participant", "error", err, "orgParticipant", censusPart.ID)
 		}
 	}
 
@@ -441,7 +441,7 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 		opts := options.Update().SetUpsert(true)
 		_, err := ms.censusMemberships.UpdateOne(ctx, filter, update, opts)
 		if err != nil {
-			log.Warnw("error upserting census membership", "err", err, "censusMembership", censusMem.ParticipantNo)
+			log.Warnw("error upserting census membership", "error", err, "censusMembership", censusMem.ParticipantNo)
 		}
 	}
 
@@ -453,7 +453,7 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 		opts := options.Update().SetUpsert(true)
 		_, err := ms.publishedCensuses.UpdateOne(ctx, filter, update, opts)
 		if err != nil {
-			log.Warnw("error upserting published census", "err", err, "publishedCensus", pubCensus.Root)
+			log.Warnw("error upserting published census", "error", err, "publishedCensus", pubCensus.Root)
 		}
 	}
 
@@ -465,7 +465,7 @@ func (ms *MongoStorage) Import(jsonData []byte) error {
 		opts := options.Update().SetUpsert(true)
 		_, err := ms.processes.UpdateOne(ctx, filter, update, opts)
 		if err != nil {
-			log.Warnw("error upserting process", "err", err, "process", process.ID)
+			log.Warnw("error upserting process", "error", err, "process", process.ID.String())
 		}
 	}
 	return nil
diff --git a/go.mod b/go.mod
index ca317475..4cd8e44c 100644
--- a/go.mod
+++ b/go.mod
@@ -13,6 +13,7 @@ require (
 	github.com/go-chi/jwtauth/v5 v5.3.1
 	github.com/google/uuid v1.6.0
 	github.com/lestrrat-go/jwx/v2 v2.0.20
+	github.com/nyaruka/phonenumbers v1.5.0
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.18.2
 	github.com/stripe/stripe-go/v81 v81.0.0
diff --git a/go.sum b/go.sum
index d920109b..9e3c259c 100644
--- a/go.sum
+++ b/go.sum
@@ -1135,6 +1135,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/nyaruka/phonenumbers v1.5.0 h1:0M+Gd9zl53QC4Nl5z1Yj1O/zPk2XXBUwR/vlzdXSJv4=
+github.com/nyaruka/phonenumbers v1.5.0/go.mod h1:gv+CtldaFz+G3vHHnasBSirAi3O2XLqZzVWz4V1pl2E=
 github.com/oasisprotocol/curve25519-voi v0.0.0-20220708102147-0a8a51822cae h1:FatpGJD2jmJfhZiFDElaC0QhZUDQnxUeAwTGkfAHN3I=
 github.com/oasisprotocol/curve25519-voi v0.0.0-20220708102147-0a8a51822cae/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s=
 github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
diff --git a/internal/utils.go b/internal/utils.go
index 71fee696..9416736c 100644
--- a/internal/utils.go
+++ b/internal/utils.go
@@ -6,26 +6,33 @@ import (
 	"encoding/hex"
 	"fmt"
 	"regexp"
+
+	"github.com/nyaruka/phonenumbers"
 )
 
 const (
-	EmailRegexTemplate       = `^[\w.\+\.\-]+@([\w\-]+\.)+[\w]{2,}$`
-	PhoneNumberRegexTemplate = `^(?:\+|00)([1-9]\d{0,3})(?:[ -]?\d){4,14}$`
+	EmailRegexTemplate  = `^[\w.\+\.\-]+@([\w\-]+\.)+[\w]{2,}$`
+	DefaultPhoneCountry = "ES"
 )
 
-var (
-	emailRegex       = regexp.MustCompile(EmailRegexTemplate)
-	phoneNumberRegex = regexp.MustCompile(PhoneNumberRegexTemplate)
-)
+var emailRegex = regexp.MustCompile(EmailRegexTemplate)
 
 // ValidEmail helper function allows to validate an email address.
 func ValidEmail(email string) bool {
 	return emailRegex.MatchString(email)
 }
 
-// ValidPhoneNumber helper function allows to validate a phone number.
-func ValidPhoneNumber(phoneNumber string) bool {
-	return phoneNumberRegex.MatchString(phoneNumber)
+// SanitizeAndVerifyEmail helper function allows to sanitize and verify a phone number.
+func SanitizeAndVerifyPhoneNumber(phone string) (string, error) {
+	pn, err := phonenumbers.Parse(phone, DefaultPhoneCountry)
+	if err != nil {
+		return "", fmt.Errorf("invalid phone number %s: %w", phone, err)
+	}
+	if !phonenumbers.IsValidNumber(pn) {
+		return "", fmt.Errorf("invalid phone number %s", phone)
+	}
+	// Build the phone number string
+	return fmt.Sprintf("+%d%d", pn.GetCountryCode(), pn.GetNationalNumber()), nil
 }
 
 // RandomBytes helper function allows to generate a random byte slice of n bytes.
diff --git a/twofactor/queue.go b/twofactor/queue.go
index ec9eb5ff..17edc2da 100644
--- a/twofactor/queue.go
+++ b/twofactor/queue.go
@@ -50,7 +50,7 @@ func (sq *Queue) add(userID, electionID internal.HexBytes, contact string, chall
 		startTime:  time.Now(),
 		retries:    0,
 	}
-	defer log.Debugf("%s: enqueued new sms with challenge", c)
+	defer log.Debugw("enqueued new sms with challenge", "challenge", c.String())
 	return sq.queue.Enqueue(c)
 }
 
@@ -59,7 +59,7 @@ func (sq *Queue) run() {
 		time.Sleep(sq.throttle)
 		c, err := sq.queue.DequeueOrWaitForNextElement()
 		if err != nil {
-			log.Warn(err)
+			log.Warnw("queue error", "error", err)
 			continue
 		}
 		challenge := c.(challengeData)
@@ -68,9 +68,9 @@ func (sq *Queue) run() {
 		sendChallenge := sq.sendChallenge[challenge.retries%2]
 		if err := sendChallenge(challenge.contact, challenge.challenge); err != nil {
 			// Fail
-			log.Warnf("%s: failed to send notification: %v", challenge, err)
+			log.Warnw("failed to send notification", "challenge", challenge.String(), "error", err)
 			if err := sq.reenqueue(challenge); err != nil {
-				log.Warnf("%s: removed from notification queue: %v", challenge, err)
+				log.Warnw("removed from notification queue", "challenge", challenge.String(), "error", err)
 				// Send a signal (channel) to let the caller know we are removing this element
 				challenge.success = false
 				sq.response <- challenge
@@ -78,7 +78,7 @@ func (sq *Queue) run() {
 			continue
 		}
 		// Success
-		log.Debugf("%s: sms with challenge successfully sent", challenge)
+		log.Debugw("sms with challenge successfully sent", "challenge", challenge.String())
 		// Send a signal (channel) to let the caller know we succeed
 		challenge.success = true
 		sq.response <- challenge
@@ -95,6 +95,6 @@ func (sq *Queue) reenqueue(challenge challengeData) error {
 	if err := sq.queue.Enqueue(challenge); err != nil {
 		return fmt.Errorf("cannot enqueue sms: %w", err)
 	}
-	log.Infof("%s: re-enqueued sms, retry #%d", challenge, challenge.retries)
+	log.Infow("re-enqueued sms", "challenge", challenge.String(), "retry", challenge.retries)
 	return nil
 }
diff --git a/twofactor/sign.go b/twofactor/sign.go
index d722126b..192b96ea 100644
--- a/twofactor/sign.go
+++ b/twofactor/sign.go
@@ -53,12 +53,12 @@ func (tf *Twofactor) PubKeyECDSA(processID []byte) string {
 func (tf *Twofactor) NewBlindRequestKey() (*blind.Point, error) {
 	k, signerR, err := blind.NewRequestParameters()
 	if err != nil {
-		log.Warn(err)
+		log.Warnw("new request parameters error", "error", err)
 		return nil, err
 	}
 	index := signerR.X.String() + signerR.Y.String()
 	if err := tf.addKey(index, k); err != nil {
-		log.Warn(err)
+		log.Warnw("add key error", "error", err)
 		return nil, err
 	}
 	if k.Uint64() == 0 {
@@ -77,7 +77,7 @@ func (tf *Twofactor) NewRequestKey() []byte {
 		panic(err)
 	}
 	if err := tf.addKey(string(b), new(big.Int).SetUint64(0)); err != nil {
-		log.Warn(err)
+		log.Warnw("add key error", "error", err)
 		return nil
 	}
 	return b
@@ -92,7 +92,7 @@ func (tf *Twofactor) SignECDSA(token, msg []byte, processID []byte) ([]byte, err
 	}
 	defer func() {
 		if err := tf.delKey(string(token)); err != nil {
-			log.Warn(err)
+			log.Warnw("delete key error", "error", err)
 		}
 	}()
 	if processID == nil {
@@ -148,7 +148,7 @@ func (tf *Twofactor) addKey(index string, point *big.Int) error {
 	tx := tf.keys.WriteTx()
 	defer tx.Discard()
 	if err := tx.Set([]byte(index), point.Bytes()); err != nil {
-		log.Error(err)
+		log.Warnw("set key error", "error", err)
 	}
 	return tx.Commit()
 }
@@ -159,7 +159,7 @@ func (tf *Twofactor) delKey(index string) error {
 	tx := tf.keys.WriteTx()
 	defer tx.Discard()
 	if err := tx.Delete([]byte(index)); err != nil {
-		log.Error(err)
+		log.Warnw("delete key error", "error", err)
 	}
 	return tx.Commit()
 }
diff --git a/twofactor/twofactor.go b/twofactor/twofactor.go
index 8ca7bae8..228b7280 100644
--- a/twofactor/twofactor.go
+++ b/twofactor/twofactor.go
@@ -36,8 +36,6 @@ const (
 	DefaultSMSthrottleTime = time.Millisecond * 500
 	// DefaultSMSqueueMaxRetries is how many times to retry delivering an SMS in case upstream provider returns an error
 	DefaultSMSqueueMaxRetries = 10
-	// DefaultPhoneCountry defines the default country code for phone numbers.
-	DefaultPhoneCountry = "ES"
 )
 
 // NotifServices holds the notification services used for two-factor authentication.
@@ -120,10 +118,14 @@ func (mf *MailNotification) SendChallenge(mail string, challenge string) error {
 
 // SendChallenge sends an authentication challenge to the specified phone number.
 func (sn *SmsNotification) SendChallenge(phone string, challenge string) error {
+	to, err := internal.SanitizeAndVerifyPhoneNumber(phone)
+	if err != nil {
+		return err
+	}
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
 	notif := &notifications.Notification{
-		ToNumber:  phone,
+		ToNumber:  to,
 		Subject:   "Vocdoni verification code",
 		PlainBody: fmt.Sprintf("Your authentication code is %s", challenge),
 	}
@@ -238,12 +240,12 @@ func (tf *Twofactor) queueController(queue *Queue) {
 		r := <-queue.response
 		if r.success {
 			if err := tf.stg.SetAttempts(r.userID, r.electionID, -1); err != nil {
-				log.Warnf("challenge cannot be sent: %v", err)
+				log.Warnw("challenge cannot be sent", "error", err)
 			} else {
-				log.Infof("%s: challenge successfully sent to user %s", r, r.userID)
+				log.Infow("challenge successfully sent", "challenge", r.String(), "userID", r.userID.String())
 			}
 		} else {
-			log.Warnf("%s: challenge sending failed", r)
+			log.Warnw("challenge sending failed", "challenge", r.String())
 		}
 	}
 }
@@ -254,7 +256,7 @@ func (tf *Twofactor) queueController(queue *Queue) {
 func (tf *Twofactor) Indexer(userID internal.HexBytes) []Election {
 	user, err := tf.stg.User(userID)
 	if err != nil {
-		log.Warnf("cannot get indexer elections: %v", err)
+		log.Warnw("cannot get indexer elections", "error", err)
 		return nil
 	}
 	// Get the last two digits of the phone and return them as extraData
@@ -305,9 +307,9 @@ func (tf *Twofactor) AddProcess(
 		participant.ElectionIds = append(participant.ElectionIds, bundleElectionId)
 
 		if err := tf.stg.AddUser(userID, participant.ElectionIds, participant.HashedEmail, participant.HashedPhone, ""); err != nil {
-			log.Warnf("cannot add user from line %d", i)
+			log.Warnw("cannot add user", "line", i)
 		}
-		log.Debugf("user %s added to process %s", userID, participant.ElectionIds)
+		log.Debugw("user added to process", "userID", userID.String(), "electionIDs", formatElectionIds(participant.ElectionIds))
 	}
 	// log.Debug(tf.stg.String())
 	return nil
@@ -342,11 +344,11 @@ func (tf *Twofactor) InitiateAuth(
 	// Get the phone number. This methods checks for bundleId and user verification status.
 	_, _, attemptNo, err := tf.stg.NewAttempt(userID, bundleIdBytes, challengeSecret, &atoken)
 	if err != nil {
-		log.Warnf("new attempt for user %s failed: %v", userID, err)
+		log.Warnw("new attempt for user failed", "userID", userID.String(), "error", err)
 		return AuthResponse{Error: err.Error()}
 	}
 	if contact == "" {
-		log.Warnf("phone is nil for user %s", userID)
+		log.Warnw("phone is nil for user", "userID", userID.String())
 		return AuthResponse{Error: "no phone for this user data"}
 	}
 	// Enqueue to send the challenge
@@ -356,16 +358,16 @@ func (tf *Twofactor) InitiateAuth(
 
 	if notifType == notifications.Email {
 		if err := tf.mailQueue.add(userID, bundleIdBytes, contact, otpCode); err != nil {
-			log.Errorf("cannot enqueue challenge: %v", err)
+			log.Warnw("cannot enqueue challenge", "error", err)
 			return AuthResponse{Error: "problem with Email challenge system"}
 		}
-		log.Infof("user %s challenged with %s at contact %s", userID.String(), otpCode, contact)
+		log.Infow("user challenged", "userID", userID.String(), "otpCode", otpCode, "contact", contact)
 	} else if notifType == notifications.SMS {
 		if err := tf.smsQueue.add(userID, bundleIdBytes, contact, otpCode); err != nil {
-			log.Errorf("cannot enqueue challenge: %v", err)
+			log.Warnw("cannot enqueue challenge", "error", err)
 			return AuthResponse{Error: "problem with SMS challenge system"}
 		}
-		log.Infof("user %s challenged with %s at contact %s", userID.String(), otpCode, contact)
+		log.Infow("user challenged", "userID", userID.String(), "otpCode", otpCode, "contact", contact)
 	} else {
 		return AuthResponse{Error: "invalid notification type"}
 	}
@@ -390,7 +392,7 @@ func (tf *Twofactor) Auth(bundleId string, authToken *uuid.UUID, authData []stri
 	bundleIdBytes.SetString(bundleId)
 	// Verify the challenge solution
 	if err := tf.stg.VerifyChallenge(bundleIdBytes, authToken, solution); err != nil {
-		log.Warnf("verify challenge %s failed: %v", solution, err)
+		log.Warnw("verify challenge failed", "solution", solution, "error", err)
 		return AuthResponse{Error: "challenge not completed:" + err.Error()}
 	}
 
@@ -403,7 +405,7 @@ func (tf *Twofactor) Auth(bundleId string, authToken *uuid.UUID, authData []stri
 	// }
 	// tokenR := r.BytesUncompressed()
 
-	log.Infof("new user registered, challenge resolved %s", authData[0])
+	log.Infow("new user registered", "challenge", authData[0])
 	return AuthResponse{
 		Response:  []string{"challenge resolved"},
 		AuthToken: authToken,
@@ -411,6 +413,24 @@ func (tf *Twofactor) Auth(bundleId string, authToken *uuid.UUID, authData []stri
 	}
 }
 
+// formatElectionIds converts a slice of internal.HexBytes to a string representation
+// for proper logging of binary data.
+func formatElectionIds(ids []internal.HexBytes) string {
+	if len(ids) == 0 {
+		return "[]"
+	}
+
+	result := "["
+	for i, id := range ids {
+		if i > 0 {
+			result += ", "
+		}
+		result += id.String()
+	}
+	result += "]"
+	return result
+}
+
 // Sign creates a cryptographic signature for the provided message using the specified signature type.
 // It requires a valid token obtained from a successful authentication.
 // For process bundles, the electionID should be the bundle ID or the first process ID in the bundle.


Reviews:
user: login: gemini-code-assist[bot]
body: ## Code Review
The pull request focuses on sanitizing phone numbers and improving logging within the application. The changes include adding phone number validation and sanitization using the `github.com/nyaruka/phonenumbers` library, as well as updating log messages to use key/value pairs for structured logging. Overall, the changes seem beneficial for improving data quality and application maintainability. However, there are some areas that could be improved, particularly around error handling and consistency.

### Summary of Findings
* **Error Handling**: In `api/census.go`, the error message construction in the default case could be improved by including the specific error that caused the unsupported census type. This would provide more context for debugging.
* **Logging**: The pull request updates several log messages to use key/value pairs, which is great for structured logging. However, it's important to ensure consistency in the message style and the keys used across the application. Also, the style guide recommends that log messages start with a non-capital letter.
* **Phone Number Handling**: The pull request introduces phone number sanitization and validation. It's important to ensure that the default country code is appropriate for the application's target audience and that the error messages are clear and informative.

### Merge Readiness
The pull request is not quite ready for merging. There are several medium and high severity issues that should be addressed before merging, particularly around error handling, logging, and phone number handling. I am unable to directly approve this pull request, and users should have others review and approve this code before merging. At a minimum, the high severity issues should be addressed before merging.
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-03-05T11:12:40Z

Review comments:
diff_hunk: @@ -51,16 +51,17 @@ func (a *API) sendSMS(ctx context.Context, to string, mail mailtemplates.MailTem
 		ctx, cancel := context.WithTimeout(ctx, time.Second*10)
 		defer cancel()
 		// check if the phone number is valid
-		if !internal.ValidPhoneNumber(to) {
-			return fmt.Errorf("invalid email address")
+		recipient, err := internal.SanitizeAndVerifyPhoneNumber(to)
+		if err != nil {
+			return err
path: api/notifications.go
user: login: gemini-code-assist[bot]
body: ![high](https://img.shields.io/badge/priority-high-orange)

It's good to see phone number validation being added. However, the error message `invalid email address` is misleading since this function is for SMS notifications.  Also, per the style guide, the error message should start with a non-capital letter[^1].

#### Style Guide References
[^1]: Log and error messages must start with a non-capital letter. [(link)](https://github.com/vocdoni/saas-backend/blob/main/.gemini/styleguide.md?plain=1#L36-L40)

```suggestion
recipient, err := internal.SanitizeAndVerifyPhoneNumber(to)
		if err != nil {
			return fmt.Errorf("invalid phone number: %w", err)
		}
```
created_at: 2025-03-05T11:12:39Z
author_association: NONE
diff_hunk: @@ -165,6 +166,9 @@ func (a *API) publishCensusHandler(w http.ResponseWriter, r *http.Request) {
 			URI:    a.serverURL + "/process",
 			Root:   a.account.PubKey.String(),
 		}
+	default:
+		ErrGenericInternalServerError.WithErr(fmt.Errorf("unsupported census type")).Write(w)
+		return
path: api/census.go
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The error message here could be improved by including the specific census type that is unsupported. This would provide more context for debugging.

Consider using `%v` to include the census type in the error message.

```suggestion
ErrGenericInternalServerError.WithErr(fmt.Errorf("unsupported census type: %v", pubCensus)).Write(w)
		return
```
created_at: 2025-03-05T11:12:39Z
author_association: NONE
diff_hunk: @@ -456,21 +459,29 @@ func (a *API) initiateBundleAuthRequest(r *http.Request, bundleId string, census
 		if req.Phone == "" {
 			return nil, ErrUnauthorized.Withf("missing phone")
 		}
+		pn, err := internal.SanitizeAndVerifyPhoneNumber(req.Phone)
+		if err != nil {
+			return nil, ErrUnauthorized.Withf("invalid phone number: %v", err)
+		}
 		if !bytes.Equal(internal.HashOrgData(census.OrgAddress, req.Phone), participant.HashedPhone) {
 			return nil, ErrUnauthorized.Withf("invalid user data")
 		}
-		authResp = a.twofactor.InitiateAuth(bundleId, participant.ParticipantNo, req.Phone, notifications.SMS)
+		authResp = a.twofactor.InitiateAuth(bundleId, participant.ParticipantNo, pn, notifications.SMS)
 	case db.CensusTypeSMSorMail:
-		if req.Email != "" {
+		if internal.ValidEmail(req.Email) {
path: api/process_bundles.go
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Why is the email validated here, but not the phone number?  Shouldn't both be validated for consistency?
created_at: 2025-03-05T11:12:39Z
author_association: NONE
diff_hunk: @@ -173,14 +173,19 @@ func (ms *MongoStorage) SetBulkCensusMembership(
 		}
 		participant.OrgAddress = census.OrgAddress
 		participant.CreatedAt = time
-		if participant.Email != "" {
+		if participant.Email != "" && internal.ValidEmail(participant.Email) {
 			// store only the hashed email
 			participant.HashedEmail = internal.HashOrgData(census.OrgAddress, participant.Email)
 			participant.Email = ""
 		}
 		if participant.Phone != "" {
+			pn, err := internal.SanitizeAndVerifyPhoneNumber(participant.Phone)
+			if err != nil {
+				log.Warnw("invalid phone number", "phone", participant.Phone)
path: db/census_membership.go
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Consider logging the error `err` as well, to provide more context for debugging. Also, the style guide recommends using key/value pairs for logging[^1].

#### Style Guide References
[^1]: Use logging functions with key/value pairs (e.g., `log.Debugw`, `log.Infow`) to provide structured context. [(link)](https://github.com/vocdoni/saas-backend/blob/main/.gemini/styleguide.md?plain=1#L31-L35)

```suggestion
log.Warnw("invalid phone number", "phone", participant.Phone, "error", err)
				continue
```
created_at: 2025-03-05T11:12:39Z
author_association: NONE
diff_hunk: @@ -100,12 +100,12 @@ func (ms *MongoStorage) Close() {
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
 	if err := ms.client.Disconnect(ctx); err != nil {
-		log.Warn(err)
+		log.Warnw("disconnect error", "error", err)
path: db/mongo.go
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Per the style guide, use key/value pairs for logging[^1].

#### Style Guide References
[^1]: Use logging functions with key/value pairs (e.g., `log.Debugw`, `log.Infow`) to provide structured context. [(link)](https://github.com/vocdoni/saas-backend/blob/main/.gemini/styleguide.md?plain=1#L31-L35)

```suggestion
log.Warnw("disconnect error", "error", err)
```
created_at: 2025-03-05T11:12:39Z
author_association: NONE
diff_hunk: @@ -36,8 +36,6 @@ const (
 	DefaultSMSthrottleTime = time.Millisecond * 500
 	// DefaultSMSqueueMaxRetries is how many times to retry delivering an SMS in case upstream provider returns an error
 	DefaultSMSqueueMaxRetries = 10
-	// DefaultPhoneCountry defines the default country code for phone numbers.
-	DefaultPhoneCountry = "ES"
 )
 
path: twofactor/twofactor.go
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The `DefaultPhoneCountry` constant is removed here, but it's still used in `internal/utils.go`. Consider defining it only once in `internal/utils.go` to avoid confusion and ensure consistency.
created_at: 2025-03-05T11:12:40Z
author_association: NONE

PR comments:
user: login: gemini-code-assist[bot]
created_at: 2025-03-05T11:11:00Z
body: 
<details open>

<summary><b>Using Gemini Code Assist</b></summary>
<br>

The full guide for Gemini Code Assist can be found on our [documentation page](https://developers.google.com/gemini-code-assist/docs/review-github-code), here are some quick tips.

<b>Invoking Gemini</b>

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either `/gemini <command>` or `@gemini-code-assist <command>`. Below is a summary of the supported commands.

Feature | Command | Description
--- | --- | ---
Code Review | `/gemini review` | Performs a code review for the current pull request in its current state.
Pull Request Summary | `/gemini summary` | Provides a summary of the current pull request in its current state.
Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments.
Help | `/gemini help` | Displays a list of available commands.

<b>Customization</b>

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a `.gemini/` folder in the base of the repository. Detailed instructions can be found [here](https://developers.google.com/gemini-code-assist/docs/review-github-code).

<b>Limitations & Feedback</b>

Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with :thumbsup: and :thumbsdown: on @gemini-code-assist comments to provide feedback.

</details>
author_association: NONE
user: login: p4u
created_at: 2025-03-05T11:11:58Z
body: /gemini review	
author_association: MEMBER