From 0a14c01412101f4b75e45f7a69682f7ec48f9e02 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:14:41 +0200 Subject: [PATCH] fix: configure default url templates (#10416) # Which Problems Are Solved Emails are still send only with URLs to login v1. # How the Problems Are Solved Add configuration for URLs as URL templates, so that links can point at Login v2. # Additional Changes None # Additional Context Closes #10236 --------- Co-authored-by: Marco A. --- cmd/defaults.yaml | 6 +- cmd/mirror/projections.go | 4 +- cmd/setup/03.go | 2 + cmd/setup/config_change.go | 2 + cmd/setup/setup.go | 5 +- cmd/start/start.go | 4 +- internal/api/authz/context_mock.go | 8 + internal/api/ui/login/login.go | 38 ++++- internal/api/ui/login/login_test.go | 178 ++++++++++++++++++++++ internal/command/command.go | 11 +- internal/command/user_human.go | 5 + internal/command/user_human_test.go | 27 ++-- internal/command/user_v2_email.go | 10 ++ internal/command/user_v2_email_test.go | 48 +++--- internal/command/user_v2_human.go | 4 + internal/command/user_v2_human_test.go | 49 +++--- internal/command/user_v2_password.go | 3 + internal/command/user_v2_password_test.go | 43 +++--- 18 files changed, 370 insertions(+), 77 deletions(-) create mode 100644 internal/api/ui/login/login_test.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index bb94e6103..ddc0e0684 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -571,7 +571,11 @@ Login: MaxAge: 12h # ZITADEL_LOGIN_CACHE_MAXAGE # 168h is 7 days, one week SharedMaxAge: 168h # ZITADEL_LOGIN_CACHE_SHAREDMAXAGE - DefaultOTPEmailURLV2: "/otp/verify?loginName={{.LoginName}}&code={{.Code}}" # ZITADEL_LOGIN_CACHE_DEFAULTOTPEMAILURLV2 + DefaultPaths: + BasePath: "/ui/v2/login" + PasswordSetPath: "/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}" + EmailCodePath: "/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}" + OTPEmailPath: "/otp/verify?code={{.Code}}&userID={{.UserID}}&sessionId={{.SessionID}}" Console: ShortCache: diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index 0ff4356d6..6c66c36ca 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -197,6 +197,8 @@ func projections( config.OIDC.DefaultRefreshTokenExpiration, config.OIDC.DefaultRefreshTokenIdleExpiration, config.DefaultInstance.SecretGenerators, + nil, + nil, ) logging.OnError(err).Fatal("unable to start commands") @@ -219,7 +221,7 @@ func projections( commands, queries, es, - config.Login.DefaultOTPEmailURLV2, + config.Login.DefaultPaths.OTPEmailPath, config.SystemDefaults.Notifications.FileSystemPath, keys.User, keys.SMTP, diff --git a/cmd/setup/03.go b/cmd/setup/03.go index e8c51c79c..29c7408ac 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -92,6 +92,8 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error 0, 0, nil, + nil, + nil, ) if err != nil { return err diff --git a/cmd/setup/config_change.go b/cmd/setup/config_change.go index fb3ae08d5..8aeefa85b 100644 --- a/cmd/setup/config_change.go +++ b/cmd/setup/config_change.go @@ -58,6 +58,8 @@ func (mig *externalConfigChange) Execute(ctx context.Context, _ eventstore.Event 0, 0, nil, + nil, + nil, ) if err != nil { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 15236a73e..896e0a4ac 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -527,6 +527,9 @@ func startCommandsQueries( config.OIDC.DefaultRefreshTokenExpiration, config.OIDC.DefaultRefreshTokenIdleExpiration, config.DefaultInstance.SecretGenerators, + + nil, + nil, ) logging.OnError(err).Fatal("unable to start commands") @@ -549,7 +552,7 @@ func startCommandsQueries( commands, queries, eventstoreClient, - config.Login.DefaultOTPEmailURLV2, + config.Login.DefaultPaths.OTPEmailPath, config.SystemDefaults.Notifications.FileSystemPath, keys.User, keys.SMTP, diff --git a/cmd/start/start.go b/cmd/start/start.go index 045bc99d5..c06b44063 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -256,6 +256,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server config.OIDC.DefaultRefreshTokenExpiration, config.OIDC.DefaultRefreshTokenIdleExpiration, config.DefaultInstance.SecretGenerators, + config.Login.DefaultEmailCodeURLTemplate, + config.Login.DefaultPasswordSetURLTemplate, ) if err != nil { return fmt.Errorf("cannot start commands: %w", err) @@ -301,7 +303,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server commands, queries, eventstoreClient, - config.Login.DefaultOTPEmailURLV2, + config.Login.DefaultPaths.OTPEmailPath, config.SystemDefaults.Notifications.FileSystemPath, keys.User, keys.SMTP, diff --git a/internal/api/authz/context_mock.go b/internal/api/authz/context_mock.go index d26b371bc..4ff6f47bc 100644 --- a/internal/api/authz/context_mock.go +++ b/internal/api/authz/context_mock.go @@ -4,6 +4,8 @@ import ( "context" "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/feature" ) type MockContextInstanceOpts func(i *instance) @@ -14,6 +16,12 @@ func WithMockDefaultLanguage(lang language.Tag) MockContextInstanceOpts { } } +func WithMockFeatures(features feature.Features) MockContextInstanceOpts { + return func(i *instance) { + i.features = features + } +} + func NewMockContext(instanceID, orgID, userID string, opts ...MockContextInstanceOpts) context.Context { ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID}) diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index 5ff27c14f..68861baa9 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -52,7 +52,43 @@ type Config struct { AssetCache middleware.CacheConfig // LoginV2 - DefaultOTPEmailURLV2 string + DefaultPaths *DefaultPaths +} + +type DefaultPaths struct { + BasePath string + PasswordSetPath string + EmailCodePath string + OTPEmailPath string +} + +func (c *Config) defaultBaseURL(ctx context.Context) string { + loginV2 := authz.GetInstance(ctx).Features().LoginV2 + if loginV2.Required { + // use the origin as default + baseURI := http_utils.DomainContext(ctx).Origin() + // use custom base URI if defined + if loginV2.BaseURI != nil && loginV2.BaseURI.String() != "" { + baseURI = loginV2.BaseURI.String() + } + return baseURI + c.DefaultPaths.BasePath + } + return "" +} + +func (c *Config) DefaultEmailCodeURLTemplate(ctx context.Context) string { + basePath := c.defaultBaseURL(ctx) + if basePath == "" { + return "" + } + return basePath + c.DefaultPaths.EmailCodePath +} +func (c *Config) DefaultPasswordSetURLTemplate(ctx context.Context) string { + basePath := c.defaultBaseURL(ctx) + if basePath == "" { + return "" + } + return c.defaultBaseURL(ctx) + c.DefaultPaths.PasswordSetPath } const ( diff --git a/internal/api/ui/login/login_test.go b/internal/api/ui/login/login_test.go new file mode 100644 index 000000000..4cc4fa958 --- /dev/null +++ b/internal/api/ui/login/login_test.go @@ -0,0 +1,178 @@ +package login + +import ( + "context" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/feature" +) + +func TestConfig_defaultBaseURL(t *testing.T) { + t.Parallel() + + config := &Config{ + DefaultPaths: &DefaultPaths{BasePath: "/basepath"}, + } + + baseCustomURI, err := url.Parse("https://custom") + require.Nil(t, err) + + tt := []struct { + name string + inputCtx context.Context + http.DomainCtx + expected string + }{ + { + name: "LoginV2 not required", + inputCtx: authz.NewMockContext("instance1", "org1", "user1"), + expected: "", + }, + { + name: "LoginV2 required, no custom BaseURI", + inputCtx: http.WithDomainContext( + authz.NewMockContext("instance1", "org1", "user1", + authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true}}), + ), + &http.DomainCtx{Protocol: "https", PublicHost: "origin"}, + ), + expected: "https://origin/basepath", + }, + { + name: "LoginV2 required, custom BaseURI", + inputCtx: http.WithDomainContext( + authz.NewMockContext("instance1", "org1", "user1", + authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: baseCustomURI}}), + ), + &http.DomainCtx{Protocol: "https", PublicHost: "origin"}, + ), + + expected: "https://custom/basepath", + }, + { + name: "LoginV2 required, custom BaseURI empty string", + inputCtx: http.WithDomainContext( + authz.NewMockContext("instance1", "org1", "user1", + authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: &url.URL{}}}), + ), + &http.DomainCtx{Protocol: "https", PublicHost: "origin"}, + ), + expected: "https://origin/basepath", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := config.defaultBaseURL(tc.inputCtx) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConfig_DefaultEmailCodeURLTemplate(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + expectedEmailURLTemplate string + }{ + { + testName: "when base path is empty should return empty email url template", + inputCtx: http.WithDomainContext( + authz.NewMockContext("instance1", "org1", "user1", + authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: false, BaseURI: &url.URL{}}}), + ), + &http.DomainCtx{Protocol: "https", PublicHost: "origin"}, + ), + expectedEmailURLTemplate: "", + }, + { + testName: "when base path is not empty should return expected url template", + inputCtx: http.WithDomainContext( + authz.NewMockContext("instance1", "org1", "user1", + authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: &url.URL{}}}), + ), + &http.DomainCtx{Protocol: "https", PublicHost: "origin"}, + ), + expectedEmailURLTemplate: "https://origin/basepath/email-code-path", + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := &Config{ + DefaultPaths: &DefaultPaths{ + BasePath: "/basepath", + EmailCodePath: "/email-code-path"}, + } + + // Test + res := c.DefaultEmailCodeURLTemplate(tc.inputCtx) + + // Verify + assert.Equal(t, tc.expectedEmailURLTemplate, res) + }) + } +} + +func TestConfig_DefaultPasswordSetURLTemplate(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + expectedEmailURLTemplate string + }{ + { + testName: "when base path is empty should return empty email url template", + inputCtx: http.WithDomainContext( + authz.NewMockContext("instance1", "org1", "user1", + authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: false, BaseURI: &url.URL{}}}), + ), + &http.DomainCtx{Protocol: "https", PublicHost: "origin"}, + ), + expectedEmailURLTemplate: "", + }, + { + testName: "when base path is not empty should return expected url template", + inputCtx: http.WithDomainContext( + authz.NewMockContext("instance1", "org1", "user1", + authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: &url.URL{}}}), + ), + &http.DomainCtx{Protocol: "https", PublicHost: "origin"}, + ), + expectedEmailURLTemplate: "https://origin/basepath/password-set-path", + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := &Config{ + DefaultPaths: &DefaultPaths{ + BasePath: "/basepath", + PasswordSetPath: "/password-set-path", + }, + } + + // Test + res := c.DefaultPasswordSetURLTemplate(tc.inputCtx) + + // Verify + assert.Equal(t, tc.expectedEmailURLTemplate, res) + }) + } +} diff --git a/internal/command/command.go b/internal/command/command.go index 64b7b53b6..060892a64 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -99,6 +99,9 @@ type Commands struct { // These instance's milestones never need to be invalidated, // so the query and cache overhead can completely eliminated. milestonesCompleted sync.Map + + defaultEmailCodeURLTemplate func(ctx context.Context) string + defaultPasswordSetURLTemplate func(ctx context.Context) string } func StartCommands( @@ -120,6 +123,8 @@ func StartCommands( defaultRefreshTokenLifetime, defaultRefreshTokenIdleLifetime time.Duration, defaultSecretGenerators *SecretGenerators, + defaultEmailCodeURLTemplate func(ctx context.Context) string, + defaultPasswordSetURLTemplate func(ctx context.Context) string, ) (repo *Commands, err error) { if externalDomain == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified") @@ -199,8 +204,10 @@ func StartCommands( Issuer: defaults.Multifactors.OTP.Issuer, }, }, - GenerateDomain: domain.NewGeneratedInstanceDomain, - caches: caches, + GenerateDomain: domain.NewGeneratedInstanceDomain, + caches: caches, + defaultEmailCodeURLTemplate: defaultEmailCodeURLTemplate, + defaultPasswordSetURLTemplate: defaultPasswordSetURLTemplate, } if defaultSecretGenerators != nil && defaultSecretGenerators.ClientSecret != nil { diff --git a/internal/command/user_human.go b/internal/command/user_human.go index e4a614815..aed8bf45a 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -304,6 +304,11 @@ func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation. if human.Email.ReturnCode { human.EmailCode = &emailCode.Plain } + + if human.Email.URLTemplate == "" { + human.Email.URLTemplate = c.defaultEmailCodeURLTemplate(ctx) + } + return append(cmds, user.NewHumanEmailCodeAddedEventV2(ctx, &a.Aggregate, emailCode.Crypted, emailCode.Expiry, human.Email.URLTemplate, human.Email.ReturnCode, human.AuthRequestID)), nil } return cmds, nil diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index a5641d69c..7b52ebc05 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -8,6 +8,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/text/language" @@ -44,6 +45,7 @@ func TestCommandSide_AddHuman(t *testing.T) { newCode encrypedCodeFunc newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc defaultSecretGenerators *SecretGenerators + defaultEmailCodeURLTemplate func(ctx context.Context) string } type args struct { ctx context.Context @@ -539,10 +541,11 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockEncryptedCode("userinit", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockEncryptedCode("userinit", time.Hour), + defaultEmailCodeURLTemplate: func(_ context.Context) string { return "" }, }, args: args{ ctx: context.Background(), @@ -680,16 +683,17 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("emailCode"), }, 1*time.Hour, - "", + "http://example.com/{{.user}}/email/{{.code}}", true, "", ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockEncryptedCode("emailCode", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockEncryptedCode("emailCode", time.Hour), + defaultEmailCodeURLTemplate: func(_ context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -1477,12 +1481,11 @@ func TestCommandSide_AddHuman(t *testing.T) { newEncryptedCode: tt.fields.newCode, newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, defaultSecretGenerators: tt.fields.defaultSecretGenerators, + defaultEmailCodeURLTemplate: tt.fields.defaultEmailCodeURLTemplate, } err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail) if tt.res.err == nil { - if !assert.NoError(t, err) { - t.FailNow() - } + require.NoError(t, err) } else if !tt.res.err(err) { t.Errorf("got wrong err: %v ", err) return diff --git a/internal/command/user_v2_email.go b/internal/command/user_v2_email.go index 4aa75d093..f372d5846 100644 --- a/internal/command/user_v2_email.go +++ b/internal/command/user_v2_email.go @@ -154,6 +154,11 @@ func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userI if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { return nil, err } + + if urlTmpl == "" { + urlTmpl = c.defaultEmailCodeURLTemplate(ctx) + } + if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil { return nil, err } @@ -171,6 +176,11 @@ func (c *Commands) sendUserEmailCodeWithGeneratorEvents(ctx context.Context, use if existingCheck && cmd.model.Code == nil { return nil, zerrors.ThrowPreconditionFailed(err, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty") } + + if urlTmpl == "" { + urlTmpl = c.defaultEmailCodeURLTemplate(ctx) + } + if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil { return nil, err } diff --git a/internal/command/user_v2_email_test.go b/internal/command/user_v2_email_test.go index 73ab2e1c4..9bf690dc1 100644 --- a/internal/command/user_v2_email_test.go +++ b/internal/command/user_v2_email_test.go @@ -1160,8 +1160,9 @@ func TestCommands_ChangeUserEmailVerified(t *testing.T) { func TestCommands_changeUserEmailWithGenerator(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - checkPermission domain.PermissionCheck + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + defaultEmailCodeURLTemplate func(ctx context.Context) string } type args struct { userID string @@ -1320,11 +1321,13 @@ func TestCommands_changeUserEmailWithGenerator(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, - "", false, "", + "http://example.com/{{.user}}/email/{{.code}}", + false, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ userID: "user1", @@ -1376,11 +1379,12 @@ func TestCommands_changeUserEmailWithGenerator(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, - "", true, "", + "http://example.com/{{.user}}/email/{{.code2}}", true, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code2}}" }, }, args: args{ userID: "user1", @@ -1458,8 +1462,9 @@ func TestCommands_changeUserEmailWithGenerator(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, + checkPermission: tt.fields.checkPermission, + defaultEmailCodeURLTemplate: tt.fields.defaultEmailCodeURLTemplate, } got, err := c.changeUserEmailWithGenerator(context.Background(), tt.args.userID, tt.args.email, GetMockSecretGenerator(t), tt.args.returnCode, tt.args.urlTmpl) require.ErrorIs(t, tt.wantErr, err) @@ -1470,8 +1475,9 @@ func TestCommands_changeUserEmailWithGenerator(t *testing.T) { func TestCommands_sendUserEmailCodeWithGeneratorEvents(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - checkPermission domain.PermissionCheck + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + defaultEmailCodeURLTemplate func(ctx context.Context) string } type args struct { userID string @@ -1573,11 +1579,12 @@ func TestCommands_sendUserEmailCodeWithGeneratorEvents(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, - "", false, "", + "http://example.com/{{.user}}/email/{{.code}}", false, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ userID: "user1", @@ -1624,7 +1631,7 @@ func TestCommands_sendUserEmailCodeWithGeneratorEvents(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, - "", false, "", + "http://example.com/{{.user}}/email/{{.code2}}", false, "", ), ), ), @@ -1638,11 +1645,12 @@ func TestCommands_sendUserEmailCodeWithGeneratorEvents(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, - "", false, "", + "http://example.com/{{.user}}/email/{{.code2}}", false, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code2}}" }, }, args: args{ userID: "user1", @@ -1722,11 +1730,12 @@ func TestCommands_sendUserEmailCodeWithGeneratorEvents(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, - "", true, "", + "http://example.com/{{.user}}/email/{{.code}}", true, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), + checkPermission: newMockPermissionCheckAllowed(), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ userID: "user1", @@ -1800,8 +1809,9 @@ func TestCommands_sendUserEmailCodeWithGeneratorEvents(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, + checkPermission: tt.fields.checkPermission, + defaultEmailCodeURLTemplate: tt.fields.defaultEmailCodeURLTemplate, } got, err := c.sendUserEmailCodeWithGenerator(context.Background(), tt.args.userID, GetMockSecretGenerator(t), tt.args.returnCode, tt.args.urlTmpl, tt.args.checkExisting) require.ErrorIs(t, err, tt.wantErr) diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index 3bb54c6d0..572aa2962 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -411,6 +411,10 @@ func (c *Commands) changeUserEmail(ctx context.Context, cmds []eventstore.Comman if err != nil { return cmds, code, err } + if email.URLTemplate == "" { + email.URLTemplate = c.defaultEmailCodeURLTemplate(ctx) + } + cmds = append(cmds, user.NewHumanEmailCodeAddedEventV2(ctx, &wm.Aggregate().Aggregate, cryptoCode.Crypted, cryptoCode.Expiry, email.URLTemplate, email.ReturnCode, "")) if email.ReturnCode { code = &cryptoCode.Plain diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index afa570cd5..723d955ac 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -43,6 +43,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc checkPermission domain.PermissionCheck defaultSecretGenerators *SecretGenerators + defaultEmailCodeURLTemplate func(ctx context.Context) string } type args struct { ctx context.Context @@ -500,15 +501,16 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("emailverify"), }, 1*time.Hour, - "", + "http://example.com/{{.user}}/email/{{.code}}", false, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - newCode: mockEncryptedCode("emailverify", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("emailverify", time.Hour), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -647,16 +649,17 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("emailCode"), }, 1*time.Hour, - "", + "http://example.com/{{.user}}/email/{{.code}}", true, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - newCode: mockEncryptedCode("emailCode", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + newCode: mockEncryptedCode("emailCode", time.Hour), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -1519,7 +1522,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), expectPush( newRegisterHumanEvent("email@test.ch", "", false, true, "", language.English), - user.NewHumanEmailCodeAddedEvent( + user.NewHumanEmailCodeAddedEventV2( context.Background(), &userAgg.Aggregate, &crypto.CryptoValue{ @@ -1529,6 +1532,8 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("mailVerify"), }, time.Hour, + "http://example.com/{{.user}}/email/{{.code}}", + false, "authRequestID", ), user.NewUserIDPLinkAddedEvent( @@ -1540,9 +1545,10 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - newCode: mockEncryptedCode("mailVerify", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("mailVerify", time.Hour), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -2162,6 +2168,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { CryptoMFA: cryptoAlg, }, }, + defaultEmailCodeURLTemplate: tt.fields.defaultEmailCodeURLTemplate, } err := r.AddUserHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail, tt.args.codeAlg) if tt.res.err == nil { @@ -2199,6 +2206,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc checkPermission domain.PermissionCheck defaultSecretGenerators *SecretGenerators + defaultEmailCodeURLTemplate func(ctx context.Context) string } type args struct { ctx context.Context @@ -2555,14 +2563,15 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { Crypted: []byte("emailCode"), }, time.Hour, - "", + "http://example.com/{{.user}}/email/{{.code}}", false, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("emailCode", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailCode", time.Hour), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -2735,14 +2744,15 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { Crypted: []byte("emailCode"), }, time.Hour, - "", + "http://example.com/{{.user}}/email/{{.code}}", true, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("emailCode", time.Hour), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailCode", time.Hour), + defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -3747,6 +3757,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { checkPermission: tt.fields.checkPermission, defaultSecretGenerators: tt.fields.defaultSecretGenerators, userEncryption: tt.args.codeAlg, + defaultEmailCodeURLTemplate: tt.fields.defaultEmailCodeURLTemplate, } err := r.ChangeUserHuman(tt.args.ctx, tt.args.human, tt.args.codeAlg) if tt.res.err == nil { diff --git a/internal/command/user_v2_password.go b/internal/command/user_v2_password.go index faa1fe14a..e04b6ed74 100644 --- a/internal/command/user_v2_password.go +++ b/internal/command/user_v2_password.go @@ -62,6 +62,9 @@ func (c *Commands) requestPasswordReset(ctx context.Context, userID string, retu if err != nil { return nil, nil, err } + if urlTmpl == "" { + urlTmpl = c.defaultPasswordSetURLTemplate(ctx) + } cmd := user.NewHumanPasswordCodeAddedEventV2(ctx, UserAggregateFromWriteModelCtx(ctx, &model.WriteModel), passwordCode.CryptedCode(), passwordCode.CodeExpiry(), notificationType, urlTmpl, returnCode, generatorID) if returnCode { diff --git a/internal/command/user_v2_password_test.go b/internal/command/user_v2_password_test.go index b51be864f..23b1afa07 100644 --- a/internal/command/user_v2_password_test.go +++ b/internal/command/user_v2_password_test.go @@ -5,11 +5,10 @@ import ( "testing" "time" - "golang.org/x/text/language" - "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" @@ -339,12 +338,13 @@ func TestCommands_requestPasswordReset(t *testing.T) { }, } type fields struct { - checkPermission domain.PermissionCheck - eventstore func(t *testing.T) *eventstore.Eventstore - userEncryption crypto.EncryptionAlgorithm - newCode encrypedCodeFunc - newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc - defaultSecretGenerators *SecretGenerators + checkPermission domain.PermissionCheck + eventstore func(t *testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + newCode encrypedCodeFunc + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators + defaultPasswordSetURLTemplate func(ctx context.Context) string } type args struct { ctx context.Context @@ -460,14 +460,15 @@ func TestCommands_requestPasswordReset(t *testing.T) { }, 10*time.Minute, domain.NotificationTypeEmail, - "", + "http://example.com/{{.user}}/password/{{.code}}", false, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("code", 10*time.Minute), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("code", 10*time.Minute), + defaultPasswordSetURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/password/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -686,14 +687,15 @@ func TestCommands_requestPasswordReset(t *testing.T) { }, 10*time.Minute, domain.NotificationTypeEmail, - "", + "http://example.com/{{.user}}/password/{{.code}}", true, "", ), ), ), - checkPermission: newMockPermissionCheckAllowed(), - newCode: mockEncryptedCode("code", 10*time.Minute), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("code", 10*time.Minute), + defaultPasswordSetURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/password/{{.code}}" }, }, args: args{ ctx: context.Background(), @@ -711,12 +713,13 @@ func TestCommands_requestPasswordReset(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - checkPermission: tt.fields.checkPermission, - eventstore: tt.fields.eventstore(t), - userEncryption: tt.fields.userEncryption, - newEncryptedCode: tt.fields.newCode, - newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, - defaultSecretGenerators: tt.fields.defaultSecretGenerators, + checkPermission: tt.fields.checkPermission, + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + newEncryptedCode: tt.fields.newCode, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + defaultPasswordSetURLTemplate: tt.fields.defaultPasswordSetURLTemplate, } got, gotPlainCode, err := c.requestPasswordReset(tt.args.ctx, tt.args.userID, tt.args.returnCode, tt.args.urlTmpl, tt.args.notificationType)