mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 19:07:30 +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",
|
name: "create, return code, ok",
|
||||||
args: args{
|
args: args{
|
||||||
|
@@ -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")
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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) = {
|
||||||
|
@@ -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: {
|
||||||
|
Reference in New Issue
Block a user