From 5beeb5738a9be3cedb64e7820c6bad75ab624c2e Mon Sep 17 00:00:00 2001 From: Zach Hirschtritt Date: Mon, 24 Nov 2025 01:44:48 -0500 Subject: [PATCH] feat: Add recovery code MFA support (#9954) 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 --- cmd/defaults.yaml | 9 + cmd/setup/65.sql | 2 +- cmd/setup/66.go | 27 + cmd/setup/66.sql | 2 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + cmd/start/config.go | 54 +- cmd/start/config_test.go | 5 +- .../factor-table/factor-table.component.ts | 1 + console/src/assets/i18n/bg.json | 3 +- console/src/assets/i18n/cs.json | 3 +- console/src/assets/i18n/de.json | 3 +- console/src/assets/i18n/en.json | 3 +- console/src/assets/i18n/es.json | 3 +- console/src/assets/i18n/fr.json | 3 +- console/src/assets/i18n/hu.json | 3 +- console/src/assets/i18n/id.json | 3 +- console/src/assets/i18n/it.json | 3 +- console/src/assets/i18n/ja.json | 3 +- console/src/assets/i18n/ko.json | 3 +- console/src/assets/i18n/mk.json | 3 +- console/src/assets/i18n/nl.json | 3 +- console/src/assets/i18n/pl.json | 3 +- console/src/assets/i18n/pt.json | 3 +- console/src/assets/i18n/ro.json | 3 +- console/src/assets/i18n/ru.json | 3 +- console/src/assets/i18n/sv.json | 3 +- console/src/assets/i18n/tr.json | 3 +- console/src/assets/i18n/zh.json | 3 +- internal/api/grpc/admin/import.go | 19 + .../admin/integration_test/import_test.go | 174 +++ internal/api/grpc/policy/auth_factor.go | 4 + .../v2/integration_test/session_test.go | 34 + internal/api/grpc/session/v2/query.go | 24 +- internal/api/grpc/session/v2/session.go | 3 + internal/api/grpc/session/v2/session_test.go | 39 + .../grpc/settings/v2/settings_converter.go | 2 + .../settings/v2/settings_converter_test.go | 6 + internal/api/grpc/user/v2/recovery_codes.go | 29 + internal/api/grpc/user/v2/user.go | 2 + internal/api/oidc/amr.go | 3 +- internal/api/ui/login/custom_action.go | 1 + .../api/ui/login/mfa_verify_otp_handler.go | 4 +- internal/auth/repository/auth_request.go | 1 + .../eventsourcing/eventstore/auth_request.go | 14 + .../eventsourcing/handler/user_session.go | 32 +- .../eventstore/token_verifier.go | 3 + internal/command/command.go | 6 + internal/command/session.go | 4 + internal/command/session_model.go | 46 +- internal/command/session_model_test.go | 60 +- internal/command/session_recovery_codes.go | 28 + internal/command/session_test.go | 189 +++ .../command/user_human_recovery_code_model.go | 90 ++ .../user_human_recovery_code_model_test.go | 383 ++++++ internal/command/user_human_recovery_codes.go | 201 ++++ .../command/user_human_recovery_codes_test.go | 1037 +++++++++++++++++ .../config/systemdefaults/system_defaults.go | 37 +- internal/domain/auth_request.go | 3 + internal/domain/factors.go | 1 + internal/domain/human_recovery_codes.go | 136 +++ internal/domain/mfa.go | 21 +- internal/domain/user.go | 3 + internal/query/projection/session.go | 26 + internal/query/projection/user_auth_method.go | 17 +- .../query/projection/user_auth_method_test.go | 65 ++ internal/query/session.go | 117 +- internal/query/sessions_test.go | 20 + internal/repository/session/eventstore.go | 1 + internal/repository/session/session.go | 70 +- internal/repository/user/eventstore.go | 4 + internal/repository/user/human_mfa_otp.go | 28 +- .../user/human_mfa_recovery_codes.go | 149 +++ internal/static/i18n/bg.yaml | 6 + internal/static/i18n/cs.yaml | 6 + internal/static/i18n/de.yaml | 6 + internal/static/i18n/en.yaml | 6 + internal/static/i18n/es.yaml | 6 + internal/static/i18n/fr.yaml | 6 + internal/static/i18n/hu.yaml | 6 + internal/static/i18n/id.yaml | 6 + internal/static/i18n/it.yaml | 6 + internal/static/i18n/ja.yaml | 6 + internal/static/i18n/ko.yaml | 6 + internal/static/i18n/mk.yaml | 6 + internal/static/i18n/nl.yaml | 6 + internal/static/i18n/pl.yaml | 6 + internal/static/i18n/pt.yaml | 6 + internal/static/i18n/ro.yaml | 6 + internal/static/i18n/ru.yaml | 6 + internal/static/i18n/sv.yaml | 6 + internal/static/i18n/tr.yaml | 6 + internal/static/i18n/zh.yaml | 6 + internal/user/model/otp.go | 1 + internal/user/repository/view/user_by_id.sql | 1 + proto/zitadel/management.proto | 29 + proto/zitadel/policy.proto | 1 + proto/zitadel/session/v2/session.proto | 6 + .../zitadel/session/v2/session_service.proto | 20 + .../zitadel/settings/v2/login_settings.proto | 1 + proto/zitadel/user/v2/user_service.proto | 88 ++ 101 files changed, 3397 insertions(+), 169 deletions(-) create mode 100644 cmd/setup/66.go create mode 100644 cmd/setup/66.sql create mode 100644 internal/api/grpc/user/v2/recovery_codes.go create mode 100644 internal/command/session_recovery_codes.go create mode 100644 internal/command/user_human_recovery_code_model.go create mode 100644 internal/command/user_human_recovery_code_model_test.go create mode 100644 internal/command/user_human_recovery_codes.go create mode 100644 internal/command/user_human_recovery_codes_test.go create mode 100644 internal/domain/human_recovery_codes.go create mode 100644 internal/repository/user/human_mfa_recovery_codes.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 7cfeefa46..5ce3e078b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -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 diff --git a/cmd/setup/65.sql b/cmd/setup/65.sql index 910d524f2..21c7d23cf 100644 --- a/cmd/setup/65.sql +++ b/cmd/setup/65.sql @@ -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; \ No newline at end of file +DROP INDEX IF EXISTS projections.user_metadata5_metadata_value_idx; diff --git a/cmd/setup/66.go b/cmd/setup/66.go new file mode 100644 index 000000000..04091561f --- /dev/null +++ b/cmd/setup/66.go @@ -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" +} diff --git a/cmd/setup/66.sql b/cmd/setup/66.sql new file mode 100644 index 000000000..c82ec69c2 --- /dev/null +++ b/cmd/setup/66.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS projections.sessions8 +ADD COLUMN IF NOT EXISTS mfa_recovery_code_checked_at TIMESTAMPTZ; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index f906db624..ec22c5fd4 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -162,6 +162,7 @@ type Steps struct { s63AlterResourceCounts *AlterResourceCounts s64ChangePushPosition *ChangePushPosition s65FixUserMetadata5Index *FixUserMetadata5Index + s66SessionRecoveryCodeCheckedAt *SessionRecoveryCodeCheckedAt } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index ed56ad304..e5bcd1268 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -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 { diff --git a/cmd/start/config.go b/cmd/start/config.go index c680bf7c0..d8a7b4089 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -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 } diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index 3fd482728..17dcd1ceb 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -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) }) } diff --git a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts index 17a4e3bd7..89f5422cb 100644 --- a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts +++ b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts @@ -159,6 +159,7 @@ export class FactorTableComponent { SecondFactorType.SECOND_FACTOR_TYPE_OTP, SecondFactorType.SECOND_FACTOR_TYPE_OTP_SMS, SecondFactorType.SECOND_FACTOR_TYPE_OTP_EMAIL, + SecondFactorType.SECOND_FACTOR_TYPE_RECOVERY_CODES, ] : []; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index cf3e0dc71..1dda94c29 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -2396,7 +2396,8 @@ "1": "Еднократна парола чрез приложение за удостоверяване на автентичността (TOTP)", "2": "Пръстов отпечатък, ключове за сигурност, Face ID и други", "3": "Еднократна парола по имейл (Email OTP)", - "4": "Еднократна парола чрез SMS (SMS OTP)" + "4": "Еднократна парола чрез SMS (SMS OTP)", + "5": "Кодове за възстановяване" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 1234a58c6..57babe228 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -2410,7 +2410,8 @@ "1": "Jednorázové heslo pomocí autentizační aplikace (TOTP)", "2": "Otisk prstu, bezpečnostní klíče, Face ID a další", "3": "Jednorázové heslo e-mailem (Email OTP)", - "4": "Jednorázové heslo SMS (SMS OTP)" + "4": "Jednorázové heslo SMS (SMS OTP)", + "5": "Obnovovací kódy" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 3f6b4d10b..c60793884 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -2401,7 +2401,8 @@ "1": "One Time Password per Authenticator App (TOTP)", "2": "Fingerabdruck, Security Keys, Face ID und andere", "3": "One Time Password per Email (Email OTP)", - "4": "One Time Password per SMS (SMS OTP)" + "4": "One Time Password per SMS (SMS OTP)", + "5": "Wiederherstellungscodes" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 787a5b656..6f974c178 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -2427,7 +2427,8 @@ "1": "One Time Password by Authenticator App (TOTP)", "2": "Fingerprint, Security Keys, Face ID and other", "3": "One Time Password by Email (Email OTP)", - "4": "One Time Password by SMS (SMS OTP)" + "4": "One Time Password by SMS (SMS OTP)", + "5": "Recovery Codes" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 65d947e41..c95dd50aa 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -2398,7 +2398,8 @@ "1": "One Time Password por Authenticator App (TOTP)", "2": "Huella dactilar, claves de seguridad, Face ID y otros", "3": "One Time Password por email (Email OTP)", - "4": "One Time Password por SMS (SMS OTP)" + "4": "One Time Password por SMS (SMS OTP)", + "5": "Códigos de recuperación" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 6d4da801e..bc97107f5 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -2402,7 +2402,8 @@ "1": "One Time Password via une application d'authentification (TOTP)", "2": "Empreinte digitale, clés de sécurité, Face ID et autres", "3": "One Time Password par e-mail (E-mail OTP)", - "4": "One Time Password par SMS (SMS OTP)" + "4": "One Time Password par SMS (SMS OTP)", + "5": "Codes de récupération" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 2c868a487..a7d779e04 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -2421,7 +2421,8 @@ "1": "Egyszeri Jelszó az Authenticator App-al (TOTP)", "2": "Ujjlenyomat, Biztonsági Kulcsok, Face ID és egyéb", "3": "Egyszeri Jelszó Emailben (Email OTP)", - "4": "Egyszeri jelszó SMS-ben (SMS OTP)" + "4": "Egyszeri jelszó SMS-ben (SMS OTP)", + "5": "Helyreállítási kódok" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 71f2e996a..be74755e9 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -2193,7 +2193,8 @@ "1": "Kata Sandi Sekali Pakai oleh Aplikasi Authenticator (TOTP)", "2": "Sidik Jari, Kunci Keamanan, ID Wajah dan lainnya", "3": "Kata Sandi Satu Kali melalui Email (Email OTP)", - "4": "Kata Sandi Satu Kali melalui SMS (SMS OTP)" + "4": "Kata Sandi Satu Kali melalui SMS (SMS OTP)", + "5": "Kode Pemulihan" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 6f5ea0f3b..0743152c5 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -2402,7 +2402,8 @@ "1": "One Time Password per Authenticator App (TOTP)", "2": "Impronta digitale, chiave di sicurezza, Face ID e altri", "3": "One Time Password per Email (Email OTP)", - "4": "One Time Password per SMS (SMS OTP)" + "4": "One Time Password per SMS (SMS OTP)", + "5": "Codici di recupero" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 1f164d40a..e8bb87798 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -2423,7 +2423,8 @@ "1": "認証アプリ用ワンタイムパスワード(TOTP)", "2": "指紋、セキュリティキー、フェイスIDなど", "3": "Eメール用ワンタイムパスワード(email OTP)", - "4": "SMS用ワンタイムパスワード(SMS OTP)" + "4": "SMS用ワンタイムパスワード(SMS OTP)", + "5": "リカバリーコード" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index 025fa3653..3ce453aac 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -2423,7 +2423,8 @@ "1": "인증 앱을 통한 일회성 비밀번호(TOTP)", "2": "지문, 보안 키, Face ID 및 기타", "3": "이메일을 통한 일회성 비밀번호(이메일 OTP)", - "4": "SMS를 통한 일회성 비밀번호(SMS OTP)" + "4": "SMS를 통한 일회성 비밀번호(SMS OTP)", + "5": "복구 코드" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 012efbdd2..185b311a1 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -2398,7 +2398,8 @@ "1": "Еднократна лозинка преку апликација за автентикатор (TOTP)", "2": "Отпечаток на прст, безбедносни клучеви, Face ID и други", "3": "Еднократна лозинка по е-пошта (Еmail OTP)", - "4": "Еднократна лозинка преку СМС (SMS OTP)" + "4": "Еднократна лозинка преку СМС (SMS OTP)", + "5": "Кодови за обновување" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index d331a6c4a..2c136da7e 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -2420,7 +2420,8 @@ "1": "Eenmalig wachtwoord door Authenticator App (TOTP)", "2": "Vingerafdruk, Beveiligingssleutels, Face ID en andere", "3": "Eenmalig wachtwoord per e-mail (Email OTP)", - "4": "Eenmalig wachtwoord per SMS (SMS OTP)" + "4": "Eenmalig wachtwoord per SMS (SMS OTP)", + "5": "Herstelcodes" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 00f2c3fc7..20f5afa9b 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -2401,7 +2401,8 @@ "1": "Hasło jednorazowe dla aplikacji uwierzytelniającej (TOTP)", "2": "Odcisk palca, Klucze Bezpieczeństwa, Face ID i inne", "3": "Hasło jednorazowe dla wiadomości e-mail (Email OTP)", - "4": "Hasło jednorazowe dla wiadomości SMS (SMS OTP)" + "4": "Hasło jednorazowe dla wiadomości SMS (SMS OTP)", + "5": "Kody odzyskiwania" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index a070280c8..920c3d606 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -2397,7 +2397,8 @@ "1": "Senha de uso único para o aplicativo autenticador (TOTP)", "2": "Impressão digital, Chaves de Segurança, Face ID e outros", "3": "Senha de uso único para e-mail (Email OTP)", - "4": "Senha de uso único para SMS (SMS OTP)" + "4": "Senha de uso único para SMS (SMS OTP)", + "5": "Códigos de recuperação" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index 608b69892..77d561dbf 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -2419,7 +2419,8 @@ "1": "Parolă unică după aplicația de autentificare (TOTP)", "2": "Amprentă, chei de securitate, Face ID și altele", "3": "Parolă unică prin e-mail (OTP e-mail)", - "4": "Parolă unică prin SMS (OTP SMS)" + "4": "Parolă unică prin SMS (OTP SMS)", + "5": "Coduri de recuperare" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 5351ad642..2bab06672 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -2514,7 +2514,8 @@ "1": "Одноразовый пароль (OTP)", "2": "Отпечаток пальца, ключи безопасности, Face ID и другие", "3": "Одноразовый пароль по электронной почте", - "4": "Одноразовый пароль по SMS" + "4": "Одноразовый пароль по SMS", + "5": "Коды восстановления" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 1d00dad98..af5c0c62f 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -2426,7 +2426,8 @@ "1": "Engångslösenord via autentiseringsapp (TOTP)", "2": "Fingeravtryck, säkerhetsnycklar, Face ID och andra", "3": "Engångslösenord via e-post (E-post OTP)", - "4": "Engångslösenord via SMS (SMS OTP)" + "4": "Engångslösenord via SMS (SMS OTP)", + "5": "Återställningskoder" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/tr.json b/console/src/assets/i18n/tr.json index 6f99cc0ad..17c3a3900 100644 --- a/console/src/assets/i18n/tr.json +++ b/console/src/assets/i18n/tr.json @@ -2426,7 +2426,8 @@ "1": "Kimlik Doğrulayıcı Uygulama ile Tek Kullanımlık Şifre (TOTP)", "2": "Parmak izi, Güvenlik Anahtarları, Face ID ve diğerleri", "3": "E-posta ile Tek Kullanımlık Şifre (Email OTP)", - "4": "SMS ile Tek Kullanımlık Şifre (SMS OTP)" + "4": "SMS ile Tek Kullanımlık Şifre (SMS OTP)", + "5": "Kurtarma Kodları" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index f2320f77c..4e23c3497 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -2430,7 +2430,8 @@ "1": "身份验证应用程序的一次性密码(TOTP)", "2": "指纹、安全密钥、Face ID 等", "3": "电子邮件一次性密码(email OTP)", - "4": "短信一次性密码(SMS OTP)" + "4": "短信一次性密码(SMS OTP)", + "5": "恢复码" } }, "LOGINPOLICY": { diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 67802777e..42ff65bff 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -504,6 +504,25 @@ func importHumanUsers(ctx context.Context, s *Server, errors *[]*admin_pb.Import logging.Debugf("successful user otp: %s", user.GetUserId()) } } + + if len(user.User.RecoveryCodes) > 0 { + logging.Debugf("import user recovery codes: %s", user.GetUserId()) + recoveryCodes := make([]domain.ImportHumanRecoveryCode, len(user.User.RecoveryCodes)) + for i, code := range user.User.RecoveryCodes { + recoveryCodes[i] = domain.ImportHumanRecoveryCode{ + RawCode: code.GetRaw(), + HashedCode: code.GetHash(), + } + } + if err := s.command.ImportHumanRecoveryCodes(ctx, user.UserId, org.GetOrgId(), recoveryCodes); err != nil { + *errors = append(*errors, &admin_pb.ImportDataError{Type: "human_user_recovery_codes", Id: user.GetUserId(), Message: err.Error()}) + if isCtxTimeout(ctx) { + return err + } + } else { + logging.Debugf("successful user recovery codes: %s", user.GetUserId()) + } + } } return nil } diff --git a/internal/api/grpc/admin/integration_test/import_test.go b/internal/api/grpc/admin/integration_test/import_test.go index 26f3d483a..443fa6b68 100644 --- a/internal/api/grpc/admin/integration_test/import_test.go +++ b/internal/api/grpc/admin/integration_test/import_test.go @@ -491,6 +491,180 @@ func TestServer_ImportData(t *testing.T) { }, }, }, + { + name: "success with recovery codes", + req: &admin.ImportDataRequest{ + Data: &admin.ImportDataRequest_DataOrgs{ + DataOrgs: &admin.ImportDataOrg{ + Orgs: []*admin.DataOrg{ + { + OrgId: orgIDs[7], + Org: &management.AddOrgRequest{ + Name: integration.OrganizationName(), + }, + HumanUsers: []*v1.DataHumanUser{ + { + UserId: userIDs[3], + User: &management.ImportHumanUserRequest{ + UserName: integration.Username(), + Profile: &management.ImportHumanUserRequest_Profile{ + FirstName: integration.FirstName(), + LastName: integration.LastName(), + DisplayName: integration.Username(), + PreferredLanguage: integration.Language(), + }, + Email: &management.ImportHumanUserRequest_Email{ + Email: integration.Email(), + IsEmailVerified: true, + }, + RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{ + {CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-001"}}, + {CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-002"}}, + {CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-003"}}, + }, + }, + }, + { + UserId: userIDs[4], + User: &management.ImportHumanUserRequest{ + UserName: integration.Username(), + Profile: &management.ImportHumanUserRequest_Profile{ + FirstName: integration.FirstName(), + LastName: integration.LastName(), + DisplayName: integration.Username(), + PreferredLanguage: integration.Language(), + }, + Email: &management.ImportHumanUserRequest_Email{ + Email: integration.Email(), + IsEmailVerified: true, + }, + RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{ + {CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-101"}}, + {CodeType: &management.ImportHumanUserRequest_RecoveryCode_Hash{Hash: "$2a$12$3UWLT4aUQsgO/Jn/rydRmuqF39JTXox6G4dwea/UOvbstI/Qba20y"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Timeout: (5 * time.Minute).String(), + }, + want: &admin.ImportDataResponse{ + Success: &admin.ImportDataSuccess{ + Orgs: []*admin.ImportDataSuccessOrg{ + { + OrgId: orgIDs[7], + HumanUserIds: userIDs[3:5], + }, + }, + }, + }, + }, + { + name: "empty recovery codes", + req: &admin.ImportDataRequest{ + Data: &admin.ImportDataRequest_DataOrgs{ + DataOrgs: &admin.ImportDataOrg{ + Orgs: []*admin.DataOrg{ + { + OrgId: orgIDs[8], + Org: &management.AddOrgRequest{ + Name: integration.OrganizationName(), + }, + HumanUsers: []*v1.DataHumanUser{ + { + UserId: userIDs[5], + User: &management.ImportHumanUserRequest{ + UserName: integration.Username(), + Profile: &management.ImportHumanUserRequest_Profile{ + FirstName: integration.FirstName(), + LastName: integration.LastName(), + DisplayName: integration.Username(), + PreferredLanguage: integration.Language(), + }, + Email: &management.ImportHumanUserRequest_Email{ + Email: integration.Email(), + IsEmailVerified: true, + }, + RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{}, + }, + }, + }, + }, + }, + }, + }, + Timeout: time.Minute.String(), + }, + want: &admin.ImportDataResponse{ + Success: &admin.ImportDataSuccess{ + Orgs: []*admin.ImportDataSuccessOrg{ + { + OrgId: orgIDs[8], + HumanUserIds: userIDs[5:6], + }, + }, + }, + }, + }, + { + name: "invalid recovery code hash", + req: &admin.ImportDataRequest{ + Data: &admin.ImportDataRequest_DataOrgs{ + DataOrgs: &admin.ImportDataOrg{ + Orgs: []*admin.DataOrg{ + { + OrgId: orgIDs[9], + Org: &management.AddOrgRequest{ + Name: integration.OrganizationName(), + }, + HumanUsers: []*v1.DataHumanUser{ + { + UserId: userIDs[6], + User: &management.ImportHumanUserRequest{ + UserName: integration.Username(), + Profile: &management.ImportHumanUserRequest_Profile{ + FirstName: integration.FirstName(), + LastName: integration.LastName(), + DisplayName: integration.Username(), + PreferredLanguage: integration.Language(), + }, + Email: &management.ImportHumanUserRequest_Email{ + Email: integration.Email(), + IsEmailVerified: true, + }, + RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{ + {CodeType: &management.ImportHumanUserRequest_RecoveryCode_Hash{Hash: "invalid-hash"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Timeout: time.Minute.String(), + }, + want: &admin.ImportDataResponse{ + Success: &admin.ImportDataSuccess{ + Orgs: []*admin.ImportDataSuccessOrg{ + { + OrgId: orgIDs[9], + HumanUserIds: userIDs[6:7], + }, + }, + }, + Errors: []*admin.ImportDataError{ + { + Type: "human_user_recovery_codes", + Id: userIDs[6], + Message: "ID=DOMAIN-JDk4t Message=Errors.Hash.NotSupported", + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/grpc/policy/auth_factor.go b/internal/api/grpc/policy/auth_factor.go index e12eabfd9..f785c2e20 100644 --- a/internal/api/grpc/policy/auth_factor.go +++ b/internal/api/grpc/policy/auth_factor.go @@ -23,6 +23,8 @@ func SecondFactorTypeToDomain(secondFactorType policy_pb.SecondFactorType) domai return domain.SecondFactorTypeOTPEmail case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS: return domain.SecondFactorTypeOTPSMS + case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES: + return domain.SecondFactorTypeRecoveryCodes default: return domain.SecondFactorTypeUnspecified } @@ -46,6 +48,8 @@ func ModelSecondFactorTypeToPb(secondFactorType domain.SecondFactorType) policy_ return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL case domain.SecondFactorTypeOTPSMS: return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS + case domain.SecondFactorTypeRecoveryCodes: + return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES default: return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED } diff --git a/internal/api/grpc/session/v2/integration_test/session_test.go b/internal/api/grpc/session/v2/integration_test/session_test.go index 5cfbab97b..2a8bbf65f 100644 --- a/internal/api/grpc/session/v2/integration_test/session_test.go +++ b/internal/api/grpc/session/v2/integration_test/session_test.go @@ -80,6 +80,7 @@ const ( wantIntentFactor wantOTPSMSFactor wantOTPEmailFactor + wantRecoveryCodeFactor ) func verifyFactors(t assert.TestingT, factors *session.Factors, creationDate, changeDate *timestamppb.Timestamp, userID string, want []wantFactor) { @@ -122,6 +123,10 @@ func verifyFactors(t assert.TestingT, factors *session.Factors, creationDate, ch pf := factors.GetOtpEmail() assert.NotNil(t, pf) assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), creationDateWithSkew, changeDateWithSkew) + case wantRecoveryCodeFactor: + pf := factors.GetRecoveryCode() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), creationDateWithSkew, changeDateWithSkew) } } } @@ -555,6 +560,19 @@ func registerOTPEmail(ctx context.Context, t *testing.T, userID string) { require.NoError(t, err) } +func registerRecoveryCode(ctx context.Context, t *testing.T, userID string) []string { + _, err := Instance.Client.UserV2.RemoveRecoveryCodes(ctx, &user.RemoveRecoveryCodesRequest{ + UserId: userID, + }) + require.NoError(t, err) + recoveryCodes, err := Instance.Client.UserV2.GenerateRecoveryCodes(ctx, &user.GenerateRecoveryCodesRequest{ + UserId: userID, + Count: 5, + }) + require.NoError(t, err) + return recoveryCodes.GetRecoveryCodes() +} + func TestServer_SetSession_flow_totp(t *testing.T) { userExisting := createFullUser(CTX) @@ -728,6 +746,8 @@ func TestServer_SetSession_flow(t *testing.T) { totpSecret := registerTOTP(userAuthCtx, t, User.GetUserId()) registerOTPSMS(userAuthCtx, t, User.GetUserId()) registerOTPEmail(userAuthCtx, t, User.GetUserId()) + recoveryCodes := registerRecoveryCode(userAuthCtx, t, User.GetUserId()) + recoveryCode := recoveryCodes[0] t.Run("check webauthn, user not verified (U2F)", func(t *testing.T) { @@ -838,6 +858,20 @@ func TestServer_SetSession_flow(t *testing.T) { sessionToken = resp.GetSessionToken() verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), createResp.GetDetails().GetChangeDate(), resp.GetDetails().GetChangeDate(), nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantOTPEmailFactor) }) + + t.Run("check Recovery Code", func(t *testing.T) { + resp, err := Client.SetSession(LoginCTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + RecoveryCode: &session.CheckRecoveryCode{ + Code: recoveryCode, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), createResp.GetDetails().GetChangeDate(), resp.GetDetails().GetChangeDate(), nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantRecoveryCodeFactor) + }) } func TestServer_SetSession_expired(t *testing.T) { diff --git a/internal/api/grpc/session/v2/query.go b/internal/api/grpc/session/v2/query.go index 4301cfb05..f704157a6 100644 --- a/internal/api/grpc/session/v2/query.go +++ b/internal/api/grpc/session/v2/query.go @@ -219,13 +219,14 @@ func factorsToPb(s *query.Session) *session.Factors { return nil } return &session.Factors{ - User: user, - Password: passwordFactorToPb(s.PasswordFactor), - WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), - Intent: intentFactorToPb(s.IntentFactor), - Totp: totpFactorToPb(s.TOTPFactor), - OtpSms: otpFactorToPb(s.OTPSMSFactor), - OtpEmail: otpFactorToPb(s.OTPEmailFactor), + User: user, + Password: passwordFactorToPb(s.PasswordFactor), + WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), + Intent: intentFactorToPb(s.IntentFactor), + Totp: totpFactorToPb(s.TOTPFactor), + OtpSms: otpFactorToPb(s.OTPSMSFactor), + OtpEmail: otpFactorToPb(s.OTPEmailFactor), + RecoveryCode: recoveryCodeFactorToPb(s.RecoveryCodeFactor), } } @@ -275,6 +276,15 @@ func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor { } } +func recoveryCodeFactorToPb(factor query.SessionRecoveryCodeFactor) *session.RecoveryCodeFactor { + if factor.RecoveryCodeCheckedAt.IsZero() { + return nil + } + return &session.RecoveryCodeFactor{ + VerifiedAt: timestamppb.New(factor.RecoveryCodeCheckedAt), + } +} + func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { if factor.UserID == "" || factor.UserCheckedAt.IsZero() { return nil diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 94f686a72..742693108 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -145,6 +145,9 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([ if otp := checks.GetOtpEmail(); otp != nil { sessionChecks = append(sessionChecks, command.CheckOTPEmail(otp.GetCode())) } + if recoveryCode := checks.GetRecoveryCode(); recoveryCode != nil { + sessionChecks = append(sessionChecks, command.CheckRecoveryCode(recoveryCode.GetCode())) + } return sessionChecks, nil } diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index 3015b9655..1382ff916 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -127,6 +127,26 @@ func Test_sessionsToPb(t *testing.T) { }, Metadata: map[string][]byte{"hello": []byte("world")}, }, + { // recovery code factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + UserFactor: query.SessionUserFactor{ + UserID: "345", + UserCheckedAt: past, + LoginName: "donald", + DisplayName: "donald duck", + ResourceOwner: "org1", + }, + RecoveryCodeFactor: query.SessionRecoveryCodeFactor{ + RecoveryCodeCheckedAt: past, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, } want := []*session.Session{ @@ -221,6 +241,25 @@ func Test_sessionsToPb(t *testing.T) { }, Metadata: map[string][]byte{"hello": []byte("world")}, }, + { // recovery code factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: &session.Factors{ + User: &session.UserFactor{ + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + OrganizationId: "org1", + }, + RecoveryCode: &session.RecoveryCodeFactor{ + VerifiedAt: timestamppb.New(past), + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, } out := sessionsToPb(sessions) diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go index 3d52bd276..061f6cbc9 100644 --- a/internal/api/grpc/settings/v2/settings_converter.go +++ b/internal/api/grpc/settings/v2/settings_converter.go @@ -76,6 +76,8 @@ func secondFactorTypeToPb(secondFactorType domain.SecondFactorType) settings.Sec return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL case domain.SecondFactorTypeOTPSMS: return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS + case domain.SecondFactorTypeRecoveryCodes: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES case domain.SecondFactorTypeUnspecified: return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED default: diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go index f365c0ea2..d459c51b2 100644 --- a/internal/api/grpc/settings/v2/settings_converter_test.go +++ b/internal/api/grpc/settings/v2/settings_converter_test.go @@ -46,6 +46,7 @@ func Test_loginSettingsToPb(t *testing.T) { domain.SecondFactorTypeU2F, domain.SecondFactorTypeOTPEmail, domain.SecondFactorTypeOTPSMS, + domain.SecondFactorTypeRecoveryCodes, }, MultiFactors: []domain.MultiFactorType{ domain.MultiFactorTypeU2FWithPIN, @@ -77,6 +78,7 @@ func Test_loginSettingsToPb(t *testing.T) { settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F, settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL, settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + settings.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES, }, MultiFactors: []settings.MultiFactorType{ settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, @@ -182,6 +184,10 @@ func Test_secondFactorTypeToPb(t *testing.T) { args: args{99}, want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED, }, + { + args: args{domain.SecondFactorTypeRecoveryCodes}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES, + }, } for _, tt := range tests { t.Run(tt.want.String(), func(t *testing.T) { diff --git a/internal/api/grpc/user/v2/recovery_codes.go b/internal/api/grpc/user/v2/recovery_codes.go new file mode 100644 index 000000000..2ec388b31 --- /dev/null +++ b/internal/api/grpc/user/v2/recovery_codes.go @@ -0,0 +1,29 @@ +package user + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) GenerateRecoveryCodes(ctx context.Context, req *connect.Request[user.GenerateRecoveryCodesRequest]) (*connect.Response[user.GenerateRecoveryCodesResponse], error) { + details, err := s.command.GenerateRecoveryCodes(ctx, req.Msg.GetUserId(), int(req.Msg.GetCount()), "", nil) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.GenerateRecoveryCodesResponse{ + Details: object.DomainToDetailsPb(details.ObjectDetails), + RecoveryCodes: details.RawCodes, + }), nil +} + +func (s *Server) RemoveRecoveryCodes(ctx context.Context, req *connect.Request[user.RemoveRecoveryCodesRequest]) (*connect.Response[user.RemoveRecoveryCodesResponse], error) { + objectDetails, err := s.command.RemoveRecoveryCodes(ctx, req.Msg.GetUserId(), "", nil) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.RemoveRecoveryCodesResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 3b1260d52..a45192876 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -343,6 +343,8 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL case domain.UserAuthMethodTypeUnspecified: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED + case domain.UserAuthMethodTypeRecoveryCode: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_RECOVERY_CODE default: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED } diff --git a/internal/api/oidc/amr.go b/internal/api/oidc/amr.go index 83856af99..9041a287c 100644 --- a/internal/api/oidc/amr.go +++ b/internal/api/oidc/amr.go @@ -44,7 +44,8 @@ func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string { case domain.UserAuthMethodTypeOTP, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, - domain.UserAuthMethodTypeOTPEmail: + domain.UserAuthMethodTypeOTPEmail, + domain.UserAuthMethodTypeRecoveryCode: // a user could use multiple (t)otp, which is a factor, but still will be returned as a single `otp` entry otp++ factors++ diff --git a/internal/api/ui/login/custom_action.go b/internal/api/ui/login/custom_action.go index 69f4048ab..d3e2dbb2c 100644 --- a/internal/api/ui/login/custom_action.go +++ b/internal/api/ui/login/custom_action.go @@ -169,6 +169,7 @@ const ( authMethodOTP authMethod = "OTP" authMethodOTPSMS authMethod = "OTP SMS" authMethodOTPEmail authMethod = "OTP Email" + authMethodRecoveryCode authMethod = "recovery code" authMethodU2F authMethod = "U2F" authMethodPasswordless authMethod = "passwordless" ) diff --git a/internal/api/ui/login/mfa_verify_otp_handler.go b/internal/api/ui/login/mfa_verify_otp_handler.go index 2ee9d29d2..a34220b71 100644 --- a/internal/api/ui/login/mfa_verify_otp_handler.go +++ b/internal/api/ui/login/mfa_verify_otp_handler.go @@ -103,7 +103,9 @@ func (l *Login) handleOTPVerificationCheck(w http.ResponseWriter, r *http.Reques case domain.MFATypeOTPEmail: actionType = authMethodOTPEmail verifyCode = l.authRepo.VerifyMFAOTPEmail - // another type should never be passed, but just making sure + case domain.MFATypeRecoveryCode: + actionType = authMethodRecoveryCode + verifyCode = l.authRepo.VerifyMFARecoveryCode case domain.MFATypeU2F, domain.MFATypeTOTP, domain.MFATypeU2FUserVerification: diff --git a/internal/auth/repository/auth_request.go b/internal/auth/repository/auth_request.go index 6c607bc1a..072681784 100644 --- a/internal/auth/repository/auth_request.go +++ b/internal/auth/repository/auth_request.go @@ -28,6 +28,7 @@ type AuthRequestRepository interface { VerifyMFAOTPSMS(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) error SendMFAOTPEmail(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) error VerifyMFAOTPEmail(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) error + VerifyMFARecoveryCode(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) error BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (*domain.WebAuthNLogin, error) VerifyMFAU2F(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string, credentialData []byte, info *domain.BrowserInfo) error BeginPasswordlessSetup(ctx context.Context, userID, resourceOwner string, preferredPlatformType domain.AuthenticatorAttachment) (login *domain.WebAuthNToken, err error) diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index d2e2bc62f..43f3713f9 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -460,6 +460,16 @@ func (repo *AuthRequestRepo) VerifyMFAOTPEmail(ctx context.Context, userID, reso return repo.Command.HumanCheckOTPEmail(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) } +func (repo *AuthRequestRepo) VerifyMFARecoveryCode(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + request, err := repo.getAuthRequestEnsureUser(ctx, authRequestID, userAgentID, userID) + if err != nil { + return err + } + return repo.Command.HumanCheckRecoveryCode(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) +} + func (repo *AuthRequestRepo) BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (login *domain.WebAuthNLogin, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -1641,6 +1651,8 @@ var ( user_repo.UserIDPLoginCheckSucceededType, user_repo.HumanMFAOTPCheckSucceededType, user_repo.HumanMFAOTPCheckFailedType, + user_repo.HumanRecoveryCodeCheckSucceededType, + user_repo.HumanRecoveryCodeCheckFailedType, user_repo.HumanSignedOutType, user_repo.HumanPasswordlessTokenCheckSucceededType, user_repo.HumanPasswordlessTokenCheckFailedType, @@ -1690,6 +1702,8 @@ func userSessionByIDs(ctx context.Context, provider userSessionViewProvider, eve user_repo.UserIDPLoginCheckSucceededType, user_repo.HumanMFAOTPCheckSucceededType, user_repo.HumanMFAOTPCheckFailedType, + user_repo.HumanRecoveryCodeCheckSucceededType, + user_repo.HumanRecoveryCodeCheckFailedType, user_repo.HumanSignedOutType, user_repo.HumanPasswordlessTokenCheckSucceededType, user_repo.HumanPasswordlessTokenCheckFailedType, diff --git a/internal/auth/repository/eventsourcing/handler/user_session.go b/internal/auth/repository/eventsourcing/handler/user_session.go index 98890c381..b1693f8d9 100644 --- a/internal/auth/repository/eventsourcing/handler/user_session.go +++ b/internal/auth/repository/eventsourcing/handler/user_session.go @@ -103,6 +103,18 @@ func (s *UserSession) Reducers() []handler.AggregateReducer { Event: user.HumanMFAOTPCheckFailedType, Reduce: s.Reduce, }, + { + Event: user.HumanRecoveryCodeCheckSucceededType, + Reduce: s.Reduce, + }, + { + Event: user.HumanRecoveryCodeCheckFailedType, + Reduce: s.Reduce, + }, + { + Event: user.HumanRecoveryCodesRemovedType, + Reduce: s.Reduce, + }, { Event: user.HumanU2FTokenCheckSucceededType, Reduce: s.Reduce, @@ -272,6 +284,23 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return nil, err } return handler.NewUpsertStatement(event, columns[0:3], columns), nil + case user.HumanRecoveryCodeCheckSucceededType: + columns, err := u.sessionColumnsActivate(event, + handler.NewCol(view_model.UserSessionKeySecondFactorVerification, event.CreatedAt()), + handler.NewCol(view_model.UserSessionKeySecondFactorVerificationType, domain.MFATypeRecoveryCode), + ) + if err != nil { + return nil, err + } + return handler.NewUpsertStatement(event, columns[0:3], columns), nil + case user.HumanRecoveryCodeCheckFailedType: + columns, err := u.sessionColumnsActivate(event, + handler.NewCol(view_model.UserSessionKeySecondFactorVerification, time.Time{}), + ) + if err != nil { + return nil, err + } + return handler.NewUpsertStatement(event, columns[0:3], columns), nil case user.UserV1SignedOutType, user.HumanSignedOutType: columns, err := u.sessionColumns(event, @@ -399,7 +428,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err ), nil case user.UserV1MFAOTPRemovedType, user.HumanMFAOTPRemovedType, - user.HumanU2FTokenRemovedType: + user.HumanU2FTokenRemovedType, + user.HumanRecoveryCodesRemovedType: return handler.NewUpdateStatement(event, []handler.Column{ handler.NewCol(view_model.UserSessionKeySecondFactorVerification, time.Time{}), diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index a71ad4a88..f6bb97dc4 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -251,6 +251,9 @@ func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType if !session.OTPEmailFactor.OTPCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeOTPEmail) } + if !session.RecoveryCodeFactor.RecoveryCodeCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeRecoveryCode) + } return types } diff --git a/internal/command/command.go b/internal/command/command.go index 050ce6679..29ef5adc8 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -204,6 +204,12 @@ func StartCommands( CryptoMFA: otpEncryption, Issuer: defaults.Multifactors.OTP.Issuer, }, + RecoveryCodes: domain.RecoveryCodesConfig{ + MaxCount: defaults.Multifactors.RecoveryCodes.MaxCount, + Format: domain.RecoveryCodeFormat(defaults.Multifactors.RecoveryCodes.Format), + Length: defaults.Multifactors.RecoveryCodes.Length, + WithHyphen: defaults.Multifactors.RecoveryCodes.WithHyphen, + }, }, GenerateDomain: domain.NewGeneratedInstanceDomain, caches: caches, diff --git a/internal/command/session.go b/internal/command/session.go index a50391077..f5ff0802f 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -219,6 +219,10 @@ func (s *SessionCommands) OTPEmailChecked(ctx context.Context, checkedAt time.Ti s.eventCommands = append(s.eventCommands, session.NewOTPEmailCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) } +func (s *SessionCommands) RecoveryCodeChecked(ctx context.Context, checkedAt time.Time) { + s.eventCommands = append(s.eventCommands, session.NewRecoveryCodeCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) +} + func (s *SessionCommands) SetToken(ctx context.Context, tokenID string) { // trigger activity log for session for user activity.Trigger(ctx, s.sessionWriteModel.UserResourceOwner, s.sessionWriteModel.UserID, activity.SessionAPI, s.eventstore.FilterToQueryReducer) diff --git a/internal/command/session_model.go b/internal/command/session_model.go index 646bee97e..ccd36d30b 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -41,28 +41,28 @@ func (p *WebAuthNChallengeModel) WebAuthNLogin(human *domain.Human, credentialAs type SessionWriteModel struct { eventstore.WriteModel - TokenID string - UserID string - UserResourceOwner string - PreferredLanguage *language.Tag - UserCheckedAt time.Time - PasswordCheckedAt time.Time - IntentCheckedAt time.Time - WebAuthNCheckedAt time.Time - TOTPCheckedAt time.Time - OTPSMSCheckedAt time.Time - OTPEmailCheckedAt time.Time - WebAuthNUserVerified bool - Metadata map[string][]byte - State domain.SessionState - UserAgent *domain.UserAgent - Expiration time.Time + TokenID string + UserID string + UserResourceOwner string + PreferredLanguage *language.Tag + UserCheckedAt time.Time + PasswordCheckedAt time.Time + IntentCheckedAt time.Time + WebAuthNCheckedAt time.Time + TOTPCheckedAt time.Time + OTPSMSCheckedAt time.Time + OTPEmailCheckedAt time.Time + RecoveryCodeCheckedAt time.Time + WebAuthNUserVerified bool + Metadata map[string][]byte + State domain.SessionState + UserAgent *domain.UserAgent + Expiration time.Time WebAuthNChallenge *WebAuthNChallengeModel OTPSMSCodeChallenge *OTPCode OTPEmailCodeChallenge *OTPCode - - aggregate *eventstore.Aggregate + aggregate *eventstore.Aggregate } func NewSessionWriteModel(sessionID string, instanceID string) *SessionWriteModel { @@ -108,6 +108,8 @@ func (wm *SessionWriteModel) Reduce() error { wm.reduceLifetimeSet(e) case *session.TerminateEvent: wm.reduceTerminate() + case *session.RecoveryCodeCheckedEvent: + wm.reduceRecoveryCodeChecked(e) } } return wm.WriteModel.Reduce() @@ -131,6 +133,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { session.OTPSMSCheckedType, session.OTPEmailChallengedType, session.OTPEmailCheckedType, + session.RecoveryCodeCheckedType, session.TokenSetType, session.MetadataSetType, session.LifetimeSetType, @@ -227,6 +230,10 @@ func (wm *SessionWriteModel) reduceTerminate() { wm.State = domain.SessionStateTerminated } +func (wm *SessionWriteModel) reduceRecoveryCodeChecked(e *session.RecoveryCodeCheckedEvent) { + wm.RecoveryCodeCheckedAt = e.CheckedAt +} + // AuthenticationTime returns the time the user authenticated using the latest time of all checks func (wm *SessionWriteModel) AuthenticationTime() time.Time { var authTime time.Time @@ -270,6 +277,9 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType { if !wm.OTPEmailCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeOTPEmail) } + if !wm.RecoveryCodeCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeRecoveryCode) + } return types } diff --git a/internal/command/session_model_test.go b/internal/command/session_model_test.go index bddeecf59..10506ad52 100644 --- a/internal/command/session_model_test.go +++ b/internal/command/session_model_test.go @@ -11,10 +11,14 @@ import ( func TestSessionWriteModel_AuthMethodTypes(t *testing.T) { type fields struct { - PasswordCheckedAt time.Time - IntentCheckedAt time.Time - WebAuthNCheckedAt time.Time - WebAuthNUserVerified bool + PasswordCheckedAt time.Time + IntentCheckedAt time.Time + WebAuthNCheckedAt time.Time + WebAuthNUserVerified bool + TOTPCheckedAt time.Time + OTPSMSCheckedAt time.Time + OTPEmailCheckedAt time.Time + RecoveryCodeCheckedAt time.Time } tests := []struct { name string @@ -59,14 +63,54 @@ func TestSessionWriteModel_AuthMethodTypes(t *testing.T) { domain.UserAuthMethodTypeIDP, }, }, + { + name: "totp", + fields: fields{ + TOTPCheckedAt: testNow, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypeTOTP, + }, + }, + { + name: "otp sms", + fields: fields{ + OTPSMSCheckedAt: testNow, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypeOTPSMS, + }, + }, + { + name: "otp email", + fields: fields{ + OTPEmailCheckedAt: testNow, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypeOTPEmail, + }, + }, + { + name: "recovery code", + fields: fields{ + RecoveryCodeCheckedAt: testNow, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypeRecoveryCode, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { wm := &SessionWriteModel{ - PasswordCheckedAt: tt.fields.PasswordCheckedAt, - IntentCheckedAt: tt.fields.IntentCheckedAt, - WebAuthNCheckedAt: tt.fields.WebAuthNCheckedAt, - WebAuthNUserVerified: tt.fields.WebAuthNUserVerified, + PasswordCheckedAt: tt.fields.PasswordCheckedAt, + IntentCheckedAt: tt.fields.IntentCheckedAt, + WebAuthNCheckedAt: tt.fields.WebAuthNCheckedAt, + WebAuthNUserVerified: tt.fields.WebAuthNUserVerified, + TOTPCheckedAt: tt.fields.TOTPCheckedAt, + OTPSMSCheckedAt: tt.fields.OTPSMSCheckedAt, + OTPEmailCheckedAt: tt.fields.OTPEmailCheckedAt, + RecoveryCodeCheckedAt: tt.fields.RecoveryCodeCheckedAt, } got := wm.AuthMethodTypes() assert.Equal(t, got, tt.want) diff --git a/internal/command/session_recovery_codes.go b/internal/command/session_recovery_codes.go new file mode 100644 index 000000000..2638eecc0 --- /dev/null +++ b/internal/command/session_recovery_codes.go @@ -0,0 +1,28 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +func CheckRecoveryCode(code string) SessionCommand { + return func(ctx context.Context, cmd *SessionCommands) ([]eventstore.Command, error) { + commands, err := checkRecoveryCode(ctx, cmd.sessionWriteModel.UserID, code, cmd.sessionWriteModel.UserResourceOwner, nil, cmd.eventstore.FilterToQueryReducer, cmd.hasher) + if err != nil { + return commands, err + } + + cmd.eventCommands = append(cmd.eventCommands, commands...) + cmd.RecoveryCodeChecked(ctx, cmd.now()) + return nil, nil + } +} + +func toHumanRecoveryCode(recoveryCodeWriteModel *HumanRecoveryCodeWriteModel) *domain.HumanRecoveryCodes { + return &domain.HumanRecoveryCodes{ + ObjectDetails: writeModelToObjectDetails(&recoveryCodeWriteModel.WriteModel), + Codes: recoveryCodeWriteModel.Codes(), + } +} diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 2ff7dc7d3..0d0e5b043 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -1283,6 +1283,195 @@ func TestCheckTOTP(t *testing.T) { } } +func TestCheckRecoveryCode(t *testing.T) { + ctx := authz.NewMockContext("instance1", "org1", "user1") + + sessAgg := &session.NewAggregate("session1", "instance1").Aggregate + userAgg := &user.NewAggregate("user1", "org1").Aggregate + orgAgg := &org.NewAggregate("org1").Aggregate + + hasher := mockPasswordHasher("x") + hashedCodes, rawCodes, err := domain.GenerateRecoveryCodes(ctx, 1, domain.RecoveryCodesConfig{ + Format: domain.RecoveryCodeFormatUUID, + MaxCount: 10, + Length: 10, + }, hasher) + require.NoError(t, err) + validCode := rawCodes[0] + invalidCode := "invalid-code" + + type fields struct { + sessionWriteModel *SessionWriteModel + eventstore func(*testing.T) *eventstore.Eventstore + hasher *crypto.Hasher + } + + tests := []struct { + name string + code string + fields fields + wantEventCommands []eventstore.Command + wantErrorCommands []eventstore.Command + wantErr error + }{ + { + name: "missing userID", + code: validCode, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + aggregate: sessAgg, + }, + eventstore: expectEventstore(), + hasher: hasher, + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"), + }, + { + name: "filter error", + code: validCode, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + hasher: hasher, + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "recovery codes not ready", + code: validCode, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter(), // No codes added + ), + hasher: hasher, + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"), + }, + { + name: "invalid code", + code: invalidCode, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil), + ), + ), + expectFilter(), // additional lock check + expectFilter( + eventFromEventPusher(org.NewLockoutPolicyAddedEvent(ctx, orgAgg, 0, 0, false)), + ), + ), + hasher: hasher, + }, + wantErrorCommands: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckFailedEvent(ctx, userAgg, nil), + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid code, locked", + code: invalidCode, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil), + ), + ), + expectFilter(), // additional lock check + expectFilter( + eventFromEventPusher(org.NewLockoutPolicyAddedEvent(ctx, orgAgg, 1, 1, false)), + ), + ), + hasher: hasher, + }, + wantErrorCommands: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckFailedEvent(ctx, userAgg, nil), + user.NewUserLockedEvent(ctx, userAgg), + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "user locked", + code: validCode, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil), + ), + eventFromEventPusher( + user.NewUserLockedEvent(ctx, userAgg), + ), + ), + ), + hasher: hasher, + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"), + }, + { + name: "ok", + code: validCode, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil), + ), + ), + expectFilter(), // additional lock check + ), + hasher: hasher, + }, + wantEventCommands: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckSucceededEvent(ctx, userAgg, "$plain$x$"+validCode, nil), + session.NewRecoveryCodeCheckedEvent(ctx, sessAgg, testNow), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &SessionCommands{ + sessionWriteModel: tt.fields.sessionWriteModel, + eventstore: tt.fields.eventstore(t), + hasher: tt.fields.hasher, + now: func() time.Time { return testNow }, + } + gotCmds, err := CheckRecoveryCode(tt.code)(ctx, cmd) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantErrorCommands, gotCmds) + assert.Equal(t, tt.wantEventCommands, cmd.eventCommands) + }) + } +} + func TestCommands_TerminateSession(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore diff --git a/internal/command/user_human_recovery_code_model.go b/internal/command/user_human_recovery_code_model.go new file mode 100644 index 000000000..a56951756 --- /dev/null +++ b/internal/command/user_human_recovery_code_model.go @@ -0,0 +1,90 @@ +package command + +import ( + "slices" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" +) + +type HumanRecoveryCodeWriteModel struct { + eventstore.WriteModel + + State domain.MFAState + FailedAttempts uint64 + codes []string + userLocked bool +} + +func (wm *HumanRecoveryCodeWriteModel) Codes() []string { + if wm.codes == nil { + return make([]string, 0) + } + return wm.codes +} + +func (wm *HumanRecoveryCodeWriteModel) UserLocked() bool { + return wm.userLocked +} + +func NewHumanRecoveryCodeWriteModel(userID, resourceOwner string) *HumanRecoveryCodeWriteModel { + return &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *HumanRecoveryCodeWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.HumanRecoveryCodesAddedEvent: + wm.codes = append(wm.Codes(), e.Codes...) + wm.State = domain.MFAStateReady + case *user.HumanRecoveryCodeCheckSucceededEvent: + wm.FailedAttempts = 0 + codeCheckedIndex := slices.Index(wm.codes, e.CodeChecked) + if codeCheckedIndex == -1 { + // NB: this should typically never happen, but a race-condition could + // possibly lead to inconsistent state and duplicate code checks + continue + } + wm.codes = slices.Delete(wm.codes, codeCheckedIndex, codeCheckedIndex+1) + case *user.HumanRecoveryCodeCheckFailedEvent: + wm.FailedAttempts += 1 + case *user.HumanRecoveryCodesRemovedEvent: + wm.codes = make([]string, 0) + wm.State = domain.MFAStateRemoved + case *user.UserLockedEvent: + wm.userLocked = true + case *user.UserUnlockedEvent: + wm.userLocked = false + wm.FailedAttempts = 0 + case *user.UserRemovedEvent: + wm.State = domain.MFAStateRemoved + } + } + return wm.WriteModel.Reduce() +} + +func (wm *HumanRecoveryCodeWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(user.HumanRecoveryCodesAddedType, + user.HumanRecoveryCodesRemovedType, + user.HumanRecoveryCodeCheckSucceededType, + user.HumanRecoveryCodeCheckFailedType, + user.UserLockedType, + user.UserUnlockedType, + user.UserRemovedType). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} diff --git a/internal/command/user_human_recovery_code_model_test.go b/internal/command/user_human_recovery_code_model_test.go new file mode 100644 index 000000000..7f7b4d83e --- /dev/null +++ b/internal/command/user_human_recovery_code_model_test.go @@ -0,0 +1,383 @@ +package command + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestHumanRecoveryCodeWriteModel_Reduce(t *testing.T) { + userAgg := user.NewAggregate("user1", "org1") + ctx := context.Background() + + tests := []struct { + name string + events []eventstore.Event + want *HumanRecoveryCodeWriteModel + }{ + { + name: "no events", + events: []eventstore.Event{}, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + }, + }, + { + name: "recovery codes added", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 0, + codes: []string{"code1", "code2", "code3"}, + userLocked: false, + }, + }, + { + name: "recovery codes added multiple times", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2"}, + nil, + ), + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code3", "code4"}, + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 0, + codes: []string{"code1", "code2", "code3", "code4"}, + userLocked: false, + }, + }, + { + name: "recovery code check succeeded", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewHumanRecoveryCodeCheckSucceededEvent( + ctx, + &userAgg.Aggregate, + "code2", + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 0, + codes: []string{"code1", "code3"}, + userLocked: false, + }, + }, + { + name: "recovery code check succeeded with code that no longer exists", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewHumanRecoveryCodeCheckSucceededEvent( + ctx, + &userAgg.Aggregate, + "nonexistent", + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 0, + codes: []string{"code1", "code2", "code3"}, + userLocked: false, + }, + }, + { + name: "recovery code check failed", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 1, + codes: []string{"code1", "code2", "code3"}, + userLocked: false, + }, + }, + { + name: "multiple recovery code check failures", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 3, + codes: []string{"code1", "code2", "code3"}, + userLocked: false, + }, + }, + { + name: "recovery codes removed", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewHumanRecoveryCodeRemovedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateRemoved, + FailedAttempts: 0, + codes: []string{}, + userLocked: false, + }, + }, + { + name: "user locked", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + user.NewUserLockedEvent( + ctx, + &userAgg.Aggregate, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 1, + codes: []string{"code1", "code2", "code3"}, + userLocked: true, + }, + }, + { + name: "user unlocked", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + user.NewUserLockedEvent( + ctx, + &userAgg.Aggregate, + ), + user.NewUserUnlockedEvent( + ctx, + &userAgg.Aggregate, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 0, + codes: []string{"code1", "code2", "code3"}, + userLocked: false, + }, + }, + { + name: "user removed", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3"}, + nil, + ), + user.NewUserRemovedEvent( + ctx, + &userAgg.Aggregate, + "username", + nil, + false, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateRemoved, + FailedAttempts: 0, + codes: []string{"code1", "code2", "code3"}, + userLocked: false, + }, + }, + { + name: "complex flow with multiple events", + events: []eventstore.Event{ + user.NewHumanRecoveryCodesAddedEvent( + ctx, + &userAgg.Aggregate, + []string{"code1", "code2", "code3", "code4"}, + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + user.NewHumanRecoveryCodeCheckSucceededEvent( + ctx, + &userAgg.Aggregate, + "code2", + nil, + ), + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &userAgg.Aggregate, + nil, + ), + user.NewUserLockedEvent( + ctx, + &userAgg.Aggregate, + ), + user.NewUserUnlockedEvent( + ctx, + &userAgg.Aggregate, + ), + user.NewHumanRecoveryCodeCheckSucceededEvent( + ctx, + &userAgg.Aggregate, + "code4", + nil, + ), + }, + want: &HumanRecoveryCodeWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.MFAStateReady, + FailedAttempts: 0, + codes: []string{"code1", "code3"}, + userLocked: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wm := NewHumanRecoveryCodeWriteModel("user1", "org1") + wm.Events = tt.events + + err := wm.Reduce() + require.NoError(t, err) + + assert.Equal(t, tt.want.State, wm.State) + assert.Equal(t, tt.want.FailedAttempts, wm.FailedAttempts) + assert.Equal(t, tt.want.codes, wm.codes) + assert.Equal(t, tt.want.userLocked, wm.userLocked) + }) + } +} diff --git a/internal/command/user_human_recovery_codes.go b/internal/command/user_human_recovery_codes.go new file mode 100644 index 000000000..8d968748c --- /dev/null +++ b/internal/command/user_human_recovery_codes.go @@ -0,0 +1,201 @@ +package command + +import ( + "context" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func (c *Commands) ImportHumanRecoveryCodes(ctx context.Context, userID, resourceOwner string, codes []domain.ImportHumanRecoveryCode) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if len(codes) == 0 { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-vee93", "Errors.User.MFA.RecoveryCodes.CountInvalid") + } + + if _, err = c.checkUserExists(ctx, userID, resourceOwner); err != nil { + return err + } + + recoveryCodeWriteModel := NewHumanRecoveryCodeWriteModel(userID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, recoveryCodeWriteModel); err != nil { + return err + } + + if len(recoveryCodeWriteModel.Codes())+len(codes) > c.multifactors.RecoveryCodes.MaxCount { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-53cjw", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded") + } + + hashedCodes, err := domain.HashRecoveryCodesIfNeeded(ctx, codes, c.userPasswordHasher) + if err != nil { + return err + } + + userAgg := UserAggregateFromWriteModelCtx(ctx, &recoveryCodeWriteModel.WriteModel) + + _, err = c.eventstore.Push(ctx, + user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil), + ) + return err +} + +type RecoveryCodesDetails struct { + *domain.ObjectDetails + RawCodes []string +} + +func (c *Commands) GenerateRecoveryCodes(ctx context.Context, userID string, count int, resourceOwner string, authRequest *domain.AuthRequest) (*RecoveryCodesDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4kje7", "Errors.User.UserIDMissing") + } + if count <= 0 { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-7c0nx", "Errors.User.RecoveryCodes.CountInvalid") + } + + resourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + + if err := c.checkPermissionUpdateUserCredentials(ctx, resourceOwner, userID); err != nil { + return nil, err + } + + recoveryCodeWriteModel := NewHumanRecoveryCodeWriteModel(userID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, recoveryCodeWriteModel); err != nil { + return nil, err + } + + if len(recoveryCodeWriteModel.Codes())+count > c.multifactors.RecoveryCodes.MaxCount { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded") + } + + hashedCodes, rawCodes, err := domain.GenerateRecoveryCodes(ctx, count, c.multifactors.RecoveryCodes, c.userPasswordHasher) + if err != nil { + return nil, err + } + + userAgg := UserAggregateFromWriteModelCtx(ctx, &recoveryCodeWriteModel.WriteModel) + + _, err = c.eventstore.Push(ctx, + user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, authRequestDomainToAuthRequestInfo(authRequest)), + ) + if err != nil { + return nil, err + } + + return &RecoveryCodesDetails{ + ObjectDetails: writeModelToObjectDetails(&recoveryCodeWriteModel.WriteModel), + RawCodes: rawCodes, + }, nil +} + +func (c *Commands) RemoveRecoveryCodes(ctx context.Context, userID, resourceOwner string, authRequest *domain.AuthRequest) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-l2n9r", "Errors.User.UserIDMissing") + } + + writeModel := NewHumanRecoveryCodeWriteModel(userID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + + if err := c.checkPermissionUpdateUserCredentials(ctx, writeModel.ResourceOwner, userID); err != nil { + return nil, err + } + + if writeModel.UserLocked() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-d9u8q", "Errors.User.Locked") + } + + // if there aren't any recovery codes, we don't need to do anything + if writeModel.State != domain.MFAStateReady { + return writeModelToObjectDetails(&writeModel.WriteModel), nil + } + + userAgg := UserAggregateFromWriteModelCtx(ctx, &writeModel.WriteModel) + + _, err := c.eventstore.Push(ctx, user.NewHumanRecoveryCodeRemovedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) + if err != nil { + return nil, err + } + + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) HumanCheckRecoveryCode(ctx context.Context, userID, code, resourceOwner string, authRequest *domain.AuthRequest) error { + commands, err := checkRecoveryCode(ctx, userID, code, resourceOwner, authRequest, c.eventstore.FilterToQueryReducer, c.userPasswordHasher) + if len(commands) > 0 { + _, err = c.eventstore.Push(ctx, commands...) + logging.OnError(err).Error("failed to push recovery code check events") + } + return err +} + +func checkRecoveryCode( + ctx context.Context, + userID, code, resourceOwner string, + authRequest *domain.AuthRequest, + queryReducer func(ctx context.Context, r eventstore.QueryReducer) error, + secretHasher *crypto.Hasher, +) ([]eventstore.Command, error) { + if code == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing") + } + + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing") + } + + recoveryCodeWm := NewHumanRecoveryCodeWriteModel(userID, resourceOwner) + err := queryReducer(ctx, recoveryCodeWm) + if err != nil { + return nil, err + } + + if recoveryCodeWm.UserLocked() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked") + } + + if recoveryCodeWm.State != domain.MFAStateReady { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady") + } + + hashedCode, err := domain.ValidateRecoveryCode(ctx, code, toHumanRecoveryCode(recoveryCodeWm), secretHasher) + + // recheck for additional events (failed password checks or locks) + recheckErr := queryReducer(ctx, recoveryCodeWm) + if recheckErr != nil { + return nil, recheckErr + } + if recoveryCodeWm.UserLocked() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked") + } + + userAgg := UserAggregateFromWriteModelCtx(ctx, &recoveryCodeWm.WriteModel) + commands := make([]eventstore.Command, 0, 2) + + authRequestInfo := authRequestDomainToAuthRequestInfo(authRequest) + + if err == nil { + return append(commands, user.NewHumanRecoveryCodeCheckSucceededEvent(ctx, userAgg, hashedCode, authRequestInfo)), nil + } + commands = append(commands, user.NewHumanRecoveryCodeCheckFailedEvent(ctx, userAgg, authRequestInfo)) + + lockoutPolicy, lockoutErr := getLockoutPolicy(ctx, recoveryCodeWm.ResourceOwner, queryReducer) + logging.OnError(lockoutErr).Error("failed to get lockout policy") + + if lockoutPolicy != nil && lockoutPolicy.MaxOTPAttempts > 0 && recoveryCodeWm.FailedAttempts+1 >= lockoutPolicy.MaxOTPAttempts { + commands = append(commands, user.NewUserLockedEvent(ctx, userAgg)) + } + + return commands, err +} diff --git a/internal/command/user_human_recovery_codes_test.go b/internal/command/user_human_recovery_codes_test.go new file mode 100644 index 000000000..729579be0 --- /dev/null +++ b/internal/command/user_human_recovery_codes_test.go @@ -0,0 +1,1037 @@ +package command + +import ( + "context" + "io" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_ImportHumanRecoveryCodes(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + resourceOwner string + codes []domain.ImportHumanRecoveryCode + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "user not found, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // user not found + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + codes: []domain.ImportHumanRecoveryCode{ + {RawCode: "code1"}, + {RawCode: "code2"}, + }, + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound"), + }, + { + name: "recovery codes already exist, add to existing", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"hashedcode1", "hashedcode2"}, + nil, + ), + ), + ), + expectPush( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"$plain$$code1", "$plain$$code2"}, + nil, + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + codes: []domain.ImportHumanRecoveryCode{ + {RawCode: "code1"}, + {RawCode: "code2"}, + }, + }, + }, + { + name: "empty codes, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + codes: []domain.ImportHumanRecoveryCode{}, + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-vee93", "Errors.User.MFA.RecoveryCodes.CountInvalid"), + }, + { + name: "max count exceeded with existing codes, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"code1", "code2", "code3", "code4", "code5", "code6", "code7", "code8"}, + nil, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + codes: []domain.ImportHumanRecoveryCode{ // 8 existing + 3 new = 11 > max 10 + {RawCode: "code9"}, + {RawCode: "code10"}, + {RawCode: "code11"}, + }, + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-53cjw", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"), + }, + { + name: "successful import", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), // no existing recovery codes + expectPush( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"$plain$$code1", "$plain$$code2"}, + nil, + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + codes: []domain.ImportHumanRecoveryCode{ + {RawCode: "code1"}, + {RawCode: "code2"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + userPasswordHasher: mockPasswordHasher(""), + multifactors: domain.MultifactorConfigs{ + RecoveryCodes: domain.RecoveryCodesConfig{ + MaxCount: 10, + Format: domain.RecoveryCodeFormatAlphanumeric, + Length: 8, + WithHyphen: false, + }, + }, + } + err := c.ImportHumanRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.codes) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestCommands_GenerateRecoveryCodes(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + userID string + count int + resourceOwner string + authRequest *domain.AuthRequest + } + tests := []struct { + name string + fields fields + args args + want *RecoveryCodesDetails + wantErr error + }{ + { + name: "missing userID, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + count: 2, + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4kje7", "Errors.User.UserIDMissing"), + }, + { + name: "user not found, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // user not found + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + count: 2, + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound"), + }, + { + name: "permission denied, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + &user.NewAggregate("user2", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user2", + count: 2, + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "invalid count (zero), error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "user1", + count: 0, + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-7c0nx", "Errors.User.RecoveryCodes.CountInvalid"), + }, + { + name: "invalid count (too high), error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), // no existing recovery codes + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + count: 15, // exceeds max count of 10 + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"), + }, + { + name: "max count exceeded with existing codes, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"code1", "code2", "code3", "code4", "code5", "code6", "code7", "code8"}, + nil, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + count: 5, // 8 existing + 5 new = 13 > max 10 + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + userPasswordHasher: mockPasswordHasher(""), + multifactors: domain.MultifactorConfigs{ + RecoveryCodes: domain.RecoveryCodesConfig{ + MaxCount: 10, + Format: domain.RecoveryCodeFormatAlphanumeric, + Length: 8, + WithHyphen: false, + }, + }, + } + got, err := c.GenerateRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.count, tt.args.resourceOwner, tt.args.authRequest) + require.ErrorIs(t, err, tt.wantErr) + if tt.want != nil { + require.NotNil(t, got) + assert.Equal(t, tt.want.ResourceOwner, got.ResourceOwner) + } + }) + } +} + +func TestCommands_RemoveRecoveryCodes(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + userID string + resourceOwner string + authRequest *domain.AuthRequest + } + tests := []struct { + name string + fields fields + args args + want *domain.ObjectDetails + wantErr error + }{ + { + name: "missing userID, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-l2n9r", "Errors.User.UserIDMissing"), + }, + { + name: "permission denied, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user2", "org1").Aggregate, + []string{"code1", "code2"}, + nil, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user2", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "user locked, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"code1", "code2"}, + nil, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-d9u8q", "Errors.User.Locked"), + }, + { + name: "recovery codes not added, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // no recovery codes + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "user1", + }, + }, + { + name: "successful removal", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"code1", "code2"}, + nil, + ), + ), + ), + expectPush( + user.NewHumanRecoveryCodeRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + nil, + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "user1", + }, + }, + { + name: "successful removal with auth request", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"code1", "code2"}, + nil, + ), + ), + ), + expectPush( + user.NewHumanRecoveryCodeRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "user1", + }, + }, + { + name: "successful removal, other user", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user2", "org1").Aggregate, + []string{"code1", "code2"}, + nil, + ), + ), + ), + expectPush( + user.NewHumanRecoveryCodeRemovedEvent(ctx, + &user.NewAggregate("user2", "org1").Aggregate, + nil, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user2", + resourceOwner: "org1", + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "user2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := c.RemoveRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest) + require.ErrorIs(t, err, tt.wantErr) + if tt.want != nil { + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestCommands_HumanCheckRecoveryCode(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + code string + resourceOwner string + authRequest *domain.AuthRequest + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "missing code, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing"), + }, + { + name: "missing userID, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + code: "validcode", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"), + }, + { + name: "user locked, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"$plain$$validcode", "$plain$$validcode2"}, + nil, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"), + }, + { + name: "recovery codes not ready, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // no recovery codes + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"), + }, + { + name: "valid code, successful check", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"$plain$$validcode", "$plain$$validcode2"}, + nil, + ), + ), + ), + expectFilter(), // additional lock check + expectPush( + user.NewHumanRecoveryCodeCheckSucceededEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$$validcode", + nil, + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + }, + }, + { + name: "valid code, successful check with auth request", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"$plain$$validcode", "$plain$$validcode2"}, + nil, + ), + ), + ), + expectFilter(), // additional lock check + expectPush( + user.NewHumanRecoveryCodeCheckSucceededEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$$validcode", + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + }, + { + name: "valid code, locked in the meantime, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanRecoveryCodesAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + []string{"$plain$$validcode", "$plain$$validcode2"}, + nil, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + userPasswordHasher: mockPasswordHasher(""), + } + err := c.HumanCheckRecoveryCode(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestCommands_checkRecoveryCode(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + hasher := mockPasswordHasher("") + + queryReducer := func(ctx context.Context, r eventstore.QueryReducer) error { + // Basic query reducer that doesn't set any specific state + return nil + } + queryReducerError := func(ctx context.Context, r eventstore.QueryReducer) error { + return io.ErrClosedPipe + } + + // Query reducer that sets up a write model with valid recovery codes + queryReducerWithCodes := func(ctx context.Context, r eventstore.QueryReducer) error { + switch wm := r.(type) { + case *HumanRecoveryCodeWriteModel: + wm.State = domain.MFAStateReady + wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"} + case *OrgLockoutPolicyWriteModel: + // Set up lockout policy - no lockout for this test + wm.MaxOTPAttempts = 0 + wm.State = domain.PolicyStateActive + } + return nil + } + + // Query reducer that sets up a locked user + queryReducerUserLocked := func(ctx context.Context, r eventstore.QueryReducer) error { + switch wm := r.(type) { + case *HumanRecoveryCodeWriteModel: + wm.State = domain.MFAStateReady + wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"} + wm.userLocked = true + case *OrgLockoutPolicyWriteModel: + // Default lockout policy + wm.State = domain.PolicyStateActive + } + return nil + } + + // Query reducer that sets up recovery codes not ready + queryReducerNotReady := func(ctx context.Context, r eventstore.QueryReducer) error { + switch wm := r.(type) { + case *HumanRecoveryCodeWriteModel: + wm.State = domain.MFAStateNotReady + case *OrgLockoutPolicyWriteModel: + // Default lockout policy + wm.State = domain.PolicyStateActive + } + return nil + } + + // Query reducer that sets up a locked user after the first check + queryReducerLockedAfterFirstCheck := func(ctx context.Context, r eventstore.QueryReducer) error { + switch wm := r.(type) { + case *HumanRecoveryCodeWriteModel: + wm.State = domain.MFAStateReady + // simulate lock after first check when the codes are already set by the first reduction + if len(wm.codes) > 0 { + wm.userLocked = true + } + wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"} + case *OrgLockoutPolicyWriteModel: + // Default lockout policy + wm.State = domain.PolicyStateActive + } + return nil + } + + type args struct { + ctx context.Context + userID string + code string + resourceOwner string + authRequest *domain.AuthRequest + queryReducer func(ctx context.Context, r eventstore.QueryReducer) error + secretHasher *crypto.Hasher + } + tests := []struct { + name string + args args + wantCommands int + wantErr error + }{ + { + name: "missing code, error", + args: args{ + ctx: ctx, + userID: "user1", + code: "", + resourceOwner: "org1", + queryReducer: queryReducer, + secretHasher: hasher, + }, + wantCommands: 0, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing"), + }, + { + name: "missing userID, error", + args: args{ + ctx: ctx, + userID: "", + code: "validcode", + resourceOwner: "org1", + queryReducer: queryReducer, + secretHasher: hasher, + }, + wantCommands: 0, + wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"), + }, + { + name: "query reducer error", + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + queryReducer: queryReducerError, + secretHasher: hasher, + }, + wantCommands: 0, + wantErr: io.ErrClosedPipe, + }, + { + name: "user locked, error", + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + queryReducer: queryReducerUserLocked, + secretHasher: hasher, + }, + wantCommands: 0, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"), + }, + { + name: "recovery codes not ready, error", + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + queryReducer: queryReducerNotReady, + secretHasher: hasher, + }, + wantCommands: 0, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"), + }, + { + name: "invalid code, returns failed event and error", + args: args{ + ctx: ctx, + userID: "user1", + code: "invalidcode", + resourceOwner: "org1", + queryReducer: queryReducerWithCodes, + secretHasher: hasher, + }, + wantCommands: 1, // should return failed event command + wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "valid code, returns success event", + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + queryReducer: queryReducerWithCodes, + secretHasher: hasher, + }, + wantCommands: 1, // should return success event command + wantErr: nil, + }, + { + name: "valid code, locked in the meantime, error", + args: args{ + ctx: ctx, + userID: "user1", + code: "validcode", + resourceOwner: "org1", + queryReducer: queryReducerLockedAfterFirstCheck, + secretHasher: hasher, + }, + wantCommands: 0, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commands, err := checkRecoveryCode(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest, tt.args.queryReducer, tt.args.secretHasher) + require.ErrorIs(t, err, tt.wantErr) + assert.Len(t, commands, tt.wantCommands) + }) + } +} diff --git a/internal/config/systemdefaults/system_defaults.go b/internal/config/systemdefaults/system_defaults.go index 2d3ace7c3..64eb10129 100644 --- a/internal/config/systemdefaults/system_defaults.go +++ b/internal/config/systemdefaults/system_defaults.go @@ -1,9 +1,11 @@ package systemdefaults import ( + "fmt" "time" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" ) type SystemDefaults struct { @@ -20,19 +22,52 @@ type SystemDefaults struct { MaxIdPIntentLifetime time.Duration } +func (s SystemDefaults) Validate() error { + // NB: currently we only validate the recovery codes config, + // but we may want to add more validations in the future or refactor + return s.Multifactors.RecoveryCodes.Validate() +} + type SecretGenerators struct { MachineKeySize uint32 ApplicationKeySize uint32 } type MultifactorConfig struct { - OTP OTPConfig + OTP OTPConfig + RecoveryCodes RecoveryCodesConfig } type OTPConfig struct { Issuer string } +type RecoveryCodesConfig struct { + MaxCount int + Format string + Length int + WithHyphen bool +} + +func (r RecoveryCodesConfig) Validate() error { + if r.MaxCount < 1 || r.MaxCount > 100 { + return fmt.Errorf("RecoveryCodes.MaxCount must be between 1 and 100, got %d", r.MaxCount) + } + + switch r.Format { + case string(domain.RecoveryCodeFormatUUID): + // pass + case string(domain.RecoveryCodeFormatAlphanumeric): + if r.Length < 8 || r.Length > 60 { + return fmt.Errorf("RecoveryCodes.Length must be between 8 and 60 for alphanumeric format, got %d", r.Length) + } + default: + return fmt.Errorf("RecoveryCodes.Format must be 'uuid' or 'alphanumeric', got '%s'", r.Format) + } + + return nil +} + type DomainVerification struct { VerificationGenerator crypto.GeneratorConfig } diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index c880c5159..ea746a383 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -140,6 +140,7 @@ const ( MFATypeU2FUserVerification MFATypeOTPSMS MFATypeOTPEmail + MFATypeRecoveryCode ) func (m MFAType) UserAuthMethodType() UserAuthMethodType { @@ -154,6 +155,8 @@ func (m MFAType) UserAuthMethodType() UserAuthMethodType { return UserAuthMethodTypeOTPSMS case MFATypeOTPEmail: return UserAuthMethodTypeOTPEmail + case MFATypeRecoveryCode: + return UserAuthMethodTypeRecoveryCode default: return UserAuthMethodTypeUnspecified } diff --git a/internal/domain/factors.go b/internal/domain/factors.go index 41f1a9f8c..709fcd7dc 100644 --- a/internal/domain/factors.go +++ b/internal/domain/factors.go @@ -8,6 +8,7 @@ const ( SecondFactorTypeU2F SecondFactorTypeOTPEmail SecondFactorTypeOTPSMS + SecondFactorTypeRecoveryCodes secondFactorCount ) diff --git a/internal/domain/human_recovery_codes.go b/internal/domain/human_recovery_codes.go new file mode 100644 index 000000000..78cab5a27 --- /dev/null +++ b/internal/domain/human_recovery_codes.go @@ -0,0 +1,136 @@ +package domain + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type HumanRecoveryCodes struct { + *ObjectDetails + + Codes []string +} + +type ImportHumanRecoveryCode struct { + RawCode string + HashedCode string +} + +func HashRecoveryCodesIfNeeded(ctx context.Context, codes []ImportHumanRecoveryCode, hasher *crypto.Hasher) (_ []string, err error) { + _, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + hashedCodes := make([]string, len(codes)) + for i, code := range codes { + hashed, err := HashRecoveryCodeIfNeeded(ctx, code, hasher) + if err != nil { + return nil, err + } + hashedCodes[i] = hashed + } + return hashedCodes, nil +} + +func HashRecoveryCodeIfNeeded(ctx context.Context, code ImportHumanRecoveryCode, hasher *crypto.Hasher) (_ string, err error) { + _, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if code.RawCode != "" { + return hasher.Hash(code.RawCode) + } + if !hasher.EncodingSupported(code.HashedCode) { + return "", zerrors.ThrowInvalidArgument(nil, "DOMAIN-JDk4t", "Errors.Hash.NotSupported") + } + return code.HashedCode, nil +} + +func GenerateRecoveryCodes(ctx context.Context, count int, config RecoveryCodesConfig, hasher *crypto.Hasher) (hashedCodes []string, rawCodes []string, err error) { + hashedCodes, rawCodes = make([]string, count), make([]string, count) + + for i := range count { + hashedCodes[i], rawCodes[i], err = generateRecoveryCode(ctx, config, hasher) + if err != nil { + return nil, nil, err + } + } + + return hashedCodes, rawCodes, nil +} + +func generateRecoveryCode(ctx context.Context, config RecoveryCodesConfig, hasher *crypto.Hasher) (hashedCode string, rawCode string, err error) { + _, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + rawCode, err = makeRawCode(config) + if err != nil { + return "", "", err + } + hashedCode, err = hasher.Hash(rawCode) + if err != nil { + return "", "", err + } + return hashedCode, rawCode, nil +} + +func makeRawCode(config RecoveryCodesConfig) (string, error) { + switch config.Format { + case RecoveryCodeFormatAlphanumeric: + return generateAlphanumericCode(config.Length, config.WithHyphen) + case RecoveryCodeFormatUUID: + code := uuid.New().String() + if !config.WithHyphen { + code = strings.ReplaceAll(code, "-", "") + } + return code, nil + default: + return "", zerrors.ThrowError(nil, "DOMAIN-g52kn", "Errors.User.MFA.RecoveryCodes.ConfigInvalid") + } +} + +func generateAlphanumericCode(length int, withHyphen bool) (string, error) { + if length <= 0 { + return "", zerrors.ThrowError(nil, "DOMAIN-68mvq", "Errors.User.MFA.RecoveryCodes.ConfigInvalid") + } + + // lower-cased base32 character set https://www.crockford.com/base32.html + chars := []rune("0123456789abcdefghjkmnpqrstvwxyz") + + code, err := crypto.GenerateRandomString(uint(length), chars) + if err != nil { + return "", err + } + + if withHyphen && length > 2 { + mid := length / 2 + return code[:mid] + "-" + code[mid:], nil + } + + return code, nil +} + +func ValidateRecoveryCode(ctx context.Context, code string, recoveryCodes *HumanRecoveryCodes, hasher *crypto.Hasher) (_ string, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if code == "" { + return "", zerrors.ThrowInvalidArgument(nil, "DOMAIN-9xrr0", "Errors.User.MFA.RecoveryCodes.InvalidCode") + } + + for _, recoveryCode := range recoveryCodes.Codes { + _, spanCodeComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") + _, verifyErr := hasher.Verify(recoveryCode, code) + spanCodeComparison.EndWithError(verifyErr) + if verifyErr != nil { + continue + } + return recoveryCode, nil + } + + return "", zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode") +} diff --git a/internal/domain/mfa.go b/internal/domain/mfa.go index 3b408109f..0f0af00f5 100644 --- a/internal/domain/mfa.go +++ b/internal/domain/mfa.go @@ -1,6 +1,8 @@ package domain -import "github.com/zitadel/zitadel/internal/crypto" +import ( + "github.com/zitadel/zitadel/internal/crypto" +) type MFAState int32 @@ -14,10 +16,25 @@ const ( ) type MultifactorConfigs struct { - OTP OTPConfig + OTP OTPConfig + RecoveryCodes RecoveryCodesConfig } type OTPConfig struct { Issuer string CryptoMFA crypto.EncryptionAlgorithm } + +type RecoveryCodeFormat string + +const ( + RecoveryCodeFormatUUID RecoveryCodeFormat = "uuid" + RecoveryCodeFormatAlphanumeric RecoveryCodeFormat = "alphanumeric" +) + +type RecoveryCodesConfig struct { + MaxCount int + Format RecoveryCodeFormat + Length int + WithHyphen bool +} diff --git a/internal/domain/user.go b/internal/domain/user.go index c59ff0563..d6d12715e 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -45,6 +45,7 @@ const ( UserAuthMethodTypeOTP // generic OTP when parsing AMR from OIDC UserAuthMethodTypePrivateKey userAuthMethodTypeCount + UserAuthMethodTypeRecoveryCode ) // HasMFA checks whether the user authenticated with multiple auth factors. @@ -63,6 +64,7 @@ func HasMFA(methods []UserAuthMethodType) bool { UserAuthMethodTypeOTPEmail, UserAuthMethodTypeIDP, UserAuthMethodTypeOTP, + UserAuthMethodTypeRecoveryCode, UserAuthMethodTypePrivateKey: factors++ case UserAuthMethodTypeUnspecified, @@ -83,6 +85,7 @@ func Has2FA(methods []UserAuthMethodType) bool { UserAuthMethodTypeTOTP, UserAuthMethodTypeOTPSMS, UserAuthMethodTypeOTPEmail, + UserAuthMethodTypeRecoveryCode, UserAuthMethodTypeOTP: factors++ case UserAuthMethodTypeUnspecified, diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index 53eba54ef..bb604c566 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -34,6 +34,7 @@ const ( SessionColumnTOTPCheckedAt = "totp_checked_at" SessionColumnOTPSMSCheckedAt = "otp_sms_checked_at" SessionColumnOTPEmailCheckedAt = "otp_email_checked_at" + SessionColumnRecoveryCodeCheckedAt = "mfa_recovery_code_checked_at" SessionColumnMetadata = "metadata" SessionColumnTokenID = "token_id" SessionColumnUserAgentFingerprintID = "user_agent_fingerprint_id" @@ -74,6 +75,7 @@ func (*sessionProjection) Init() *old_handler.Check { handler.NewColumn(SessionColumnTOTPCheckedAt, handler.ColumnTypeTimestamp, handler.Nullable()), handler.NewColumn(SessionColumnOTPSMSCheckedAt, handler.ColumnTypeTimestamp, handler.Nullable()), handler.NewColumn(SessionColumnOTPEmailCheckedAt, handler.ColumnTypeTimestamp, handler.Nullable()), + handler.NewColumn(SessionColumnRecoveryCodeCheckedAt, handler.ColumnTypeTimestamp, handler.Nullable()), handler.NewColumn(SessionColumnMetadata, handler.ColumnTypeJSONB, handler.Nullable()), handler.NewColumn(SessionColumnTokenID, handler.ColumnTypeText, handler.Nullable()), handler.NewColumn(SessionColumnUserAgentFingerprintID, handler.ColumnTypeText, handler.Nullable()), @@ -129,6 +131,10 @@ func (p *sessionProjection) Reducers() []handler.AggregateReducer { Event: session.OTPEmailCheckedType, Reduce: p.reduceOTPEmailChecked, }, + { + Event: session.RecoveryCodeCheckedType, + Reduce: p.reduceRecoveryCodeChecked, + }, { Event: session.TokenSetType, Reduce: p.reduceTokenSet, @@ -346,6 +352,26 @@ func (p *sessionProjection) reduceOTPEmailChecked(event eventstore.Event) (*hand ), nil } +func (p *sessionProjection) reduceRecoveryCodeChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.RecoveryCodeCheckedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnRecoveryCodeCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*session.TokenSetEvent) if !ok { diff --git a/internal/query/projection/user_auth_method.go b/internal/query/projection/user_auth_method.go index 7726550ff..cda34a064 100644 --- a/internal/query/projection/user_auth_method.go +++ b/internal/query/projection/user_auth_method.go @@ -129,6 +129,14 @@ func (p *userAuthMethodProjection) Reducers() []handler.AggregateReducer { Event: user.UserRemovedType, Reduce: p.reduceUserRemoved, }, + { + Event: user.HumanRecoveryCodesAddedType, + Reduce: p.reduceInitAuthMethod, + }, + { + Event: user.HumanRecoveryCodesRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, }, }, { @@ -167,8 +175,10 @@ func (p *userAuthMethodProjection) reduceInitAuthMethod(event eventstore.Event) rpID = &e.RPID case *user.HumanOTPAddedEvent: methodType = domain.UserAuthMethodTypeTOTP + case *user.HumanRecoveryCodesAddedEvent: + methodType = domain.UserAuthMethodTypeRecoveryCode default: - return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}) + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType, user.HumanMFAOTPAddedType, user.HumanRecoveryCodesAddedType}) } cols := []handler.Column{ handler.NewCol(UserAuthMethodTokenIDCol, tokenID), @@ -280,11 +290,12 @@ func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event methodType = domain.UserAuthMethodTypeOTPSMS case *user.HumanOTPEmailRemovedEvent: methodType = domain.UserAuthMethodTypeOTPEmail - + case *user.HumanRecoveryCodesRemovedEvent: + methodType = domain.UserAuthMethodTypeRecoveryCode default: return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType, user.HumanMFAOTPRemovedType, - user.HumanOTPSMSRemovedType, user.HumanPhoneRemovedType, user.HumanOTPEmailRemovedType}) + user.HumanOTPSMSRemovedType, user.HumanPhoneRemovedType, user.HumanOTPEmailRemovedType, user.HumanRecoveryCodesRemovedType}) } conditions := []handler.Condition{ handler.NewCond(UserAuthMethodUserIDCol, event.Aggregate().ID), diff --git a/internal/query/projection/user_auth_method_test.go b/internal/query/projection/user_auth_method_test.go index e21a480a9..06286c367 100644 --- a/internal/query/projection/user_auth_method_test.go +++ b/internal/query/projection/user_auth_method_test.go @@ -528,6 +528,71 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduceAddedRecoveryCodes", + args: args{ + event: getEvent( + testEvent( + user.HumanRecoveryCodesAddedType, + user.AggregateType, + []byte(`{ + "codes": ["code1", "code2"] + }`), + ), eventstore.GenericEventMapper[user.HumanRecoveryCodesAddedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceInitAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.user_auth_methods5 (token_id, creation_date, change_date, resource_owner, instance_id, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (instance_id, user_id, method_type, token_id) DO UPDATE SET (creation_date, change_date, resource_owner, sequence, state, name) = (projections.user_auth_methods5.creation_date, EXCLUDED.change_date, EXCLUDED.resource_owner, EXCLUDED.sequence, EXCLUDED.state, EXCLUDED.name)", + expectedArgs: []interface{}{ + "", + anyArg{}, + anyArg{}, + "ro-id", + "instance-id", + "agg-id", + uint64(15), + domain.MFAStateNotReady, + domain.UserAuthMethodTypeRecoveryCode, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveRecoveryCodes", + args: args{ + event: getEvent(testEvent( + user.HumanRecoveryCodesRemovedType, + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanRecoveryCodesRemovedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods5 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeRecoveryCode, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "reduceUserRemoved", reduce: (&userAuthMethodProjection{}).reduceUserRemoved, diff --git a/internal/query/session.go b/internal/query/session.go index 680113920..718a5e315 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -27,23 +27,24 @@ type Sessions struct { } type Session struct { - ID string - CreationDate time.Time - ChangeDate time.Time - Sequence uint64 - State domain.SessionState - ResourceOwner string - Creator string - UserFactor SessionUserFactor - PasswordFactor SessionPasswordFactor - IntentFactor SessionIntentFactor - WebAuthNFactor SessionWebAuthNFactor - TOTPFactor SessionTOTPFactor - OTPSMSFactor SessionOTPFactor - OTPEmailFactor SessionOTPFactor - Metadata map[string][]byte - UserAgent domain.UserAgent - Expiration time.Time + ID string + CreationDate time.Time + ChangeDate time.Time + Sequence uint64 + State domain.SessionState + ResourceOwner string + Creator string + UserFactor SessionUserFactor + PasswordFactor SessionPasswordFactor + IntentFactor SessionIntentFactor + WebAuthNFactor SessionWebAuthNFactor + TOTPFactor SessionTOTPFactor + OTPSMSFactor SessionOTPFactor + OTPEmailFactor SessionOTPFactor + RecoveryCodeFactor SessionRecoveryCodeFactor + Metadata map[string][]byte + UserAgent domain.UserAgent + Expiration time.Time } type SessionUserFactor struct { @@ -75,6 +76,10 @@ type SessionOTPFactor struct { OTPCheckedAt time.Time } +type SessionRecoveryCodeFactor struct { + RecoveryCodeCheckedAt time.Time +} + type SessionsSearchQueries struct { SearchRequest Queries []SearchQuery @@ -216,6 +221,10 @@ var ( name: projection.SessionColumnOTPEmailCheckedAt, table: sessionsTable, } + SessionColumnRecoveryCodeCheckedAt = Column{ + name: projection.SessionColumnRecoveryCodeCheckedAt, + table: sessionsTable, + } SessionColumnMetadata = Column{ name: projection.SessionColumnMetadata, table: sessionsTable, @@ -387,6 +396,7 @@ func prepareSessionQuery() (sq.SelectBuilder, func(*sql.Row) (*Session, string, SessionColumnTOTPCheckedAt.identifier(), SessionColumnOTPSMSCheckedAt.identifier(), SessionColumnOTPEmailCheckedAt.identifier(), + SessionColumnRecoveryCodeCheckedAt.identifier(), SessionColumnMetadata.identifier(), SessionColumnToken.identifier(), SessionColumnUserAgentFingerprintID.identifier(), @@ -402,23 +412,24 @@ func prepareSessionQuery() (sq.SelectBuilder, func(*sql.Row) (*Session, string, session := new(Session) var ( - userID sql.NullString - userResourceOwner sql.NullString - userCheckedAt sql.NullTime - loginName sql.NullString - displayName sql.NullString - passwordCheckedAt sql.NullTime - intentCheckedAt sql.NullTime - webAuthNCheckedAt sql.NullTime - webAuthNUserPresent sql.NullBool - totpCheckedAt sql.NullTime - otpSMSCheckedAt sql.NullTime - otpEmailCheckedAt sql.NullTime - metadata database.Map[[]byte] - token sql.NullString - userAgentIP sql.NullString - userAgentHeader database.Map[[]string] - expiration sql.NullTime + userID sql.NullString + userResourceOwner sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + passwordCheckedAt sql.NullTime + intentCheckedAt sql.NullTime + webAuthNCheckedAt sql.NullTime + webAuthNUserPresent sql.NullBool + totpCheckedAt sql.NullTime + otpSMSCheckedAt sql.NullTime + otpEmailCheckedAt sql.NullTime + recoveryCodesCheckedAt sql.NullTime + metadata database.Map[[]byte] + token sql.NullString + userAgentIP sql.NullString + userAgentHeader database.Map[[]string] + expiration sql.NullTime ) err := row.Scan( @@ -441,6 +452,7 @@ func prepareSessionQuery() (sq.SelectBuilder, func(*sql.Row) (*Session, string, &totpCheckedAt, &otpSMSCheckedAt, &otpEmailCheckedAt, + &recoveryCodesCheckedAt, &metadata, &token, &session.UserAgent.FingerprintID, @@ -469,6 +481,7 @@ func prepareSessionQuery() (sq.SelectBuilder, func(*sql.Row) (*Session, string, session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time session.OTPSMSFactor.OTPCheckedAt = otpSMSCheckedAt.Time session.OTPEmailFactor.OTPCheckedAt = otpEmailCheckedAt.Time + session.RecoveryCodeFactor.RecoveryCodeCheckedAt = recoveryCodesCheckedAt.Time session.Metadata = metadata session.UserAgent.Header = http.Header(userAgentHeader) if userAgentIP.Valid { @@ -500,6 +513,7 @@ func prepareSessionsQuery() (sq.SelectBuilder, func(*sql.Rows) (*Sessions, error SessionColumnTOTPCheckedAt.identifier(), SessionColumnOTPSMSCheckedAt.identifier(), SessionColumnOTPEmailCheckedAt.identifier(), + SessionColumnRecoveryCodeCheckedAt.identifier(), SessionColumnMetadata.identifier(), SessionColumnUserAgentFingerprintID.identifier(), SessionColumnUserAgentIP.identifier(), @@ -518,22 +532,23 @@ func prepareSessionsQuery() (sq.SelectBuilder, func(*sql.Rows) (*Sessions, error session := new(Session) var ( - userID sql.NullString - userResourceOwner sql.NullString - userCheckedAt sql.NullTime - loginName sql.NullString - displayName sql.NullString - passwordCheckedAt sql.NullTime - intentCheckedAt sql.NullTime - webAuthNCheckedAt sql.NullTime - webAuthNUserPresent sql.NullBool - totpCheckedAt sql.NullTime - otpSMSCheckedAt sql.NullTime - otpEmailCheckedAt sql.NullTime - metadata database.Map[[]byte] - userAgentIP sql.NullString - userAgentHeader database.Map[[]string] - expiration sql.NullTime + userID sql.NullString + userResourceOwner sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + passwordCheckedAt sql.NullTime + intentCheckedAt sql.NullTime + webAuthNCheckedAt sql.NullTime + webAuthNUserPresent sql.NullBool + totpCheckedAt sql.NullTime + otpSMSCheckedAt sql.NullTime + otpEmailCheckedAt sql.NullTime + recoveryCodesCheckedAt sql.NullTime + metadata database.Map[[]byte] + userAgentIP sql.NullString + userAgentHeader database.Map[[]string] + expiration sql.NullTime ) err := rows.Scan( @@ -556,6 +571,7 @@ func prepareSessionsQuery() (sq.SelectBuilder, func(*sql.Rows) (*Sessions, error &totpCheckedAt, &otpSMSCheckedAt, &otpEmailCheckedAt, + &recoveryCodesCheckedAt, &metadata, &session.UserAgent.FingerprintID, &userAgentIP, @@ -580,6 +596,7 @@ func prepareSessionsQuery() (sq.SelectBuilder, func(*sql.Rows) (*Sessions, error session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time session.OTPSMSFactor.OTPCheckedAt = otpSMSCheckedAt.Time session.OTPEmailFactor.OTPCheckedAt = otpEmailCheckedAt.Time + session.RecoveryCodeFactor.RecoveryCodeCheckedAt = recoveryCodesCheckedAt.Time session.Metadata = metadata session.UserAgent.Header = http.Header(userAgentHeader) if userAgentIP.Valid { diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index e0d9cfda7..829258b72 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -40,6 +40,7 @@ var ( ` projections.sessions8.totp_checked_at,` + ` projections.sessions8.otp_sms_checked_at,` + ` projections.sessions8.otp_email_checked_at,` + + ` projections.sessions8.mfa_recovery_code_checked_at,` + ` projections.sessions8.metadata,` + ` projections.sessions8.token_id,` + ` projections.sessions8.user_agent_fingerprint_id,` + @@ -70,6 +71,7 @@ var ( ` projections.sessions8.totp_checked_at,` + ` projections.sessions8.otp_sms_checked_at,` + ` projections.sessions8.otp_email_checked_at,` + + ` projections.sessions8.mfa_recovery_code_checked_at,` + ` projections.sessions8.metadata,` + ` projections.sessions8.user_agent_fingerprint_id,` + ` projections.sessions8.user_agent_ip,` + @@ -102,6 +104,7 @@ var ( "totp_checked_at", "otp_sms_checked_at", "otp_email_checked_at", + "mfa_recovery_code_checked_at", "metadata", "token", "user_agent_fingerprint_id", @@ -131,6 +134,7 @@ var ( "totp_checked_at", "otp_sms_checked_at", "otp_email_checked_at", + "mfa_recovery_code_checked_at", "metadata", "user_agent_fingerprint_id", "user_agent_ip", @@ -192,6 +196,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), "fingerPrintID", "1.2.3.4", @@ -241,6 +246,9 @@ func Test_SessionsPrepare(t *testing.T) { OTPEmailFactor: SessionOTPFactor{ OTPCheckedAt: testNow, }, + RecoveryCodeFactor: SessionRecoveryCodeFactor{ + RecoveryCodeCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -283,6 +291,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), "fingerPrintID", "1.2.3.4", @@ -310,6 +319,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), "fingerPrintID", "1.2.3.4", @@ -359,6 +369,9 @@ func Test_SessionsPrepare(t *testing.T) { OTPEmailFactor: SessionOTPFactor{ OTPCheckedAt: testNow, }, + RecoveryCodeFactor: SessionRecoveryCodeFactor{ + RecoveryCodeCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -404,6 +417,9 @@ func Test_SessionsPrepare(t *testing.T) { OTPEmailFactor: SessionOTPFactor{ OTPCheckedAt: testNow, }, + RecoveryCodeFactor: SessionRecoveryCodeFactor{ + RecoveryCodeCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -499,6 +515,7 @@ func Test_SessionPrepare(t *testing.T) { testNow, testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), "tokenID", "fingerPrintID", @@ -543,6 +560,9 @@ func Test_SessionPrepare(t *testing.T) { OTPEmailFactor: SessionOTPFactor{ OTPCheckedAt: testNow, }, + RecoveryCodeFactor: SessionRecoveryCodeFactor{ + RecoveryCodeCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go index 40384743d..6348ece61 100644 --- a/internal/repository/session/eventstore.go +++ b/internal/repository/session/eventstore.go @@ -16,6 +16,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, OTPEmailChallengedType, eventstore.GenericEventMapper[OTPEmailChallengedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, OTPEmailSentType, eventstore.GenericEventMapper[OTPEmailSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, OTPEmailCheckedType, eventstore.GenericEventMapper[OTPEmailCheckedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, RecoveryCodeCheckedType, eventstore.GenericEventMapper[RecoveryCodeCheckedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, LifetimeSetType, eventstore.GenericEventMapper[LifetimeSetEvent]) diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 7aad34884..6661541ce 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -15,24 +15,25 @@ import ( ) const ( - sessionEventPrefix = "session." - AddedType = sessionEventPrefix + "added" - UserCheckedType = sessionEventPrefix + "user.checked" - PasswordCheckedType = sessionEventPrefix + "password.checked" - IntentCheckedType = sessionEventPrefix + "intent.checked" - WebAuthNChallengedType = sessionEventPrefix + "webAuthN.challenged" - WebAuthNCheckedType = sessionEventPrefix + "webAuthN.checked" - TOTPCheckedType = sessionEventPrefix + "totp.checked" - OTPSMSChallengedType = sessionEventPrefix + "otp.sms.challenged" - OTPSMSSentType = sessionEventPrefix + "otp.sms.sent" - OTPSMSCheckedType = sessionEventPrefix + "otp.sms.checked" - OTPEmailChallengedType = sessionEventPrefix + "otp.email.challenged" - OTPEmailSentType = sessionEventPrefix + "otp.email.sent" - OTPEmailCheckedType = sessionEventPrefix + "otp.email.checked" - TokenSetType = sessionEventPrefix + "token.set" - MetadataSetType = sessionEventPrefix + "metadata.set" - LifetimeSetType = sessionEventPrefix + "lifetime.set" - TerminateType = sessionEventPrefix + "terminated" + sessionEventPrefix = "session." + AddedType = sessionEventPrefix + "added" + UserCheckedType = sessionEventPrefix + "user.checked" + PasswordCheckedType = sessionEventPrefix + "password.checked" + IntentCheckedType = sessionEventPrefix + "intent.checked" + WebAuthNChallengedType = sessionEventPrefix + "webAuthN.challenged" + WebAuthNCheckedType = sessionEventPrefix + "webAuthN.checked" + TOTPCheckedType = sessionEventPrefix + "totp.checked" + OTPSMSChallengedType = sessionEventPrefix + "otp.sms.challenged" + OTPSMSSentType = sessionEventPrefix + "otp.sms.sent" + OTPSMSCheckedType = sessionEventPrefix + "otp.sms.checked" + OTPEmailChallengedType = sessionEventPrefix + "otp.email.challenged" + OTPEmailSentType = sessionEventPrefix + "otp.email.sent" + OTPEmailCheckedType = sessionEventPrefix + "otp.email.checked" + RecoveryCodeCheckedType = sessionEventPrefix + "recoveryCode.checked" + TokenSetType = sessionEventPrefix + "token.set" + MetadataSetType = sessionEventPrefix + "metadata.set" + LifetimeSetType = sessionEventPrefix + "lifetime.set" + TerminateType = sessionEventPrefix + "terminated" ) type AddedEvent struct { @@ -694,3 +695,36 @@ func TerminateEventMapper(event eventstore.Event) (eventstore.Event, error) { BaseEvent: *eventstore.BaseEventFromRepo(event), }, nil } + +type RecoveryCodeCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *RecoveryCodeCheckedEvent) Payload() interface{} { + return e +} + +func (e *RecoveryCodeCheckedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *RecoveryCodeCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = *base +} + +func NewRecoveryCodeCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + checkedAt time.Time, +) *RecoveryCodeCheckedEvent { + return &RecoveryCodeCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + RecoveryCodeCheckedType, + ), + CheckedAt: checkedAt, + } +} diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index 84c5bbb8c..10e811275 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -103,6 +103,10 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, HumanOTPEmailCodeSentType, eventstore.GenericEventMapper[HumanOTPEmailCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckSucceededType, eventstore.GenericEventMapper[HumanOTPEmailCheckSucceededEvent]) eventstore.RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckFailedType, eventstore.GenericEventMapper[HumanOTPEmailCheckFailedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanRecoveryCodesAddedType, eventstore.GenericEventMapper[HumanRecoveryCodesAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanRecoveryCodesRemovedType, eventstore.GenericEventMapper[HumanRecoveryCodesRemovedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanRecoveryCodeCheckSucceededType, eventstore.GenericEventMapper[HumanRecoveryCodeCheckSucceededEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanRecoveryCodeCheckFailedType, eventstore.GenericEventMapper[HumanRecoveryCodeCheckFailedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, HumanU2FTokenAddedType, HumanU2FAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanU2FTokenVerifiedType, HumanU2FVerifiedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanU2FTokenSignCountChangedType, HumanU2FSignCountChangedEventMapper) diff --git a/internal/repository/user/human_mfa_otp.go b/internal/repository/user/human_mfa_otp.go index cd79e5fa3..60b0e7270 100644 --- a/internal/repository/user/human_mfa_otp.go +++ b/internal/repository/user/human_mfa_otp.go @@ -12,19 +12,21 @@ import ( ) const ( - otpEventPrefix = mfaEventPrefix + "otp." - HumanMFAOTPAddedType = otpEventPrefix + "added" - HumanMFAOTPVerifiedType = otpEventPrefix + "verified" - HumanMFAOTPRemovedType = otpEventPrefix + "removed" - HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded" - HumanMFAOTPCheckFailedType = otpEventPrefix + "check.failed" - otpSMSEventPrefix = otpEventPrefix + "sms." - HumanOTPSMSAddedType = otpSMSEventPrefix + "added" - HumanOTPSMSRemovedType = otpSMSEventPrefix + "removed" - HumanOTPSMSCodeAddedType = otpSMSEventPrefix + "code.added" - HumanOTPSMSCodeSentType = otpSMSEventPrefix + "code.sent" - HumanOTPSMSCheckSucceededType = otpSMSEventPrefix + "check.succeeded" - HumanOTPSMSCheckFailedType = otpSMSEventPrefix + "check.failed" + otpEventPrefix = mfaEventPrefix + "otp." + HumanMFAOTPAddedType = otpEventPrefix + "added" + HumanMFAOTPVerifiedType = otpEventPrefix + "verified" + HumanMFAOTPRemovedType = otpEventPrefix + "removed" + HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded" + HumanMFAOTPCheckFailedType = otpEventPrefix + "check.failed" + + otpSMSEventPrefix = otpEventPrefix + "sms." + HumanOTPSMSAddedType = otpSMSEventPrefix + "added" + HumanOTPSMSRemovedType = otpSMSEventPrefix + "removed" + HumanOTPSMSCodeAddedType = otpSMSEventPrefix + "code.added" + HumanOTPSMSCodeSentType = otpSMSEventPrefix + "code.sent" + HumanOTPSMSCheckSucceededType = otpSMSEventPrefix + "check.succeeded" + HumanOTPSMSCheckFailedType = otpSMSEventPrefix + "check.failed" + otpEmailEventPrefix = otpEventPrefix + "email." HumanOTPEmailAddedType = otpEmailEventPrefix + "added" HumanOTPEmailRemovedType = otpEmailEventPrefix + "removed" diff --git a/internal/repository/user/human_mfa_recovery_codes.go b/internal/repository/user/human_mfa_recovery_codes.go new file mode 100644 index 000000000..1312c322c --- /dev/null +++ b/internal/repository/user/human_mfa_recovery_codes.go @@ -0,0 +1,149 @@ +package user + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + recoveryCodeEventPrefix = mfaEventPrefix + "recoverycode." + HumanRecoveryCodesAddedType = recoveryCodeEventPrefix + "added" + HumanRecoveryCodesRemovedType = recoveryCodeEventPrefix + "removed" + HumanRecoveryCodeCheckSucceededType = recoveryCodeEventPrefix + "check.succeeded" + HumanRecoveryCodeCheckFailedType = recoveryCodeEventPrefix + "check.failed" +) + +type HumanRecoveryCodesAddedEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo + Codes []string `json:"codes,omitempty"` +} + +func (e *HumanRecoveryCodesAddedEvent) Payload() interface{} { + return e +} + +func (e *HumanRecoveryCodesAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HumanRecoveryCodesAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanRecoveryCodesAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + hashedCodes []string, + optionalAuthRequest *AuthRequestInfo, +) *HumanRecoveryCodesAddedEvent { + return &HumanRecoveryCodesAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanRecoveryCodesAddedType, + ), + Codes: hashedCodes, + AuthRequestInfo: optionalAuthRequest, + } +} + +type HumanRecoveryCodesRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanRecoveryCodesRemovedEvent) Payload() interface{} { + return nil +} + +func (e *HumanRecoveryCodesRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HumanRecoveryCodesRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanRecoveryCodeRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + authRequest *AuthRequestInfo, +) *HumanRecoveryCodesRemovedEvent { + return &HumanRecoveryCodesRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanRecoveryCodesRemovedType, + ), + AuthRequestInfo: authRequest, + } +} + +type HumanRecoveryCodeCheckSucceededEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo + CodeChecked string `json:"codeChecked,omitempty"` +} + +func (e *HumanRecoveryCodeCheckSucceededEvent) Payload() interface{} { + return e +} + +func (e *HumanRecoveryCodeCheckSucceededEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HumanRecoveryCodeCheckSucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanRecoveryCodeCheckSucceededEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + codeChecked string, + info *AuthRequestInfo, +) *HumanRecoveryCodeCheckSucceededEvent { + return &HumanRecoveryCodeCheckSucceededEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanRecoveryCodeCheckSucceededType, + ), + AuthRequestInfo: info, + CodeChecked: codeChecked, + } +} + +type HumanRecoveryCodeCheckFailedEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanRecoveryCodeCheckFailedEvent) Payload() interface{} { + return e +} + +func (e *HumanRecoveryCodeCheckFailedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HumanRecoveryCodeCheckFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanRecoveryCodeCheckFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanRecoveryCodeCheckFailedEvent { + return &HumanRecoveryCodeCheckFailedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanRecoveryCodeCheckFailedType, + ), + AuthRequestInfo: info, + } +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 076577f3f..0f58feffc 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -97,6 +97,7 @@ Errors: InitCodeNotFound: Кодът за инициализиране не е намерен UsernameNotChanged: Потребителското име не е променено InvalidURLTemplate: URL шаблонът е невалиден + Locked: Потребителят е заключен Profile: NotFound: Профилът не е намерен NotChanged: Профилът не е променен @@ -179,6 +180,11 @@ Errors: NotExisting: U2F не съществува Passwordless: NotExisting: Без парола не съществува + RecoveryCodes: + InvalidCode: Предоставен е невалиден код за възстановяване + MaxCountExceeded: Превишен е максималният брой кодове за възстановяване + NotReady: Кодовете за възстановяване не са готови за употреба + ConfigInvalid: Конфигурацията на кодовете за възстановяване е невалидна WebAuthN: NotFound: WebAuthN Token не можа да бъде намерен BeginRegisterFailed: Неуспешна регистрация за стартиране на WebAuthN diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 45ca1d8a3..b65efdc37 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Inicializační kód nenalezen UsernameNotChanged: Uživatelské jméno nezměněno InvalidURLTemplate: Šablona URL je neplatná + Locked: Uživatel je uzamčen Profile: NotFound: Profil nenalezen NotChanged: Profil nezměněn @@ -177,6 +178,11 @@ Errors: NotExisting: U2F neexistuje Passwordless: NotExisting: Bezheslové přihlášení neexistuje + RecoveryCodes: + InvalidCode: Neplatný kód pro obnovení + MaxCountExceeded: Maximální počet kódů pro obnovení překročen + NotReady: Kódy pro obnovení nejsou připraveny k použití + ConfigInvalid: Konfigurace kódů pro obnovení je neplatná WebAuthN: NotFound: WebAuthN token nenalezen BeginRegisterFailed: Registrace WebAuthN selhala diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index cbcb61feb..ea7b7d67d 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Kein Initialisierungs-Code gefunden UsernameNotChanged: Benutzername wurde nicht verändert InvalidURLTemplate: URL Template ist ungültig + Locked: Benutzer ist gesperrt Profile: NotFound: Profil nicht gefunden NotChanged: Profil nicht verändert @@ -177,6 +178,11 @@ Errors: NotExisting: U2F existiert nicht Passwordless: NotExisting: Passwortlos existiert nicht + RecoveryCodes: + InvalidCode: Ungültiger Wiederherstellungscode angegeben + MaxCountExceeded: Maximale Anzahl von Wiederherstellungscodes überschritten + NotReady: Wiederherstellungscodes sind nicht bereit für die Verwendung + ConfigInvalid: Wiederherstellungscodes-Konfiguration ist ungültig WebAuthN: NotFound: WebAuthN Token konnte nicht gefunden werden BeginRegisterFailed: Es ist ein Fehler bei der WebAuthN Registrierung aufgetreten diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 91b90bf04..7392c08d4 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -96,6 +96,7 @@ Errors: InitCodeNotFound: Initialization Code not found UsernameNotChanged: Username not changed InvalidURLTemplate: URL Template is invalid + Locked: User is locked Profile: NotFound: Profile not found NotChanged: Profile not changed @@ -178,6 +179,11 @@ Errors: NotExisting: U2F does not exist Passwordless: NotExisting: Passwordless does not exist + RecoveryCodes: + InvalidCode: Invalid recovery code provided + MaxCountExceeded: Maximum number of recovery codes exceeded + NotReady: Recovery codes are not ready for use + ConfigInvalid: Recovery codes configuration is invalid WebAuthN: NotFound: WebAuthN Token could not be found BeginRegisterFailed: WebAuthN begin registration failed diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 71c4cdc59..2f96dff7a 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Código de inicialización no encontrado UsernameNotChanged: El nombre de usuario no cambió InvalidURLTemplate: La plantilla URL no es válida + Locked: El usuario está bloqueado Profile: NotFound: Perfil no encontrado NotChanged: El perfil no ha cambiado @@ -177,6 +178,11 @@ Errors: NotExisting: U2F no existe Passwordless: NotExisting: No existe inicio sin contraseña + RecoveryCodes: + InvalidCode: Código de recuperación inválido proporcionado + MaxCountExceeded: Número máximo de códigos de recuperación excedido + NotReady: Los códigos de recuperación no están listos para usar + ConfigInvalid: La configuración de códigos de recuperación es inválida WebAuthN: NotFound: No pude encontrarse un token WebAuthN BeginRegisterFailed: El comienzo del registro WebAuthN falló diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 4c038f780..f042e7bb8 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Code d'initialisation non trouvé UsernameNotChanged: Nom d'utilisateur non modifié InvalidURLTemplate: Le modèle d'URL n'est pas valide + Locked: L'utilisateur est verrouillé Profile: NotFound: Profil non trouvé NotChanged: Le profil n'a pas changé @@ -177,6 +178,11 @@ Errors: NotExisting: L'U2F n'existe pas Passwordless: NotExisting: Passwordless n'existe pas + RecoveryCodes: + InvalidCode: Code de récupération invalide fourni + MaxCountExceeded: Nombre maximum de codes de récupération dépassé + NotReady: Les codes de récupération ne sont pas prêts à être utilisés + ConfigInvalid: La configuration des codes de récupération est invalide WebAuthN: NotFound: Le token WebAuthN n'a pas été trouvé BeginRegisterFailed: L'enregistrement de WebAuthN a échoué diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index 7aa276c14..1369db042 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Az inicializáló kód nem található UsernameNotChanged: A felhasználónév nem változott InvalidURLTemplate: Az URL sablon érvénytelen + Locked: A felhasználó zárolva van Profile: NotFound: A profil nem található NotChanged: A profil nem változott @@ -177,6 +178,11 @@ Errors: NotExisting: Az U2F nem létezik Passwordless: NotExisting: Passwordless nem létezik + RecoveryCodes: + InvalidCode: Érvénytelen visszaállítási kód lett megadva + MaxCountExceeded: A visszaállítási kódok maximális száma túllépve + NotReady: A visszaállítási kódok nem készek a használatra + ConfigInvalid: A visszaállítási kódok konfigurációja érvénytelen WebAuthN: NotFound: A WebAuthN token nem található BeginRegisterFailed: A WebAuthN regisztráció megkezdése sikertelen diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 8ea9a1eb8..f10ce4d3b 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Kode Inisialisasi tidak ditemukan UsernameNotChanged: Nama pengguna tidak diubah InvalidURLTemplate: Templat URL tidak valid + Locked: Pengguna dikunci Profile: NotFound: Profil tidak ditemukan NotChanged: Profil tidak berubah @@ -177,6 +178,11 @@ Errors: NotExisting: U2F tidak ada Passwordless: NotExisting: Tanpa kata sandi tidak ada + RecoveryCodes: + InvalidCode: Kode pemulihan yang tidak valid disediakan + MaxCountExceeded: Jumlah maksimum kode pemulihan terlampaui + NotReady: Kode pemulihan tidak siap untuk digunakan + ConfigInvalid: Konfigurasi kode pemulihan tidak valid WebAuthN: NotFound: Token WebAuthN tidak dapat ditemukan BeginRegisterFailed: Pendaftaran awal WebAuthN gagal diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 903e948aa..bdfb430ae 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Codice di inizializzazione non trovato UsernameNotChanged: Nome utente non cambiato InvalidURLTemplate: Il modello di URL non è valido + Locked: L'utente è bloccato Profile: NotFound: Profilo non trovato NotChanged: Profilo non cambiato @@ -177,6 +178,11 @@ Errors: NotExisting: U2F non esistente Passwordless: NotExisting: Passwordless non esistente + RecoveryCodes: + InvalidCode: Codice di recupero non valido fornito + MaxCountExceeded: Numero massimo di codici di recupero superato + NotReady: I codici di recupero non sono pronti per l'uso + ConfigInvalid: La configurazione dei codici di recupero non è valida WebAuthN: NotFound: WebAuthN Token non trovato BeginRegisterFailed: WebAuthN inizializzazione non riuscita diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 5ca2c920f..776ddb4c3 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -96,6 +96,7 @@ Errors: InitCodeNotFound: 初期化コードが見つかりません UsernameNotChanged: ユーザー名は変更されていません InvalidURLTemplate: URLテンプレートが無効です + Locked: ユーザーがロックされています Profile: NotFound: プロファイルが見つかりません NotChanged: プロファイルが変更されていません @@ -178,6 +179,11 @@ Errors: NotExisting: U2Fは存在しません Passwordless: NotExisting: パスワードレスは存在しません + RecoveryCodes: + InvalidCode: 無効なリカバリーコードが提供されました + MaxCountExceeded: リカバリーコードの最大数を超過しました + NotReady: リカバリーコードの使用準備ができていません + ConfigInvalid: リカバリーコードの設定が無効です WebAuthN: NotFound: WebAuthNトークンが見つかりませんでした BeginRegisterFailed: WebAuthN登録の開始に失敗しました diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 729494c1f..6b44eafba 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -96,6 +96,7 @@ Errors: InitCodeNotFound: 초기화 코드를 찾을 수 없습니다 UsernameNotChanged: 사용자 이름이 변경되지 않았습니다 InvalidURLTemplate: URL 템플릿이 잘못되었습니다 + Locked: 사용자가 잠겨 있습니다 Profile: NotFound: 프로필을 찾을 수 없습니다 NotChanged: 프로필이 변경되지 않았습니다 @@ -178,6 +179,11 @@ Errors: NotExisting: U2F가 존재하지 않습니다 Passwordless: NotExisting: 패스워드리스가 존재하지 않습니다 + RecoveryCodes: + InvalidCode: 잘못된 복구 코드가 제공되었습니다 + MaxCountExceeded: 최대 복구 코드 수가 초과되었습니다 + NotReady: 복구 코드가 사용할 준비가 되지 않았습니다 + ConfigInvalid: 복구 코드 구성이 잘못되었습니다 WebAuthN: NotFound: WebAuthN 토큰을 찾을 수 없습니다 BeginRegisterFailed: WebAuthN 등록 시작에 실패했습니다 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 9ef147696..7c2df84e9 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Кодот за иницијализација не е пронајден UsernameNotChanged: Корисничкото име не е променето InvalidURLTemplate: Шаблонот за URL е невалиден + Locked: Корисникот е заклучен Profile: NotFound: Профилот не е пронајден NotChanged: Профилот не е променет @@ -176,6 +177,11 @@ Errors: NotExisting: U2F не постои Passwordless: NotExisting: Најава без лозинка не постои + RecoveryCodes: + InvalidCode: Невалиден код за опоравување + MaxCountExceeded: Максималниот број на кодови за опоравување е надминат + NotReady: Кодовите за опоравување не се готови за употреба + ConfigInvalid: Конфигурацијата за кодовите за опоравување е невалидна WebAuthN: NotFound: WebAuthN токенот не може да биде пронајден BeginRegisterFailed: Почетокот на регистрацијата на WebAuthN не успеа diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index ddc831b3d..22cfadec4 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Initialisatiecode niet gevonden UsernameNotChanged: Gebruikersnaam niet veranderd InvalidURLTemplate: URL-sjabloon is ongeldig + Locked: Gebruiker is vergrendeld Profile: NotFound: Profiel niet gevonden NotChanged: Profiel niet veranderd @@ -177,6 +178,11 @@ Errors: NotExisting: U2F bestaat niet Passwordless: NotExisting: Wachtwoordloos bestaat niet + RecoveryCodes: + InvalidCode: Ongeldige herstelcode opgegeven + MaxCountExceeded: Maximum aantal herstelcodes overschreden + NotReady: Herstelcodes zijn niet klaar voor gebruik + ConfigInvalid: Configuratie van herstelcodes is ongeldig WebAuthN: NotFound: WebAuthN Token kon niet worden gevonden BeginRegisterFailed: WebAuthN begin registratie mislukt diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 04d3c64fa..f4babd16b 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Kod inicjalizacji nie znaleziony UsernameNotChanged: Nazwa użytkownika nie została zmieniona InvalidURLTemplate: Szablon URL jest nieprawidłowy + Locked: Użytkownik jest zablokowany Profile: NotFound: Profil nie znaleziony NotChanged: Profil nie zmieniony @@ -177,6 +178,11 @@ Errors: NotExisting: U2F nie istnieje Passwordless: NotExisting: Bezhasłowe nie istnieje + RecoveryCodes: + InvalidCode: Podano nieprawidłowy kod odzyskiwania + MaxCountExceeded: Przekroczono maksymalną liczbę kodów odzyskiwania + NotReady: Kody odzyskiwania nie są gotowe do użycia + ConfigInvalid: Konfiguracja kodów odzyskiwania jest nieprawidłowa WebAuthN: NotFound: Token WebAuthN nie został znaleziony BeginRegisterFailed: Rozpoczęcie rejestracji WebAuthN nie powiodło się diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 73571476b..fd126da6e 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Código de inicialização não encontrado UsernameNotChanged: Nome de usuário não alterado InvalidURLTemplate: O modelo de URL é inválido + Locked: Usuário está bloqueado Profile: NotFound: Perfil não encontrado NotChanged: Perfil não alterado @@ -176,6 +177,11 @@ Errors: NotExisting: U2F não existe Passwordless: NotExisting: Autenticação sem senha não existe + RecoveryCodes: + InvalidCode: Código de recuperação inválido fornecido + MaxCountExceeded: Número máximo de códigos de recuperação excedido + NotReady: Códigos de recuperação não estão prontos para uso + ConfigInvalid: Configuração de códigos de recuperação é inválida WebAuthN: NotFound: Token WebAuthN não pôde ser encontrado BeginRegisterFailed: Falha ao iniciar o registro do WebAuthN diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index fede8eb85..a908809e9 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -96,6 +96,7 @@ Errors: InitCodeNotFound: Codul de inițializare nu a fost găsit UsernameNotChanged: Numele de utilizator nu a fost schimbat InvalidURLTemplate: Șablonul URL este invalid + Locked: Utilizatorul este blocat Profile: NotFound: Profilul nu a fost găsit NotChanged: Profilul nu a fost schimbat @@ -178,6 +179,11 @@ Errors: NotExisting: U2F nu există Passwordless: NotExisting: Fără parolă nu există + RecoveryCodes: + InvalidCode: Codul de recuperare furnizat este invalid + MaxCountExceeded: Numărul maxim de coduri de recuperare a fost depășit + NotReady: Codurile de recuperare nu sunt gata pentru utilizare + ConfigInvalid: Configurația codurilor de recuperare este invalidă WebAuthN: NotFound: Token-ul WebAuthN nu a putut fi găsit BeginRegisterFailed: Înregistrarea WebAuthN a început, dar a eșuat diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 39654d8b1..23d4f8c38 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Код инициализации не найден UsernameNotChanged: Имя пользователя не изменено InvalidURLTemplate: Шаблон URL-адреса недействителен. + Locked: Пользователь заблокирован Profile: NotFound: Профиль не найден NotChanged: Профиль не изменён @@ -177,6 +178,11 @@ Errors: NotExisting: Двухфакторная аутентификация не существует Passwordless: NotExisting: Беспарольный вход не существует + RecoveryCodes: + InvalidCode: Предоставлен неверный код восстановления + MaxCountExceeded: Превышено максимальное количество кодов восстановления + NotReady: Коды восстановления не готовы к использованию + ConfigInvalid: Конфигурация кодов восстановления недействительна WebAuthN: NotFound: Токен WebAuthN не найден BeginRegisterFailed: Ошибка начала регистрации WebAuthN diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index daf8da5cf..65b41bc7f 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -95,6 +95,7 @@ Errors: InitCodeNotFound: Initieringskod hittades inte UsernameNotChanged: Användarnamn ändrades inte InvalidURLTemplate: URL-mallen är felaktig + Locked: Användaren är låst Profile: NotFound: Profil hittades inte NotChanged: Profil ändrades inte @@ -177,6 +178,11 @@ Errors: NotExisting: U2F finns inte Passwordless: NotExisting: Lösenordsfri finns inte + RecoveryCodes: + InvalidCode: Ogiltig återställningskod angiven + MaxCountExceeded: Maximalt antal återställningskoder överskridet + NotReady: Återställningskoder är inte redo för användning + ConfigInvalid: Konfiguration för återställningskoder är ogiltig WebAuthN: NotFound: WebAuthN-token kunde inte hittas BeginRegisterFailed: WebAuthN-registrering misslyckades diff --git a/internal/static/i18n/tr.yaml b/internal/static/i18n/tr.yaml index 80a7c48eb..9b2d42bf2 100644 --- a/internal/static/i18n/tr.yaml +++ b/internal/static/i18n/tr.yaml @@ -96,6 +96,7 @@ Errors: InitCodeNotFound: Başlatma Kodu bulunamadı UsernameNotChanged: Kullanıcı adı değişmedi InvalidURLTemplate: URL Şablonu geçersiz + Locked: Kullanıcı kilitli Profile: NotFound: Profil bulunamadı NotChanged: Profil değişmedi @@ -178,6 +179,11 @@ Errors: NotExisting: U2F mevcut değil Passwordless: NotExisting: Şifresiz giriş mevcut değil + RecoveryCodes: + InvalidCode: Geçersiz kurtarma kodu sağlandı + MaxCountExceeded: Maksimum kurtarma kodu sayısı aşıldı + NotReady: Kurtarma kodları kullanıma hazır değil + ConfigInvalid: Kurtarma kodları yapılandırması geçersiz WebAuthN: NotFound: WebAuthN Token bulunamadı BeginRegisterFailed: WebAuthN kayıt başlatma başarısız diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 483ae0ed5..342b9d70a 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -96,6 +96,7 @@ Errors: InitCodeNotFound: 未找到初始化验证码 UsernameNotChanged: 用户名未更改 InvalidURLTemplate: URL模板无效 + Locked: 用户已被锁定 Profile: NotFound: 未找到个人资料 NotChanged: 个人资料未更改 @@ -178,6 +179,11 @@ Errors: NotExisting: U2F 不存在 Passwordless: NotExisting: 未设置无密码登录 + RecoveryCodes: + InvalidCode: 提供的恢复代码无效 + MaxCountExceeded: 恢复代码数量已超过最大限制 + NotReady: 恢复代码尚未准备好使用 + ConfigInvalid: 恢复代码配置无效 WebAuthN: NotFound: 找不到 WebAuthN 令牌 BeginRegisterFailed: WebAuthN 注册失败 diff --git a/internal/user/model/otp.go b/internal/user/model/otp.go index 8f8c48034..c73762508 100644 --- a/internal/user/model/otp.go +++ b/internal/user/model/otp.go @@ -21,4 +21,5 @@ const ( MFATypeUnspecified MFAType = iota MFATypeOTP MFATypeU2F + MFATypeRecoveryCode ) diff --git a/internal/user/repository/view/user_by_id.sql b/internal/user/repository/view/user_by_id.sql index e56d4e027..8465a3089 100644 --- a/internal/user/repository/view/user_by_id.sql +++ b/internal/user/repository/view/user_by_id.sql @@ -74,6 +74,7 @@ SELECT , u.instance_id , (SELECT EXISTS (SELECT true FROM verified_auth_methods WHERE method_type = 6)) AS otp_sms_added , (SELECT EXISTS (SELECT true FROM verified_auth_methods WHERE method_type = 7)) AS otp_email_added + , (SELECT EXISTS (SELECT true FROM verified_auth_methods WHERE method_type = 8)) AS recovery_code_added FROM projections.users14 u LEFT JOIN projections.users14_humans h ON u.instance_id = h.instance_id diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index cb9734462..8f9934f83 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -8734,6 +8734,30 @@ message ImportHumanUserRequest { } ]; } + message RecoveryCode { + oneof code_type { + string raw = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"a435b-34kns\""; + description: "\"Encoded hash of a recovery code in Modular Crypt Format: https://zitadel.com/docs/concepts/architecture/secrets#hashed-secrets\""; + min_length: 1, + max_length: 200; + } + ]; + string hash = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"$2a$12$lJ08fqVr8bFJilRVnDT9QeULI7YW.nT3iwUv6dyg0aCrfm3UY8XR2\""; + description: "\"Encoded hash of a recovery code in Modular Crypt Format: https://zitadel.com/docs/concepts/architecture/secrets#hashed-secrets\""; + min_length: 1, + max_length: 200; + } + ]; + } + } string user_name = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -8773,6 +8797,11 @@ message ImportHumanUserRequest { description: "To link your user directly with an external identity provider (Identity brokering)" } ]; + repeated RecoveryCode recovery_codes = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Import your existing recovery codes. You can import the raw code or the hashed code. The raw code will be hashed on import, the hashed code will be stored as is and need to be in Modular Crypt Format: https://zitadel.com/docs/concepts/architecture/secrets#hashed-secrets"; + } + ]; } message ImportHumanUserResponse { diff --git a/proto/zitadel/policy.proto b/proto/zitadel/policy.proto index 2bce22e2f..a1e756827 100644 --- a/proto/zitadel/policy.proto +++ b/proto/zitadel/policy.proto @@ -261,6 +261,7 @@ enum SecondFactorType { SECOND_FACTOR_TYPE_U2F = 2; SECOND_FACTOR_TYPE_OTP_EMAIL = 3; SECOND_FACTOR_TYPE_OTP_SMS = 4; + SECOND_FACTOR_TYPE_RECOVERY_CODES = 5; } enum MultiFactorType { diff --git a/proto/zitadel/session/v2/session.proto b/proto/zitadel/session/v2/session.proto index 08346e21d..70b350390 100644 --- a/proto/zitadel/session/v2/session.proto +++ b/proto/zitadel/session/v2/session.proto @@ -52,6 +52,7 @@ message Factors { TOTPFactor totp = 5; OTPFactor otp_sms = 6; OTPFactor otp_email = 7; + RecoveryCodeFactor recovery_code = 8; } message UserFactor { @@ -103,6 +104,11 @@ message OTPFactor { google.protobuf.Timestamp verified_at = 1; } +message RecoveryCodeFactor { + // The timestamp when the Recovery Code was last verified. + google.protobuf.Timestamp verified_at = 1; +} + message SearchQuery { oneof query { option (validate.required) = true; diff --git a/proto/zitadel/session/v2/session_service.proto b/proto/zitadel/session/v2/session_service.proto index e8b394e91..5ae45fcd1 100644 --- a/proto/zitadel/session/v2/session_service.proto +++ b/proto/zitadel/session/v2/session_service.proto @@ -535,6 +535,12 @@ message Checks { // containing the verification time. // Note that the OTP code is valid for a single use only and will be invalidated after a successful check. optional CheckOTP otp_email = 7; + + // Check the Recovery Code and update the session on success. + // Requires that the user is already checked, either in the previous or the same request. + // On successful Recovery Code check, the session's `factors` field will be updated with a `recovery_code` factor, + // containing the verification time. + optional CheckRecoveryCode recovery_code = 8; } message CheckUser { @@ -643,3 +649,17 @@ message CheckOTP { } ]; } + +message CheckRecoveryCode { + // The Recovery Code of the user to be checked. + // The code must match the exact code previously generated for the user, including dashes if any. + // On successful check, the recovery code will be invalidated and cannot be used again. + string code = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"1234567890\""; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/settings/v2/login_settings.proto b/proto/zitadel/settings/v2/login_settings.proto index 35c8e019e..43ca11a50 100644 --- a/proto/zitadel/settings/v2/login_settings.proto +++ b/proto/zitadel/settings/v2/login_settings.proto @@ -151,6 +151,7 @@ enum SecondFactorType { SECOND_FACTOR_TYPE_U2F = 2; SECOND_FACTOR_TYPE_OTP_EMAIL = 3; SECOND_FACTOR_TYPE_OTP_SMS = 4; + SECOND_FACTOR_TYPE_RECOVERY_CODES = 5; } enum MultiFactorType { diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 24a292465..6e9d4e6a3 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -1145,6 +1145,53 @@ service UserService { }; } + // Generate single-use recovery codes for a user + // + // Generate new single-use recovery codes for the authenticated user. Recovery codes can be used to recover access to the account if other second factors are not available. + rpc GenerateRecoveryCodes (GenerateRecoveryCodesRequest) returns (GenerateRecoveryCodesResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/recovery_codes" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Remove recovery codes from a user + // + // Remove all recovery codes from the authenticated user. This will disable the recovery code second factor. + rpc RemoveRecoveryCodes (RemoveRecoveryCodesRequest) returns (RemoveRecoveryCodesResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/recovery_codes" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + // Add OTP Email for a user // // Add a new One-Time Password (OTP) Email factor to the authenticated user. OTP Email will enable the user to verify a OTP with the latest verified email. The email has to be verified to add the second factor.. @@ -2886,6 +2933,46 @@ message RemoveTOTPResponse { zitadel.object.v2.Details details = 1; } +message GenerateRecoveryCodesRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; + int32 count = 2 [ + (validate.rules).int32 = {gte: 1, lte: 50}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The number of recovery codes to generate" + example: "5"; + } + ]; +} + +message GenerateRecoveryCodesResponse { + zitadel.object.v2.Details details = 1; + repeated string recovery_codes = 2; +} + +message RemoveRecoveryCodesRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; +} + +message RemoveRecoveryCodesResponse { + zitadel.object.v2.Details details = 1; +} + message AddOTPSMSRequest { string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -3262,6 +3349,7 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_U2F = 5; AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6; AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; + AUTHENTICATION_METHOD_TYPE_RECOVERY_CODE = 8; } message ListAuthenticationFactorsRequest{