Skip to content

Commit

Permalink
feat: add support for Twilio Verify (#1124)
Browse files Browse the repository at this point in the history
## What kind of change does this PR introduce?

Aims to add Twilio Verify Support. Twilio Verify is implemented as a
separate provider. Only one of Twilio Verify or Twilio Programmable
messaging an be selected. At this time, we only support the use of the
`whatsapp` and `sms` channels with Twilio Verify.

This will affect the:
1. Signup flow
2. Verification flow (sms and phone_change)
3. Resend

The token is still generated, but not used in the Twilio Verify flow. It
is used as a placeholder so as to try to ensure that to the OTP returned
by the Verify service can only be used with the corresponding flow it
was generated for.

## What is the current behaviour?

We support programmable messaging.

## What is the new behaviour?

Developer can toggle between using Twilio Programmable Messaging on all
flows or Twilio Verify on all flows.

## Additional context

Manual tests:

Probably need to be conducted on both Phone Change and SMS OTP
Verification:

- [x] Existing Programmable Messaging (SMS/WhatsApp)
(Signup/Verify/PhoneChange)
- [x] Twilio Verify(SMS/WhatsApp) 
- [ ] Update Frontend to include Twilio Verify

Admin methods shouldn't need to be updated to send to Twilio Verify
since admin methods don't require confirmation

---------

Co-authored-by: joel@joellee.org <joel@joellee.org>
Co-authored-by: Stojan Dimitrovski <sdimitrovski@gmail.com>
  • Loading branch information
3 people authored Jun 23, 2023
1 parent 007324c commit 7e240f8
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 13 deletions.
1 change: 0 additions & 1 deletion internal/api/phone.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection,
if sentAt != nil && !sentAt.Add(config.Sms.MaxFrequency).Before(time.Now()) {
return "", MaxFrequencyLimitError
}

oldToken := *token
otp, err := crypto.GenerateOtp(config.Sms.OtpLength)
if err != nil {
Expand Down
12 changes: 10 additions & 2 deletions internal/api/reauthenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,16 @@ func (a *API) verifyReauthentication(nonce string, tx *storage.Connection, confi
tokenHash := fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetEmail()+nonce)))
isValid = isOtpValid(tokenHash, user.ReauthenticationToken, user.ReauthenticationSentAt, config.Mailer.OtpExp)
} else if user.GetPhone() != "" {
tokenHash := fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetPhone()+nonce)))
isValid = isOtpValid(tokenHash, user.ReauthenticationToken, user.ReauthenticationSentAt, config.Sms.OtpExp)
if config.Sms.IsTwilioVerifyProvider() {
smsProvider, _ := sms_provider.GetSmsProvider(*config)
if err := smsProvider.(*sms_provider.TwilioVerifyProvider).VerifyOTP(string(user.Phone), nonce); err != nil {
return expiredTokenError("Token has expired or is invalid").WithInternalError(err)
}
return nil
} else {
tokenHash := fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetPhone()+nonce)))
isValid = isOtpValid(tokenHash, user.ReauthenticationToken, user.ReauthenticationSentAt, config.Sms.OtpExp)
}
} else {
return unprocessableEntityError("Reauthentication requires an email or a phone number")
}
Expand Down
2 changes: 2 additions & 0 deletions internal/api/sms_provider/sms_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ func GetSmsProvider(config conf.GlobalConfiguration) (SmsProvider, error) {
return NewTextlocalProvider(config.Sms.Textlocal)
case "vonage":
return NewVonageProvider(config.Sms.Vonage)
case "twilio_verify":
return NewTwilioVerifyProvider(config.Sms.TwilioVerify)
default:
return nil, fmt.Errorf("sms Provider %s could not be found", name)
}
Expand Down
66 changes: 66 additions & 0 deletions internal/api/sms_provider/sms_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func TestSmsProvider(t *testing.T) {
AuthToken: "test_auth_token",
MessageServiceSid: "test_message_service_id",
},
TwilioVerify: conf.TwilioVerifyProviderConfiguration{
AccountSid: "test_account_sid",
AuthToken: "test_auth_token",
MessageServiceSid: "test_message_service_id",
},
Messagebird: conf.MessagebirdProviderConfiguration{
AccessKey: "test_access_key",
Originator: "test_originator",
Expand Down Expand Up @@ -216,3 +221,64 @@ func (ts *SmsProviderTestSuite) TestTextLocalSendSms() {
_, err = textlocalProvider.SendSms(phone, message)
require.NoError(ts.T(), err)
}
func (ts *SmsProviderTestSuite) TestTwilioVerifySendSms() {
defer gock.Off()
provider, err := NewTwilioVerifyProvider(ts.Config.Sms.TwilioVerify)
require.NoError(ts.T(), err)

twilioVerifyProvider, ok := provider.(*TwilioVerifyProvider)
require.Equal(ts.T(), true, ok)

phone := "123456789"
message := "This is the sms code: 123456"

body := url.Values{
"To": {"+" + phone},
"Channel": {"sms"},
}

cases := []struct {
Desc string
TwilioResponse *gock.Response
ExpectedError error
}{
{
Desc: "Successfully sent sms",
TwilioResponse: gock.New(twilioVerifyProvider.APIPath).Post("").
MatchHeader("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(twilioVerifyProvider.Config.AccountSid+":"+twilioVerifyProvider.Config.AuthToken))).
MatchType("url").BodyString(body.Encode()).
Reply(200).JSON(SmsStatus{
To: "+" + phone,
From: twilioVerifyProvider.Config.MessageServiceSid,
Status: "sent",
Body: message,
}),
ExpectedError: nil,
},
{
Desc: "Non-2xx status code returned",
TwilioResponse: gock.New(twilioVerifyProvider.APIPath).Post("").
MatchHeader("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(twilioVerifyProvider.Config.AccountSid+":"+twilioVerifyProvider.Config.AuthToken))).
MatchType("url").BodyString(body.Encode()).
Reply(500).JSON(twilioErrResponse{
Code: 500,
Message: "Internal server error",
MoreInfo: "error",
Status: 500,
}),
ExpectedError: &twilioErrResponse{
Code: 500,
Message: "Internal server error",
MoreInfo: "error",
Status: 500,
},
},
}

for _, c := range cases {
ts.Run(c.Desc, func() {
_, err = twilioVerifyProvider.SendSms(phone, message, SMSProvider)
require.Equal(ts.T(), c.ExpectedError, err)
})
}
}
139 changes: 139 additions & 0 deletions internal/api/sms_provider/twilio_verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package sms_provider

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/supabase/gotrue/internal/conf"
"github.com/supabase/gotrue/internal/utilities"
)

const (
verifyServiceApiBase = "https://verify.twilio.com/v2/Services/"
)

type TwilioVerifyProvider struct {
Config *conf.TwilioVerifyProviderConfiguration
APIPath string
}

type VerificationResponse struct {
To string `json:"to"`
Status string `json:"status"`
Channel string `json:"channel"`
Valid bool `json:"valid"`
VerificationSID string `json:"sid"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
}

// See: https://www.twilio.com/docs/verify/api/verification-check
type VerificationCheckResponse struct {
To string `json:"to"`
Status string `json:"status"`
Channel string `json:"channel"`
Valid bool `json:"valid"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
}

// Creates a SmsProvider with the Twilio Config
func NewTwilioVerifyProvider(config conf.TwilioVerifyProviderConfiguration) (SmsProvider, error) {
if err := config.Validate(); err != nil {
return nil, err
}
apiPath := verifyServiceApiBase + config.MessageServiceSid + "/Verifications"

return &TwilioVerifyProvider{
Config: &config,
APIPath: apiPath,
}, nil
}

func (t *TwilioVerifyProvider) SendMessage(phone string, message string, channel string) (string, error) {
switch channel {
case SMSProvider, WhatsappProvider:
return t.SendSms(phone, message, channel)
default:
return "", fmt.Errorf("channel type %q is not supported for Twilio", channel)
}
}

// Send an SMS containing the OTP with Twilio's API
func (t *TwilioVerifyProvider) SendSms(phone, message, channel string) (string, error) {
// Unlike Programmable Messaging, Verify does not require a prefix for channel
receiver := "+" + phone
body := url.Values{
"To": {receiver},
"Channel": {channel},
}
client := &http.Client{Timeout: defaultTimeout}
r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
if err != nil {
return "", err
}
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
r.SetBasicAuth(t.Config.AccountSid, t.Config.AuthToken)
res, err := client.Do(r)
defer utilities.SafeClose(res.Body)
if err != nil {
return "", err
}
if !(res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated) {
resp := &twilioErrResponse{}
if err := json.NewDecoder(res.Body).Decode(resp); err != nil {
return "", err
}
return "", resp
}

resp := &VerificationResponse{}
derr := json.NewDecoder(res.Body).Decode(resp)
if derr != nil {
return "", derr
}
return resp.VerificationSID, nil
}

func (t *TwilioVerifyProvider) VerifyOTP(phone, code string) error {
verifyPath := verifyServiceApiBase + t.Config.MessageServiceSid + "/VerificationCheck"
receiver := "+" + phone

body := url.Values{
"To": {receiver}, // twilio api requires "+" extension to be included
"Code": {code},
}
client := &http.Client{Timeout: defaultTimeout}
r, err := http.NewRequest("POST", verifyPath, strings.NewReader(body.Encode()))
if err != nil {
return err
}
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
r.SetBasicAuth(t.Config.AccountSid, t.Config.AuthToken)
res, err := client.Do(r)
defer utilities.SafeClose(res.Body)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
resp := &twilioErrResponse{}
if err := json.NewDecoder(res.Body).Decode(resp); err != nil {
return err
}
return resp
}
resp := &VerificationCheckResponse{}
derr := json.NewDecoder(res.Body).Decode(resp)
if derr != nil {
return derr
}

if resp.Status != "approved" || !resp.Valid {
return fmt.Errorf("twilio verification error: %v %v", resp.ErrorMessage, resp.Status)
}

return nil
}
14 changes: 14 additions & 0 deletions internal/api/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/sethvargo/go-password/password"
"github.com/supabase/gotrue/internal/api/sms_provider"
"github.com/supabase/gotrue/internal/models"
"github.com/supabase/gotrue/internal/observability"
"github.com/supabase/gotrue/internal/storage"
Expand Down Expand Up @@ -525,6 +526,7 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
}

