From 14e2aba1bcd0ad6655ce276003c18a40af9931bc Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 26 Sep 2024 09:14:33 +0200 Subject: [PATCH] feat: Add Twilio Verification Service (#8678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Twilio supports a robust, multi-channel verification service that notably supports multi-region SMS sender numbers required for our use case. Currently, Zitadel does much of the work of the Twilio Verify (eg. localization, code generation, messaging) but doesn't support the pool of sender numbers that Twilio Verify does. # How the Problems Are Solved To support this API, we need to be able to store the Twilio Service ID and send that in a verification request where appropriate: phone number verification and SMS 2FA code paths. This PR does the following: - Adds the ability to use Twilio Verify of standard messaging through Twilio - Adds support for international numbers and more reliable verification messages sent from multiple numbers - Adds a new Twilio configuration option to support Twilio Verify in the admin console - Sends verification SMS messages through Twilio Verify - Implements Twilio Verification Checks for codes generated through the same # Additional Changes # Additional Context - base was implemented by @zhirschtritt in https://github.com/zitadel/zitadel/pull/8268 ❤️ - closes https://github.com/zitadel/zitadel/issues/8581 --------- Co-authored-by: Zachary Hirschtritt Co-authored-by: Joey Biscoglia --- cmd/setup/27.go | 2 +- cmd/setup/33.go | 27 + cmd/setup/33.sql | 1 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + .../dialog-add-sms-provider.component.html | 8 + .../dialog-add-sms-provider.component.ts | 10 +- console/src/assets/i18n/bg.json | 2 + console/src/assets/i18n/cs.json | 2 + console/src/assets/i18n/de.json | 2 + console/src/assets/i18n/en.json | 2 + console/src/assets/i18n/es.json | 2 + console/src/assets/i18n/fr.json | 2 + console/src/assets/i18n/id.json | 2 + console/src/assets/i18n/it.json | 2 + console/src/assets/i18n/ja.json | 2 + console/src/assets/i18n/mk.json | 2 + console/src/assets/i18n/nl.json | 2 + console/src/assets/i18n/pl.json | 2 + console/src/assets/i18n/pt.json | 2 + console/src/assets/i18n/ru.json | 2 + console/src/assets/i18n/sv.json | 2 + console/src/assets/i18n/zh.json | 2 + .../manage/console/default-settings.mdx | 5 +- docs/static/img/guides/console/twilio.png | Bin 40165 -> 35695 bytes go.mod | 6 +- go.sum | 27 +- internal/api/grpc/admin/sms_converter.go | 27 +- internal/command/command.go | 3 + internal/command/crypto.go | 23 + internal/command/crypto_test.go | 21 + internal/command/phone.go | 14 +- internal/command/session.go | 22 +- internal/command/session_model.go | 17 +- internal/command/session_otp.go | 39 +- internal/command/session_otp_test.go | 85 ++- internal/command/sms_config.go | 33 +- internal/command/sms_config_model.go | 66 ++- internal/command/sms_config_test.go | 47 +- internal/command/user_human.go | 42 +- internal/command/user_human_otp.go | 48 +- internal/command/user_human_otp_model.go | 35 ++ internal/command/user_human_otp_test.go | 418 +++++++++++---- internal/command/user_human_password.go | 45 +- internal/command/user_human_password_model.go | 8 + internal/command/user_human_password_test.go | 149 +++++- internal/command/user_human_phone.go | 72 ++- internal/command/user_human_phone_model.go | 7 + internal/command/user_human_phone_test.go | 499 +++++++++++++++--- internal/command/user_human_test.go | 182 ++++++- internal/command/user_v2_human.go | 13 +- internal/command/user_v2_human_test.go | 402 ++++++++++++-- internal/command/user_v2_model.go | 34 +- internal/command/user_v2_model_test.go | 63 +++ internal/command/user_v2_phone.go | 43 +- internal/command/user_v2_phone_test.go | 367 ++++++++++--- internal/command/user_v3.go | 6 + internal/command/user_v3_model.go | 29 +- internal/command/user_v3_phone.go | 8 +- internal/command/user_v3_phone_test.go | 359 ++++++++++++- internal/command/user_v3_test.go | 390 +++++++++++++- internal/domain/human_phone.go | 11 - .../notification/channels/twilio/channel.go | 28 +- .../notification/channels/twilio/config.go | 35 +- internal/notification/handlers/commands.go | 9 +- internal/notification/handlers/config_sms.go | 7 +- .../handlers/mock/commands.mock.go | 117 ++-- .../notification/handlers/user_notifier.go | 50 +- .../handlers/user_notifier_test.go | 285 ++++++++-- internal/notification/messages/sms.go | 3 + .../notification/senders/code_verifier.go | 24 + internal/notification/senders/gen_mock.go | 3 + .../senders/mock/code_generator.mock.go | 53 ++ internal/notification/types/notification.go | 2 + internal/notification/types/user_phone.go | 12 +- internal/query/projection/sms.go | 20 +- internal/query/projection/sms_test.go | 52 +- internal/query/sms.go | 31 +- internal/query/sms_test.go | 38 +- internal/repository/instance/sms.go | 38 +- internal/repository/session/session.go | 8 + internal/repository/user/eventstore.go | 8 +- internal/repository/user/human_mfa_otp.go | 10 +- internal/repository/user/human_password.go | 27 +- internal/repository/user/human_phone.go | 27 +- internal/repository/user/schemauser/phone.go | 9 +- internal/static/i18n/en.yaml | 1 + proto/zitadel/admin.proto | 24 +- proto/zitadel/settings.proto | 1 + 89 files changed, 3888 insertions(+), 782 deletions(-) create mode 100644 cmd/setup/33.go create mode 100644 cmd/setup/33.sql create mode 100644 internal/notification/senders/code_verifier.go create mode 100644 internal/notification/senders/gen_mock.go create mode 100644 internal/notification/senders/mock/code_generator.mock.go diff --git a/cmd/setup/27.go b/cmd/setup/27.go index ee420ddfae..22ded046e7 100644 --- a/cmd/setup/27.go +++ b/cmd/setup/27.go @@ -23,5 +23,5 @@ func (mig *IDPTemplate6SAMLNameIDFormat) Execute(ctx context.Context, _ eventsto } func (mig *IDPTemplate6SAMLNameIDFormat) String() string { - return "26_idp_templates6_add_saml_name_id_format" + return "27_idp_templates6_add_saml_name_id_format" } diff --git a/cmd/setup/33.go b/cmd/setup/33.go new file mode 100644 index 0000000000..2dd8038f5c --- /dev/null +++ b/cmd/setup/33.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 33.sql + addTwilioVerifyServiceSID string +) + +type SMSConfigs3TwilioAddVerifyServiceSid struct { + dbClient *database.DB +} + +func (mig *SMSConfigs3TwilioAddVerifyServiceSid) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addTwilioVerifyServiceSID) + return err +} + +func (mig *SMSConfigs3TwilioAddVerifyServiceSid) String() string { + return "33_sms_configs3_twilio_add_verification_sid" +} diff --git a/cmd/setup/33.sql b/cmd/setup/33.sql new file mode 100644 index 0000000000..62353e58e4 --- /dev/null +++ b/cmd/setup/33.sql @@ -0,0 +1 @@ +ALTER TABLE IF EXISTS projections.sms_configs3_twilio ADD COLUMN IF NOT EXISTS verify_service_sid TEXT; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 465aa5aa34..bb1f030b1d 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -119,6 +119,7 @@ type Steps struct { s30FillFieldsForOrgDomainVerified *FillFieldsForOrgDomainVerified s31AddAggregateIndexToFields *AddAggregateIndexToFields s32AddAuthSessionID *AddAuthSessionID + s33SMSConfigs3TwilioAddVerifyServiceSid *SMSConfigs3TwilioAddVerifyServiceSid } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 37688b7957..6391c271ed 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -161,6 +161,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient} steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient} steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: esPusherDBClient} + steps.s33SMSConfigs3TwilioAddVerifyServiceSid = &SMSConfigs3TwilioAddVerifyServiceSid{dbClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -218,6 +219,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s25User11AddLowerFieldsToVerifiedEmail, steps.s27IDPTemplate6SAMLNameIDFormat, steps.s32AddAuthSessionID, + steps.s33SMSConfigs3TwilioAddVerifyServiceSid, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.html b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.html index 78f70c7fbe..442b11b6db 100644 --- a/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.html +++ b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.html @@ -18,6 +18,14 @@ + + {{ 'SETTING.SMS.TWILIO.VERIFYSERVICESID' | translate }} + + + {{ + 'SETTING.SMS.TWILIO.VERIFYSERVICESID_DESCRIPTION' | translate + }} + diff --git a/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts index e766f6c360..7f3e3f3b0d 100644 --- a/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts +++ b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts @@ -41,7 +41,9 @@ export class DialogAddSMSProviderComponent { ) { this.twilioForm = this.fb.group({ sid: ['', [requiredValidator]], - senderNumber: ['', [requiredValidator]], + senderNumber: [''], + // NB: not required if not using verification service + verifyServiceSid: [''], }); this.smsProviders = data.smsProviders; @@ -62,12 +64,14 @@ export class DialogAddSMSProviderComponent { this.req.setId(this.twilioProvider.id); this.req.setSid(this.sid?.value); this.req.setSenderNumber(this.senderNumber?.value); + this.req.setVerifyServiceSid(this.verifyServiceSid?.value ?? ''); this.dialogRef.close(this.req); } else { this.req = new AddSMSProviderTwilioRequest(); this.req.setSid(this.sid?.value); this.req.setToken(this.token?.value); this.req.setSenderNumber(this.senderNumber?.value); + this.req.setVerifyServiceSid(this.verifyServiceSid?.value ?? ''); this.dialogRef.close(this.req); } } @@ -104,6 +108,10 @@ export class DialogAddSMSProviderComponent { return this.twilioForm.get('senderNumber'); } + public get verifyServiceSid(): AbstractControl | null { + return this.twilioForm.get('verifyServiceSid'); + } + public get token(): AbstractControl | null { return this.twilioForm.get('token'); } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 88cc186625..3dbab7b72c 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1419,6 +1419,8 @@ "SID": "Сид", "TOKEN": "Токен", "SENDERNUMBER": "Номер на изпращача", + "VERIFYSERVICESID": "Идентификатор на услугата за проверка", + "VERIFYSERVICESID_DESCRIPTION": "Задаването на идентификатор на услугата за проверка позволява използването на услугата за проверка на Twilio вместо услугата за съобщения за проверка на телефонни номера и OTP SMS.", "ADDED": "Twilio добави успешно.", "UPDATED": "Twilio се актуализира успешно.", "REMOVED": "Twilio премахнат", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index c49d2ce87a..92e6d02da5 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Číslo odesílatele", + "VERIFYSERVICESID": "ID služby ověření", + "VERIFYSERVICESID_DESCRIPTION": "Nastavení ID služby ověření umožňuje použití služby Twilio Verify místo služby Zprávy pro ověření telefonních čísel a SMS OTP.", "ADDED": "Twilio bylo úspěšně přidáno.", "UPDATED": "Twilio bylo úspěšně aktualizováno.", "REMOVED": "Twilio bylo odebráno", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index f0f01e4682..871cdbaae4 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1420,6 +1420,8 @@ "SID": "SID", "TOKEN": "Token", "SENDERNUMBER": "Sender Number", + "VERIFYSERVICESID": "Verification Service Sid", + "VERIFYSERVICESID_DESCRIPTION": "Das Setzen einer Verification Service Sid ermöglicht die Verwendung des Twilio Verify-Dienstes anstelle des Nachrichtendienstes zur Überprüfung von Telefonnummern und OTP-SMS.", "ADDED": "Twilio erfolgreich hinzugefügt.", "UPDATED": "Twilio wurde erfolgreich aktualisiert.", "REMOVED": "Twilio entfernt", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 63b07aba72..023fb6e80e 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Sender Number", + "VERIFYSERVICESID": "Verification Service Sid", + "VERIFYSERVICESID_DESCRIPTION": "Setting a Verification Service Sid, allows using the Twilio Verify Service instead of the Messages Service for verification of phone numbers and OTP SMS", "ADDED": "Twilio added successfully.", "UPDATED": "Twilio updated successfully.", "REMOVED": "Twilio removed", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 979541eb43..92f74c884e 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1421,6 +1421,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Número de emisor", + "VERIFYSERVICESID": "SID del servicio de verificación", + "VERIFYSERVICESID_DESCRIPTION": "Establecer un SID del servicio de verificación permite usar el servicio Twilio Verify en lugar del servicio de mensajes para la verificación de números de teléfono y SMS OTP.", "ADDED": "Twilio añadido con éxito.", "UPDATED": "Twilio actualizado con éxito", "REMOVED": "Twilio eliminado", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index e7f99670a7..65257fb261 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Jeton", "SENDERNUMBER": "Numéro d'expéditeur", + "VERIFYSERVICESID": "SID du service de vérification", + "VERIFYSERVICESID_DESCRIPTION": "Le réglage d'un SID du service de vérification permet d'utiliser le service Twilio Verify au lieu du service de messagerie pour la vérification des numéros de téléphone et des SMS OTP.", "ADDED": "Twilio a été ajouté avec succès.", "UPDATED": "Twilio a été mis à jour avec succès.", "REMOVED": "Twilio a été supprimé avec succès", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 2569835012..f4d76cf078 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1294,6 +1294,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Nomor Pengirim", + "VERIFYSERVICESID": "ID Layanan Verifikasi", + "VERIFYSERVICESID_DESCRIPTION": "Menetapkan ID Layanan Verifikasi memungkinkan penggunaan Layanan Verifikasi Twilio alih-alih Layanan Pesan untuk verifikasi nomor telepon dan SMS OTP.", "ADDED": "Twilio menambahkan dengan sukses.", "UPDATED": "Twilio berhasil diperbarui.", "REMOVED": "Twilio dihapus", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index cf793e78b6..8d81c8471f 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Sender Number", + "VERIFYSERVICESID": "SID del servizio di verifica", + "VERIFYSERVICESID_DESCRIPTION": "Impostando un SID del servizio di verifica, è possibile utilizzare il servizio Twilio Verify invece del servizio messaggi per la verifica dei numeri di telefono e degli SMS OTP.", "ADDED": "Twilio aggiunto con successo.", "UPDATED": "Twilio aggiornato correttamente.", "REMOVED": "Twilio rimosso con successo.", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 4ba6a69d72..c617d82ecb 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "トークン", "SENDERNUMBER": "送信者番号", + "VERIFYSERVICESID": "検証サービス SID", + "VERIFYSERVICESID_DESCRIPTION": "検証サービス SID を設定すると、電話番号と OTP SMS の検証にメッセージサービスの代わりに Twilio Verify サービスを使用できるようになります。", "ADDED": "Twilioは正常に追加されました。", "UPDATED": "Twilio が正常に更新されました。", "REMOVED": "Twilioが削除されました", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index ecf038eacc..a975731e06 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1421,6 +1421,8 @@ "SID": "Sid", "TOKEN": "Токен", "SENDERNUMBER": "Број на испраќач", + "VERIFYSERVICESID": "Идентификатор на услугата за проверка", + "VERIFYSERVICESID_DESCRIPTION": "Поставувањето на идентификатор на услугата за проверка овозможува користење на услугата за проверка на Twilio наместо услугата за пораки за проверка на телефонски броеви и OTP SMS.", "ADDED": "Twilio e успешно додаден.", "UPDATED": "Twilio се ажурираше успешно.", "REMOVED": "Twilio отстранет", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 525113e0b5..92592ba427 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1419,6 +1419,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Verzender Nummer", + "VERIFYSERVICESID": "Verificatieservice-SID", + "VERIFYSERVICESID_DESCRIPTION": "Het instellen van een Verificatieservice-SID maakt het mogelijk om de Twilio Verify-service te gebruiken in plaats van de Berichten-service voor het verifiëren van telefoonnummers en OTP-SMS.", "ADDED": "Twilio succesvol toegevoegd.", "UPDATED": "Twilio succesvol bijgewerkt.", "REMOVED": "Twilio verwijderd", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index a739bc08d2..9e8df33974 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1419,6 +1419,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Numer nadawcy", + "VERIFYSERVICESID": "SID usługi weryfikacji", + "VERIFYSERVICESID_DESCRIPTION": "Ustawienie SID usługi weryfikacji umożliwia korzystanie z usługi Twilio Verify zamiast usługi wiadomości do weryfikacji numerów telefonów i SMS OTP.", "ADDED": "Twilio dodano pomyślnie.", "UPDATED": "Twilio zostało pomyślnie zaktualizowane.", "REMOVED": "Twilio usunięte", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 1bd825e7a1..18389e0fc5 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1421,6 +1421,8 @@ "SID": "SID", "TOKEN": "Token", "SENDERNUMBER": "Número do remetente", + "VERIFYSERVICESID": "SID do serviço de verificação", + "VERIFYSERVICESID_DESCRIPTION": "Configurar um SID do serviço de verificação permite usar o serviço Twilio Verify em vez do serviço de mensagens para a verificação de números de telefone e SMS OTP.", "ADDED": "Twilio adicionado com sucesso.", "UPDATED": "Twilio atualizado com sucesso.", "REMOVED": "Twilio removido", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 292280af37..663a0d100a 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1464,6 +1464,8 @@ "SID": "ID Безопасности", "TOKEN": "Токен", "SENDERNUMBER": "Номер отправителя", + "VERIFYSERVICESID": "Идентификатор службы проверки", + "VERIFYSERVICESID_DESCRIPTION": "Установка идентификатора службы проверки позволяет использовать службу проверки Twilio вместо службы сообщений для проверки телефонных номеров и SMS OTP.", "ADDED": "Twilio успешно добавлен.", "REMOVED": "Twilio удалён", "CHANGETOKEN": "Изменить токен", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 061cbb6bba..ab267adf14 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1424,6 +1424,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Avsändarnummer", + "VERIFYSERVICESID": "Verifieringstjänst-SID", + "VERIFYSERVICESID_DESCRIPTION": "Inställning av en Verifieringstjänst-SID gör det möjligt att använda Twilio Verify-tjänsten istället för Meddelandetjänsten för verifiering av telefonnummer och OTP-SMS.", "ADDED": "Twilio tillagd framgångsrikt.", "UPDATED": "Twilio uppdaterad framgångsrikt.", "REMOVED": "Twilio borttagen", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 48f7ae849e..346f29745d 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "令牌", "SENDERNUMBER": "发件人号码", + "VERIFYSERVICESID": "验证服务 SID", + "VERIFYSERVICESID_DESCRIPTION": "设置验证服务 SID,允许使用 Twilio 验证服务而不是消息服务来验证电话号码和 OTP SMS。", "ADDED": "Twilio 添加成功。", "UPDATED": "Twilio 更新成功。", "REMOVED": "Twilio 已删除", diff --git a/docs/docs/guides/manage/console/default-settings.mdx b/docs/docs/guides/manage/console/default-settings.mdx index 4936131029..dce9f4648b 100644 --- a/docs/docs/guides/manage/console/default-settings.mdx +++ b/docs/docs/guides/manage/console/default-settings.mdx @@ -118,9 +118,10 @@ In the SMTP providers table you can hover on a provider row to show buttons that ### SMS -No default provider is configured to send some SMS to your users. If you like to validate the phone numbers of your users make sure to add your twilio configuration by adding your Sid, Token and Sender Number. +No default provider is configured to send some SMS to your users. If you like to validate the phone numbers of your users make sure to add your twilio configuration by adding your Sid, Token and either a Sender Number or a Verification Service Sid. +Setting a Verification Service Sid allows using the Twilio Verify Service instead of the Messages Service for verification. -Twilio +Twilio ## Login Behavior and Access diff --git a/docs/static/img/guides/console/twilio.png b/docs/static/img/guides/console/twilio.png index eb253128a4007b285227a78aac6e84ea4b62c5b8..e8fb891d97c56ea743ff09a9aba1b62460f28c37 100644 GIT binary patch literal 35695 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 literal 40165 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 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) { return true, nil } return u.queries.IsAlreadyHandled(ctx, event, data, eventTypes...) diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index c18ddc1df3..d0151b6d7e 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -7,11 +7,13 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" @@ -19,6 +21,7 @@ import ( 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" @@ -36,10 +39,13 @@ const ( codeID = "event1" logoURL = "logo.png" eventOrigin = "https://triggered.here" + eventOriginDomain = "triggered.here" assetsPath = "/assets/v1" preferredLoginName = "loginName1" lastEmail = "last@email.com" verifiedEmail = "verified@email.com" + lastPhone = "+41797654321" + verifiedPhone = "+41791234567" instancePrimaryDomain = "primary.domain" externalDomain = "external.domain" externalPort = 3000 @@ -47,6 +53,9 @@ const ( externalProtocol = "http" defaultOTPEmailTemplate = "/otp/verify?loginName={{.LoginName}}&code={{.Code}}" authRequestID = "authRequestID" + smsProviderID = "smsProviderID" + emailProviderID = "emailProviderID" + verificationID = "verificationID" ) func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { @@ -59,7 +68,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -92,7 +101,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -131,7 +140,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", eventOrigin, "", testCode, preferredLoginName, orgID, false, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -165,7 +174,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, preferredLoginName, orgID, false, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -204,7 +213,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, preferredLoginName, orgID, false, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -272,7 +281,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -307,7 +316,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -348,7 +357,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -385,7 +394,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -426,7 +435,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -469,7 +478,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -533,14 +542,14 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, } codeAlg, code := cryptoValue(t, ctrl, "testcode") expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -568,7 +577,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -581,7 +590,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }}, }, nil) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -609,14 +618,14 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, } codeAlg, code := cryptoValue(t, ctrl, testCode) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -646,7 +655,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -659,7 +668,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }}, }, nil) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -687,7 +696,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -700,7 +709,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }}, }, nil) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -730,14 +739,14 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, } codeAlg, code := cryptoValue(t, ctrl, testCode) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -761,7 +770,44 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }, }, w }, - }} + }, { + name: "external code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + expectContent := "We received a password reset request. Please use the button below to reset your password. (Code ) If you didn't ask for this mail, please ignore it." + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: lastPhone, + Content: expectContent, + } + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: 0, + URLTemplate: "", + CodeReturned: false, + NotificationType: domain.NotificationTypeSms, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) @@ -794,7 +840,7 @@ func Test_userNotifier_reduceDomainClaimed(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -823,7 +869,7 @@ func Test_userNotifier_reduceDomainClaimed(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -885,7 +931,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -921,7 +967,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -964,7 +1010,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { testCode := "testcode" codeAlg, code := cryptoValue(t, ctrl, testCode) expectContent := fmt.Sprintf("%s/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", eventOrigin, userID, orgID, codeID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1002,7 +1048,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { testCode := "testcode" codeAlg, code := cryptoValue(t, ctrl, testCode) expectContent := fmt.Sprintf("%s://%s:%d/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, codeID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1044,7 +1090,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1109,7 +1155,7 @@ func Test_userNotifier_reducePasswordChanged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1141,7 +1187,7 @@ func Test_userNotifier_reducePasswordChanged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1206,7 +1252,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1242,7 +1288,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1284,7 +1330,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/otp/verify?loginName=%s&code=%s", eventOrigin, preferredLoginName, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1322,7 +1368,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/otp/verify?loginName=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, preferredLoginName, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1364,7 +1410,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { urlTemplate := "https://my.custom.url/user/{{.LoginName}}/verify" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/user/%s/verify", preferredLoginName) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1413,6 +1459,107 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { } } +func Test_userNotifier_reduceOTPSMSChallenged(t *testing.T) { + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + testCode := "" + expiry := 0 * time.Hour + expectContent := fmt.Sprintf(`%[1]s is your one-time-password for %[2]s. Use it within the next %[3]s. +@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry) + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: verifiedPhone, + Content: expectContent, + } + expectTemplateQueriesSMS(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: expiry, + CodeReturned: false, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + testCode := "" + expiry := 0 * time.Hour + expectContent := fmt.Sprintf(`%[1]s is your one-time-password for %[2]s. Use it within the next %[3]s. +@%[2]s #%[1]s`, testCode, instancePrimaryDomain, expiry) + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: verifiedPhone, + Content: expectContent, + } + expectTemplateQueriesSMS(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: expiry, + CodeReturned: false, + GeneratorID: smsProviderID, + }, + }, w + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err := newUserNotifier(t, ctrl, queries, f, a, w).reduceSessionOTPSMSChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + type fields struct { queries *mock.MockQueries commands *mock.MockCommands @@ -1424,8 +1571,9 @@ type args struct { event eventstore.Event } type want struct { - message messages.Email - err assert.ErrorAssertionFunc + message *messages.Email + messageSMS *messages.SMS + err assert.ErrorAssertionFunc } func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fields, a args, w want) *userNotifier { @@ -1433,8 +1581,17 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu smtpAlg, _ := cryptoValue(t, ctrl, "smtppw") channel := channel_mock.NewMockNotificationChannel(ctrl) if w.err == nil { - w.message.TriggeringEvent = a.event - channel.EXPECT().HandleMessage(&w.message).Return(nil) + if w.message != nil { + w.message.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.message).Return(nil) + } + if w.messageSMS != nil { + w.messageSMS.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error { + message.VerificationID = gu.Ptr(verificationID) + return nil + }) + } } return &userNotifier{ commands: f.commands, @@ -1454,8 +1611,8 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu Chain: *senders.ChainChannels(channel), EmailConfig: &email.Config{ ProviderConfig: &email.Provider{ - ID: "ID", - Description: "Description", + ID: "emailProviderID", + Description: "description", }, SMTPConfig: &smtp.Config{ SMTP: smtp.SMTP{ @@ -1470,6 +1627,18 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu }, WebhookConfig: nil, }, + SMSConfig: &sms.Config{ + ProviderConfig: &sms.Provider{ + ID: "smsProviderID", + Description: "description", + }, + TwilioConfig: &twilio.Config{ + SID: "sid", + Token: "token", + SenderNumber: "senderNumber", + VerifyServiceSID: "verifyServiceSID", + }, + }, }, } } @@ -1479,6 +1648,7 @@ var _ types.ChannelChains = (*channels)(nil) type channels struct { senders.Chain EmailConfig *email.Config + SMSConfig *sms.Config } func (c *channels) Email(context.Context) (*senders.Chain, *email.Config, error) { @@ -1486,7 +1656,7 @@ func (c *channels) Email(context.Context) (*senders.Chain, *email.Config, error) } func (c *channels) SMS(context.Context) (*senders.Chain, *sms.Config, error) { - return &c.Chain, nil, nil + return &c.Chain, c.SMSConfig, nil } func (c *channels) Webhook(context.Context, webhook.Config) (*senders.Chain, error) { @@ -1510,6 +1680,31 @@ func expectTemplateQueries(queries *mock.MockQueries, template string) { LastEmail: lastEmail, VerifiedEmail: verifiedEmail, PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, nil) + queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) + queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) +} + +func expectTemplateQueriesSMS(queries *mock.MockQueries, template string) { + queries.EXPECT().GetInstanceRestrictions(gomock.Any()).Return(query.Restrictions{ + AllowedLanguages: []language.Tag{language.English}, + }, nil) + queries.EXPECT().ActiveLabelPolicyByOrg(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.LabelPolicy{ + ID: policyID, + Light: query.Theme{ + LogoURL: logoURL, + }, + }, nil) + queries.EXPECT().GetNotifyUserByID(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, }, nil) queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) diff --git a/internal/notification/messages/sms.go b/internal/notification/messages/sms.go index 72c377b337..0dfaea8772 100644 --- a/internal/notification/messages/sms.go +++ b/internal/notification/messages/sms.go @@ -12,6 +12,9 @@ type SMS struct { RecipientPhoneNumber string Content string TriggeringEvent eventstore.Event + + // VerificationID is set by the sender + VerificationID *string } func (msg *SMS) GetContent() (string, error) { diff --git a/internal/notification/senders/code_verifier.go b/internal/notification/senders/code_verifier.go new file mode 100644 index 0000000000..3aa9ec2e0e --- /dev/null +++ b/internal/notification/senders/code_verifier.go @@ -0,0 +1,24 @@ +package senders + +type CodeGenerator interface { + VerifyCode(verificationID, code string) error +} + +type CodeGeneratorInfo struct { + ID string `json:"id,omitempty"` + VerificationID string `json:"verificationId,omitempty"` +} + +func (c *CodeGeneratorInfo) GetID() string { + if c == nil { + return "" + } + return c.ID +} + +func (c *CodeGeneratorInfo) GetVerificationID() string { + if c == nil { + return "" + } + return c.VerificationID +} diff --git a/internal/notification/senders/gen_mock.go b/internal/notification/senders/gen_mock.go new file mode 100644 index 0000000000..5a0f472859 --- /dev/null +++ b/internal/notification/senders/gen_mock.go @@ -0,0 +1,3 @@ +package senders + +//go:generate mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator diff --git a/internal/notification/senders/mock/code_generator.mock.go b/internal/notification/senders/mock/code_generator.mock.go new file mode 100644 index 0000000000..15bdd2cc31 --- /dev/null +++ b/internal/notification/senders/mock/code_generator.mock.go @@ -0,0 +1,53 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/notification/senders (interfaces: CodeGenerator) +// +// Generated by this command: +// +// mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator +// + +// Package mock is a generated GoMock package. +package mock + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockCodeGenerator is a mock of CodeGenerator interface. +type MockCodeGenerator struct { + ctrl *gomock.Controller + recorder *MockCodeGeneratorMockRecorder +} + +// MockCodeGeneratorMockRecorder is the mock recorder for MockCodeGenerator. +type MockCodeGeneratorMockRecorder struct { + mock *MockCodeGenerator +} + +// NewMockCodeGenerator creates a new mock instance. +func NewMockCodeGenerator(ctrl *gomock.Controller) *MockCodeGenerator { + mock := &MockCodeGenerator{ctrl: ctrl} + mock.recorder = &MockCodeGeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCodeGenerator) EXPECT() *MockCodeGeneratorMockRecorder { + return m.recorder +} + +// VerifyCode mocks base method. +func (m *MockCodeGenerator) VerifyCode(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyCode", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// VerifyCode indicates an expected call of VerifyCode. +func (mr *MockCodeGeneratorMockRecorder) VerifyCode(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyCode", reflect.TypeOf((*MockCodeGenerator)(nil).VerifyCode), arg0, arg1) +} diff --git a/internal/notification/types/notification.go b/internal/notification/types/notification.go index 8d1b013164..49a437ff18 100644 --- a/internal/notification/types/notification.go +++ b/internal/notification/types/notification.go @@ -87,6 +87,7 @@ func SendSMS( user *query.NotifyUser, colors *query.LabelPolicy, triggeringEvent eventstore.Event, + generatorInfo *senders.CodeGeneratorInfo, ) Notify { return func( url string, @@ -104,6 +105,7 @@ func SendSMS( args, allowUnverifiedNotificationChannel, triggeringEvent, + generatorInfo, ) } } diff --git a/internal/notification/types/user_phone.go b/internal/notification/types/user_phone.go index 8e79f73718..0016f0f7a4 100644 --- a/internal/notification/types/user_phone.go +++ b/internal/notification/types/user_phone.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/notification/messages" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" @@ -27,6 +28,7 @@ func generateSms( args map[string]interface{}, lastPhone bool, triggeringEvent eventstore.Event, + generatorInfo *senders.CodeGeneratorInfo, ) error { smsChannels, config, err := channels.SMS(ctx) logging.OnError(err).Error("could not create sms channel") @@ -48,7 +50,15 @@ func generateSms( Content: data.Text, TriggeringEvent: triggeringEvent, } - return smsChannels.HandleMessage(message) + err = smsChannels.HandleMessage(message) + if err != nil { + return err + } + if config.TwilioConfig.VerifyServiceSID != "" { + generatorInfo.ID = config.ProviderConfig.ID + generatorInfo.VerificationID = *message.VerificationID + } + return nil } if config.WebhookConfig != nil { caseArgs := make(map[string]interface{}, len(args)) diff --git a/internal/query/projection/sms.go b/internal/query/projection/sms.go index 9b157ff992..eb54d7afac 100644 --- a/internal/query/projection/sms.go +++ b/internal/query/projection/sms.go @@ -25,12 +25,13 @@ const ( SMSColumnInstanceID = "instance_id" SMSColumnDescription = "description" - smsTwilioTableSuffix = "twilio" - SMSTwilioColumnSMSID = "sms_id" - SMSTwilioColumnInstanceID = "instance_id" - SMSTwilioColumnSID = "sid" - SMSTwilioColumnSenderNumber = "sender_number" - SMSTwilioColumnToken = "token" + smsTwilioTableSuffix = "twilio" + SMSTwilioColumnSMSID = "sms_id" + SMSTwilioColumnInstanceID = "instance_id" + SMSTwilioColumnSID = "sid" + SMSTwilioColumnSenderNumber = "sender_number" + SMSTwilioColumnToken = "token" + SMSTwilioColumnVerifyServiceSID = "verify_service_sid" smsHTTPTableSuffix = "http" SMSHTTPColumnSMSID = "sms_id" @@ -69,6 +70,7 @@ func (*smsConfigProjection) Init() *old_handler.Check { handler.NewColumn(SMSTwilioColumnSID, handler.ColumnTypeText), handler.NewColumn(SMSTwilioColumnSenderNumber, handler.ColumnTypeText), handler.NewColumn(SMSTwilioColumnToken, handler.ColumnTypeJSONB), + handler.NewColumn(SMSTwilioColumnVerifyServiceSID, handler.ColumnTypeText), }, handler.NewPrimaryKey(SMSTwilioColumnInstanceID, SMSTwilioColumnSMSID), smsTwilioTableSuffix, @@ -172,6 +174,7 @@ func (p *smsConfigProjection) reduceSMSConfigTwilioAdded(event eventstore.Event) handler.NewCol(SMSTwilioColumnSID, e.SID), handler.NewCol(SMSTwilioColumnToken, e.Token), handler.NewCol(SMSTwilioColumnSenderNumber, e.SenderNumber), + handler.NewCol(SMSTwilioColumnVerifyServiceSID, e.VerifyServiceSID), }, handler.WithTableSuffix(smsTwilioTableSuffix), ), @@ -202,13 +205,16 @@ func (p *smsConfigProjection) reduceSMSConfigTwilioChanged(event eventstore.Even )) } - twilioColumns := make([]handler.Column, 0) + twilioColumns := make([]handler.Column, 0, 3) if e.SID != nil { twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnSID, *e.SID)) } if e.SenderNumber != nil { twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnSenderNumber, *e.SenderNumber)) } + if e.VerifyServiceSID != nil { + twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnVerifyServiceSID, *e.VerifyServiceSID)) + } if len(twilioColumns) > 0 { stmts = append(stmts, handler.AddUpdateStatement( twilioColumns, diff --git a/internal/query/projection/sms_test.go b/internal/query/projection/sms_test.go index 88ce6e4417..7a083c2234 100644 --- a/internal/query/projection/sms_test.go +++ b/internal/query/projection/sms_test.go @@ -38,7 +38,8 @@ func TestSMSProjection_reduces(t *testing.T) { "crypted": "Y3J5cHRlZA==" }, "senderNumber": "sender-number", - "description": "description" + "description": "description", + "verifyServiceSid": "verify-service-sid" }`), ), eventstore.GenericEventMapper[instance.SMSConfigTwilioAddedEvent]), }, @@ -63,7 +64,7 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.sms_configs3_twilio (sms_id, instance_id, sid, token, sender_number) VALUES ($1, $2, $3, $4, $5)", + expectedStmt: "INSERT INTO projections.sms_configs3_twilio (sms_id, instance_id, sid, token, sender_number, verify_service_sid) VALUES ($1, $2, $3, $4, $5, $6)", expectedArgs: []interface{}{ "id", "instance-id", @@ -75,6 +76,7 @@ func TestSMSProjection_reduces(t *testing.T) { Crypted: []byte("crypted"), }, "sender-number", + "verify-service-sid", }, }, }, @@ -92,7 +94,8 @@ func TestSMSProjection_reduces(t *testing.T) { "id": "id", "sid": "sid", "senderNumber": "sender-number", - "description": "description" + "description": "description", + "verifyServiceSid": "verify-service-sid" }`), ), eventstore.GenericEventMapper[instance.SMSConfigTwilioChangedEvent]), }, @@ -113,10 +116,11 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, { - expectedStmt: "UPDATE projections.sms_configs3_twilio SET (sid, sender_number) = ($1, $2) WHERE (sms_id = $3) AND (instance_id = $4)", + expectedStmt: "UPDATE projections.sms_configs3_twilio SET (sid, sender_number, verify_service_sid) = ($1, $2, $3) WHERE (sms_id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ "sid", "sender-number", + "verify-service-sid", "id", "instance-id", }, @@ -248,6 +252,46 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, }, + { + name: "instance reduceSMSConfigTwilioChanged, only sid", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigTwilioChangedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "verifyServiceSid": "verify-service-sid" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioChangedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3_twilio SET verify_service_sid = $1 WHERE (sms_id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "verify-service-sid", + "id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "instance reduceSMSHTTPAdded", args: args{ diff --git a/internal/query/sms.go b/internal/query/sms.go index 6f0555634f..ef4d1cfca2 100644 --- a/internal/query/sms.go +++ b/internal/query/sms.go @@ -37,9 +37,10 @@ type SMSConfig struct { } type Twilio struct { - SID string - Token *crypto.CryptoValue - SenderNumber string + SID string + Token *crypto.CryptoValue + SenderNumber string + VerifyServiceSID string } type HTTP struct { @@ -123,6 +124,10 @@ var ( name: projection.SMSTwilioColumnSenderNumber, table: smsTwilioTable, } + SMSTwilioColumnVerifyServiceSID = Column{ + name: projection.SMSTwilioColumnVerifyServiceSID, + table: smsTwilioTable, + } ) var ( @@ -227,6 +232,7 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu SMSTwilioColumnSID.identifier(), SMSTwilioColumnToken.identifier(), SMSTwilioColumnSenderNumber.identifier(), + SMSTwilioColumnVerifyServiceSID.identifier(), SMSHTTPColumnSMSID.identifier(), SMSHTTPColumnEndpoint.identifier(), @@ -255,6 +261,7 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu &twilioConfig.sid, &twilioConfig.token, &twilioConfig.senderNumber, + &twilioConfig.verifyServiceSid, &httpConfig.id, &httpConfig.endpoint, @@ -289,6 +296,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB SMSTwilioColumnSID.identifier(), SMSTwilioColumnToken.identifier(), SMSTwilioColumnSenderNumber.identifier(), + SMSTwilioColumnVerifyServiceSID.identifier(), SMSHTTPColumnSMSID.identifier(), SMSHTTPColumnEndpoint.identifier(), @@ -321,6 +329,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB &twilioConfig.sid, &twilioConfig.token, &twilioConfig.senderNumber, + &twilioConfig.verifyServiceSid, &httpConfig.id, &httpConfig.endpoint, @@ -343,10 +352,11 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB } type sqlTwilioConfig struct { - smsID sql.NullString - sid sql.NullString - token *crypto.CryptoValue - senderNumber sql.NullString + smsID sql.NullString + sid sql.NullString + token *crypto.CryptoValue + senderNumber sql.NullString + verifyServiceSid sql.NullString } func (c sqlTwilioConfig) set(smsConfig *SMSConfig) { @@ -354,9 +364,10 @@ func (c sqlTwilioConfig) set(smsConfig *SMSConfig) { return } smsConfig.TwilioConfig = &Twilio{ - SID: c.sid.String, - Token: c.token, - SenderNumber: c.senderNumber.String, + SID: c.sid.String, + Token: c.token, + SenderNumber: c.senderNumber.String, + VerifyServiceSID: c.verifyServiceSid.String, } } diff --git a/internal/query/sms_test.go b/internal/query/sms_test.go index 20cf62f8cb..82c3659f2c 100644 --- a/internal/query/sms_test.go +++ b/internal/query/sms_test.go @@ -28,6 +28,7 @@ var ( ` projections.sms_configs3_twilio.sid,` + ` projections.sms_configs3_twilio.token,` + ` projections.sms_configs3_twilio.sender_number,` + + ` projections.sms_configs3_twilio.verify_service_sid,` + // http config ` projections.sms_configs3_http.sms_id,` + @@ -50,6 +51,7 @@ var ( ` projections.sms_configs3_twilio.sid,` + ` projections.sms_configs3_twilio.token,` + ` projections.sms_configs3_twilio.sender_number,` + + ` projections.sms_configs3_twilio.verify_service_sid,` + // http config ` projections.sms_configs3_http.sms_id,` + @@ -74,6 +76,7 @@ var ( "sid", "token", "sender-number", + "verify_sid", // http config "sms_id", "endpoint", @@ -126,6 +129,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { "sid", &crypto.CryptoValue{}, "sender-number", + "", // http config nil, nil, @@ -148,9 +152,10 @@ func Test_SMSConfigsPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid", - Token: &crypto.CryptoValue{}, - SenderNumber: "sender-number", + SID: "sid", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number", + VerifyServiceSID: "", }, }, }, @@ -178,6 +183,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { nil, nil, nil, + nil, // http config "sms-id", "endpoint", @@ -228,6 +234,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { "sid", &crypto.CryptoValue{}, "sender-number", + "verify-service-sid", // http config nil, nil, @@ -246,6 +253,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { "sid2", &crypto.CryptoValue{}, "sender-number2", + "verify-service-sid2", // http config nil, nil, @@ -264,6 +272,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { nil, nil, nil, + nil, // http config "sms-id3", "endpoint3", @@ -286,9 +295,10 @@ func Test_SMSConfigsPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid", - Token: &crypto.CryptoValue{}, - SenderNumber: "sender-number", + SID: "sid", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number", + VerifyServiceSID: "verify-service-sid", }, }, { @@ -301,9 +311,10 @@ func Test_SMSConfigsPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid2", - Token: &crypto.CryptoValue{}, - SenderNumber: "sender-number2", + SID: "sid2", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number2", + VerifyServiceSID: "verify-service-sid2", }, }, { @@ -397,6 +408,7 @@ func Test_SMSConfigPrepare(t *testing.T) { "sid", &crypto.CryptoValue{}, "sender-number", + "verify-service-sid", // http config nil, nil, @@ -413,9 +425,10 @@ func Test_SMSConfigPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid", - SenderNumber: "sender-number", - Token: &crypto.CryptoValue{}, + SID: "sid", + SenderNumber: "sender-number", + Token: &crypto.CryptoValue{}, + VerifyServiceSID: "verify-service-sid", }, }, }, @@ -440,6 +453,7 @@ func Test_SMSConfigPrepare(t *testing.T) { nil, nil, nil, + nil, // http config "sms-id", "endpoint", diff --git a/internal/repository/instance/sms.go b/internal/repository/instance/sms.go index 309ce9aa46..7a402e67fe 100644 --- a/internal/repository/instance/sms.go +++ b/internal/repository/instance/sms.go @@ -28,11 +28,12 @@ const ( type SMSConfigTwilioAddedEvent struct { *eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` - Description string `json:"description,omitempty"` - SID string `json:"sid,omitempty"` - Token *crypto.CryptoValue `json:"token,omitempty"` - SenderNumber string `json:"senderNumber,omitempty"` + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` + SID string `json:"sid,omitempty"` + Token *crypto.CryptoValue `json:"token,omitempty"` + SenderNumber string `json:"senderNumber,omitempty"` + VerifyServiceSID string `json:"verifyServiceSid,omitempty"` } func NewSMSConfigTwilioAddedEvent( @@ -43,6 +44,7 @@ func NewSMSConfigTwilioAddedEvent( sid, senderNumber string, token *crypto.CryptoValue, + verifyServiceSid string, ) *SMSConfigTwilioAddedEvent { return &SMSConfigTwilioAddedEvent{ BaseEvent: eventstore.NewBaseEventForPush( @@ -50,11 +52,12 @@ func NewSMSConfigTwilioAddedEvent( aggregate, SMSConfigTwilioAddedEventType, ), - ID: id, - Description: description, - SID: sid, - Token: token, - SenderNumber: senderNumber, + ID: id, + Description: description, + SID: sid, + Token: token, + SenderNumber: senderNumber, + VerifyServiceSID: verifyServiceSid, } } @@ -73,10 +76,11 @@ func (e *SMSConfigTwilioAddedEvent) UniqueConstraints() []*eventstore.UniqueCons type SMSConfigTwilioChangedEvent struct { *eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` - Description *string `json:"description,omitempty"` - SID *string `json:"sid,omitempty"` - SenderNumber *string `json:"senderNumber,omitempty"` + ID string `json:"id,omitempty"` + Description *string `json:"description,omitempty"` + SID *string `json:"sid,omitempty"` + SenderNumber *string `json:"senderNumber,omitempty"` + VerifyServiceSID *string `json:"verifyServiceSid,omitempty"` } func NewSMSConfigTwilioChangedEvent( @@ -122,6 +126,12 @@ func ChangeSMSConfigTwilioSenderNumber(senderNumber string) func(event *SMSConfi } } +func ChangeSMSConfigTwilioVerifyServiceSID(verifyServiceSID string) func(event *SMSConfigTwilioChangedEvent) { + return func(e *SMSConfigTwilioChangedEvent) { + e.VerifyServiceSID = &verifyServiceSID + } +} + func (e *SMSConfigTwilioChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { e.BaseEvent = event } diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 3e9b727f5a..f5622fd4b4 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -323,6 +324,7 @@ type OTPSMSChallengedEvent struct { Code *crypto.CryptoValue `json:"code"` Expiry time.Duration `json:"expiry"` CodeReturned bool `json:"codeReturned,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } @@ -348,6 +350,7 @@ func NewOTPSMSChallengedEvent( code *crypto.CryptoValue, expiry time.Duration, codeReturned bool, + generatorID string, ) *OTPSMSChallengedEvent { return &OTPSMSChallengedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -358,12 +361,15 @@ func NewOTPSMSChallengedEvent( Code: code, Expiry: expiry, CodeReturned: codeReturned, + GeneratorID: generatorID, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } type OTPSMSSentEvent struct { eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` } func (e *OTPSMSSentEvent) Payload() interface{} { @@ -381,6 +387,7 @@ func (e *OTPSMSSentEvent) SetBaseEvent(base *eventstore.BaseEvent) { func NewOTPSMSSentEvent( ctx context.Context, aggregate *eventstore.Aggregate, + generatorInfo *senders.CodeGeneratorInfo, ) *OTPSMSSentEvent { return &OTPSMSSentEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -388,6 +395,7 @@ func NewOTPSMSSentEvent( aggregate, OTPSMSSentType, ), + GeneratorInfo: generatorInfo, } } diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index 2b726d378a..42bc96280f 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -14,7 +14,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, UserV1SignedOutType, HumanSignedOutEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordChangedType, HumanPasswordChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCodeAddedType, HumanPasswordCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCodeSentType, HumanPasswordCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCodeSentType, eventstore.GenericEventMapper[HumanPasswordCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCheckSucceededType, HumanPasswordCheckSucceededEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCheckFailedType, HumanPasswordCheckFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1EmailChangedType, HumanEmailChangedEventMapper) @@ -27,7 +27,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneVerifiedType, HumanPhoneVerifiedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneVerificationFailedType, HumanPhoneVerificationFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneCodeAddedType, HumanPhoneCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneCodeSentType, HumanPhoneCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneCodeSentType, eventstore.GenericEventMapper[HumanPhoneCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, UserV1ProfileChangedType, HumanProfileChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1AddressChangedType, HumanAddressChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1MFAInitSkippedType, HumanMFAInitSkippedEventMapper) @@ -60,7 +60,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, HumanSignedOutType, HumanSignedOutEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordChangedType, HumanPasswordChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCodeAddedType, HumanPasswordCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCodeSentType, HumanPasswordCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCodeSentType, eventstore.GenericEventMapper[HumanPasswordCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordChangeSentType, HumanPasswordChangeSentEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCheckSucceededType, HumanPasswordCheckSucceededEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCheckFailedType, HumanPasswordCheckFailedEventMapper) @@ -81,7 +81,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneVerifiedType, HumanPhoneVerifiedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneVerificationFailedType, HumanPhoneVerificationFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneCodeAddedType, HumanPhoneCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneCodeSentType, HumanPhoneCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneCodeSentType, eventstore.GenericEventMapper[HumanPhoneCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, HumanProfileChangedType, HumanProfileChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanAvatarAddedType, HumanAvatarAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanAvatarRemovedType, HumanAvatarRemovedEventMapper) diff --git a/internal/repository/user/human_mfa_otp.go b/internal/repository/user/human_mfa_otp.go index f0f3762c81..93706d714e 100644 --- a/internal/repository/user/human_mfa_otp.go +++ b/internal/repository/user/human_mfa_otp.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -280,6 +281,7 @@ type HumanOTPSMSCodeAddedEvent struct { Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` *AuthRequestInfo } @@ -305,6 +307,7 @@ func NewHumanOTPSMSCodeAddedEvent( code *crypto.CryptoValue, expiry time.Duration, info *AuthRequestInfo, + generatorID string, ) *HumanOTPSMSCodeAddedEvent { return &HumanOTPSMSCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -316,15 +319,14 @@ func NewHumanOTPSMSCodeAddedEvent( Expiry: expiry, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), AuthRequestInfo: info, + GeneratorID: generatorID, } } type HumanOTPSMSCodeSentEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` - *AuthRequestInfo + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` } func (e *HumanOTPSMSCodeSentEvent) Payload() interface{} { @@ -342,6 +344,7 @@ func (e *HumanOTPSMSCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { func NewHumanOTPSMSCodeSentEvent( ctx context.Context, aggregate *eventstore.Aggregate, + generatorInfo *senders.CodeGeneratorInfo, ) *HumanOTPSMSCodeSentEvent { return &HumanOTPSMSCodeSentEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -349,6 +352,7 @@ func NewHumanOTPSMSCodeSentEvent( aggregate, HumanOTPSMSCodeSentType, ), + GeneratorInfo: generatorInfo, } } diff --git a/internal/repository/user/human_password.go b/internal/repository/user/human_password.go index c425c144b2..4251a7987f 100644 --- a/internal/repository/user/human_password.go +++ b/internal/repository/user/human_password.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -89,6 +90,7 @@ type HumanPasswordCodeAddedEvent struct { TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` // AuthRequest is only used in V1 Login UI AuthRequestID string `json:"authRequestID,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` } func (e *HumanPasswordCodeAddedEvent) Payload() interface{} { @@ -109,7 +111,8 @@ func NewHumanPasswordCodeAddedEvent( code *crypto.CryptoValue, expiry time.Duration, notificationType domain.NotificationType, - authRequestID string, + authRequestID, + generatorID string, ) *HumanPasswordCodeAddedEvent { return &HumanPasswordCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -122,6 +125,7 @@ func NewHumanPasswordCodeAddedEvent( NotificationType: notificationType, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), AuthRequestID: authRequestID, + GeneratorID: generatorID, } } @@ -162,33 +166,34 @@ func HumanPasswordCodeAddedEventMapper(event eventstore.Event) (eventstore.Event } type HumanPasswordCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` +} + +func (e *HumanPasswordCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event } func (e *HumanPasswordCodeSentEvent) Payload() interface{} { - return nil + return e } func (e *HumanPasswordCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return nil } -func NewHumanPasswordCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanPasswordCodeSentEvent { +func NewHumanPasswordCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate, generatorInfo *senders.CodeGeneratorInfo) *HumanPasswordCodeSentEvent { return &HumanPasswordCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, HumanPasswordCodeSentType, ), + GeneratorInfo: generatorInfo, } } -func HumanPasswordCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &HumanPasswordCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} - type HumanPasswordChangeSentEvent struct { eventstore.BaseEvent `json:"-"` } diff --git a/internal/repository/user/human_phone.go b/internal/repository/user/human_phone.go index 5655b1b3d3..b86cbf93ef 100644 --- a/internal/repository/user/human_phone.go +++ b/internal/repository/user/human_phone.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -151,6 +152,7 @@ type HumanPhoneCodeAddedEvent struct { Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` CodeReturned bool `json:"code_returned,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } @@ -171,15 +173,18 @@ func NewHumanPhoneCodeAddedEvent( aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, + generatorID string, ) *HumanPhoneCodeAddedEvent { - return NewHumanPhoneCodeAddedEventV2(ctx, aggregate, code, expiry, false) + return NewHumanPhoneCodeAddedEventV2(ctx, aggregate, code, expiry, false, generatorID) } + func NewHumanPhoneCodeAddedEventV2( ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, codeReturned bool, + generatorID string, ) *HumanPhoneCodeAddedEvent { return &HumanPhoneCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -190,6 +195,7 @@ func NewHumanPhoneCodeAddedEventV2( Code: code, Expiry: expiry, CodeReturned: codeReturned, + GeneratorID: generatorID, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } @@ -207,7 +213,13 @@ func HumanPhoneCodeAddedEventMapper(event eventstore.Event) (eventstore.Event, e } type HumanPhoneCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` +} + +func (e *HumanPhoneCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event } func (e *HumanPhoneCodeSentEvent) Payload() interface{} { @@ -218,18 +230,13 @@ func (e *HumanPhoneCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstr return nil } -func NewHumanPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanPhoneCodeSentEvent { +func NewHumanPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate, generatorInfo *senders.CodeGeneratorInfo) *HumanPhoneCodeSentEvent { return &HumanPhoneCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, HumanPhoneCodeSentType, ), + GeneratorInfo: generatorInfo, } } - -func HumanPhoneCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &HumanPhoneCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} diff --git a/internal/repository/user/schemauser/phone.go b/internal/repository/user/schemauser/phone.go index a491dab776..9a68168198 100644 --- a/internal/repository/user/schemauser/phone.go +++ b/internal/repository/user/schemauser/phone.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" ) const ( @@ -107,6 +108,7 @@ type PhoneCodeAddedEvent struct { Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` CodeReturned bool `json:"code_returned,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } @@ -132,6 +134,7 @@ func NewPhoneCodeAddedEvent( code *crypto.CryptoValue, expiry time.Duration, codeReturned bool, + generatorID string, ) *PhoneCodeAddedEvent { return &PhoneCodeAddedEvent{ BaseEvent: eventstore.NewBaseEventForPush( @@ -142,12 +145,15 @@ func NewPhoneCodeAddedEvent( Code: code, Expiry: expiry, CodeReturned: codeReturned, + GeneratorID: generatorID, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } type PhoneCodeSentEvent struct { *eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` } func (e *PhoneCodeSentEvent) Payload() interface{} { @@ -162,12 +168,13 @@ func (e *PhoneCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { e.BaseEvent = event } -func NewPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneCodeSentEvent { +func NewPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate, generatorInfo *senders.CodeGeneratorInfo) *PhoneCodeSentEvent { return &PhoneCodeSentEvent{ BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneCodeSentType, ), + GeneratorInfo: generatorInfo, } } diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 4f4e06fd7f..110a8d71e0 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -53,6 +53,7 @@ Errors: NotFound: SMS configuration not found AlreadyActive: SMS configuration already active AlreadyDeactivated: SMS configuration already deactivated + NotExternalVerification: SMS configuration does not support code verification SMTP: NotEmailMessage: message is not EmailMessage RequiredAttributes: subject, recipients and content must be set but some or all of them are empty diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 6e1020cf5e..d3cf774f41 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -5176,11 +5176,10 @@ message AddSMSProviderTwilioRequest { } ]; string sender_number = 3 [ - (validate.rules).string = {min_len: 1, max_len: 200}, - (google.api.field_behavior) = REQUIRED, + (validate.rules).string = {min_len: 0, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; - min_length: 1; + min_length: 0; max_length: 200; } ]; @@ -5192,6 +5191,14 @@ message AddSMSProviderTwilioRequest { max_length: 200; } ]; + string verify_service_sid = 5 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; + min_length: 0; + max_length: 200; + } + ]; } message AddSMSProviderTwilioResponse { @@ -5211,8 +5218,7 @@ message UpdateSMSProviderTwilioRequest { } ]; string sender_number = 3 [ - (validate.rules).string = {min_len: 1, max_len: 200}, - (google.api.field_behavior) = REQUIRED, + (validate.rules).string = {min_len: 0, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; min_length: 1; @@ -5227,6 +5233,14 @@ message UpdateSMSProviderTwilioRequest { max_length: 200; } ]; + string verify_service_sid = 5 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; + min_length: 0; + max_length: 200; + } + ]; } message UpdateSMSProviderTwilioResponse { diff --git a/proto/zitadel/settings.proto b/proto/zitadel/settings.proto index a2a6806c65..c761e1c841 100644 --- a/proto/zitadel/settings.proto +++ b/proto/zitadel/settings.proto @@ -161,6 +161,7 @@ message SMSProvider { message TwilioConfig { string sid = 1; string sender_number = 2; + string verify_service_sid = 3; } message HTTPConfig {