feat: add http as sms provider (#8540)

# Which Problems Are Solved

Send SMS messages as a HTTP call to a relay, for own logic on handling
different SMS providers.

# How the Problems Are Solved

Add HTTP as SMS provider type and handling of webhook messages in the
notification handlers.

# Additional Changes

Clean up old Twilio events, which were supposed to handle the general
SMS providers with deactivate, activate and remove.

# Additional Context

Partially closes #8270

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz
2024-09-06 15:11:36 +02:00
committed by GitHub
parent d2e0ac07f1
commit 5bdf1a4547
26 changed files with 2536 additions and 593 deletions

View File

@@ -5,8 +5,8 @@ import (
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/notification/handlers"
"github.com/zitadel/zitadel/internal/notification/senders"
@@ -78,20 +78,20 @@ func (c *channels) Email(ctx context.Context) (*senders.Chain, *smtp.Config, err
return chain, smtpCfg, err
}
func (c *channels) SMS(ctx context.Context) (*senders.Chain, *twilio.Config, error) {
twilioCfg, err := c.q.GetTwilioConfig(ctx)
func (c *channels) SMS(ctx context.Context) (*senders.Chain, *sms.Config, error) {
smsCfg, err := c.q.GetActiveSMSConfig(ctx)
if err != nil {
return nil, nil, err
}
chain, err := senders.SMSChannels(
ctx,
twilioCfg,
smsCfg,
c.q.GetFileSystemProvider,
c.q.GetLogProvider,
c.counters.success.sms,
c.counters.failed.sms,
)
return chain, twilioCfg, err
return chain, smsCfg, err
}
func (c *channels) Webhook(ctx context.Context, cfg webhook.Config) (*senders.Chain, error) {

View File

@@ -0,0 +1,17 @@
package sms
import (
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
)
type Config struct {
ProviderConfig *Provider
TwilioConfig *twilio.Config
WebhookConfig *webhook.Config
}
type Provider struct {
ID string `json:"id,omitempty"`
Description string `json:"description,omitempty"`
}

View File

@@ -0,0 +1,52 @@
package handlers
import (
"context"
"net/http"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/zerrors"
)
// GetActiveSMSConfig reads the active iam sms provider config
func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Config, error) {
config, err := n.SMSProviderConfigActive(ctx, authz.GetInstance(ctx).InstanceID())
if err != nil {
return nil, err
}
provider := &sms.Provider{
ID: config.ID,
Description: config.Description,
}
if config.TwilioConfig != nil {
token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto)
if err != nil {
return nil, err
}
return &sms.Config{
ProviderConfig: provider,
TwilioConfig: &twilio.Config{
SID: config.TwilioConfig.SID,
Token: token,
SenderNumber: config.TwilioConfig.SenderNumber,
},
}, nil
}
if config.HTTPConfig != nil {
return &sms.Config{
ProviderConfig: provider,
WebhookConfig: &webhook.Config{
CallURL: config.HTTPConfig.Endpoint,
Method: http.MethodPost,
Headers: nil,
},
}, nil
}
return nil, zerrors.ThrowNotFound(nil, "HANDLER-8nfow", "Errors.SMS.Twilio.NotFound")
}

View File

@@ -1,35 +0,0 @@
package handlers
import (
"context"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
// GetTwilioConfig reads the iam Twilio provider config
func (n *NotificationQueries) GetTwilioConfig(ctx context.Context) (*twilio.Config, error) {
active, err := query.NewSMSProviderStateQuery(domain.SMSConfigStateActive)
if err != nil {
return nil, err
}
config, err := n.SMSProviderConfig(ctx, active)
if err != nil {
return nil, err
}
if config.TwilioConfig == nil {
return nil, zerrors.ThrowNotFound(nil, "HANDLER-8nfow", "Errors.SMS.Twilio.NotFound")
}
token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto)
if err != nil {
return nil, err
}
return &twilio.Config{
SID: config.TwilioConfig.SID,
Token: token,
SenderNumber: config.TwilioConfig.SenderNumber,
}, nil
}

View File

@@ -161,24 +161,19 @@ func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, a
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2)
}
// SMSProviderConfig mocks base method.
func (m *MockQueries) SMSProviderConfig(arg0 context.Context, arg1 ...query.SearchQuery) (*query.SMSConfig, error) {
// SMSProviderConfigActive mocks base method.
func (m *MockQueries) SMSProviderConfigActive(arg0 context.Context, arg1 string) (*query.SMSConfig, error) {
m.ctrl.T.Helper()
varargs := []any{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "SMSProviderConfig", varargs...)
ret := m.ctrl.Call(m, "SMSProviderConfigActive", arg0, arg1)
ret0, _ := ret[0].(*query.SMSConfig)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SMSProviderConfig indicates an expected call of SMSProviderConfig.
func (mr *MockQueriesMockRecorder) SMSProviderConfig(arg0 any, arg1 ...any) *gomock.Call {
// SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive.
func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfig", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfig), varargs...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), arg0, arg1)
}
// SMTPConfigActive mocks base method.

View File

@@ -21,7 +21,7 @@ type Queries interface {
NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error)
SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error)
NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error)
SMSProviderConfig(ctx context.Context, queries ...query.SearchQuery) (*query.SMSConfig, error)
SMSProviderConfigActive(ctx context.Context, resourceOwner string) (config *query.SMSConfig, err error)
SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error)
GetDefaultLanguage(ctx context.Context) language.Tag
GetInstanceRestrictions(ctx context.Context) (restrictions query.Restrictions, err error)

