fix: allow invite codes for users with verified mails (#9962)

# Which Problems Are Solved

Users who started the invitation code verification, but haven't set up
any authentication method, need to be able to do so. This might require
a new invitation code, which was currently not possible since creation
was prevented for users with verified emails.

# How the Problems Are Solved

- Allow creation of invitation emails for users with verified emails.
- Merged the creation and resend into a single method, defaulting the
urlTemplate, applicatioName and authRequestID from the previous code (if
one exists). On the user service API, the `ResendInviteCode` endpoint
has been deprecated in favor of the `CreateInviteCode`

# Additional Changes

None

# Additional Context

- Noticed while investigating something internally.
- requires backport to 2.x and 3.x
This commit is contained in:
Livio Spring
2025-05-26 13:59:20 +02:00
committed by GitHub
parent eb0eed21fa
commit 833f6279e1
6 changed files with 71 additions and 125 deletions

View File

@@ -3190,6 +3190,33 @@ func TestServer_CreateInviteCode(t *testing.T) {
}, },
}, },
}, },
{
name: "recreate",
args: args{
ctx: CTX,
req: &user.CreateInviteCodeRequest{},
prepare: func(request *user.CreateInviteCodeRequest) error {
resp := Instance.CreateHumanUser(CTX)
request.UserId = resp.GetUserId()
_, err := Instance.Client.UserV2.CreateInviteCode(CTX, &user.CreateInviteCodeRequest{
UserId: resp.GetUserId(),
Verification: &user.CreateInviteCodeRequest_SendCode{
SendCode: &user.SendInviteCode{
UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
ApplicationName: gu.Ptr("TestApp"),
},
},
})
return err
},
},
want: &user.CreateInviteCodeResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.DefaultOrg.Id,
},
},
},
{ {
name: "create, return code, ok", name: "create, return code, ok",
args: args{ args: args{

View File

@@ -19,14 +19,34 @@ type CreateUserInvite struct {
URLTemplate string URLTemplate string
ReturnCode bool ReturnCode bool
ApplicationName string ApplicationName string
AuthRequestID string
} }
func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvite) (details *domain.ObjectDetails, returnCode *string, err error) { func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvite) (details *domain.ObjectDetails, returnCode *string, err error) {
return c.sendInviteCode(ctx, invite, "", false)
}
// ResendInviteCode resends the invite mail with a new code and an optional authRequestID.
// It will reuse the applicationName from the previous code.
func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) {
details, _, err := c.sendInviteCode(
ctx,
&CreateUserInvite{
UserID: userID,
AuthRequestID: authRequestID,
},
resourceOwner,
true,
)
return details, err
}
func (c *Commands) sendInviteCode(ctx context.Context, invite *CreateUserInvite, resourceOwner string, requireExisting bool) (details *domain.ObjectDetails, returnCode *string, err error) {
invite.UserID = strings.TrimSpace(invite.UserID) invite.UserID = strings.TrimSpace(invite.UserID)
if invite.UserID == "" { if invite.UserID == "" {
return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing") return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing")
} }
wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, "") wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, resourceOwner)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -39,10 +59,22 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit
if !wm.CreationAllowed() { if !wm.CreationAllowed() {
return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised") return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised")
} }
if requireExisting && wm.InviteCode == nil || wm.CodeReturned {
return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound")
}
code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if invite.URLTemplate == "" {
invite.URLTemplate = wm.URLTemplate
}
if invite.ApplicationName == "" {
invite.ApplicationName = wm.ApplicationName
}
if invite.AuthRequestID == "" {
invite.AuthRequestID = wm.AuthRequestID
}
err = c.pushAppendAndReduce(ctx, wm, user.NewHumanInviteCodeAddedEvent( err = c.pushAppendAndReduce(ctx, wm, user.NewHumanInviteCodeAddedEvent(
ctx, ctx,
UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel), UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel),
@@ -51,7 +83,7 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit
invite.URLTemplate, invite.URLTemplate,
invite.ReturnCode, invite.ReturnCode,
invite.ApplicationName, invite.ApplicationName,
"", invite.AuthRequestID,
)) ))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -62,53 +94,6 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit
return writeModelToObjectDetails(&wm.WriteModel), returnCode, nil return writeModelToObjectDetails(&wm.WriteModel), returnCode, nil
} }
// ResendInviteCode resends the invite mail with a new code and an optional authRequestID.
// It will reuse the applicationName from the previous code.
func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing")
}
existingCode, err := c.userInviteCodeWriteModel(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if !existingCode.UserState.Exists() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound")
}
if err := c.checkPermissionUpdateUser(ctx, existingCode.ResourceOwner, userID); err != nil {
return nil, err
}
if !existingCode.CreationAllowed() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Gg42s", "Errors.User.AlreadyInitialised")
}
if existingCode.InviteCode == nil || existingCode.CodeReturned {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound")
}
code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint
if err != nil {
return nil, err
}
if authRequestID == "" {
authRequestID = existingCode.AuthRequestID
}
err = c.pushAppendAndReduce(ctx, existingCode,
user.NewHumanInviteCodeAddedEvent(
ctx,
UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel),
code.Crypted,
code.Expiry,
existingCode.URLTemplate,
false,
existingCode.ApplicationName,
authRequestID,
))
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingCode.WriteModel), nil
}
func (c *Commands) InviteCodeSent(ctx context.Context, userID, orgID string) (err error) { func (c *Commands) InviteCodeSent(ctx context.Context, userID, orgID string) (err error) {
if userID == "" { if userID == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing") return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing")

View File

@@ -28,7 +28,7 @@ type UserV2InviteWriteModel struct {
} }
func (wm *UserV2InviteWriteModel) CreationAllowed() bool { func (wm *UserV2InviteWriteModel) CreationAllowed() bool {
return !wm.EmailVerified && !wm.AuthMethodSet return !wm.AuthMethodSet
} }
func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel { func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel {

View File

@@ -11,7 +11,6 @@ import (
"go.uber.org/mock/gomock" "go.uber.org/mock/gomock"
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
@@ -316,7 +315,7 @@ func TestCommands_ResendInviteCode(t *testing.T) {
userID: "", userID: "",
}, },
want{ want{
err: zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing"), err: zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing"),
}, },
}, },
{ {
@@ -362,7 +361,7 @@ func TestCommands_ResendInviteCode(t *testing.T) {
userID: "unknown", userID: "unknown",
}, },
want{ want{
err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound"), err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound"),
}, },
}, },
{ {
@@ -580,76 +579,6 @@ func TestCommands_ResendInviteCode(t *testing.T) {
}, },
}, },
}, },
{
"resend with own user ok",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username", "firstName",
"lastName",
"nickName",
"displayName",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
),
eventFromEventPusher(
user.NewHumanInviteCodeAddedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
time.Hour,
"",
false,
"",
"authRequestID",
),
),
),
expectPush(
eventFromEventPusher(
user.NewHumanInviteCodeAddedEvent(authz.NewMockContext("instanceID", "org1", "userID"),
&user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
time.Hour,
"",
false,
"",
"authRequestID2",
),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(), // user does not have permission, is allowed in the own context
newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour),
defaultSecretGenerators: &SecretGenerators{},
},
args{
ctx: authz.NewMockContext("instanceID", "org1", "userID"),
userID: "userID",
authRequestID: "authRequestID2",
},
want{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
ID: "userID",
},
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View File

