feat: Add recovery code MFA support (#9954)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

One-time recovery codes are a common multi-factor authentication (MFA)
backup method, letting users access their account if they lose other MFA
devices. Support for recovery codes can also reduce support burden for
users locked out of their accounts and provide a more secure and
reliable form of verification than security questions.

# Which Problems Are Solved

Zitadel currently lacks support for recovery codes.

# How the Problems Are Solved

This PR partially addresses recovery code support in Zitadel.
Importantly, it adds recovery codes as a new 2FA `Factor` and an
additional `Check` type for the Session API.

```
Example recovery code flow:

1. User generates N new recovery codes using `POST /v2/users/{user_id}/recovery_codes`
2. Zitadel hashes and stores these codes and returns the un-hashed codes in the response
3. User creates new session with an additional check: `recoveryCode`
4. Code is checked against hash and, if valid, cannot be used again
5. User attempts to adds N more codes using the same endpoint 
6. If `remaining_codes + N <= RecoveryCodes->MaxCount` config value, then recovery codes are added in addition to original codes
7. User can remove all recovery codes using `DEL /v2/users/:userId/recovery_codes` 
```
This PR adds: 
- [x] Session recovery_code check support on `POST+PATCH /v2/sessions`
endpoints
- [x] Adds `mfa_recovery_code_checked_at` column (default null) to
`projections.sessions8` table
- [x] Support for `SECOND_FACTOR_TYPE_RECOVERY_CODES` as available 2FA
method on login policy
- [x] Support for importing recovery codes in /import code

Missing, will _not_ implement in this PR:
- [ ] Admin console support for displaying Recovery Code settings for
user(s)
- [ ] Zitadel Typescript login support for recovery codes 

TODO: 
- [x] Additional unit and integration tests
- [x] Error translations

# Additional Changes

None

# Additional Context

- Closes #6898

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Zach Hirschtritt
2025-11-24 06:44:48 +00:00
committed by GitHub
co-authored by Livio Spring
parent afa92e55f7
commit 5beeb5738a
101 changed files with 3397 additions and 169 deletions
+9
View File
@@ -740,6 +740,15 @@ SystemDefaults:
# If this is empty, the issuer is the requested domain
# This is helpful in scenarios with multiple ZITADEL environments or virtual instances
Issuer: "ZITADEL" # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_OTP_ISSUER
RecoveryCodes:
# The maximum number of recovery codes that a user can hold at any given time
MaxCount: 10 # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_MAXCOUNT
# The format of the recovery codes, supported: uuid, alphanumeric
Format: alphanumeric # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_FORMAT
# Length of the recovery codes (only applies to alphanumeric format)
Length: 10 # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_LENGTH
# Whether to include hyphens in the recovery codes (alphanumeric: hyphen in middle, uuid: keep/remove all hyphens)
WithHyphen: true # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_WITHHYPHEN
Tarpit:
# The amount of failed attempts, the tarpit should start.
MinFailedAttempts: 5 # ZITADEL_SYSTEMDEFAULTS_TARPIT_MINFAILEDATTEMPTS
+1 -1
View File
@@ -1,3 +1,3 @@
ALTER INDEX IF EXISTS projections.user_metadata5_metadata_key_idx
RENAME TO user_metadata5_key_idx;
DROP INDEX IF EXISTS projections.user_metadata5_metadata_value_idx;
DROP INDEX IF EXISTS projections.user_metadata5_metadata_value_idx;
+27
View File
@@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 66.sql
addSessionRecoveryCodeCheckedAt string
)
type SessionRecoveryCodeCheckedAt struct {
dbClient *database.DB
}
func (mig *SessionRecoveryCodeCheckedAt) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addSessionRecoveryCodeCheckedAt)
return err
}
func (mig *SessionRecoveryCodeCheckedAt) String() string {
return "66_session_recovery_code_checked_at"
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE IF EXISTS projections.sessions8
ADD COLUMN IF NOT EXISTS mfa_recovery_code_checked_at TIMESTAMPTZ;
+1
View File
@@ -162,6 +162,7 @@ type Steps struct {
s63AlterResourceCounts *AlterResourceCounts
s64ChangePushPosition *ChangePushPosition
s65FixUserMetadata5Index *FixUserMetadata5Index
s66SessionRecoveryCodeCheckedAt *SessionRecoveryCodeCheckedAt
}
func MustNewSteps(v *viper.Viper) *Steps {
+2
View File
@@ -223,6 +223,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s63AlterResourceCounts = &AlterResourceCounts{dbClient: dbClient}
steps.s64ChangePushPosition = &ChangePushPosition{dbClient: dbClient}
steps.s65FixUserMetadata5Index = &FixUserMetadata5Index{dbClient: dbClient}
steps.s66SessionRecoveryCodeCheckedAt = &SessionRecoveryCodeCheckedAt{dbClient: dbClient}
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
logging.OnError(err).Fatal("unable to start projections")
@@ -338,6 +339,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s43CreateFieldsDomainIndex,
steps.s48Apps7SAMLConfigsLoginVersion,
steps.s59SetupWebkeys, // this step needs commands.
steps.s66SessionRecoveryCodeCheckedAt,
} {
setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed")
if setupErr != nil {
+31 -23
View File
@@ -94,6 +94,36 @@ type QuotasConfig struct {
}
func MustNewConfig(v *viper.Viper) *Config {
config, err := readConfig(v)
logging.OnError(err).Fatal("unable to read config")
err = config.Log.SetLogger()
logging.OnError(err).Fatal("unable to set logger")
err = config.Tracing.NewTracer()
logging.OnError(err).Fatal("unable to set tracer")
err = config.Metrics.NewMeter()
logging.OnError(err).Fatal("unable to set meter")
err = config.Profiler.NewProfiler()
logging.OnError(err).Fatal("unable to set profiler")
id.Configure(config.Machine)
if config.Actions != nil {
actions.SetHTTPConfig(&config.Actions.HTTP)
}
err = config.SystemDefaults.Validate()
logging.OnError(err).Fatal("system defaults config invalid")
// Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API.
config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings
return config
}
func readConfig(v *viper.Viper) (*Config, error) {
config := new(Config)
err := v.Unmarshal(config,
@@ -115,27 +145,5 @@ func MustNewConfig(v *viper.Viper) *Config {
mapstructure.TextUnmarshallerHookFunc(),
)),
)
logging.OnError(err).Fatal("unable to read config")
err = config.Log.SetLogger()
logging.OnError(err).Fatal("unable to set logger")
err = config.Tracing.NewTracer()
logging.OnError(err).Fatal("unable to set tracer")
err = config.Metrics.NewMeter()
logging.OnError(err).Fatal("unable to set meter")
err = config.Profiler.NewProfiler()
logging.OnError(err).Fatal("unable to set profiler")
id.Configure(config.Machine)
if config.Actions != nil {
actions.SetHTTPConfig(&config.Actions.HTTP)
}
// Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API.
config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings
return config
return config, err
}
+3 -2
View File
@@ -19,7 +19,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
)
func TestMustNewConfig(t *testing.T) {
func Test_readConfig(t *testing.T) {
encodedKey := "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
decodedKey, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil {
@@ -278,7 +278,8 @@ Actions:
v := viper.New()
v.SetConfigType("yaml")
require.NoError(t, v.ReadConfig(strings.NewReader(tt.args.yaml)))
got := MustNewConfig(v)
got, err := readConfig(v)
require.NoError(t, err)
tt.want(t, got)
})
}