feat: add recovery codes to user repository (#11702)

# Which Problems Are Solved

Adds a repository implementation to add/remove recovery codes to the
users relational table.

# How the Problems Are Solved

This is achieved by:
* adding the following columns to the users table: `recovery_codes`,
`recovery_code_last_successful_check`, `recovery_code_failed_attempts`
* adding the `RecoveryCodes` field to the `HumanUser` domain with fields
to set recovery `codes`, `lastSuccessfullyCheckedAt` timestamp, and
`failedAttempts`
* setting `recoveryCodes` in the `Get` user query statement to return a
json object with details related to the recovery codes
* adding the repository-layer implementation to add/remove recovery
codes and set fields related to recovery code checks.
* adding projection reducers to handle the following events:
`HumanRecoveryCodesAddedEvent`, `HumanRecoveryCodesRemovedEvent`,
`HumanRecoveryCodeCheckSucceededEvent`, and
`HumanRecoveryCodeCheckFailedEvent`
* adding unit tests

# Additional Changes
* fix the error message when the recovery is empty in
`internal/command/user_human_recovery_codes.go`
* add a new error message for empty recovery code during checks in
`en.yaml`

# Additional Context
- Closes https://github.com/zitadel/zitadel/issues/11212
- Follow-up: integration tests for the reducers will be added in a
different PR after this
[PR](https://github.com/zitadel/zitadel/pull/11478) is merged
This commit is contained in:
Gayathri Vijayan
2026-02-26 19:22:37 +01:00
committed by GitHub
parent 850adc0da4
commit fb35fdd3c7
12 changed files with 502 additions and 16 deletions
+11
View File
@@ -48,6 +48,7 @@ type HumanUser struct {
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:"-"`
}
+21 -2
View File
@@ -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
@@ -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
@@ -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)` +
@@ -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")
}
@@ -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())
})
}
}
@@ -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")
}
@@ -65,7 +65,9 @@ func (v verification) update(update *domain.VerificationTypeUpdate, existingTabl
builder.WriteString(v.qualifiedTableName())
builder.WriteString(" SET ")
err := changes.Write(builder)
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)
if err != nil {
logging.New(logging.StreamRuntime).Debug("write cte failed", "error", err)
}
builder.WriteString(" FROM ")
builder.WriteString(existingTableName)
writeCondition(builder, database.And(
@@ -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 == "" {
@@ -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",
@@ -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:
+3 -4
View File
@@ -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"