var isValid bool
smsProvider, _ := sms_provider.GetSmsProvider(*config)
switch params.Type {
case emailOTPVerification:
// if the type is emailOTPVerification, we'll check both the confirmation_token and recovery_token columns
Expand All @@ -545,8 +547,20 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
isValid = isOtpValid(tokenHash, user.EmailChangeTokenCurrent, user.EmailChangeSentAt, config.Mailer.OtpExp) ||
isOtpValid(tokenHash, user.EmailChangeTokenNew, user.EmailChangeSentAt, config.Mailer.OtpExp)
case phoneChangeVerification:
if config.Sms.IsTwilioVerifyProvider() {
if err := smsProvider.(*sms_provider.TwilioVerifyProvider).VerifyOTP(user.PhoneChange, params.Token); err != nil {
return nil, expiredTokenError("Token has expired or is invalid").WithInternalError(err)
}
return user, nil
}
isValid = isOtpValid(tokenHash, user.PhoneChangeToken, user.PhoneChangeSentAt, config.Sms.OtpExp)
case smsVerification:
if config.Sms.IsTwilioVerifyProvider() {
if err := smsProvider.(*sms_provider.TwilioVerifyProvider).VerifyOTP(params.Phone, params.Token); err != nil {
return nil, expiredTokenError("Token has expired or is invalid").WithInternalError(err)
}
return user, nil
}
isValid = isOtpValid(tokenHash, user.ConfirmationToken, user.ConfirmationSentAt, config.Sms.OtpExp)
}

