diff --git a/MEETING_SCHEDULE.md b/MEETING_SCHEDULE.md index 671da36fa1..695a19855f 100644 --- a/MEETING_SCHEDULE.md +++ b/MEETING_SCHEDULE.md @@ -3,6 +3,34 @@ Dear community! We're excited to announce bi-weekly office hours. +## #5 Q&A + +Dear community, + +This week's office hour is dedicated for you to drop by and ask any questions you may have about ZITADEL. We are happy to discuss anything, from Actions to Zero downtime deployments. + +Join us on the stage or ask your questions in the chat next Wednesday in the office hours channel on Discord. We're looking forward to have a nice chat with you. + +**What to expect:** + +* **Q&A Session**: Ask your questions and feel free to join the discussion to help others getting their questions answered + +**Details:** + +* **Target Audience:** Developers and IT Ops personnel using ZITADEL +* **Topic:** Q\&A session +* **When**: Wednesday 25th of September 6 pm UTC +* **Duration**: about 1 hour +* **Platform:** Zitadel Discord Server (Join us here: https://discord.gg/zitadel-927474939156643850?event=1286221582838272000 ) + +**In the meantime:** + +If you have questions upfront, feel free to already post them in the chat of the [office hours channel](https://zitadel.com/office-hours) on our Discord server :gigi: + +We look forward to seeing you there\! + +**P.S.** Spread the word\! Share this announcement with your fellow ZITADEL users who might be interested 📢 + ## #4 Login UI deepdive Dear community, diff --git a/Makefile b/Makefile index 17e1bbd9b7..79aaa7f1b2 100644 --- a/Makefile +++ b/Makefile @@ -135,7 +135,7 @@ core_integration_server_start: core_integration_setup .PHONY: core_integration_test_packages core_integration_test_packages: - go test -count 1 -tags integration -timeout 30m $$(go list -tags integration ./... | grep "integration_test") + go test -race -count 1 -tags integration -timeout 30m $$(go list -tags integration ./... | grep "integration_test") .PHONY: core_integration_server_stop core_integration_server_stop: diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index a81a1ff126..81c312137c 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -183,6 +183,37 @@ Database: Cert: # ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_CERT Key: # ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_KEY +# Caches are EXPERIMENTAL. The following config may have breaking changes in the future. +# If no config is provided, caching is disabled by default. +# Caches: + # Connectors are reused by caches. +# Connectors: + # Memory connector works with local server memory. + # It is the simplest (and probably fastest) cache implementation. + # Unsuitable for deployments with multiple containers, + # as each container's cache may hold a different state of the same object. +# Memory: +# Enabled: true + # AutoPrune removes invalidated or expired object from the cache. +# AutoPrune: +# Interval: 15m +# TimeOut: 30s + + # Instance caches auth middleware instances, gettable by domain or ID. +# Instance: + # Connector must be enabled above. + # When connector is empty, this cache will be disabled. +# Connector: "memory" +# MaxAge: 1h +# LastUsage: 10m +# + # Log enables cache-specific logging. Default to error log to stdout when omitted. +# Log: +# Level: debug +# AddSource: true +# Formatter: +# Format: text + Machine: # Cloud-hosted VMs need to specify their metadata endpoint so that the machine can be uniquely identified. Identification: @@ -231,7 +262,7 @@ Projections: # The maximum duration a transaction remains open # before it spots left folding additional events # and updates the table. - TransactionDuration: 500ms # ZITADEL_PROJECTIONS_TRANSACTIONDURATION + TransactionDuration: 1m # ZITADEL_PROJECTIONS_TRANSACTIONDURATION # Time interval between scheduled projections RequeueEvery: 60s # ZITADEL_PROJECTIONS_REQUEUEEVERY # Time between retried database statements resulting from projected events @@ -246,10 +277,7 @@ Projections: HandleActiveInstances: 0s # ZITADEL_PROJECTIONS_HANDLEACTIVEINSTANCES # In the Customizations section, all settings from above can be overwritten for each specific projection Customizations: - Projects: - TransactionDuration: 2s custom_texts: - TransactionDuration: 2s BulkLimit: 400 project_grant_fields: TransactionDuration: 0s diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index af7ba98c5c..c7b83c9d3a 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -25,6 +25,7 @@ import ( auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" "github.com/zitadel/zitadel/internal/authz" authz_es "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/eventstore" + "github.com/zitadel/zitadel/internal/cache" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/systemdefaults" crypto_db "github.com/zitadel/zitadel/internal/crypto/database" @@ -71,6 +72,7 @@ type ProjectionsConfig struct { EncryptionKeys *encryption.EncryptionKeyConfig SystemAPIUsers map[string]*internal_authz.SystemAPIUser Eventstore *eventstore.Config + Caches *cache.CachesConfig Admin admin_es.Config Auth auth_es.Config @@ -132,6 +134,7 @@ func projections( esV4.Querier, client, client, + config.Caches, config.Projections, config.SystemDefaults, keys.IDPConfig, 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 02f1f2aa26..bb1f030b1d 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -15,6 +15,7 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/cache" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/systemdefaults" @@ -30,6 +31,7 @@ import ( type Config struct { ForMirror bool Database database.Config + Caches *cache.CachesConfig SystemDefaults systemdefaults.SystemDefaults InternalAuthZ internal_authz.Config ExternalDomain string @@ -117,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 3cfc28fadb..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") } @@ -309,6 +311,7 @@ func initProjections( eventstoreV4.Querier, queryDBClient, projectionDBClient, + config.Caches, config.Projections, config.SystemDefaults, keys.IDPConfig, diff --git a/cmd/start/config.go b/cmd/start/config.go index 1e36d3310a..ea432e6296 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/login" auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing" + "github.com/zitadel/zitadel/internal/cache" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/network" @@ -48,6 +49,7 @@ type Config struct { HTTP1HostHeader string WebAuthNName string Database database.Config + Caches *cache.CachesConfig Tracing tracing.Config Metrics metrics.Config Profiler profiler.Config diff --git a/cmd/start/start.go b/cmd/start/start.go index 5fc4ba936a..991e4df592 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -184,6 +184,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server eventstoreV4.Querier, queryDBClient, projectionDBClient, + config.Caches, config.Projections, config.SystemDefaults, keys.IDPConfig, @@ -454,7 +455,7 @@ func startAPIs( if err := apis.RegisterService(ctx, userschema_v3_alpha.CreateServer(config.SystemDefaults, commands, queries)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands, keys.User)); err != nil { + if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands)); err != nil { return nil, err } if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { diff --git a/console/package.json b/console/package.json index 1631a206d6..e68ccec202 100644 --- a/console/package.json +++ b/console/package.json @@ -28,7 +28,7 @@ "@fortawesome/angular-fontawesome": "^0.13.0", "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-brands-svg-icons": "^6.4.2", - "@grpc/grpc-js": "^1.11.1", + "@grpc/grpc-js": "^1.11.2", "@netlify/framework-info": "^9.8.13", "@ngx-translate/core": "^15.0.0", "angular-oauth2-oidc": "^15.0.1", @@ -42,14 +42,14 @@ "google-protobuf": "^3.21.2", "grpc-web": "^1.4.1", "i18n-iso-countries": "^7.7.0", - "libphonenumber-js": "^1.11.4", + "libphonenumber-js": "^1.11.8", "material-design-icons-iconfont": "^6.1.1", "moment": "^2.29.4", "ngx-color": "^9.0.0", "opentype.js": "^1.3.4", "rxjs": "~7.8.0", "tinycolor2": "^1.6.0", - "tslib": "^2.6.2", + "tslib": "^2.7.0", "uuid": "^10.0.0", "zone.js": "~0.13.3" }, @@ -60,16 +60,16 @@ "@angular-eslint/eslint-plugin-template": "18.0.0", "@angular-eslint/schematics": "16.2.0", "@angular-eslint/template-parser": "18.3.0", - "@angular/cli": "^16.2.14", + "@angular/cli": "^16.2.15", "@angular/compiler-cli": "^16.2.5", - "@angular/language-service": "^18.2.2", - "@bufbuild/buf": "^1.39.0", + "@angular/language-service": "^18.2.4", + "@bufbuild/buf": "^1.41.0", "@types/file-saver": "^2.0.7", "@types/google-protobuf": "^3.15.3", "@types/jasmine": "~5.1.4", "@types/jasminewd2": "~2.0.13", "@types/jsonwebtoken": "^9.0.6", - "@types/node": "^22.5.2", + "@types/node": "^22.5.5", "@types/opentype.js": "^1.3.8", "@types/qrcode": "^1.5.2", "@types/uuid": "^10.0.0", @@ -77,7 +77,7 @@ "@typescript-eslint/parser": "^5.60.1", "codelyzer": "^6.0.2", "eslint": "^8.50.0", - "jasmine-core": "~5.2.0", + "jasmine-core": "~5.3.0", "jasmine-spec-reporter": "~7.0.0", "karma": "^6.4.2", "karma-chrome-launcher": "^3.2.0", 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/console/yarn.lock b/console/yarn.lock index eec0cc2438..9b87fccf03 100644 --- a/console/yarn.lock +++ b/console/yarn.lock @@ -26,6 +26,14 @@ "@angular-devkit/core" "16.2.14" rxjs "7.8.1" +"@angular-devkit/architect@0.1602.15": + version "0.1602.15" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.1602.15.tgz#b70f2456677f6859d4dac4ad80c6b13d00108797" + integrity sha512-+yPlUG5c8l7Z/A6dyeV7NQjj4WDWnWWQt+8eW/KInwVwoYiM32ntTJ0M4uU/aDdHuwKQnMLly28AcSWPWKYf2Q== + dependencies: + "@angular-devkit/core" "16.2.15" + rxjs "7.8.1" + "@angular-devkit/build-angular@^16.2.2": version "16.2.14" resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-16.2.14.tgz#0c4e41aa3f67e52b474b2fabeb027aebf6e76566" @@ -118,12 +126,24 @@ rxjs "7.8.1" source-map "0.7.4" -"@angular-devkit/schematics@16.2.14": - version "16.2.14" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-16.2.14.tgz#819c2ef8bb298e383cb312d9d1411f5970f0328f" - integrity sha512-B6LQKInCT8w5zx5Pbroext5eFFRTCJdTwHN8GhcVS8IeKCnkeqVTQLjB4lBUg7LEm8Y7UHXwzrVxmk+f+MBXhw== +"@angular-devkit/core@16.2.15": + version "16.2.15" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-16.2.15.tgz#44ef98cda82ef82435a2a41507f8c24720d372df" + integrity sha512-68BgPWpcjNKz++uvLFG8IZaOH3ti2BWQVqaE3yTIYaMoNt0y0A0X2MUVd7EGbAGUk2JdloWJv5LTPVZMzCuK4w== dependencies: - "@angular-devkit/core" "16.2.14" + ajv "8.12.0" + ajv-formats "2.1.1" + jsonc-parser "3.2.0" + picomatch "2.3.1" + rxjs "7.8.1" + source-map "0.7.4" + +"@angular-devkit/schematics@16.2.15": + version "16.2.15" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-16.2.15.tgz#cedcb48fdd240db0a779674cf52455a78a4098bb" + integrity sha512-C/j2EwapdBMf1HWDuH89bA9B2e511iEYImkyZ+vCSXRwGiWUaZCrhl18bvztpErTrdOLM3mCwNXWEAMXI4zUXA== + dependencies: + "@angular-devkit/core" "16.2.15" jsonc-parser "3.2.0" magic-string "0.30.1" ora "5.4.1" @@ -242,15 +262,15 @@ optionalDependencies: parse5 "^7.1.2" -"@angular/cli@^16.2.14": - version "16.2.14" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-16.2.14.tgz#ab58910ae354ee31b89a7479efd5978fd1a3042e" - integrity sha512-0y71jtitigVolm4Rim1b8xPQ+B22cGp4Spef2Wunpqj67UowN6tsZaVuWBEQh4u5xauX8LAHKqsvy37ZPWCc4A== +"@angular/cli@^16.2.15": + version "16.2.15" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-16.2.15.tgz#951d84ef9a7113242b10fe89be1adfa3a94dd6aa" + integrity sha512-nNUmt0ZRj2xHH8tGXSJUiusP5rmakAz0f6cc6T4p03OyeShOKdvs9+/F4hzzsM79/ylZofBlFfwYVCBTbOtMqw== dependencies: - "@angular-devkit/architect" "0.1602.14" - "@angular-devkit/core" "16.2.14" - "@angular-devkit/schematics" "16.2.14" - "@schematics/angular" "16.2.14" + "@angular-devkit/architect" "0.1602.15" + "@angular-devkit/core" "16.2.15" + "@angular-devkit/schematics" "16.2.15" + "@schematics/angular" "16.2.15" "@yarnpkg/lockfile" "1.1.0" ansi-colors "4.1.3" ini "4.1.1" @@ -318,10 +338,10 @@ dependencies: tslib "^2.3.0" -"@angular/language-service@^18.2.2": - version "18.2.2" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-18.2.2.tgz#8a6b3f224871cb4b1dd5d76a43a1c3884d14aa62" - integrity sha512-aROQNQeLf+o+F5OVvE/9BUe/Tpv8pjzmrZlogBbic5cb4IqSNhR4RjxbgIyXBO/6bhLCZwqfmMqRbW2J2xqMkg== +"@angular/language-service@^18.2.4": + version "18.2.4" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-18.2.4.tgz#c449a75bc405bf519fc90f7a9269a98e2a1f7758" + integrity sha512-Keg6n8u8xHLhRDTmx4hUqh1AtVFUt8hDxPMYSUu64czjOT5Dnh8XsgKagu563NEjxbDaCzttPuO+y3DlcaDZoQ== "@angular/material-moment-adapter@^16.2.4": version "16.2.14" @@ -1462,47 +1482,47 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" -"@bufbuild/buf-darwin-arm64@1.39.0": - version "1.39.0" - resolved "https://registry.yarnpkg.com/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.39.0.tgz#0ab8453dc7fc7694e5bd39c69d934edc51b81c81" - integrity sha512-Ptl0uAGssLxQTzoZhGwv1FFTbzUfcstIpEwMhN+XrwiuqsSxOg9eq/n3yXoci5VJsHokjDUHnWkR3y+j5P/5KA== +"@bufbuild/buf-darwin-arm64@1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.41.0.tgz#a6aee96452f5a624eb7e5b0336833fdd7a3a7911" + integrity sha512-+G5DwpIgnm0AkqgxORxoYXVT0RGDcw8P4SXFXcovgvDBkk9rPvEI1dbPF83n3SUxzcu2A2OxC7DxlXszWIh2Gw== -"@bufbuild/buf-darwin-x64@1.39.0": - version "1.39.0" - resolved "https://registry.yarnpkg.com/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.39.0.tgz#9c9a211c8039b8cb89b45bf44f338edf82d5e506" - integrity sha512-XNCuy9sjQwVJ4NIZqxaTIyzUtlyquSkp/Uuoh5W5thJ3nzZ5RSgvXKF5iXHhZmesrfRGApktwoCx5Am8runsfQ== +"@bufbuild/buf-darwin-x64@1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.41.0.tgz#aac6a6b86f6d1f30c86f70e918d212e067e5257f" + integrity sha512-qjkJ/LAWqNk3HX65n+JTt18WtKrhrrAhIu3Dpfbe0eujsxafFZKoPzlWJYybxvsaF9CdEyMMm/OalBPpoosMOA== -"@bufbuild/buf-linux-aarch64@1.39.0": - version "1.39.0" - resolved "https://registry.yarnpkg.com/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.39.0.tgz#9778732efbdbbfe02ec821017cc2392ce4a0153f" - integrity sha512-Am+hrw94awp/eY027ROXwRQBuwAzOpQ/4zI4dgmgsyhzeWZ8w1LWC8z2SSr8T2cqd0cm52KxtoWMW+B3b2qzbw== +"@bufbuild/buf-linux-aarch64@1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.41.0.tgz#8ac97e7a19cf0c0957ca1b3e690d8c039b0b3468" + integrity sha512-5E+MLAF4QHPwAjwVVRRP3Is2U3zpIpQQR7S3di9HlKACbgvefJEBrUfRqQZvHrMuuynQRqjFuZD16Sfvxn9rCQ== -"@bufbuild/buf-linux-x64@1.39.0": - version "1.39.0" - resolved "https://registry.yarnpkg.com/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.39.0.tgz#d7ca62c4f506c60011f5a97ca2e8683aa26693b0" - integrity sha512-JXVkHoMrTvmpseqdoQPJJ6MRV7/vlloYtvXHHACEzVytYjljOYCNoVET/E5gLBco/edeXFMNc40cCi1KgL3rSw== +"@bufbuild/buf-linux-x64@1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.41.0.tgz#8a272846929215affccb9c271f02948e10f8d4a9" + integrity sha512-W4T+uqmdtypzzatv6OXjUzGacZiNzGECogr+qDkJF38MSZd3jHXhTEN2KhRckl3i9rRAnfHBwG68BjCTxxBCOQ== -"@bufbuild/buf-win32-arm64@1.39.0": - version "1.39.0" - resolved "https://registry.yarnpkg.com/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.39.0.tgz#efdaf1eca30445f04124c6d829a46a676e6b1dc3" - integrity sha512-akdGW02mo04wbLfjNMBQqxC4mPQ/L/vTU8/o79I67GSxyFYt7bKifvYIYhAA39C2gibHyB7ZLmoeRPbaU8wbYA== +"@bufbuild/buf-win32-arm64@1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.41.0.tgz#e26b67b2da15e284326c3d3c38255b443e201e0b" + integrity sha512-OsRVoTZHJZYGIphAwaRqcCeYR9Sk5VEMjpCJiFt/dkHxx2acKH4u/7O+633gcCxQL8EnsU2l8AfdbW7sQaOvlg== -"@bufbuild/buf-win32-x64@1.39.0": - version "1.39.0" - resolved "https://registry.yarnpkg.com/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.39.0.tgz#09f2b0290818d826847689d6149f8fb0def4ac4b" - integrity sha512-jos08UMg9iUZsGjPrNpLXP+FNk6q6GizO+bjee/GcI0kSijIzXYMg14goQr0TKlvqs/+IRAM5vZIokQBYlAENQ== +"@bufbuild/buf-win32-x64@1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.41.0.tgz#b2ff4e9cdb9f73baaad216d35c91c733c4c4b661" + integrity sha512-2KJLp7Py0GsfRjDxwBzS17RMpaYFGCvzkwY5CtxfPMw8cg6cE7E36r+vcjHh5dBOj/CumaiXLTwxhCSBtp0V1g== -"@bufbuild/buf@^1.39.0": - version "1.39.0" - resolved "https://registry.yarnpkg.com/@bufbuild/buf/-/buf-1.39.0.tgz#65884f55d072b93122959c92b389c1d7d8ab510b" - integrity sha512-lm7xb9pc7X04rRjCQ69o9byAAZ7/dsUQGoH+iJ9uBSXQWiwQ1Ts8gneBnuUVsAH2vdW73NFBpmNQGE9XtFauVQ== +"@bufbuild/buf@^1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf/-/buf-1.41.0.tgz#76077338696009c2f34e7ca1c76baf89a04079f5" + integrity sha512-6pN2fqMrPqnIkrC1q9KpXpu7fv3Rul0ZPhT4MSYYj+8VcyR3kbLVk6K+CzzPvYhr4itfotnI3ZVGQ/X/vupECg== optionalDependencies: - "@bufbuild/buf-darwin-arm64" "1.39.0" - "@bufbuild/buf-darwin-x64" "1.39.0" - "@bufbuild/buf-linux-aarch64" "1.39.0" - "@bufbuild/buf-linux-x64" "1.39.0" - "@bufbuild/buf-win32-arm64" "1.39.0" - "@bufbuild/buf-win32-x64" "1.39.0" + "@bufbuild/buf-darwin-arm64" "1.41.0" + "@bufbuild/buf-darwin-x64" "1.41.0" + "@bufbuild/buf-linux-aarch64" "1.41.0" + "@bufbuild/buf-linux-x64" "1.41.0" + "@bufbuild/buf-win32-arm64" "1.41.0" + "@bufbuild/buf-win32-x64" "1.41.0" "@colors/colors@1.5.0": version "1.5.0" @@ -1810,10 +1830,10 @@ resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== -"@grpc/grpc-js@^1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.11.1.tgz#a92f33e98f1959feffcd1b25a33b113d2c977b70" - integrity sha512-gyt/WayZrVPH2w/UTLansS7F9Nwld472JxxaETamrM8HNlsa+jSLNyKAZmhxI2Me4c3mQHFiS1wWHDY1g1Kthw== +"@grpc/grpc-js@^1.11.2": + version "1.11.2" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.11.2.tgz#541a00303e533b5efe9d84ed61b84cdf9dd93ee7" + integrity sha512-DWp92gDD7/Qkj7r8kus6/HCINeo3yPZWZ3paKgDgsbKbSpoxKg1yvN8xe2Q8uE3zOsPe3bX8FQX2+XValq2yTw== dependencies: "@grpc/proto-loader" "^0.7.13" "@js-sdsl/ordered-map" "^4.4.2" @@ -2884,13 +2904,13 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== -"@schematics/angular@16.2.14": - version "16.2.14" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-16.2.14.tgz#3aac7e05b6e3919195275cf06ac403d7a3567876" - integrity sha512-YqIv727l9Qze8/OL6H9mBHc2jVXzAGRNBYnxYWqWhLbfvuVbbldo6NNIIjgv6lrl2LJSdPAAMNOD5m/f6210ug== +"@schematics/angular@16.2.15": + version "16.2.15" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-16.2.15.tgz#f3b810842959808f0d65ce816f4f0c1a7463c176" + integrity sha512-T7wEGYxidpLAkis+hO5nsVfnWsy6sXf1T9GS8uztC8IYYsnqB9jTVfjVyfhASugZasdmx7+jWv3oCGy6Z5ZehA== dependencies: - "@angular-devkit/core" "16.2.14" - "@angular-devkit/schematics" "16.2.14" + "@angular-devkit/core" "16.2.15" + "@angular-devkit/schematics" "16.2.15" jsonc-parser "3.2.0" "@sigstore/bundle@^1.1.0": @@ -3098,10 +3118,10 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=13.7.0", "@types/node@^22.5.2": - version "22.5.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.2.tgz#e42344429702e69e28c839a7e16a8262a8086793" - integrity sha512-acJsPTEqYqulZS/Yp/S3GgeE6GZ0qYODUR8aVr/DkhHQ8l9nd4j5x1/ZJy9/gHrRlFMqkO6i0I3E27Alu4jjPg== +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=13.7.0", "@types/node@^22.5.5": + version "22.5.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.5.tgz#52f939dd0f65fc552a4ad0b392f3c466cc5d7a44" + integrity sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA== dependencies: undici-types "~6.19.2" @@ -3978,10 +3998,10 @@ blocking-proxy@^1.0.0: dependencies: minimist "^1.2.0" -body-parser@1.20.2, body-parser@^1.19.0: - version "1.20.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" - integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== +body-parser@1.20.3, body-parser@^1.19.0: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== dependencies: bytes "3.1.2" content-type "~1.0.5" @@ -3991,7 +4011,7 @@ body-parser@1.20.2, body-parser@^1.19.0: http-errors "2.0.0" iconv-lite "0.4.24" on-finished "2.4.1" - qs "6.11.0" + qs "6.13.0" raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" @@ -4902,6 +4922,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -5276,36 +5301,36 @@ exponential-backoff@^3.1.1: integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== express@^4.17.3: - version "4.19.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" - integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== + version "4.20.0" + resolved "https://registry.yarnpkg.com/express/-/express-4.20.0.tgz#f1d08e591fcec770c07be4767af8eb9bcfd67c48" + integrity sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.2" + body-parser "1.20.3" content-disposition "0.5.4" content-type "~1.0.4" cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" finalhandler "1.2.0" fresh "0.5.2" http-errors "2.0.0" - merge-descriptors "1.0.1" + merge-descriptors "1.0.3" methods "~1.1.2" on-finished "2.4.1" parseurl "~1.3.3" - path-to-regexp "0.1.7" + path-to-regexp "0.1.10" proxy-addr "~2.0.7" qs "6.11.0" range-parser "~1.2.1" safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" + send "0.19.0" + serve-static "1.16.0" setprototypeof "1.2.0" statuses "2.0.1" type-is "~1.6.18" @@ -6482,10 +6507,10 @@ jasmine-core@~2.8.0: resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.8.0.tgz#bcc979ae1f9fd05701e45e52e65d3a5d63f1a24e" integrity sha512-SNkOkS+/jMZvLhuSx1fjhcNWUC/KG6oVyFUGkSBEr9n1axSNduWU8GlI7suaHXr4yxjet6KjrUZxUTE5WzzWwQ== -jasmine-core@~5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-5.2.0.tgz#7d0aa4c26cb3dbaed201d0505489baf1e48faeca" - integrity sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw== +jasmine-core@~5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-5.3.0.tgz#ed784e5a10af43988d8408bad80b9f08e240a3f8" + integrity sha512-zsOmeBKESky4toybvWEikRiZ0jHoBEu79wNArLfMdSnlLMZx3Xcp6CSm2sUcYyoJC+Uyj8LBJap/MUbVSfJ27g== jasmine-spec-reporter@~7.0.0: version "7.0.0" @@ -6810,10 +6835,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.11.4: - version "1.11.5" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.11.5.tgz#50a441da5ff9ed9a322d796a14f1e9cbc0fdabdf" - integrity sha512-TwHR5BZxGRODtAfz03szucAkjT5OArXr+94SMtAM2pYXIlQNVMrxvb6uSCbnaJJV6QXEyICk7+l6QPgn72WHhg== +libphonenumber-js@^1.11.8: + version "1.11.8" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.11.8.tgz#697fdd36500a97bc672d7927d867edf34b4bd2a7" + integrity sha512-0fv/YKpJBAgXKy0kaS3fnqoUVN8901vUYAKIGD/MWZaDfhJt1nZjPL3ZzdZBt/G8G8Hw2J1xOIrXWdNHFHPAvg== license-webpack-plugin@4.0.2: version "4.0.2" @@ -7034,10 +7059,10 @@ memfs@^3.4.12, memfs@^3.4.3: dependencies: fs-monkey "^1.0.4" -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== merge-stream@^2.0.0: version "2.0.0" @@ -7855,10 +7880,10 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-to-regexp@0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" + integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== path-type@^4.0.0: version "4.0.0" @@ -8145,6 +8170,13 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" +qs@6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== + dependencies: + side-channel "^1.0.6" + qs@~6.5.2: version "6.5.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" @@ -8618,6 +8650,25 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" @@ -8638,10 +8689,10 @@ serve-index@^1.9.1: mime-types "~2.1.17" parseurl "~1.3.2" -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== +serve-static@1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.0.tgz#2bf4ed49f8af311b519c46f272bf6ac3baf38a92" + integrity sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA== dependencies: encodeurl "~1.0.2" escape-html "~1.0.3" @@ -8704,7 +8755,7 @@ shell-quote@^1.8.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== -side-channel@^1.0.4: +side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== @@ -8956,7 +9007,16 @@ streamroller@^3.1.5: debug "^4.3.4" fs-extra "^8.1.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8993,7 +9053,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9007,6 +9067,13 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9269,10 +9336,10 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.6.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== tsutils@^3.21.0: version "3.21.0" @@ -9781,7 +9848,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9799,6 +9866,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" diff --git a/docs/docs/examples/imports/_setup_pylon.mdx b/docs/docs/examples/imports/_setup_pylon.mdx deleted file mode 100644 index e68dad2c3c..0000000000 --- a/docs/docs/examples/imports/_setup_pylon.mdx +++ /dev/null @@ -1 +0,0 @@ -You have to install Pylon as described in [their documentation](https://pylon.cronit.io/docs/installation/). diff --git a/docs/docs/examples/secure-api/pylon.mdx b/docs/docs/examples/secure-api/pylon.mdx index 81f66999af..c8a3cd4635 100644 --- a/docs/docs/examples/secure-api/pylon.mdx +++ b/docs/docs/examples/secure-api/pylon.mdx @@ -6,7 +6,6 @@ sidebar_label: Pylon import AppJWT from "../imports/_app_jwt.mdx"; import ServiceuserJWT from "../imports/_serviceuser_jwt.mdx"; import ServiceuserRole from "../imports/_serviceuser_role.mdx"; -import SetupPylon from "../imports/_setup_pylon.mdx"; This integration guide demonstrates the recommended way to incorporate ZITADEL into your [Pylon](https://pylon.cronit.io) service. It explains how to check the token validity in the API and how to check for permissions. @@ -43,26 +42,27 @@ And the following from the Serviceuser: ## Setup new Pylon service -### Setup Pylon +Pylon allows you to create a new service using the `npm create pylon` command. This command creates a new Pylon project with a basic project structure and configuration. +During the setup process, you can choose your preferred runtime, such as Bun, Node.js, or Cloudflare Workers. - +**This guide uses the Bun runtime.** ### Creating a new project To create a new Pylon project, run the following command: ```bash -pylon new my-pylon-project +npm create pylon my-pylon@latest ``` -This will create a new directory called `my-pylon-project` with a basic Pylon project structure. +This will create a new directory called `my-pylon` with a basic Pylon project structure. ### Project structure Pylon projects are structured as follows: ``` -my-pylon-project/ +my-pylon/ ├── .pylon/ ├── src/ │ ├── index.ts @@ -81,16 +81,18 @@ my-pylon-project/ Here's an example of a basic Pylon service: ```ts -import { defineService } from "@getcronit/pylon"; +import { app } from "@getcronit/pylon"; -export default defineService({ +export const graphql = { Query: { sum: (a: number, b: number) => a + b, }, Mutation: { divide: (a: number, b: number) => a / b, }, -}); +}; + +export default app; ``` ## Secure the API @@ -113,6 +115,8 @@ AUTH_PROJECT_ID='250719519163548112' 2. Copy the `.json`-key-file that you downloaded from the ZITADEL Console into the root folder of your project and rename it to `key.json`. +3. (Optional) For added convenience in production environments, you can include the content of the .json key file as `AUTH_KEY` in the .env file or as an environment variable. + ### Auth Pylon provides a auth module and a decorator to check the validity of the token and the permissions. @@ -140,8 +144,7 @@ The following code demonstrates how to create a Pylon service with the required ```ts import { - defineService, - PylonAPI, + app, auth, requireAuth, getContext, @@ -208,7 +211,7 @@ class User { } } -export default defineService({ +export const graphql = { Query: { me: User.me, info: () => "Public Data", @@ -216,43 +219,43 @@ export default defineService({ Mutation: { createUser: User.create, }, +}; + +// Initialize the authentication middleware +app.use("*", auth.initialize()); + +// Automatically try to create a user for each request for demonstration purposes +app.use(async (_, next) => { + try { + await User.create(); + } catch { + // Ignore errors + // Fail silently if the user already exists + } + + await next(); }); -export const configureApp: PylonAPI["configureApp"] = (app) => { - // Initialize the authentication middleware - app.use("*", auth.initialize()); +app.get("/api/info", (c) => { + return new Response("Public Data"); +}); - // Automatically try to create a user for each request for demonstration purposes - app.use(async (_, next) => { - try { - await User.create(); - } catch { - // Ignore errors - // Fail silently if the user already exists - } +// The `auth.require()` middleware is optional here, as the `User.me` method already checks for it. +app.get("/api/me", auth.require(), async (c) => { + const user = await User.me(); - await next(); - }); + return c.json(user); +}); - app.get("/api/info", (c) => { - return new Response("Public Data"); - }); +// A role check for `read:messages` is not required here, as the `user.messages` method already checks for it. +app.get("/api/me/messages", auth.require(), async (c) => { + const user = await User.me(); - // The `auth.require()` middleware is optional here, as the `User.me` method already checks for it. - app.get("/api/me", auth.require(), async (c) => { - const user = await User.me(); + // This will throw an error if the user does not have the `read:messages` role + return c.json(await user.messages()); +}); - return c.json(user); - }); - - // A role check for `read:messages` is not required here, as the `user.messages` method already checks for it. - app.get("/api/me/messages", auth.require(), async (c) => { - const user = await User.me(); - - // This will throw an error if the user does not have the `read:messages` role - return c.json(await user.messages()); - }); -}; +export default app; ``` ### Call the API @@ -273,7 +276,7 @@ export TOKEN='MtjHodGy4zxKylDOhg6kW90WeEQs2q...' Now you have to start the Pylon service: ```bash -bun run develop +bun run dev ``` With the access token, you can then do the following calls: 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/docs/guides/manage/customize/notification-providers.mdx b/docs/docs/guides/manage/customize/notification-providers.mdx new file mode 100644 index 0000000000..b94ce7542d --- /dev/null +++ b/docs/docs/guides/manage/customize/notification-providers.mdx @@ -0,0 +1,166 @@ +--- +title: SMS, SMTP and HTTP Provider for Notifications +sidebar_label: Notification Providers +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +ZITADEL can send messages to users via different notification providers, such as SMS, SMTP, or Webhook (HTTP Provider). +While you can add multiple providers to different channels, messages will only be delivered via the actived provider. +Message and notification texts can be [customized](./texts) for an instance or for each organization. + +## SMS providers + +ZITADEL integrates with Twilio as SMS provider. + +## SMTP providers + +Integration with most SMTP providers is possible through a generic SMTP provider template that allows you to configure custom SMTP providers. +Additionally, integration templates are available for: + +- Amazon SES +- Mailgun +- Mailjet +- Postmark +- Sendgrid + +:::info Default Provider ZITADEL Cloud +A default SMTP provider is configured for ZITADEL Cloud customers. +This provider meant for development and testing purposes and you must replace this provider with your custom SMTP provider for production use cases to guarantee security and reliability of your service. +::: + +## Webhook / HTTP provider + +Webhook (HTTP Provider) allows you to fully customize the messages and use integrate with any provider or custom solution to deliver the messages to users. +A provider with HTTP type will send the messages and the data to a pre-defined webhook as JSON. + +### Configuring a HTTP provider + + + + + First [add a new SMS Provider of type HTTP](/apis/resources/admin/admin-service-add-sms-provider-http) to create a new HTTP provider that can be used to send SMS messages: + + ```bash + curl -L 'https://$CUSTOM-DOMAIN/admin/v1/sms/http' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "endpoint": "http://relay.example.com/provider", + "description": "provider description" + }' + ``` + + Where `endpoint` defines the Webhook endpoint to which the data should be sent to. + The result will contain an ID of the provider that we need in the next step. + + You can configure multiple SMS providers at the same time. + To use the HTTP provider you need to [activate the SMS provider](/apis/resources/admin/admin-service-activate-sms-provider): + + ```bash + curl -L 'https://$CUSTOM-DOMAIN/admin/v1/sms/:id/_activate' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{}' + ``` + + The `id` is the provider's ID from the previous step. + + See full API reference for [SMS Providers](/apis/resources/admin/sms-provider) for more details. + + + + + First [add a new Email Provider of type HTTP](/apis/resources/admin/admin-service-add-email-provider-http) to create a new HTTP provider that can be used to send SMS messages: + + ```bash + curl -L 'https://$CUSTOM-DOMAIN/admin/v1/email/http' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "endpoint": "http://relay.example.com/provider", + "description": "provider description" + }' + ``` + + Where `endpoint` defines the Webhook endpoint to which the data should be sent to. + The result will contain an ID of the provider that we need in the next step. + + You can configure multiple Email providers at the same time. + To use the HTTP provider you need to [activate the Email provider](/apis/resources/admin/admin-service-activate-email-provider): + + ```bash + curl -L 'https://$CUSTOM-DOMAIN/admin/v1/email/:id/_activate' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{}' + ``` + + The `id` is the provider's ID from the previous step. + + See full API reference for [Email Providers](/apis/resources/admin/admin-service-list-email-providers) for more details. + + + + +### HTTP provider payload + +In case of the Twilio and Email providers, the messages will be sent as before, in case of the HTTP providers the content of the messages is the same but as a HTTP call. + +Here an example of the body of an payload sent via Email HTTP provider: + +```json +{ + "contextInfo": { + "eventType": "user.human.initialization.code.added", + "provider": { + "id": "285181292935381355", + "description": "test" + }, + "recipientEmailAddress": "example@zitadel.com" + }, + "templateData": { + "title": "Zitadel - Initialize User", + "preHeader": "Initialize User", + "subject": "Initialize User", + "greeting": "Hello GivenName FamilyName,", + "text": "This user was created in Zitadel. Use the username Username to login. Please click the button below to finish the initialization process. (Code 0M53RF) If you didn't ask for this mail, please ignore it.", + "url": "http://example.zitadel.com/ui/login/user/init?authRequestID=\u0026code=0M53RF\u0026loginname=Username\u0026orgID=275353657317327214\u0026passwordset=false\u0026userID=285181014567813483", + "buttonText": "Finish initialization", + "primaryColor": "#5469d4", + "backgroundColor": "#fafafa", + "fontColor": "#000000", + "fontFamily": "-apple-system, BlinkMacSystemFont, Segoe UI, Lato, Arial, Helvetica, sans-serif", + "footerText": "InitCode.Footer" + }, + "args": { + "changeDate": "2024-09-16T10:58:50.73237+02:00", + "code": "0M53RF", + "creationDate": "2024-09-16T10:58:50.73237+02:00", + "displayName": "GivenName FamilyName", + "firstName": "GivenName", + "lastEmail": "example@zitadel.com", + "lastName": "FamilyName", + "lastPhone": "+41791234567", + "loginNames": [ + "Username" + ], + "nickName": "", + "preferredLoginName": "Username", + "userName": "Username", + "verifiedEmail": "example@zitadel.com", + "verifiedPhone": "" + } +} +``` + +There are 3 elements to this message: + +- `contextInfo`, with information on why this message is sent like the Event, which Email or SMS provider is used and which recipient should receive this message +- `templateData`, with all texts and format information which can be used with a template to produce the desired message +- `args`, with the information provided to the user which can be used in the message to customize \ No newline at end of file diff --git a/docs/docs/guides/manage/customize/notifications.md b/docs/docs/guides/manage/customize/notifications.md deleted file mode 100644 index 6ab69a9335..0000000000 --- a/docs/docs/guides/manage/customize/notifications.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: SMS, SMTP and HTTP Provider for Notifications ---- - -All Notifications send as SMS and Email are customizable as that you can define your own providers, -which then send the notifications out. These providers can also be defined as an HTTP type, -and the text and content, which is used to send the SMS's and Emails will get send to a Webhook as JSON. - -With this everything can be customized or even custom logic can be implemented to use a not yet supported provider by ZITADEL. - -## How it works - -There is a default provider configured in ZITADEL Cloud, both for SMS's and Emails, but this default providers can be changed through the respective API's. - -This API's are provided on an instance level: -- [SMS Providers](/apis/resources/admin/sms-provider) -- [Email Providers](/apis/resources/admin/email-provider) - -To use a non-default provider just add, and then activate. There can only be 1 provider be activated at the same time. - -## Resulting messages - -In case of the Twilio and SMTP providers, the messages will be sent as before, in case of the HTTP providers the content of the messages is the same but as a HTTP call. - -Here an example of the body of an Email sent via HTTP provider: -```json -{ - "contextInfo": { - "eventType": "user.human.initialization.code.added", - "provider": { - "id": "285181292935381355", - "description": "test" - }, - "recipientEmailAddress": "example@zitadel.com" - }, - "templateData": { - "title": "Zitadel - Initialize User", - "preHeader": "Initialize User", - "subject": "Initialize User", - "greeting": "Hello GivenName FamilyName,", - "text": "This user was created in Zitadel. Use the username Username to login. Please click the button below to finish the initialization process. (Code 0M53RF) If you didn't ask for this mail, please ignore it.", - "url": "http://example.zitadel.com/ui/login/user/init?authRequestID=\u0026code=0M53RF\u0026loginname=Username\u0026orgID=275353657317327214\u0026passwordset=false\u0026userID=285181014567813483", - "buttonText": "Finish initialization", - "primaryColor": "#5469d4", - "backgroundColor": "#fafafa", - "fontColor": "#000000", - "fontFamily": "-apple-system, BlinkMacSystemFont, Segoe UI, Lato, Arial, Helvetica, sans-serif", - "footerText": "InitCode.Footer" - }, - "args": { - "changeDate": "2024-09-16T10:58:50.73237+02:00", - "code": "0M53RF", - "creationDate": "2024-09-16T10:58:50.73237+02:00", - "displayName": "GivenName FamilyName", - "firstName": "GivenName", - "lastEmail": "example@zitadel.com", - "lastName": "FamilyName", - "lastPhone": "+41791234567", - "loginNames": [ - "Username" - ], - "nickName": "", - "preferredLoginName": "Username", - "userName": "Username", - "verifiedEmail": "example@zitadel.com", - "verifiedPhone": "" - } -} -``` - -There are 3 elements to this message: -- contextInfo, with information on why this message is sent like the Event, which Email or SMS provider is used and which recipient should receive this message -- templateData, with all texts and format information which can be used with a template to produce the desired message -- args, with the information provided to the user which can be used in the message to customize diff --git a/docs/docs/guides/overview.mdx b/docs/docs/guides/overview.mdx index d01a4a6903..7fd7c2aa70 100644 --- a/docs/docs/guides/overview.mdx +++ b/docs/docs/guides/overview.mdx @@ -23,7 +23,7 @@ If you're unsure, consider the generous free tier of [ZITADEL Cloud](./manage/cl Choose [ZITADEL Cloud](./manage/cloud/overview) if you want: -- A turnkey solution that's ready to go- A generous free tier with an excellent pay-as-you-go option +- A turnkey solution that's ready to go, offering a generous free tier with an excellent pay-as-you-go option. - Global scalability without the hassle of managing it yourself - Data-residency compliance for your customers @@ -47,4 +47,4 @@ ZITADEL holds bi-weekly community calls. To join the community calls use [this l ZITADEL is open source — and so is the documentation. -If you find any inaccuracies, spelling mistakes, or unclear text passages, please don't hesitate to leave a comment or [contribute a corresponding change](https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md). \ No newline at end of file +If you find any inaccuracies, spelling mistakes, or unclear text passages, please don't hesitate to leave a comment or [contribute a corresponding change](https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md). diff --git a/docs/docs/sdk-examples/introduction.mdx b/docs/docs/sdk-examples/introduction.mdx index 45e2465fb8..de928731c3 100644 --- a/docs/docs/sdk-examples/introduction.mdx +++ b/docs/docs/sdk-examples/introduction.mdx @@ -1,6 +1,7 @@ --- -title: Introduction +title: Examples and SDKs for ZITADEL sidebar_label: Introduction +sidebar_position: 1 --- You can integrate ZITADEL quickly into your application and be up and running within minutes. @@ -10,7 +11,7 @@ The SDKs and integration depend on the framework and language you are using. import { Frameworks } from "../../src/components/frameworks"; -### Resources +## Resources @@ -20,7 +21,7 @@ To further streamline your setup, simply visit the console in ZITADEL where you To begin configuring login for any of these samples, start [here](https://zitadel.com/signin). -### OIDC Libraries +## OIDC Libraries OIDC is a standard for authentication and most languages and frameworks do provide a OIDC library which can be easily integrated to your application. If we do not provide an specific example, SDK or guide, we strongly recommend using existing authentication libraries for your @@ -34,7 +35,7 @@ You might want to check out the following links to find a good library: - [OpenID General References](https://openid.net/developers/libraries/) - [OpenID certified developer tools](https://openid.net/certified-open-id-developer-tools/) -### Other example applications +## Other example applications - [B2B customer portal](https://github.com/zitadel/zitadel-nextjs-b2b): Showcase the use of personal access tokens in a B2B environment. Uses NextJS Framework. - [Frontend with backend API](https://github.com/zitadel/example-quote-generator-app): A simple web application using a React front-end and a Python back-end API, both secured using ZITADEL @@ -43,7 +44,7 @@ You might want to check out the following links to find a good library: Search for the "example" tag in our repository to [explore all examples](https://github.com/search?q=topic%3Aexamples+org%3Azitadel&type=repositories). -### Missing SDK +## Missing SDK Is your language/framework missing? Fear not, you can generate your gRPC API Client with ease. @@ -54,7 +55,7 @@ Is your language/framework missing? Fear not, you can generate your gRPC API Cli Let us make an example with Ruby. Any other supported language by buf will work as well. Consult the [buf plugin registry](https://buf.build/plugins) for more ideas. -#### Example with Ruby +### Example with Ruby With gRPC we usually need to generate the client stub and the messages/types. This is why we need two plugins. The plugin `grpc/ruby` generates the client stub and the plugin `protocolbuffers/ruby` takes care of the messages/types. diff --git a/docs/docs/support/advisory/a10012.md b/docs/docs/support/advisory/a10012.md new file mode 100644 index 0000000000..fd08a9afd6 --- /dev/null +++ b/docs/docs/support/advisory/a10012.md @@ -0,0 +1,60 @@ +--- +title: Technical Advisory 10012 +--- + +## Date and Version + +Version: 2.63.0 + +Date: 2024-09-26 + +## Description + +In version 2.63.0 we've increased the transaction duration for projections. + +ZITADEL has an event driven architecture. After events are pushed to the eventstore, +they are reduced into projections in bulk batches. Projections allow for efficient lookup of data through normalized SQL tables. + +We've investigated multiple reports of outdated projections. +For example created users missing in get requests, or missing data after a ZITADEL upgrade[^1]. +The conclusion is that the transaction in which we perform a bulk of queries can timeout. +The old setting defined a transaction duration of 500ms for a bulk of 200 events. +A single event may create multiple statements in a single projection. +A timeout may occur even if the actual bulk size is less than 200, +which then results in more back-pressure on a busy system, leading to more timeouts and effectively dead-locking a projection. + +Increasing or disabling the projection transaction duration solved dead-locks in all reported cases. +We've decided to increase the transaction duration to 1 minute. +Due to the high value it is functionally similar to disabling, +however it still provides a safety net for transaction that do freeze, +perhaps due to connection issues with the database. + +[^1]: Changes written to the eventstore are the main source of truth. When a projection is out of date, some request may serve incomplete or no data. The data itself is however not lost. + +## Statement + +A summary of bug reports can be found in the following issue: [Missing data due to outdated projections](https://github.com/zitadel/zitadel/issues/8517). +This change was submitted in the following PR: +[fix(projection): increase transaction duration](https://github.com/zitadel/zitadel/pull/8632), which will be released in Version [2.63.0](https://github.com/zitadel/zitadel/releases/tag/v2.63.0) + +## Mitigation + +If you have a custom configuration for projections, this update will not apply to your system or some projections. When encountering projection dead-lock consider increasing the timeout to the new default value. + +Note that entries under `Customizations` overwrite the global settings for a single projection. + +```yaml +Projections: + TransactionDuration: 1m # ZITADEL_PROJECTIONS_TRANSACTIONDURATION + BulkLimit: 200 # ZITADEL_PROJECTIONS_BULKLIMIT + Customizations: + custom_texts: + BulkLimit: 400 + project_grant_fields: + TransactionDuration: 0s + BulkLimit: 2000 +``` + +## Impact + +Once this update has been released and deployed, transactions are allowed to run longer. No other functional impact is expected. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 0318ad074a..c0c7d5a45c 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -187,7 +187,7 @@ module.exports = { selector: "div#", }, prism: { - additionalLanguages: ["csharp", "dart", "groovy", "regex", "java", "php", "python", "protobuf"], + additionalLanguages: ["csharp", "dart", "groovy", "regex", "java", "php", "python", "protobuf", "json", "bash"], }, colorMode: { defaultMode: "dark", diff --git a/docs/sidebars.js b/docs/sidebars.js index 4d8ec9f48d..38596d4bfc 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -54,18 +54,10 @@ module.exports = { label: "Examples & SDKs", link: {type: "doc", id: "sdk-examples/introduction"}, items: [ - "sdk-examples/introduction", - "sdk-examples/angular", - "sdk-examples/flutter", - "sdk-examples/go", - "sdk-examples/java", - "sdk-examples/nestjs", - "sdk-examples/nextjs", - "sdk-examples/python-flask", - "sdk-examples/python-django", - "sdk-examples/react", - "sdk-examples/symfony", - "sdk-examples/vue", + { + type: "autogenerated", + dirName: "sdk-examples" + }, { type: "link", label: "Dart", @@ -154,11 +146,10 @@ module.exports = { type: "category", label: "Customize", items: [ - "guides/manage/customize/branding", - "guides/manage/customize/texts", - "guides/manage/customize/behavior", - "guides/manage/customize/restrictions", - "guides/manage/customize/notifications", + { + type: "autogenerated", + dirName: "guides/manage/customize", + }, ], }, { diff --git a/docs/src/components/tile.jsx b/docs/src/components/tile.jsx index e1066325ce..1ce2a1c1f8 100644 --- a/docs/src/components/tile.jsx +++ b/docs/src/components/tile.jsx @@ -8,7 +8,7 @@ export function Tile({ title, imageSource, imageSourceLight, link, external }) { className={styles.tile} target={external ? "_blank" : "_self"} > -