View File

@@ -283,7 +283,7 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler
}
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e)
if e.NotificationType == domain.NotificationTypeSms {
notify = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e)
notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e)
}
err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
if err != nil {
@@ -373,7 +373,7 @@ func (u *userNotifier) reduceOTPSMS(
if err != nil {
return nil, err
}
notify := types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, event)
notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event)
err = notify.SendOTPSMSCode(ctx, plainCode, expiry)
if err != nil {
return nil, err
@@ -709,7 +709,7 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St
if err != nil {
return err
}
err = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e).
err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e).
SendPhoneVerificationCode(ctx, code)
if err != nil {
return err

View File

@@ -16,8 +16,8 @@ import (
"github.com/zitadel/zitadel/internal/eventstore/repository"
es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock"
channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/notification/handlers/mock"
"github.com/zitadel/zitadel/internal/notification/messages"
@@ -1463,7 +1463,7 @@ func (c *channels) Email(context.Context) (*senders.Chain, *smtp.Config, error)
return &c.Chain, nil, nil
}
func (c *channels) SMS(context.Context) (*senders.Chain, *twilio.Config, error) {
func (c *channels) SMS(context.Context) (*senders.Chain, *sms.Config, error) {
return &c.Chain, nil, nil
}

View File

@@ -3,36 +3,60 @@ package senders
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/channels/fs"
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
"github.com/zitadel/zitadel/internal/notification/channels/log"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
)
const twilioSpanName = "twilio.NotificationChannel"
func SMSChannels(
ctx context.Context,
twilioConfig *twilio.Config,
smsConfig *sms.Config,
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
getLogProvider func(ctx context.Context) (*log.Config, error),
successMetricName,
failureMetricName string,
) (chain *Chain, err error) {
channels := make([]channels.NotificationChannel, 0, 3)
if twilioConfig != nil {
if smsConfig.TwilioConfig != nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
twilio.InitChannel(*twilioConfig),
twilio.InitChannel(*smsConfig.TwilioConfig),
twilioSpanName,
successMetricName,
failureMetricName,
),
)
}
if smsConfig.WebhookConfig != nil {
webhookChannel, err := webhook.InitChannel(ctx, *smsConfig.WebhookConfig)
logging.WithFields(
"instance", authz.GetInstance(ctx).InstanceID(),
"callurl", smsConfig.WebhookConfig.CallURL,
).OnError(err).Debug("initializing JSON channel failed")
if err == nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
webhookChannel,
webhookSpanName,
successMetricName,
failureMetricName,
),
)
}
}
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
return ChainChannels(channels...), nil
}

View File

