mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 16:47:32 +00:00
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:
@@ -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",
|
||||
args: args{
|
||||
|
@@ -19,14 +19,34 @@ type CreateUserInvite struct {
|
||||
URLTemplate string
|
||||
ReturnCode bool
|
||||
ApplicationName string
|
||||
AuthRequestID string
|
||||
}
|
||||
|
||||
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)
|
||||
if invite.UserID == "" {
|
||||
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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -39,10 +59,22 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit
|
||||
if !wm.CreationAllowed() {
|
||||
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
|
||||
if err != nil {
|
||||
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(
|
||||
ctx,
|
||||
UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel),
|
||||
@@ -51,7 +83,7 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit
|
||||
invite.URLTemplate,
|
||||
invite.ReturnCode,
|
||||
invite.ApplicationName,
|
||||
"",
|
||||
invite.AuthRequestID,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -62,53 +94,6 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit
|
||||
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) {
|
||||
if userID == "" {
|
||||
return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing")
|
||||
|
@@ -28,7 +28,7 @@ type UserV2InviteWriteModel struct {
|
||||
}
|
||||
|
||||
func (wm *UserV2InviteWriteModel) CreationAllowed() bool {
|
||||
return !wm.EmailVerified && !wm.AuthMethodSet
|
||||
return !wm.AuthMethodSet
|
||||
}
|
||||
|
||||
func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel {
|
||||
|
@@ -11,7 +11,6 @@ import (
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"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"
|
||||
@@ -316,7 +315,7 @@ func TestCommands_ResendInviteCode(t *testing.T) {
|
||||
userID: "",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
@@ -334,7 +334,7 @@ message AuthFactorU2F {
|
||||
|
||||
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.
|
||||
// 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
|
||||
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.
|
||||
// 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 [
|
||||
(validate.rules).string = {min_len: 1, max_len: 200},
|
||||
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
|
||||
|
@@ -1135,6 +1135,8 @@ service UserService {
|
||||
// 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.
|
||||
// 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) {
|
||||
option (google.api.http) = {
|
||||
post: "/v2/users/{user_id}/invite_code"
|
||||
@@ -1158,6 +1160,8 @@ service UserService {
|
||||
|
||||
// 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.
|
||||
// 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) {
|
||||
@@ -1172,6 +1176,7 @@ service UserService {
|
||||
};
|
||||
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
deprecated: true;
|
||||
responses: {
|
||||
key: "200"
|
||||
value: {
|
||||
|
Reference in New Issue
Block a user