{title}

+

{title}

0", assert.Greater, zeroCounts) + counts.assertAll(c, "seeded events are > 0", assert.Greater, zeroCounts) }, "wait for seeded event assertions to pass") produceEvents(iamOwnerCtx, t, isoInstance.Client, userID, appID, projectID, projectGrantID) addedCount := requireEventually(t, iamOwnerCtx, isoInstance.Client, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { - counts.assertAll(t, c, "added events are > seeded events", assert.Greater, seededCount) + counts.assertAll(c, "added events are > seeded events", assert.Greater, seededCount) }, "wait for added event assertions to pass") _, err := integration.SystemClient().SetLimits(CTX, &system.SetLimitsRequest{ InstanceId: isoInstance.ID(), @@ -44,8 +44,8 @@ func TestServer_Limits_AuditLogRetention(t *testing.T) { require.NoError(t, err) var limitedCounts *eventCounts requireEventually(t, iamOwnerCtx, isoInstance.Client, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { - counts.assertAll(t, c, "limited events < added events", assert.Less, addedCount) - counts.assertAll(t, c, "limited events > 0", assert.Greater, zeroCounts) + counts.assertAll(c, "limited events < added events", assert.Less, addedCount) + counts.assertAll(c, "limited events > 0", assert.Greater, zeroCounts) limitedCounts = counts }, "wait for limited event assertions to pass") listedEvents, err := isoInstance.Client.Admin.ListEvents(iamOwnerCtx, &admin.ListEventsRequest{CreationDateFilter: &admin.ListEventsRequest_From{ @@ -63,7 +63,7 @@ func TestServer_Limits_AuditLogRetention(t *testing.T) { }) require.NoError(t, err) requireEventually(t, iamOwnerCtx, isoInstance.Client, userID, projectID, appID, projectGrantID, func(c assert.TestingT, counts *eventCounts) { - counts.assertAll(t, c, "with reset limit, added events are > seeded events", assert.Greater, seededCount) + counts.assertAll(c, "with reset limit, added events are > seeded events", assert.Greater, seededCount) }, "wait for reset event assertions to pass") } @@ -77,7 +77,7 @@ func requireEventually( ) (counts *eventCounts) { countTimeout := 30 * time.Second assertTimeout := countTimeout + time.Second - countCtx, cancel := context.WithTimeout(ctx, countTimeout) + countCtx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() require.EventuallyWithT(t, func(c *assert.CollectT) { counts = countEvents(countCtx, c, cc, userID, projectID, appID, projectGrantID) @@ -168,63 +168,77 @@ type eventCounts struct { all, myUser, aUser, grant, project, app, org int } -func (e *eventCounts) assertAll(t *testing.T, c assert.TestingT, name string, compare assert.ComparisonAssertionFunc, than *eventCounts) { - t.Run(name, func(t *testing.T) { - compare(c, e.all, than.all, "ListEvents") - compare(c, e.myUser, than.myUser, "ListMyUserChanges") - compare(c, e.aUser, than.aUser, "ListUserChanges") - compare(c, e.grant, than.grant, "ListProjectGrantChanges") - compare(c, e.project, than.project, "ListProjectChanges") - compare(c, e.app, than.app, "ListAppChanges") - compare(c, e.org, than.org, "ListOrgChanges") - }) +func (e *eventCounts) assertAll(c assert.TestingT, name string, compare assert.ComparisonAssertionFunc, than *eventCounts) { + compare(c, e.all, than.all, name+"ListEvents") + compare(c, e.myUser, than.myUser, name+"ListMyUserChanges") + compare(c, e.aUser, than.aUser, name+"ListUserChanges") + compare(c, e.grant, than.grant, name+"ListProjectGrantChanges") + compare(c, e.project, than.project, name+"ListProjectChanges") + compare(c, e.app, than.app, name+"ListAppChanges") + compare(c, e.org, than.org, name+"ListOrgChanges") } func countEvents(ctx context.Context, t assert.TestingT, cc *integration.Client, userID, projectID, appID, grantID string) *eventCounts { counts := new(eventCounts) var wg sync.WaitGroup wg.Add(7) + + var mutex sync.Mutex + assertResultLocked := func(err error, f func(counts *eventCounts)) { + mutex.Lock() + assert.NoError(t, err) + f(counts) + mutex.Unlock() + } + go func() { defer wg.Done() result, err := cc.Admin.ListEvents(ctx, &admin.ListEventsRequest{}) - assert.NoError(t, err) - counts.all = len(result.GetEvents()) + assertResultLocked(err, func(counts *eventCounts) { + counts.all = len(result.GetEvents()) + }) }() go func() { defer wg.Done() result, err := cc.Auth.ListMyUserChanges(ctx, &auth.ListMyUserChangesRequest{}) - assert.NoError(t, err) - counts.myUser = len(result.GetResult()) + assertResultLocked(err, func(counts *eventCounts) { + counts.myUser = len(result.GetResult()) + }) }() go func() { defer wg.Done() result, err := cc.Mgmt.ListUserChanges(ctx, &management.ListUserChangesRequest{UserId: userID}) - assert.NoError(t, err) - counts.aUser = len(result.GetResult()) + assertResultLocked(err, func(counts *eventCounts) { + counts.aUser = len(result.GetResult()) + }) }() go func() { defer wg.Done() result, err := cc.Mgmt.ListAppChanges(ctx, &management.ListAppChangesRequest{ProjectId: projectID, AppId: appID}) - assert.NoError(t, err) - counts.app = len(result.GetResult()) + assertResultLocked(err, func(counts *eventCounts) { + counts.app = len(result.GetResult()) + }) }() go func() { defer wg.Done() result, err := cc.Mgmt.ListOrgChanges(ctx, &management.ListOrgChangesRequest{}) - assert.NoError(t, err) - counts.org = len(result.GetResult()) + assertResultLocked(err, func(counts *eventCounts) { + counts.org = len(result.GetResult()) + }) }() go func() { defer wg.Done() result, err := cc.Mgmt.ListProjectChanges(ctx, &management.ListProjectChangesRequest{ProjectId: projectID}) - assert.NoError(t, err) - counts.project = len(result.GetResult()) + assertResultLocked(err, func(counts *eventCounts) { + counts.project = len(result.GetResult()) + }) }() go func() { defer wg.Done() result, err := cc.Mgmt.ListProjectGrantChanges(ctx, &management.ListProjectGrantChangesRequest{ProjectId: projectID, GrantId: grantID}) - assert.NoError(t, err) - counts.grant = len(result.GetResult()) + assertResultLocked(err, func(counts *eventCounts) { + counts.grant = len(result.GetResult()) + }) }() wg.Wait() return counts diff --git a/internal/api/oidc/integration_test/token_jwt_profile_test.go b/internal/api/oidc/integration_test/token_jwt_profile_test.go index 24fad41a2b..4315b0b30d 100644 --- a/internal/api/oidc/integration_test/token_jwt_profile_test.go +++ b/internal/api/oidc/integration_test/token_jwt_profile_test.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/oidc/v3/pkg/client/profile" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" + "golang.org/x/oauth2" oidc_api "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/domain" @@ -98,13 +99,19 @@ func TestServer_JWTProfile(t *testing.T) { tokenSource, err := profile.NewJWTProfileTokenSourceFromKeyFileData(CTX, Instance.OIDCIssuer(), tt.keyData, tt.scope) require.NoError(t, err) - tokens, err := tokenSource.TokenCtx(CTX) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - require.NotNil(t, tokens) + var tokens *oauth2.Token + require.EventuallyWithT( + t, func(collect *assert.CollectT) { + tokens, err = tokenSource.TokenCtx(CTX) + if tt.wantErr { + assert.Error(collect, err) + return + } + assert.NoError(collect, err) + assert.NotNil(collect, tokens) + }, + time.Minute, time.Second, + ) provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), "", "", redirectURI, tt.scope) require.NoError(t, err) diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000000..f6b474eefd --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,106 @@ +// Package cache provides abstraction of cache implementations that can be used by zitadel. +package cache + +import ( + "context" + "time" + + "github.com/zitadel/logging" +) + +// Cache stores objects with a value of type `V`. +// Objects may be referred to by one or more indices. +// Implementations may encode the value for storage. +// This means non-exported fields may be lost and objects +// with function values may fail to encode. +// See https://pkg.go.dev/encoding/json#Marshal for example. +// +// `I` is the type by which indices are identified, +// typically an enum for type-safe access. +// Indices are defined when calling the constructor of an implementation of this interface. +// It is illegal to refer to an idex not defined during construction. +// +// `K` is the type used as key in each index. +// Due to the limitations in type constraints, all indices use the same key type. +// +// Implementations are free to use stricter type constraints or fixed typing. +type Cache[I, K comparable, V Entry[I, K]] interface { + // Get an object through specified index. + // An [IndexUnknownError] may be returned if the index is unknown. + // [ErrCacheMiss] is returned if the key was not found in the index, + // or the object is not valid. + Get(ctx context.Context, index I, key K) (V, bool) + + // Set an object. + // Keys are created on each index based in the [Entry.Keys] method. + // If any key maps to an existing object, the object is invalidated, + // regardless if the object has other keys defined in the new entry. + // This to prevent ghost objects when an entry reduces the amount of keys + // for a given index. + Set(ctx context.Context, value V) + + // Invalidate an object through specified index. + // Implementations may choose to instantly delete the object, + // defer until prune or a separate cleanup routine. + // Invalidated object are no longer returned from Get. + // It is safe to call Invalidate multiple times or on non-existing entries. + Invalidate(ctx context.Context, index I, key ...K) error + + // Delete one or more keys from a specific index. + // An [IndexUnknownError] may be returned if the index is unknown. + // The referred object is not invalidated and may still be accessible though + // other indices and keys. + // It is safe to call Delete multiple times or on non-existing entries + Delete(ctx context.Context, index I, key ...K) error + + // Truncate deletes all cached objects. + Truncate(ctx context.Context) error + + // Close the cache. Subsequent calls to the cache are not allowed. + Close(ctx context.Context) error +} + +// Entry contains a value of type `V` to be cached. +// +// `I` is the type by which indices are identified, +// typically an enum for type-safe access. +// +// `K` is the type used as key in an index. +// Due to the limitations in type constraints, all indices use the same key type. +type Entry[I, K comparable] interface { + // Keys returns which keys map to the object in a specified index. + // May return nil if the index in unknown or when there are no keys. + Keys(index I) (key []K) +} + +type CachesConfig struct { + Connectors struct { + Memory MemoryConnectorConfig + // SQL database.Config + // Redis redis.Config? + } + Instance *CacheConfig +} + +type CacheConfig struct { + Connector string + + // Age since an object was added to the cache, + // after which the object is considered invalid. + // 0 disables max age checks. + MaxAge time.Duration + + // Age since last use (Get) of an object, + // after which the object is considered invalid. + // 0 disables last use age checks. + LastUseAge time.Duration + + // Log allows logging of the specific cache. + // By default only errors are logged to stdout. + Log *logging.Config +} + +type MemoryConnectorConfig struct { + Enabled bool + AutoPrune AutoPruneConfig +} diff --git a/internal/cache/error.go b/internal/cache/error.go new file mode 100644 index 0000000000..b66b9447bf --- /dev/null +++ b/internal/cache/error.go @@ -0,0 +1,29 @@ +package cache + +import ( + "errors" + "fmt" +) + +type IndexUnknownError[I comparable] struct { + index I +} + +func NewIndexUnknownErr[I comparable](index I) error { + return IndexUnknownError[I]{index} +} + +func (i IndexUnknownError[I]) Error() string { + return fmt.Sprintf("index %v unknown", i.index) +} + +func (a IndexUnknownError[I]) Is(err error) bool { + if b, ok := err.(IndexUnknownError[I]); ok { + return a.index == b.index + } + return false +} + +var ( + ErrCacheMiss = errors.New("cache miss") +) diff --git a/internal/cache/gomap/gomap.go b/internal/cache/gomap/gomap.go new file mode 100644 index 0000000000..e9c938bbf9 --- /dev/null +++ b/internal/cache/gomap/gomap.go @@ -0,0 +1,204 @@ +package gomap + +import ( + "context" + "errors" + "log/slog" + "maps" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/zitadel/zitadel/internal/cache" +) + +type mapCache[I, K comparable, V cache.Entry[I, K]] struct { + config *cache.CacheConfig + indexMap map[I]*index[K, V] + logger *slog.Logger +} + +// NewCache returns an in-memory Cache implementation based on the builtin go map type. +// Object values are stored as-is and there is no encoding or decoding involved. +func NewCache[I, K comparable, V cache.Entry[I, K]](background context.Context, indices []I, config cache.CacheConfig) cache.PrunerCache[I, K, V] { + m := &mapCache[I, K, V]{ + config: &config, + indexMap: make(map[I]*index[K, V], len(indices)), + logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelError, + })), + } + if config.Log != nil { + m.logger = config.Log.Slog() + } + m.logger.InfoContext(background, "map cache logging enabled") + + for _, name := range indices { + m.indexMap[name] = &index[K, V]{ + config: m.config, + entries: make(map[K]*entry[V]), + } + } + return m +} + +func (c *mapCache[I, K, V]) Get(ctx context.Context, index I, key K) (value V, ok bool) { + i, ok := c.indexMap[index] + if !ok { + c.logger.ErrorContext(ctx, "map cache get", "err", cache.NewIndexUnknownErr(index), "index", index, "key", key) + return value, false + } + entry, err := i.Get(key) + if err == nil { + c.logger.DebugContext(ctx, "map cache get", "index", index, "key", key) + return entry.value, true + } + if errors.Is(err, cache.ErrCacheMiss) { + c.logger.InfoContext(ctx, "map cache get", "err", err, "index", index, "key", key) + return value, false + } + c.logger.ErrorContext(ctx, "map cache get", "err", cache.NewIndexUnknownErr(index), "index", index, "key", key) + return value, false +} + +func (c *mapCache[I, K, V]) Set(ctx context.Context, value V) { + now := time.Now() + entry := &entry[V]{ + value: value, + created: now, + } + entry.lastUse.Store(now.UnixMicro()) + + for name, i := range c.indexMap { + keys := value.Keys(name) + i.Set(keys, entry) + c.logger.DebugContext(ctx, "map cache set", "index", name, "keys", keys) + } +} + +func (c *mapCache[I, K, V]) Invalidate(ctx context.Context, index I, keys ...K) error { + i, ok := c.indexMap[index] + if !ok { + return cache.NewIndexUnknownErr(index) + } + i.Invalidate(keys) + c.logger.DebugContext(ctx, "map cache invalidate", "index", index, "keys", keys) + return nil +} + +func (c *mapCache[I, K, V]) Delete(ctx context.Context, index I, keys ...K) error { + i, ok := c.indexMap[index] + if !ok { + return cache.NewIndexUnknownErr(index) + } + i.Delete(keys) + c.logger.DebugContext(ctx, "map cache delete", "index", index, "keys", keys) + return nil +} + +func (c *mapCache[I, K, V]) Prune(ctx context.Context) error { + for name, index := range c.indexMap { + index.Prune() + c.logger.DebugContext(ctx, "map cache prune", "index", name) + } + return nil +} + +func (c *mapCache[I, K, V]) Truncate(ctx context.Context) error { + for name, index := range c.indexMap { + index.Truncate() + c.logger.DebugContext(ctx, "map cache clear", "index", name) + } + return nil +} + +func (c *mapCache[I, K, V]) Close(ctx context.Context) error { + return ctx.Err() +} + +type index[K comparable, V any] struct { + mutex sync.RWMutex + config *cache.CacheConfig + entries map[K]*entry[V] +} + +func (i *index[K, V]) Get(key K) (*entry[V], error) { + i.mutex.RLock() + entry, ok := i.entries[key] + i.mutex.RUnlock() + if ok && entry.isValid(i.config) { + return entry, nil + } + return nil, cache.ErrCacheMiss +} + +func (c *index[K, V]) Set(keys []K, entry *entry[V]) { + c.mutex.Lock() + for _, key := range keys { + c.entries[key] = entry + } + c.mutex.Unlock() +} + +func (i *index[K, V]) Invalidate(keys []K) { + i.mutex.RLock() + for _, key := range keys { + if entry, ok := i.entries[key]; ok { + entry.invalid.Store(true) + } + } + i.mutex.RUnlock() +} + +func (c *index[K, V]) Delete(keys []K) { + c.mutex.Lock() + for _, key := range keys { + delete(c.entries, key) + } + c.mutex.Unlock() +} + +func (c *index[K, V]) Prune() { + c.mutex.Lock() + maps.DeleteFunc(c.entries, func(_ K, entry *entry[V]) bool { + return !entry.isValid(c.config) + }) + c.mutex.Unlock() +} + +func (c *index[K, V]) Truncate() { + c.mutex.Lock() + c.entries = make(map[K]*entry[V]) + c.mutex.Unlock() +} + +type entry[V any] struct { + value V + created time.Time + invalid atomic.Bool + lastUse atomic.Int64 // UnixMicro time +} + +func (e *entry[V]) isValid(c *cache.CacheConfig) bool { + if e.invalid.Load() { + return false + } + now := time.Now() + if c.MaxAge > 0 { + if e.created.Add(c.MaxAge).Before(now) { + e.invalid.Store(true) + return false + } + } + if c.LastUseAge > 0 { + lastUse := e.lastUse.Load() + if time.UnixMicro(lastUse).Add(c.LastUseAge).Before(now) { + e.invalid.Store(true) + return false + } + e.lastUse.CompareAndSwap(lastUse, now.UnixMicro()) + } + return true +} diff --git a/internal/cache/gomap/gomap_test.go b/internal/cache/gomap/gomap_test.go new file mode 100644 index 0000000000..21a273b616 --- /dev/null +++ b/internal/cache/gomap/gomap_test.go @@ -0,0 +1,334 @@ +package gomap + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/cache" +) + +type testIndex int + +const ( + testIndexID testIndex = iota + testIndexName +) + +var testIndices = []testIndex{ + testIndexID, + testIndexName, +} + +type testObject struct { + id string + names []string +} + +func (o *testObject) Keys(index testIndex) []string { + switch index { + case testIndexID: + return []string{o.id} + case testIndexName: + return o.names + default: + return nil + } +} + +func Test_mapCache_Get(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.CacheConfig{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + defer c.Close(context.Background()) + obj := &testObject{ + id: "id", + names: []string{"foo", "bar"}, + } + c.Set(context.Background(), obj) + + type args struct { + index testIndex + key string + } + tests := []struct { + name string + args args + want *testObject + wantOk bool + }{ + { + name: "ok", + args: args{ + index: testIndexID, + key: "id", + }, + want: obj, + wantOk: true, + }, + { + name: "miss", + args: args{ + index: testIndexID, + key: "spanac", + }, + want: nil, + wantOk: false, + }, + { + name: "unknown index", + args: args{ + index: 99, + key: "id", + }, + want: nil, + wantOk: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := c.Get(context.Background(), tt.args.index, tt.args.key) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantOk, ok) + }) + } +} + +func Test_mapCache_Invalidate(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.CacheConfig{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + defer c.Close(context.Background()) + obj := &testObject{ + id: "id", + names: []string{"foo", "bar"}, + } + c.Set(context.Background(), obj) + err := c.Invalidate(context.Background(), testIndexName, "bar") + require.NoError(t, err) + got, ok := c.Get(context.Background(), testIndexID, "id") + assert.Nil(t, got) + assert.False(t, ok) +} + +func Test_mapCache_Delete(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.CacheConfig{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + defer c.Close(context.Background()) + obj := &testObject{ + id: "id", + names: []string{"foo", "bar"}, + } + c.Set(context.Background(), obj) + err := c.Delete(context.Background(), testIndexName, "bar") + require.NoError(t, err) + + // Shouldn't find object by deleted name + got, ok := c.Get(context.Background(), testIndexName, "bar") + assert.Nil(t, got) + assert.False(t, ok) + + // Should find object by other name + got, ok = c.Get(context.Background(), testIndexName, "foo") + assert.Equal(t, obj, got) + assert.True(t, ok) + + // Should find object by id + got, ok = c.Get(context.Background(), testIndexID, "id") + assert.Equal(t, obj, got) + assert.True(t, ok) +} + +func Test_mapCache_Prune(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.CacheConfig{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + defer c.Close(context.Background()) + + objects := []*testObject{ + { + id: "id1", + names: []string{"foo", "bar"}, + }, + { + id: "id2", + names: []string{"hello"}, + }, + } + for _, obj := range objects { + c.Set(context.Background(), obj) + } + // invalidate one entry + err := c.Invalidate(context.Background(), testIndexName, "bar") + require.NoError(t, err) + + err = c.(cache.Pruner).Prune(context.Background()) + require.NoError(t, err) + + // Other object should still be found + got, ok := c.Get(context.Background(), testIndexID, "id2") + assert.Equal(t, objects[1], got) + assert.True(t, ok) +} + +func Test_mapCache_Truncate(t *testing.T) { + c := NewCache[testIndex, string, *testObject](context.Background(), testIndices, cache.CacheConfig{ + MaxAge: time.Second, + LastUseAge: time.Second / 4, + Log: &logging.Config{ + Level: "debug", + AddSource: true, + }, + }) + defer c.Close(context.Background()) + objects := []*testObject{ + { + id: "id1", + names: []string{"foo", "bar"}, + }, + { + id: "id2", + names: []string{"hello"}, + }, + } + for _, obj := range objects { + c.Set(context.Background(), obj) + } + + err := c.Truncate(context.Background()) + require.NoError(t, err) + + mc := c.(*mapCache[testIndex, string, *testObject]) + for _, index := range mc.indexMap { + index.mutex.RLock() + assert.Len(t, index.entries, 0) + index.mutex.RUnlock() + } +} + +func Test_entry_isValid(t *testing.T) { + type fields struct { + created time.Time + invalid bool + lastUse time.Time + } + tests := []struct { + name string + fields fields + config *cache.CacheConfig + want bool + }{ + { + name: "invalid", + fields: fields{ + created: time.Now(), + invalid: true, + lastUse: time.Now(), + }, + config: &cache.CacheConfig{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: false, + }, + { + name: "max age exceeded", + fields: fields{ + created: time.Now().Add(-(time.Minute + time.Second)), + invalid: false, + lastUse: time.Now(), + }, + config: &cache.CacheConfig{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: false, + }, + { + name: "max age disabled", + fields: fields{ + created: time.Now().Add(-(time.Minute + time.Second)), + invalid: false, + lastUse: time.Now(), + }, + config: &cache.CacheConfig{ + LastUseAge: time.Second, + }, + want: true, + }, + { + name: "last use age exceeded", + fields: fields{ + created: time.Now().Add(-(time.Minute / 2)), + invalid: false, + lastUse: time.Now().Add(-(time.Second * 2)), + }, + config: &cache.CacheConfig{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: false, + }, + { + name: "last use age disabled", + fields: fields{ + created: time.Now().Add(-(time.Minute / 2)), + invalid: false, + lastUse: time.Now().Add(-(time.Second * 2)), + }, + config: &cache.CacheConfig{ + MaxAge: time.Minute, + }, + want: true, + }, + { + name: "valid", + fields: fields{ + created: time.Now(), + invalid: false, + lastUse: time.Now(), + }, + config: &cache.CacheConfig{ + MaxAge: time.Minute, + LastUseAge: time.Second, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &entry[any]{ + created: tt.fields.created, + } + e.invalid.Store(tt.fields.invalid) + e.lastUse.Store(tt.fields.lastUse.UnixMicro()) + got := e.isValid(tt.config) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cache/noop/noop.go b/internal/cache/noop/noop.go new file mode 100644 index 0000000000..03c82d574d --- /dev/null +++ b/internal/cache/noop/noop.go @@ -0,0 +1,22 @@ +package noop + +import ( + "context" + + "github.com/zitadel/zitadel/internal/cache" +) + +type noop[I, K comparable, V cache.Entry[I, K]] struct{} + +// NewCache returns a cache that does nothing +func NewCache[I, K comparable, V cache.Entry[I, K]]() cache.Cache[I, K, V] { + return noop[I, K, V]{} +} + +func (noop[I, K, V]) Set(context.Context, V) {} +func (noop[I, K, V]) Get(context.Context, I, K) (value V, ok bool) { return } +func (noop[I, K, V]) Invalidate(context.Context, I, ...K) (err error) { return } +func (noop[I, K, V]) Delete(context.Context, I, ...K) (err error) { return } +func (noop[I, K, V]) Prune(context.Context) (err error) { return } +func (noop[I, K, V]) Truncate(context.Context) (err error) { return } +func (noop[I, K, V]) Close(context.Context) (err error) { return } diff --git a/internal/cache/pruner.go b/internal/cache/pruner.go new file mode 100644 index 0000000000..d4b0b41266 --- /dev/null +++ b/internal/cache/pruner.go @@ -0,0 +1,76 @@ +package cache + +import ( + "context" + "math/rand" + "time" + + "github.com/jonboulle/clockwork" + "github.com/zitadel/logging" +) + +// Pruner is an optional [Cache] interface. +type Pruner interface { + // Prune deletes all invalidated or expired objects. + Prune(ctx context.Context) error +} + +type PrunerCache[I, K comparable, V Entry[I, K]] interface { + Cache[I, K, V] + Pruner +} + +type AutoPruneConfig struct { + // Interval at which the cache is automatically pruned. + // 0 or lower disables automatic pruning. + Interval time.Duration + + // Timeout for an automatic prune. + // It is recommended to keep the value shorter than AutoPruneInterval + // 0 or lower disables automatic pruning. + Timeout time.Duration +} + +func (c AutoPruneConfig) StartAutoPrune(background context.Context, pruner Pruner, name string) (close func()) { + return c.startAutoPrune(background, pruner, name, clockwork.NewRealClock()) +} + +func (c *AutoPruneConfig) startAutoPrune(background context.Context, pruner Pruner, name string, clock clockwork.Clock) (close func()) { + if c.Interval <= 0 { + return func() {} + } + background, cancel := context.WithCancel(background) + // randomize the first interval + timer := clock.NewTimer(time.Duration(rand.Int63n(int64(c.Interval)))) + go c.pruneTimer(background, pruner, name, timer) + return cancel +} + +func (c *AutoPruneConfig) pruneTimer(background context.Context, pruner Pruner, name string, timer clockwork.Timer) { + defer func() { + if !timer.Stop() { + <-timer.Chan() + } + }() + + for { + select { + case <-background.Done(): + return + case <-timer.Chan(): + timer.Reset(c.Interval) + err := c.doPrune(background, pruner) + logging.OnError(err).WithField("name", name).Error("cache auto prune") + } + } +} + +func (c *AutoPruneConfig) doPrune(background context.Context, pruner Pruner) error { + ctx, cancel := context.WithCancel(background) + defer cancel() + if c.Timeout > 0 { + ctx, cancel = context.WithTimeout(background, c.Timeout) + defer cancel() + } + return pruner.Prune(ctx) +} diff --git a/internal/cache/pruner_test.go b/internal/cache/pruner_test.go new file mode 100644 index 0000000000..ababe81e59 --- /dev/null +++ b/internal/cache/pruner_test.go @@ -0,0 +1,43 @@ +package cache + +import ( + "context" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" +) + +type testPruner struct { + called chan struct{} +} + +func (p *testPruner) Prune(context.Context) error { + p.called <- struct{}{} + return nil +} + +func TestAutoPruneConfig_startAutoPrune(t *testing.T) { + c := AutoPruneConfig{ + Interval: time.Second, + Timeout: time.Millisecond, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + pruner := testPruner{ + called: make(chan struct{}), + } + clock := clockwork.NewFakeClock() + close := c.startAutoPrune(ctx, &pruner, "foo", clock) + defer close() + clock.Advance(time.Second) + + select { + case _, ok := <-pruner.called: + assert.True(t, ok) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} diff --git a/internal/command/command.go b/internal/command/command.go index 89f23e6ff7..4ee8310525 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -24,6 +24,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/static" "github.com/zitadel/zitadel/internal/telemetry/tracing" webauthn_helper "github.com/zitadel/zitadel/internal/webauthn" @@ -64,6 +65,7 @@ type Commands struct { defaultAccessTokenLifetime time.Duration defaultRefreshTokenLifetime time.Duration defaultRefreshTokenIdleLifetime time.Duration + phoneCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) multifactors domain.MultifactorConfigs webauthnConfig *webauthn_helper.Config @@ -179,6 +181,7 @@ func StartCommands( if defaultSecretGenerators != nil && defaultSecretGenerators.ClientSecret != nil { repo.newHashedSecret = newHashedSecretWithDefault(secretHasher, defaultSecretGenerators.ClientSecret) } + repo.phoneCodeVerifier = repo.phoneCodeVerifierFromConfig return repo, nil } @@ -189,6 +192,9 @@ type AppendReducer interface { } func (c *Commands) pushAppendAndReduce(ctx context.Context, object AppendReducer, cmds ...eventstore.Command) error { + if len(cmds) == 0 { + return nil + } events, err := c.eventstore.Push(ctx, cmds...) if err != nil { return err @@ -196,6 +202,20 @@ func (c *Commands) pushAppendAndReduce(ctx context.Context, object AppendReducer return AppendAndReduce(object, events...) } +type AppendReducerDetails interface { + AppendEvents(...eventstore.Event) + // TODO: Why is it allowed to return an error here? + Reduce() error + GetWriteModel() *eventstore.WriteModel +} + +func (c *Commands) pushAppendAndReduceDetails(ctx context.Context, object AppendReducerDetails, cmds ...eventstore.Command) (*domain.ObjectDetails, error) { + if err := c.pushAppendAndReduce(ctx, object, cmds...); err != nil { + return nil, err + } + return writeModelToObjectDetails(object.GetWriteModel()), nil +} + func AppendAndReduce(object AppendReducer, events ...eventstore.Event) error { object.AppendEvents(events...) return object.Reduce() diff --git a/internal/command/crypto.go b/internal/command/crypto.go index 45c597fd95..94b009aaa2 100644 --- a/internal/command/crypto.go +++ b/internal/command/crypto.go @@ -13,6 +13,8 @@ type encrypedCodeFunc func(ctx context.Context, filter preparation.FilterToQuery type encryptedCodeWithDefaultFunc func(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, error) +type encryptedCodeGeneratorWithDefaultFunc func(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, string, error) + var emptyConfig = &crypto.GeneratorConfig{} type EncryptedCode struct { @@ -21,6 +23,27 @@ type EncryptedCode struct { Expiry time.Duration } +func (e *EncryptedCode) CryptedCode() *crypto.CryptoValue { + if e == nil { + return nil + } + return e.Crypted +} + +func (e *EncryptedCode) PlainCode() string { + if e == nil { + return "" + } + return e.Plain +} + +func (e *EncryptedCode) CodeExpiry() time.Duration { + if e == nil { + return 0 + } + return e.Expiry +} + func newEncryptedCode(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm) (*EncryptedCode, error) { return newEncryptedCodeWithDefaultConfig(ctx, filter, typ, alg, emptyConfig) } diff --git a/internal/command/crypto_test.go b/internal/command/crypto_test.go index 815539120a..03a5f61975 100644 --- a/internal/command/crypto_test.go +++ b/internal/command/crypto_test.go @@ -49,6 +49,27 @@ func mockEncryptedCodeWithDefault(code string, exp time.Duration) encryptedCodeW } } +func mockEncryptedCodeGeneratorWithDefault(code string, exp time.Duration) encryptedCodeGeneratorWithDefaultFunc { + return func(ctx context.Context, filter preparation.FilterToQueryReducer, _ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, _ *crypto.GeneratorConfig) (*EncryptedCode, string, error) { + return &EncryptedCode{ + Crypted: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte(code), + }, + Plain: code, + Expiry: exp, + }, "", nil + } +} + +func mockEncryptedCodeGeneratorWithDefaultExternal(id string) encryptedCodeGeneratorWithDefaultFunc { + return func(ctx context.Context, filter preparation.FilterToQueryReducer, _ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, _ *crypto.GeneratorConfig) (*EncryptedCode, string, error) { + return nil, id, nil + } +} + func mockHashedSecret(secret string) hashedSecretFunc { return func(_ context.Context, _ preparation.FilterToQueryReducer) (encodedHash string, plain string, err error) { return secret, secret, nil diff --git a/internal/command/phone.go b/internal/command/phone.go index 0770231969..8d971e2f98 100644 --- a/internal/command/phone.go +++ b/internal/command/phone.go @@ -16,6 +16,16 @@ type Phone struct { ReturnCode bool } -func (c *Commands) newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*EncryptedCode, error) { - return c.newEncryptedCode(ctx, filter, domain.SecretGeneratorTypeVerifyPhoneCode, alg) +// newPhoneCode generates a new code to be sent out to via SMS or +// returns the ID of the external code provider (e.g. when using Twilio verification API) +func (c *Commands) newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, secretGeneratorType domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, string, error) { + externalID, err := c.activeSMSProvider(ctx) + if err != nil { + return nil, "", err + } + if externalID != "" { + return nil, externalID, nil + } + code, err := c.newEncryptedCodeWithDefault(ctx, filter, secretGeneratorType, alg, defaultConfig) + return code, "", err } diff --git a/internal/command/session.go b/internal/command/session.go index ccc224cab7..dafb10b818 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -16,6 +16,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -31,13 +32,15 @@ type SessionCommands struct { eventstore *eventstore.Eventstore eventCommands []eventstore.Command - hasher *crypto.Hasher - intentAlg crypto.EncryptionAlgorithm - totpAlg crypto.EncryptionAlgorithm - otpAlg crypto.EncryptionAlgorithm - createCode encryptedCodeWithDefaultFunc - createToken func(sessionID string) (id string, token string, err error) - now func() time.Time + hasher *crypto.Hasher + intentAlg crypto.EncryptionAlgorithm + totpAlg crypto.EncryptionAlgorithm + otpAlg crypto.EncryptionAlgorithm + createCode encryptedCodeWithDefaultFunc + createPhoneCode encryptedCodeGeneratorWithDefaultFunc + createToken func(sessionID string) (id string, token string, err error) + getCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) + now func() time.Time } func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWriteModel) *SessionCommands { @@ -50,6 +53,7 @@ func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWri totpAlg: c.multifactors.OTP.CryptoMFA, otpAlg: c.userEncryption, createCode: c.newEncryptedCodeWithDefault, + createPhoneCode: c.newPhoneCode, createToken: c.sessionTokenCreator, now: time.Now, } @@ -188,8 +192,8 @@ func (s *SessionCommands) TOTPChecked(ctx context.Context, checkedAt time.Time) s.eventCommands = append(s.eventCommands, session.NewTOTPCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) } -func (s *SessionCommands) OTPSMSChallenged(ctx context.Context, code *crypto.CryptoValue, expiry time.Duration, returnCode bool) { - s.eventCommands = append(s.eventCommands, session.NewOTPSMSChallengedEvent(ctx, s.sessionWriteModel.aggregate, code, expiry, returnCode)) +func (s *SessionCommands) OTPSMSChallenged(ctx context.Context, code *crypto.CryptoValue, expiry time.Duration, returnCode bool, generatorID string) { + s.eventCommands = append(s.eventCommands, session.NewOTPSMSChallengedEvent(ctx, s.sessionWriteModel.aggregate, code, expiry, returnCode, generatorID)) } func (s *SessionCommands) OTPSMSChecked(ctx context.Context, checkedAt time.Time) { diff --git a/internal/command/session_model.go b/internal/command/session_model.go index e418a3adb3..646bee97e9 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -20,9 +20,11 @@ type WebAuthNChallengeModel struct { } type OTPCode struct { - Code *crypto.CryptoValue - Expiry time.Duration - CreationDate time.Time + Code *crypto.CryptoValue + Expiry time.Duration + CreationDate time.Time + GeneratorID string + VerificationID string } func (p *WebAuthNChallengeModel) WebAuthNLogin(human *domain.Human, credentialAssertionData []byte) *domain.WebAuthNLogin { @@ -92,6 +94,8 @@ func (wm *SessionWriteModel) Reduce() error { wm.reduceTOTPChecked(e) case *session.OTPSMSChallengedEvent: wm.reduceOTPSMSChallenged(e) + case *session.OTPSMSSentEvent: + wm.reduceOTPSMSSent(e) case *session.OTPSMSCheckedEvent: wm.reduceOTPSMSChecked(e) case *session.OTPEmailChallengedEvent: @@ -123,6 +127,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { session.WebAuthNCheckedType, session.TOTPCheckedType, session.OTPSMSChallengedType, + session.OTPSMSSentType, session.OTPSMSCheckedType, session.OTPEmailChallengedType, session.OTPEmailCheckedType, @@ -183,9 +188,15 @@ func (wm *SessionWriteModel) reduceOTPSMSChallenged(e *session.OTPSMSChallengedE Code: e.Code, Expiry: e.Expiry, CreationDate: e.CreationDate(), + GeneratorID: e.GeneratorID, } } +func (wm *SessionWriteModel) reduceOTPSMSSent(e *session.OTPSMSSentEvent) { + wm.OTPSMSCodeChallenge.GeneratorID = e.GeneratorInfo.GetID() + wm.OTPSMSCodeChallenge.VerificationID = e.GeneratorInfo.GetVerificationID() +} + func (wm *SessionWriteModel) reduceOTPSMSChecked(e *session.OTPSMSCheckedEvent) { wm.OTPSMSCodeChallenge = nil wm.OTPSMSCheckedAt = e.CheckedAt diff --git a/internal/command/session_otp.go b/internal/command/session_otp.go index 6b7e20fb1a..6a4517c982 100644 --- a/internal/command/session_otp.go +++ b/internal/command/session_otp.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -33,19 +34,19 @@ func (c *Commands) createOTPSMSChallenge(returnCode bool, dst *string) SessionCo if !writeModel.OTPAdded() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-BJ2g3", "Errors.User.MFA.OTP.NotReady") } - code, err := cmd.createCode(ctx, cmd.eventstore.Filter, domain.SecretGeneratorTypeOTPSMS, cmd.otpAlg, c.defaultSecretGenerators.OTPSMS) + code, generatorID, err := cmd.createPhoneCode(ctx, cmd.eventstore.Filter, domain.SecretGeneratorTypeOTPSMS, cmd.otpAlg, c.defaultSecretGenerators.OTPSMS) //nolint:staticcheck if err != nil { return nil, err } if returnCode { *dst = code.Plain } - cmd.OTPSMSChallenged(ctx, code.Crypted, code.Expiry, returnCode) + cmd.OTPSMSChallenged(ctx, code.CryptedCode(), code.CodeExpiry(), returnCode, generatorID) return nil, nil } } -func (c *Commands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string) error { +func (c *Commands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { sessionWriteModel := NewSessionWriteModel(sessionID, resourceOwner) err := c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) if err != nil { @@ -55,7 +56,7 @@ func (c *Commands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner stri return zerrors.ThrowPreconditionFailed(nil, "COMMAND-G3t31", "Errors.User.Code.NotFound") } return c.pushAppendAndReduce(ctx, sessionWriteModel, - session.NewOTPSMSSentEvent(ctx, &session.NewAggregate(sessionID, sessionWriteModel.ResourceOwner).Aggregate), + session.NewOTPSMSSentEvent(ctx, &session.NewAggregate(sessionID, sessionWriteModel.ResourceOwner).Aggregate, generatorInfo), ) } @@ -86,7 +87,7 @@ func (c *Commands) createOTPEmailChallenge(returnCode bool, urlTmpl string, dst if !writeModel.OTPAdded() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-JKLJ3", "Errors.User.MFA.OTP.NotReady") } - code, err := cmd.createCode(ctx, cmd.eventstore.Filter, domain.SecretGeneratorTypeOTPEmail, cmd.otpAlg, c.defaultSecretGenerators.OTPEmail) + code, err := cmd.createCode(ctx, cmd.eventstore.Filter, domain.SecretGeneratorTypeOTPEmail, cmd.otpAlg, c.defaultSecretGenerators.OTPEmail) //nolint:staticcheck if err != nil { return nil, err } @@ -130,7 +131,19 @@ func CheckOTPSMS(code string) SessionCommand { failedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command { return user.NewHumanOTPSMSCheckFailedEvent(ctx, aggregate, nil) } - commands, err := checkOTP(ctx, cmd.sessionWriteModel.UserID, code, "", nil, writeModel, cmd.eventstore.FilterToQueryReducer, cmd.otpAlg, succeededEvent, failedEvent) + commands, err := checkOTP( + ctx, + cmd.sessionWriteModel.UserID, + code, + "", + nil, + writeModel, + cmd.eventstore.FilterToQueryReducer, + cmd.otpAlg, + cmd.getCodeVerifier, + succeededEvent, + failedEvent, + ) if err != nil { return commands, err } @@ -158,7 +171,19 @@ func CheckOTPEmail(code string) SessionCommand { failedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command { return user.NewHumanOTPEmailCheckFailedEvent(ctx, aggregate, nil) } - commands, err := checkOTP(ctx, cmd.sessionWriteModel.UserID, code, "", nil, writeModel, cmd.eventstore.FilterToQueryReducer, cmd.otpAlg, succeededEvent, failedEvent) + commands, err := checkOTP( + ctx, + cmd.sessionWriteModel.UserID, + code, + "", + nil, + writeModel, + cmd.eventstore.FilterToQueryReducer, + cmd.otpAlg, + cmd.getCodeVerifier, + succeededEvent, + failedEvent, + ) if err != nil { return commands, err } diff --git a/internal/command/session_otp_test.go b/internal/command/session_otp_test.go index d6934d0472..3e061749b0 100644 --- a/internal/command/session_otp_test.go +++ b/internal/command/session_otp_test.go @@ -11,6 +11,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/repository/org" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" @@ -19,9 +20,9 @@ import ( func TestCommands_CreateOTPSMSChallengeReturnCode(t *testing.T) { type fields struct { - userID string - eventstore func(*testing.T) *eventstore.Eventstore - createCode encryptedCodeWithDefaultFunc + userID string + eventstore func(*testing.T) *eventstore.Eventstore + createPhoneCode encryptedCodeGeneratorWithDefaultFunc } type res struct { err error @@ -66,7 +67,7 @@ func TestCommands_CreateOTPSMSChallengeReturnCode(t *testing.T) { ), ), ), - createCode: mockEncryptedCodeWithDefault("1234567", 5*time.Minute), + createPhoneCode: mockEncryptedCodeGeneratorWithDefault("1234567", 5*time.Minute), }, res: res{ returnCode: "1234567", @@ -80,6 +81,7 @@ func TestCommands_CreateOTPSMSChallengeReturnCode(t *testing.T) { }, 5*time.Minute, true, + "", ), }, }, @@ -107,7 +109,7 @@ func TestCommands_CreateOTPSMSChallengeReturnCode(t *testing.T) { sessionCommands: []SessionCommand{cmd}, sessionWriteModel: sessionModel, eventstore: tt.fields.eventstore(t), - createCode: tt.fields.createCode, + createPhoneCode: tt.fields.createPhoneCode, now: time.Now, } @@ -122,9 +124,9 @@ func TestCommands_CreateOTPSMSChallengeReturnCode(t *testing.T) { func TestCommands_CreateOTPSMSChallenge(t *testing.T) { type fields struct { - userID string - eventstore func(*testing.T) *eventstore.Eventstore - createCode encryptedCodeWithDefaultFunc + userID string + eventstore func(*testing.T) *eventstore.Eventstore + createPhoneCode encryptedCodeGeneratorWithDefaultFunc } type res struct { err error @@ -168,7 +170,7 @@ func TestCommands_CreateOTPSMSChallenge(t *testing.T) { ), ), ), - createCode: mockEncryptedCodeWithDefault("1234567", 5*time.Minute), + createPhoneCode: mockEncryptedCodeGeneratorWithDefault("1234567", 5*time.Minute), }, res: res{ commands: []eventstore.Command{ @@ -181,6 +183,31 @@ func TestCommands_CreateOTPSMSChallenge(t *testing.T) { }, 5*time.Minute, false, + "", + ), + }, + }, + }, + { + name: "generate code externally", + fields: fields{ + userID: "userID", + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(context.Background(), &user.NewAggregate("userID", "org").Aggregate), + ), + ), + ), + createPhoneCode: mockEncryptedCodeGeneratorWithDefaultExternal("generatorID"), + }, + res: res{ + commands: []eventstore.Command{ + session.NewOTPSMSChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + nil, + 0, + false, + "generatorID", ), }, }, @@ -208,7 +235,7 @@ func TestCommands_CreateOTPSMSChallenge(t *testing.T) { sessionCommands: []SessionCommand{cmd}, sessionWriteModel: sessionModel, eventstore: tt.fields.eventstore(t), - createCode: tt.fields.createCode, + createPhoneCode: tt.fields.createPhoneCode, now: time.Now, } @@ -228,6 +255,7 @@ func TestCommands_OTPSMSSent(t *testing.T) { ctx context.Context sessionID string resourceOwner string + generatorInfo *senders.CodeGeneratorInfo } tests := []struct { name string @@ -246,6 +274,7 @@ func TestCommands_OTPSMSSent(t *testing.T) { ctx: context.Background(), sessionID: "sessionID", resourceOwner: "instanceID", + generatorInfo: &senders.CodeGeneratorInfo{}, }, wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-G3t31", "Errors.User.Code.NotFound"), }, @@ -264,11 +293,12 @@ func TestCommands_OTPSMSSent(t *testing.T) { }, 5*time.Minute, false, + "", ), ), ), expectPush( - session.NewOTPSMSSentEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate), + session.NewOTPSMSSentEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, &senders.CodeGeneratorInfo{}), ), ), }, @@ -276,6 +306,37 @@ func TestCommands_OTPSMSSent(t *testing.T) { ctx: context.Background(), sessionID: "sessionID", resourceOwner: "instanceID", + generatorInfo: &senders.CodeGeneratorInfo{}, + }, + wantErr: nil, + }, + { + name: "challenged and sent (externally)", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + session.NewOTPSMSChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + nil, + 0, + false, + "", + ), + ), + ), + expectPush( + session.NewOTPSMSSentEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, &senders.CodeGeneratorInfo{ID: "generatorID", VerificationID: "verificationID"}), + ), + ), + }, + args: args{ + ctx: context.Background(), + sessionID: "sessionID", + resourceOwner: "instanceID", + generatorInfo: &senders.CodeGeneratorInfo{ + ID: "generatorID", + VerificationID: "verificationID", + }, }, wantErr: nil, }, @@ -285,7 +346,7 @@ func TestCommands_OTPSMSSent(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore(t), } - err := c.OTPSMSSent(tt.args.ctx, tt.args.sessionID, tt.args.resourceOwner) + err := c.OTPSMSSent(tt.args.ctx, tt.args.sessionID, tt.args.resourceOwner, tt.args.generatorInfo) assert.ErrorIs(t, err, tt.wantErr) }) } diff --git a/internal/command/sms_config.go b/internal/command/sms_config.go index 82eae763df..bc16380217 100644 --- a/internal/command/sms_config.go +++ b/internal/command/sms_config.go @@ -14,10 +14,11 @@ type AddTwilioConfig struct { ResourceOwner string ID string - Description string - SID string - Token string - SenderNumber string + Description string + SID string + Token string + SenderNumber string + VerifyServiceSID string } func (c *Commands) AddSMSConfigTwilio(ctx context.Context, config *AddTwilioConfig) (err error) { @@ -52,6 +53,7 @@ func (c *Commands) AddSMSConfigTwilio(ctx context.Context, config *AddTwilioConf config.SID, config.SenderNumber, token, + config.VerifyServiceSID, ), ) if err != nil { @@ -66,10 +68,11 @@ type ChangeTwilioConfig struct { ResourceOwner string ID string - Description *string - SID *string - Token *string - SenderNumber *string + Description *string + SID *string + Token *string + SenderNumber *string + VerifyServiceSID *string } func (c *Commands) ChangeSMSConfigTwilio(ctx context.Context, config *ChangeTwilioConfig) (err error) { @@ -92,7 +95,9 @@ func (c *Commands) ChangeSMSConfigTwilio(ctx context.Context, config *ChangeTwil config.ID, config.Description, config.SID, - config.SenderNumber) + config.SenderNumber, + config.VerifyServiceSID, + ) if err != nil { return err } @@ -330,3 +335,13 @@ func (c *Commands) getSMSConfig(ctx context.Context, instanceID, id string) (_ * } return writeModel, nil } + +// getActiveSMSConfig returns the last activated SMS configuration +func (c *Commands) getActiveSMSConfig(ctx context.Context, instanceID string) (_ *IAMSMSConfigWriteModel, err error) { + writeModel := NewIAMSMSLastActivatedConfigWriteModel(instanceID) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return c.getSMSConfig(ctx, instanceID, writeModel.activeID) +} diff --git a/internal/command/sms_config_model.go b/internal/command/sms_config_model.go index 06360a5cfd..922c43ada5 100644 --- a/internal/command/sms_config_model.go +++ b/internal/command/sms_config_model.go @@ -20,9 +20,10 @@ type IAMSMSConfigWriteModel struct { } type TwilioConfig struct { - SID string - Token *crypto.CryptoValue - SenderNumber string + SID string + Token *crypto.CryptoValue + SenderNumber string + VerifyServiceSID string } type HTTPConfig struct { @@ -48,9 +49,10 @@ func (wm *IAMSMSConfigWriteModel) Reduce() error { continue } wm.Twilio = &TwilioConfig{ - SID: e.SID, - Token: e.Token, - SenderNumber: e.SenderNumber, + SID: e.SID, + Token: e.Token, + SenderNumber: e.SenderNumber, + VerifyServiceSID: e.VerifyServiceSID, } wm.Description = e.Description wm.State = domain.SMSConfigStateInactive @@ -67,6 +69,9 @@ func (wm *IAMSMSConfigWriteModel) Reduce() error { if e.SenderNumber != nil { wm.Twilio.SenderNumber = *e.SenderNumber } + if e.VerifyServiceSID != nil { + wm.Twilio.VerifyServiceSID = *e.VerifyServiceSID + } case *instance.SMSConfigTwilioTokenChangedEvent: if wm.ID != e.ID { continue @@ -131,6 +136,7 @@ func (wm *IAMSMSConfigWriteModel) Reduce() error { } return wm.WriteModel.Reduce() } + func (wm *IAMSMSConfigWriteModel) Query() *eventstore.SearchQueryBuilder { return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). ResourceOwner(wm.ResourceOwner). @@ -152,7 +158,7 @@ func (wm *IAMSMSConfigWriteModel) Query() *eventstore.SearchQueryBuilder { Builder() } -func (wm *IAMSMSConfigWriteModel) NewTwilioChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id string, description, sid, senderNumber *string) (*instance.SMSConfigTwilioChangedEvent, bool, error) { +func (wm *IAMSMSConfigWriteModel) NewTwilioChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id string, description, sid, senderNumber, verifyServiceSID *string) (*instance.SMSConfigTwilioChangedEvent, bool, error) { changes := make([]instance.SMSConfigTwilioChanges, 0) var err error @@ -169,6 +175,9 @@ func (wm *IAMSMSConfigWriteModel) NewTwilioChangedEvent(ctx context.Context, agg if senderNumber != nil && wm.Twilio.SenderNumber != *senderNumber { changes = append(changes, instance.ChangeSMSConfigTwilioSenderNumber(*senderNumber)) } + if verifyServiceSID != nil && wm.Twilio.VerifyServiceSID != *verifyServiceSID { + changes = append(changes, instance.ChangeSMSConfigTwilioVerifyServiceSID(*verifyServiceSID)) + } if len(changes) == 0 { return nil, false, nil @@ -204,3 +213,46 @@ func (wm *IAMSMSConfigWriteModel) NewHTTPChangedEvent(ctx context.Context, aggre } return changeEvent, true, nil } + +type IAMSMSLastActivatedConfigWriteModel struct { + eventstore.WriteModel + + activeID string +} + +func NewIAMSMSLastActivatedConfigWriteModel(instanceID string) *IAMSMSLastActivatedConfigWriteModel { + return &IAMSMSLastActivatedConfigWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: instanceID, + ResourceOwner: instanceID, + InstanceID: instanceID, + }, + } +} + +func (wm *IAMSMSLastActivatedConfigWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *instance.SMSConfigActivatedEvent: + wm.activeID = e.ID + case *instance.SMSConfigTwilioActivatedEvent: + wm.activeID = e.ID + } + } + return wm.WriteModel.Reduce() +} + +func (wm *IAMSMSLastActivatedConfigWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + OrderDesc(). + Limit(1). + AddQuery(). + AggregateTypes(instance.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + instance.SMSConfigActivatedEventType, + instance.SMSConfigTwilioActivatedEventType, + ). + Builder() +} diff --git a/internal/command/sms_config_test.go b/internal/command/sms_config_test.go index b0936ab8f1..12fcd8661c 100644 --- a/internal/command/sms_config_test.go +++ b/internal/command/sms_config_test.go @@ -72,6 +72,7 @@ func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { KeyID: "id", Crypted: []byte("token"), }, + "", ), ), ), @@ -81,11 +82,12 @@ func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { args: args{ ctx: context.Background(), sms: &AddTwilioConfig{ - ResourceOwner: "INSTANCE", - Description: "description", - SID: "sid", - Token: "token", - SenderNumber: "senderName", + ResourceOwner: "INSTANCE", + Description: "description", + SID: "sid", + Token: "token", + SenderNumber: "senderName", + VerifyServiceSID: "", }, }, res: res{ @@ -206,6 +208,7 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { KeyID: "id", Crypted: []byte("token"), }, + "", ), ), ), @@ -214,11 +217,12 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { args: args{ ctx: context.Background(), sms: &ChangeTwilioConfig{ - ResourceOwner: "INSTANCE", - ID: "providerid", - SID: gu.Ptr("sid"), - Token: gu.Ptr("token"), - SenderNumber: gu.Ptr("senderName"), + ResourceOwner: "INSTANCE", + ID: "providerid", + SID: gu.Ptr("sid"), + Token: gu.Ptr("token"), + SenderNumber: gu.Ptr("senderName"), + VerifyServiceSID: gu.Ptr(""), }, }, res: res{ @@ -246,6 +250,7 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { KeyID: "id", Crypted: []byte("token"), }, + "verifyServiceSid", ), ), ), @@ -256,6 +261,7 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { "sid2", "senderName2", "description2", + "verifyServiceSid2", ), ), ), @@ -263,12 +269,13 @@ func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { args: args{ ctx: context.Background(), sms: &ChangeTwilioConfig{ - ResourceOwner: "INSTANCE", - ID: "providerid", - Description: gu.Ptr("description2"), - SID: gu.Ptr("sid2"), - Token: gu.Ptr("token2"), - SenderNumber: gu.Ptr("senderName2"), + ResourceOwner: "INSTANCE", + ID: "providerid", + Description: gu.Ptr("description2"), + SID: gu.Ptr("sid2"), + Token: gu.Ptr("token2"), + SenderNumber: gu.Ptr("senderName2"), + VerifyServiceSID: gu.Ptr("verifyServiceSid2"), }, }, res: res{ @@ -626,6 +633,7 @@ func TestCommandSide_ActivateSMSConfig(t *testing.T) { "sid", "sender-name", &crypto.CryptoValue{}, + "", ), ), eventFromEventPusher( @@ -663,6 +671,7 @@ func TestCommandSide_ActivateSMSConfig(t *testing.T) { "sid", "sender-name", &crypto.CryptoValue{}, + "", ), ), ), @@ -820,6 +829,7 @@ func TestCommandSide_DeactivateSMSConfig(t *testing.T) { "sid", "sender-name", &crypto.CryptoValue{}, + "", ), ), eventFromEventPusher( @@ -864,6 +874,7 @@ func TestCommandSide_DeactivateSMSConfig(t *testing.T) { "sid", "sender-name", &crypto.CryptoValue{}, + "", ), ), eventFromEventPusher( @@ -1036,6 +1047,7 @@ func TestCommandSide_RemoveSMSConfig(t *testing.T) { "sid", "sender-name", &crypto.CryptoValue{}, + "", ), ), ), @@ -1114,11 +1126,12 @@ func TestCommandSide_RemoveSMSConfig(t *testing.T) { } } -func newSMSConfigTwilioChangedEvent(ctx context.Context, id, sid, senderName, description string) *instance.SMSConfigTwilioChangedEvent { +func newSMSConfigTwilioChangedEvent(ctx context.Context, id, sid, senderName, description, verifyServiceSid string) *instance.SMSConfigTwilioChangedEvent { changes := []instance.SMSConfigTwilioChanges{ instance.ChangeSMSConfigTwilioSID(sid), instance.ChangeSMSConfigTwilioSenderNumber(senderName), instance.ChangeSMSConfigTwilioDescription(description), + instance.ChangeSMSConfigTwilioVerifyServiceSID(verifyServiceSid), } event, _ := instance.NewSMSConfigTwilioChangedEvent(ctx, &instance.NewAggregate("INSTANCE").Aggregate, diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 1208296159..825ae50f9c 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -3,6 +3,7 @@ package command import ( "context" "strings" + "time" "golang.org/x/text/language" @@ -11,6 +12,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/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -315,14 +317,14 @@ func (c *Commands) addHumanCommandPhone(ctx context.Context, filter preparation. if human.Phone.Verified { return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &a.Aggregate)), nil } - phoneCode, err := c.newPhoneCode(ctx, filter, codeAlg) + phoneCode, generatorID, err := c.newPhoneCode(ctx, filter, domain.SecretGeneratorTypeVerifyPhoneCode, codeAlg, c.defaultSecretGenerators.PhoneVerificationCode) if err != nil { return nil, err } if human.Phone.ReturnCode { human.PhoneCode = &phoneCode.Plain } - return append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry, human.Phone.ReturnCode)), nil + return append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &a.Aggregate, phoneCode.CryptedCode(), phoneCode.CodeExpiry(), human.Phone.ReturnCode, generatorID)), nil } // Deprecated: use commands.NewUserHumanWriteModel, to remove deprecated eventstore.Filter @@ -561,11 +563,11 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. } if human.Phone != nil && human.PhoneNumber != "" && !human.IsPhoneVerified { - phoneCode, err := domain.NewPhoneCode(phoneCodeGenerator) + phoneCode, generatorID, err := c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck if err != nil { return nil, nil, err } - events = append(events, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.Code, phoneCode.Expiry)) + events = append(events, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.CryptedCode(), phoneCode.CodeExpiry(), generatorID)) } else if human.Phone != nil && human.PhoneNumber != "" && human.IsPhoneVerified { events = append(events, user.NewHumanPhoneVerifiedEvent(ctx, userAgg)) } @@ -733,3 +735,35 @@ func AddHumanFromDomain(user *domain.Human, metadataList []*domain.Metadata, aut } return human } + +func verifyCode( + ctx context.Context, + codeCreationDate time.Time, + codeExpiry time.Duration, + encryptedCode *crypto.CryptoValue, + codeProviderID string, + codeVerificationID string, + code string, + codeAlg crypto.EncryptionAlgorithm, + getCodeVerifier func(ctx context.Context, id string) (_ senders.CodeGenerator, err error), +) (err error) { + if codeProviderID == "" { + if encryptedCode == nil { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound") + } + _, spanCrypto := tracing.NewNamedSpan(ctx, "crypto.VerifyCode") + defer func() { + spanCrypto.EndWithError(err) + }() + return crypto.VerifyCode(codeCreationDate, codeExpiry, encryptedCode, code, codeAlg) + } + if getCodeVerifier == nil { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-M0g95", "Errors.User.Code.NotConfigured") + } + verifier, err := getCodeVerifier(ctx, codeProviderID) + if err != nil { + return err + } + + return verifier.VerifyCode(codeVerificationID, code) +} diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index 7f587cf9e5..e505288cbd 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -9,9 +9,11 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/command/preparation" "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/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -335,8 +337,8 @@ func (c *Commands) HumanSendOTPSMS(ctx context.Context, userID, resourceOwner st smsWriteModel := func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error) { return c.otpSMSWriteModelByID(ctx, userID, resourceOwner) } - codeAddedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo) eventstore.Command { - return user.NewHumanOTPSMSCodeAddedEvent(ctx, aggregate, code, expiry, info) + codeAddedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo, generatorID string) eventstore.Command { + return user.NewHumanOTPSMSCodeAddedEvent(ctx, aggregate, code, expiry, info, generatorID) } return c.sendHumanOTP( ctx, @@ -347,15 +349,16 @@ func (c *Commands) HumanSendOTPSMS(ctx context.Context, userID, resourceOwner st domain.SecretGeneratorTypeOTPSMS, c.defaultSecretGenerators.OTPSMS, codeAddedEvent, + c.newPhoneCode, ) } -func (c *Commands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string) (err error) { +func (c *Commands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) (err error) { smsWriteModel := func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error) { return c.otpSMSWriteModelByID(ctx, userID, resourceOwner) } codeSentEvent := func(ctx context.Context, aggregate *eventstore.Aggregate) eventstore.Command { - return user.NewHumanOTPSMSCodeSentEvent(ctx, aggregate) + return user.NewHumanOTPSMSCodeSentEvent(ctx, aggregate, generatorInfo) } return c.humanOTPSent(ctx, userID, resourceOwner, smsWriteModel, codeSentEvent) } @@ -379,6 +382,7 @@ func (c *Commands) HumanCheckOTPSMS(ctx context.Context, userID, code, resourceO writeModel, c.eventstore.FilterToQueryReducer, c.userEncryption, + c.phoneCodeVerifier, succeededEvent, failedEvent, ) @@ -467,9 +471,13 @@ func (c *Commands) HumanSendOTPEmail(ctx context.Context, userID, resourceOwner smsWriteModel := func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error) { return c.otpEmailWriteModelByID(ctx, userID, resourceOwner) } - codeAddedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo) eventstore.Command { + codeAddedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo, _ string) eventstore.Command { return user.NewHumanOTPEmailCodeAddedEvent(ctx, aggregate, code, expiry, info) } + generateCode := func(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, string, error) { + code, err := c.newEncryptedCodeWithDefault(ctx, filter, typ, alg, defaultConfig) + return code, "", err + } return c.sendHumanOTP( ctx, userID, @@ -479,6 +487,7 @@ func (c *Commands) HumanSendOTPEmail(ctx context.Context, userID, resourceOwner domain.SecretGeneratorTypeOTPEmail, c.defaultSecretGenerators.OTPEmail, codeAddedEvent, + generateCode, ) } @@ -511,6 +520,7 @@ func (c *Commands) HumanCheckOTPEmail(ctx context.Context, userID, code, resourc writeModel, c.eventstore.FilterToQueryReducer, c.userEncryption, + nil, // email currently always uses local code checks succeededEvent, failedEvent, ) @@ -529,7 +539,8 @@ func (c *Commands) sendHumanOTP( writeModelByID func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error), secretGeneratorType domain.SecretGeneratorType, defaultSecretGenerator *crypto.GeneratorConfig, - codeAddedEvent func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo) eventstore.Command, + codeAddedEvent func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo, generatorID string) eventstore.Command, + generateCode func(ctx context.Context, filter preparation.FilterToQueryReducer, secretGeneratorType domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, string, error), ) (err error) { if userID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-S3SF1", "Errors.User.UserIDMissing") @@ -541,17 +552,12 @@ func (c *Commands) sendHumanOTP( if !existingOTP.OTPAdded() { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-SFD52", "Errors.User.MFA.OTP.NotReady") } - config, err := cryptoGeneratorConfigWithDefault(ctx, c.eventstore.Filter, secretGeneratorType, defaultSecretGenerator) //nolint:staticcheck - if err != nil { - return err - } - gen := crypto.NewEncryptionGenerator(*config, c.userEncryption) - value, _, err := crypto.NewCode(gen) + code, generatorID, err := generateCode(ctx, c.eventstore.Filter, secretGeneratorType, c.userEncryption, defaultSecretGenerator) //nolint:staticcheck if err != nil { return err } userAgg := &user.NewAggregate(userID, resourceOwner).Aggregate - _, err = c.eventstore.Push(ctx, codeAddedEvent(ctx, userAgg, value, gen.Expiry(), authRequestDomainToAuthRequestInfo(authRequest))) + _, err = c.eventstore.Push(ctx, codeAddedEvent(ctx, userAgg, code.CryptedCode(), code.CodeExpiry(), authRequestDomainToAuthRequestInfo(authRequest), generatorID)) return err } @@ -583,6 +589,7 @@ func checkOTP( writeModelByID func(ctx context.Context, userID string, resourceOwner string) (OTPCodeWriteModel, error), queryReducer func(ctx context.Context, r eventstore.QueryReducer) error, alg crypto.EncryptionAlgorithm, + getCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error), checkSucceededEvent, checkFailedEvent func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command, ) ([]eventstore.Command, error) { if userID == "" { @@ -598,12 +605,21 @@ func checkOTP( if !existingOTP.OTPAdded() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-d2r52", "Errors.User.MFA.OTP.NotReady") } - if existingOTP.Code() == nil { + if existingOTP.Code() == nil && existingOTP.GeneratorID() == "" { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-S34gh", "Errors.User.Code.NotFound") } userAgg := &user.NewAggregate(userID, existingOTP.ResourceOwner()).Aggregate - verifyErr := crypto.VerifyCode(existingOTP.CodeCreationDate(), existingOTP.CodeExpiry(), existingOTP.Code(), code, alg) - + verifyErr := verifyCode( + ctx, + existingOTP.CodeCreationDate(), + existingOTP.CodeExpiry(), + existingOTP.Code(), + existingOTP.GeneratorID(), + existingOTP.ProviderVerificationID(), + code, + alg, + getCodeVerifier, + ) // recheck for additional events (failed OTP checks or locks) recheckErr := queryReducer(ctx, existingOTP) if recheckErr != nil { diff --git a/internal/command/user_human_otp_model.go b/internal/command/user_human_otp_model.go index 2780a52d7c..7119adfea8 100644 --- a/internal/command/user_human_otp_model.go +++ b/internal/command/user_human_otp_model.go @@ -90,6 +90,8 @@ type OTPCodeWriteModel interface { Code() *crypto.CryptoValue CheckFailedCount() uint64 UserLocked() bool + GeneratorID() string + ProviderVerificationID() string eventstore.QueryReducer } @@ -192,6 +194,20 @@ func (wm *HumanOTPSMSCodeWriteModel) UserLocked() bool { return wm.userLocked } +func (wm *HumanOTPSMSCodeWriteModel) GeneratorID() string { + if wm.otpCode == nil { + return "" + } + return wm.otpCode.GeneratorID +} + +func (wm *HumanOTPSMSCodeWriteModel) ProviderVerificationID() string { + if wm.otpCode == nil { + return "" + } + return wm.otpCode.VerificationID +} + func NewHumanOTPSMSCodeWriteModel(userID, resourceOwner string) *HumanOTPSMSCodeWriteModel { return &HumanOTPSMSCodeWriteModel{ HumanOTPSMSWriteModel: NewHumanOTPSMSWriteModel(userID, resourceOwner), @@ -206,7 +222,11 @@ func (wm *HumanOTPSMSCodeWriteModel) Reduce() error { Code: e.Code, CreationDate: e.CreationDate(), Expiry: e.Expiry, + GeneratorID: e.GeneratorID, } + case *user.HumanOTPSMSCodeSentEvent: + wm.otpCode.GeneratorID = e.GeneratorInfo.GetID() + wm.otpCode.VerificationID = e.GeneratorInfo.GetVerificationID() case *user.HumanOTPSMSCheckSucceededEvent: wm.checkFailedCount = 0 case *user.HumanOTPSMSCheckFailedEvent: @@ -228,6 +248,7 @@ func (wm *HumanOTPSMSCodeWriteModel) Query() *eventstore.SearchQueryBuilder { AggregateIDs(wm.AggregateID). EventTypes( user.HumanOTPSMSCodeAddedType, + user.HumanOTPSMSCodeSentType, user.HumanOTPSMSCheckSucceededType, user.HumanOTPSMSCheckFailedType, user.UserLockedType, @@ -344,6 +365,20 @@ func (wm *HumanOTPEmailCodeWriteModel) UserLocked() bool { return wm.userLocked } +func (wm *HumanOTPEmailCodeWriteModel) GeneratorID() string { + if wm.otpCode == nil { + return "" + } + return wm.otpCode.GeneratorID +} + +func (wm *HumanOTPEmailCodeWriteModel) ProviderVerificationID() string { + if wm.otpCode == nil { + return "" + } + return wm.otpCode.VerificationID +} + func NewHumanOTPEmailCodeWriteModel(userID, resourceOwner string) *HumanOTPEmailCodeWriteModel { return &HumanOTPEmailCodeWriteModel{ HumanOTPEmailWriteModel: NewHumanOTPEmailWriteModel(userID, resourceOwner), diff --git a/internal/command/user_human_otp_test.go b/internal/command/user_human_otp_test.go index 197d222518..330025cf37 100644 --- a/internal/command/user_human_otp_test.go +++ b/internal/command/user_human_otp_test.go @@ -18,6 +18,8 @@ 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/notification/senders/mock" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/user" @@ -1439,9 +1441,9 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { }, } type fields struct { - eventstore func(*testing.T) *eventstore.Eventstore - userEncryption crypto.EncryptionAlgorithm - defaultSecretGenerators *SecretGenerators + eventstore func(*testing.T) *eventstore.Eventstore + defaultSecretGenerators *SecretGenerators + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc } type ( args struct { @@ -1506,16 +1508,33 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { ), expectFilter( eventFromEventPusher( - instance.NewSecretGeneratorAddedEvent(context.Background(), + instance.NewSMSConfigActivatedEvent( + context.Background(), &instance.NewAggregate("instanceID").Aggregate, - domain.SecretGeneratorTypeOTPSMS, - 8, - time.Hour, - true, - true, - true, - true, - )), + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), ), expectPush( user.NewHumanOTPSMSCodeAddedEvent(ctx, @@ -1528,11 +1547,77 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { }, time.Hour, nil, + "", ), ), ), - userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), - defaultSecretGenerators: defaultGenerators, + defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "successful add (external code)", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSID", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + user.NewHumanOTPSMSCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + nil, + "id", + ), + ), + ), + defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), }, args: args{ ctx: ctx, @@ -1556,7 +1641,36 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { ), ), ), - expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( user.NewHumanOTPSMSCodeAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, @@ -1568,11 +1682,12 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { }, time.Hour, nil, + "", ), ), ), - userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), - defaultSecretGenerators: defaultGenerators, + defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), }, args: args{ ctx: ctx, @@ -1598,16 +1713,33 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { ), expectFilter( eventFromEventPusher( - instance.NewSecretGeneratorAddedEvent(context.Background(), + instance.NewSMSConfigActivatedEvent( + context.Background(), &instance.NewAggregate("instanceID").Aggregate, - domain.SecretGeneratorTypeOTPSMS, - 8, - time.Hour, - true, - true, - true, - true, - )), + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), ), expectPush( user.NewHumanOTPSMSCodeAddedEvent(ctx, @@ -1628,11 +1760,12 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { RemoteIP: net.IP{192, 0, 2, 1}, }, }, + "", ), ), ), - userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), - defaultSecretGenerators: defaultGenerators, + defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), }, args: args{ ctx: ctx, @@ -1658,9 +1791,9 @@ func TestCommandSide_HumanSendOTPSMS(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), - userEncryption: tt.fields.userEncryption, - defaultSecretGenerators: tt.fields.defaultSecretGenerators, + eventstore: tt.fields.eventstore(t), + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, } err := r.HumanSendOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest) assert.ErrorIs(t, err, tt.res.err) @@ -1678,6 +1811,7 @@ func TestCommandSide_HumanOTPSMSCodeSent(t *testing.T) { ctx context.Context userID string resourceOwner string + generatorInfo *senders.CodeGeneratorInfo } ) type res struct { @@ -1734,6 +1868,7 @@ func TestCommandSide_HumanOTPSMSCodeSent(t *testing.T) { expectPush( user.NewHumanOTPSMSCodeSentEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{}, ), ), ), @@ -1742,6 +1877,44 @@ func TestCommandSide_HumanOTPSMSCodeSent(t *testing.T) { ctx: ctx, userID: "user1", resourceOwner: "org1", + generatorInfo: &senders.CodeGeneratorInfo{}, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "successful add (external code)", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + user.NewHumanOTPSMSCodeSentEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{ + ID: "generatorID", + VerificationID: "verificationID", + }, + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + generatorInfo: &senders.CodeGeneratorInfo{ + ID: "generatorID", + VerificationID: "verificationID", + }, }, res: res{ want: &domain.ObjectDetails{ @@ -1755,7 +1928,7 @@ func TestCommandSide_HumanOTPSMSCodeSent(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore(t), } - err := r.HumanOTPSMSCodeSent(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + err := r.HumanOTPSMSCodeSent(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.generatorInfo) assert.ErrorIs(t, err, tt.res.err) }) } @@ -1764,8 +1937,9 @@ func TestCommandSide_HumanOTPSMSCodeSent(t *testing.T) { func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { ctx := authz.NewMockContext("inst1", "org1", "user1") type fields struct { - eventstore func(*testing.T) *eventstore.Eventstore - userEncryption crypto.EncryptionAlgorithm + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + phoneCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) } type ( args struct { @@ -1885,6 +2059,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { RemoteIP: net.IP{192, 0, 2, 1}, }, }, + "", ), ), ), @@ -1962,6 +2137,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { RemoteIP: net.IP{192, 0, 2, 1}, }, }, + "", ), ), ), @@ -2042,6 +2218,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { RemoteIP: net.IP{192, 0, 2, 1}, }, }, + "", ), ), ), @@ -2113,6 +2290,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { RemoteIP: net.IP{192, 0, 2, 1}, }, }, + "", ), ), ), @@ -2143,12 +2321,94 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-S6h4R", "Errors.User.Locked"), }, }, + { + name: "code ok (external)", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanOTPSMSCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + "id", + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanOTPSMSCodeSentEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{ + ID: "id", + VerificationID: "verificationID", + }, + ), + ), + ), + expectFilter(), // recheck + expectPush( + user.NewHumanOTPSMSCheckSucceededEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + phoneCodeVerifier: func(ctx context.Context, id string) (senders.CodeGenerator, error) { + sender := mock.NewMockCodeGenerator(gomock.NewController(t)) + sender.EXPECT().VerifyCode("verificationID", "code").Return(nil) + return sender, nil + }, + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), - userEncryption: tt.fields.userEncryption, + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + phoneCodeVerifier: tt.fields.phoneCodeVerifier, } err := r.HumanCheckOTPSMS(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest) assert.ErrorIs(t, err, tt.res.err) @@ -2594,9 +2854,9 @@ func TestCommandSide_HumanSendOTPEmail(t *testing.T) { }, } type fields struct { - eventstore func(*testing.T) *eventstore.Eventstore - userEncryption crypto.EncryptionAlgorithm - defaultSecretGenerators *SecretGenerators + eventstore func(*testing.T) *eventstore.Eventstore + defaultSecretGenerators *SecretGenerators + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc } type ( args struct { @@ -2659,19 +2919,6 @@ func TestCommandSide_HumanSendOTPEmail(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - instance.NewSecretGeneratorAddedEvent(context.Background(), - &instance.NewAggregate("instanceID").Aggregate, - domain.SecretGeneratorTypeOTPEmail, - 8, - time.Hour, - true, - true, - true, - true, - )), - ), expectPush( user.NewHumanOTPEmailCodeAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, @@ -2686,48 +2933,8 @@ func TestCommandSide_HumanSendOTPEmail(t *testing.T) { ), ), ), - userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), - defaultSecretGenerators: defaultGenerators, - }, - args: args{ - ctx: ctx, - userID: "user1", - resourceOwner: "org1", - }, - res: res{ - want: &domain.ObjectDetails{ - ResourceOwner: "org1", - }, - }, - }, - { - name: "successful add (without secret config)", - fields: fields{ - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - user.NewHumanOTPEmailAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, - ), - ), - ), - expectFilter(), - expectPush( - user.NewHumanOTPEmailCodeAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("12345678"), - }, - time.Hour, - nil, - ), - ), - ), - userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), - defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: ctx, @@ -2751,19 +2958,6 @@ func TestCommandSide_HumanSendOTPEmail(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - instance.NewSecretGeneratorAddedEvent(context.Background(), - &instance.NewAggregate("instanceID").Aggregate, - domain.SecretGeneratorTypeOTPEmail, - 8, - time.Hour, - true, - true, - true, - true, - )), - ), expectPush( user.NewHumanOTPEmailCodeAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, @@ -2786,8 +2980,8 @@ func TestCommandSide_HumanSendOTPEmail(t *testing.T) { ), ), ), - userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), - defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: ctx, @@ -2813,9 +3007,9 @@ func TestCommandSide_HumanSendOTPEmail(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), - userEncryption: tt.fields.userEncryption, - defaultSecretGenerators: tt.fields.defaultSecretGenerators, + eventstore: tt.fields.eventstore(t), + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, } err := r.HumanSendOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest) assert.ErrorIs(t, err, tt.res.err) diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index a94624231a..9b686f88b7 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -11,6 +11,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/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -67,7 +68,14 @@ func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, "", userAgentID, changeRequired, - c.setPasswordWithVerifyCode(wm.CodeCreationDate, wm.CodeExpiry, wm.Code, code), + c.setPasswordWithVerifyCode( + wm.CodeCreationDate, + wm.CodeExpiry, + wm.Code, + wm.GeneratorID, + wm.VerificationID, + code, + ), ) } @@ -111,17 +119,22 @@ func (c *Commands) setPasswordWithVerifyCode( passwordCodeCreationDate time.Time, passwordCodeExpiry time.Duration, passwordCode *crypto.CryptoValue, + passwordCodeProviderID string, + passwordCodeVerificationID string, code string, ) setPasswordVerification { return func(ctx context.Context) (_ string, err error) { - if passwordCode == nil { - return "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound") - } - _, spanCrypto := tracing.NewNamedSpan(ctx, "crypto.VerifyCode") - defer func() { - spanCrypto.EndWithError(err) - }() - return "", crypto.VerifyCode(passwordCodeCreationDate, passwordCodeExpiry, passwordCode, code, c.userEncryption) + return "", verifyCode( + ctx, + passwordCodeCreationDate, + passwordCodeExpiry, + passwordCode, + passwordCodeProviderID, + passwordCodeVerificationID, + code, + c.userEncryption, + c.phoneCodeVerifier, // password code can only be custom generated by SMS + ) } } @@ -253,11 +266,17 @@ func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M9sd", "Errors.User.NotInitialised") } userAgg := UserAggregateFromWriteModel(&existingHuman.WriteModel) - passwordCode, err := c.newEncryptedCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption) //nolint:staticcheck + var passwordCode *EncryptedCode + var generatorID string + if notifyType == domain.NotificationTypeSms { + passwordCode, generatorID, err = c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption, c.defaultSecretGenerators.PasswordVerificationCode) //nolint:staticcheck + } else { + passwordCode, err = c.newEncryptedCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption) //nolint:staticcheck + } if err != nil { return nil, err } - pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPasswordCodeAddedEvent(ctx, userAgg, passwordCode.Crypted, passwordCode.Expiry, notifyType, authRequestID)) + pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPasswordCodeAddedEvent(ctx, userAgg, passwordCode.CryptedCode(), passwordCode.CodeExpiry(), notifyType, authRequestID, generatorID)) if err != nil { return nil, err } @@ -269,7 +288,7 @@ func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner } // PasswordCodeSent notification send with code to change password -func (c *Commands) PasswordCodeSent(ctx context.Context, orgID, userID string) (err error) { +func (c *Commands) PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) (err error) { if userID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-meEfe", "Errors.User.UserIDMissing") } @@ -282,7 +301,7 @@ func (c *Commands) PasswordCodeSent(ctx context.Context, orgID, userID string) ( return zerrors.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.NotFound") } userAgg := UserAggregateFromWriteModel(&existingPassword.WriteModel) - _, err = c.eventstore.Push(ctx, user.NewHumanPasswordCodeSentEvent(ctx, userAgg)) + _, err = c.eventstore.Push(ctx, user.NewHumanPasswordCodeSentEvent(ctx, userAgg, generatorInfo)) return err } diff --git a/internal/command/user_human_password_model.go b/internal/command/user_human_password_model.go index ce23769812..e43731d866 100644 --- a/internal/command/user_human_password_model.go +++ b/internal/command/user_human_password_model.go @@ -19,6 +19,8 @@ type HumanPasswordWriteModel struct { CodeCreationDate time.Time CodeExpiry time.Duration PasswordCheckFailedCount uint64 + GeneratorID string + VerificationID string UserState domain.UserState } @@ -56,6 +58,10 @@ func (wm *HumanPasswordWriteModel) Reduce() error { wm.Code = e.Code wm.CodeCreationDate = e.CreationDate() wm.CodeExpiry = e.Expiry + wm.GeneratorID = e.GeneratorID + case *user.HumanPasswordCodeSentEvent: + wm.GeneratorID = e.GeneratorInfo.GetID() + wm.VerificationID = e.GeneratorInfo.GetVerificationID() case *user.HumanEmailVerifiedEvent: if wm.UserState == domain.UserStateInitial { wm.UserState = domain.UserStateActive @@ -91,6 +97,7 @@ func (wm *HumanPasswordWriteModel) Query() *eventstore.SearchQueryBuilder { user.HumanInitializedCheckSucceededType, user.HumanPasswordChangedType, user.HumanPasswordCodeAddedType, + user.HumanPasswordCodeSentType, user.HumanEmailVerifiedType, user.HumanPasswordCheckFailedType, user.HumanPasswordCheckSucceededType, @@ -104,6 +111,7 @@ func (wm *HumanPasswordWriteModel) Query() *eventstore.SearchQueryBuilder { user.UserV1InitializedCheckSucceededType, user.UserV1PasswordChangedType, user.UserV1PasswordCodeAddedType, + user.UserV1PasswordCodeSentType, user.UserV1EmailVerifiedType, user.UserV1PasswordCheckFailedType, user.UserV1PasswordCheckSucceededType, diff --git a/internal/command/user_human_password_test.go b/internal/command/user_human_password_test.go index 1eb886828b..9eee9d5b93 100644 --- a/internal/command/user_human_password_test.go +++ b/internal/command/user_human_password_test.go @@ -15,6 +15,8 @@ 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/notification/senders/mock" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -265,6 +267,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { eventstore func(*testing.T) *eventstore.Eventstore userEncryption crypto.EncryptionAlgorithm userPasswordHasher *crypto.Hasher + phoneCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) } type args struct { ctx context.Context @@ -393,6 +396,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { time.Hour*1, domain.NotificationTypeEmail, "", + "", ), ), ), @@ -446,6 +450,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { time.Hour*1, domain.NotificationTypeEmail, "", + "", ), ), ), @@ -522,6 +527,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { time.Hour*1, domain.NotificationTypeEmail, "", + "", ), ), ), @@ -599,6 +605,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { time.Hour*1, domain.NotificationTypeEmail, "", + "", ), ), ), @@ -641,6 +648,92 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { }, }, }, + { + name: "set password (external code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPasswordCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + domain.NotificationTypeSms, + "", + "id", + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPasswordCodeSentEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{ + ID: "id", + VerificationID: "verificationID", + }, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + false, + "", + ), + ), + ), + userPasswordHasher: mockPasswordHasher("x"), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + phoneCodeVerifier: func(ctx context.Context, id string) (senders.CodeGenerator, error) { + sender := mock.NewMockCodeGenerator(gomock.NewController(t)) + sender.EXPECT().VerifyCode("verificationID", "a").Return(nil) + return sender, nil + }, + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + password: "password", + code: "a", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -648,6 +741,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { eventstore: tt.fields.eventstore(t), userPasswordHasher: tt.fields.userPasswordHasher, userEncryption: tt.fields.userEncryption, + phoneCodeVerifier: tt.fields.phoneCodeVerifier, } got, err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.userAgentID, tt.args.changeRequired) if tt.res.err == nil { @@ -1248,6 +1342,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { time.Hour*1, domain.NotificationTypeEmail, "", + "", ), ), ), @@ -1304,6 +1399,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { time.Hour*1, domain.NotificationTypeEmail, "authRequestID", + "", ), ), ), @@ -1350,6 +1446,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { ctx context.Context userID string resourceOwner string + generatorInfo *senders.CodeGeneratorInfo } type res struct { err func(error) bool @@ -1418,6 +1515,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { expectPush( user.NewHumanPasswordCodeSentEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{}, ), ), ), @@ -1426,6 +1524,55 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { ctx: context.Background(), userID: "user1", resourceOwner: "org1", + generatorInfo: &senders.CodeGeneratorInfo{}, + }, + res: res{}, + }, + { + name: "code sent (external code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+411234567", + ), + ), + ), + expectPush( + user.NewHumanPasswordCodeSentEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{ + ID: "generatorID", + VerificationID: "verificationID", + }, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + generatorInfo: &senders.CodeGeneratorInfo{ + ID: "generatorID", + VerificationID: "verificationID", + }, }, res: res{}, }, @@ -1435,7 +1582,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore(t), } - err := r.PasswordCodeSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID) + err := r.PasswordCodeSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.generatorInfo) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user_human_phone.go b/internal/command/user_human_phone.go index 7003c0530c..0da72b0222 100644 --- a/internal/command/user_human_phone.go +++ b/internal/command/user_human_phone.go @@ -5,9 +5,12 @@ import ( "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -40,11 +43,11 @@ func (c *Commands) ChangeHumanPhone(ctx context.Context, phone *domain.Phone, re if phone.IsPhoneVerified { events = append(events, user.NewHumanPhoneVerifiedEvent(ctx, userAgg)) } else { - phoneCode, err := domain.NewPhoneCode(phoneCodeGenerator) + phoneCode, generatorID, err := c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck if err != nil { return nil, err } - events = append(events, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.Code, phoneCode.Expiry)) + events = append(events, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.CryptedCode(), phoneCode.CodeExpiry(), generatorID)) } pushedEvents, err := c.eventstore.Push(ctx, events...) @@ -74,12 +77,22 @@ func (c *Commands) VerifyHumanPhone(ctx context.Context, userID, code, resourceo if !existingCode.UserState.Exists() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Rsj8c", "Errors.User.NotFound") } - if !existingCode.State.Exists() || existingCode.Code == nil { + if !existingCode.State.Exists() || (existingCode.Code == nil && existingCode.GeneratorID == "") { return nil, zerrors.ThrowNotFound(nil, "COMMAND-Rsj8c", "Errors.User.Code.NotFound") } userAgg := UserAggregateFromWriteModel(&existingCode.WriteModel) - err = crypto.VerifyCode(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, phoneCodeGenerator.Alg()) + err = verifyCode( + ctx, + existingCode.CodeCreationDate, + existingCode.CodeExpiry, + existingCode.Code, + existingCode.GeneratorID, + existingCode.VerificationID, + code, + phoneCodeGenerator.Alg(), + c.phoneCodeVerifier, + ) if err == nil { pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPhoneVerifiedEvent(ctx, userAgg)) if err != nil { @@ -92,10 +105,47 @@ func (c *Commands) VerifyHumanPhone(ctx context.Context, userID, code, resourceo return writeModelToObjectDetails(&existingCode.WriteModel), nil } _, err = c.eventstore.Push(ctx, user.NewHumanPhoneVerificationFailedEvent(ctx, userAgg)) - logging.LogWithFields("COMMAND-5M9ds", "userID", userAgg.ID).OnError(err).Error("NewHumanPhoneVerificationFailedEvent push failed") + logging.WithFields("userID", userAgg.ID).OnError(err).Error("NewHumanPhoneVerificationFailedEvent push failed") return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-sM0cs", "Errors.User.Code.Invalid") } +func (c *Commands) phoneCodeVerifierFromConfig(ctx context.Context, id string) (senders.CodeGenerator, error) { + config, err := c.getSMSConfig(ctx, authz.GetInstance(ctx).InstanceID(), id) + if err != nil { + return nil, err + } + if config.State != domain.SMSConfigStateActive { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-M0odsf", "Errors.SMSConfig.NotFound") + } + if config.Twilio != nil { + if config.Twilio.VerifyServiceSID == "" { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Sgb4h", "Errors.SMSConfig.NotExternalVerification") + } + token, err := crypto.DecryptString(config.Twilio.Token, c.smsEncryption) + if err != nil { + return nil, err + } + return &twilio.Config{ + SID: config.Twilio.SID, + Token: token, + SenderNumber: config.Twilio.SenderNumber, + VerifyServiceSID: config.Twilio.VerifyServiceSID, + }, nil + } + return nil, nil +} + +func (c *Commands) activeSMSProvider(ctx context.Context) (string, error) { + config, err := c.getActiveSMSConfig(ctx, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return "", err + } + if config.State == domain.SMSConfigStateActive && config.Twilio != nil && config.Twilio.VerifyServiceSID != "" { + return config.ID, nil + } + return "", err +} + func (c *Commands) CreateHumanPhoneVerificationCode(ctx context.Context, userID, resourceowner string) (*domain.ObjectDetails, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4M0ds", "Errors.User.UserIDMissing") @@ -115,23 +165,19 @@ func (c *Commands) CreateHumanPhoneVerificationCode(ctx context.Context, userID, if existingPhone.IsPhoneVerified { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M9sf", "Errors.User.Phone.AlreadyVerified") } - config, err := cryptoGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode) //nolint:staticcheck - if err != nil { - return nil, err - } - phoneCode, err := domain.NewPhoneCode(crypto.NewEncryptionGenerator(*config, c.userEncryption)) + phoneCode, generatorID, err := c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck if err != nil { return nil, err } userAgg := UserAggregateFromWriteModel(&existingPhone.WriteModel) - if err = c.pushAppendAndReduce(ctx, existingPhone, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.Code, phoneCode.Expiry)); err != nil { + if err = c.pushAppendAndReduce(ctx, existingPhone, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.CryptedCode(), phoneCode.CodeExpiry(), generatorID)); err != nil { return nil, err } return writeModelToObjectDetails(&existingPhone.WriteModel), nil } -func (c *Commands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string) (err error) { +func (c *Commands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) (err error) { if userID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-3m9Fs", "Errors.User.UserIDMissing") } @@ -148,7 +194,7 @@ func (c *Commands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, us } userAgg := UserAggregateFromWriteModel(&existingPhone.WriteModel) - _, err = c.eventstore.Push(ctx, user.NewHumanPhoneCodeSentEvent(ctx, userAgg)) + _, err = c.eventstore.Push(ctx, user.NewHumanPhoneCodeSentEvent(ctx, userAgg, generatorInfo)) return err } diff --git a/internal/command/user_human_phone_model.go b/internal/command/user_human_phone_model.go index ddac65f4bb..41e8e84570 100644 --- a/internal/command/user_human_phone_model.go +++ b/internal/command/user_human_phone_model.go @@ -19,6 +19,8 @@ type HumanPhoneWriteModel struct { Code *crypto.CryptoValue CodeCreationDate time.Time CodeExpiry time.Duration + GeneratorID string + VerificationID string State domain.PhoneState UserState domain.UserState @@ -64,6 +66,10 @@ func (wm *HumanPhoneWriteModel) Reduce() error { wm.Code = e.Code wm.CodeCreationDate = e.CreationDate() wm.CodeExpiry = e.Expiry + wm.GeneratorID = e.GeneratorID + case *user.HumanPhoneCodeSentEvent: + wm.GeneratorID = e.GeneratorInfo.GetID() + wm.VerificationID = e.GeneratorInfo.GetVerificationID() case *user.HumanPhoneRemovedEvent: wm.State = domain.PhoneStateRemoved wm.IsPhoneVerified = false @@ -90,6 +96,7 @@ func (wm *HumanPhoneWriteModel) Query() *eventstore.SearchQueryBuilder { user.HumanPhoneChangedType, user.HumanPhoneVerifiedType, user.HumanPhoneCodeAddedType, + user.HumanPhoneCodeSentType, user.HumanPhoneRemovedType, user.UserRemovedType, user.UserV1AddedType, diff --git a/internal/command/user_human_phone_test.go b/internal/command/user_human_phone_test.go index 9ee3fcbdd2..66835ef3fd 100644 --- a/internal/command/user_human_phone_test.go +++ b/internal/command/user_human_phone_test.go @@ -13,14 +13,29 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/senders/mock" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_ChangeHumanPhone(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + defaultSecretGenerators *SecretGenerators + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc } type args struct { ctx context.Context @@ -41,9 +56,7 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { { name: "invalid phone, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -61,8 +74,7 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -83,8 +95,7 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { { name: "phone not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -126,8 +137,7 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { { name: "verified phone changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -186,8 +196,7 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { { name: "phone changed to verified, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -242,8 +251,7 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { { name: "phone changed to verified, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -298,8 +306,7 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { { name: "phone changed with code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -316,6 +323,36 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( user.NewHumanPhoneChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -330,9 +367,100 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), + userEncryption: crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)), + defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("a", time.Hour), + }, + args: args{ + ctx: context.Background(), + email: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + }, + PhoneNumber: "+41711234567", + }, + resourceOwner: "org1", + secretGenerator: GetMockSecretGenerator(t), + }, + res: res{ + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: "+41711234567", + }, + }, + }, + { + name: "phone changed with code (external), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSID", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+41711234567", + ), + user.NewHumanPhoneCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + "id", + ), + ), + ), + userEncryption: crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -359,7 +487,10 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, } got, err := r.ChangeHumanPhone(tt.args.ctx, tt.args.email, tt.args.resourceOwner, tt.args.secretGenerator) if tt.res.err == nil { @@ -377,7 +508,8 @@ func TestCommandSide_ChangeHumanPhone(t *testing.T) { func TestCommandSide_VerifyHumanPhone(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore + phoneCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) } type args struct { ctx context.Context @@ -399,9 +531,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -415,9 +545,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { { name: "code missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -431,8 +559,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -449,8 +576,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { { name: "code not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -482,8 +608,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { { name: "invalid code, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -515,6 +640,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -539,8 +665,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { { name: "valid code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -572,6 +697,7 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -595,11 +721,80 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { }, }, }, + { + name: "valid code (external), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+411234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPhoneCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + "id", + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanPhoneCodeSentEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{ + ID: "id", + VerificationID: "verificationID", + }, + ), + ), + ), + expectPush( + user.NewHumanPhoneVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + phoneCodeVerifier: func(ctx context.Context, id string) (senders.CodeGenerator, error) { + sender := mock.NewMockCodeGenerator(gomock.NewController(t)) + sender.EXPECT().VerifyCode("verificationID", "a") + return sender, nil + }, + }, + args: args{ + ctx: context.Background(), + userID: "user1", + code: "a", + resourceOwner: "org1", + secretGenerator: GetMockSecretGenerator(t), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + phoneCodeVerifier: tt.fields.phoneCodeVerifier, } got, err := r.VerifyHumanPhone(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.secretGenerator) if tt.res.err == nil { @@ -616,9 +811,21 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { } func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore *eventstore.Eventstore - userEncryption crypto.EncryptionAlgorithm + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + defaultSecretGenerators *SecretGenerators + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc } type args struct { ctx context.Context @@ -638,9 +845,7 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -653,8 +858,7 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -670,8 +874,7 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { { name: "phone already verified, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -713,8 +916,7 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { { name: "new code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -739,16 +941,33 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { ), expectFilter( eventFromEventPusher( - instance.NewSecretGeneratorAddedEvent(context.Background(), + instance.NewSMSConfigActivatedEvent( + context.Background(), &instance.NewAggregate("instanceID").Aggregate, - domain.SecretGeneratorTypeVerifyPhoneCode, - 8, - time.Hour, - true, - true, - true, - true, - )), + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), ), expectPush( user.NewHumanPhoneCodeAddedEvent(context.Background(), @@ -760,10 +979,92 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { Crypted: []byte("12345678"), }, time.Hour*1, + "", ), ), ), - userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + defaultSecretGenerators: defaultGenerators, + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "new code (external), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+411234567", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSID", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + user.NewHumanPhoneCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + "id", + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -780,8 +1081,10 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - userEncryption: tt.fields.userEncryption, + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, } got, err := r.CreateHumanPhoneVerificationCode(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) if tt.res.err == nil { @@ -799,12 +1102,13 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context userID string resourceOwner string + generatorInfo *senders.CodeGeneratorInfo } type res struct { err func(error) bool @@ -818,9 +1122,7 @@ func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -833,8 +1135,7 @@ func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -850,8 +1151,7 @@ func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { { name: "code sent, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -877,6 +1177,7 @@ func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { expectPush( user.NewHumanPhoneCodeSentEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{}, ), ), ), @@ -885,6 +1186,55 @@ func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { ctx: context.Background(), userID: "user1", resourceOwner: "org1", + generatorInfo: &senders.CodeGeneratorInfo{}, + }, + res: res{}, + }, + { + name: "code sent (external), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+411234567", + ), + ), + ), + expectPush( + user.NewHumanPhoneCodeSentEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &senders.CodeGeneratorInfo{ + ID: "generatorID", + VerificationID: "verificationID", + }, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + generatorInfo: &senders.CodeGeneratorInfo{ + ID: "generatorID", + VerificationID: "verificationID", + }, }, res: res{}, }, @@ -892,9 +1242,9 @@ func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - err := r.HumanPhoneVerificationCodeSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID) + err := r.HumanPhoneVerificationCodeSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.generatorInfo) if tt.res.err == nil { assert.NoError(t, err) } @@ -907,7 +1257,7 @@ func TestCommandSide_PhoneVerificationCodeSent(t *testing.T) { func TestCommandSide_RemoveHumanPhone(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -927,9 +1277,7 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -942,8 +1290,7 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -959,8 +1306,7 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) { { name: "phone not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -991,8 +1337,7 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) { { name: "remove phone, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1037,7 +1382,7 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.RemoveHumanPhone(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index 468a27e8b8..fbf3523fc9 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -19,18 +19,31 @@ import ( "github.com/zitadel/zitadel/internal/id" id_mock "github.com/zitadel/zitadel/internal/id/mock" "github.com/zitadel/zitadel/internal/repository/idp" + "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_AddHuman(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - idGenerator id.Generator - userPasswordHasher *crypto.Hasher - codeAlg crypto.EncryptionAlgorithm - newCode encrypedCodeFunc + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + userPasswordHasher *crypto.Hasher + codeAlg crypto.EncryptionAlgorithm + newCode encrypedCodeFunc + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -660,7 +673,6 @@ func TestCommandSide_AddHuman(t *testing.T) { idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), userPasswordHasher: mockPasswordHasher("x"), codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockEncryptedCode("emailCode", time.Hour), }, args: args{ ctx: context.Background(), @@ -1042,6 +1054,36 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( newAddHumanEvent("$plain$x$password", false, true, "+41711234567", AllowedLanguage), user.NewHumanEmailVerifiedEvent( @@ -1057,13 +1099,15 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("phonecode"), }, time.Hour*1, + "", ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockEncryptedCode("phonecode", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phonecode", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -1183,6 +1227,36 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( newAddHumanEvent("$plain$x$password", false, true, "+41711234567", AllowedLanguage), user.NewHumanEmailVerifiedEvent(context.Background(), @@ -1198,13 +1272,15 @@ func TestCommandSide_AddHuman(t *testing.T) { }, 1*time.Hour, true, + "", ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockEncryptedCode("phoneCode", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -1307,11 +1383,13 @@ func TestCommandSide_AddHuman(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), - userPasswordHasher: tt.fields.userPasswordHasher, - userEncryption: tt.fields.codeAlg, - idGenerator: tt.fields.idGenerator, - newEncryptedCode: tt.fields.newCode, + eventstore: tt.fields.eventstore(t), + userPasswordHasher: tt.fields.userPasswordHasher, + userEncryption: tt.fields.codeAlg, + idGenerator: tt.fields.idGenerator, + newEncryptedCode: tt.fields.newCode, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, } err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail) if tt.res.err == nil { @@ -1332,10 +1410,22 @@ func TestCommandSide_AddHuman(t *testing.T) { } func TestCommandSide_ImportHuman(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore func(*testing.T) *eventstore.Eventstore - idGenerator id.Generator - userPasswordHasher *crypto.Hasher + eventstore func(*testing.T) *eventstore.Eventstore + idGenerator id.Generator + userPasswordHasher *crypto.Hasher + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -1889,6 +1979,36 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( newAddHumanEvent("$plain$x$password", false, true, "+41711234567", AllowedLanguage), user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -1910,11 +2030,15 @@ func TestCommandSide_ImportHuman(t *testing.T) { KeyID: "id", Crypted: []byte("a"), }, - time.Hour*1), + time.Hour*1, + "", + ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("a", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args{ ctx: context.Background(), @@ -2866,9 +2990,11 @@ func TestCommandSide_ImportHuman(t *testing.T) { t.Run(tt.name, func(t *testing.T) { f, a := tt.given(t) r := &Commands{ - eventstore: f.eventstore(t), - idGenerator: f.idGenerator, - userPasswordHasher: f.userPasswordHasher, + eventstore: f.eventstore(t), + idGenerator: f.idGenerator, + userPasswordHasher: f.userPasswordHasher, + newEncryptedCodeWithDefault: f.newEncryptedCodeWithDefault, + defaultSecretGenerators: f.defaultSecretGenerators, } gotHuman, gotCode, err := r.ImportHuman(a.ctx, a.orgID, a.human, a.passwordless, a.links, a.secretGenerator, a.secretGenerator, a.secretGenerator, a.secretGenerator) if tt.res.err == nil { diff --git a/internal/command/user_schema.go b/internal/command/user_schema.go index 7b53c9e165..3120071d55 100644 --- a/internal/command/user_schema.go +++ b/internal/command/user_schema.go @@ -186,7 +186,7 @@ func validateUserSchema(userSchema json.RawMessage) error { } func (c *Commands) getSchemaWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserSchemaWriteModel, error) { - writeModel := NewUserSchemaWriteModel(resourceOwner, id, "") + writeModel := NewUserSchemaWriteModel(resourceOwner, id) if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { return nil, err } diff --git a/internal/command/user_schema_model.go b/internal/command/user_schema_model.go index e8df80479a..08352e6d57 100644 --- a/internal/command/user_schema_model.go +++ b/internal/command/user_schema_model.go @@ -19,16 +19,15 @@ type UserSchemaWriteModel struct { Schema json.RawMessage PossibleAuthenticators []domain.AuthenticatorType State domain.UserSchemaState - Revision uint64 + SchemaRevision uint64 } -func NewUserSchemaWriteModel(resourceOwner, schemaID, ty string) *UserSchemaWriteModel { +func NewUserSchemaWriteModel(resourceOwner, schemaID string) *UserSchemaWriteModel { return &UserSchemaWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: schemaID, ResourceOwner: resourceOwner, }, - SchemaType: ty, } } @@ -40,13 +39,13 @@ func (wm *UserSchemaWriteModel) Reduce() error { wm.Schema = e.Schema wm.PossibleAuthenticators = e.PossibleAuthenticators wm.State = domain.UserSchemaStateActive - wm.Revision = 1 + wm.SchemaRevision = 1 case *schema.UpdatedEvent: if e.SchemaType != nil { wm.SchemaType = *e.SchemaType } if e.SchemaRevision != nil { - wm.Revision = *e.SchemaRevision + wm.SchemaRevision = *e.SchemaRevision } if len(e.Schema) > 0 { wm.Schema = e.Schema @@ -79,10 +78,6 @@ func (wm *UserSchemaWriteModel) Query() *eventstore.SearchQueryBuilder { schema.DeletedType, ) - if wm.SchemaType != "" { - query = query.EventData(map[string]interface{}{"schemaType": wm.SchemaType}) - } - return query.Builder() } func (wm *UserSchemaWriteModel) NewUpdatedEvent( @@ -99,7 +94,7 @@ func (wm *UserSchemaWriteModel) NewUpdatedEvent( if !bytes.Equal(wm.Schema, userSchema) { changes = append(changes, schema.ChangeSchema(userSchema)) // change revision if the content of the schema changed - changes = append(changes, schema.IncreaseRevision(wm.Revision)) + changes = append(changes, schema.IncreaseRevision(wm.SchemaRevision)) } if len(possibleAuthenticators) > 0 && slices.Compare(wm.PossibleAuthenticators, possibleAuthenticators) != 0 { changes = append(changes, schema.ChangePossibleAuthenticators(possibleAuthenticators)) diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index 08b8da72b5..a85f905e05 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -352,11 +352,11 @@ func (c *Commands) changeUserPhone(ctx context.Context, cmds []eventstore.Comman if phone.Verified { return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), code, nil } else { - cryptoCode, err := c.newPhoneCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck + cryptoCode, generatorID, err := c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, alg, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck if err != nil { return cmds, code, err } - cmds = append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &wm.Aggregate().Aggregate, cryptoCode.Crypted, cryptoCode.Expiry, phone.ReturnCode)) + cmds = append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &wm.Aggregate().Aggregate, cryptoCode.CryptedCode(), cryptoCode.CodeExpiry(), phone.ReturnCode, generatorID)) if phone.ReturnCode { code = &cryptoCode.Plain } @@ -389,7 +389,14 @@ func (c *Commands) changeUserPassword(ctx context.Context, cmds []eventstore.Com verification := c.setPasswordWithPermission(wm.AggregateID, wm.ResourceOwner) // otherwise check the password code... if password.PasswordCode != "" { - verification = c.setPasswordWithVerifyCode(wm.PasswordCodeCreationDate, wm.PasswordCodeExpiry, wm.PasswordCode, password.PasswordCode) + verification = c.setPasswordWithVerifyCode( + wm.PasswordCodeCreationDate, + wm.PasswordCodeExpiry, + wm.PasswordCode, + wm.PasswordCodeGeneratorID, + wm.PasswordCodeVerificationID, + password.PasswordCode, + ) } // ...or old password if password.OldPassword != "" { diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index 37cd2837e7..24899a9301 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -18,18 +18,31 @@ import ( "github.com/zitadel/zitadel/internal/id" id_mock "github.com/zitadel/zitadel/internal/id/mock" "github.com/zitadel/zitadel/internal/repository/idp" + "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_AddUserHuman(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - idGenerator id.Generator - userPasswordHasher *crypto.Hasher - newCode encrypedCodeFunc - checkPermission domain.PermissionCheck + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + userPasswordHasher *crypto.Hasher + newCode encrypedCodeFunc + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + checkPermission domain.PermissionCheck + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -1027,6 +1040,36 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( newAddHumanEvent("$plain$x$password", false, true, "+41711234567", language.English), user.NewHumanEmailVerifiedEvent( @@ -1042,13 +1085,120 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("phonecode"), }, time.Hour*1, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - newCode: mockEncryptedCode("phonecode", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phonecode", time.Hour), + defaultSecretGenerators: defaultGenerators, + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Password: "password", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + Phone: Phone{ + Number: "+41711234567", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with phone), ok (external)", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifiyServiceSid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "+41711234567", language.English), + user.NewHumanEmailVerifiedEvent( + context.Background(), + &userAgg.Aggregate, + ), + user.NewHumanPhoneCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + nil, + 0, + "id", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phonecode", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -1171,6 +1321,36 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( newAddHumanEvent("$plain$x$password", false, true, "+41711234567", language.English), user.NewHumanEmailVerifiedEvent(context.Background(), @@ -1186,13 +1366,15 @@ func TestCommandSide_AddUserHuman(t *testing.T) { }, 1*time.Hour, true, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - newCode: mockEncryptedCode("phoneCode", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -1547,11 +1729,13 @@ func TestCommandSide_AddUserHuman(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), - userPasswordHasher: tt.fields.userPasswordHasher, - idGenerator: tt.fields.idGenerator, - newEncryptedCode: tt.fields.newCode, - checkPermission: tt.fields.checkPermission, + eventstore: tt.fields.eventstore(t), + userPasswordHasher: tt.fields.userPasswordHasher, + idGenerator: tt.fields.idGenerator, + newEncryptedCode: tt.fields.newCode, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + checkPermission: tt.fields.checkPermission, multifactors: domain.MultifactorConfigs{ OTP: domain.OTPConfig{ Issuer: "zitadel.com", @@ -1578,11 +1762,23 @@ func TestCommandSide_AddUserHuman(t *testing.T) { } func TestCommandSide_ChangeUserHuman(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - userPasswordHasher *crypto.Hasher - newCode encrypedCodeFunc - checkPermission domain.PermissionCheck + eventstore func(t *testing.T) *eventstore.Eventstore + userPasswordHasher *crypto.Hasher + newCode encrypedCodeFunc + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + checkPermission domain.PermissionCheck + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -2107,6 +2303,36 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { newAddHumanEvent("$plain$x$password", true, true, "", language.English), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( user.NewHumanPhoneChangedEvent(context.Background(), &userAgg.Aggregate, @@ -2122,11 +2348,13 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { }, time.Hour, false, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("phoneCode", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -2145,7 +2373,83 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ResourceOwner: "org1", }, }, - }, { + }, + { + name: "change human phone, ok (external)", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, true, "", language.English), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), + defaultSecretGenerators: defaultGenerators, + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Phone: &Phone{ + Number: "+41791234567", + }, + }, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { name: "change human phone verified, not allowed", fields: fields{ eventstore: expectEventstore( @@ -2255,6 +2559,36 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { newAddHumanEvent("$plain$x$password", true, true, "", language.English), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( user.NewHumanPhoneChangedEvent(context.Background(), &userAgg.Aggregate, @@ -2270,11 +2604,13 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { }, time.Hour, true, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("phoneCode", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ ctx: context.Background(), @@ -2929,11 +3265,13 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), - userPasswordHasher: tt.fields.userPasswordHasher, - newEncryptedCode: tt.fields.newCode, - checkPermission: tt.fields.checkPermission, - userEncryption: tt.args.codeAlg, + eventstore: tt.fields.eventstore(t), + userPasswordHasher: tt.fields.userPasswordHasher, + newEncryptedCode: tt.fields.newCode, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + checkPermission: tt.fields.checkPermission, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + userEncryption: tt.args.codeAlg, } err := r.ChangeUserHuman(tt.args.ctx, tt.args.human, tt.args.codeAlg) if tt.res.err == nil { diff --git a/internal/command/user_v2_model.go b/internal/command/user_v2_model.go index d31c3e6676..25150ab655 100644 --- a/internal/command/user_v2_model.go +++ b/internal/command/user_v2_model.go @@ -42,13 +42,15 @@ type UserV2WriteModel struct { InitCodeExpiry time.Duration InitCheckFailedCount uint64 - PasswordWriteModel bool - PasswordEncodedHash string - PasswordChangeRequired bool - PasswordCode *crypto.CryptoValue - PasswordCodeCreationDate time.Time - PasswordCodeExpiry time.Duration - PasswordCheckFailedCount uint64 + PasswordWriteModel bool + PasswordEncodedHash string + PasswordChangeRequired bool + PasswordCode *crypto.CryptoValue + PasswordCodeCreationDate time.Time + PasswordCodeExpiry time.Duration + PasswordCheckFailedCount uint64 + PasswordCodeGeneratorID string + PasswordCodeVerificationID string EmailWriteModel bool Email domain.EmailAddress @@ -270,7 +272,9 @@ func (wm *UserV2WriteModel) Reduce() error { wm.PasswordChangeRequired = e.ChangeRequired wm.EmptyPasswordCode() case *user.HumanPasswordCodeAddedEvent: - wm.SetPasswordCode(e.Code, e.Expiry, e.CreationDate()) + wm.SetPasswordCode(e) + case *user.HumanPasswordCodeSentEvent: + wm.SetPasswordCodeSent(e) case *user.UserIDPLinkAddedEvent: wm.AddIDPLink(e.IDPConfigID, e.DisplayName, e.ExternalUserID) case *user.UserIDPLinkRemovedEvent: @@ -337,10 +341,16 @@ func (wm *UserV2WriteModel) EmptyPasswordCode() { wm.PasswordCodeExpiry = 0 wm.PasswordCodeCreationDate = time.Time{} } -func (wm *UserV2WriteModel) SetPasswordCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) { - wm.PasswordCode = code - wm.PasswordCodeExpiry = expiry - wm.PasswordCodeCreationDate = creationDate +func (wm *UserV2WriteModel) SetPasswordCode(e *user.HumanPasswordCodeAddedEvent) { + wm.PasswordCode = e.Code + wm.PasswordCodeExpiry = e.Expiry + wm.PasswordCodeCreationDate = e.CreationDate() + wm.PasswordCodeGeneratorID = e.GeneratorID +} + +func (wm *UserV2WriteModel) SetPasswordCodeSent(e *user.HumanPasswordCodeSentEvent) { + wm.PasswordCodeGeneratorID = e.GeneratorInfo.GetID() + wm.PasswordCodeVerificationID = e.GeneratorInfo.GetVerificationID() } func (wm *UserV2WriteModel) Query() *eventstore.SearchQueryBuilder { diff --git a/internal/command/user_v2_model_test.go b/internal/command/user_v2_model_test.go index a62bf4546a..ba5180cae3 100644 --- a/internal/command/user_v2_model_test.go +++ b/internal/command/user_v2_model_test.go @@ -1021,6 +1021,7 @@ func TestCommandSide_userHumanWriteModel_phone(t *testing.T) { }, time.Hour*1, false, + "", ), ), ), @@ -1064,6 +1065,65 @@ func TestCommandSide_userHumanWriteModel_phone(t *testing.T) { }, }, }, + { + name: "user added with phone code external", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newRegisterHumanEvent("username", "$plain$x$password", true, true, "", language.English), + ), + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &userAgg.Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &userAgg.Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &UserV2WriteModel{ + HumanWriteModel: true, + PhoneWriteModel: true, + StateWriteModel: true, + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + Events: []eventstore.Event{}, + ProcessedSequence: 0, + ResourceOwner: "org1", + }, + UserName: "username", + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + PasswordEncodedHash: "$plain$x$password", + PasswordChangeRequired: true, + Email: "email@test.ch", + IsEmailVerified: false, + Phone: "+41791234567", + IsPhoneVerified: false, + PhoneCode: nil, + PhoneCodeCreationDate: time.Time{}, + PhoneCodeExpiry: 0, + UserState: domain.UserStateActive, + }, + }, + }, { name: "user added with phone code verified", fields: fields{ @@ -1089,6 +1149,7 @@ func TestCommandSide_userHumanWriteModel_phone(t *testing.T) { }, time.Hour*1, false, + "", ), ), eventFromEventPusher( @@ -1155,6 +1216,7 @@ func TestCommandSide_userHumanWriteModel_phone(t *testing.T) { }, time.Hour*1, false, + "", ), ), eventFromEventPusher( @@ -1229,6 +1291,7 @@ func TestCommandSide_userHumanWriteModel_phone(t *testing.T) { }, time.Hour*1, false, + "", ), ), eventFromEventPusher( diff --git a/internal/command/user_v2_phone.go b/internal/command/user_v2_phone.go index 7ebbd4b4d5..8b754b36f3 100644 --- a/internal/command/user_v2_phone.go +++ b/internal/command/user_v2_phone.go @@ -6,9 +6,11 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command/preparation" "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/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -88,7 +90,7 @@ func (c *Commands) changeUserPhoneWithGenerator(ctx context.Context, userID, pho if err = cmd.Change(ctx, domain.PhoneNumber(phone)); err != nil { return nil, err } - if err = cmd.AddGeneratedCode(ctx, gen, returnCode); err != nil { + if err = cmd.AddGeneratedCode(ctx, returnCode); err != nil { return nil, err } return cmd.Push(ctx) @@ -107,10 +109,10 @@ func (c *Commands) resendUserPhoneCodeWithGenerator(ctx context.Context, userID return nil, err } } - if cmd.model.Code == nil { + if cmd.model.Code == nil && cmd.model.GeneratorID == "" { return nil, zerrors.ThrowPreconditionFailed(err, "PHONE-5xrra88eq8", "Errors.User.Code.Empty") } - if err = cmd.AddGeneratedCode(ctx, gen, returnCode); err != nil { + if err = cmd.AddGeneratedCode(ctx, returnCode); err != nil { return nil, err } return cmd.Push(ctx) @@ -166,10 +168,12 @@ func (c *Commands) removeUserPhone(ctx context.Context, userID string) (*domain. // UserPhoneEvents allows step-by-step additions of events, // operating on the Human Phone Model. type UserPhoneEvents struct { - eventstore *eventstore.Eventstore - aggregate *eventstore.Aggregate - events []eventstore.Command - model *HumanPhoneWriteModel + eventstore *eventstore.Eventstore + aggregate *eventstore.Aggregate + events []eventstore.Command + model *HumanPhoneWriteModel + generateCode func(ctx context.Context, filter preparation.FilterToQueryReducer) (*EncryptedCode, string, error) + getCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) plainCode *string } @@ -196,6 +200,10 @@ func (c *Commands) NewUserPhoneEvents(ctx context.Context, userID string) (*User eventstore: c.eventstore, aggregate: UserAggregateFromWriteModel(&model.WriteModel), model: model, + generateCode: func(ctx context.Context, filter preparation.FilterToQueryReducer) (*EncryptedCode, string, error) { + return c.newPhoneCode(ctx, filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) + }, + getCodeVerifier: c.phoneCodeVerifier, }, nil } @@ -229,15 +237,14 @@ func (c *UserPhoneEvents) SetVerified(ctx context.Context) { // AddGeneratedCode generates a new encrypted code and sets it to the phone number. // When returnCode a plain text of the code will be returned from Push. -func (c *UserPhoneEvents) AddGeneratedCode(ctx context.Context, gen crypto.Generator, returnCode bool) error { - value, plain, err := crypto.NewCode(gen) +func (c *UserPhoneEvents) AddGeneratedCode(ctx context.Context, returnCode bool) error { + code, generatorID, err := c.generateCode(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return err } - - c.events = append(c.events, user.NewHumanPhoneCodeAddedEventV2(ctx, c.aggregate, value, gen.Expiry(), returnCode)) + c.events = append(c.events, user.NewHumanPhoneCodeAddedEventV2(ctx, c.aggregate, code.CryptedCode(), code.CodeExpiry(), returnCode, generatorID)) if returnCode { - c.plainCode = &plain + c.plainCode = &code.Plain } return nil } @@ -247,7 +254,17 @@ func (c *UserPhoneEvents) VerifyCode(ctx context.Context, code string, gen crypt return zerrors.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty") } - err := crypto.VerifyCode(c.model.CodeCreationDate, c.model.CodeExpiry, c.model.Code, code, gen.Alg()) + err := verifyCode( + ctx, + c.model.CodeCreationDate, + c.model.CodeExpiry, + c.model.Code, + c.model.GeneratorID, + c.model.VerificationID, + code, + gen.Alg(), + c.getCodeVerifier, + ) if err == nil { c.events = append(c.events, user.NewHumanPhoneVerifiedEvent(ctx, c.aggregate)) return nil diff --git a/internal/command/user_v2_phone_test.go b/internal/command/user_v2_phone_test.go index d64960efd9..cba3b6d38c 100644 --- a/internal/command/user_v2_phone_test.go +++ b/internal/command/user_v2_phone_test.go @@ -22,7 +22,7 @@ import ( func TestCommands_ChangeUserPhone(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type args struct { @@ -38,8 +38,7 @@ func TestCommands_ChangeUserPhone(t *testing.T) { { name: "missing permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -81,8 +80,7 @@ func TestCommands_ChangeUserPhone(t *testing.T) { { name: "missing phone", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -124,8 +122,7 @@ func TestCommands_ChangeUserPhone(t *testing.T) { { name: "not changed", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -168,7 +165,7 @@ func TestCommands_ChangeUserPhone(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } _, err := c.ChangeUserPhone(context.Background(), tt.args.userID, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) @@ -180,7 +177,7 @@ func TestCommands_ChangeUserPhone(t *testing.T) { func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type args struct { @@ -196,8 +193,7 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) { { name: "missing permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -239,8 +235,7 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) { { name: "missing phone", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -283,7 +278,7 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } _, err := c.ChangeUserPhoneReturnCode(context.Background(), tt.args.userID, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) @@ -295,7 +290,7 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) { func TestCommands_ResendUserPhoneCode(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type args struct { @@ -310,8 +305,7 @@ func TestCommands_ResendUserPhoneCode(t *testing.T) { { name: "missing permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -352,8 +346,7 @@ func TestCommands_ResendUserPhoneCode(t *testing.T) { { name: "no code", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -395,7 +388,7 @@ func TestCommands_ResendUserPhoneCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } _, err := c.ResendUserPhoneCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) @@ -407,7 +400,7 @@ func TestCommands_ResendUserPhoneCode(t *testing.T) { func TestCommands_ResendUserPhoneCodeReturnCode(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type args struct { @@ -422,8 +415,7 @@ func TestCommands_ResendUserPhoneCodeReturnCode(t *testing.T) { { name: "missing permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -464,8 +456,7 @@ func TestCommands_ResendUserPhoneCodeReturnCode(t *testing.T) { { name: "no code", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent(context.Background(), @@ -507,7 +498,7 @@ func TestCommands_ResendUserPhoneCodeReturnCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } _, err := c.ResendUserPhoneCodeReturnCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) @@ -519,7 +510,7 @@ func TestCommands_ResendUserPhoneCodeReturnCode(t *testing.T) { func TestCommands_ChangeUserPhoneVerified(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type args struct { @@ -536,7 +527,7 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) { { name: "missing userID", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckNotAllowed(), }, args: args{ @@ -548,8 +539,7 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) { { name: "missing permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -582,8 +572,7 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) { { name: "missing phone", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -616,8 +605,7 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) { { name: "phone changed", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -667,7 +655,7 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } got, err := c.ChangeUserPhoneVerified(context.Background(), tt.args.userID, tt.args.phone) @@ -678,9 +666,22 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) { } func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore *eventstore.Eventstore - checkPermission domain.PermissionCheck + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + userEncryption crypto.EncryptionAlgorithm + defaultSecretGenerators *SecretGenerators } type args struct { userID string @@ -697,7 +698,7 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { { name: "missing user", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), }, args: args{ userID: "", @@ -709,8 +710,7 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { { name: "missing permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -744,8 +744,7 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { { name: "missing phone", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -779,8 +778,7 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { { name: "not changed", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -814,8 +812,7 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { { name: "phone changed", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -836,6 +833,36 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { }(), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( user.NewHumanPhoneChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -851,10 +878,13 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { }, time.Hour*1, false, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("a", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ userID: "user1", @@ -873,8 +903,7 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { { name: "phone changed, return code", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -895,6 +924,36 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { }(), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( user.NewHumanPhoneChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -910,10 +969,13 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { }, time.Hour*1, true, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("a", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ userID: "user1", @@ -934,8 +996,11 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, - checkPermission: tt.fields.checkPermission, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + userEncryption: tt.fields.userEncryption, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, } got, err := c.changeUserPhoneWithGenerator(context.Background(), tt.args.userID, tt.args.phone, GetMockSecretGenerator(t), tt.args.returnCode) require.ErrorIs(t, err, tt.wantErr) @@ -945,9 +1010,21 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { } func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore *eventstore.Eventstore - checkPermission domain.PermissionCheck + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { userID string @@ -963,7 +1040,7 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { { name: "missing user", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), }, args: args{ userID: "", @@ -974,8 +1051,7 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { { name: "missing permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -1008,8 +1084,7 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { { name: "no code", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -1042,8 +1117,7 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { { name: "resend", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -1074,6 +1148,37 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { }, time.Hour*1, true, + "", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", ), ), ), @@ -1088,10 +1193,103 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { }, time.Hour*1, false, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("a", time.Hour), + defaultSecretGenerators: defaultGenerators, + }, + args: args{ + userID: "user1", + returnCode: false, + }, + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: "+41791234567", + IsPhoneVerified: false, + }, + }, + { + name: "resend (external)", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + true, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("a", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ userID: "user1", @@ -1109,8 +1307,7 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { { name: "return code", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( func() eventstore.Command { @@ -1141,6 +1338,37 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { }, time.Hour*1, true, + "", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", ), ), ), @@ -1155,10 +1383,13 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { }, time.Hour*1, true, + "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("a", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args: args{ userID: "user1", @@ -1178,8 +1409,10 @@ func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, - checkPermission: tt.fields.checkPermission, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, } got, err := c.resendUserPhoneCodeWithGenerator(context.Background(), tt.args.userID, GetMockSecretGenerator(t), tt.args.returnCode) require.ErrorIs(t, err, tt.wantErr) diff --git a/internal/command/user_v3.go b/internal/command/user_v3.go index f2cacd6cb0..2dede584a6 100644 --- a/internal/command/user_v3.go +++ b/internal/command/user_v3.go @@ -6,28 +6,24 @@ import ( "encoding/json" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" domain_schema "github.com/zitadel/zitadel/internal/domain/schema" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user/schemauser" "github.com/zitadel/zitadel/internal/zerrors" ) type CreateSchemaUser struct { - Details *domain.ObjectDetails - ResourceOwner string - SchemaID string schemaRevision uint64 - ID string - Data json.RawMessage + ResourceOwner string + ID string + Data json.RawMessage Email *Email - ReturnCodeEmail string + ReturnCodeEmail *string Phone *Phone - ReturnCodePhone string + ReturnCodePhone *string } func (s *CreateSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { @@ -45,7 +41,7 @@ func (s *CreateSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { if !schemaWriteModel.Exists() { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-N9QOuN4F7o", "Errors.UserSchema.NotExists") } - s.schemaRevision = schemaWriteModel.Revision + s.schemaRevision = schemaWriteModel.SchemaRevision if s.ID == "" { s.ID, err = c.idGenerator.Next() @@ -99,120 +95,269 @@ func (c *Commands) getSchemaRoleForWrite(ctx context.Context, resourceOwner, use return domain_schema.RoleOwner, nil } -func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser, alg crypto.EncryptionAlgorithm) (err error) { +func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser) (*domain.ObjectDetails, error) { if err := user.Valid(ctx, c); err != nil { - return err + return nil, err } writeModel, err := c.getSchemaUserExists(ctx, user.ResourceOwner, user.ID) if err != nil { - return err - } - if writeModel.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.AlreadyExists") + return nil, err } - userAgg := UserV3AggregateFromWriteModel(&writeModel.WriteModel) - events := []eventstore.Command{ - schemauser.NewCreatedEvent(ctx, - userAgg, - user.SchemaID, user.schemaRevision, user.Data, - ), + events, codeEmail, codePhone, err := writeModel.NewCreated(ctx, + user.SchemaID, + user.schemaRevision, + user.Data, + user.Email, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + func(ctx context.Context) (*EncryptedCode, string, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err } - if user.Email != nil { - events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, events, userAgg, user.Email, alg) - if err != nil { - return err - } + if codeEmail != "" { + user.ReturnCodeEmail = &codeEmail } - if user.Phone != nil { - events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, events, userAgg, user.Phone, alg) - if err != nil { - return err - } + if codePhone != "" { + user.ReturnCodePhone = &codePhone } - - if err := c.pushAppendAndReduce(ctx, writeModel, events...); err != nil { - return err - } - user.Details = writeModelToObjectDetails(&writeModel.WriteModel) - return nil + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) } -func (c *Commands) DeleteSchemaUser(ctx context.Context, id string) (*domain.ObjectDetails, error) { +func (c *Commands) DeleteSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { if id == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Vs4wJCME7T", "Errors.IDMissing") } - writeModel, err := c.getSchemaUserExists(ctx, "", id) + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewDelete(ctx) + if err != nil { + return nil, err + } + + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +type ChangeSchemaUser struct { + schemaWriteModel *UserSchemaWriteModel + + ResourceOwner string + ID string + + SchemaUser *SchemaUser + + Email *Email + ReturnCodeEmail *string + Phone *Phone + ReturnCodePhone *string +} + +type SchemaUser struct { + SchemaID string + Data json.RawMessage +} + +func (s *ChangeSchemaUser) Valid() (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-gEJR1QOGHb", "Errors.IDMissing") + } + if s.Email != nil && s.Email.Address != "" { + if err := s.Email.Validate(); err != nil { + return err + } + } + + if s.Phone != nil && s.Phone.Number != "" { + if s.Phone.Number, err = s.Phone.Number.Normalize(); err != nil { + return err + } + } + + return nil +} + +func (c *Commands) ChangeSchemaUser(ctx context.Context, user *ChangeSchemaUser) (*domain.ObjectDetails, error) { + if err := user.Valid(); err != nil { + return nil, err + } + + writeModel, err := c.getSchemaUserWriteModelByID(ctx, user.ResourceOwner, user.ID) if err != nil { return nil, err } if !writeModel.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound") + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") } - if err := c.checkPermissionDeleteUser(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + + schemaID := writeModel.SchemaID + if user.SchemaUser != nil && user.SchemaUser.SchemaID != "" { + schemaID = user.SchemaUser.SchemaID + } + + var schemaWM *UserSchemaWriteModel + if user.SchemaUser != nil { + schemaWriteModel, err := c.getSchemaWriteModelByID(ctx, "", schemaID) + if err != nil { + return nil, err + } + if !schemaWriteModel.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists") + } + schemaWM = schemaWriteModel + } + + events, codeEmail, codePhone, err := writeModel.NewUpdate(ctx, + schemaWM, + user.SchemaUser, + user.Email, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + func(ctx context.Context) (*EncryptedCode, string, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err + } + + if codeEmail != "" { + user.ReturnCodeEmail = &codeEmail + } + if codePhone != "" { + user.ReturnCodePhone = &codePhone + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) checkPermissionUpdateUserState(ctx context.Context, resourceOwner, userID string) error { + return c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID) +} + +func (c *Commands) LockSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Eu8I2VAfjF", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + if !writeModel.Exists() || writeModel.Locked { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { return nil, err } if err := c.pushAppendAndReduce(ctx, writeModel, - schemauser.NewDeletedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + schemauser.NewLockedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), ); err != nil { return nil, err } return writeModelToObjectDetails(&writeModel.WriteModel), nil } -func (c *Commands) updateSchemaUserEmail(ctx context.Context, events []eventstore.Command, agg *eventstore.Aggregate, email *Email, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { - - events = append(events, schemauser.NewEmailUpdatedEvent(ctx, - agg, - email.Address, - )) - if email.Verified { - events = append(events, schemauser.NewEmailVerifiedEvent(ctx, agg)) - } else { - cryptoCode, err := c.newEmailCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck - if err != nil { - return nil, "", err - } - if email.ReturnCode { - plainCode = cryptoCode.Plain - } - events = append(events, schemauser.NewEmailCodeAddedEvent(ctx, agg, - cryptoCode.Crypted, - cryptoCode.Expiry, - email.URLTemplate, - email.ReturnCode, - )) +func (c *Commands) UnlockSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-krXtYscQZh", "Errors.IDMissing") } - return events, plainCode, nil + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + if !writeModel.Exists() || !writeModel.Locked { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + if err := c.pushAppendAndReduce(ctx, writeModel, + schemauser.NewUnlockedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil } -func (c *Commands) updateSchemaUserPhone(ctx context.Context, events []eventstore.Command, agg *eventstore.Aggregate, phone *Phone, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { - events = append(events, schemauser.NewPhoneChangedEvent(ctx, - agg, - phone.Number, - )) - if phone.Verified { - events = append(events, schemauser.NewPhoneVerifiedEvent(ctx, agg)) - } else { - cryptoCode, err := c.newPhoneCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck - if err != nil { - return nil, "", err - } - if phone.ReturnCode { - plainCode = cryptoCode.Plain - } - events = append(events, schemauser.NewPhoneCodeAddedEvent(ctx, agg, - cryptoCode.Crypted, - cryptoCode.Expiry, - phone.ReturnCode, - )) +func (c *Commands) DeactivateSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-pjJhge86ZV", "Errors.IDMissing") } - return events, plainCode, nil + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + if writeModel.State != domain.UserStateActive { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + if err := c.pushAppendAndReduce(ctx, writeModel, + schemauser.NewDeactivatedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) ActivateSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-17XupGvxBJ", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + if writeModel.State != domain.UserStateInactive { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + if err := c.pushAppendAndReduce(ctx, writeModel, + schemauser.NewActivatedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil } func (c *Commands) getSchemaUserExists(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { - writeModel := NewExistsUserV3WriteModel(resourceOwner, id) + writeModel := NewExistsUserV3WriteModel(resourceOwner, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) getSchemaUserWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3WriteModel(resourceOwner, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) getSchemaUserEmailWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3EmailWriteModel(resourceOwner, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) getSchemaUserPhoneWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3PhoneWriteModel(resourceOwner, id, c.checkPermission) if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { return nil, err } diff --git a/internal/command/user_v3_email.go b/internal/command/user_v3_email.go new file mode 100644 index 0000000000..9fa3a235f5 --- /dev/null +++ b/internal/command/user_v3_email.go @@ -0,0 +1,115 @@ +package command + +import ( + "context" + "io" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeSchemaUserEmail struct { + ResourceOwner string + ID string + + Email *Email + ReturnCode *string +} + +func (s *ChangeSchemaUserEmail) Valid() (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-0oj2PquNGA", "Errors.IDMissing") + } + if s.Email != nil && s.Email.Address != "" { + if err := s.Email.Validate(); err != nil { + return err + } + } + if s.Email != nil && s.Email.URLTemplate != "" { + if err := domain.RenderConfirmURLTemplate(io.Discard, s.Email.URLTemplate, s.ID, "code", "orgID"); err != nil { + return err + } + } + return nil +} + +func (c *Commands) ChangeSchemaUserEmail(ctx context.Context, user *ChangeSchemaUserEmail) (_ *domain.ObjectDetails, err error) { + if err := user.Valid(); err != nil { + return nil, err + } + + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewEmailUpdate(ctx, + user.Email, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.ReturnCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) VerifySchemaUserEmail(ctx context.Context, resourceOwner, id, code string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-y3n4Sdu8j5", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewEmailVerify(ctx, + func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error { + return crypto.VerifyCode(creationDate, expiry, cryptoCode, code, c.userEncryption) + }, + ) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +type ResendSchemaUserEmailCode struct { + ResourceOwner string + ID string + + URLTemplate string + ReturnCode bool + PlainCode *string +} + +func (c *Commands) ResendSchemaUserEmailCode(ctx context.Context, user *ResendSchemaUserEmailCode) (*domain.ObjectDetails, error) { + if user.ID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-KvPc5o9GeJ", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewResendEmailCode(ctx, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + user.URLTemplate, + user.ReturnCode, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.PlainCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} diff --git a/internal/command/user_v3_email_test.go b/internal/command/user_v3_email_test.go new file mode 100644 index 0000000000..5516b32f19 --- /dev/null +++ b/internal/command/user_v3_email_test.go @@ -0,0 +1,1076 @@ +package command + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_ChangeSchemaUserEmail(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ChangeSchemaUserEmail + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-0oj2PquNGA", "Errors.IDMissing")) + }, + }, + }, + { + "no valid email, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "no valid template, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail", URLTemplate: "{{"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "email update, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-nJ0TQFuRmP", "Errors.User.NotFound")) + }, + }, + }, + { + "email update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail@example.com"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email update, email not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "test@example.com", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email update, email return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "emailverify", + }, + }, + { + "user updated, email to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "test@example.com", Verified: true}, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ChangeSchemaUserEmail(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.ReturnCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.ReturnCode) + } + } + }) + } +} + +func TestCommands_VerifySchemaUserEmail(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner string + id string + code string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-y3n4Sdu8j5", "Errors.IDMissing")) + }, + }, + }, + { + "email verify, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-qbGyMPvjvj", "Errors.User.NotFound")) + }, + }, + }, + { + "email verify, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email verify, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email verify, wrong code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid")) + }, + }, + }, + { + "email verify, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + code: "emailverify", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.VerifySchemaUserEmail(tt.args.ctx, tt.args.resourceOwner, tt.args.id, tt.args.code) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_ResendSchemaUserEmailCode(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ResendSchemaUserEmailCode + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-KvPc5o9GeJ", "Errors.IDMissing")) + }, + }, + }, + { + "email code resend, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-EajeF6ypOV", "Errors.User.NotFound")) + }, + }, + }, + { + "email code resend, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty")) + }, + }, + }, + { + "email code resend, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty")) + }, + }, + }, + { + "email code resend, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email code resend, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify2"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email code resend, return, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify2"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + ReturnCode: true, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "emailverify2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ResendSchemaUserEmailCode(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.PlainCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.PlainCode) + } + } + }) + } +} diff --git a/internal/command/user_v3_model.go b/internal/command/user_v3_model.go index 51f783aaed..46568df87f 100644 --- a/internal/command/user_v3_model.go +++ b/internal/command/user_v3_model.go @@ -4,10 +4,15 @@ import ( "bytes" "context" "encoding/json" + "time" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" + domain_schema "github.com/zitadel/zitadel/internal/domain/schema" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" ) type UserV3WriteModel struct { @@ -23,36 +28,77 @@ type UserV3WriteModel struct { Email string IsEmailVerified bool EmailVerifiedFailedCount int + EmailCode *VerifyCode + Phone string IsPhoneVerified bool PhoneVerifiedFailedCount int + PhoneCode *VerifyCode Data json.RawMessage - State domain.UserState + Locked bool + State domain.UserState + + checkPermission domain.PermissionCheck + writePermissionCheck bool } -func NewExistsUserV3WriteModel(resourceOwner, userID string) *UserV3WriteModel { +func (wm *UserV3WriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + +type VerifyCode struct { + Code *crypto.CryptoValue + CreationDate time.Time + Expiry time.Duration +} + +func NewExistsUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { return &UserV3WriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, }, - PhoneWM: false, - EmailWM: false, - DataWM: false, + PhoneWM: false, + EmailWM: false, + DataWM: false, + checkPermission: checkPermission, } } -func NewUserV3WriteModel(resourceOwner, userID string) *UserV3WriteModel { +func NewUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { return &UserV3WriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, }, - PhoneWM: true, - EmailWM: true, - DataWM: true, + PhoneWM: true, + EmailWM: true, + DataWM: true, + checkPermission: checkPermission, + } +} + +func NewUserV3EmailWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { + return &UserV3WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + EmailWM: true, + checkPermission: checkPermission, + } +} + +func NewUserV3PhoneWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { + return &UserV3WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + PhoneWM: true, + checkPermission: checkPermission, } } @@ -61,8 +107,9 @@ func (wm *UserV3WriteModel) Reduce() error { switch e := event.(type) { case *schemauser.CreatedEvent: wm.SchemaID = e.SchemaID - wm.SchemaRevision = 1 + wm.SchemaRevision = e.SchemaRevision wm.Data = e.Data + wm.Locked = false wm.State = domain.UserStateActive case *schemauser.UpdatedEvent: @@ -79,46 +126,75 @@ func (wm *UserV3WriteModel) Reduce() error { wm.State = domain.UserStateDeleted case *schemauser.EmailUpdatedEvent: wm.Email = string(e.EmailAddress) + wm.IsEmailVerified = false + wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.EmailCodeAddedEvent: wm.IsEmailVerified = false wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = &VerifyCode{ + Code: e.Code, + CreationDate: e.CreationDate(), + Expiry: e.Expiry, + } case *schemauser.EmailVerifiedEvent: wm.IsEmailVerified = true wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.EmailVerificationFailedEvent: wm.EmailVerifiedFailedCount += 1 - case *schemauser.PhoneChangedEvent: + case *schemauser.PhoneUpdatedEvent: wm.Phone = string(e.PhoneNumber) + wm.IsPhoneVerified = false + wm.PhoneVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.PhoneCodeAddedEvent: wm.IsPhoneVerified = false wm.PhoneVerifiedFailedCount = 0 + wm.PhoneCode = &VerifyCode{ + Code: e.Code, + CreationDate: e.CreationDate(), + Expiry: e.Expiry, + } case *schemauser.PhoneVerifiedEvent: wm.PhoneVerifiedFailedCount = 0 wm.IsPhoneVerified = true + wm.PhoneCode = nil case *schemauser.PhoneVerificationFailedEvent: wm.PhoneVerifiedFailedCount += 1 + case *schemauser.LockedEvent: + wm.Locked = true + case *schemauser.UnlockedEvent: + wm.Locked = false + case *schemauser.DeactivatedEvent: + wm.State = domain.UserStateInactive + case *schemauser.ActivatedEvent: + wm.State = domain.UserStateActive } } return wm.WriteModel.Reduce() } func (wm *UserV3WriteModel) Query() *eventstore.SearchQueryBuilder { - query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - ResourceOwner(wm.ResourceOwner). - AddQuery(). - AggregateTypes(schemauser.AggregateType). - AggregateIDs(wm.AggregateID). - EventTypes( - schemauser.CreatedType, - schemauser.DeletedType, - ) + builder := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent) + if wm.ResourceOwner != "" { + builder = builder.ResourceOwner(wm.ResourceOwner) + } + eventtypes := []eventstore.EventType{ + schemauser.CreatedType, + schemauser.DeletedType, + schemauser.ActivatedType, + schemauser.DeactivatedType, + schemauser.LockedType, + schemauser.UnlockedType, + } if wm.DataWM { - query = query.EventTypes( + eventtypes = append(eventtypes, schemauser.UpdatedType, ) } if wm.EmailWM { - query = query.EventTypes( + eventtypes = append(eventtypes, schemauser.EmailUpdatedType, schemauser.EmailVerifiedType, schemauser.EmailCodeAddedType, @@ -126,37 +202,201 @@ func (wm *UserV3WriteModel) Query() *eventstore.SearchQueryBuilder { ) } if wm.PhoneWM { - query = query.EventTypes( + eventtypes = append(eventtypes, schemauser.PhoneUpdatedType, schemauser.PhoneVerifiedType, schemauser.PhoneCodeAddedType, schemauser.PhoneVerificationFailedType, ) } - return query.Builder() + return builder.AddQuery(). + AggregateTypes(schemauser.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(eventtypes...).Builder() } -func (wm *UserV3WriteModel) NewUpdatedEvent( +func (wm *UserV3WriteModel) NewCreated( ctx context.Context, - agg *eventstore.Aggregate, - schemaID *string, - schemaRevision *uint64, + schemaID string, + schemaRevision uint64, data json.RawMessage, -) *schemauser.UpdatedEvent { + email *Email, + phone *Phone, + emailCode func(context.Context) (*EncryptedCode, error), + phoneCode func(context.Context) (*EncryptedCode, string, error), +) (_ []eventstore.Command, codeEmail string, codePhone string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", "", err + } + if wm.Exists() { + return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.AlreadyExists") + } + events := []eventstore.Command{ + schemauser.NewCreatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + schemaID, schemaRevision, data, + ), + } + if email != nil { + emailEvents, plainCodeEmail, err := wm.NewEmailCreate(ctx, + email, + emailCode, + ) + if err != nil { + return nil, "", "", err + } + if plainCodeEmail != "" { + codeEmail = plainCodeEmail + } + events = append(events, emailEvents...) + } + + if phone != nil { + phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx, + phone, + phoneCode, + ) + if err != nil { + return nil, "", "", err + } + if plainCodePhone != "" { + codePhone = plainCodePhone + } + events = append(events, phoneEvents...) + } + + return events, codeEmail, codePhone, nil +} + +func (wm *UserV3WriteModel) getSchemaRoleForWrite(ctx context.Context, resourceOwner, userID string) (domain_schema.Role, error) { + if userID == authz.GetCtxData(ctx).UserID { + return domain_schema.RoleSelf, nil + } + if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return domain_schema.RoleUnspecified, err + } + return domain_schema.RoleOwner, nil +} + +func (wm *UserV3WriteModel) validateData(ctx context.Context, data []byte, schemaWM *UserSchemaWriteModel) (string, uint64, error) { + // get role for permission check in schema through extension + role, err := wm.getSchemaRoleForWrite(ctx, wm.ResourceOwner, wm.AggregateID) + if err != nil { + return "", 0, err + } + + schema, err := domain_schema.NewSchema(role, bytes.NewReader(schemaWM.Schema)) + if err != nil { + return "", 0, err + } + + // if data not changed but a new schema or revision should be used + if data == nil { + data = wm.Data + } + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return "", 0, zerrors.ThrowInvalidArgument(nil, "COMMAND-7o3ZGxtXUz", "Errors.User.Invalid") + } + + if err := schema.Validate(v); err != nil { + return "", 0, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid") + } + return schemaWM.AggregateID, schemaWM.SchemaRevision, nil +} + +func (wm *UserV3WriteModel) NewUpdate( + ctx context.Context, + schemaWM *UserSchemaWriteModel, + user *SchemaUser, + email *Email, + phone *Phone, + emailCode func(context.Context) (*EncryptedCode, error), + phoneCode func(context.Context) (*EncryptedCode, string, error), +) (_ []eventstore.Command, codeEmail string, codePhone string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", "", err + } + if !wm.Exists() { + return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") + } + events := make([]eventstore.Command, 0) + if user != nil { + schemaID, schemaRevision, err := wm.validateData(ctx, user.Data, schemaWM) + if err != nil { + return nil, "", "", err + } + userEvents := wm.newUpdatedEvents(ctx, + schemaID, + schemaRevision, + user.Data, + ) + events = append(events, userEvents...) + } + if email != nil { + emailEvents, plainCodeEmail, err := wm.NewEmailUpdate(ctx, + email, + emailCode, + ) + if err != nil { + return nil, "", "", err + } + if plainCodeEmail != "" { + codeEmail = plainCodeEmail + } + events = append(events, emailEvents...) + } + + if phone != nil { + phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx, + phone, + phoneCode, + ) + if err != nil { + return nil, "", "", err + } + if plainCodePhone != "" { + codePhone = plainCodePhone + } + events = append(events, phoneEvents...) + } + + return events, codeEmail, codePhone, nil +} + +func (wm *UserV3WriteModel) newUpdatedEvents( + ctx context.Context, + schemaID string, + schemaRevision uint64, + data json.RawMessage, +) []eventstore.Command { changes := make([]schemauser.Changes, 0) - if schemaID != nil && wm.SchemaID != *schemaID { - changes = append(changes, schemauser.ChangeSchemaID(wm.SchemaID, *schemaID)) + if wm.SchemaID != schemaID { + changes = append(changes, schemauser.ChangeSchemaID(schemaID)) } - if schemaRevision != nil && wm.SchemaRevision != *schemaRevision { - changes = append(changes, schemauser.ChangeSchemaRevision(wm.SchemaRevision, *schemaRevision)) + if wm.SchemaRevision != schemaRevision { + changes = append(changes, schemauser.ChangeSchemaRevision(schemaRevision)) } - if !bytes.Equal(wm.Data, data) { + if data != nil && !bytes.Equal(wm.Data, data) { changes = append(changes, schemauser.ChangeData(data)) } if len(changes) == 0 { return nil } - return schemauser.NewUpdatedEvent(ctx, agg, changes) + return []eventstore.Command{schemauser.NewUpdatedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel), changes)} +} + +func (wm *UserV3WriteModel) NewDelete( + ctx context.Context, +) (_ []eventstore.Command, err error) { + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound") + } + if err := wm.checkPermissionDelete(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + return []eventstore.Command{schemauser.NewDeletedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel))}, nil + } func UserV3AggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { @@ -172,3 +412,272 @@ func UserV3AggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggreg func (wm *UserV3WriteModel) Exists() bool { return wm.State != domain.UserStateDeleted && wm.State != domain.UserStateUnspecified } + +func (wm *UserV3WriteModel) checkPermissionWrite( + ctx context.Context, + resourceOwner string, + userID string, +) error { + if wm.writePermissionCheck { + return nil + } + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return err + } + wm.writePermissionCheck = true + return nil +} + +func (wm *UserV3WriteModel) checkPermissionDelete( + ctx context.Context, + resourceOwner string, + userID string, +) error { + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + return wm.checkPermission(ctx, domain.PermissionUserDelete, resourceOwner, userID) +} + +func (wm *UserV3WriteModel) NewEmailCreate( + ctx context.Context, + email *Email, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if email == nil || wm.Email == string(email.Address) { + return nil, "", nil + } + events := []eventstore.Command{ + schemauser.NewEmailUpdatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + email.Address, + ), + } + if email.Verified { + events = append(events, wm.newEmailVerifiedEvent(ctx)) + } else { + codeEvent, code, err := wm.newEmailCodeAddedEvent(ctx, code, email.URLTemplate, email.ReturnCode) + if err != nil { + return nil, "", err + } + events = append(events, codeEvent) + if code != "" { + plainCode = code + } + } + return events, plainCode, nil +} + +func (wm *UserV3WriteModel) NewEmailUpdate( + ctx context.Context, + email *Email, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.EmailWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-nJ0TQFuRmP", "Errors.User.NotFound") + } + return wm.NewEmailCreate(ctx, email, code) +} + +func (wm *UserV3WriteModel) NewEmailVerify( + ctx context.Context, + verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error, +) ([]eventstore.Command, error) { + if !wm.EmailWM { + return nil, nil + } + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-qbGyMPvjvj", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + if wm.EmailCode == nil { + return nil, nil + } + if err := verify(wm.EmailCode.CreationDate, wm.EmailCode.Expiry, wm.EmailCode.Code); err != nil { + return nil, err + } + return []eventstore.Command{wm.newEmailVerifiedEvent(ctx)}, nil +} + +func (wm *UserV3WriteModel) newEmailVerifiedEvent( + ctx context.Context, +) *schemauser.EmailVerifiedEvent { + return schemauser.NewEmailVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel)) +} + +func (wm *UserV3WriteModel) NewResendEmailCode( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + urlTemplate string, + isReturnCode bool, +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.EmailWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-EajeF6ypOV", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if wm.EmailCode == nil { + return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty") + } + event, plainCode, err := wm.newEmailCodeAddedEvent(ctx, code, urlTemplate, isReturnCode) + if err != nil { + return nil, "", err + } + return []eventstore.Command{event}, plainCode, nil +} + +func (wm *UserV3WriteModel) newEmailCodeAddedEvent( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + urlTemplate string, + isReturnCode bool, +) (_ *schemauser.EmailCodeAddedEvent, plainCode string, err error) { + cryptoCode, err := code(ctx) + if err != nil { + return nil, "", err + } + if isReturnCode { + plainCode = cryptoCode.Plain + } + return schemauser.NewEmailCodeAddedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + cryptoCode.Crypted, + cryptoCode.Expiry, + urlTemplate, + isReturnCode, + ), plainCode, nil +} + +func (wm *UserV3WriteModel) NewPhoneCreate( + ctx context.Context, + phone *Phone, + code func(context.Context) (*EncryptedCode, string, error), +) (_ []eventstore.Command, plainCode string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if phone == nil || wm.Phone == string(phone.Number) { + return nil, "", nil + } + events := []eventstore.Command{ + schemauser.NewPhoneUpdatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + phone.Number, + ), + } + if phone.Verified { + events = append(events, wm.newPhoneVerifiedEvent(ctx)) + } else { + codeEvent, code, err := wm.newPhoneCodeAddedEvent(ctx, code, phone.ReturnCode) + if err != nil { + return nil, "", err + } + events = append(events, codeEvent) + if code != "" { + plainCode = code + } + } + return events, plainCode, nil +} + +func (wm *UserV3WriteModel) NewPhoneUpdate( + ctx context.Context, + phone *Phone, + code func(context.Context) (*EncryptedCode, string, error), +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.PhoneWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-b33QAVgel6", "Errors.User.NotFound") + } + return wm.NewPhoneCreate(ctx, phone, code) +} + +func (wm *UserV3WriteModel) NewPhoneVerify( + ctx context.Context, + verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error, +) ([]eventstore.Command, error) { + if !wm.PhoneWM { + return nil, nil + } + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-bx2OLtgGNS", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + if wm.PhoneCode == nil { + return nil, nil + } + if err := verify(wm.PhoneCode.CreationDate, wm.PhoneCode.Expiry, wm.PhoneCode.Code); err != nil { + return nil, err + } + return []eventstore.Command{wm.newPhoneVerifiedEvent(ctx)}, nil +} + +func (wm *UserV3WriteModel) newPhoneVerifiedEvent( + ctx context.Context, +) *schemauser.PhoneVerifiedEvent { + return schemauser.NewPhoneVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel)) +} + +func (wm *UserV3WriteModel) NewResendPhoneCode( + ctx context.Context, + code func(context.Context) (*EncryptedCode, string, error), + isReturnCode bool, +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.PhoneWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-z8Bu9vuL9s", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if wm.PhoneCode == nil { + return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty") + } + event, plainCode, err := wm.newPhoneCodeAddedEvent(ctx, code, isReturnCode) + if err != nil { + return nil, "", err + } + return []eventstore.Command{event}, plainCode, nil +} + +func (wm *UserV3WriteModel) newPhoneCodeAddedEvent( + ctx context.Context, + code func(context.Context) (*EncryptedCode, string, error), + isReturnCode bool, +) (_ *schemauser.PhoneCodeAddedEvent, plainCode string, err error) { + cryptoCode, generatorID, err := code(ctx) + if err != nil { + return nil, "", err + } + if isReturnCode { + plainCode = cryptoCode.Plain + } + return schemauser.NewPhoneCodeAddedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + cryptoCode.CryptedCode(), + cryptoCode.CodeExpiry(), + isReturnCode, + generatorID, + ), plainCode, nil +} diff --git a/internal/command/user_v3_phone.go b/internal/command/user_v3_phone.go new file mode 100644 index 0000000000..fa6ed1baba --- /dev/null +++ b/internal/command/user_v3_phone.go @@ -0,0 +1,107 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeSchemaUserPhone struct { + ResourceOwner string + ID string + + Phone *Phone + ReturnCode *string +} + +func (s *ChangeSchemaUserPhone) Valid() (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-DkQ9aurv5u", "Errors.IDMissing") + } + if s.Phone != nil && s.Phone.Number != "" { + if s.Phone.Number, err = s.Phone.Number.Normalize(); err != nil { + return err + } + } + return nil +} + +func (c *Commands) ChangeSchemaUserPhone(ctx context.Context, user *ChangeSchemaUserPhone) (_ *domain.ObjectDetails, err error) { + if err := user.Valid(); err != nil { + return nil, err + } + + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewPhoneUpdate(ctx, + user.Phone, + func(ctx context.Context) (*EncryptedCode, string, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.ReturnCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) VerifySchemaUserPhone(ctx context.Context, resourceOwner, id, code string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-R4LKY44Ke3", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewPhoneVerify(ctx, + func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error { + return crypto.VerifyCode(creationDate, expiry, cryptoCode, code, c.userEncryption) + }, + ) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +type ResendSchemaUserPhoneCode struct { + ResourceOwner string + ID string + + ReturnCode bool + PlainCode *string +} + +func (c *Commands) ResendSchemaUserPhoneCode(ctx context.Context, user *ResendSchemaUserPhoneCode) (*domain.ObjectDetails, error) { + if user.ID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-zmxIFR2nMo", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewResendPhoneCode(ctx, + func(ctx context.Context) (*EncryptedCode, string, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck + }, + user.ReturnCode, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.PlainCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} diff --git a/internal/command/user_v3_phone_test.go b/internal/command/user_v3_phone_test.go new file mode 100644 index 0000000000..1d45bade49 --- /dev/null +++ b/internal/command/user_v3_phone_test.go @@ -0,0 +1,1359 @@ +package command + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_ChangeSchemaUserPhone(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + } + type args struct { + ctx context.Context + user *ChangeSchemaUserPhone + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-DkQ9aurv5u", "Errors.IDMissing")) + }, + }, + }, + { + "no valid phone, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{Number: "nonumber"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "PHONE-so0wa", "Errors.User.Phone.Invalid")) + }, + }, + }, { + "phone update, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-b33QAVgel6", "Errors.User.NotFound")) + }, + }, + }, + { + "phone update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{Number: "+41791234567"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone update, phone not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "+41791234567", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone update, phone return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + true, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "phoneverify", + }, + }, + { + "user updated, phone to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, time.Hour*1, + false, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, phone to verify (external)", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifiyServiceSid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + Verified: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + defaultSecretGenerators: &SecretGenerators{ + PhoneVerificationCode: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + }, + } + details, err := c.ChangeSchemaUserPhone(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.ReturnCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.ReturnCode) + } + }) + } +} + +func TestCommands_VerifySchemaUserPhone(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner string + id string + code string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-R4LKY44Ke3", "Errors.IDMissing")) + }, + }, + }, + { + "phone verify, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-bx2OLtgGNS", "Errors.User.NotFound")) + }, + }, + }, + { + "phone verify, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone verify, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone verify, wrong code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid")) + }, + }, + }, + { + "phone verify, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + code: "phoneverify", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.VerifySchemaUserPhone(tt.args.ctx, tt.args.resourceOwner, tt.args.id, tt.args.code) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_ResendSchemaUserPhoneCode(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + } + type args struct { + ctx context.Context + user *ResendSchemaUserPhoneCode + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-zmxIFR2nMo", "Errors.IDMissing")) + }, + }, + }, + { + "phone code resend, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-z8Bu9vuL9s", "Errors.User.NotFound")) + }, + }, + }, + { + "phone code resend, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty")) + }, + }, + }, + { + "phone code resend, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty")) + }, + }, + }, + { + "phone code resend, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone code resend, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify2"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone code resend, ok (external)", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone code resend, return, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify2"), + }, + time.Hour*1, + true, + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + ReturnCode: true, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "phoneverify2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + defaultSecretGenerators: &SecretGenerators{ + PhoneVerificationCode: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + }, + } + details, err := c.ResendSchemaUserPhoneCode(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.PlainCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.PlainCode) + } + } + }) + } +} diff --git a/internal/command/user_v3_test.go b/internal/command/user_v3_test.go index 5bbb9e0c55..34ac7bc1e0 100644 --- a/internal/command/user_v3_test.go +++ b/internal/command/user_v3_test.go @@ -16,17 +16,30 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/user/schema" "github.com/zitadel/zitadel/internal/repository/user/schemauser" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommands_CreateSchemaUser(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - idGenerator id.Generator - checkPermission domain.PermissionCheck - newCode encrypedCodeFunc + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -663,6 +676,36 @@ func TestCommands_CreateSchemaUser(t *testing.T) { ), ), expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( schemauser.NewCreatedEvent( context.Background(), @@ -673,7 +716,7 @@ func TestCommands_CreateSchemaUser(t *testing.T) { "name": "user" }`), ), - schemauser.NewPhoneChangedEvent(context.Background(), + schemauser.NewPhoneUpdatedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, "+41791234567", ), @@ -687,12 +730,14 @@ func TestCommands_CreateSchemaUser(t *testing.T) { }, time.Hour*1, true, + "", ), ), ), - idGenerator: mock.ExpectID(t, "id1"), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("phoneverify", time.Hour), + idGenerator: mock.ExpectID(t, "id1"), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args{ ctx: authz.NewMockContext("instanceID", "", ""), @@ -741,6 +786,36 @@ func TestCommands_CreateSchemaUser(t *testing.T) { ), ), expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), expectPush( schemauser.NewCreatedEvent( context.Background(), @@ -751,7 +826,7 @@ func TestCommands_CreateSchemaUser(t *testing.T) { "name": "user" }`), ), - schemauser.NewPhoneChangedEvent(context.Background(), + schemauser.NewPhoneUpdatedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, "+41791234567", ), @@ -765,12 +840,117 @@ func TestCommands_CreateSchemaUser(t *testing.T) { }, time.Hour*1, false, + "", ), ), ), - idGenerator: mock.ExpectID(t, "id1"), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("phoneverify", time.Hour), + idGenerator: mock.ExpectID(t, "id1"), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + defaultSecretGenerators: defaultGenerators, + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &CreateSchemaUser{ + ResourceOwner: "org1", + SchemaID: "type", + schemaRevision: 1, + Data: json.RawMessage(`{ + "name": "user" + }`), + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "id1", + }, + }, + }, + { + "user created, phone to verify (external)", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("id1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("id1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("id1", "org1").Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + idGenerator: mock.ExpectID(t, "id1"), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + defaultSecretGenerators: defaultGenerators, }, args{ ctx: authz.NewMockContext("instanceID", "", ""), @@ -834,7 +1014,7 @@ func TestCommands_CreateSchemaUser(t *testing.T) { schemauser.NewEmailVerifiedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, ), - schemauser.NewPhoneChangedEvent(context.Background(), + schemauser.NewPhoneUpdatedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, "+41791234567", ), @@ -870,12 +1050,15 @@ func TestCommands_CreateSchemaUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, - checkPermission: tt.fields.checkPermission, - newEncryptedCode: tt.fields.newCode, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), } - err := c.CreateSchemaUser(tt.args.ctx, tt.args.user, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + details, err := c.CreateSchemaUser(tt.args.ctx, tt.args.user) if tt.res.err == nil { assert.NoError(t, err) } @@ -883,14 +1066,16 @@ func TestCommands_CreateSchemaUser(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.details, tt.args.user.Details) + assertObjectDetails(t, tt.res.details, details) } if tt.res.returnCodePhone != "" { - assert.Equal(t, tt.res.returnCodePhone, tt.args.user.ReturnCodePhone) + assert.NotNil(t, tt.args.user.ReturnCodePhone) + assert.Equal(t, tt.res.returnCodePhone, *tt.args.user.ReturnCodePhone) } if tt.res.returnCodeEmail != "" { - assert.Equal(t, tt.res.returnCodeEmail, tt.args.user.ReturnCodeEmail) + assert.NotNil(t, tt.args.user.ReturnCodeEmail) + assert.Equal(t, tt.res.returnCodeEmail, *tt.args.user.ReturnCodeEmail) } }) } @@ -904,6 +1089,7 @@ func TestCommandSide_DeleteSchemaUser(t *testing.T) { type ( args struct { ctx context.Context + orgID string userID string } ) @@ -1088,7 +1274,7 @@ func TestCommandSide_DeleteSchemaUser(t *testing.T) { eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } - got, err := r.DeleteSchemaUser(tt.args.ctx, tt.args.userID) + got, err := r.DeleteSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) if tt.res.err == nil { assert.NoError(t, err) } @@ -1101,3 +1287,2238 @@ func TestCommandSide_DeleteSchemaUser(t *testing.T) { }) } } + +func TestCommandSide_LockSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-Eu8I2VAfjF", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user locked, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound")) + }, + }, + }, + { + name: "lock user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + expectPush( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "lock user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.LockSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_UnlockSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-krXtYscQZh", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user not locked, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound")) + }, + }, + }, + { + name: "unlock user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + schemauser.NewUnlockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "unlock user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.UnlockSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_DeactivateSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-pjJhge86ZV", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user not active, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound")) + }, + }, + }, + { + name: "deactivate user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + expectPush( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "deactivate user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.DeactivateSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_ReactivateSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-17XupGvxBJ", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user not inactive, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound")) + }, + }, + }, + { + name: "activate user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + schemauser.NewActivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "activate user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.ActivateSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommands_ChangeSchemaUser(t *testing.T) { + defaultGenerators := &SecretGenerators{ + OTPSMS: &crypto.GeneratorConfig{ + Length: 8, + Expiry: time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, + } + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators + } + type args struct { + ctx context.Context + user *ChangeSchemaUser + } + type res struct { + returnCodeEmail string + returnCodePhone string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-gEJR1QOGHb", "Errors.IDMissing")) + }, + }, + }, + { + "schema not existing, error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + }, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists")) + }, + }, + }, + { + "no valid email, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{Address: "noemail"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "no valid phone, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{Number: "invalid"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "PHONE-so0wa", "Errors.User.Phone.Invalid")) + }, + }, + }, + { + "user update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ + "name": "user" + }`), + }, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "user updated, same schema", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeData( + json.RawMessage(`{ + "name": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ + "name": "user2" + }`), + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, changed schema", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id2", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaID("id2"), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, new schema", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id2", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaID("id2"), + schemauser.ChangeData( + json.RawMessage(`{ + "name": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + Data: json.RawMessage(`{ + "name": "user2" + }`), + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, same schema revision", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name1": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name1": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + eventFromEventPusher( + schema.NewUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + []schema.Changes{ + schema.IncreaseRevision(1), + schema.ChangeSchema(json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name2": { + "type": "string" + } + } + }`)), + }, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaRevision(2), + schemauser.ChangeData( + json.RawMessage(`{ + "name2": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ + "name2": "user2" + }`), + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, new schema and revision", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 2, + json.RawMessage(`{ + "name1": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id2", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name2": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaID("id2"), + schemauser.ChangeSchemaRevision(1), + schemauser.ChangeData( + json.RawMessage(`{ + "name2": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + Data: json.RawMessage(`{ + "name2": "user2" + }`), + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user update, no field permission as admin", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "urn:zitadel:schema:permission": { + "owner": "r" + }, + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + Data: json.RawMessage(`{ + "name": "user" + }`), + }, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, no field permission as user", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "urn:zitadel:schema:permission": { + "self": "r" + }, + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "user1"), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ + "name": "user" + }`), + }, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, invalid data type", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "user1"), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ + "name": 1 + }`), + }, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, additional property", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeData( + json.RawMessage(`{ + "name": "user1", + "additional": "property" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + Data: json.RawMessage(`{ + "name": "user1", + "additional": "property" + }`), + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user update, invalid data attribute name", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "user1"), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ + "invalid": "user" + }`), + }, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, email not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "test@example.com", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user update, email return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + }, + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCodeEmail: "emailverify", + }, + }, + { + "user updated, email to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, phone no change", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, phone return", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + true, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + defaultSecretGenerators: defaultGenerators, + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCodePhone: "phoneverify", + }, + }, + { + "user updated, phone to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("id1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + defaultSecretGenerators: defaultGenerators, + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, phone to verify (external)", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("id1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSMSConfigTwilioAddedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + "", + "sid", + "senderNumber", + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", Crypted: []byte("crypted")}, + "verifyServiceSid", + ), + ), + eventFromEventPusher( + instance.NewSMSConfigActivatedEvent( + context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + "id", + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + nil, + 0, + false, + "id", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneverify", time.Hour), + defaultSecretGenerators: defaultGenerators, + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, full verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{Address: "test@example.com", Verified: true}, + Phone: &Phone{Number: "+41791234567", Verified: true}, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ChangeSchemaUser(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + + if tt.res.returnCodePhone != "" { + assert.NotNil(t, tt.args.user.ReturnCodePhone) + assert.Equal(t, tt.res.returnCodePhone, *tt.args.user.ReturnCodePhone) + } + if tt.res.returnCodeEmail != "" { + assert.NotNil(t, tt.args.user.ReturnCodeEmail) + assert.Equal(t, tt.res.returnCodeEmail, *tt.args.user.ReturnCodeEmail) + } + }) + } +} diff --git a/internal/domain/human_phone.go b/internal/domain/human_phone.go index f350ea72a0..2bf60bca89 100644 --- a/internal/domain/human_phone.go +++ b/internal/domain/human_phone.go @@ -54,17 +54,6 @@ func (p *Phone) Normalize() error { return nil } -func NewPhoneCode(phoneGenerator crypto.Generator) (*PhoneCode, error) { - phoneCodeCrypto, _, err := crypto.NewCode(phoneGenerator) - if err != nil { - return nil, err - } - return &PhoneCode{ - Code: phoneCodeCrypto, - Expiry: phoneGenerator.Expiry(), - }, nil -} - type PhoneState int32 const ( diff --git a/internal/eventstore/handler/v2/failed_event.go b/internal/eventstore/handler/v2/failed_event.go index 53457883ca..cee1bbc774 100644 --- a/internal/eventstore/handler/v2/failed_event.go +++ b/internal/eventstore/handler/v2/failed_event.go @@ -39,9 +39,9 @@ func failureFromEvent(event eventstore.Event, err error) *failure { func failureFromStatement(statement *Statement, err error) *failure { return &failure{ sequence: statement.Sequence, - instance: statement.InstanceID, - aggregateID: statement.AggregateID, - aggregateType: statement.AggregateType, + instance: statement.Aggregate.InstanceID, + aggregateID: statement.Aggregate.ID, + aggregateType: statement.Aggregate.Type, eventDate: statement.CreationDate, err: err, } diff --git a/internal/eventstore/handler/v2/handler.go b/internal/eventstore/handler/v2/handler.go index b395035b8f..615a9a6fcc 100644 --- a/internal/eventstore/handler/v2/handler.go +++ b/internal/eventstore/handler/v2/handler.go @@ -62,6 +62,7 @@ type Handler struct { triggeredInstancesSync sync.Map triggerWithoutEvents Reduce + cacheInvalidations []func(ctx context.Context, aggregates []*eventstore.Aggregate) } var _ migration.Migration = (*Handler)(nil) @@ -418,6 +419,12 @@ func (h *Handler) Trigger(ctx context.Context, opts ...TriggerOpt) (_ context.Co } } +// RegisterCacheInvalidation registers a function to be called when a cache needs to be invalidated. +// In order to avoid race conditions, this method must be called before [Handler.Start] is called. +func (h *Handler) RegisterCacheInvalidation(invalidate func(ctx context.Context, aggregates []*eventstore.Aggregate)) { + h.cacheInvalidations = append(h.cacheInvalidations, invalidate) +} + // lockInstance tries to lock the instance. // If the instance is already locked from another process no cancel function is returned // the instance can be skipped then @@ -486,10 +493,6 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add h.log().OnError(rollbackErr).Debug("unable to rollback tx") return } - commitErr := tx.Commit() - if err == nil { - err = commitErr - } }() currentState, err := h.currentState(ctx, tx, config) @@ -509,6 +512,17 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add if err != nil { return additionalIteration, err } + + defer func() { + commitErr := tx.Commit() + if err == nil { + err = commitErr + } + if err == nil && currentState.aggregateID != "" && len(statements) > 0 { + h.invalidateCaches(ctx, aggregatesFromStatements(statements)) + } + }() + if len(statements) == 0 { err = h.setState(tx, currentState) return additionalIteration, err @@ -522,8 +536,8 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add currentState.position = statements[lastProcessedIndex].Position currentState.offset = statements[lastProcessedIndex].offset - currentState.aggregateID = statements[lastProcessedIndex].AggregateID - currentState.aggregateType = statements[lastProcessedIndex].AggregateType + currentState.aggregateID = statements[lastProcessedIndex].Aggregate.ID + currentState.aggregateType = statements[lastProcessedIndex].Aggregate.Type currentState.sequence = statements[lastProcessedIndex].Sequence currentState.eventTimestamp = statements[lastProcessedIndex].CreationDate err = h.setState(tx, currentState) @@ -556,8 +570,8 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta if idx+1 == len(statements) { currentState.position = statements[len(statements)-1].Position currentState.offset = statements[len(statements)-1].offset - currentState.aggregateID = statements[len(statements)-1].AggregateID - currentState.aggregateType = statements[len(statements)-1].AggregateType + currentState.aggregateID = statements[len(statements)-1].Aggregate.ID + currentState.aggregateType = statements[len(statements)-1].Aggregate.Type currentState.sequence = statements[len(statements)-1].Sequence currentState.eventTimestamp = statements[len(statements)-1].CreationDate @@ -577,8 +591,8 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta func skipPreviouslyReducedStatements(statements []*Statement, currentState *state) int { for i, statement := range statements { if statement.Position == currentState.position && - statement.AggregateID == currentState.aggregateID && - statement.AggregateType == currentState.aggregateType && + statement.Aggregate.ID == currentState.aggregateID && + statement.Aggregate.Type == currentState.aggregateType && statement.Sequence == currentState.sequence { return i } @@ -667,3 +681,34 @@ func (h *Handler) eventQuery(currentState *state) *eventstore.SearchQueryBuilder func (h *Handler) ProjectionName() string { return h.projection.Name() } + +func (h *Handler) invalidateCaches(ctx context.Context, aggregates []*eventstore.Aggregate) { + if len(h.cacheInvalidations) == 0 { + return + } + + var wg sync.WaitGroup + wg.Add(len(h.cacheInvalidations)) + + for _, invalidate := range h.cacheInvalidations { + go func(invalidate func(context.Context, []*eventstore.Aggregate)) { + defer wg.Done() + invalidate(ctx, aggregates) + }(invalidate) + } + wg.Wait() +} + +// aggregatesFromStatements returns the unique aggregates from statements. +// Duplicate aggregates are omitted. +func aggregatesFromStatements(statements []*Statement) []*eventstore.Aggregate { + aggregates := make([]*eventstore.Aggregate, 0, len(statements)) + for _, statement := range statements { + if !slices.ContainsFunc(aggregates, func(aggregate *eventstore.Aggregate) bool { + return *statement.Aggregate == *aggregate + }) { + aggregates = append(aggregates, statement.Aggregate) + } + } + return aggregates +} diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 207f3d0f58..6ff10fb2e1 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -80,12 +80,10 @@ func (h *Handler) reduce(event eventstore.Event) (*Statement, error) { } type Statement struct { - AggregateType eventstore.AggregateType - AggregateID string - Sequence uint64 - Position float64 - CreationDate time.Time - InstanceID string + Aggregate *eventstore.Aggregate + Sequence uint64 + Position float64 + CreationDate time.Time offset uint32 @@ -108,13 +106,11 @@ var ( func NewStatement(event eventstore.Event, e Exec) *Statement { return &Statement{ - AggregateType: event.Aggregate().Type, - Sequence: event.Sequence(), - Position: event.Position(), - AggregateID: event.Aggregate().ID, - CreationDate: event.CreatedAt(), - InstanceID: event.Aggregate().InstanceID, - Execute: e, + Aggregate: event.Aggregate(), + Sequence: event.Sequence(), + Position: event.Position(), + CreationDate: event.CreatedAt(), + Execute: e, } } diff --git a/internal/integration/client.go b/internal/integration/client.go index 82b8ab3b6e..dde8822acd 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -28,7 +28,7 @@ import ( object_v3alpha "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" oidc_pb_v2beta "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" org_v2beta "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" action "github.com/zitadel/zitadel/pkg/grpc/resources/action/v3alpha" user_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" @@ -776,6 +776,32 @@ func (i *Instance) CreateSchemaUser(ctx context.Context, orgID string, schemaID return user } +func (i *Instance) UpdateSchemaUserEmail(ctx context.Context, orgID string, userID string, email string) *user_v3alpha.SetContactEmailResponse { + user, err := i.Client.UserV3Alpha.SetContactEmail(ctx, &user_v3alpha.SetContactEmailRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userID, + Email: &user_v3alpha.SetEmail{ + Address: email, + Verification: &user_v3alpha.SetEmail_ReturnCode{}, + }, + }) + logging.OnError(err).Fatal("create user") + return user +} + +func (i *Instance) UpdateSchemaUserPhone(ctx context.Context, orgID string, userID string, phone string) *user_v3alpha.SetContactPhoneResponse { + user, err := i.Client.UserV3Alpha.SetContactPhone(ctx, &user_v3alpha.SetContactPhoneRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userID, + Phone: &user_v3alpha.SetPhone{ + Number: phone, + Verification: &user_v3alpha.SetPhone_ReturnCode{}, + }, + }) + logging.OnError(err).Fatal("create user") + return user +} + func (i *Instance) CreateInviteCode(ctx context.Context, userID string) *user_v2.CreateInviteCodeResponse { user, err := i.Client.UserV2.CreateInviteCode(ctx, &user_v2.CreateInviteCodeRequest{ UserId: userID, @@ -784,3 +810,55 @@ func (i *Instance) CreateInviteCode(ctx context.Context, userID string) *user_v2 logging.OnError(err).Fatal("create invite code") return user } + +func (i *Instance) LockSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.LockUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.LockUser(ctx, &user_v3alpha.LockUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("lock user") + return user +} + +func (i *Instance) UnlockSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.UnlockUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.UnlockUser(ctx, &user_v3alpha.UnlockUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("unlock user") + return user +} + +func (i *Instance) DeactivateSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.DeactivateUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.DeactivateUser(ctx, &user_v3alpha.DeactivateUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("deactivate user") + return user +} + +func (i *Instance) ActivateSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.ActivateUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.ActivateUser(ctx, &user_v3alpha.ActivateUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("reactivate user") + return user +} diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index 68e0b43f9c..8ff36b2175 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -6,6 +6,23 @@ ExternalSecure: false TLS: Enabled: false +Caches: + Connectors: + Memory: + Enabled: true + AutoPrune: + Interval: 30s + TimeOut: 1s + Instance: + Connector: "memory" + MaxAge: 1m + LastUsage: 30s + Log: + Level: info + AddSource: true + Formatter: + Format: text + Quotas: Access: Enabled: true @@ -33,7 +50,6 @@ LogStore: Projections: HandleActiveInstances: 30m RequeueEvery: 5s - TransactionDuration: 1m Customizations: NotificationsQuotas: RequeueEvery: 1s diff --git a/internal/notification/channels/twilio/channel.go b/internal/notification/channels/twilio/channel.go index da04b20f5e..e3e2767a0e 100644 --- a/internal/notification/channels/twilio/channel.go +++ b/internal/notification/channels/twilio/channel.go @@ -1,7 +1,9 @@ package twilio import ( - "github.com/kevinburke/twilio-go" + newTwilio "github.com/twilio/twilio-go" + openapi "github.com/twilio/twilio-go/rest/api/v2010" + verify "github.com/twilio/twilio-go/rest/verify/v2" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/notification/channels" @@ -10,8 +12,7 @@ import ( ) func InitChannel(config Config) channels.NotificationChannel { - client := twilio.NewClient(config.SID, config.Token, nil) - + client := newTwilio.NewRestClientWithParams(newTwilio.ClientParams{Username: config.SID, Password: config.Token}) logging.Debug("successfully initialized twilio sms channel") return channels.HandleMessageFunc(func(message channels.Message) error { @@ -19,11 +20,30 @@ func InitChannel(config Config) channels.NotificationChannel { if !ok { return zerrors.ThrowInternal(nil, "TWILI-s0pLc", "message is not SMS") } + if config.VerifyServiceSID != "" { + params := &verify.CreateVerificationParams{} + params.SetTo(twilioMsg.RecipientPhoneNumber) + params.SetChannel("sms") + + resp, err := client.VerifyV2.CreateVerification(config.VerifyServiceSID, params) + if err != nil { + return zerrors.ThrowInternal(err, "TWILI-0s9f2", "could not send verification") + } + logging.WithFields("sid", resp.Sid, "status", resp.Status).Debug("verification sent") + + twilioMsg.VerificationID = resp.Sid + return nil + } + content, err := twilioMsg.GetContent() if err != nil { return err } - m, err := client.Messages.SendMessage(twilioMsg.SenderPhoneNumber, twilioMsg.RecipientPhoneNumber, content, nil) + params := &openapi.CreateMessageParams{} + params.SetTo(twilioMsg.RecipientPhoneNumber) + params.SetFrom(twilioMsg.SenderPhoneNumber) + params.SetBody(content) + m, err := client.Api.CreateMessage(params) if err != nil { return zerrors.ThrowInternal(err, "TWILI-osk3S", "could not send message") } diff --git a/internal/notification/channels/twilio/config.go b/internal/notification/channels/twilio/config.go index dc9550d766..02da0ad00c 100644 --- a/internal/notification/channels/twilio/config.go +++ b/internal/notification/channels/twilio/config.go @@ -1,11 +1,40 @@ package twilio +import ( + newTwilio "github.com/twilio/twilio-go" + verify "github.com/twilio/twilio-go/rest/verify/v2" + + "github.com/zitadel/zitadel/internal/zerrors" +) + type Config struct { - SID string - Token string - SenderNumber string + SID string + Token string + SenderNumber string + VerifyServiceSID string } func (t *Config) IsValid() bool { return t.SID != "" && t.Token != "" && t.SenderNumber != "" } + +func (t *Config) VerifyCode(verificationID, code string) error { + client := newTwilio.NewRestClientWithParams(newTwilio.ClientParams{Username: t.SID, Password: t.Token}) + checkParams := &verify.CreateVerificationCheckParams{} + checkParams.SetVerificationSid(verificationID) + checkParams.SetCode(code) + resp, err := client.VerifyV2.CreateVerificationCheck(t.VerifyServiceSID, checkParams) + if err != nil || resp.Status == nil { + return zerrors.ThrowInvalidArgument(err, "TWILI-JK3ta", "Errors.User.Code.NotFound") + } + switch *resp.Status { + case "approved": + return nil + case "expired": + return zerrors.ThrowInvalidArgument(nil, "TWILI-SF3ba", "Errors.User.Code.Expired") + case "max_attempts_reached": + return zerrors.ThrowInvalidArgument(nil, "TWILI-Ok39a", "Errors.User.Code.NotFound") + default: + return zerrors.ThrowInvalidArgument(nil, "TWILI-Skwe4", "Errors.User.Code.Invalid") + } +} diff --git a/internal/notification/handlers/commands.go b/internal/notification/handlers/commands.go index 3d8546f800..a50abe18a7 100644 --- a/internal/notification/handlers/commands.go +++ b/internal/notification/handlers/commands.go @@ -3,6 +3,7 @@ package handlers import ( "context" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/repository/milestone" "github.com/zitadel/zitadel/internal/repository/quota" ) @@ -10,15 +11,15 @@ import ( type Commands interface { HumanInitCodeSent(ctx context.Context, orgID, userID string) error HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error - PasswordCodeSent(ctx context.Context, orgID, userID string) error - HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string) error + PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error + HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error - OTPSMSSent(ctx context.Context, sessionID, resourceOwner string) error + OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error UserDomainClaimedSent(ctx context.Context, orgID, userID string) error HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error PasswordChangeSent(ctx context.Context, orgID, userID string) error - HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string) error + HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error InviteCodeSent(ctx context.Context, orgID, userID string) error UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error MilestonePushed(ctx context.Context, msType milestone.Type, endpoints []string, primaryDomain string) error diff --git a/internal/notification/handlers/config_sms.go b/internal/notification/handlers/config_sms.go index 4698772eae..1962824c9a 100644 --- a/internal/notification/handlers/config_sms.go +++ b/internal/notification/handlers/config_sms.go @@ -31,9 +31,10 @@ func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Conf return &sms.Config{ ProviderConfig: provider, TwilioConfig: &twilio.Config{ - SID: config.TwilioConfig.SID, - Token: token, - SenderNumber: config.TwilioConfig.SenderNumber, + SID: config.TwilioConfig.SID, + Token: token, + SenderNumber: config.TwilioConfig.SenderNumber, + VerifyServiceSID: config.TwilioConfig.VerifyServiceSID, }, }, nil } diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index ab94eda2cc..51942be42a 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -13,35 +13,36 @@ import ( context "context" reflect "reflect" + senders "github.com/zitadel/zitadel/internal/notification/senders" milestone "github.com/zitadel/zitadel/internal/repository/milestone" quota "github.com/zitadel/zitadel/internal/repository/quota" gomock "go.uber.org/mock/gomock" ) -// MockCommands is a mock of Commands interface +// MockCommands is a mock of Commands interface. type MockCommands struct { ctrl *gomock.Controller recorder *MockCommandsMockRecorder } -// MockCommandsMockRecorder is the mock recorder for MockCommands +// MockCommandsMockRecorder is the mock recorder for MockCommands. type MockCommandsMockRecorder struct { mock *MockCommands } -// NewMockCommands creates a new mock instance +// NewMockCommands creates a new mock instance. func NewMockCommands(ctrl *gomock.Controller) *MockCommands { mock := &MockCommands{ctrl: ctrl} mock.recorder = &MockCommandsMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { return m.recorder } -// HumanEmailVerificationCodeSent mocks base method +// HumanEmailVerificationCodeSent mocks base method. func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) @@ -49,13 +50,13 @@ func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1 return ret0 } -// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent -func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent. +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) } -// HumanInitCodeSent mocks base method +// HumanInitCodeSent mocks base method. func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) @@ -63,13 +64,13 @@ func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string return ret0 } -// HumanInitCodeSent indicates an expected call of HumanInitCodeSent -func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// HumanInitCodeSent indicates an expected call of HumanInitCodeSent. +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) } -// HumanOTPEmailCodeSent mocks base method +// HumanOTPEmailCodeSent mocks base method. func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) @@ -77,27 +78,27 @@ func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 st return ret0 } -// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent -func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent. +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) } -// HumanOTPSMSCodeSent mocks base method -func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string) error { +// HumanOTPSMSCodeSent mocks base method. +func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } -// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent -func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent. +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2, arg3) } -// HumanPasswordlessInitCodeSent mocks base method +// HumanPasswordlessInitCodeSent mocks base method. func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) @@ -105,27 +106,27 @@ func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, return ret0 } -// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent -func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent. +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) } -// HumanPhoneVerificationCodeSent mocks base method -func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { +// HumanPhoneVerificationCodeSent mocks base method. +func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } -// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent -func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent. +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2, arg3) } -// InviteCodeSent mocks base method +// InviteCodeSent mocks base method. func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InviteCodeSent", arg0, arg1, arg2) @@ -133,13 +134,13 @@ func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) e return ret0 } -// InviteCodeSent indicates an expected call of InviteCodeSent -func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// InviteCodeSent indicates an expected call of InviteCodeSent. +func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), arg0, arg1, arg2) } -// MilestonePushed mocks base method +// MilestonePushed mocks base method. func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 milestone.Type, arg2 []string, arg3 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) @@ -147,13 +148,13 @@ func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 milestone.Type return ret0 } -// MilestonePushed indicates an expected call of MilestonePushed -func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +// MilestonePushed indicates an expected call of MilestonePushed. +func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) } -// OTPEmailSent mocks base method +// OTPEmailSent mocks base method. func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) @@ -161,27 +162,27 @@ func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) err return ret0 } -// OTPEmailSent indicates an expected call of OTPEmailSent -func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// OTPEmailSent indicates an expected call of OTPEmailSent. +func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) } -// OTPSMSSent mocks base method -func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string) error { +// OTPSMSSent mocks base method. +func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } -// OTPSMSSent indicates an expected call of OTPSMSSent -func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// OTPSMSSent indicates an expected call of OTPSMSSent. +func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2, arg3) } -// PasswordChangeSent mocks base method +// PasswordChangeSent mocks base method. func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) @@ -189,27 +190,27 @@ func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 strin return ret0 } -// PasswordChangeSent indicates an expected call of PasswordChangeSent -func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// PasswordChangeSent indicates an expected call of PasswordChangeSent. +func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) } -// PasswordCodeSent mocks base method -func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string) error { +// PasswordCodeSent mocks base method. +func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) return ret0 } -// PasswordCodeSent indicates an expected call of PasswordCodeSent -func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// PasswordCodeSent indicates an expected call of PasswordCodeSent. +func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2, arg3) } -// UsageNotificationSent mocks base method +// UsageNotificationSent mocks base method. func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) @@ -217,13 +218,13 @@ func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.N return ret0 } -// UsageNotificationSent indicates an expected call of UsageNotificationSent -func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 interface{}) *gomock.Call { +// UsageNotificationSent indicates an expected call of UsageNotificationSent. +func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) } -// UserDomainClaimedSent mocks base method +// UserDomainClaimedSent mocks base method. func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) @@ -231,8 +232,8 @@ func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 st return ret0 } -// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent -func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 interface{}) *gomock.Call { +// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent. +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) } diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 57aa3a9251..799a006abf 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/types" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/session" @@ -258,9 +259,12 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler if alreadyHandled { return nil } - code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err + var code string + if e.Code != nil { + code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } } colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) if err != nil { @@ -285,15 +289,16 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler if err != nil { return err } + generatorInfo := new(senders.CodeGeneratorInfo) notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) if e.NotificationType == domain.NotificationTypeSms { - notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e) + notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo) } err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) if err != nil { return err } - return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) }), nil } @@ -345,7 +350,7 @@ func (u *userNotifier) reduceOTPSMS( expiry time.Duration, userID, resourceOwner string, - sentCommand func(ctx context.Context, userID string, resourceOwner string) (err error), + sentCommand func(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) (err error), eventTypes ...eventstore.EventType, ) (*handler.Statement, error) { ctx := HandlerContext(event.Aggregate()) @@ -356,9 +361,12 @@ func (u *userNotifier) reduceOTPSMS( if alreadyHandled { return handler.NewNoOpStatement(event), nil } - plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto) - if err != nil { - return nil, err + var plainCode string + if code != nil { + plainCode, err = crypto.DecryptString(code, u.queries.UserDataCrypto) + if err != nil { + return nil, err + } } colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false) if err != nil { @@ -377,12 +385,13 @@ func (u *userNotifier) reduceOTPSMS( if err != nil { return nil, err } - notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event) + generatorInfo := new(senders.CodeGeneratorInfo) + notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event, generatorInfo) err = notify.SendOTPSMSCode(ctx, plainCode, expiry) if err != nil { return nil, err } - err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner) + err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner, generatorInfo) if err != nil { return nil, err } @@ -691,9 +700,12 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if alreadyHandled { return nil } - code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err + var code string + if e.Code != nil { + code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } } colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) if err != nil { @@ -713,12 +725,12 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if err != nil { return err } - err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e). - SendPhoneVerificationCode(ctx, code) - if err != nil { + generatorInfo := new(senders.CodeGeneratorInfo) + if err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo). + SendPhoneVerificationCode(ctx, code); err != nil { return err } - return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) }), nil } @@ -778,7 +790,7 @@ func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.S } func (u *userNotifier) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) { - if event.CreatedAt().Add(expiry).Before(time.Now().UTC()) { + if expiry > 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/cache.go b/internal/query/cache.go new file mode 100644 index 0000000000..742bf4af58 --- /dev/null +++ b/internal/query/cache.go @@ -0,0 +1,95 @@ +package query + +import ( + "context" + "fmt" + "strings" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/cache" + "github.com/zitadel/zitadel/internal/cache/gomap" + "github.com/zitadel/zitadel/internal/cache/noop" + "github.com/zitadel/zitadel/internal/eventstore" +) + +type Caches struct { + connectors *cacheConnectors + instance cache.Cache[instanceIndex, string, *authzInstance] +} + +func startCaches(background context.Context, conf *cache.CachesConfig) (_ *Caches, err error) { + caches := &Caches{ + instance: noop.NewCache[instanceIndex, string, *authzInstance](), + } + if conf == nil { + return caches, nil + } + caches.connectors, err = startCacheConnectors(background, conf) + if err != nil { + return nil, err + } + caches.instance, err = startCache[instanceIndex, string, *authzInstance](background, instanceIndexValues(), "authz_instance", conf.Instance, caches.connectors) + if err != nil { + return nil, err + } + caches.registerInstanceInvalidation() + + return caches, nil +} + +type cacheConnectors struct { + memory *cache.AutoPruneConfig + // pool *pgxpool.Pool +} + +func startCacheConnectors(_ context.Context, conf *cache.CachesConfig) (*cacheConnectors, error) { + connectors := new(cacheConnectors) + if conf.Connectors.Memory.Enabled { + connectors.memory = &conf.Connectors.Memory.AutoPrune + } + + return connectors, nil +} + +func startCache[I, K comparable, V cache.Entry[I, K]](background context.Context, indices []I, name string, conf *cache.CacheConfig, connectors *cacheConnectors) (cache.Cache[I, K, V], error) { + if conf == nil || conf.Connector == "" { + return noop.NewCache[I, K, V](), nil + } + if strings.EqualFold(conf.Connector, "memory") && connectors.memory != nil { + c := gomap.NewCache[I, K, V](background, indices, *conf) + connectors.memory.StartAutoPrune(background, c, name) + return c, nil + } + + /* TODO + if strings.EqualFold(conf.Connector, "sql") && connectors.pool != nil { + return ... + } + */ + + return nil, fmt.Errorf("cache connector %q not enabled", conf.Connector) +} + +type invalidator[I comparable] interface { + Invalidate(ctx context.Context, index I, key ...string) error +} + +func cacheInvalidationFunc[I comparable](cache invalidator[I], index I, getID func(*eventstore.Aggregate) string) func(context.Context, []*eventstore.Aggregate) { + return func(ctx context.Context, aggregates []*eventstore.Aggregate) { + ids := make([]string, len(aggregates)) + for i, aggregate := range aggregates { + ids[i] = getID(aggregate) + } + err := cache.Invalidate(ctx, index, ids...) + logging.OnError(err).Warn("cache invalidation failed") + } +} + +func getAggregateID(aggregate *eventstore.Aggregate) string { + return aggregate.ID +} + +func getResourceOwner(aggregate *eventstore.Aggregate) string { + return aggregate.ResourceOwner +} diff --git a/internal/query/instance.go b/internal/query/instance.go index fb60946d31..6b1292014f 100644 --- a/internal/query/instance.go +++ b/internal/query/instance.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "time" @@ -17,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/call" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/query/projection" @@ -206,22 +208,35 @@ func (q *Queries) InstanceByHost(ctx context.Context, instanceHost, publicHost s instanceDomain := strings.Split(instanceHost, ":")[0] // remove possible port publicDomain := strings.Split(publicHost, ":")[0] // remove possible port - instance, scan := scanAuthzInstance() - // in case public domain is the same as the instance domain, we do not need to check it - // and can empty it for the check - if instanceDomain == publicDomain { - publicDomain = "" + + instance, ok := q.caches.instance.Get(ctx, instanceIndexByHost, instanceDomain) + if ok { + return instance, instance.checkDomain(instanceDomain, publicDomain) } - err = q.client.QueryRowContext(ctx, scan, instanceByDomainQuery, instanceDomain, publicDomain) - return instance, err + instance, scan := scanAuthzInstance() + if err = q.client.QueryRowContext(ctx, scan, instanceByDomainQuery, instanceDomain); err != nil { + return nil, err + } + q.caches.instance.Set(ctx, instance) + + return instance, instance.checkDomain(instanceDomain, publicDomain) } func (q *Queries) InstanceByID(ctx context.Context, id string) (_ authz.Instance, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + + instance, ok := q.caches.instance.Get(ctx, instanceIndexByID, id) + if ok { + return instance, nil + } + instance, scan := scanAuthzInstance() err = q.client.QueryRowContext(ctx, scan, instanceByIDQuery, id) logging.OnError(err).WithField("instance_id", id).Warn("instance by ID") + if err == nil { + q.caches.instance.Set(ctx, instance) + } return instance, err } @@ -431,6 +446,8 @@ type authzInstance struct { block *bool auditLogRetention *time.Duration features feature.Features + externalDomains database.TextArray[string] + trustedDomains database.TextArray[string] } type csp struct { @@ -485,6 +502,31 @@ func (i *authzInstance) Features() feature.Features { return i.features } +var errPublicDomain = "public domain %q not trusted" + +func (i *authzInstance) checkDomain(instanceDomain, publicDomain string) error { + // in case public domain is empty, or the same as the instance domain, we do not need to check it + if publicDomain == "" || instanceDomain == publicDomain { + return nil + } + if !slices.Contains(i.trustedDomains, publicDomain) { + return zerrors.ThrowNotFound(fmt.Errorf(errPublicDomain, publicDomain), "QUERY-IuGh1", "Errors.IAM.NotFound") + } + return nil +} + +// Keys implements [cache.Entry] +func (i *authzInstance) Keys(index instanceIndex) []string { + switch index { + case instanceIndexByID: + return []string{i.id} + case instanceIndexByHost: + return i.externalDomains + default: + return nil + } +} + func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { instance := &authzInstance{} return instance, func(row *sql.Row) error { @@ -509,6 +551,8 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { &auditLogRetention, &block, &features, + &instance.externalDomains, + &instance.trustedDomains, ) if errors.Is(err, sql.ErrNoRows) { return zerrors.ThrowNotFound(nil, "QUERY-1kIjX", "Errors.IAM.NotFound") @@ -534,3 +578,30 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { return nil } } + +func (c *Caches) registerInstanceInvalidation() { + invalidate := cacheInvalidationFunc(c.instance, instanceIndexByID, getAggregateID) + projection.InstanceProjection.RegisterCacheInvalidation(invalidate) + projection.InstanceDomainProjection.RegisterCacheInvalidation(invalidate) + projection.InstanceFeatureProjection.RegisterCacheInvalidation(invalidate) + projection.InstanceTrustedDomainProjection.RegisterCacheInvalidation(invalidate) + projection.SecurityPolicyProjection.RegisterCacheInvalidation(invalidate) + + // limits uses own aggregate ID, invalidate using resource owner. + invalidate = cacheInvalidationFunc(c.instance, instanceIndexByID, getResourceOwner) + projection.LimitsProjection.RegisterCacheInvalidation(invalidate) + + // System feature update should invalidate all instances, so Truncate the cache. + projection.SystemFeatureProjection.RegisterCacheInvalidation(func(ctx context.Context, _ []*eventstore.Aggregate) { + err := c.instance.Truncate(ctx) + logging.OnError(err).Warn("cache truncate failed") + }) +} + +type instanceIndex int16 + +//go:generate enumer -type instanceIndex +const ( + instanceIndexByID instanceIndex = iota + instanceIndexByHost +) diff --git a/internal/query/instance_by_domain.sql b/internal/query/instance_by_domain.sql index 0d0aeeb4f5..2f3fcb3518 100644 --- a/internal/query/instance_by_domain.sql +++ b/internal/query/instance_by_domain.sql @@ -14,6 +14,16 @@ with domain as ( cross join projections.system_features s full outer join instance_features i using (instance_id, key) group by instance_id +), external_domains as ( + select ed.instance_id, array_agg(ed.domain) as domains + from domain d + join projections.instance_domains ed on d.instance_id = ed.instance_id + group by ed.instance_id +), trusted_domains as ( + select td.instance_id, array_agg(td.domain) as domains + from domain d + join projections.instance_trusted_domains td on d.instance_id = td.instance_id + group by td.instance_id ) select i.id, @@ -27,11 +37,13 @@ select s.enable_impersonation, l.audit_log_retention, l.block, - f.features + f.features, + ed.domains as external_domains, + td.domains as trusted_domains from domain d join projections.instances i on i.id = d.instance_id -left join projections.instance_trusted_domains td on i.id = td.instance_id left join projections.security_policies2 s on i.id = s.instance_id left join projections.limits l on i.id = l.instance_id left join features f on i.id = f.instance_id -where case when $2 = '' then true else td.domain = $2 end; +left join external_domains ed on i.id = ed.instance_id +left join trusted_domains td on i.id = td.instance_id; diff --git a/internal/query/instance_by_id.sql b/internal/query/instance_by_id.sql index 08398846cc..d4000b8d8e 100644 --- a/internal/query/instance_by_id.sql +++ b/internal/query/instance_by_id.sql @@ -7,6 +7,16 @@ with features as ( cross join projections.system_features s full outer join projections.instance_features2 i using (key, instance_id) group by instance_id +), external_domains as ( + select instance_id, array_agg(domain) as domains + from projections.instance_domains + where instance_id = $1 + group by instance_id +), trusted_domains as ( + select instance_id, array_agg(domain) as domains + from projections.instance_trusted_domains + where instance_id = $1 + group by instance_id ) select i.id, @@ -20,9 +30,13 @@ select s.enable_impersonation, l.audit_log_retention, l.block, - f.features + f.features, + ed.domains as external_domains, + td.domains as trusted_domains from projections.instances i left join projections.security_policies2 s on i.id = s.instance_id left join projections.limits l on i.id = l.instance_id left join features f on i.id = f.instance_id +left join external_domains ed on i.id = ed.instance_id +left join trusted_domains td on i.id = td.instance_id where i.id = $1; diff --git a/internal/query/instanceindex_enumer.go b/internal/query/instanceindex_enumer.go new file mode 100644 index 0000000000..5a1516c47f --- /dev/null +++ b/internal/query/instanceindex_enumer.go @@ -0,0 +1,78 @@ +// Code generated by "enumer -type instanceIndex"; DO NOT EDIT. + +package query + +import ( + "fmt" + "strings" +) + +const _instanceIndexName = "instanceIndexByIDinstanceIndexByHost" + +var _instanceIndexIndex = [...]uint8{0, 17, 36} + +const _instanceIndexLowerName = "instanceindexbyidinstanceindexbyhost" + +func (i instanceIndex) String() string { + if i < 0 || i >= instanceIndex(len(_instanceIndexIndex)-1) { + return fmt.Sprintf("instanceIndex(%d)", i) + } + return _instanceIndexName[_instanceIndexIndex[i]:_instanceIndexIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _instanceIndexNoOp() { + var x [1]struct{} + _ = x[instanceIndexByID-(0)] + _ = x[instanceIndexByHost-(1)] +} + +var _instanceIndexValues = []instanceIndex{instanceIndexByID, instanceIndexByHost} + +var _instanceIndexNameToValueMap = map[string]instanceIndex{ + _instanceIndexName[0:17]: instanceIndexByID, + _instanceIndexLowerName[0:17]: instanceIndexByID, + _instanceIndexName[17:36]: instanceIndexByHost, + _instanceIndexLowerName[17:36]: instanceIndexByHost, +} + +var _instanceIndexNames = []string{ + _instanceIndexName[0:17], + _instanceIndexName[17:36], +} + +// instanceIndexString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func instanceIndexString(s string) (instanceIndex, error) { + if val, ok := _instanceIndexNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _instanceIndexNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to instanceIndex values", s) +} + +// instanceIndexValues returns all values of the enum +func instanceIndexValues() []instanceIndex { + return _instanceIndexValues +} + +// instanceIndexStrings returns a slice of all String values of the enum +func instanceIndexStrings() []string { + strs := make([]string, len(_instanceIndexNames)) + copy(strs, _instanceIndexNames) + return strs +} + +// IsAinstanceIndex returns "true" if the value is listed in the enum definition. "false" otherwise +func (i instanceIndex) IsAinstanceIndex() bool { + for _, v := range _instanceIndexValues { + if i == v { + return true + } + } + return false +} diff --git a/internal/query/projection/event_test.go b/internal/query/projection/event_test.go index 4998629cf6..50975265be 100644 --- a/internal/query/projection/event_test.go +++ b/internal/query/projection/event_test.go @@ -74,8 +74,8 @@ func assertReduce(t *testing.T, stmt *handler.Statement, err error, projection s if want.err != nil && want.err(err) { return } - if stmt.AggregateType != want.aggregateType { - t.Errorf("wrong aggregate type: want: %q got: %q", want.aggregateType, stmt.AggregateType) + if stmt.Aggregate.Type != want.aggregateType { + t.Errorf("wrong aggregate type: want: %q got: %q", want.aggregateType, stmt.Aggregate.Type) } if stmt.Sequence != want.sequence { 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/query.go b/internal/query/query.go index c2fbcb00a3..8768294457 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -11,6 +11,7 @@ import ( "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/cache" sd "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/database" @@ -26,6 +27,7 @@ type Queries struct { eventstore *eventstore.Eventstore eventStoreV4 es_v4.Querier client *database.DB + caches *Caches keyEncryptionAlgorithm crypto.EncryptionAlgorithm idpConfigEncryption crypto.EncryptionAlgorithm @@ -47,6 +49,7 @@ func StartQueries( es *eventstore.Eventstore, esV4 es_v4.Querier, querySqlClient, projectionSqlClient *database.DB, + caches *cache.CachesConfig, projections projection.Config, defaults sd.SystemDefaults, idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, @@ -86,6 +89,10 @@ func StartQueries( if startProjections { projection.Start(ctx) } + repo.caches, err = startCaches(ctx, caches) + if err != nil { + return nil, err + } return repo, nil } 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/aggregate.go b/internal/repository/user/schemauser/aggregate.go index 1c9901c08c..18b901281e 100644 --- a/internal/repository/user/schemauser/aggregate.go +++ b/internal/repository/user/schemauser/aggregate.go @@ -5,8 +5,8 @@ import ( ) const ( - AggregateType = "user" - AggregateVersion = "v3" + AggregateType = "schemauser" + AggregateVersion = "v1" ) type Aggregate struct { diff --git a/internal/repository/user/schemauser/email.go b/internal/repository/user/schemauser/email.go index 07ae1bdf71..b5e4206252 100644 --- a/internal/repository/user/schemauser/email.go +++ b/internal/repository/user/schemauser/email.go @@ -8,7 +8,6 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/zerrors" ) const ( @@ -21,11 +20,15 @@ const ( ) type EmailUpdatedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` EmailAddress domain.EmailAddress `json:"email,omitempty"` } +func (e *EmailUpdatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *EmailUpdatedEvent) Payload() interface{} { return e } @@ -36,7 +39,7 @@ func (e *EmailUpdatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { func NewEmailUpdatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, emailAddress domain.EmailAddress) *EmailUpdatedEvent { return &EmailUpdatedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailUpdatedType, @@ -45,24 +48,16 @@ func NewEmailUpdatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, } } -func EmailUpdatedEventMapper(event eventstore.Event) (eventstore.Event, error) { - emailChangedEvent := &EmailUpdatedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(emailChangedEvent) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-4M0sd", "unable to unmarshal human password changed") - } - - return emailChangedEvent, nil -} - type EmailVerifiedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` IsEmailVerified bool `json:"-"` } +func (e *EmailVerifiedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *EmailVerifiedEvent) Payload() interface{} { return nil } @@ -73,7 +68,7 @@ func (e *EmailVerifiedEvent) UniqueConstraints() []*eventstore.UniqueConstraint func NewEmailVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailVerifiedEvent { return &EmailVerifiedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailVerifiedType, @@ -81,18 +76,13 @@ func NewEmailVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) } } -func HumanVerifiedEventMapper(event eventstore.Event) (eventstore.Event, error) { - emailVerified := &EmailVerifiedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - IsEmailVerified: true, - } - return emailVerified, nil -} - type EmailVerificationFailedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` } +func (e *EmailVerificationFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *EmailVerificationFailedEvent) Payload() interface{} { return nil } @@ -101,9 +91,9 @@ func (e *EmailVerificationFailedEvent) UniqueConstraints() []*eventstore.UniqueC return nil } -func NewHumanEmailVerificationFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailVerificationFailedEvent { +func NewEmailVerificationFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailVerificationFailedEvent { return &EmailVerificationFailedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailVerificationFailedType, @@ -111,14 +101,8 @@ func NewHumanEmailVerificationFailedEvent(ctx context.Context, aggregate *events } } -func EmailVerificationFailedEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &EmailVerificationFailedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} - type EmailCodeAddedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` @@ -127,6 +111,10 @@ type EmailCodeAddedEvent struct { TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } +func (e *EmailCodeAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *EmailCodeAddedEvent) Payload() interface{} { return e } @@ -148,7 +136,7 @@ func NewEmailCodeAddedEvent( codeReturned bool, ) *EmailCodeAddedEvent { return &EmailCodeAddedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailCodeAddedType, @@ -161,22 +149,13 @@ func NewEmailCodeAddedEvent( } } -func EmailCodeAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { - codeAdded := &EmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(codeAdded) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-3M0sd", "unable to unmarshal human email code added") - } - - return codeAdded, nil -} - type EmailCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` } +func (e *EmailCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *EmailCodeSentEvent) Payload() interface{} { return nil } @@ -185,18 +164,12 @@ func (e *EmailCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint return nil } -func NewHumanEmailCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailCodeSentEvent { +func NewEmailCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailCodeSentEvent { return &EmailCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailCodeSentType, ), } } - -func EmailCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &EmailCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} diff --git a/internal/repository/user/schemauser/eventstore.go b/internal/repository/user/schemauser/eventstore.go index b9cf03e5d3..85ad15c17d 100644 --- a/internal/repository/user/schemauser/eventstore.go +++ b/internal/repository/user/schemauser/eventstore.go @@ -6,4 +6,18 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, CreatedType, eventstore.GenericEventMapper[CreatedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, UpdatedType, eventstore.GenericEventMapper[UpdatedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, DeletedType, eventstore.GenericEventMapper[DeletedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, LockedType, eventstore.GenericEventMapper[LockedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, UnlockedType, eventstore.GenericEventMapper[UnlockedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, ActivatedType, eventstore.GenericEventMapper[ActivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, DeactivatedType, eventstore.GenericEventMapper[DeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailUpdatedType, eventstore.GenericEventMapper[EmailUpdatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailCodeAddedType, eventstore.GenericEventMapper[EmailCodeAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailCodeSentType, eventstore.GenericEventMapper[EmailCodeSentEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailVerifiedType, eventstore.GenericEventMapper[EmailVerifiedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailVerificationFailedType, eventstore.GenericEventMapper[EmailVerificationFailedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneUpdatedType, eventstore.GenericEventMapper[PhoneUpdatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneCodeAddedType, eventstore.GenericEventMapper[PhoneCodeAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneCodeSentType, eventstore.GenericEventMapper[PhoneCodeSentEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneVerifiedType, eventstore.GenericEventMapper[PhoneVerifiedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneVerificationFailedType, eventstore.GenericEventMapper[PhoneVerificationFailedEvent]) } diff --git a/internal/repository/user/schemauser/phone.go b/internal/repository/user/schemauser/phone.go index 5110772c04..9a68168198 100644 --- a/internal/repository/user/schemauser/phone.go +++ b/internal/repository/user/schemauser/phone.go @@ -8,7 +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/zerrors" + "github.com/zitadel/zitadel/internal/notification/senders" ) const ( @@ -20,23 +20,27 @@ const ( PhoneCodeSentType = phoneEventPrefix + "code.sent" ) -type PhoneChangedEvent struct { - eventstore.BaseEvent `json:"-"` +type PhoneUpdatedEvent struct { + *eventstore.BaseEvent `json:"-"` PhoneNumber domain.PhoneNumber `json:"phone,omitempty"` } -func (e *PhoneChangedEvent) Payload() interface{} { +func (e *PhoneUpdatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *PhoneUpdatedEvent) Payload() interface{} { return e } -func (e *PhoneChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { +func (e *PhoneUpdatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return nil } -func NewPhoneChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, phone domain.PhoneNumber) *PhoneChangedEvent { - return &PhoneChangedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( +func NewPhoneUpdatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, phone domain.PhoneNumber) *PhoneUpdatedEvent { + return &PhoneUpdatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneUpdatedType, @@ -45,24 +49,15 @@ func NewPhoneChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, } } -func PhoneChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { - phoneChangedEvent := &PhoneChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(phoneChangedEvent) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-5M0pd", "unable to unmarshal phone changed") - } - - return phoneChangedEvent, nil -} - type PhoneVerifiedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` IsPhoneVerified bool `json:"-"` } +func (e *PhoneVerifiedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *PhoneVerifiedEvent) Payload() interface{} { return nil } @@ -73,7 +68,7 @@ func (e *PhoneVerifiedEvent) UniqueConstraints() []*eventstore.UniqueConstraint func NewPhoneVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneVerifiedEvent { return &PhoneVerifiedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneVerifiedType, @@ -81,15 +76,12 @@ func NewPhoneVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) } } -func PhoneVerifiedEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &PhoneVerifiedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - IsPhoneVerified: true, - }, nil +type PhoneVerificationFailedEvent struct { + *eventstore.BaseEvent `json:"-"` } -type PhoneVerificationFailedEvent struct { - eventstore.BaseEvent `json:"-"` +func (e *PhoneVerificationFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event } func (e *PhoneVerificationFailedEvent) Payload() interface{} { @@ -102,7 +94,7 @@ func (e *PhoneVerificationFailedEvent) UniqueConstraints() []*eventstore.UniqueC func NewPhoneVerificationFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneVerificationFailedEvent { return &PhoneVerificationFailedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneVerificationFailedType, @@ -110,18 +102,13 @@ func NewPhoneVerificationFailedEvent(ctx context.Context, aggregate *eventstore. } } -func PhoneVerificationFailedEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &PhoneVerificationFailedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} - type PhoneCodeAddedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` 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"` } @@ -137,15 +124,20 @@ func (e *PhoneCodeAddedEvent) TriggerOrigin() string { return e.TriggeredAtOrigin } +func (e *PhoneCodeAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func NewPhoneCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, codeReturned bool, + generatorID string, ) *PhoneCodeAddedEvent { return &PhoneCodeAddedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneCodeAddedType, @@ -153,24 +145,15 @@ func NewPhoneCodeAddedEvent( Code: code, Expiry: expiry, CodeReturned: codeReturned, + GeneratorID: generatorID, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } -func PhoneCodeAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { - codeAdded := &PhoneCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(codeAdded) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-6Ms9d", "unable to unmarshal phone code added") - } - - return codeAdded, nil -} - type PhoneCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` } func (e *PhoneCodeSentEvent) Payload() interface{} { @@ -181,18 +164,17 @@ func (e *PhoneCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint return nil } -func NewPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneCodeSentEvent { +func (e *PhoneCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func NewPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate, generatorInfo *senders.CodeGeneratorInfo) *PhoneCodeSentEvent { return &PhoneCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneCodeSentType, ), + GeneratorInfo: generatorInfo, } } - -func PhoneCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &PhoneCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} diff --git a/internal/repository/user/schemauser/user.go b/internal/repository/user/schemauser/user.go index 4c88d53087..6583e57366 100644 --- a/internal/repository/user/schemauser/user.go +++ b/internal/repository/user/schemauser/user.go @@ -8,10 +8,14 @@ import ( ) const ( - eventPrefix = "user." - CreatedType = eventPrefix + "created" - UpdatedType = eventPrefix + "updated" - DeletedType = eventPrefix + "deleted" + eventPrefix = "schemauser." + CreatedType = eventPrefix + "created" + UpdatedType = eventPrefix + "updated" + DeletedType = eventPrefix + "deleted" + LockedType = eventPrefix + "locked" + UnlockedType = eventPrefix + "unlocked" + DeactivatedType = eventPrefix + "deactivated" + ActivatedType = eventPrefix + "activated" ) type CreatedEvent struct { @@ -60,8 +64,6 @@ type UpdatedEvent struct { SchemaID *string `json:"schemaID,omitempty"` SchemaRevision *uint64 `json:"schemaRevision,omitempty"` Data json.RawMessage `json:"schema,omitempty"` - oldSchemaID string - oldRevision uint64 } func (e *UpdatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { @@ -95,16 +97,14 @@ func NewUpdatedEvent( type Changes func(event *UpdatedEvent) -func ChangeSchemaID(oldSchemaID, schemaID string) func(event *UpdatedEvent) { +func ChangeSchemaID(schemaID string) func(event *UpdatedEvent) { return func(e *UpdatedEvent) { e.SchemaID = &schemaID - e.oldSchemaID = oldSchemaID } } -func ChangeSchemaRevision(oldSchemaRevision, schemaRevision uint64) func(event *UpdatedEvent) { +func ChangeSchemaRevision(schemaRevision uint64) func(event *UpdatedEvent) { return func(e *UpdatedEvent) { e.SchemaRevision = &schemaRevision - e.oldRevision = oldSchemaRevision } } @@ -142,3 +142,119 @@ func NewDeletedEvent( ), } } + +type LockedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *LockedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *LockedEvent) Payload() interface{} { + return e +} + +func (e *LockedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewLockedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *LockedEvent { + return &LockedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + LockedType, + ), + } +} + +type UnlockedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *UnlockedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *UnlockedEvent) Payload() interface{} { + return e +} + +func (e *UnlockedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewUnlockedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *UnlockedEvent { + return &UnlockedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + UnlockedType, + ), + } +} + +type DeactivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *DeactivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *DeactivatedEvent) Payload() interface{} { + return e +} + +func (e *DeactivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewDeactivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *DeactivatedEvent { + return &DeactivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + DeactivatedType, + ), + } +} + +type ActivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *ActivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *ActivatedEvent) Payload() interface{} { + return e +} + +func (e *ActivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewActivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *ActivatedEvent { + return &ActivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + ActivatedType, + ), + } +} diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 8f271ec9c4..ea3df3fde6 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/resources/user/v3alpha/user_service.proto b/proto/zitadel/resources/user/v3alpha/user_service.proto index d39ac1930a..96e595d81d 100644 --- a/proto/zitadel/resources/user/v3alpha/user_service.proto +++ b/proto/zitadel/resources/user/v3alpha/user_service.proto @@ -150,7 +150,7 @@ service ZITADELUsers { // Returns the user identified by the requested ID. rpc GetUser (GetUserRequest) returns (GetUserResponse) { option (google.api.http) = { - get: "/resources/v3alpha/users/{user_id}" + get: "/resources/v3alpha/users/{id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -208,7 +208,7 @@ service ZITADELUsers { // Patch an existing user with data based on a user schema. rpc PatchUser (PatchUserRequest) returns (PatchUserResponse) { option (google.api.http) = { - patch: "/resources/v3alpha/users/{user_id}" + patch: "/resources/v3alpha/users/{id}" body: "user" }; @@ -238,7 +238,7 @@ service ZITADELUsers { // The endpoint returns an error if the user is already in the state 'deactivated'. rpc DeactivateUser (DeactivateUserRequest) returns (DeactivateUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_deactivate" + post: "/resources/v3alpha/users/{id}/_deactivate" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -257,15 +257,15 @@ service ZITADELUsers { }; } - // Reactivate a user + // Activate a user // - // Reactivate a previously deactivated user and change the state to 'active'. + // Activate a previously deactivated user and change the state to 'active'. // The user will be able to log in again. // // The endpoint returns an error if the user is not in the state 'deactivated'. - rpc ReactivateUser (ReactivateUserRequest) returns (ReactivateUserResponse) { + rpc ActivateUser (ActivateUserRequest) returns (ActivateUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_reactivate" + post: "/resources/v3alpha/users/{id}/_activate" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -278,7 +278,7 @@ service ZITADELUsers { responses: { key: "200"; value: { - description: "User successfully reactivated"; + description: "User successfully activated"; }; }; }; @@ -294,7 +294,7 @@ service ZITADELUsers { // The endpoint returns an error if the user is already in the state 'locked'. rpc LockUser (LockUserRequest) returns (LockUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_lock" + post: "/resources/v3alpha/users/{id}/_lock" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -321,7 +321,7 @@ service ZITADELUsers { // The endpoint returns an error if the user is not in the state 'locked'. rpc UnlockUser (UnlockUserRequest) returns (UnlockUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_unlock" + post: "/resources/v3alpha/users/{id}/_unlock" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -346,7 +346,7 @@ service ZITADELUsers { // The user will be able to log in anymore. rpc DeleteUser (DeleteUserRequest) returns (DeleteUserResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}" + delete: "/resources/v3alpha/users/{id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -372,7 +372,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by email. rpc SetContactEmail (SetContactEmailRequest) returns (SetContactEmailResponse) { option (google.api.http) = { - put: "/resources/v3alpha/users/{user_id}/email" + put: "/resources/v3alpha/users/{id}/email" body: "email" }; @@ -397,7 +397,7 @@ service ZITADELUsers { // Verify the contact email with the provided code. rpc VerifyContactEmail (VerifyContactEmailRequest) returns (VerifyContactEmailResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/email/_verify" + post: "/resources/v3alpha/users/{id}/email/_verify" body: "verification_code" }; @@ -422,7 +422,7 @@ service ZITADELUsers { // Resend the email with the verification code for the contact email address. rpc ResendContactEmailCode (ResendContactEmailCodeRequest) returns (ResendContactEmailCodeResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/email/_resend" + post: "/resources/v3alpha/users/{id}/email/_resend" body: "*" }; @@ -449,7 +449,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by SMS. rpc SetContactPhone (SetContactPhoneRequest) returns (SetContactPhoneResponse) { option (google.api.http) = { - put: "/resources/v3alpha/users/{user_id}/phone" + put: "/resources/v3alpha/users/{id}/phone" body: "phone" }; @@ -474,7 +474,7 @@ service ZITADELUsers { // Verify the contact phone with the provided code. rpc VerifyContactPhone (VerifyContactPhoneRequest) returns (VerifyContactPhoneResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/phone/_verify" + post: "/resources/v3alpha/users/{id}/phone/_verify" body: "verification_code" }; @@ -499,7 +499,7 @@ service ZITADELUsers { // Resend the phone with the verification code for the contact phone number. rpc ResendContactPhoneCode (ResendContactPhoneCodeRequest) returns (ResendContactPhoneCodeResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/phone/_resend" + post: "/resources/v3alpha/users/{id}/phone/_resend" body: "*" }; @@ -524,7 +524,7 @@ service ZITADELUsers { // Add a new unique username to a user. The username will be used to identify the user on authentication. rpc AddUsername (AddUsernameRequest) returns (AddUsernameResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/username" + post: "/resources/v3alpha/users/{id}/username" body: "username" }; @@ -549,7 +549,7 @@ service ZITADELUsers { // Remove an existing username of a user, so it cannot be used for authentication anymore. rpc RemoveUsername (RemoveUsernameRequest) returns (RemoveUsernameResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/username/{username_id}" + delete: "/resources/v3alpha/users/{id}/username/{username_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -573,7 +573,7 @@ service ZITADELUsers { // Add, update or reset a user's password with either a verification code or the current password. rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/password" + post: "/resources/v3alpha/users/{id}/password" body: "new_password" }; @@ -598,7 +598,7 @@ service ZITADELUsers { // Request a code to be able to set a new password. rpc RequestPasswordReset (RequestPasswordResetRequest) returns (RequestPasswordResetResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/password/_reset" + post: "/resources/v3alpha/users/{id}/password/_reset" body: "*" }; @@ -625,7 +625,7 @@ service ZITADELUsers { // which are used to verify the device. rpc StartWebAuthNRegistration (StartWebAuthNRegistrationRequest) returns (StartWebAuthNRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/webauthn" + post: "/resources/v3alpha/users/{id}/webauthn" body: "registration" }; @@ -650,7 +650,7 @@ service ZITADELUsers { // Verify the WebAuthN registration started by StartWebAuthNRegistration with the public key credential. rpc VerifyWebAuthNRegistration (VerifyWebAuthNRegistrationRequest) returns (VerifyWebAuthNRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/webauthn/{web_auth_n_id}/_verify" + post: "/resources/v3alpha/users/{id}/webauthn/{web_auth_n_id}/_verify" body: "verify" }; @@ -675,7 +675,7 @@ service ZITADELUsers { // The code will allow the user to start a new WebAuthN registration. rpc CreateWebAuthNRegistrationLink (CreateWebAuthNRegistrationLinkRequest) returns (CreateWebAuthNRegistrationLinkResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/webauthn/registration_link" + post: "/resources/v3alpha/users/{id}/webauthn/registration_link" body: "*" }; @@ -699,7 +699,7 @@ service ZITADELUsers { // Remove an existing WebAuthN authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveWebAuthNAuthenticator (RemoveWebAuthNAuthenticatorRequest) returns (RemoveWebAuthNAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/webauthn/{web_auth_n_id}" + delete: "/resources/v3alpha/users/{id}/webauthn/{web_auth_n_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -723,7 +723,7 @@ service ZITADELUsers { // As a response a secret is returned, which is used to initialize a TOTP app or device. rpc StartTOTPRegistration (StartTOTPRegistrationRequest) returns (StartTOTPRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/totp" + post: "/resources/v3alpha/users/{id}/totp" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -746,7 +746,7 @@ service ZITADELUsers { // Verify the time-based one-time-password (TOTP) registration with the generated code. rpc VerifyTOTPRegistration (VerifyTOTPRegistrationRequest) returns (VerifyTOTPRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/totp/{totp_id}/_verify" + post: "/resources/v3alpha/users/{id}/totp/{totp_id}/_verify" body: "code" }; @@ -770,7 +770,7 @@ service ZITADELUsers { // Remove an existing time-based one-time-password (TOTP) authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveTOTPAuthenticator (RemoveTOTPAuthenticatorRequest) returns (RemoveTOTPAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/totp/{totp_id}" + delete: "/resources/v3alpha/users/{id}/totp/{totp_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -795,7 +795,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by SMS. rpc AddOTPSMSAuthenticator (AddOTPSMSAuthenticatorRequest) returns (AddOTPSMSAuthenticatorResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_sms" + post: "/resources/v3alpha/users/{id}/otp_sms" body: "phone" }; @@ -819,7 +819,7 @@ service ZITADELUsers { // Verify the OTP SMS registration with the provided code. rpc VerifyOTPSMSRegistration (VerifyOTPSMSRegistrationRequest) returns (VerifyOTPSMSRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_sms/{otp_sms_id}/_verify" + post: "/resources/v3alpha/users/{id}/otp_sms/{otp_sms_id}/_verify" body: "code" }; @@ -844,7 +844,7 @@ service ZITADELUsers { // Remove an existing one-time-password (OTP) SMS authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveOTPSMSAuthenticator (RemoveOTPSMSAuthenticatorRequest) returns (RemoveOTPSMSAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/otp_sms/{otp_sms_id}" + delete: "/resources/v3alpha/users/{id}/otp_sms/{otp_sms_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -869,7 +869,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by email. rpc AddOTPEmailAuthenticator (AddOTPEmailAuthenticatorRequest) returns (AddOTPEmailAuthenticatorResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_email" + post: "/resources/v3alpha/users/{id}/otp_email" body: "email" }; @@ -893,7 +893,7 @@ service ZITADELUsers { // Verify the OTP Email registration with the provided code. rpc VerifyOTPEmailRegistration (VerifyOTPEmailRegistrationRequest) returns (VerifyOTPEmailRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_email/{otp_email_id}/_verify" + post: "/resources/v3alpha/users/{id}/otp_email/{otp_email_id}/_verify" body: "code" }; @@ -918,7 +918,7 @@ service ZITADELUsers { // Remove an existing one-time-password (OTP) Email authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveOTPEmailAuthenticator (RemoveOTPEmailAuthenticatorRequest) returns (RemoveOTPEmailAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/otp_email/{otp_email_id}" + delete: "/resources/v3alpha/users/{id}/otp_email/{otp_email_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -991,7 +991,7 @@ service ZITADELUsers { // This will allow the user to authenticate with the provided IDP. rpc AddIDPAuthenticator (AddIDPAuthenticatorRequest) returns (AddIDPAuthenticatorResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/idps" + post: "/resources/v3alpha/users/{id}/idps" body: "authenticator" }; @@ -1016,7 +1016,7 @@ service ZITADELUsers { // Remove an existing identity provider (IDP) authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveIDPAuthenticator (RemoveIDPAuthenticatorRequest) returns (RemoveIDPAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/idps/{idp_id}" + delete: "/resources/v3alpha/users/{id}/idps/{idp_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -1069,7 +1069,7 @@ message GetUserRequest { } ]; // unique identifier of the user. - string user_id = 2 [ + string id = 2 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1123,7 +1123,7 @@ message PatchUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"69629012906488334\""; } @@ -1156,7 +1156,7 @@ message DeactivateUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1172,7 +1172,7 @@ message DeactivateUserResponse { } -message ReactivateUserRequest { +message ActivateUserRequest { optional zitadel.object.v3alpha.Instance instance = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { default: "\"domain from HOST or :authority header\"" @@ -1181,7 +1181,7 @@ message ReactivateUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1192,7 +1192,7 @@ message ReactivateUserRequest { ]; } -message ReactivateUserResponse { +message ActivateUserResponse { zitadel.resources.object.v3alpha.Details details = 1; } @@ -1205,7 +1205,7 @@ message LockUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1229,7 +1229,7 @@ message UnlockUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1253,7 +1253,7 @@ message DeleteUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1277,7 +1277,7 @@ message SetContactEmailRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1309,7 +1309,7 @@ message VerifyContactEmailRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1343,7 +1343,7 @@ message ResendContactEmailCodeRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1376,7 +1376,7 @@ message SetContactPhoneRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1392,7 +1392,7 @@ message SetContactPhoneRequest { message SetContactPhoneResponse { zitadel.resources.object.v3alpha.Details details = 1; // The phone verification code will be set if a contact phone was set with a return_code verification option. - optional string email_code = 3 [ + optional string verification_code = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"SKJd342k\""; } @@ -1408,7 +1408,7 @@ message VerifyContactPhoneRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1442,7 +1442,7 @@ message ResendContactPhoneCodeRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1475,7 +1475,7 @@ message AddUsernameRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1507,7 +1507,7 @@ message RemoveUsernameRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1541,7 +1541,7 @@ message SetPasswordRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1567,7 +1567,7 @@ message RequestPasswordResetRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1606,7 +1606,7 @@ message StartWebAuthNRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1645,7 +1645,7 @@ message VerifyWebAuthNRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1680,7 +1680,7 @@ message CreateWebAuthNRegistrationLinkRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1713,7 +1713,7 @@ message RemoveWebAuthNAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1747,7 +1747,7 @@ message StartTOTPRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1789,7 +1789,7 @@ message VerifyTOTPRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1833,7 +1833,7 @@ message RemoveTOTPAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1867,7 +1867,7 @@ message AddOTPSMSAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1906,7 +1906,7 @@ message VerifyOTPSMSRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1950,7 +1950,7 @@ message RemoveOTPSMSAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1984,7 +1984,7 @@ message AddOTPEmailAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2022,7 +2022,7 @@ message VerifyOTPEmailRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2066,7 +2066,7 @@ message RemoveOTPEmailAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2170,7 +2170,7 @@ message GetIdentityProviderIntentResponse { // and detailed / profile information. IDPInformation idp_information = 2; // If the user was already federated and linked to a ZITADEL user, it's id will be returned. - optional string user_id = 3 [ + optional string id = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"163840776835432345\""; } @@ -2186,7 +2186,7 @@ message AddIDPAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2211,7 +2211,7 @@ message RemoveIDPAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { 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 {