From fe3ccc85d6038d340d40995a2fbe1930f81da834 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:09:48 +0200 Subject: [PATCH] fix: invite code generation after multiple verification failures (#10323) # Which Problems Are Solved If a wrong verification code is used three or more times during verification, or if the verification code is expired, the user state is marked as [deleted](https://github.com/zitadel/zitadel/blob/main/internal/command/user_v2_invite_model.go#L69). This prevents the creation of a new code with the following [error](https://github.com/zitadel/zitadel/blob/main/internal/command/user_v2_invite.go#L60): `Errors.User.NotFound`. This PR aims to fix this bug. # How the Problems Are Solved This issue is solved by invalidating the previously issued invite code and setting the value of `UserV2InviteWriteModel.CodeReturned` as `false` # Additional Changes N/A # Additional Context - Closes #9860 - Follow-up: API doc update --- internal/command/user_v2_invite_model.go | 7 +- internal/command/user_v2_invite_test.go | 348 +++++++++++++++++++++++ 2 files changed, 353 insertions(+), 2 deletions(-) diff --git a/internal/command/user_v2_invite_model.go b/internal/command/user_v2_invite_model.go index 6b2ab62e0d..91726498f8 100644 --- a/internal/command/user_v2_invite_model.go +++ b/internal/command/user_v2_invite_model.go @@ -65,8 +65,11 @@ func (wm *UserV2InviteWriteModel) Reduce() error { wm.EmptyInviteCode() case *user.HumanInviteCheckFailedEvent: wm.InviteCheckFailureCount++ - if wm.InviteCheckFailureCount >= 3 { //TODO: config? - wm.UserState = domain.UserStateDeleted + if wm.InviteCheckFailureCount >= 3 || crypto.IsCodeExpired(wm.InviteCodeCreationDate, wm.InviteCodeExpiry) { //TODO: make failure count comparison with wm.InviteCheckFailureCount configurable? + // invalidate the invite code after attempting to verify an expired code, or a wrong code three or more times + // so that a new invite code can be created for this user + wm.EmptyInviteCode() + wm.CodeReturned = false } case *user.HumanEmailVerifiedEvent: wm.EmailVerified = true diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go index 53ad1bd944..49a2e78249 100644 --- a/internal/command/user_v2_invite_test.go +++ b/internal/command/user_v2_invite_test.go @@ -21,6 +21,7 @@ import ( ) func TestCommands_CreateInviteCode(t *testing.T) { + t.Parallel() type fields struct { checkPermission domain.PermissionCheck newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc @@ -323,9 +324,348 @@ func TestCommands_CreateInviteCode(t *testing.T) { returnCode: nil, }, }, + { + "create ok after three verification failures", + 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, + ), + ), + // first invite code generated and returned + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code1"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + // simulate three failed verification attempts + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code2"), + }, + time.Hour, + "", + false, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code2", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: nil, + }, + }, + { + "return ok after three verification failures", + 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, + ), + ), + // first invite code generated and returned + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code1"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + // simulate three failed verification attempts + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code2"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code2", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + ReturnCode: true, + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: gu.Ptr("code2"), + }, + }, + { + "create ok after verification fails due to invite code expiration", + 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, + ), + ), + // first invite code generated and returned + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code1"), + }, + -5*time.Minute, // expired code + "", + true, + "", + "", + ), + ), + // simulate a failed verification attempt due to expiry + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code2"), + }, + time.Hour, + "", + false, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code2", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: nil, + }, + }, + { + "return ok after verification fails due to invite code expiration", + 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, + ), + ), + // first invite code generated and returned + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code1"), + }, + -5*time.Minute, // expired code + "", + true, + "", + "", + ), + ), + // simulate a failed verification attempt due to expiry + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code2"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code2", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + ReturnCode: true, + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: gu.Ptr("code2"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ checkPermission: tt.fields.checkPermission, newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, @@ -342,6 +682,7 @@ func TestCommands_CreateInviteCode(t *testing.T) { } func TestCommands_ResendInviteCode(t *testing.T) { + t.Parallel() type fields struct { checkPermission domain.PermissionCheck newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc @@ -713,6 +1054,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ checkPermission: tt.fields.checkPermission, newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, @@ -727,6 +1069,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { } func TestCommands_InviteCodeSent(t *testing.T) { + t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -845,6 +1188,7 @@ func TestCommands_InviteCodeSent(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ eventstore: tt.fields.eventstore(t), } @@ -855,6 +1199,7 @@ func TestCommands_InviteCodeSent(t *testing.T) { } func TestCommands_VerifyInviteCode(t *testing.T) { + t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore userEncryption crypto.EncryptionAlgorithm @@ -940,6 +1285,7 @@ func TestCommands_VerifyInviteCode(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ eventstore: tt.fields.eventstore(t), userEncryption: tt.fields.userEncryption, @@ -952,6 +1298,7 @@ func TestCommands_VerifyInviteCode(t *testing.T) { } func TestCommands_VerifyInviteCodeSetPassword(t *testing.T) { + t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore userEncryption crypto.EncryptionAlgorithm @@ -1268,6 +1615,7 @@ func TestCommands_VerifyInviteCodeSetPassword(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ eventstore: tt.fields.eventstore(t), userEncryption: tt.fields.userEncryption,