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. <marco@zitadel.com>
(cherry picked from commit 0a14c01412)
This commit is contained in:
Stefan Benz
2025-08-26 12:14:41 +02:00
committed by Livio Spring
parent e06df6e161
commit 1625e5f7bc
18 changed files with 370 additions and 77 deletions

View File

@@ -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 {

View File

@@ -299,6 +299,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

View File

@@ -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
@@ -464,10 +466,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(),
@@ -603,16 +606,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(),
@@ -1391,12 +1395,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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -406,6 +406,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

View File

@@ -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
@@ -494,15 +495,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(),
@@ -639,16 +641,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(),
@@ -1501,7 +1504,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{
@@ -1511,6 +1514,8 @@ func TestCommandSide_AddUserHuman(t *testing.T) {
Crypted: []byte("mailVerify"),
},
time.Hour,
"http://example.com/{{.user}}/email/{{.code}}",
false,
"authRequestID",
),
user.NewUserIDPLinkAddedEvent(
@@ -1522,9 +1527,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(),
@@ -2055,6 +2061,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 {
@@ -2092,6 +2099,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
@@ -2398,14 +2406,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(),
@@ -2578,14 +2587,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(),
@@ -3590,6 +3600,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 {

View File

@@ -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 {

View File

@@ -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)