mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-29 18:42:15 +00:00
feat: Add recovery code MFA support (#9954)
<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> One-time recovery codes are a common multi-factor authentication (MFA) backup method, letting users access their account if they lose other MFA devices. Support for recovery codes can also reduce support burden for users locked out of their accounts and provide a more secure and reliable form of verification than security questions. # Which Problems Are Solved Zitadel currently lacks support for recovery codes. # How the Problems Are Solved This PR partially addresses recovery code support in Zitadel. Importantly, it adds recovery codes as a new 2FA `Factor` and an additional `Check` type for the Session API. ``` Example recovery code flow: 1. User generates N new recovery codes using `POST /v2/users/{user_id}/recovery_codes` 2. Zitadel hashes and stores these codes and returns the un-hashed codes in the response 3. User creates new session with an additional check: `recoveryCode` 4. Code is checked against hash and, if valid, cannot be used again 5. User attempts to adds N more codes using the same endpoint 6. If `remaining_codes + N <= RecoveryCodes->MaxCount` config value, then recovery codes are added in addition to original codes 7. User can remove all recovery codes using `DEL /v2/users/:userId/recovery_codes` ``` This PR adds: - [x] Session recovery_code check support on `POST+PATCH /v2/sessions` endpoints - [x] Adds `mfa_recovery_code_checked_at` column (default null) to `projections.sessions8` table - [x] Support for `SECOND_FACTOR_TYPE_RECOVERY_CODES` as available 2FA method on login policy - [x] Support for importing recovery codes in /import code Missing, will _not_ implement in this PR: - [ ] Admin console support for displaying Recovery Code settings for user(s) - [ ] Zitadel Typescript login support for recovery codes TODO: - [x] Additional unit and integration tests - [x] Error translations # Additional Changes None # Additional Context - Closes #6898 --------- Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
co-authored by
Livio Spring
parent
afa92e55f7
commit
5beeb5738a
@@ -740,6 +740,15 @@ SystemDefaults:
|
||||
# If this is empty, the issuer is the requested domain
|
||||
# This is helpful in scenarios with multiple ZITADEL environments or virtual instances
|
||||
Issuer: "ZITADEL" # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_OTP_ISSUER
|
||||
RecoveryCodes:
|
||||
# The maximum number of recovery codes that a user can hold at any given time
|
||||
MaxCount: 10 # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_MAXCOUNT
|
||||
# The format of the recovery codes, supported: uuid, alphanumeric
|
||||
Format: alphanumeric # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_FORMAT
|
||||
# Length of the recovery codes (only applies to alphanumeric format)
|
||||
Length: 10 # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_LENGTH
|
||||
# Whether to include hyphens in the recovery codes (alphanumeric: hyphen in middle, uuid: keep/remove all hyphens)
|
||||
WithHyphen: true # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_WITHHYPHEN
|
||||
Tarpit:
|
||||
# The amount of failed attempts, the tarpit should start.
|
||||
MinFailedAttempts: 5 # ZITADEL_SYSTEMDEFAULTS_TARPIT_MINFAILEDATTEMPTS
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
ALTER INDEX IF EXISTS projections.user_metadata5_metadata_key_idx
|
||||
RENAME TO user_metadata5_key_idx;
|
||||
DROP INDEX IF EXISTS projections.user_metadata5_metadata_value_idx;
|
||||
DROP INDEX IF EXISTS projections.user_metadata5_metadata_value_idx;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE IF EXISTS projections.sessions8
|
||||
ADD COLUMN IF NOT EXISTS mfa_recovery_code_checked_at TIMESTAMPTZ;
|
||||
@@ -162,6 +162,7 @@ type Steps struct {
|
||||
s63AlterResourceCounts *AlterResourceCounts
|
||||
s64ChangePushPosition *ChangePushPosition
|
||||
s65FixUserMetadata5Index *FixUserMetadata5Index
|
||||
s66SessionRecoveryCodeCheckedAt *SessionRecoveryCodeCheckedAt
|
||||
}
|
||||
|
||||
func MustNewSteps(v *viper.Viper) *Steps {
|
||||
|
||||
@@ -223,6 +223,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
|
||||
steps.s63AlterResourceCounts = &AlterResourceCounts{dbClient: dbClient}
|
||||
steps.s64ChangePushPosition = &ChangePushPosition{dbClient: dbClient}
|
||||
steps.s65FixUserMetadata5Index = &FixUserMetadata5Index{dbClient: dbClient}
|
||||
steps.s66SessionRecoveryCodeCheckedAt = &SessionRecoveryCodeCheckedAt{dbClient: dbClient}
|
||||
|
||||
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
|
||||
logging.OnError(err).Fatal("unable to start projections")
|
||||
@@ -338,6 +339,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
|
||||
steps.s43CreateFieldsDomainIndex,
|
||||
steps.s48Apps7SAMLConfigsLoginVersion,
|
||||
steps.s59SetupWebkeys, // this step needs commands.
|
||||
steps.s66SessionRecoveryCodeCheckedAt,
|
||||
} {
|
||||
setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed")
|
||||
if setupErr != nil {
|
||||
|
||||
+31
-23
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
: [];
|
||||
|
||||
|
||||
@@ -2396,7 +2396,8 @@
|
||||
"1": "Еднократна парола чрез приложение за удостоверяване на автентичността (TOTP)",
|
||||
"2": "Пръстов отпечатък, ключове за сигурност, Face ID и други",
|
||||
"3": "Еднократна парола по имейл (Email OTP)",
|
||||
"4": "Еднократна парола чрез SMS (SMS OTP)"
|
||||
"4": "Еднократна парола чрез SMS (SMS OTP)",
|
||||
"5": "Кодове за възстановяване"
|
||||
}
|
||||
},
|
||||
"LOGINPOLICY": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2423,7 +2423,8 @@
|
||||
"1": "認証アプリ用ワンタイムパスワード(TOTP)",
|
||||
"2": "指紋、セキュリティキー、フェイスIDなど",
|
||||
"3": "Eメール用ワンタイムパスワード(email OTP)",
|
||||
"4": "SMS用ワンタイムパスワード(SMS OTP)"
|
||||
"4": "SMS用ワンタイムパスワード(SMS OTP)",
|
||||
"5": "リカバリーコード"
|
||||
}
|
||||
},
|
||||
"LOGINPOLICY": {
|
||||
|
||||
@@ -2423,7 +2423,8 @@
|
||||
"1": "인증 앱을 통한 일회성 비밀번호(TOTP)",
|
||||
"2": "지문, 보안 키, Face ID 및 기타",
|
||||
"3": "이메일을 통한 일회성 비밀번호(이메일 OTP)",
|
||||
"4": "SMS를 통한 일회성 비밀번호(SMS OTP)"
|
||||
"4": "SMS를 통한 일회성 비밀번호(SMS OTP)",
|
||||
"5": "복구 코드"
|
||||
}
|
||||
},
|
||||
"LOGINPOLICY": {
|
||||
|
||||
@@ -2398,7 +2398,8 @@
|
||||
"1": "Еднократна лозинка преку апликација за автентикатор (TOTP)",
|
||||
"2": "Отпечаток на прст, безбедносни клучеви, Face ID и други",
|
||||
"3": "Еднократна лозинка по е-пошта (Еmail OTP)",
|
||||
"4": "Еднократна лозинка преку СМС (SMS OTP)"
|
||||
"4": "Еднократна лозинка преку СМС (SMS OTP)",
|
||||
"5": "Кодови за обновување"
|
||||
}
|
||||
},
|
||||
"LOGINPOLICY": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2514,7 +2514,8 @@
|
||||
"1": "Одноразовый пароль (OTP)",
|
||||
"2": "Отпечаток пальца, ключи безопасности, Face ID и другие",
|
||||
"3": "Одноразовый пароль по электронной почте",
|
||||
"4": "Одноразовый пароль по SMS"
|
||||
"4": "Одноразовый пароль по SMS",
|
||||
"5": "Коды восстановления"
|
||||
}
|
||||
},
|
||||
"LOGINPOLICY": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2430,7 +2430,8 @@
|
||||
"1": "身份验证应用程序的一次性密码(TOTP)",
|
||||
"2": "指纹、安全密钥、Face ID 等",
|
||||
"3": "电子邮件一次性密码(email OTP)",
|
||||
"4": "短信一次性密码(SMS OTP)"
|
||||
"4": "短信一次性密码(SMS OTP)",
|
||||
"5": "恢复码"
|
||||
}
|
||||
},
|
||||
"LOGINPOLICY": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const (
|
||||
SecondFactorTypeU2F
|
||||
SecondFactorTypeOTPEmail
|
||||
SecondFactorTypeOTPSMS
|
||||
SecondFactorTypeRecoveryCodes
|
||||
|
||||
secondFactorCount
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+19
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+67
-50
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ó
|
||||
|
||||
@@ -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é
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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登録の開始に失敗しました
|
||||
|
||||
@@ -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 등록 시작에 실패했습니다
|
||||
|
||||
@@ -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 не успеа
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ę
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 注册失败
|
||||
|
||||
@@ -21,4 +21,5 @@ const (
|
||||
MFATypeUnspecified MFAType = iota
|
||||
MFATypeOTP
|
||||
MFATypeU2F
|
||||
MFATypeRecoveryCode
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\"";
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user