binwiederhier 2 سال پیش
والد
کامیت
d4767caf30
10فایلهای تغییر یافته به همراه279 افزوده شده و 26 حذف شده
  1. 3 0
      cmd/serve.go
  2. 6 2
      server/config.go
  3. 5 0
      server/server.go
  4. 1 0
      server/server.yml
  5. 87 0
      server/server_account.go
  6. 68 6
      server/server_twilio.go
  7. 6 6
      server/server_twilio_test.go
  8. 27 12
      server/types.go
  9. 70 0
      user/manager.go
  10. 6 0
      user/types.go

+ 3 - 0
cmd/serve.go

@@ -74,6 +74,7 @@ var flagsServe = append(
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for SMS and calling, e.g. AC123..."}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls and text messages"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
@@ -159,6 +160,7 @@ func execServe(c *cli.Context) error {
 	twilioAccount := c.String("twilio-account")
 	twilioAuthToken := c.String("twilio-auth-token")
 	twilioFromNumber := c.String("twilio-from-number")
+	twilioVerifyService := c.String("twilio-verify-service")
 	totalTopicLimit := c.Int("global-topic-limit")
 	visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
 	visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
@@ -323,6 +325,7 @@ func execServe(c *cli.Context) error {
 	conf.TwilioAccount = twilioAccount
 	conf.TwilioAuthToken = twilioAuthToken
 	conf.TwilioFromNumber = twilioFromNumber
+	conf.TwilioVerifyService = twilioVerifyService
 	conf.TotalTopicLimit = totalTopicLimit
 	conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
 	conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit

+ 6 - 2
server/config.go

@@ -107,10 +107,12 @@ type Config struct {
 	SMTPServerListen                     string
 	SMTPServerDomain                     string
 	SMTPServerAddrPrefix                 string
-	TwilioBaseURL                        string
+	TwilioMessagingBaseURL               string
 	TwilioAccount                        string
 	TwilioAuthToken                      string
 	TwilioFromNumber                     string
+	TwilioVerifyBaseURL                  string
+	TwilioVerifyService                  string
 	MetricsEnable                        bool
 	MetricsListenHTTP                    string
 	ProfileListenHTTP                    string
@@ -191,10 +193,12 @@ func NewConfig() *Config {
 		SMTPServerListen:                     "",
 		SMTPServerDomain:                     "",
 		SMTPServerAddrPrefix:                 "",
-		TwilioBaseURL:                        "https://api.twilio.com", // Override for tests
+		TwilioMessagingBaseURL:               "https://api.twilio.com", // Override for tests
 		TwilioAccount:                        "",
 		TwilioAuthToken:                      "",
 		TwilioFromNumber:                     "",
+		TwilioVerifyBaseURL:                  "https://verify.twilio.com", // Override for tests
+		TwilioVerifyService:                  "",
 		MessageLimit:                         DefaultMessageLengthLimit,
 		MinDelay:                             DefaultMinDelay,
 		MaxDelay:                             DefaultMaxDelay,

+ 5 - 0
server/server.go

@@ -88,6 +88,7 @@ var (
 	apiAccountSettingsPath                               = "/v1/account/settings"
 	apiAccountSubscriptionPath                           = "/v1/account/subscription"
 	apiAccountReservationPath                            = "/v1/account/reservation"
+	apiAccountPhonePath                                  = "/v1/account/phone"
 	apiAccountBillingPortalPath                          = "/v1/account/billing/portal"
 	apiAccountBillingWebhookPath                         = "/v1/account/billing/webhook"
 	apiAccountBillingSubscriptionPath                    = "/v1/account/billing/subscription"
@@ -450,6 +451,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
 	} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
 		return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
+	} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath {
+		return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v)
+	} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath {
+		return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
 		return s.handleStats(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {

+ 1 - 0
server/server.yml

@@ -149,6 +149,7 @@
 # twilio-account:
 # twilio-auth-token:
 # twilio-from-number:
+# twilio-verify-service:
 
 # Interval in which keepalive messages are sent to the client. This is to prevent
 # intermediaries closing the connection for inactivity.

+ 87 - 0
server/server_account.go

@@ -144,6 +144,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
 				})
 			}
 		}
+		phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
+		if err != nil {
+			return err
+		}
+		if len(phoneNumbers) > 0 {
+			response.PhoneNumbers = make([]*apiAccountPhoneNumberResponse, 0)
+			for _, p := range phoneNumbers {
+				response.PhoneNumbers = append(response.PhoneNumbers, &apiAccountPhoneNumberResponse{
+					Number:   p.Number,
+					Verified: p.Verified,
+				})
+			}
+		}
 	} else {
 		response.Username = user.Everyone
 		response.Role = string(user.RoleAnonymous)
@@ -517,6 +530,80 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
 	return nil
 }
 
+func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	u := v.User()
+	req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false)
+	if err != nil {
+		return err
+	}
+	if !phoneNumberRegex.MatchString(req.Number) {
+		return errHTTPBadRequestPhoneNumberInvalid
+	}
+	// Check user is allowed to add phone numbers
+	if u == nil || (u.IsUser() && u.Tier == nil) {
+		return errHTTPUnauthorized
+	} else if u.IsUser() && u.Tier.SMSLimit == 0 && u.Tier.CallLimit == 0 {
+		return errHTTPUnauthorized
+	}
+	// Actually add the unverified number, and send verification
+	logvr(v, r).
+		Tag(tagAccount).
+		Fields(log.Context{
+			"number": req.Number,
+		}).
+		Debug("Adding phone number, and sending verification")
+	if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
+		return err
+	}
+	if err := s.verifyPhone(v, r, req.Number); err != nil {
+		return err
+	}
+	return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	u := v.User()
+	req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false)
+	if err != nil {
+		return err
+	}
+	if !phoneNumberRegex.MatchString(req.Number) {
+		return errHTTPBadRequestPhoneNumberInvalid
+	}
+	// Check user is allowed to add phone numbers
+	if u == nil {
+		return errHTTPUnauthorized
+	}
+	// Get phone numbers, and check if it's in the list
+	phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
+	if err != nil {
+		return err
+	}
+	found := false
+	for _, phoneNumber := range phoneNumbers {
+		if phoneNumber.Number == req.Number && phoneNumber.Verified {
+			found = true
+			break
+		}
+	}
+	if !found {
+		return errHTTPBadRequestPhoneNumberInvalid
+	}
+	if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil {
+		return err
+	}
+	logvr(v, r).
+		Tag(tagAccount).
+		Fields(log.Context{
+			"number": req.Number,
+		}).
+		Debug("Marking phone number as verified")
+	if err := s.userManager.MarkPhoneNumberVerified(u.ID, req.Number); err != nil {
+		return err
+	}
+	return s.writeJSON(w, newSuccessResponse())
+}
+
 // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
 func (s *Server) publishSyncEventAsync(v *visitor) {
 	go func() {

+ 68 - 6
server/server_twilio.go

@@ -38,7 +38,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
 	data.Set("From", s.config.TwilioFromNumber)
 	data.Set("To", to)
 	data.Set("Body", body)
-	s.performTwilioRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data)
+	s.twilioMessagingRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data)
 }
 
 func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