@@ -334,7 +334,7 @@ message AuthFactorU2F {
message SendInviteCode { message SendInviteCode {
// Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page. // Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page.
// If no template is set, the default ZITADEL url will be used. // If no template is set and no previous code was created, the default ZITADEL url will be used.
// //
// The following placeholders can be used: UserID, OrgID, Code // The following placeholders can be used: UserID, OrgID, Code
optional string url_template = 1 [ optional string url_template = 1 [
@@ -346,7 +346,7 @@ message SendInviteCode {
} }
]; ];
// Optionally set an application name, which will be used in the invite mail sent by ZITADEL. // Optionally set an application name, which will be used in the invite mail sent by ZITADEL.
// If no application name is set, ZITADEL will be used as default. // If no application name is set and no previous code was created, ZITADEL will be used as default.
optional string application_name = 2 [ optional string application_name = 2 [
(validate.rules).string = {min_len: 1, max_len: 200}, (validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {

View File

@@ -1135,6 +1135,8 @@ service UserService {
// Create an invite code for a user // Create an invite code for a user
// //
// Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. // Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods.
// If an invite code has been created previously, it's url template and application name will be used as defaults for the new code.
// The new code will overwrite the previous one and make it invalid.
rpc CreateInviteCode (CreateInviteCodeRequest) returns (CreateInviteCodeResponse) { rpc CreateInviteCode (CreateInviteCodeRequest) returns (CreateInviteCodeResponse) {
option (google.api.http) = { option (google.api.http) = {
post: "/v2/users/{user_id}/invite_code" post: "/v2/users/{user_id}/invite_code"
@@ -1158,6 +1160,8 @@ service UserService {
// Resend an invite code for a user // Resend an invite code for a user
// //
// Deprecated: Use [CreateInviteCode](apis/resources/user_service_v2/user-service-create-invite-code.api.mdx) instead.
//
// Resend an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. // Resend an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods.
// A resend is only possible if a code has been created previously and sent to the user. If there is no code or it was directly returned, an error will be returned. // A resend is only possible if a code has been created previously and sent to the user. If there is no code or it was directly returned, an error will be returned.
rpc ResendInviteCode (ResendInviteCodeRequest) returns (ResendInviteCodeResponse) { rpc ResendInviteCode (ResendInviteCodeRequest) returns (ResendInviteCodeResponse) {
@@ -1172,6 +1176,7 @@ service UserService {
}; };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
deprecated: true;
responses: { responses: {
key: "200" key: "200"
value: { value: {