From d4767caf304217f96d46798f7bb21d9e67bf744a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 11 May 2023 13:50:10 -0400 Subject: [PATCH] Verify --- cmd/serve.go | 3 ++ server/config.go | 8 +++- server/server.go | 5 +++ server/server.yml | 1 + server/server_account.go | 87 ++++++++++++++++++++++++++++++++++++ server/server_twilio.go | 74 +++++++++++++++++++++++++++--- server/server_twilio_test.go | 12 ++--- server/types.go | 39 +++++++++++----- user/manager.go | 70 +++++++++++++++++++++++++++++ user/types.go | 6 +++ 10 files changed, 279 insertions(+), 26 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 6c729753..42b72332 100644 --- a/cmd/serve.go +++ b/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 diff --git a/server/config.go b/server/config.go index b6d57d90..352d91fc 100644 --- a/server/config.go +++ b/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, diff --git a/server/server.go b/server/server.go index 79aa8085..056e0a6d 100644 --- a/server/server.go +++ b/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 { diff --git a/server/server.yml b/server/server.yml index fb4d1d99..f11ad362 100644 --- a/server/server.yml +++ b/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. diff --git a/server/server_account.go b/server/server_account.go index bdc42903..cb3a52ee 100644 --- a/server/server_account.go +++ b/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() { diff --git a/server/server_twilio.go b/server/server_twilio.go index 1bd11113..2f587735 100644 --- a/server/server_twilio.go +++ b/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 diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 913a520d..133138f3 100644 --- a/server/server_twilio_test.go +++ b/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" diff --git a/server/types.go b/server/types.go index 98ab4e23..17622949 100644 --- a/server/types.go +++ b/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"` +} diff --git a/user/manager.go b/user/manager.go index 017996cf..824622ba 100644 --- a/user/manager.go +++ b/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 { diff --git a/user/types.go b/user/types.go index 6340229b..8f579e89 100644 --- a/user/types.go +++ b/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") )