@@ -47,10 +47,72 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
 	data.Set("From", s.config.TwilioFromNumber)
 	data.Set("To", to)
 	data.Set("Twiml", body)
-	s.performTwilioRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data)
+	s.twilioMessagingRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data)
 }
 
-func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) {
+func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) error {
+	logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification")
+	data := url.Values{}
+	data.Set("To", phoneNumber)
+	data.Set("Channel", "sms")
+	requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
+	req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+	response, err := io.ReadAll(resp.Body)
+	ev := logvr(v, r).Tag(tagTwilio)
+	if err != nil {
+		ev.Err(err).Warn("Error sending Twilio phone verification request")
+		return err
+	}
+	if ev.IsTrace() {
+		ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
+	} else if ev.IsDebug() {
+		ev.Debug("Received successful Twilio phone verification response")
+	}
+	return nil
+}
+
+func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code string) error {
+	logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
+	data := url.Values{}
+	data.Set("To", phoneNumber)
+	data.Set("Code", code)
+	requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioAccount)
+	req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	} else if resp.StatusCode != http.StatusOK {
+		return
+	}
+	response, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+
+	ev := logvr(v, r).Tag(tagTwilio)
+	if ev.IsTrace() {
+		ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
+	} else if ev.IsDebug() {
+		ev.Debug("Received successful Twilio phone verification response")
+	}
+	return nil
+}
+
+func (s *Server) twilioMessagingRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) {
 	logContext := log.Context{
 		"twilio_from": s.config.TwilioFromNumber,
 		"twilio_to":   to,
@@ -61,7 +123,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m
 	} else if ev.IsDebug() {
 		ev.Debug("Sending Twilio request")
 	}
-	response, err := s.performTwilioRequestInternal(endpoint, data)
+	response, err := s.performTwilioMessagingRequestInternal(endpoint, data)
 	if err != nil {
 		ev.
 			Field("twilio_body", body).
@@ -79,8 +141,8 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m
 	minc(msuccess)
 }
 
-func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) {
-	requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioBaseURL, s.config.TwilioAccount, endpoint)
+func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url.Values) (string, error) {
+	requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioMessagingBaseURL, s.config.TwilioAccount, endpoint)
 	req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
 	if err != nil {
 		return "", err

+ 6 - 6
server/server_twilio_test.go

@@ -25,7 +25,7 @@ func TestServer_Twilio_SMS(t *testing.T) {
 
 	c := newTestConfig(t)
 	c.BaseURL = "https://ntfy.sh"
-	c.TwilioBaseURL = twilioServer.URL
+	c.TwilioMessagingBaseURL = twilioServer.URL
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"
@@ -58,7 +58,7 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) {
 
 	c := newTestConfigWithAuthFile(t)
 	c.BaseURL = "https://ntfy.sh"
-	c.TwilioBaseURL = twilioServer.URL
+	c.TwilioMessagingBaseURL = twilioServer.URL
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"
@@ -104,7 +104,7 @@ func TestServer_Twilio_Call(t *testing.T) {
 	defer twilioServer.Close()
 
 	c := newTestConfig(t)
-	c.TwilioBaseURL = twilioServer.URL
+	c.TwilioMessagingBaseURL = twilioServer.URL
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"
@@ -139,7 +139,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) {
 	defer twilioServer.Close()
 
 	c := newTestConfigWithAuthFile(t)
-	c.TwilioBaseURL = twilioServer.URL
+	c.TwilioMessagingBaseURL = twilioServer.URL
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"
@@ -167,7 +167,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) {
 
 func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
 	c := newTestConfig(t)
-	c.TwilioBaseURL = "https://127.0.0.1"
+	c.TwilioMessagingBaseURL = "https://127.0.0.1"
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"
@@ -181,7 +181,7 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
 
 func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) {
 	c := newTestConfig(t)
-	c.TwilioBaseURL = "https://127.0.0.1"
+	c.TwilioMessagingBaseURL = "https://127.0.0.1"
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"

+ 27 - 12
server/types.go

@@ -277,6 +277,16 @@ type apiAccountTokenResponse struct {
 	Expires    int64  `json:"expires,omitempty"` // Unix timestamp
 }
 
+type apiAccountPhoneNumberRequest struct {
+	Number string `json:"number"`
+	Code   string `json:"code,omitempty"` // Only supplied in "verify" call
+}
+
+type apiAccountPhoneNumberResponse struct {
+	Number   string `json:"number"`
+	Verified bool   `json:"verified"`
+}
+
 type apiAccountTier struct {
 	Code string `json:"code"`
 	Name string `json:"name"`
@@ -326,18 +336,19 @@ type apiAccountBilling struct {
 }
 
 type apiAccountResponse struct {
-	Username      string                     `json:"username"`
-	Role          string                     `json:"role,omitempty"`
-	SyncTopic     string                     `json:"sync_topic,omitempty"`
-	Language      string                     `json:"language,omitempty"`
-	Notification  *user.NotificationPrefs    `json:"notification,omitempty"`
-	Subscriptions []*user.Subscription       `json:"subscriptions,omitempty"`
-	Reservations  []*apiAccountReservation   `json:"reservations,omitempty"`
-	Tokens        []*apiAccountTokenResponse `json:"tokens,omitempty"`
-	Tier          *apiAccountTier            `json:"tier,omitempty"`
-	Limits        *apiAccountLimits          `json:"limits,omitempty"`
-	Stats         *apiAccountStats           `json:"stats,omitempty"`
-	Billing       *apiAccountBilling         `json:"billing,omitempty"`
+	Username      string                           `json:"username"`
+	Role          string                           `json:"role,omitempty"`
+	SyncTopic     string                           `json:"sync_topic,omitempty"`
+	Language      string                           `json:"language,omitempty"`
+	Notification  *user.NotificationPrefs          `json:"notification,omitempty"`
+	Subscriptions []*user.Subscription             `json:"subscriptions,omitempty"`
+	Reservations  []*apiAccountReservation         `json:"reservations,omitempty"`
+	Tokens        []*apiAccountTokenResponse       `json:"tokens,omitempty"`
+	PhoneNumbers  []*apiAccountPhoneNumberResponse `json:"phone_numbers,omitempty"`
+	Tier          *apiAccountTier                  `json:"tier,omitempty"`
+	Limits        *apiAccountLimits                `json:"limits,omitempty"`
+	Stats         *apiAccountStats                 `json:"stats,omitempty"`
+	Billing       *apiAccountBilling               `json:"billing,omitempty"`
 }
 
 type apiAccountReservationRequest struct {
@@ -419,3 +430,7 @@ type apiStripeSubscriptionDeletedEvent struct {
 	ID       string `json:"id"`
 	Customer string `json:"customer"`
 }
+
+type apiTwilioVerifyResponse struct {
+	Status string `json:"status"`
+}

+ 70 - 0
user/manager.go

@@ -113,6 +113,14 @@ const (
 			PRIMARY KEY (user_id, token),
 			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
 		);
+		CREATE TABLE IF NOT EXISTS user_phone (
+			user_id TEXT NOT NULL,
+			phone_number TEXT NOT NULL,
+			verified INT NOT NULL,
+			PRIMARY KEY (user_id, phone_number),
+			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
+		);
+		CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number);
 		CREATE TABLE IF NOT EXISTS schemaVersion (
 			id INT PRIMARY KEY,
 			version INT NOT NULL
@@ -261,6 +269,10 @@ const (
 		)
 	`
 
+	selectPhoneNumbersQuery        = `SELECT phone_number, verified FROM user_phone WHERE user_id = ?`
+	insertPhoneNumberQuery         = `INSERT INTO user_phone (user_id, phone_number, verified) VALUES (?, ?, 0)`
+	updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?`
+
 	insertTierQuery = `
 		INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
 		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -402,6 +414,14 @@ const (
 		ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
 		ALTER TABLE user ADD COLUMN stats_sms INT NOT NULL DEFAULT (0);
 		ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
+		CREATE TABLE IF NOT EXISTS user_phone (
+			user_id TEXT NOT NULL,
+			phone_number TEXT NOT NULL,
+			verified INT NOT NULL,
+			PRIMARY KEY (user_id, phone_number),
+			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
+		);
+		CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number);
 	`
 )
 
@@ -631,6 +651,56 @@ func (a *Manager) RemoveExpiredTokens() error {
 	return nil
 }
 
+func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) {
+	rows, err := a.db.Query(selectPhoneNumbersQuery, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	phoneNumbers := make([]*PhoneNumber, 0)
+	for {
+		phoneNumber, err := a.readPhoneNumber(rows)
+		if err == ErrPhoneNumberNotFound {
+			break
+		} else if err != nil {
+			return nil, err
+		}
+		phoneNumbers = append(phoneNumbers, phoneNumber)
+	}
+	return phoneNumbers, nil
+}
+
+func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) {
+	var phoneNumber string
+	var verified bool
+	if !rows.Next() {
+		return nil, ErrPhoneNumberNotFound
+	}
+	if err := rows.Scan(&phoneNumber, &verified); err != nil {
+		return nil, err
+	} else if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return &PhoneNumber{
+		Number:   phoneNumber,
+		Verified: verified,
+	}, nil
+}
+
+func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error {
+	if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (a *Manager) MarkPhoneNumberVerified(userID string, phoneNumber string) error {
+	if _, err := a.db.Exec(updatePhoneNumberVerifiedQuery, userID, phoneNumber); err != nil {
+		return err
+	}
+	return nil
+}
+
 // RemoveDeletedUsers deletes all users that have been marked deleted for
 func (a *Manager) RemoveDeletedUsers() error {
 	if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil {

+ 6 - 0
user/types.go

@@ -71,6 +71,11 @@ type TokenUpdate struct {
 	LastOrigin netip.Addr
 }
 
+type PhoneNumber struct {
+	Number   string
+	Verified bool
+}
+
 // Prefs represents a user's configuration settings
 type Prefs struct {
 	Language      *string            `json:"language,omitempty"`
@@ -282,5 +287,6 @@ var (
 	ErrUserNotFound        = errors.New("user not found")
 	ErrTierNotFound        = errors.New("tier not found")
 	ErrTokenNotFound       = errors.New("token not found")
+	ErrPhoneNumberNotFound = errors.New("phone number not found")
 	ErrTooManyReservations = errors.New("new tier has lower reservation limit")
 )