Files
zitadel/backend/v3/storage/database/repository/verification.go
T
Gayathri VijayanandGitHub fb35fdd3c7 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
2026-02-26 19:22:37 +01:00

176 lines
6.4 KiB
Go

package repository
import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type verification struct{}
func (v verification) unqualifiedTableName() string {
return "verifications"
}
func (v verification) qualifiedTableName() string {
return "zitadel." + v.unqualifiedTableName()
}
// init creates a new verification
func (v verification) init(init *domain.VerificationTypeInit, existingTableName string, verificationID database.Column) database.CTEChange {
return database.NewCTEChange(func(builder *database.StatementBuilder) {
var (
createdAt any = database.NowInstruction
expiry any = database.NullInstruction
id any = database.GenRandomUUIDInstruction
)
if !init.CreatedAt.IsZero() {
createdAt = init.CreatedAt
}
if init.Expiry != nil {
expiry = *init.Expiry
}
if init.ID != nil {
id = *init.ID
}
builder.WriteString("INSERT INTO zitadel.verifications(instance_id, user_id, id, code, created_at, updated_at, expiry) SELECT instance_id, id, ")
builder.WriteArgs(id, init.Code, createdAt, createdAt, expiry)
builder.WriteString(" FROM ")
builder.WriteString(existingTableName)
builder.WriteString(" RETURNING id")
}, func(name string) database.Change {
return database.NewChangeToStatement(verificationID, func(builder *database.StatementBuilder) {
builder.WriteString("SELECT id FROM ")
builder.WriteString(name)
})
})
}
// updates the given fields of the verification with the given ID. Only non-nil fields get updated.
func (v verification) update(update *domain.VerificationTypeUpdate, existingTableName string, instanceID, verificationID database.Column) database.CTEChange {
changes := make(database.Changes, 0, 3)
if update.Code != nil {
changes = append(changes, database.NewChange(v.codeColumn(), update.Code))
}
if update.Expiry != nil {
changes = append(changes, database.NewChange(v.expiryColumn(), *update.Expiry))
}
if update.UpdatedAt.IsZero() {
changes = append(changes, database.NewChange(v.updatedAtColumn(), database.NowInstruction))
} else {
changes = append(changes, database.NewChange(v.updatedAtColumn(), update.UpdatedAt))
}
return database.NewCTEChange(func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE ")
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(
database.NewColumnCondition(v.instanceIDColumn(), instanceID),
database.NewColumnCondition(v.idColumn(), verificationID),
))
}, nil)
}
// verified constructs the following changes:
// - sets the verifiedAt column to the current time or the provided time
// - clears the pending verification ID
// - deletes the verification
// - resets the failed attempts counter
func (v verification) verified(verified *domain.VerificationTypeSucceeded, existingTableName string, instanceID, pendingVerificationID, verifiedAt, failedAttempts database.Column) database.Changes {
verifiedAtChange := database.NewChange(verifiedAt, database.NowInstruction)
if !verified.VerifiedAt.IsZero() {
verifiedAtChange = database.NewChange(verifiedAt, verified.VerifiedAt)
}
return database.NewChanges(
verifiedAtChange,
database.NewChangeToNull(pendingVerificationID),
database.NewChange(failedAttempts, 0),
database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM ")
builder.WriteString(v.qualifiedTableName())
builder.WriteString(" USING ")
builder.WriteString(existingTableName)
writeCondition(builder, database.And(
database.NewColumnCondition(v.instanceIDColumn(), instanceID),
database.NewColumnCondition(v.idColumn(), pendingVerificationID),
))
}, nil,
),
)
}
// skipped constructs the following changes:
// - sets the verifiedAt column to the current time or the provided time
// - clears the pending verification ID
// - resets the failed attempts counter
func (v verification) skipped(skipped *domain.VerificationTypeSkipped, verifiedAt, pendingVerificationID, failedAttempts database.Column) database.Changes {
verifiedAtChange := database.NewChange(verifiedAt, database.NowInstruction)
if !skipped.SkippedAt.IsZero() {
verifiedAtChange = database.NewChange(verifiedAt, skipped.SkippedAt)
}
return database.NewChanges(
verifiedAtChange,
database.NewChange(failedAttempts, 0),
database.NewChangeToNull(pendingVerificationID),
)
}
// failed increments the failed attempts counter.
func (v verification) failed(existingTableName string, instanceID, verificationID database.Column) database.CTEChange {
return database.NewCTEChange(func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE ")
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(
database.NewColumnCondition(v.instanceIDColumn(), instanceID),
database.NewColumnCondition(v.idColumn(), verificationID),
))
}, nil)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (v verification) instanceIDColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "instance_id")
}
func (v verification) idColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "id")
}
func (v verification) codeColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "code")
}
func (v verification) expiryColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "expiry")
}
func (v verification) failedAttemptsColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "failed_attempts")
}
func (v verification) userIDColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "user_id")
}
func (v verification) updatedAtColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "updated_at")
}