mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user