diff --git a/backend/v3/domain/user.go b/backend/v3/domain/user.go index 9b4cdbe6d..e24ddc344 100644 --- a/backend/v3/domain/user.go +++ b/backend/v3/domain/user.go @@ -43,11 +43,12 @@ type HumanUser struct { MultifactorInitializationSkippedAt time.Time `json:"multifactorInitializationSkippedAt,omitzero" db:"-"` - Email HumanEmail `json:"email,omitzero" db:"-"` - Phone *HumanPhone `json:"phone,omitempty" db:"-"` - Passkeys []*Passkey `json:"passkeys,omitempty" db:"-"` - Password HumanPassword `json:"password,omitzero" db:"-"` - TOTP *HumanTOTP `json:"totp,omitempty" db:"-"` + Email HumanEmail `json:"email,omitzero" db:"-"` + Phone *HumanPhone `json:"phone,omitempty" db:"-"` + Passkeys []*Passkey `json:"passkeys,omitempty" db:"-"` + Password HumanPassword `json:"password,omitzero" db:"-"` + TOTP *HumanTOTP `json:"totp,omitempty" db:"-"` + RecoveryCodes *HumanRecoveryCodes `json:"recoveryCodes,omitempty" db:"-"` IdentityProviderLinks []*IdentityProviderLink `json:"identityProviderLinks,omitempty" db:"-"` @@ -236,3 +237,13 @@ type HumanInvite struct { // FailedAttempts is the number of consecutive failed invite attempts FailedAttempts uint8 `json:"failedAttempts,omitempty" db:"-"` } + +type HumanRecoveryCodes struct { + // Codes are the hashed recovery codes for the user + Codes []string `json:"codes" db:"-"` + // LastSuccessfullyCheckedAt is the time when a recovery code was last successfully checked + LastSuccessfullyCheckedAt *time.Time `json:"lastSuccessfullyCheckedAt,omitzero" db:"-"` + // FailedAttempts is the number of consecutive failed recovery code attempts + // It is reset to 0 on successful verification + FailedAttempts uint8 `json:"failedAttempts,omitempty" db:"-"` +} diff --git a/backend/v3/domain/user_repo.go b/backend/v3/domain/user_repo.go index 5086f6490..3560c5e49 100644 --- a/backend/v3/domain/user_repo.go +++ b/backend/v3/domain/user_repo.go @@ -82,6 +82,7 @@ type HumanUserRepository interface { // Update updates the user // It ensures that updates are only applied to user Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) + humanConditions humanChanges humanColumns @@ -152,13 +153,14 @@ type humanChanges interface { identityProviderLinkChanges humanPasskeyChanges inviteChanges + recoveryCodeChanges } type humanPasswordChanges interface { // SetPassword overwrites the password field. // It resets the password verification and the last successful password check time. SetPassword(hash string) database.Change - // SetResetPassword handles the password reset process based on the verification type: + // SetResetPasswordVerification handles the password reset process based on the verification type: // * [VerificationTypeInit]: to initialize password reset, a verification will be created. The current password remains unchanged // * [VerificationTypeSucceeded]: to mark the password as reset and remove the verification. The current password remains unchanged use [humanPasswordChanges.SetPassword] to set the new password. // * [VerificationTypeUpdate]: to update the existing password reset verification. If a new code is requested, use [VerificationTypeInit]. @@ -236,7 +238,7 @@ type humanPhoneChanges interface { } type humanTOTPChanges interface { - // SetTOTP changes the TOTP secret and verification timestamp + // SetTOTPSecret changes the TOTP secret and verification timestamp SetTOTPSecret(secret *crypto.CryptoValue) database.Change // SetTOTPVerifiedAt sets the TOTP verified at time // If verifiedAt is zero, it will be set to NOW() @@ -275,6 +277,23 @@ type inviteChanges interface { SetInviteVerification(verification VerificationType) database.Change } +type recoveryCodeChanges interface { + // AddRecoveryCodes adds new recovery codes / appends to the existing recovery codes for the user + AddRecoveryCodes(codes []string) database.Change + // RemoveRecoveryCode removes a recovery code for the user + RemoveRecoveryCode(code string) database.Change + // RemoveAllRecoveryCodes removes all recovery codes for the user + RemoveAllRecoveryCodes() database.Change + // SetLastSuccessfulRecoveryCodeCheck sets the last successful recovery code check time + // If checkedAt is zero, it will be set to NOW() + // It resets the failed attempts + SetLastSuccessfulRecoveryCodeCheck(checkedAt time.Time) database.Change + // IncrementRecoveryCodeFailedAttempts increments the recovery code verification failed attempts + IncrementRecoveryCodeFailedAttempts() database.Change + // ResetRecoveryCodeFailedAttempts resets the recovery code verification failed attempts + ResetRecoveryCodeFailedAttempts() database.Change +} + type HumanIdentityProviderLinkConditions interface { ProviderIDCondition(providerID string) database.Condition ProvidedUserIDCondition(providedUserID string) database.Condition diff --git a/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql index dcc229065..068c51023 100644 --- a/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql +++ b/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql @@ -70,6 +70,10 @@ CREATE TABLE zitadel.users( , invite_accepted_at TIMESTAMPTZ CHECK ((type = 'machine' AND invite_accepted_at IS NULL) OR (type = 'human')) , invite_failed_attempts SMALLINT CHECK ((type = 'machine' AND invite_failed_attempts IS NULL) OR (type = 'human')) + , recovery_codes TEXT[] CHECK ((type = 'machine' AND recovery_codes IS NULL) OR (type = 'human')) + , recovery_code_last_successful_check TIMESTAMPTZ CHECK ((type = 'machine' AND recovery_code_last_successful_check IS NULL) OR (type = 'human')) + , recovery_code_failed_attempts SMALLINT CHECK ((type = 'machine' AND recovery_code_failed_attempts IS NULL) OR (type = 'human')) + -- foreign keys for verifications are created in the verification migration -- machine diff --git a/backend/v3/storage/database/repository/user.go b/backend/v3/storage/database/repository/user.go index d560683f1..d3ed7c382 100644 --- a/backend/v3/storage/database/repository/user.go +++ b/backend/v3/storage/database/repository/user.go @@ -88,6 +88,7 @@ var queryUserStmt = "SELECT users.instance_id, users.organization_id, users.id, `, 'email', jsonb_build_object('address', users.email, 'unverifiedAddress', users.unverified_email, 'verifiedAt', users.email_verified_at, 'otp', jsonb_build_object('enabledAt', users.email_otp_enabled_at, 'lastSuccessfullyCheckedAt', users.email_otp_last_successful_check, 'failedAttempts', users.email_otp_failed_attempts), 'pendingVerification', ` + verificationQuery(userHuman{}.emailVerificationIDColumn()) + `)` + `, 'phone', CASE WHEN users.phone IS NOT NULL OR users.phone_verification_id IS NOT NULL THEN jsonb_build_object('number', users.phone, 'unverifiedNumber', users.unverified_phone, 'verifiedAt', users.phone_verified_at, 'otp', jsonb_build_object('enabledAt', users.sms_otp_enabled_at, 'lastSuccessfullyCheckedAt', users.sms_otp_last_successful_check, 'failedAttempts', users.sms_otp_failed_attempts), 'pendingVerification', ` + verificationQuery(userHuman{}.phoneVerificationIDColumn()) + `) ELSE NULL END` + `, 'totp', CASE WHEN users.totp_secret IS NOT NULL THEN jsonb_build_object('secret', encode(users.totp_secret, 'escape')::JSONB, 'verifiedAt', users.totp_verified_at, 'lastSuccessfullyCheckedAt', users.totp_last_successful_check, 'failedAttempts', users.totp_failed_attempts) ELSE NULL END` + + `, 'recoveryCodes', CASE WHEN users.recovery_codes IS NOT NULL THEN jsonb_build_object('codes', users.recovery_codes, 'lastSuccessfullyCheckedAt', users.recovery_code_last_successful_check, 'failedAttempts', users.recovery_code_failed_attempts) ELSE NULL END` + `, 'passkeys', jsonb_agg(DISTINCT jsonb_build_object('id', user_passkeys.token_id, 'keyId', encode(user_passkeys.key_id, 'base64'), 'type', user_passkeys.type, 'name', user_passkeys.name, 'signCount', user_passkeys.sign_count, 'challenge', encode(user_passkeys.challenge, 'base64'), 'publicKey', encode(user_passkeys.public_key, 'base64'), 'attestationType', user_passkeys.attestation_type, 'aaGuid', encode(user_passkeys.authenticator_attestation_guid, 'base64'), 'rpId', user_passkeys.relying_party_id, 'createdAt', user_passkeys.created_at, 'updatedAt', user_passkeys.updated_at, 'verifiedAt', user_passkeys.verified_at)) FILTER (WHERE user_passkeys.user_id IS NOT NULL)` + `, 'verifications', (SELECT jsonb_agg(jsonb_build_object('id', verifications.id, 'code', encode(verifications.code, 'escape')::JSONB, 'createdAt', verifications.created_at, 'updatedAt', verifications.updated_at, 'expiresAt', verifications.created_at+verifications.expiry, 'failedAttempts', verifications.failed_attempts)) FROM zitadel.verifications WHERE verifications.instance_id = users.instance_id AND verifications.user_id = users.id AND verifications.id NOT IN (COALESCE(users.password_verification_id, ''), COALESCE(users.email_verification_id, ''), COALESCE(users.phone_verification_id, ''), COALESCE(users.invite_verification_id, '')))` + `, 'identityProviderLinks', jsonb_agg(DISTINCT jsonb_build_object('providerId', user_identity_provider_links.identity_provider_id, 'providedUserId', user_identity_provider_links.provided_user_id, 'providedUsername', user_identity_provider_links.provided_username, 'createdAt', user_identity_provider_links.created_at, 'updatedAt', user_identity_provider_links.updated_at)) FILTER (WHERE user_identity_provider_links.user_id IS NOT NULL)` + diff --git a/backend/v3/storage/database/repository/user_human_recovery_codes.go b/backend/v3/storage/database/repository/user_human_recovery_codes.go new file mode 100644 index 000000000..e5f5a2f15 --- /dev/null +++ b/backend/v3/storage/database/repository/user_human_recovery_codes.go @@ -0,0 +1,78 @@ +package repository + +import ( + "time" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +// ------------------------------------------------------------- +// changes +// ------------------------------------------------------------- + +func (u userHuman) AddRecoveryCodes(codes []string) database.Change { + return database.NewChangeToStatement(u.recoveryCodesColumn(), func(builder *database.StatementBuilder) { + // the following lines build the value expression + // `ARRAY(SELECT DISTINCT unnest(array_cat(recovery_codes, $1)))` + // for the SET recovery_codes part of the user UPDATE query + builder.WriteString("ARRAY(SELECT DISTINCT unnest(array_cat(") + database.Coalesce(u.recoveryCodesColumn(), []string{}).WriteQualified(builder) + builder.WriteString(", ") + builder.WriteArg(codes) + builder.WriteString(")))") + }) +} + +func (u userHuman) RemoveRecoveryCode(code string) database.Change { + return database.NewChangeToStatement(u.recoveryCodesColumn(), func(builder *database.StatementBuilder) { + // the following lines build the value expression + // `array_remove(recovery_codes, $1)` + // for the SET recovery_codes part of the user UPDATE query + builder.WriteString("array_remove(") + u.recoveryCodesColumn().WriteQualified(builder) + builder.WriteString(", ") + builder.WriteArg(code) + builder.WriteString(")") + }) +} + +func (u userHuman) RemoveAllRecoveryCodes() database.Change { + return database.NewChange(u.recoveryCodesColumn(), []string{}) +} + +func (u userHuman) SetLastSuccessfulRecoveryCodeCheck(checkedAt time.Time) database.Change { + if checkedAt.IsZero() { + return database.NewChanges( + database.NewChange(u.lastSuccessfulRecoveryCodeCheckColumn(), database.NowInstruction), + u.ResetRecoveryCodeFailedAttempts(), + ) + } + return database.NewChanges( + database.NewChange(u.lastSuccessfulRecoveryCodeCheckColumn(), checkedAt), + u.ResetRecoveryCodeFailedAttempts(), + ) +} + +func (u userHuman) IncrementRecoveryCodeFailedAttempts() database.Change { + return database.NewIncrementColumnChange(u.recoveryCodeFailedAttemptsColumn(), database.Coalesce(u.recoveryCodeFailedAttemptsColumn(), 0)) +} + +func (u userHuman) ResetRecoveryCodeFailedAttempts() database.Change { + return database.NewChange(u.recoveryCodeFailedAttemptsColumn(), 0) +} + +// ------------------------------------------------------------- +// columns +// ------------------------------------------------------------- + +func (u userHuman) recoveryCodesColumn() database.Column { + return database.NewColumn(u.unqualifiedTableName(), "recovery_codes") +} + +func (u userHuman) lastSuccessfulRecoveryCodeCheckColumn() database.Column { + return database.NewColumn(u.unqualifiedTableName(), "recovery_code_last_successful_check") +} + +func (u userHuman) recoveryCodeFailedAttemptsColumn() database.Column { + return database.NewColumn(u.unqualifiedTableName(), "recovery_code_failed_attempts") +} diff --git a/backend/v3/storage/database/repository/user_human_recovery_codes_test.go b/backend/v3/storage/database/repository/user_human_recovery_codes_test.go new file mode 100644 index 000000000..4f8f8ac12 --- /dev/null +++ b/backend/v3/storage/database/repository/user_human_recovery_codes_test.go @@ -0,0 +1,77 @@ +package repository_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" +) + +func TestUserHuman_RecoveryCodeChanges(t *testing.T) { + userRepo := repository.UserRepository() + + checkedAt := time.Now() + + tests := []struct { + name string + change database.Change + expectedStatement string + expectedArgs []any + }{ + { + name: "add recovery codes", + change: userRepo.Human().AddRecoveryCodes([]string{"code3"}), + expectedStatement: `recovery_codes = (ARRAY(SELECT DISTINCT unnest(array_cat(COALESCE(users.recovery_codes, $1), $2))))`, + expectedArgs: []any{[]string{}, []string{"code3"}}, + }, + { + name: "remove recovery code", + change: userRepo.Human().RemoveRecoveryCode("code3"), + expectedStatement: `recovery_codes = (array_remove(users.recovery_codes, $1))`, + expectedArgs: []any{"code3"}, + }, + { + name: "remove all recovery codes", + change: userRepo.Human().RemoveAllRecoveryCodes(), + expectedStatement: `recovery_codes = $1`, + expectedArgs: []any{[]string{}}, + }, + { + name: "set recovery code last successful checked at with checkedAt value zero", + change: userRepo.Human().SetLastSuccessfulRecoveryCodeCheck(time.Time{}), + expectedStatement: `recovery_code_last_successful_check = NOW(), recovery_code_failed_attempts = $1`, + expectedArgs: []any{0}, + }, + { + name: "set recovery code last successful checked at with a valid checkedAt value", + change: userRepo.Human().SetLastSuccessfulRecoveryCodeCheck(checkedAt), + expectedStatement: `recovery_code_last_successful_check = $1, recovery_code_failed_attempts = $2`, + expectedArgs: []any{checkedAt, 0}, + }, + { + name: "increment recovery code failed attempts", + change: userRepo.Human().IncrementRecoveryCodeFailedAttempts(), + expectedStatement: `recovery_code_failed_attempts = COALESCE(users.recovery_code_failed_attempts, $1) + 1`, + expectedArgs: []any{0}, + }, + { + name: "reset recovery code failed attempts", + change: userRepo.Human().ResetRecoveryCodeFailedAttempts(), + expectedStatement: `recovery_code_failed_attempts = $1`, + expectedArgs: []any{0}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var builder database.StatementBuilder + err := test.change.Write(&builder) + assert.NoError(t, err) + + assert.Equal(t, test.expectedStatement, builder.String()) + assert.Equal(t, test.expectedArgs, builder.Args()) + }) + } +} diff --git a/backend/v3/storage/database/repository/user_test.go b/backend/v3/storage/database/repository/user_test.go index c99bf3945..addcf8c67 100644 --- a/backend/v3/storage/database/repository/user_test.go +++ b/backend/v3/storage/database/repository/user_test.go @@ -3709,6 +3709,184 @@ func Test_user_Update(t *testing.T) { }(), }, }, + { + name: "human user - add new recovery codes", + args: args{ + condition: humanCondition, + changes: []database.Change{ + humanRepo.AddRecoveryCodes([]string{"code1", "code2"}), + }, + }, + want: want{ + user: func() *domain.User { + u, err := userRepo.Get(t.Context(), tx, database.WithCondition(humanCondition)) + require.NoError(t, err) + u.Human.RecoveryCodes = &domain.HumanRecoveryCodes{ + Codes: []string{"code1", "code2"}, + } + return u + }(), + }, + }, + { + name: "human user - append recovery codes", + setup: func(t *testing.T, tx database.QueryExecutor) error { + _, err := humanRepo.Update(t.Context(), tx, humanCondition, + humanRepo.AddRecoveryCodes([]string{"code1", "code2"}), + ) + require.NoError(t, err) + return err + }, + args: args{ + condition: humanCondition, + changes: []database.Change{ + humanRepo.AddRecoveryCodes([]string{"code3", "code4"}), + }, + }, + want: want{ + user: func() *domain.User { + u, err := userRepo.Get(t.Context(), tx, database.WithCondition(humanCondition)) + require.NoError(t, err) + u.Human.RecoveryCodes = &domain.HumanRecoveryCodes{ + Codes: []string{"code1", "code2", "code3", "code4"}, + } + return u + }(), + }, + }, + { + name: "human user - append recovery codes with duplicates", + setup: func(t *testing.T, tx database.QueryExecutor) error { + _, err := humanRepo.Update(t.Context(), tx, humanCondition, + humanRepo.AddRecoveryCodes([]string{"code1", "code2"}), + ) + require.NoError(t, err) + return err + }, + args: args{ + condition: humanCondition, + changes: []database.Change{ + humanRepo.AddRecoveryCodes([]string{"code2", "code3", "code3", "code4"}), + }, + }, + want: want{ + user: func() *domain.User { + u, err := userRepo.Get(t.Context(), tx, database.WithCondition(humanCondition)) + require.NoError(t, err) + u.Human.RecoveryCodes = &domain.HumanRecoveryCodes{ + Codes: []string{"code1", "code2", "code3", "code4"}, + } + return u + }(), + }, + }, + { + name: "human user - remove recovery code", + setup: func(t *testing.T, tx database.QueryExecutor) error { + _, err := humanRepo.Update(t.Context(), tx, humanCondition, + humanRepo.AddRecoveryCodes([]string{"code1", "code2"}), + ) + require.NoError(t, err) + return err + }, + args: args{ + condition: humanCondition, + changes: []database.Change{ + humanRepo.RemoveRecoveryCode("code2"), + }, + }, + want: want{ + user: func() *domain.User { + u, err := userRepo.Get(t.Context(), tx, database.WithCondition(humanCondition)) + require.NoError(t, err) + u.Human.RecoveryCodes = &domain.HumanRecoveryCodes{ + Codes: []string{"code1"}, + } + return u + }(), + }, + }, + { + name: "human user - remove all recovery codes", + setup: func(t *testing.T, tx database.QueryExecutor) error { + _, err := humanRepo.Update(t.Context(), tx, humanCondition, + humanRepo.AddRecoveryCodes([]string{"code1", "code2", "code3"}), + ) + require.NoError(t, err) + return err + }, + args: args{ + condition: humanCondition, + changes: []database.Change{ + humanRepo.RemoveAllRecoveryCodes(), + }, + }, + want: want{ + user: func() *domain.User { + u, err := userRepo.Get(t.Context(), tx, database.WithCondition(humanCondition)) + require.NoError(t, err) + u.Human.RecoveryCodes = &domain.HumanRecoveryCodes{ + Codes: []string{}, + } + return u + }(), + }, + }, + { + name: "human user - set recovery code failed attempts", + setup: func(t *testing.T, tx database.QueryExecutor) error { + _, err := humanRepo.Update(t.Context(), tx, humanCondition, + humanRepo.AddRecoveryCodes([]string{"code1", "code2"}), + humanRepo.IncrementRecoveryCodeFailedAttempts(), + ) + require.NoError(t, err) + return err + }, + args: args{ + condition: humanCondition, + changes: []database.Change{ + humanRepo.IncrementRecoveryCodeFailedAttempts(), + }, + }, + want: want{ + user: func() *domain.User { + u, err := userRepo.Get(t.Context(), tx, database.WithCondition(humanCondition)) + require.NoError(t, err) + u.Human.RecoveryCodes = &domain.HumanRecoveryCodes{ + Codes: []string{"code1", "code2"}, + FailedAttempts: 2, + } + return u + }(), + }, + }, + { + name: "human user - set recovery code last successfully checked at", + setup: func(t *testing.T, tx database.QueryExecutor) error { + _, err := humanRepo.Update(t.Context(), tx, humanCondition, + humanRepo.AddRecoveryCodes([]string{"code1", "code2"}), + ) + require.NoError(t, err) + return err + }, + args: args{ + condition: humanCondition, + changes: []database.Change{ + humanRepo.SetLastSuccessfulRecoveryCodeCheck(now), + }, + }, + want: want{ + user: func() *domain.User { + u, err := userRepo.Get(t.Context(), tx, database.WithCondition(humanCondition)) + require.NoError(t, err) + u.Human.RecoveryCodes = &domain.HumanRecoveryCodes{ + Codes: []string{"code1", "code2"}, + LastSuccessfullyCheckedAt: &now, + } + return u + }(), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -3963,6 +4141,7 @@ func assertHumanUser(t *testing.T, expected, actual *domain.HumanUser) { assertIdentityProviderLinks(t, expected.IdentityProviderLinks, actual.IdentityProviderLinks) assertVerifications(t, expected.Verifications, actual.Verifications) + assertRecoveryCodes(t, expected.RecoveryCodes, actual.RecoveryCodes) } func assertHumanTOTP(t *testing.T, expected, actual *domain.HumanTOTP) { @@ -4185,3 +4364,20 @@ func assertIdentityProviderLinks(t *testing.T, expected, actual []*domain.Identi assert.True(t, expectedLink.UpdatedAt.Equal(actual[i].UpdatedAt), "identity provider link[%d] updated at", i) } } + +func assertRecoveryCodes(t *testing.T, expected, actual *domain.HumanRecoveryCodes) { + t.Helper() + + require.Equal(t, expected == nil, actual == nil, "human recovery codes nil check") + if expected == nil { + return + } + assert.ElementsMatch(t, expected.Codes, actual.Codes) + if expected.LastSuccessfullyCheckedAt != nil { + require.NotNil(t, actual.LastSuccessfullyCheckedAt, "human recovery codes last successfully checked at not nil") + assert.True(t, expected.LastSuccessfullyCheckedAt.Equal(*actual.LastSuccessfullyCheckedAt), "human recovery codes last successfully checked at") + } else { + assert.Nil(t, actual.LastSuccessfullyCheckedAt, "human recovery codes last successfully checked at nil") + } + assert.Equal(t, expected.FailedAttempts, actual.FailedAttempts, "human recovery codes failed attempts") +} diff --git a/backend/v3/storage/database/repository/verification.go b/backend/v3/storage/database/repository/verification.go index 981f29e88..fb6e0ea01 100644 --- a/backend/v3/storage/database/repository/verification.go +++ b/backend/v3/storage/database/repository/verification.go @@ -65,7 +65,9 @@ func (v verification) update(update *domain.VerificationTypeUpdate, existingTabl builder.WriteString(v.qualifiedTableName()) builder.WriteString(" SET ") err := changes.Write(builder) - logging.New(logging.StreamRuntime).Debug("write changes in cte failed", "error", err) + if err != nil { + logging.New(logging.StreamRuntime).Debug("write changes in cte failed", "error", err) + } builder.WriteString(" FROM ") builder.WriteString(existingTableName) writeCondition(builder, database.And( @@ -128,7 +130,9 @@ func (v verification) failed(existingTableName string, instanceID, verificationI builder.WriteString(v.qualifiedTableName()) builder.WriteString(" SET ") err := database.NewIncrementColumnChange(v.failedAttemptsColumn(), database.Coalesce(v.failedAttemptsColumn(), 0)).Write(builder) - logging.New(logging.StreamRuntime).Debug("write cte failed", "error", err) + if err != nil { + logging.New(logging.StreamRuntime).Debug("write cte failed", "error", err) + } builder.WriteString(" FROM ") builder.WriteString(existingTableName) writeCondition(builder, database.And( diff --git a/internal/command/user_human_recovery_codes.go b/internal/command/user_human_recovery_codes.go index 8d968748c..b63d6bb79 100644 --- a/internal/command/user_human_recovery_codes.go +++ b/internal/command/user_human_recovery_codes.go @@ -148,7 +148,7 @@ func checkRecoveryCode( secretHasher *crypto.Hasher, ) ([]eventstore.Command, error) { if code == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing") + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.MFA.RecoveryCodes.Empty") } if userID == "" { diff --git a/internal/command/user_human_recovery_codes_test.go b/internal/command/user_human_recovery_codes_test.go index 729579be0..5be97589d 100644 --- a/internal/command/user_human_recovery_codes_test.go +++ b/internal/command/user_human_recovery_codes_test.go @@ -660,7 +660,7 @@ func TestCommands_HumanCheckRecoveryCode(t *testing.T) { code: "", resourceOwner: "org1", }, - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing"), + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.MFA.RecoveryCodes.Empty"), }, { name: "missing userID, error", @@ -933,7 +933,7 @@ func TestCommands_checkRecoveryCode(t *testing.T) { secretHasher: hasher, }, wantCommands: 0, - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing"), + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.MFA.RecoveryCodes.Empty"), }, { name: "missing userID, error", diff --git a/internal/query/projection/user_relational.go b/internal/query/projection/user_relational.go index eee4fb8af..0a8209d92 100644 --- a/internal/query/projection/user_relational.go +++ b/internal/query/projection/user_relational.go @@ -424,6 +424,22 @@ func (p *userRelationalProjection) Reducers() []handler.AggregateReducer { Event: user.HumanInviteCheckFailedType, Reduce: p.reduceInviteCheckFailed, }, + { + Event: user.HumanRecoveryCodesAddedType, + Reduce: p.reduceRecoveryCodesAdded, + }, + { + Event: user.HumanRecoveryCodesRemovedType, + Reduce: p.reduceRecoveryCodesRemoved, + }, + { + Event: user.HumanRecoveryCodeCheckSucceededType, + Reduce: p.reduceRecoveryCodeCheckSucceeded, + }, + { + Event: user.HumanRecoveryCodeCheckFailedType, + Reduce: p.reduceRecoveryCodeCheckFailed, + }, }, }, } @@ -2116,6 +2132,87 @@ func (p *userRelationalProjection) reduceInviteCheckFailed(event eventstore.Even }), nil } +func (p *userRelationalProjection) reduceRecoveryCodesAdded(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*user.HumanRecoveryCodesAddedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-oqwfuV", "reduce.wrong.db.pool %T", ex) + } + repo := repository.HumanUserRepository() + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + repo.AddRecoveryCodes(e.Codes), + repo.SetUpdatedAt(e.CreatedAt()), + ) + return err + }), nil +} + +func (p *userRelationalProjection) reduceRecoveryCodesRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*user.HumanRecoveryCodesRemovedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-ZueNKs", "reduce.wrong.db.pool %T", ex) + } + repo := repository.HumanUserRepository() + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + repo.RemoveAllRecoveryCodes(), + repo.SetUpdatedAt(e.CreatedAt()), + ) + return err + }), nil +} + +func (p *userRelationalProjection) reduceRecoveryCodeCheckSucceeded(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*user.HumanRecoveryCodeCheckSucceededEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-wr8WuW", "reduce.wrong.db.pool %T", ex) + } + repo := repository.HumanUserRepository() + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + repo.RemoveRecoveryCode(e.CodeChecked), + repo.SetLastSuccessfulRecoveryCodeCheck(e.CreatedAt()), + repo.SetUpdatedAt(e.CreatedAt()), + ) + return err + }), nil +} + +func (p *userRelationalProjection) reduceRecoveryCodeCheckFailed(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*user.HumanRecoveryCodeCheckFailedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-mWuV7a", "reduce.wrong.db.pool %T", ex) + } + repo := repository.HumanUserRepository() + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + repo.IncrementRecoveryCodeFailedAttempts(), + repo.SetUpdatedAt(e.CreatedAt()), + ) + return err + }), nil +} + func mapHumanGender(gender old_domain.Gender) domain.HumanGender { switch gender { case old_domain.GenderFemale: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 1f6b42c0a..c85f774b1 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -180,6 +180,7 @@ Errors: Passwordless: NotExisting: "Passkey does not exist" RecoveryCodes: + Empty: "Recovery code is empty" InvalidCode: "Invalid recovery code provided" MaxCountExceeded: "Maximum number of recovery codes exceeded" NotReady: "Recovery codes are not ready for use" @@ -196,10 +197,6 @@ Errors: RefreshToken: Invalid: "Refresh Token is invalid" NotFound: "Refresh Token not found" - Instance: - NotFound: "Instance not found" - AlreadyExists: "Instance already exists" - NotChanged: "Instance not changed" Org: AlreadyExists: "Organisation's name or id already taken" Invalid: "Organisation is invalid" @@ -346,6 +343,8 @@ Errors: NotActive: "Project Grant is not active" NotInactive: "Project Grant is not inactive" Instance: + AlreadyExists: "Instance already exists" + NotChanged: "Instance not changed" NotFound: "Instance not found. Make sure you got the domain right. Check out https://zitadel.com/docs/apis/introduction#domains" Member: RolesNotChanged: "Roles have not been changed"