feat: Add recovery code MFA support (#9954)

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

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

# Which Problems Are Solved

Zitadel currently lacks support for recovery codes.

# How the Problems Are Solved

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

```
Example recovery code flow:

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

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

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

# Additional Changes

None

# Additional Context

- Closes #6898

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Zach Hirschtritt
2025-11-24 06:44:48 +00:00
committed by GitHub
co-authored by Livio Spring
parent afa92e55f7
commit 5beeb5738a
101 changed files with 3397 additions and 169 deletions
+9
View File
@@ -740,6 +740,15 @@ SystemDefaults:
# If this is empty, the issuer is the requested domain
# This is helpful in scenarios with multiple ZITADEL environments or virtual instances
Issuer: "ZITADEL" # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_OTP_ISSUER
RecoveryCodes:
# The maximum number of recovery codes that a user can hold at any given time
MaxCount: 10 # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_MAXCOUNT
# The format of the recovery codes, supported: uuid, alphanumeric
Format: alphanumeric # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_FORMAT
# Length of the recovery codes (only applies to alphanumeric format)
Length: 10 # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_LENGTH
# Whether to include hyphens in the recovery codes (alphanumeric: hyphen in middle, uuid: keep/remove all hyphens)
WithHyphen: true # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_RECOVERYCODES_WITHHYPHEN
Tarpit:
# The amount of failed attempts, the tarpit should start.
MinFailedAttempts: 5 # ZITADEL_SYSTEMDEFAULTS_TARPIT_MINFAILEDATTEMPTS
+1 -1
View File
@@ -1,3 +1,3 @@
ALTER INDEX IF EXISTS projections.user_metadata5_metadata_key_idx
RENAME TO user_metadata5_key_idx;
DROP INDEX IF EXISTS projections.user_metadata5_metadata_value_idx;
DROP INDEX IF EXISTS projections.user_metadata5_metadata_value_idx;
+27
View File
@@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 66.sql
addSessionRecoveryCodeCheckedAt string
)
type SessionRecoveryCodeCheckedAt struct {
dbClient *database.DB
}
func (mig *SessionRecoveryCodeCheckedAt) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addSessionRecoveryCodeCheckedAt)
return err
}
func (mig *SessionRecoveryCodeCheckedAt) String() string {
return "66_session_recovery_code_checked_at"
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE IF EXISTS projections.sessions8
ADD COLUMN IF NOT EXISTS mfa_recovery_code_checked_at TIMESTAMPTZ;
+1
View File
@@ -162,6 +162,7 @@ type Steps struct {
s63AlterResourceCounts *AlterResourceCounts
s64ChangePushPosition *ChangePushPosition
s65FixUserMetadata5Index *FixUserMetadata5Index
s66SessionRecoveryCodeCheckedAt *SessionRecoveryCodeCheckedAt
}
func MustNewSteps(v *viper.Viper) *Steps {
+2
View File
@@ -223,6 +223,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s63AlterResourceCounts = &AlterResourceCounts{dbClient: dbClient}
steps.s64ChangePushPosition = &ChangePushPosition{dbClient: dbClient}
steps.s65FixUserMetadata5Index = &FixUserMetadata5Index{dbClient: dbClient}
steps.s66SessionRecoveryCodeCheckedAt = &SessionRecoveryCodeCheckedAt{dbClient: dbClient}
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
logging.OnError(err).Fatal("unable to start projections")
@@ -338,6 +339,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s43CreateFieldsDomainIndex,
steps.s48Apps7SAMLConfigsLoginVersion,
steps.s59SetupWebkeys, // this step needs commands.
steps.s66SessionRecoveryCodeCheckedAt,
} {
setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed")
if setupErr != nil {
+31 -23
View File
@@ -94,6 +94,36 @@ type QuotasConfig struct {
}
func MustNewConfig(v *viper.Viper) *Config {
config, err := readConfig(v)
logging.OnError(err).Fatal("unable to read config")
err = config.Log.SetLogger()
logging.OnError(err).Fatal("unable to set logger")
err = config.Tracing.NewTracer()
logging.OnError(err).Fatal("unable to set tracer")
err = config.Metrics.NewMeter()
logging.OnError(err).Fatal("unable to set meter")
err = config.Profiler.NewProfiler()
logging.OnError(err).Fatal("unable to set profiler")
id.Configure(config.Machine)
if config.Actions != nil {
actions.SetHTTPConfig(&config.Actions.HTTP)
}
err = config.SystemDefaults.Validate()
logging.OnError(err).Fatal("system defaults config invalid")
// Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API.
config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings
return config
}
func readConfig(v *viper.Viper) (*Config, error) {
config := new(Config)
err := v.Unmarshal(config,
@@ -115,27 +145,5 @@ func MustNewConfig(v *viper.Viper) *Config {
mapstructure.TextUnmarshallerHookFunc(),
)),
)
logging.OnError(err).Fatal("unable to read config")
err = config.Log.SetLogger()
logging.OnError(err).Fatal("unable to set logger")
err = config.Tracing.NewTracer()
logging.OnError(err).Fatal("unable to set tracer")
err = config.Metrics.NewMeter()
logging.OnError(err).Fatal("unable to set meter")
err = config.Profiler.NewProfiler()
logging.OnError(err).Fatal("unable to set profiler")
id.Configure(config.Machine)
if config.Actions != nil {
actions.SetHTTPConfig(&config.Actions.HTTP)
}
// Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API.
config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings
return config
return config, err
}
+3 -2
View File
@@ -19,7 +19,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
)
func TestMustNewConfig(t *testing.T) {
func Test_readConfig(t *testing.T) {
encodedKey := "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
decodedKey, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil {
@@ -278,7 +278,8 @@ Actions:
v := viper.New()
v.SetConfigType("yaml")
require.NoError(t, v.ReadConfig(strings.NewReader(tt.args.yaml)))
got := MustNewConfig(v)
got, err := readConfig(v)
require.NoError(t, err)
tt.want(t, got)
})
}
@@ -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,
]
: [];
+2 -1
View File
@@ -2396,7 +2396,8 @@
"1": "Еднократна парола чрез приложение за удостоверяване на автентичността (TOTP)",
"2": "Пръстов отпечатък, ключове за сигурност, Face ID и други",
"3": "Еднократна парола по имейл (Email OTP)",
"4": "Еднократна парола чрез SMS (SMS OTP)"
"4": "Еднократна парола чрез SMS (SMS OTP)",
"5": "Кодове за възстановяване"
}
},
"LOGINPOLICY": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -2423,7 +2423,8 @@
"1": "認証アプリ用ワンタイムパスワード(TOTP)",
"2": "指紋、セキュリティキー、フェイスIDなど",
"3": "Eメール用ワンタイムパスワード(email OTP)",
"4": "SMS用ワンタイムパスワード(SMS OTP)"
"4": "SMS用ワンタイムパスワード(SMS OTP)",
"5": "リカバリーコード"
}
},
"LOGINPOLICY": {
+2 -1
View File
@@ -2423,7 +2423,8 @@
"1": "인증 앱을 통한 일회성 비밀번호(TOTP)",
"2": "지문, 보안 키, Face ID 및 기타",
"3": "이메일을 통한 일회성 비밀번호(이메일 OTP)",
"4": "SMS를 통한 일회성 비밀번호(SMS OTP)"
"4": "SMS를 통한 일회성 비밀번호(SMS OTP)",
"5": "복구 코드"
}
},
"LOGINPOLICY": {
+2 -1
View File
@@ -2398,7 +2398,8 @@
"1": "Еднократна лозинка преку апликација за автентикатор (TOTP)",
"2": "Отпечаток на прст, безбедносни клучеви, Face ID и други",
"3": "Еднократна лозинка по е-пошта (Еmail OTP)",
"4": "Еднократна лозинка преку СМС (SMS OTP)"
"4": "Еднократна лозинка преку СМС (SMS OTP)",
"5": "Кодови за обновување"
}
},
"LOGINPOLICY": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -2514,7 +2514,8 @@
"1": "Одноразовый пароль (OTP)",
"2": "Отпечаток пальца, ключи безопасности, Face ID и другие",
"3": "Одноразовый пароль по электронной почте",
"4": "Одноразовый пароль по SMS"
"4": "Одноразовый пароль по SMS",
"5": "Коды восстановления"
}
},
"LOGINPOLICY": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -2430,7 +2430,8 @@
"1": "身份验证应用程序的一次性密码(TOTP)",
"2": "指纹、安全密钥、Face ID 等",
"3": "电子邮件一次性密码(email OTP)",
"4": "短信一次性密码(SMS OTP)"
"4": "短信一次性密码(SMS OTP)",
"5": "恢复码"
}
},
"LOGINPOLICY": {
+19
View File
@@ -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) {
+4
View File
@@ -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) {
+17 -7
View File
@@ -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
+3
View File
@@ -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
}
+2
View File
@@ -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
}
+2 -1
View File
@@ -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++
+1
View File
@@ -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:
+1
View File
@@ -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
}
+6
View File
@@ -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,
+4
View File
@@ -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)
+28 -18
View File
@@ -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
}
+52 -8
View File
@@ -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(),
}
}
+189
View File
@@ -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
}
+3
View File
@@ -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
}
+1
View File
@@ -8,6 +8,7 @@ const (
SecondFactorTypeU2F
SecondFactorTypeOTPEmail
SecondFactorTypeOTPSMS
SecondFactorTypeRecoveryCodes
secondFactorCount
)
+136
View File
@@ -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
View File
@@ -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
}
+3
View File
@@ -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,
+26
View File
@@ -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 {
+14 -3
View File
@@ -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
View File
@@ -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 {
+20
View File
@@ -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])
+52 -18
View File
@@ -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,
}
}
+4
View File
@@ -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)
+15 -13
View File
@@ -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,
}
}
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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ó
+6
View File
@@ -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é
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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登録の開始に失敗しました
+6
View File
@@ -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 등록 시작에 실패했습니다
+6
View File
@@ -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 не успеа
+6
View File
@@ -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
+6
View File
@@ -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ę
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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 注册失败
+1
View File
@@ -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
+29
View File
@@ -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 {
+1
View File
@@ -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 {
+6
View File
@@ -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