Expand Down
44 changes: 34 additions & 10 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,17 @@ type PhoneProviderConfiguration struct {
}

type SmsProviderConfiguration struct {
Autoconfirm bool `json:"autoconfirm"`
MaxFrequency time.Duration `json:"max_frequency" split_words:"true"`
OtpExp uint `json:"otp_exp" split_words:"true"`
OtpLength int `json:"otp_length" split_words:"true"`
Provider string `json:"provider"`
Template string `json:"template"`
Twilio TwilioProviderConfiguration `json:"twilio"`
Messagebird MessagebirdProviderConfiguration `json:"messagebird"`
Textlocal TextlocalProviderConfiguration `json:"textlocal"`
Vonage VonageProviderConfiguration `json:"vonage"`
Autoconfirm bool `json:"autoconfirm"`
MaxFrequency time.Duration `json:"max_frequency" split_words:"true"`
OtpExp uint `json:"otp_exp" split_words:"true"`
OtpLength int `json:"otp_length" split_words:"true"`
Provider string `json:"provider"`
Template string `json:"template"`
Twilio TwilioProviderConfiguration `json:"twilio"`
TwilioVerify TwilioVerifyProviderConfiguration `json:"twilio_verify" split_words:"true"`
Messagebird MessagebirdProviderConfiguration `json:"messagebird"`
Textlocal TextlocalProviderConfiguration `json:"textlocal"`
Vonage VonageProviderConfiguration `json:"vonage"`
}

type TwilioProviderConfiguration struct {
Expand All @@ -213,6 +214,12 @@ type TwilioProviderConfiguration struct {
MessageServiceSid string `json:"message_service_sid" split_words:"true"`
}

type TwilioVerifyProviderConfiguration struct {
AccountSid string `json:"account_sid" split_words:"true"`
AuthToken string `json:"auth_token" split_words:"true"`
MessageServiceSid string `json:"message_service_sid" split_words:"true"`
}

type MessagebirdProviderConfiguration struct {
AccessKey string `json:"access_key" split_words:"true"`
Originator string `json:"originator" split_words:"true"`
Expand Down Expand Up @@ -477,6 +484,19 @@ func (t *TwilioProviderConfiguration) Validate() error {
return nil
}

func (t *TwilioVerifyProviderConfiguration) Validate() error {
if t.AccountSid == "" {
return errors.New("missing Twilio account SID")
}
if t.AuthToken == "" {
return errors.New("missing Twilio auth token")
}
if t.MessageServiceSid == "" {
return errors.New("missing Twilio message service SID or Twilio phone number")
}
return nil
}

func (t *MessagebirdProviderConfiguration) Validate() error {
if t.AccessKey == "" {
return errors.New("missing Messagebird access key")
Expand Down Expand Up @@ -509,3 +529,7 @@ func (t *VonageProviderConfiguration) Validate() error {
}
return nil
}

func (t *SmsProviderConfiguration) IsTwilioVerifyProvider() bool {
return t.Provider == "twilio_verify"
}

0 comments on commit 7e240f8

Please sign in to comment.