@@ -15,23 +15,23 @@ const (
)
type TemplateData struct {
Title string
PreHeader string
Subject string
Greeting string
Text string
URL string
ButtonText string
PrimaryColor string
BackgroundColor string
FontColor string
LogoURL string
FontURL string
FontFaceFamily string
FontFamily string
Title string `json:"title,omitempty"`
PreHeader string `json:"preHeader,omitempty"`
Subject string `json:"subject,omitempty"`
Greeting string `json:"greeting,omitempty"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
ButtonText string `json:"buttonText,omitempty"`
PrimaryColor string `json:"primaryColor,omitempty"`
BackgroundColor string `json:"backgroundColor,omitempty"`
FontColor string `json:"fontColor,omitempty"`
LogoURL string `json:"logoUrl,omitempty"`
FontURL string `json:"fontUrl,omitempty"`
FontFaceFamily string `json:"fontFaceFamily,omitempty"`
FontFamily string `json:"fontFamily,omitempty"`
IncludeFooter bool
FooterText string
IncludeFooter bool `json:"includeFooter,omitempty"`
FooterText string `json:"footerText,omitempty"`
}
func (data *TemplateData) Translate(translator *i18n.Translator, msgType string, args map[string]interface{}, langs ...string) {

View File

@@ -7,8 +7,8 @@ import (
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/templates"
@@ -24,7 +24,7 @@ type Notify func(
type ChannelChains interface {
Email(context.Context) (*senders.Chain, *smtp.Config, error)
SMS(context.Context) (*senders.Chain, *twilio.Config, error)
SMS(context.Context) (*senders.Chain, *sms.Config, error)
Webhook(context.Context, webhook.Config) (*senders.Chain, error)
}
@@ -79,7 +79,7 @@ func sanitizeArgsForHTML(args map[string]any) {
}
}
func SendSMSTwilio(
func SendSMS(
ctx context.Context,
channels ChannelChains,
translator *i18n.Translator,
@@ -99,7 +99,8 @@ func SendSMSTwilio(
ctx,
channels,
user,
data.Text,
data,
args,
allowUnverifiedNotificationChannel,
triggeringEvent,
)

View File

@@ -2,40 +2,78 @@ package types
import (
"context"
"strings"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/notification/templates"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
type serializableData struct {
ContextInfo map[string]interface{} `json:"contextInfo,omitempty"`
TemplateData templates.TemplateData `json:"templateData,omitempty"`
Args map[string]interface{} `json:"args,omitempty"`
}
func generateSms(
ctx context.Context,
channels ChannelChains,
user *query.NotifyUser,
content string,
data templates.TemplateData,
args map[string]interface{},
lastPhone bool,
triggeringEvent eventstore.Event,
) error {
number := ""
smsChannels, twilioConfig, err := channels.SMS(ctx)
smsChannels, config, err := channels.SMS(ctx)
logging.OnError(err).Error("could not create sms channel")
if smsChannels == nil || smsChannels.Len() == 0 {
return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent")
}
if err == nil {
number = twilioConfig.SenderNumber
}
message := &messages.SMS{
SenderPhoneNumber: number,
RecipientPhoneNumber: user.VerifiedPhone,
Content: content,
TriggeringEvent: triggeringEvent,
}
recipient := user.VerifiedPhone
if lastPhone {
message.RecipientPhoneNumber = user.LastPhone
recipient = user.LastPhone
}
return smsChannels.HandleMessage(message)
if config.TwilioConfig != nil {
number := ""
if err == nil {
number = config.TwilioConfig.SenderNumber
}
message := &messages.SMS{
SenderPhoneNumber: number,
RecipientPhoneNumber: recipient,
Content: data.Text,
TriggeringEvent: triggeringEvent,
}
return smsChannels.HandleMessage(message)
}
if config.WebhookConfig != nil {
caseArgs := make(map[string]interface{}, len(args))
for k, v := range args {
caseArgs[strings.ToLower(string(k[0]))+k[1:]] = v
}
contextInfo := map[string]interface{}{
"recipientPhoneNumber": recipient,
"eventType": triggeringEvent.Type(),
"provider": config.ProviderConfig,
}
message := &messages.JSON{
Serializable: &serializableData{
TemplateData: data,
Args: caseArgs,
ContextInfo: contextInfo,
},
TriggeringEvent: triggeringEvent,
}
webhookChannels, err := channels.Webhook(ctx, *config.WebhookConfig)
if err != nil {
return err
}
return webhookChannels.HandleMessage(message)
}
return zerrors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent")
}