diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index e5244cbb17..3573242d59 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -383,7 +383,12 @@ SystemDefaults: # Hasher: # Algorithm: "scrypt" # Cost: 15 - + + # Hasher: + # Algorithm: "pbkdf2" + # Rounds: 290000 + # Hash: "sha256" # Can be "sha1", "sha224", "sha256", "sha384" or "sha512" + # Verifiers enable the possibility of verifying # passwords that are previously hashed using another # algorithm then the Hasher. @@ -402,6 +407,7 @@ SystemDefaults: # - "bcrypt" # - "md5" # - "scrypt" + # - "pbkdf2" # verifier for all pbkdf2 hash modes. Multifactors: OTP: # If this is empty, the issuer is the requested domain @@ -564,6 +570,20 @@ DefaultInstance: IncludeUpperLetters: true IncludeDigits: true IncludeSymbols: false + OTPSMS: + Length: 8 + Expiry: "5m" + IncludeLowerLetters: false + IncludeUpperLetters: false + IncludeDigits: true + IncludeSymbols: false + OTPEmail: + Length: 8 + Expiry: "5m" + IncludeLowerLetters: false + IncludeUpperLetters: false + IncludeDigits: true + IncludeSymbols: false PasswordComplexityPolicy: MinLength: 8 HasLowercase: true diff --git a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts index 584476d293..916e805d01 100644 --- a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts +++ b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts @@ -146,7 +146,12 @@ export class FactorTableComponent { this.componentType === LoginMethodComponentType.MultiFactor ? [MultiFactorType.MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION] : this.componentType === LoginMethodComponentType.SecondFactor - ? [SecondFactorType.SECOND_FACTOR_TYPE_U2F, SecondFactorType.SECOND_FACTOR_TYPE_OTP] + ? [ + SecondFactorType.SECOND_FACTOR_TYPE_U2F, + SecondFactorType.SECOND_FACTOR_TYPE_OTP, + SecondFactorType.SECOND_FACTOR_TYPE_OTP_SMS, + SecondFactorType.SECOND_FACTOR_TYPE_OTP_EMAIL, + ] : []; const filtered = (allTypes as Array).filter((type) => !this.list.includes(type)); diff --git a/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.html b/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.html index e4c3600926..5a09a71362 100644 --- a/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.html +++ b/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.html @@ -3,15 +3,6 @@
- - {{ 'SETTING.SECRETS.GENERATORTYPE' | translate }} - - - {{ 'SETTING.SECRETS.TYPE.' + gen | translate }} - - - -

{{ 'SETTING.SECRETS.TYPE.' + generatorType?.value | translate }}

diff --git a/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.ts b/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.ts index c8d888f914..6d72286111 100644 --- a/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.ts +++ b/console/src/app/modules/policies/secret-generator/dialog-add-secret-generator/dialog-add-secret-generator.component.ts @@ -7,7 +7,6 @@ import { import { Duration } from 'google-protobuf/google/protobuf/duration_pb'; import { requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { UpdateSecretGeneratorRequest } from 'src/app/proto/generated/zitadel/admin_pb'; -import { SecretGeneratorType } from 'src/app/proto/generated/zitadel/settings_pb'; @Component({ selector: 'cnsl-dialog-add-secret-generator', @@ -15,15 +14,6 @@ import { SecretGeneratorType } from 'src/app/proto/generated/zitadel/settings_pb styleUrls: ['./dialog-add-secret-generator.component.scss'], }) export class DialogAddSecretGeneratorComponent { - public SecretGeneratorType: any = SecretGeneratorType; - public availableGenerators: SecretGeneratorType[] = [ - SecretGeneratorType.SECRET_GENERATOR_TYPE_INIT_CODE, - SecretGeneratorType.SECRET_GENERATOR_TYPE_VERIFY_EMAIL_CODE, - SecretGeneratorType.SECRET_GENERATOR_TYPE_VERIFY_PHONE_CODE, - SecretGeneratorType.SECRET_GENERATOR_TYPE_PASSWORD_RESET_CODE, - SecretGeneratorType.SECRET_GENERATOR_TYPE_PASSWORDLESS_INIT_CODE, - SecretGeneratorType.SECRET_GENERATOR_TYPE_APP_SECRET, - ]; public req: UpdateSecretGeneratorRequest = new UpdateSecretGeneratorRequest(); public specsForm!: UntypedFormGroup; @@ -33,17 +23,19 @@ export class DialogAddSecretGeneratorComponent { public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any, ) { + let exp = 1; + if (data.config?.expiry !== undefined) { + exp = this.durationToHour(data.config?.expiry); + } this.specsForm = this.fb.group({ - generatorType: [SecretGeneratorType.SECRET_GENERATOR_TYPE_APP_SECRET, [requiredValidator]], - expiry: [1, [requiredValidator]], - includeDigits: [true, [requiredValidator]], - includeLowerLetters: [true, [requiredValidator]], - includeSymbols: [true, [requiredValidator]], - includeUpperLetters: [true, [requiredValidator]], - length: [6, [requiredValidator]], + generatorType: [data.type, [requiredValidator]], + expiry: [exp, [requiredValidator]], + length: [data.config?.length ?? 6, [requiredValidator]], + includeDigits: [data.config?.includeDigits ?? true, [requiredValidator]], + includeLowerLetters: [data.config?.includeSymbols ?? true, [requiredValidator]], + includeSymbols: [data.config?.includeLowerLetters ?? true, [requiredValidator]], + includeUpperLetters: [data.config?.includeUpperLetters ?? true, [requiredValidator]], }); - - this.generatorType?.setValue(data.type); } public closeDialog(): void { @@ -52,10 +44,7 @@ export class DialogAddSecretGeneratorComponent { public closeDialogWithRequest(): void { this.req.setGeneratorType(this.generatorType?.value); - - const expiry = new Duration().setSeconds((this.expiry?.value ?? 1) * 60 * 60); - - this.req.setExpiry(expiry); + this.req.setExpiry(this.hourToDuration(this.expiry?.value)); this.req.setIncludeDigits(this.includeDigits?.value); this.req.setIncludeLowerLetters(this.includeLowerLetters?.value); this.req.setIncludeSymbols(this.includeSymbols?.value); @@ -92,4 +81,18 @@ export class DialogAddSecretGeneratorComponent { public get length(): AbstractControl | null { return this.specsForm.get('length'); } + + private durationToHour(duration: Duration.AsObject): number { + if (duration.seconds === 0) { + return 0; + } + return (duration.seconds + duration.nanos / 1000000) / 3600; + } + + private hourToDuration(hour: number): Duration { + const exp = hour * 60 * 60; + const sec = Math.floor(exp); + const nanos = Math.round((exp - sec) * 1000000); + return new Duration().setSeconds(sec).setNanos(nanos); + } } diff --git a/console/src/app/modules/policies/secret-generator/secret-generator.component.ts b/console/src/app/modules/policies/secret-generator/secret-generator.component.ts index 1bae7b67e5..44fc85df3d 100644 --- a/console/src/app/modules/policies/secret-generator/secret-generator.component.ts +++ b/console/src/app/modules/policies/secret-generator/secret-generator.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; -import { UpdateSecretGeneratorRequest, UpdateSecretGeneratorResponse } from 'src/app/proto/generated/zitadel/admin_pb'; +import { UpdateSecretGeneratorRequest } from 'src/app/proto/generated/zitadel/admin_pb'; import { OIDCSettings, SecretGenerator, SecretGeneratorType } from 'src/app/proto/generated/zitadel/settings_pb'; import { AdminService } from 'src/app/services/admin.service'; import { ToastService } from 'src/app/services/toast.service'; @@ -25,7 +25,10 @@ export class SecretGeneratorComponent implements OnInit { SecretGeneratorType.SECRET_GENERATOR_TYPE_PASSWORD_RESET_CODE, SecretGeneratorType.SECRET_GENERATOR_TYPE_PASSWORDLESS_INIT_CODE, SecretGeneratorType.SECRET_GENERATOR_TYPE_APP_SECRET, + SecretGeneratorType.SECRET_GENERATOR_TYPE_OTP_SMS, + SecretGeneratorType.SECRET_GENERATOR_TYPE_OTP_EMAIL, ]; + constructor(private service: AdminService, private toast: ToastService, private dialog: MatDialog) {} ngOnInit(): void { @@ -48,25 +51,12 @@ export class SecretGeneratorComponent implements OnInit { }); } - private updateData(): Promise | void { - const dialogRef = this.dialog.open(DialogAddSecretGeneratorComponent, { - data: {}, - width: '400px', - }); - - dialogRef.afterClosed().subscribe((req: UpdateSecretGeneratorRequest) => { - if (req) { - return (this.service as AdminService).updateSecretGenerator(req); - } else { - return; - } - }); - } - public openGeneratorDialog(generatorType: SecretGeneratorType): void { + let config = this.generators.find((gen) => gen.generatorType === generatorType); const dialogRef = this.dialog.open(DialogAddSecretGeneratorComponent, { data: { type: generatorType, + config: config, }, width: '400px', }); @@ -77,6 +67,9 @@ export class SecretGeneratorComponent implements OnInit { .updateSecretGenerator(req) .then(() => { this.toast.showInfo('SETTING.SECRETS.UPDATED', true); + setTimeout(() => { + this.fetchData(); + }, 2000); }) .catch((error) => { this.toast.showError(error); @@ -86,21 +79,4 @@ export class SecretGeneratorComponent implements OnInit { } }); } - - public savePolicy(): void { - const prom = this.updateData(); - if (prom) { - prom - .then(() => { - this.toast.showInfo('SETTING.SMTP.SAVED', true); - this.loading = true; - setTimeout(() => { - this.fetchData(); - }, 2000); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - } } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index ddfc380835..b780879a73 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1095,7 +1095,9 @@ "3": "Телефонна проверка", "4": "Нулиране на парола", "5": "Инициализация без парола", - "6": "Тайна на приложението" + "6": "Тайна на приложението", + "7": "Еднократна парола (OTP) - SMS", + "8": "Еднократна парола (OTP) – имейл" }, "ADDGENERATOR": "Определете тайния външен вид", "GENERATORTYPE": "Тип", @@ -1838,8 +1840,10 @@ }, "SECONDFACTORTYPES": { "0": "неизвестен", - "1": "Еднократна парола (OTP)", - "2": "Пръстов отпечатък, ключове за сигурност, Face ID и други" + "1": "Еднократна парола чрез приложение за удостоверяване на автентичността (TOTP)", + "2": "Пръстов отпечатък, ключове за сигурност, Face ID и други", + "3": "Еднократна парола по имейл (Email OTP)", + "4": "Еднократна парола чрез SMS (SMS OTP)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 87b6e83fd0..78da45b99b 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1101,7 +1101,9 @@ "3": "Telefonnummer Verificationscode", "4": "Passwort Zurücksetzen Code", "5": "Passwordless Initialisierungscode", - "6": "Applicationssecret" + "6": "Applicationssecret", + "7": "One Time Password (OTP) - SMS", + "8": "One Time Password (OTP) - Email" }, "ADDGENERATOR": "Secret Erscheinungsbild definieren", "GENERATORTYPE": "Typ", @@ -1847,8 +1849,10 @@ }, "SECONDFACTORTYPES": { "0": "Unknown", - "1": "One Time Password (OTP)", - "2": "Fingerabdruck, Security Keys, Face ID und andere" + "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)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index c858d5acbb..61fa997c88 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1102,7 +1102,9 @@ "3": "Phone verification", "4": "Password Reset", "5": "Passwordless Initialization", - "6": "App Secret" + "6": "App Secret", + "7": "One Time Password (OTP) - SMS", + "8": "One Time Password (OTP) - Email" }, "ADDGENERATOR": "Define Secret Appearance", "GENERATORTYPE": "Type", @@ -1844,8 +1846,10 @@ }, "SECONDFACTORTYPES": { "0": "Unknown", - "1": "One Time Password (OTP)", - "2": "Fingerprint, Security Keys, Face ID and other" + "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)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 3a0df8980f..5c128eff11 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1102,7 +1102,9 @@ "3": "Verificación de teléfono", "4": "Restablecimiento de contraseña", "5": "Inicialización de acceso sin contraseña", - "6": "Secreto de App" + "6": "Secreto de App", + "7": "One Time Password (OTP) - SMS", + "8": "One Time Password (OTP) - email" }, "ADDGENERATOR": "Definir apariencia del secreto", "GENERATORTYPE": "Tipo", @@ -1844,8 +1846,10 @@ }, "SECONDFACTORTYPES": { "0": "Desconocido", - "1": "One Time Password (OTP)", - "2": "Huella dactilar, claves de seguridad, Face ID y otros" + "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)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index a81c6eae5d..676badc0e9 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1101,7 +1101,9 @@ "3": "Vérification par téléphone", "4": "Réinitialisation du mot de passe", "5": "Initialisation sans mot de passe", - "6": "Secret de l'application" + "6": "Secret de l'application", + "7": "Mot de passe à usage unique (OTP) - SMS", + "8": "Mot de passe à usage unique (OTP) - e-mail" }, "ADDGENERATOR": "Définir l'apparence du secret", "GENERATORTYPE": "Type", @@ -1848,8 +1850,10 @@ }, "SECONDFACTORTYPES": { "0": "Inconnu", - "1": "Mot de passe à usage unique (OTP)", - "2": "Empreinte digitale, clés de sécurité, Face ID et autres" + "1": "One Time Password par authenticator app (TOTP)", + "2": "Empreinte digitale, clés de sécurité, Face ID et autres", + "3": "One Time Password par email (Email OTP)", + "4": "One Time Password par SMS (SMS OTP)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 387bc187d7..1bec1139e6 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1101,7 +1101,9 @@ "3": "Verificazione del numero di telefono", "4": "Ripristino Password", "5": "Inizializzazione Passwordless", - "6": "Segreto dell'applicazione" + "6": "Segreto dell'applicazione", + "7": "One Time Password (OTP) - SMS", + "8": "One Time Password (OTP) - email" }, "ADDGENERATOR": "Definisci aspetto", "GENERATORTYPE": "Tipo", @@ -1848,8 +1850,10 @@ }, "SECONDFACTORTYPES": { "0": "Sconosciuto", - "1": "One Time Password (OTP)", - "2": "Impronta digitale, chiave di sicurezza, Face ID e altri" + "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)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 8ac4692d38..3551fcf4a7 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1102,7 +1102,9 @@ "3": "電話番号認証", "4": "パスワードのリセット", "5": "パスワードレスの初期設定", - "6": "アプリのシークレット" + "6": "アプリのシークレット", + "7": "ワンタイムパスワード (OTP) - SMS", + "8": "ワンタイムパスワード (OTP) - 電子メール" }, "ADDGENERATOR": "シークレットの設定を定義する", "GENERATORTYPE": "タイプ", @@ -1839,8 +1841,10 @@ }, "SECONDFACTORTYPES": { "0": "不明", - "1": "ワンタイムパスワード(OTP)", - "2": "指紋、セキュリティキー、フェイスIDなど" + "1": "認証アプリ用ワンタイムパスワード(TOTP)", + "2": "指紋、セキュリティキー、フェイスIDなど", + "3": "Eメール用ワンタイムパスワード(email OTP)", + "4": "SMS用ワンタイムパスワード(SMS OTP)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index b511a8ced9..590ee2365f 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1102,7 +1102,9 @@ "3": "Телефонска верификација", "4": "Промена на лозинка", "5": "Иницијализација на најава без лозинка", - "6": "Апликациска тајна" + "6": "Апликациска тајна", + "7": "Еднократна лозинка (OTP) - СМС", + "8": "Еднократна лозинка (OTP) - е-пошта" }, "ADDGENERATOR": "Дефинирајте изглед на тајна", "GENERATORTYPE": "Тип", @@ -1844,8 +1846,10 @@ }, "SECONDFACTORTYPES": { "0": "Непознато", - "1": "Еднократна лозинка (OTP)", - "2": "Отисок на прст, безбедносни клучеви, Face ID и другo" + "1": "Еднократна лозинка преку апликација за автентикатор (TOTP)", + "2": "Отпечаток на прст, безбедносни клучеви, Face ID и други", + "3": "Еднократна лозинка по е-пошта (Еmail OTP)", + "4": "Еднократна лозинка преку СМС (SMS OTP)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 774038f7ce..2e839ddd62 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1101,7 +1101,9 @@ "3": "Weryfikacja telefonu", "4": "Resetowanie hasła", "5": "Inicjalizacja bez hasła", - "6": "Sekret aplikacji" + "6": "Sekret aplikacji", + "7": "Hasło jednorazowe (OTP) - SMS", + "8": "Hasło jednorazowe (OTP) — e-mail" }, "ADDGENERATOR": "Zdefiniuj wygląd sekretu", "GENERATORTYPE": "Typ", @@ -1848,8 +1850,10 @@ }, "SECONDFACTORTYPES": { "0": "Nieznany", - "1": "Jednorazowe hasło (OTP)", - "2": "Odcisk palca, klucze bezpieczeństwa, Face ID i inne" + "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)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 00607c1596..a034c9b0ef 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1102,7 +1102,9 @@ "3": "Verificação de telefone", "4": "Redefinição de senha", "5": "Inicialização sem senha", - "6": "Segredo do aplicativo" + "6": "Segredo do aplicativo", + "7": "Senha única (OTP) - SMS", + "8": "Senha única (OTP) - e-mail" }, "ADDGENERATOR": "Definir aparência de segredo", "GENERATORTYPE": "Tipo", @@ -1842,8 +1844,10 @@ }, "SECONDFACTORTYPES": { "0": "Desconhecido", - "1": "Senha de Uso Único (OTP)", - "2": "Impressão Digital, Chaves de Segurança, Face ID e outros" + "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)" } }, "LOGINPOLICY": { diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 423fbf027a..2efd31d80d 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1101,7 +1101,9 @@ "3": "电话号码验证", "4": "重置密码", "5": "无密码认证初始化", - "6": "App 验证" + "6": "App 验证", + "7": "一次性密码 (OTP) - SMS", + "8": "一次性密码 (OTP) - 电子邮件" }, "ADDGENERATOR": "定义验证码外观", "GENERATORTYPE": "类型", @@ -1847,8 +1849,10 @@ }, "SECONDFACTORTYPES": { "0": "未知", - "1": "一次性密码 (OTP)", - "2": "指纹、安全密钥、Face ID 等" + "1": "身份验证应用程序的一次性密码(TOTP)", + "2": "指纹、安全密钥、Face ID 等", + "3": "电子邮件一次性密码(email OTP)", + "4": "短信一次性密码(SMS OTP)" } }, "LOGINPOLICY": { diff --git a/docs/docs/apis/actions/modules.md b/docs/docs/apis/actions/modules.md index 62a1c93717..2cc99a222e 100644 --- a/docs/docs/apis/actions/modules.md +++ b/docs/docs/apis/actions/modules.md @@ -39,7 +39,7 @@ This function allows to call HTTP servers. The function does NOT fulfil the [Fet #### Response -If the request was valid, an error will be thrown, otherwise a Response object will be returned. +If the request was invalid, an error will be thrown, otherwise a Response object will be returned. The object has the following fields and methods: diff --git a/docs/docs/concepts/architecture/software.md b/docs/docs/concepts/architecture/software.md index 565221774e..d820e2c897 100644 --- a/docs/docs/concepts/architecture/software.md +++ b/docs/docs/concepts/architecture/software.md @@ -145,7 +145,6 @@ The storage layer of ZITADEL is responsible for multiple things. For example: - Backup and restore operation for disaster recovery purpose ZITADEL currently supports CockroachDB as first choice of storage due to its perfect match for ZITADELs needs. -Postgres is currently in [Beta](/docs/support/software-release-cycles-support#beta) and will be [Enterprise Supported](/docs/support/software-release-cycles-support#partially-supported) afterwards. -Beta state will be removed as soon as [automated tests](https://github.com/zitadel/zitadel/issues/5741) are implemented. +Alternatively you can run ZITADEL also with Postgres which is [Enterprise Supported](/docs/support/software-release-cycles-support#partially-supported). Make sure to read our [Production Guide](/docs/self-hosting/manage/production#prefer-cockroachdb) before you decide to use it. diff --git a/docs/docs/concepts/architecture/solution.md b/docs/docs/concepts/architecture/solution.md index 9b0656c985..826bb976c4 100644 --- a/docs/docs/concepts/architecture/solution.md +++ b/docs/docs/concepts/architecture/solution.md @@ -9,8 +9,7 @@ Since the storage layer takes the heavy lifting of making sure that data in sync Depending on your projects needs our general recommendation is to run ZITADEL and ZITADELs storage layer across multiple availability zones in the same region or if you need higher guarantees run the storage layer across multiple regions. Consult the [CockroachDB documentation](https://www.cockroachlabs.com/docs/) for more details or use the [CockroachCloud Service](https://www.cockroachlabs.com/docs/cockroachcloud/create-an-account.html) -Postgres is currently in [Beta](/docs/support/software-release-cycles-support#beta) and will be [Enterprise Supported](/docs/support/software-release-cycles-support#partially-supported) afterwards. -Beta state will be removed as soon as [automated tests](https://github.com/zitadel/zitadel/issues/5741) are implemented. +Alternatively you can run ZITADEL also with Postgres which is [Enterprise Supported](/docs/support/software-release-cycles-support#partially-supported). Make sure to read our [Production Guide](/self-hosting/manage/production#prefer-cockroachdb) before you decide to use it. ## Scalability diff --git a/docs/docs/guides/integrate/identity-providers/migrate.mdx b/docs/docs/guides/integrate/identity-providers/migrate.mdx index dc9cbb0962..b7564a9745 100644 --- a/docs/docs/guides/integrate/identity-providers/migrate.mdx +++ b/docs/docs/guides/integrate/identity-providers/migrate.mdx @@ -28,8 +28,16 @@ Please note that you only have to perform this migration if you already have an If that isn't your case please just add a new provider from scratch. To migrate to a specific provider, you need to follow a few essential steps: 1. Create a desired IDP as Terraform resource for example [Google](https://registry.terraform.io/providers/zitadel/zitadel/latest/docs/resources/idp_google). -2. Make the corresponding API call to [migrate the IDP](./migrate#google-configuration), save the ID of the IDP for the import -3. Before applying the Terraform resources again, import the new IDP resource. +2. Remove the old terraform resource from the state as to not destroy the migrated IDP accidentally. +```bash +# terraform state rm *address* +terraform state rm zitadel_idp_oidc.oidc_idp +``` + +After this command you can also remove the resource from the terraform files, as it is not managed anymore but also not deleted. + +3. Make the corresponding API call to [migrate the IDP](./migrate#migrate-generic-oidc-provider), save the ID of the IDP for the import +4. Before applying the Terraform resources again, import the new IDP resource. ```bash #resource "zitadel_idp_google" "google" { # name = "Google" @@ -44,5 +52,6 @@ To migrate to a specific provider, you need to follow a few essential steps: # terraform import zitadel_idp_google.*resource_name* *id*:*client_secret* terraform import zitadel_idp_google.google 222302827723096428:GOCSPX-***** +``` -You have now migrated your provider and you should be able to apply the resource again. There should be no changes and the IDP is maintained by Terraform again. \ No newline at end of file +You have now migrated your provider and you should be able to apply the resource again. There should be no changes and the IDP is maintained by Terraform again. diff --git a/docs/docs/guides/integrate/login-ui/external-login.mdx b/docs/docs/guides/integrate/login-ui/external-login.mdx index fa84303d37..d92f5b3a1c 100644 --- a/docs/docs/guides/integrate/login-ui/external-login.mdx +++ b/docs/docs/guides/integrate/login-ui/external-login.mdx @@ -6,7 +6,7 @@ sidebar_label: External Identity Provider ## Flow The prerequisite for adding an external login (social and enterprise) to your user account is a registered identity provider on your ZITADEL instance or the organization of the user. -If you haven’t added a provider yet, have a look at the following guide first: [Identity Providers](https://zitadel.com/docs/guides/integrate/identity-providers) +If you haven’t added a provider yet, have a look at the following guide first: [Identity Providers](/docs/guides/integrate/identity-providers) ![Identity Provider Flow](/img/guides/login-ui/external-login-flow.png) @@ -20,7 +20,7 @@ Send the following two URLs in the request body: 2. ErrorURL: Page that should be shown when an error happens during the authentication In the response, you will get an authentication URL of the provider you like. -[Start Identity Provider Flow Documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-start-identity-provider-flow) +[Start Identity Provider Flow Documentation](/docs/apis/resources/user_service/user-service-start-identity-provider-flow) ### Request @@ -65,7 +65,7 @@ After the user has successfully authenticated, a redirect to the ZITADEL backend ZITADEL will take the information of the provider. After this, a redirect will be made to either the success page in case of a successful login or to the error page in case of a failure will be performed. In the parameters, you will provide the intentID, a token, and optionally, if a user could be found, a user ID. To get the information of the provider, make a request to ZITADEL. -[Get Identity Provider Information Documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-retrieve-identity-provider-information) +[Get Identity Provider Information Documentation](/docs/apis/resources/user_service/user-service-retrieve-identity-provider-information) ### Request ```bash @@ -90,8 +90,8 @@ curl --request POST \ }, "idpInformation": { "oauth": { - "accessToken": "ya29.a0AWY7CknrOORopcJK2XX2fQXV9NQpp8JdkKYx-mQZNrR-wktWWhc3QsepT6kloSCgFPS9N0beEBlEYoY5oYUhfc7VlLHTQz5iECE386pyx5YmTueyeQ9GXk1dAw89gi8KRyjNlJApFsfLJaoiLIvKJLf23eAyXgaCgYKAUMSARESFQG1tDrpnTJ2su8BBO24zfmzgneARw0165", - "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijg1YmE5MzEzZmQ3YTdkNGFmYTg0ODg0YWJjYzg0MDMwMDQzNjMxODAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxODI5MDIwMjY1MDgtdW1taXQ3dHZjbHBnM2NxZmM4b2ljdGE1czI1aGtudWwuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiIxODI5MDIwMjY1MDgtdW1taXQ3dHZjbHBnM2NxZmM4b2ljdGE1czI1aGtudWwuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTEzOTI4MDU5NzU3MTU4NTY2MzciLCJoZCI6InJvb3RkLmNoIiwiZW1haWwiOiJmYWJpZW5uZUByb290ZC5jaCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoidGN5X25JTkZHNnFhRTBZTWFsQzZGdyIsIm5hbWUiOiJGYWJpZW5uZSBCw7xobGVyIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hL0FBY0hUdGY5NzNRNk5IOEt6S1RNRVpFTFBVOWx4NDVXcFE5RlJCdXhGZFBiPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IkZhYmllbm5lIiwiZmFtaWx5X25hbWUiOiJCw7xobGVyIiwibG9jYWxlIjoiZGUiLCJpYXQiOjE2ODY4MTE0NjUsImV4cCI6MTY4NjgxNTA2NX0.PwlAHRM44e8eYyHzdfotOrcq5GZc4D15mWvN3rGdoDmu2RRgb4T0nDgkY6AVq2vNJxPfbiB1jFtNP6dgX-OgLIxNXg_tJJhwFh-eFPA37cIiv1CEcgEC-q1zXFIa3HrwHLreeU6i7C9JkDrKpkS-AKat1krf27QXxrxHLrWehi5F2l1OZuAKFWYaYmJOd0sVTDBA2o5SDcAiQs_D4-Q-kSl5f0gh607YVHLv7zjyfHtAOs7xPEkNEUVcqGBke2Zy9kAYIgiMriNxLA2EDxFtSyWnf-bCXKnuVX2hwEY0T0lUPrOAVkz7MEOKiacE2xAOczoQvl-wECU0UofLi8XZqg" + "accessToken": "ya29...", + "idToken": "ey..." }, "idpId": "218528353504723201", "rawInformation": { @@ -103,7 +103,7 @@ curl --request POST \ "hd": "mouse.com", "locale": "de", "name": "Minnie Mouse", - "picture": "https://lh3.googleusercontent.com/a/AAcKTtf973Q6NH8KzKTMEZELPU9lx45WpQ9FRBuxFdPb=s96-c", + "picture": "https://lh3.googleusercontent.com/a/AAcKTtf973Q7NH8KzKTMEZELPU9lx45WpQ9FRBuxFdPb=s96-c", "sub": "111392805975715856637" } } @@ -152,7 +152,7 @@ Fill the IdP links in the create user request to add a user with an external log The idpId is the ID of the provider in ZITADEL, the idpExternalId is the ID of the user in the external identity provider; usually, this is sent in the “sub”. The display name is used to list the linkings on the users. -[Create User API Documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-add-human-user) +[Create User API Documentation](/docs/apis/resources/user_service/user-service-add-human-user) #### Request ```bash @@ -190,7 +190,7 @@ curl --request POST \ If you didn't get a user ID in the parameters to your success page, you know that there is no existing user in ZITADEL with that provider and you can register a new user (read previous section), or link it to an existing account. If you want to link/connect to an existing account you can perform the add identity provider link request. -[Add IDP Link to existing user documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-add-idp-link) +[Add IDP Link to existing user documentation](/docs/apis/resources/user_service/user-service-add-idp-link) #### Request ```bash diff --git a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx index 0a7b5093a8..e379060729 100644 --- a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx @@ -2,11 +2,6 @@ title: OIDC Standard --- -:::info -Not yet implemented, but should give you a general impression of how it will work -Subscribe to the following issue: https://github.com/orgs/zitadel/projects/2/views/1?filterQuery=oidc&pane=issue&itemId=23181369 -::: - To build your own login ui for your own application it is not necessary to have the OIDC standard included or any additional work that has to be done. However, it might make sense, if you want to connect your login to different applications especially if they are not in your control and they rely on the standard. @@ -14,13 +9,135 @@ The following flow shows you the different components you need to enable OIDC fo ![OIDC Flow](/img/guides/login-ui/oidc-flow.png) 1. Your application makes an authorization request to your login UI -2. The login UI takes the requests and sends them to the ZITADEL API. In the request to the ZITADEL API, a header to authenticate your client is needed. +2. The login UI proxies the request to the ZITADEL API. In the request to the ZITADEL API, a header to identify your client is needed. 3. The ZITADEL API parses the request and does what it needs to interpret certain parameters (e.g., organization scope, etc.) -4. Redirect to a predefined, relative URL of the login UI that includes the authrequest ID -5. Request to ZITADEL API to get all the information from the auth request -6. Create and update the session till the login flow is complete and the user is authenticated. Make sure to include the auth Request ID in the session -7. Read the callback URL from the ZITADEL API +4. Redirect to a predefined, relative URL of the login UI that includes the authrequest ID ("/login?authRequest=") +5. Request to ZITADEL API to get all the information from the auth request. This is optional and only needed if you like to get all the parsed information from the authrequest- +6. Authenticate the user in your login UI by creating and updating a session with all the checks you need. +7. Finalize the auth request by sending the session to the request, you will get the callback URL in the response 8. Redirect to your application with the callback URL you got in the previous request 9. All OIDC-specific endpoints have to be accepted in the Login UI and should be proxied and sent to the ZITADEL API +## Example + +Let's assume you host your login UI on the following URL: +``` +https://login.example.com +``` + +## Authorize Request + +A user opens your application and is unauthenticated, the user will then be redirected to your login with the following auth Request: +``` +https://login.example.com/oauth/v2/authorize?client_id=170086824411201793%40yourapp&redirect_uri=https%3A%2F%2Fyourapp.example.com%2Fauth%2Fcallback&response_type=code&scope=openid%20email%20profile&code_challenge=9az09PjcfuENS7oDK7jUd2xAWRb-B3N7Sr3kDoWECOY&code_challenge_method=S256&login_hint=minnie-mouse``` +``` + +The auth request includes all the relevant information for the OIDC standard and in this example we also have a login hint for the login name "minnie-mouse". + +You now have to proxy the auth request from your own UI to the authorize Endpoint of ZITADEL. +Make sure to add the user id of your login UI as a header to the request: ```x-zitadel-login-client: ``` + +Read more about the [Authorize Endpoint Documentation](/docs/apis/openidoauth/endpoints#authorization_endpoint) + +The endpoint will redirect you to the domain of your UI on the path /login and add the auth Request ID as parameter. +```https://login.example.com/login?authRequest=V2_224908753244265546``` + +### Get Auth Request by ID + +With the ID from the redirect before you will now be able to get the information of the auth request. +[Get Auth Request By ID Documentation](/docs/apis/resources/oidc_service/oidc-service-get-auth-request) + +```bash +curl --request GET \ + --url https://$ZITADEL_DOMAIN/v2alpha/oidc/auth_requests/V2_224908753244265546 \ + --header 'Authorization: Bearer '"$TOKEN"''\ +``` + +Response Example: + +```json +{ + "authRequest": { + "id": "V2_224908753244265546", + "creationDate": "2023-07-28T13:47:43.471505Z", + "clientId": "224901977648260028@mytestproject", + "scope": [ + "openid", + "profile" + ], + "redirectUri": "https://myapp.example.com/auth/callback", + "loginHint": "mini@mouse.com" + } +} +``` + +### Perform Login + +After you have initialized the OIDC flow you can implement the login. +Implement all the steps you like the user the go trough by [creating](/docs/apis/resources/session_service/session-service-create-session) and [updating](/docs/apis/resources/session_service/session-service-set-session) the user-session. + +Read the following resources for more information about the different checks: +- [Username and Password](./username-password) +- [External Identity Provider](./external-login) +- [Passkeys](./passkey) +- [Multi-Factor](./mfa) + +### Finalize Auth Request + +To finalize the auth request and connect an existing user session with it you have to update the auth request with the session token. +On the create and update user session request you will always get a session token in the response. + +The latest session token has to be sent to the following request: + +Read more about the [Finalize Auth Request Documentation](/docs/apis/resources/oidc_service/oidc-service-create-callback) + +Make sure that the authorization header is from the same account that you originally sent in the client id header ```x-zitadel-login-client: ``` on the authorize endpoint. +```bash +curl --request POST \ + --url $ZITADEL_DOMAIN/v2alpha/oidc/auth_requests/V2_224908753244265546 \ + --header 'Accept: application/json' \ + --header 'Authorization: Bearer '"$TOKEN"''\ + --header 'Content-Type: application/json' \ + --data '{ + "session": { + "sessionId": "225307381909694508", + "sessionToken": "7N5kQCvC4jIf2OuBjwfyWSX2FUKbQqg4iG3uWT-TBngMhlS9miGUwpyUaN0HJ8OcbSzk4QHZy_Bvvv" + } +}' +``` + +In the response you will get a callback URL to which you have to redirect from your login UI. + +Example Response: +```bash +{ + "details": { + "sequence": "686", + "changeDate": "2023-07-31T08:09:19.314537Z", + "resourceOwner": "163840776801878273" + }, + "callbackUrl": "https://myapp.example.com/auth/callback?code=k98NBLrdjVbwQQI-oM_rR_cYHv0k3dqpkqlQX8UXTWVnYSQL9g&state=testd" +} +``` + +### OIDC Endpoints + +All OIDC relevant endpoints are provided by ZITADEL. In you login UI you just have to proxy them through and send them directly to the backend. + +These are endpoints like: +- Userinfo +- Well-known +- Introspection +- Token +- etc + + +### End Session / Logout + +The end session endpoint has to be implemented as all the other OIDC endpoints. This means you have to proxy the request from you UI to the ZITADEL. +In case the ZITADEL backend is not able to determine which session to terminate directly or requires additional approval from the user, it will redirect the browser to the following endpoint: + +```/logout?post_logout_redirect=``` + +Prompt the user to select a session, terminate it using the [corresponding endpoint](/docs/apis/resources/session_service/session-service-delete-session) and send the user to the `post_logout_redirect` URL. diff --git a/docs/docs/guides/manage/console/instance-settings.mdx b/docs/docs/guides/manage/console/instance-settings.mdx index 2603caee52..471db69451 100644 --- a/docs/docs/guides/manage/console/instance-settings.mdx +++ b/docs/docs/guides/manage/console/instance-settings.mdx @@ -120,8 +120,10 @@ Multifactors: Secondfactors (2FA): -- OTP (One Time Password), Authenticator Apps like Google/Microsoft Authenticator, Authy, etc. -- U2F (Universal Second Factor), e.g FaceID, WindowsHello, Fingerprint, Hardwaretokens like Yubikey +- Time-based One Time Password (TOTP), Authenticator Apps like Google/Microsoft Authenticator, Authy, etc. +- Universal Second Factor (U2F), e.g FaceID, WindowsHello, Fingerprint, Hardwaretokens like Yubikey +- One Time Password with Email (Email OTP) +- One Time Password with SMS (SMS OTP) Force a user to register and use a multifactor authentication, by checking the option "Force MFA". Ensure that you have added the MFA methods you want to allow. @@ -279,6 +281,8 @@ The following secrets can be configured: - Password reset code - Passwordless initialization code - Application secrets +- One Time Password (OTP) - SMS +- One Time Password (OTP) - Email - \ No newline at end of file + + +## Zitadel credentials +The [init phase](/docs/self-hosting/manage/updating_scaling#separating-init-and-setup-from-the-runtime) of Zitadel creates a the zitadel user (`Database.*.User.Username` & `Database.*.User.Password`) with their password if it does not exist (and Admin credentials are passed). It is though to note that it does **neither** update **nor** deprecate them. In case you provisioned a Zitadel setup with insecure or _easy-to-guess_ values you should first of all rotate them but also manually ensure, that the old role/user gets deprecated. + +If you rotate the credentials you either must opt for a new username or deprecate the old user first (might lead to interruptions) since the init phase will fail if the user already exists but only the password changes. To deprecate the old user you need admin access to your database server and remove the user with commands matching your database provider. + +:::caution +Recreating a database will not necessarily remove the user, make sure to check for the user and remove it if necessary. +::: \ No newline at end of file diff --git a/docs/docs/self-hosting/manage/productionchecklist.md b/docs/docs/self-hosting/manage/productionchecklist.md index 2f02361b77..4dd93328bd 100644 --- a/docs/docs/self-hosting/manage/productionchecklist.md +++ b/docs/docs/self-hosting/manage/productionchecklist.md @@ -45,6 +45,7 @@ To apply best practices to your production setup we created a step by step check ### Security +- [ ] Ensure that your ZITADEL does not use [the default, example or _easy-to-guess_ credentials](/docs/self-hosting/manage/database#zitadel-credentials) - [ ] Use a FQDN and a trusted valid certificate for external [TLS](/docs/self-hosting/manage/tls_modes#http2) connections - [ ] Create service accounts for applications that interact with ZITADEL's APIs - [ ] Make use of a CDN service to decrease the load for static assets served by ZITADEL diff --git a/docs/static/img/guides/login-ui/oidc-flow.png b/docs/static/img/guides/login-ui/oidc-flow.png index 552c357ba9..d54018ce65 100644 Binary files a/docs/static/img/guides/login-ui/oidc-flow.png and b/docs/static/img/guides/login-ui/oidc-flow.png differ diff --git a/e2e/cypress/e2e/quotas/quotas.cy.ts b/e2e/cypress/e2e/quotas/quotas.cy.ts index e3b6504797..3c8fa6c583 100644 --- a/e2e/cypress/e2e/quotas/quotas.cy.ts +++ b/e2e/cypress/e2e/quotas/quotas.cy.ts @@ -306,7 +306,7 @@ describe('quotas', () => { } return foundExpected >= 3; }), - ); + ), { timeout: 60_000 }; }); }); }); diff --git a/go.mod b/go.mod index 18077ccc09..bd62f2bd35 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( github.com/ttacon/libphonenumber v1.2.1 github.com/zitadel/logging v0.3.4 github.com/zitadel/oidc/v2 v2.7.0 - github.com/zitadel/passwap v0.2.0 + github.com/zitadel/passwap v0.3.0 github.com/zitadel/saml v0.0.11 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.40.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.40.0 diff --git a/go.sum b/go.sum index e69bad2e38..2edc2dd911 100644 --- a/go.sum +++ b/go.sum @@ -898,8 +898,8 @@ github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0= github.com/zitadel/oidc/v2 v2.7.0 h1:IGX4EDk6tegTjUSsZDWeTfLseFU0BdJ/Glf1tgys2lU= github.com/zitadel/oidc/v2 v2.7.0/go.mod h1:zkUkVJS0sDVy9m0UA9RgO3f8i/C0rtjvXU36UJj7T+0= -github.com/zitadel/passwap v0.2.0 h1:rkYrax9hfRIpVdXJ7pS8JHkQOhuQTdZQxEhsY0dFFrU= -github.com/zitadel/passwap v0.2.0/go.mod h1:KRTL4LL8ugJIn2xLoQYZf5t4kDyr7w41uq3XqvUlO6w= +github.com/zitadel/passwap v0.3.0 h1:kC/vzN9xQlEQjUAZs0z2P5nKrZs9AuTqprteSQ2S4Ag= +github.com/zitadel/passwap v0.3.0/go.mod h1:sIpG6HfmnP28qwxu8kf+ot53ERbLwU9fOITstAwZSms= github.com/zitadel/saml v0.0.11 h1:kObucnBrcu1PHCO7RGT0iVeuJL/5I50gUgr40S41nMs= github.com/zitadel/saml v0.0.11/go.mod h1:YGWAvPZRv4DbEZ78Ht/2P0AWzGn+6WGhFf90PMXl0Po= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= diff --git a/internal/api/authz/user.go b/internal/api/authz/user.go index 13dc4076fc..3bfbe45cec 100644 --- a/internal/api/authz/user.go +++ b/internal/api/authz/user.go @@ -10,7 +10,7 @@ import ( // equals the authenticated user in the context. func UserIDInCTX(ctx context.Context, userID string) error { if GetCtxData(ctx).UserID != userID { - return errors.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong") + return errors.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong") } return nil } diff --git a/internal/api/grpc/admin/iam_settings_converter.go b/internal/api/grpc/admin/iam_settings_converter.go index 7dfd1a381e..5b3429d757 100644 --- a/internal/api/grpc/admin/iam_settings_converter.go +++ b/internal/api/grpc/admin/iam_settings_converter.go @@ -72,6 +72,7 @@ func SecretGeneratorsToPb(generators []*query.SecretGenerator) []*settings_pb.Se func SecretGeneratorToPb(generator *query.SecretGenerator) *settings_pb.SecretGenerator { mapped := &settings_pb.SecretGenerator{ + GeneratorType: SecretGeneratorTypeToPb(generator.GeneratorType), Length: uint32(generator.Length), Expiry: durationpb.New(generator.Expiry), IncludeUpperLetters: generator.IncludeUpperLetters, @@ -97,6 +98,10 @@ func SecretGeneratorTypeToPb(generatorType domain.SecretGeneratorType) settings_ return settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_PASSWORDLESS_INIT_CODE case domain.SecretGeneratorTypeAppSecret: return settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_APP_SECRET + case domain.SecretGeneratorTypeOTPSMS: + return settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_OTP_SMS + case domain.SecretGeneratorTypeOTPEmail: + return settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_OTP_EMAIL default: return settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_UNSPECIFIED } @@ -116,6 +121,10 @@ func SecretGeneratorTypeToDomain(generatorType settings_pb.SecretGeneratorType) return domain.SecretGeneratorTypePasswordlessInitCode case settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_APP_SECRET: return domain.SecretGeneratorTypeAppSecret + case settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_OTP_SMS: + return domain.SecretGeneratorTypeOTPSMS + case settings_pb.SecretGeneratorType_SECRET_GENERATOR_TYPE_OTP_EMAIL: + return domain.SecretGeneratorTypeOTPEmail default: return domain.SecretGeneratorTypeUnspecified } diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index f905af1591..b5efbb7817 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -552,7 +552,7 @@ func (s *Server) importData(ctx context.Context, orgs []*admin_pb.DataOrg) (*adm if user.User.OtpCode != "" { logging.Debugf("import user otp: %s", user.GetUserId()) - if err := s.command.ImportHumanOTP(ctx, user.UserId, "", org.GetOrgId(), user.User.OtpCode); err != nil { + if err := s.command.ImportHumanTOTP(ctx, user.UserId, "", org.GetOrgId(), user.User.OtpCode); err != nil { errors = append(errors, &admin_pb.ImportDataError{Type: "human_user_otp", Id: user.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { return &admin_pb.ImportDataResponse{Errors: errors, Success: success}, count, err diff --git a/internal/api/grpc/auth/multi_factor.go b/internal/api/grpc/auth/multi_factor.go index cf95644492..b7c23da1ad 100644 --- a/internal/api/grpc/auth/multi_factor.go +++ b/internal/api/grpc/auth/multi_factor.go @@ -18,7 +18,7 @@ func (s *Server) ListMyAuthFactors(ctx context.Context, _ *auth_pb.ListMyAuthFac if err != nil { return nil, err } - err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeOTP) + err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail) if err != nil { return nil, err } @@ -37,16 +37,16 @@ func (s *Server) ListMyAuthFactors(ctx context.Context, _ *auth_pb.ListMyAuthFac func (s *Server) AddMyAuthFactorOTP(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPRequest) (*auth_pb.AddMyAuthFactorOTPResponse, error) { ctxData := authz.GetCtxData(ctx) - otp, err := s.command.AddHumanOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) + otp, err := s.command.AddHumanTOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) if err != nil { return nil, err } return &auth_pb.AddMyAuthFactorOTPResponse{ - Url: otp.Url, - Secret: otp.SecretString, + Url: otp.URI, + Secret: otp.Secret, Details: object.AddToDetailsPb( otp.Sequence, - otp.ChangeDate, + otp.EventDate, otp.ResourceOwner, ), }, nil @@ -54,7 +54,7 @@ func (s *Server) AddMyAuthFactorOTP(ctx context.Context, _ *auth_pb.AddMyAuthFac func (s *Server) VerifyMyAuthFactorOTP(ctx context.Context, req *auth_pb.VerifyMyAuthFactorOTPRequest) (*auth_pb.VerifyMyAuthFactorOTPResponse, error) { ctxData := authz.GetCtxData(ctx) - objectDetails, err := s.command.HumanCheckMFAOTPSetup(ctx, ctxData.UserID, req.Code, "", ctxData.ResourceOwner) + objectDetails, err := s.command.HumanCheckMFATOTPSetup(ctx, ctxData.UserID, req.Code, "", ctxData.ResourceOwner) if err != nil { return nil, err } @@ -65,7 +65,7 @@ func (s *Server) VerifyMyAuthFactorOTP(ctx context.Context, req *auth_pb.VerifyM func (s *Server) RemoveMyAuthFactorOTP(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPRequest) (*auth_pb.RemoveMyAuthFactorOTPResponse, error) { ctxData := authz.GetCtxData(ctx) - objectDetails, err := s.command.HumanRemoveOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) + objectDetails, err := s.command.HumanRemoveTOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) if err != nil { return nil, err } @@ -74,6 +74,50 @@ func (s *Server) RemoveMyAuthFactorOTP(ctx context.Context, _ *auth_pb.RemoveMyA }, nil } +func (s *Server) AddMyAuthFactorOTPSMS(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPSMSRequest) (*auth_pb.AddMyAuthFactorOTPSMSResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.AddHumanOTPSMS(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.AddMyAuthFactorOTPSMSResponse{ + Details: object.DomainToAddDetailsPb(details), + }, nil +} + +func (s *Server) RemoveMyAuthFactorOTPSMS(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPSMSRequest) (*auth_pb.RemoveMyAuthFactorOTPSMSResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.RemoveHumanOTPSMS(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.RemoveMyAuthFactorOTPSMSResponse{ + Details: object.DomainToChangeDetailsPb(details), + }, nil +} + +func (s *Server) AddMyAuthFactorOTPEmail(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPEmailRequest) (*auth_pb.AddMyAuthFactorOTPEmailResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.AddHumanOTPEmail(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.AddMyAuthFactorOTPEmailResponse{ + Details: object.DomainToAddDetailsPb(details), + }, nil +} + +func (s *Server) RemoveMyAuthFactorOTPEmail(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPEmailRequest) (*auth_pb.RemoveMyAuthFactorOTPEmailResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.RemoveHumanOTPEmail(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.RemoveMyAuthFactorOTPEmailResponse{ + Details: object.DomainToChangeDetailsPb(details), + }, nil +} + func (s *Server) AddMyAuthFactorU2F(ctx context.Context, _ *auth_pb.AddMyAuthFactorU2FRequest) (*auth_pb.AddMyAuthFactorU2FResponse, error) { ctxData := authz.GetCtxData(ctx) u2f, err := s.command.HumanAddU2FSetup(ctx, ctxData.UserID, ctxData.ResourceOwner, false) diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index d7208c5a40..c37ce3728c 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -613,7 +613,7 @@ func (s *Server) ListHumanAuthFactors(ctx context.Context, req *mgmt_pb.ListHuma if err != nil { return nil, err } - err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeOTP) + err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail) if err != nil { return nil, err } @@ -631,7 +631,7 @@ func (s *Server) ListHumanAuthFactors(ctx context.Context, req *mgmt_pb.ListHuma } func (s *Server) RemoveHumanAuthFactorOTP(ctx context.Context, req *mgmt_pb.RemoveHumanAuthFactorOTPRequest) (*mgmt_pb.RemoveHumanAuthFactorOTPResponse, error) { - objectDetails, err := s.command.HumanRemoveOTP(ctx, req.UserId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.UserId, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/policy/auth_factor.go b/internal/api/grpc/policy/auth_factor.go index 56a155413c..e12eabfd94 100644 --- a/internal/api/grpc/policy/auth_factor.go +++ b/internal/api/grpc/policy/auth_factor.go @@ -16,9 +16,13 @@ func SecondFactorsTypesToDomain(secondFactorTypes []policy_pb.SecondFactorType) func SecondFactorTypeToDomain(secondFactorType policy_pb.SecondFactorType) domain.SecondFactorType { switch secondFactorType { case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP: - return domain.SecondFactorTypeOTP + return domain.SecondFactorTypeTOTP case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_U2F: return domain.SecondFactorTypeU2F + case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL: + return domain.SecondFactorTypeOTPEmail + case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS: + return domain.SecondFactorTypeOTPSMS default: return domain.SecondFactorTypeUnspecified } @@ -34,10 +38,14 @@ func ModelSecondFactorTypesToPb(types []domain.SecondFactorType) []policy_pb.Sec func ModelSecondFactorTypeToPb(secondFactorType domain.SecondFactorType) policy_pb.SecondFactorType { switch secondFactorType { - case domain.SecondFactorTypeOTP: + case domain.SecondFactorTypeTOTP: return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP case domain.SecondFactorTypeU2F: return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_U2F + case domain.SecondFactorTypeOTPEmail: + return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL + case domain.SecondFactorTypeOTPSMS: + return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS default: return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED } diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go index 949d78ded2..7f6ad96643 100644 --- a/internal/api/grpc/session/v2/session_integration_test.go +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -396,7 +396,7 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { func Test_ZITADEL_API_missing_mfa(t *testing.T) { id, token, _, _ := Tester.CreatePasswordSession(t, CTX, User.GetUserId(), integration.UserPassword) - ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx := Tester.WithAuthorizationToken(context.Background(), token) sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.Error(t, err) require.Nil(t, sessionResp) @@ -405,7 +405,7 @@ func Test_ZITADEL_API_missing_mfa(t *testing.T) { func Test_ZITADEL_API_success(t *testing.T) { id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) - ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx := Tester.WithAuthorizationToken(context.Background(), token) sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) require.NotNil(t, id, sessionResp.GetSession().GetFactors().GetPasskey().GetVerifiedAt().AsTime()) @@ -415,7 +415,7 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) { id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) // test session token works - ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx := Tester.WithAuthorizationToken(context.Background(), token) _, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) @@ -425,7 +425,7 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) { SessionToken: gu.Ptr(token), }) require.NoError(t, err) - ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx = Tester.WithAuthorizationToken(context.Background(), token) _, err = Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.Error(t, err) } diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go index 4330613d04..f48177e1df 100644 --- a/internal/api/grpc/settings/v2/settings_converter.go +++ b/internal/api/grpc/settings/v2/settings_converter.go @@ -62,10 +62,14 @@ func passkeysTypeToPb(passwordlessType domain.PasswordlessType) settings.Passkey func secondFactorTypeToPb(secondFactorType domain.SecondFactorType) settings.SecondFactorType { switch secondFactorType { - case domain.SecondFactorTypeOTP: + case domain.SecondFactorTypeTOTP: return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP case domain.SecondFactorTypeU2F: return settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F + case domain.SecondFactorTypeOTPEmail: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL + case domain.SecondFactorTypeOTPSMS: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS case domain.SecondFactorTypeUnspecified: return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED default: diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go index e81c0a70c9..37ad664f48 100644 --- a/internal/api/grpc/settings/v2/settings_converter_test.go +++ b/internal/api/grpc/settings/v2/settings_converter_test.go @@ -39,8 +39,10 @@ func Test_loginSettingsToPb(t *testing.T) { SecondFactorCheckLifetime: time.Microsecond, MultiFactorCheckLifetime: time.Nanosecond, SecondFactors: []domain.SecondFactorType{ - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, domain.SecondFactorTypeU2F, + domain.SecondFactorTypeOTPEmail, + domain.SecondFactorTypeOTPSMS, }, MultiFactors: []domain.MultiFactorType{ domain.MultiFactorTypeU2FWithPIN, @@ -69,6 +71,8 @@ func Test_loginSettingsToPb(t *testing.T) { SecondFactors: []settings.SecondFactorType{ settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP, settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F, + settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL, + settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, }, MultiFactors: []settings.MultiFactorType{ settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, @@ -146,13 +150,21 @@ func Test_secondFactorTypeToPb(t *testing.T) { want settings.SecondFactorType }{ { - args: args{domain.SecondFactorTypeOTP}, + args: args{domain.SecondFactorTypeTOTP}, want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP, }, { args: args{domain.SecondFactorTypeU2F}, want: settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F, }, + { + args: args{domain.SecondFactorTypeOTPSMS}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + }, + { + args: args{domain.SecondFactorTypeOTPEmail}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL, + }, { args: args{domain.SecondFactorTypeUnspecified}, want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED, diff --git a/internal/api/grpc/user/converter.go b/internal/api/grpc/user/converter.go index 6b56ad6632..7b00d2f4cc 100644 --- a/internal/api/grpc/user/converter.go +++ b/internal/api/grpc/user/converter.go @@ -197,7 +197,7 @@ func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor { State: MFAStateToPb(mfa.State), } switch mfa.Type { - case domain.UserAuthMethodTypeOTP: + case domain.UserAuthMethodTypeTOTP: factor.Type = &user_pb.AuthFactor_Otp{ Otp: &user_pb.AuthFactorOTP{}, } @@ -208,6 +208,14 @@ func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor { Name: mfa.Name, }, } + case domain.UserAuthMethodTypeOTPSMS: + factor.Type = &user_pb.AuthFactor_OtpSms{ + OtpSms: &user_pb.AuthFactorOTPSMS{}, + } + case domain.UserAuthMethodTypeOTPEmail: + factor.Type = &user_pb.AuthFactor_OtpEmail{ + OtpEmail: &user_pb.AuthFactorOTPEmail{}, + } } return factor } diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go new file mode 100644 index 0000000000..6e8e7686c8 --- /dev/null +++ b/internal/api/grpc/user/v2/otp.go @@ -0,0 +1,43 @@ +package user + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + +} + +func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil +} + +func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + +} + +func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil +} diff --git a/internal/api/grpc/user/v2/otp_integration_test.go b/internal/api/grpc/user/v2/otp_integration_test.go new file mode 100644 index 0000000000..5d36dd361e --- /dev/null +++ b/internal/api/grpc/user/v2/otp_integration_test.go @@ -0,0 +1,302 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func TestServer_AddOTPSMS(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + + // TODO: add when phone can be added to user + /* + userIDPhone := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userIDPhone) + _, sessionTokenPhone, _, _ := Tester.CreatePasskeySession(t, CTX, userIDPhone) + */ + type args struct { + ctx context.Context + req *user.AddOTPSMSRequest + } + tests := []struct { + name string + args args + want *user.AddOTPSMSResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.AddOTPSMSRequest{}, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: CTX, + req: &user.AddOTPSMSRequest{ + UserId: "wrong", + }, + }, + wantErr: true, + }, + { + name: "phone not verified", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.AddOTPSMSRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + // TODO: add when phone can be added to user + /* + { + name: "add success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenPhone), + req: &user.AddOTPSMSRequest{ + UserId: userID, + }, + }, + want: &user.AddOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + */ + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOTPSMS(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemoveOTPSMS(t *testing.T) { + // TODO: add when phone can be added to user + /* + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + */ + + type args struct { + ctx context.Context + req *user.RemoveOTPSMSRequest + } + tests := []struct { + name string + args args + want *user.RemoveOTPSMSResponse + wantErr bool + }{ + { + name: "not added", + args: args{ + ctx: CTX, + req: &user.RemoveOTPSMSRequest{ + UserId: "wrong", + }, + }, + wantErr: true, + }, + // TODO: add when phone can be added to user + /* + { + name: "success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.RemoveOTPSMSRequest{ + UserId: userID, + }, + }, + want: &user.RemoveOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + */ + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveOTPSMS(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_AddOTPEmail(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + + userVerified := Tester.CreateHumanUser(CTX) + _, err := Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetEmailCode(), + }) + require.NoError(t, err) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId()) + + type args struct { + ctx context.Context + req *user.AddOTPEmailRequest + } + tests := []struct { + name string + args args + want *user.AddOTPEmailResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.AddOTPEmailRequest{}, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: CTX, + req: &user.AddOTPEmailRequest{ + UserId: "wrong", + }, + }, + wantErr: true, + }, + { + name: "email not verified", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.AddOTPEmailRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "add success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + req: &user.AddOTPEmailRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.AddOTPEmailResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOTPEmail(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemoveOTPEmail(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + + userVerified := Tester.CreateHumanUser(CTX) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Tester.Client.UserV2.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetEmailCode(), + }) + require.NoError(t, err) + _, err = Tester.Client.UserV2.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.RemoveOTPEmailRequest + } + tests := []struct { + name string + args args + want *user.RemoveOTPEmailResponse + wantErr bool + }{ + { + name: "not added", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.RemoveOTPEmailRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: userVerifiedCtx, + req: &user.RemoveOTPEmailRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.RemoveOTPEmailResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveOTPEmail(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go new file mode 100644 index 0000000000..c6d46e8341 --- /dev/null +++ b/internal/api/grpc/user/v2/phone.go @@ -0,0 +1,61 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { + var resourceOwner string // TODO: check if still needed + var phone *domain.Phone + + switch v := req.GetVerification().(type) { + case *user.SetPhoneRequest_SendCode: + phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg) + case *user.SetPhoneRequest_ReturnCode: + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg) + case *user.SetPhoneRequest_IsVerified: + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), resourceOwner, req.GetPhone()) + case nil: + phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg) + default: + err = caos_errs.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: phone.Sequence, + ChangeDate: timestamppb.New(phone.ChangeDate), + ResourceOwner: phone.ResourceOwner, + }, + VerificationCode: phone.PlainCode, + }, nil +} + +func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { + details, err := s.command.VerifyUserPhone(ctx, + req.GetUserId(), + "", // TODO: check if still needed + req.GetVerificationCode(), + s.userCodeAlg, + ) + if err != nil { + return nil, err + } + return &user.VerifyPhoneResponse{ + Details: &object.Details{ + Sequence: details.Sequence, + ChangeDate: timestamppb.New(details.EventDate), + ResourceOwner: details.ResourceOwner, + }, + }, nil +} diff --git a/internal/api/grpc/user/v2/phone_integration_test.go b/internal/api/grpc/user/v2/phone_integration_test.go new file mode 100644 index 0000000000..5fc75e7cd3 --- /dev/null +++ b/internal/api/grpc/user/v2/phone_integration_test.go @@ -0,0 +1,171 @@ +//go:build integration + +package user_test + +import ( + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func TestServer_SetPhone(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + + tests := []struct { + name string + req *user.SetPhoneRequest + want *user.SetPhoneResponse + wantErr bool + }{ + { + name: "default verification", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234568", + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "send verification", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234569", + Verification: &user.SetPhoneRequest_SendCode{ + SendCode: &user.SendPhoneVerificationCode{}, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "return code", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234566", + Verification: &user.SetPhoneRequest_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + { + name: "is verified true", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234565", + Verification: &user.SetPhoneRequest_IsVerified{ + IsVerified: true, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "is verified false", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234564", + Verification: &user.SetPhoneRequest_IsVerified{ + IsVerified: false, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.SetPhone(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + if tt.want.GetVerificationCode() != "" { + assert.NotEmpty(t, got.GetVerificationCode()) + } + }) + } +} + +func TestServer_VerifyPhone(t *testing.T) { + userResp := Tester.CreateHumanUser(CTX) + tests := []struct { + name string + req *user.VerifyPhoneRequest + want *user.VerifyPhoneResponse + wantErr bool + }{ + { + name: "wrong code", + req: &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: "xxx", + }, + wantErr: true, + }, + { + name: "wrong user", + req: &user.VerifyPhoneRequest{ + UserId: "xxx", + VerificationCode: userResp.GetPhoneCode(), + }, + wantErr: true, + }, + { + name: "verify user", + req: &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetPhoneCode(), + }, + want: &user.VerifyPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.VerifyPhone(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index b115d83ebb..a5b56014a5 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -32,6 +32,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, + PhoneCode: human.PhoneCode, }, nil } @@ -77,9 +78,13 @@ func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, ReturnCode: req.GetEmail().GetReturnCode() != nil, URLTemplate: urlTemplate, }, + Phone: command.Phone{ + Number: domain.PhoneNumber(req.GetPhone().GetPhone()), + Verified: req.GetPhone().GetIsVerified(), + ReturnCode: req.GetPhone().GetReturnCode() != nil, + }, PreferredLanguage: language.Make(req.GetProfile().GetPreferredLanguage()), Gender: genderToDomain(req.GetProfile().GetGender()), - Phone: command.Phone{}, // TODO: add as soon as possible Password: req.GetPassword().GetPassword(), EncodedPasswordHash: req.GetHashedPassword().GetHash(), PasswordChangeRequired: passwordChangeRequired, @@ -213,7 +218,7 @@ func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.Authent func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.AuthenticationMethodType { switch methodType { - case domain.UserAuthMethodTypeOTP: + case domain.UserAuthMethodTypeTOTP: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP case domain.UserAuthMethodTypeU2F: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F @@ -223,6 +228,10 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD case domain.UserAuthMethodTypeIDP: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP + case domain.UserAuthMethodTypeOTPSMS: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS + case domain.UserAuthMethodTypeOTPEmail: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL case domain.UserAuthMethodTypeUnspecified: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED default: diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index a23ebbad9b..ae8145848d 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -75,6 +75,7 @@ func TestServer_AddHumanUser(t *testing.T) { Gender: user.Gender_GENDER_DIVERSE.Enum(), }, Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{}, Metadata: []*user.SetMetadataEntry{ { Key: "somekey", @@ -97,7 +98,7 @@ func TestServer_AddHumanUser(t *testing.T) { }, }, { - name: "return verification code", + name: "return email verification code", args: args{ CTX, &user.AddHumanUserRequest{ @@ -187,6 +188,53 @@ func TestServer_AddHumanUser(t *testing.T) { }, }, }, + { + name: "return phone verification code", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + PhoneCode: gu.Ptr("something"), + }, + }, { name: "custom template error", args: args{ diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go index fa3ce0b0da..e540ae7f16 100644 --- a/internal/api/grpc/user/v2/user_test.go +++ b/internal/api/grpc/user/v2/user_test.go @@ -194,8 +194,8 @@ func Test_authMethodTypeToPb(t *testing.T) { user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED, }, { - "(t)otp", - domain.UserAuthMethodTypeOTP, + "totp", + domain.UserAuthMethodTypeTOTP, user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP, }, { @@ -218,6 +218,16 @@ func Test_authMethodTypeToPb(t *testing.T) { domain.UserAuthMethodTypeIDP, user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP, }, + { + "otp sms", + domain.UserAuthMethodTypeOTPSMS, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS, + }, + { + "otp email", + domain.UserAuthMethodTypeOTPEmail, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/oidc/amr.go b/internal/api/oidc/amr.go index 727e0f8889..e53db8b7c9 100644 --- a/internal/api/oidc/amr.go +++ b/internal/api/oidc/amr.go @@ -22,25 +22,35 @@ const ( // [RFC 8176, section 2]: https://datatracker.ietf.org/doc/html/rfc8176#section-2 func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string { amr := make([]string, 0, 4) - var mfa bool + var factors, otp int for _, methodType := range methodTypes { switch methodType { case domain.UserAuthMethodTypePassword: amr = append(amr, PWD) + factors++ case domain.UserAuthMethodTypePasswordless: - mfa = true amr = append(amr, UserPresence) + factors += 2 case domain.UserAuthMethodTypeU2F: amr = append(amr, UserPresence) - case domain.UserAuthMethodTypeOTP: - amr = append(amr, OTP) + factors++ + case domain.UserAuthMethodTypeTOTP, + domain.UserAuthMethodTypeOTPSMS, + domain.UserAuthMethodTypeOTPEmail: + // a user could use multiple (t)otp, which is a factor, but still will be returned as a single `otp` entry + otp++ + factors++ case domain.UserAuthMethodTypeIDP: // no AMR value according to specification + factors++ case domain.UserAuthMethodTypeUnspecified: // ignore } } - if mfa || len(amr) >= 2 { + if otp > 0 { + amr = append(amr, OTP) + } + if factors >= 2 { amr = append(amr, MFA) } return amr diff --git a/internal/api/oidc/amr_test.go b/internal/api/oidc/amr_test.go index 1861085bc5..d4468022f2 100644 --- a/internal/api/oidc/amr_test.go +++ b/internal/api/oidc/amr_test.go @@ -46,12 +46,33 @@ func TestAMR(t *testing.T) { []string{UserPresence}, }, { - "otp checked", + "totp checked", args{ - []domain.UserAuthMethodType{domain.UserAuthMethodTypeOTP}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypeTOTP}, }, []string{OTP}, }, + { + "otp sms checked", + args{ + []domain.UserAuthMethodType{domain.UserAuthMethodTypeOTPSMS}, + }, + []string{OTP}, + }, + { + "otp email checked", + args{ + []domain.UserAuthMethodType{domain.UserAuthMethodTypeOTPEmail}, + }, + []string{OTP}, + }, + { + "multiple (t)otp checked", + args{ + []domain.UserAuthMethodType{domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPEmail}, + }, + []string{OTP, MFA}, + }, { "multiple checked", args{ diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 02cbf8cba6..d424abfc50 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -261,7 +261,7 @@ func CodeChallengeToOIDC(challenge *domain.OIDCCodeChallenge) *oidc.CodeChalleng func AMRFromMFAType(mfaType domain.MFAType) string { switch mfaType { - case domain.MFATypeOTP: + case domain.MFATypeTOTP: return OTP case domain.MFATypeU2F, domain.MFATypeU2FUserVerification: diff --git a/internal/api/ui/login/mfa_init_verify_handler.go b/internal/api/ui/login/mfa_init_verify_handler.go index 5993e2ae7e..e6f0749e92 100644 --- a/internal/api/ui/login/mfa_init_verify_handler.go +++ b/internal/api/ui/login/mfa_init_verify_handler.go @@ -33,7 +33,7 @@ func (l *Login) handleMFAInitVerify(w http.ResponseWriter, r *http.Request) { } var verifyData *mfaVerifyData switch data.MFAType { - case domain.MFATypeOTP: + case domain.MFATypeTOTP: verifyData = l.handleOTPVerify(w, r, authReq, data) } @@ -50,13 +50,13 @@ func (l *Login) handleMFAInitVerify(w http.ResponseWriter, r *http.Request) { func (l *Login) handleOTPVerify(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaInitVerifyData) *mfaVerifyData { userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) - _, err := l.command.HumanCheckMFAOTPSetup(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, data.Code, userAgentID, authReq.UserOrgID) + _, err := l.command.HumanCheckMFATOTPSetup(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, data.Code, userAgentID, authReq.UserOrgID) if err == nil { return nil } mfadata := &mfaVerifyData{ MFAType: data.MFAType, - otpData: otpData{ + totpData: totpData{ Secret: data.Secret, Url: data.URL, }, @@ -73,10 +73,10 @@ func (l *Login) renderMFAInitVerify(w http.ResponseWriter, r *http.Request, auth translator := l.getTranslator(r.Context(), authReq) data.baseData = l.getBaseData(r, authReq, "InitMFAOTP.Title", "InitMFAOTP.Description", errID, errMessage) data.profileData = l.getProfileData(authReq) - if data.MFAType == domain.MFATypeOTP { - code, err := generateQrCode(data.otpData.Url) + if data.MFAType == domain.MFATypeTOTP { + code, err := generateQrCode(data.totpData.Url) if err == nil { - data.otpData.QrCode = code + data.totpData.QrCode = code } } diff --git a/internal/api/ui/login/mfa_prompt_handler.go b/internal/api/ui/login/mfa_prompt_handler.go index 185d61be99..c18511d317 100644 --- a/internal/api/ui/login/mfa_prompt_handler.go +++ b/internal/api/ui/login/mfa_prompt_handler.go @@ -80,8 +80,8 @@ func (l *Login) renderMFAPrompt(w http.ResponseWriter, r *http.Request, authReq func (l *Login) handleMFACreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) { switch data.MFAType { - case domain.MFATypeOTP: - l.handleOTPCreation(w, r, authReq, data) + case domain.MFATypeTOTP: + l.handleTOTPCreation(w, r, authReq, data) return case domain.MFATypeU2F: l.renderRegisterU2F(w, r, authReq, nil) @@ -90,16 +90,16 @@ func (l *Login) handleMFACreation(w http.ResponseWriter, r *http.Request, authRe l.renderError(w, r, authReq, caos_errs.ThrowPreconditionFailed(nil, "APP-Or3HO", "Errors.User.MFA.NoProviders")) } -func (l *Login) handleOTPCreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) { - otp, err := l.command.AddHumanOTP(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID) +func (l *Login) handleTOTPCreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) { + otp, err := l.command.AddHumanTOTP(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID) if err != nil { l.renderError(w, r, authReq, err) return } - data.otpData = otpData{ - Secret: otp.SecretString, - Url: otp.Url, + data.totpData = totpData{ + Secret: otp.Secret, + Url: otp.URI, } l.renderMFAInitVerify(w, r, authReq, data, nil) } diff --git a/internal/api/ui/login/mfa_verify_handler.go b/internal/api/ui/login/mfa_verify_handler.go index 788e189cb1..b65809c894 100644 --- a/internal/api/ui/login/mfa_verify_handler.go +++ b/internal/api/ui/login/mfa_verify_handler.go @@ -33,7 +33,7 @@ func (l *Login) handleMFAVerify(w http.ResponseWriter, r *http.Request) { l.renderMFAVerifySelected(w, r, authReq, step, data.SelectedProvider, nil) return } - if data.MFAType == domain.MFATypeOTP { + if data.MFAType == domain.MFATypeTOTP { userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) err = l.authRepo.VerifyMFAOTP(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.UserID, authReq.UserOrgID, data.Code, userAgentID, domain.BrowserInfoFromRequest(r)) @@ -45,7 +45,7 @@ func (l *Login) handleMFAVerify(w http.ResponseWriter, r *http.Request) { } if err != nil { - l.renderMFAVerifySelected(w, r, authReq, step, domain.MFATypeOTP, err) + l.renderMFAVerifySelected(w, r, authReq, step, domain.MFATypeTOTP, err) return } } @@ -79,9 +79,9 @@ func (l *Login) renderMFAVerifySelected(w http.ResponseWriter, r *http.Request, data.Description = translator.LocalizeWithoutArgs("VerifyMFAU2F.Description") l.renderU2FVerification(w, r, authReq, removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeU2F), nil) return - case domain.MFATypeOTP: - data.MFAProviders = removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeOTP) - data.SelectedMFAProvider = domain.MFATypeOTP + case domain.MFATypeTOTP: + data.MFAProviders = removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeTOTP) + data.SelectedMFAProvider = domain.MFATypeTOTP data.Title = translator.LocalizeWithoutArgs("VerifyMFAOTP.Title") data.Description = translator.LocalizeWithoutArgs("VerifyMFAOTP.Description") default: diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index 5c20168d35..bcbe4ad9af 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -673,7 +673,7 @@ type mfaVerifyData struct { baseData profileData MFAType domain.MFAType - otpData + totpData } type mfaDoneData struct { @@ -682,7 +682,7 @@ type mfaDoneData struct { MFAType domain.MFAType } -type otpData struct { +type totpData struct { Url string Secret string QrCode string diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index ea3377a50c..0f81e64148 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -373,7 +373,7 @@ func (repo *AuthRequestRepo) VerifyMFAOTP(ctx context.Context, authRequestID, us if err != nil { return err } - return repo.Command.HumanCheckMFAOTP(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) + return repo.Command.HumanCheckMFATOTP(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) } func (repo *AuthRequestRepo) BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (login *domain.WebAuthNLogin, err error) { @@ -926,11 +926,15 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth steps = append(steps, new(domain.ExternalNotFoundOptionStep)) return steps, nil } - steps = append(steps, new(domain.LoginStep)) if domain.IsPrompt(request.Prompt, domain.PromptCreate) { return append(steps, &domain.RegistrationStep{}), nil } - if len(request.Prompt) == 0 || domain.IsPrompt(request.Prompt, domain.PromptSelectAccount) { + // if there's a login or consent prompt, but not select account, just return the login step + if len(request.Prompt) > 0 && !domain.IsPrompt(request.Prompt, domain.PromptSelectAccount) { + return append(steps, new(domain.LoginStep)), nil + } else { + // if no user was specified, no prompt or select_account was provided, + // then check the active user sessions (of the user agent) users, err := repo.usersForUserSelection(request) if err != nil { return nil, err @@ -941,11 +945,19 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth if request.SelectedIDPConfigID != "" { steps = append(steps, &domain.RedirectToExternalIDPStep{}) } - if len(request.Prompt) == 0 && len(users) > 0 { + if len(request.Prompt) == 0 && len(users) == 0 { + steps = append(steps, new(domain.LoginStep)) + } + // if no prompt was provided, but there are multiple user sessions, then the user must decide which to use + if len(request.Prompt) == 0 && len(users) > 1 { steps = append(steps, &domain.SelectUserStep{Users: users}) } + if len(steps) > 0 { + return steps, nil + } + // a single user session was found, use that automatically + request.UserID = users[0].UserID } - return steps, nil } user, err := activeUserByID(ctx, repo.UserViewProvider, repo.UserEventProvider, repo.OrgViewProvider, repo.LockoutPolicyViewProvider, request.UserID, request.LoginPolicy.IgnoreUnknownUsernames) if err != nil { diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index 29737757b1..7e94c61ef6 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -296,6 +296,28 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { []domain.NextStep{&domain.RedirectToCallbackStep{}}, nil, }, + { + "user not set prompt create, registration step", + fields{ + userSessionViewProvider: &mockViewNoUserSession{}, + }, + args{&domain.AuthRequest{ + Prompt: []domain.Prompt{domain.PromptCreate}, + }, false}, + []domain.NextStep{&domain.RegistrationStep{}}, + nil, + }, + { + "user not set, prompts other than select account, create step", + fields{ + userSessionViewProvider: &mockViewNoUserSession{}, + }, + args{&domain.AuthRequest{ + Prompt: []domain.Prompt{domain.PromptLogin, domain.PromptConsent}, + }, false}, + []domain.NextStep{&domain.LoginStep{}}, + nil, + }, { "user not set no active session, login step", fields{ @@ -333,7 +355,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { errors.IsInternal, }, { - "user not set, prompt select account, login and select account steps", + "user not set, prompt select account, select account step", fields{ userSessionViewProvider: &mockViewUserSession{ Users: []mockUser{ @@ -353,7 +375,6 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, args{&domain.AuthRequest{Prompt: []domain.Prompt{domain.PromptSelectAccount}}, false}, []domain.NextStep{ - &domain.LoginStep{}, &domain.SelectUserStep{ Users: []domain.UserSelection{ { @@ -373,7 +394,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { nil, }, { - "user not set, primary domain set, prompt select account, login and select account steps", + "user not set, primary domain set, prompt select account, select account step", fields{ userSessionViewProvider: &mockViewUserSession{ Users: []mockUser{ @@ -393,7 +414,6 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, args{&domain.AuthRequest{Prompt: []domain.Prompt{domain.PromptSelectAccount}, RequestedOrgID: "orgID1"}, false}, []domain.NextStep{ - &domain.LoginStep{}, &domain.SelectUserStep{ Users: []domain.UserSelection{ { @@ -407,7 +427,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { nil, }, { - "user not set, prompt select account, no active session, login and select account steps", + "user not set, prompt select account, no active session, select account step", fields{ userSessionViewProvider: &mockViewUserSession{ Users: nil, @@ -416,12 +436,113 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, args{&domain.AuthRequest{Prompt: []domain.Prompt{domain.PromptSelectAccount}}, false}, []domain.NextStep{ - &domain.LoginStep{}, &domain.SelectUserStep{ Users: []domain.UserSelection{}, }}, nil, }, + { + "user not set single active session, callback step", + fields{ + userSessionViewProvider: &mockViewUserSession{ + PasswordVerification: time.Now().Add(-5 * time.Minute), + SecondFactorVerification: time.Now().Add(-5 * time.Minute), + Users: []mockUser{ + { + "id1", + "loginname1", + "orgID1", + }, + }, + }, + userViewProvider: &mockViewUser{ + PasswordSet: true, + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + userGrantProvider: &mockUserGrants{}, + projectProvider: &mockProject{}, + applicationProvider: &mockApp{app: &query.App{OIDCConfig: &query.OIDCApp{AppType: domain.OIDCApplicationTypeWeb}}}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{}, + }, + args{&domain.AuthRequest{ + Request: &domain.AuthRequestOIDC{}, + LoginPolicy: &domain.LoginPolicy{ + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, + PasswordCheckLifetime: 10 * 24 * time.Hour, + SecondFactorCheckLifetime: 18 * time.Hour, + }, + }, false}, + []domain.NextStep{&domain.RedirectToCallbackStep{}}, + nil, + }, + { + "user not set multiple active sessions, select account step", + fields{ + userSessionViewProvider: &mockViewUserSession{ + Users: []mockUser{ + { + "id1", + "loginname1", + "orgID1", + }, + { + "id2", + "loginname2", + "orgID2", + }, + }, + }, + userViewProvider: &mockViewUser{ + PasswordSet: true, + IsEmailVerified: true, + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + userGrantProvider: &mockUserGrants{}, + projectProvider: &mockProject{}, + applicationProvider: &mockApp{app: &query.App{OIDCConfig: &query.OIDCApp{AppType: domain.OIDCApplicationTypeWeb}}}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{}, + }, + args{&domain.AuthRequest{ + Request: &domain.AuthRequestOIDC{}, + LoginPolicy: &domain.LoginPolicy{ + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, + PasswordCheckLifetime: 10 * 24 * time.Hour, + SecondFactorCheckLifetime: 18 * time.Hour, + }, + }, false}, + []domain.NextStep{&domain.SelectUserStep{ + Users: []domain.UserSelection{ + { + UserID: "id1", + LoginName: "loginname1", + SelectionPossible: true, + ResourceOwner: "orgID1", + }, + { + UserID: "id2", + LoginName: "loginname2", + SelectionPossible: true, + ResourceOwner: "orgID2", + }, + }, + }}, + nil, + }, { "user not found, not found error", fields{ @@ -889,13 +1010,13 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { &domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, }, false}, []domain.NextStep{&domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }}, nil, }, @@ -923,13 +1044,13 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { &domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, }, false}, []domain.NextStep{&domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }}, nil, }, @@ -959,14 +1080,14 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { UserID: "UserID", SelectedIDPConfigID: "IDPConfigID", LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, ExternalLoginCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, }, false}, []domain.NextStep{&domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }}, nil, }, @@ -996,7 +1117,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { &domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1027,7 +1148,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1059,7 +1180,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { args{&domain.AuthRequest{ UserID: "UserID", LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1095,7 +1216,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { UserID: "UserID", Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1132,7 +1253,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1169,7 +1290,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1208,7 +1329,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1248,7 +1369,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1288,7 +1409,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1329,7 +1450,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { Prompt: []domain.Prompt{domain.PromptNone}, Request: &domain.AuthRequestOIDC{}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, PasswordCheckLifetime: 10 * 24 * time.Hour, SecondFactorCheckLifetime: 18 * time.Hour, }, @@ -1399,7 +1520,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { SelectedIDPConfigID: "IDPConfigID", LinkingUsers: []*domain.ExternalUser{{IDPConfigID: "IDPConfigID", ExternalUserID: "UserID", DisplayName: "DisplayName"}}, LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, SecondFactorCheckLifetime: 18 * time.Hour, PasswordCheckLifetime: 10 * 24 * time.Hour, }, @@ -1503,7 +1624,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { args{ request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, MFAInitSkipLifetime: 30 * 24 * time.Hour, }, }, @@ -1516,7 +1637,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { }, &domain.MFAPromptStep{ MFAProviders: []domain.MFAType{ - domain.MFATypeOTP, + domain.MFATypeTOTP, }, }, false, @@ -1528,7 +1649,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ ForceMFA: true, - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, MFAInitSkipLifetime: 30 * 24 * time.Hour, }, }, @@ -1542,7 +1663,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { &domain.MFAPromptStep{ Required: true, MFAProviders: []domain.MFAType{ - domain.MFATypeOTP, + domain.MFATypeTOTP, }, }, false, @@ -1573,7 +1694,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { args{ request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, SecondFactorCheckLifetime: 18 * time.Hour, }, }, @@ -1595,7 +1716,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { args{ request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, SecondFactorCheckLifetime: 18 * time.Hour, }, }, @@ -1610,7 +1731,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { }, &domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }, false, nil, @@ -1620,7 +1741,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { args{ request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, SecondFactorCheckLifetime: 18 * time.Hour, }, }, @@ -1634,7 +1755,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { isInternal: false, }, &domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }, false, nil, @@ -1644,7 +1765,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { args{ request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, SecondFactorCheckLifetime: 18 * time.Hour, }, }, @@ -1666,7 +1787,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { args{ request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, SecondFactorCheckLifetime: 18 * time.Hour, ForceMFA: true, }, @@ -1682,7 +1803,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { &domain.MFAPromptStep{ Required: true, MFAProviders: []domain.MFAType{ - domain.MFATypeOTP, + domain.MFATypeTOTP, }, }, false, @@ -1693,7 +1814,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { args{ request: &domain.AuthRequest{ LoginPolicy: &domain.LoginPolicy{ - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, SecondFactorCheckLifetime: 18 * time.Hour, ForceMFA: true, ForceMFALocalOnly: true, diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index 5bcecf9dd4..1e2153863a 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -199,12 +199,21 @@ func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType // TODO: add checks with https://github.com/zitadel/zitadel/issues/5477 /* if !session.TOTPFactor.TOTPCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypeOTP) + types = append(types, domain.UserAuthMethodTypeTOTP) } if !session.U2FFactor.U2FCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeU2F) } */ + // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 + /* + if !session.TOTPFactor.OTPSMSCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPSMS) + } + if !session.TOTPFactor.OTPEmailCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPEmail) + } + */ return types } diff --git a/internal/command/instance.go b/internal/command/instance.go index ac52c351d6..8bc43fbd38 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -48,6 +48,8 @@ type InstanceSetup struct { PasswordVerificationCode *crypto.GeneratorConfig PasswordlessInitCode *crypto.GeneratorConfig DomainVerification *crypto.GeneratorConfig + OTPSMS *crypto.GeneratorConfig + OTPEmail *crypto.GeneratorConfig } PasswordComplexityPolicy struct { MinLength uint64 @@ -201,6 +203,8 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str prepareAddSecretGeneratorConfig(instanceAgg, domain.SecretGeneratorTypePasswordResetCode, setup.SecretGenerators.PasswordVerificationCode), prepareAddSecretGeneratorConfig(instanceAgg, domain.SecretGeneratorTypePasswordlessInitCode, setup.SecretGenerators.PasswordlessInitCode), prepareAddSecretGeneratorConfig(instanceAgg, domain.SecretGeneratorTypeVerifyDomain, setup.SecretGenerators.DomainVerification), + prepareAddSecretGeneratorConfig(instanceAgg, domain.SecretGeneratorTypeOTPSMS, setup.SecretGenerators.OTPSMS), + prepareAddSecretGeneratorConfig(instanceAgg, domain.SecretGeneratorTypeOTPEmail, setup.SecretGenerators.OTPEmail), prepareAddDefaultPasswordComplexityPolicy( instanceAgg, @@ -241,8 +245,12 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str setup.LoginPolicy.SecondFactorCheckLifetime, setup.LoginPolicy.MultiFactorCheckLifetime, ), - prepareAddSecondFactorToDefaultLoginPolicy(instanceAgg, domain.SecondFactorTypeOTP), + prepareAddSecondFactorToDefaultLoginPolicy(instanceAgg, domain.SecondFactorTypeTOTP), prepareAddSecondFactorToDefaultLoginPolicy(instanceAgg, domain.SecondFactorTypeU2F), + /* TODO: incomment when usable + prepareAddSecondFactorToDefaultLoginPolicy(instanceAgg, domain.SecondFactorTypeOTPEmail), + prepareAddSecondFactorToDefaultLoginPolicy(instanceAgg, domain.SecondFactorTypeOTPSMS), + */ prepareAddMultiFactorToDefaultLoginPolicy(instanceAgg, domain.MultiFactorTypeU2FWithPIN), prepareAddDefaultPrivacyPolicy(instanceAgg, setup.PrivacyPolicy.TOSLink, setup.PrivacyPolicy.PrivacyLink, setup.PrivacyPolicy.HelpLink, setup.PrivacyPolicy.SupportEmail), diff --git a/internal/command/instance_policy_login_test.go b/internal/command/instance_policy_login_test.go index 881f1c9640..e69c6a5660 100644 --- a/internal/command/instance_policy_login_test.go +++ b/internal/command/instance_policy_login_test.go @@ -884,7 +884,7 @@ func TestCommandSide_AddSecondFactorDefaultLoginPolicy(t *testing.T) { eventFromEventPusher( instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, ), ), ), @@ -892,14 +892,14 @@ func TestCommandSide_AddSecondFactorDefaultLoginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, }, res: res{ err: caos_errs.IsErrorAlreadyExists, }, }, { - name: "add factor, ok", + name: "add factor totp, ok", fields: fields{ eventstore: eventstoreExpect( t, @@ -910,7 +910,7 @@ func TestCommandSide_AddSecondFactorDefaultLoginPolicy(t *testing.T) { "INSTANCE", instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, - domain.SecondFactorTypeOTP), + domain.SecondFactorTypeTOTP), ), }, ), @@ -918,7 +918,98 @@ func TestCommandSide_AddSecondFactorDefaultLoginPolicy(t *testing.T) { }, args: args{ ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "add factor otp email, ok ", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "INSTANCE", + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPEmail), + ), + }, + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + factor: domain.SecondFactorTypeOTPEmail, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "add factor otp sms, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "INSTANCE", + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPSMS), + ), + }, + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + factor: domain.SecondFactorTypeOTPSMS, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "add factor totp, add otp sms, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeTOTP, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "INSTANCE", + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPSMS), + ), + }, + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + factor: domain.SecondFactorTypeOTPSMS, }, res: res{ want: &domain.ObjectDetails{ @@ -989,14 +1080,14 @@ func TestCommandSide_RemoveSecondFactorDefaultLoginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, }, res: res{ err: caos_errs.IsNotFound, }, }, { - name: "factor removed, not found error", + name: "factor removed totp, not found error", fields: fields{ eventstore: eventstoreExpect( t, @@ -1004,13 +1095,13 @@ func TestCommandSide_RemoveSecondFactorDefaultLoginPolicy(t *testing.T) { eventFromEventPusher( instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, ), ), eventFromEventPusher( instance.NewLoginPolicySecondFactorRemovedEvent(context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, ), ), ), @@ -1018,14 +1109,14 @@ func TestCommandSide_RemoveSecondFactorDefaultLoginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, }, res: res{ err: caos_errs.IsNotFound, }, }, { - name: "add factor, ok", + name: "factor removed otp email, not found error", fields: fields{ eventstore: eventstoreExpect( t, @@ -1033,7 +1124,65 @@ func TestCommandSide_RemoveSecondFactorDefaultLoginPolicy(t *testing.T) { eventFromEventPusher( instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeOTPEmail, + ), + ), + eventFromEventPusher( + instance.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPEmail, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPEmail, + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "factor removed otp sms, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPSMS, + ), + ), + eventFromEventPusher( + instance.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPSMS, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPSMS, + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "remove factor totp, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeTOTP, ), ), ), @@ -1042,7 +1191,7 @@ func TestCommandSide_RemoveSecondFactorDefaultLoginPolicy(t *testing.T) { eventFromEventPusher( instance.NewLoginPolicySecondFactorRemovedEvent(context.Background(), &instance.NewAggregate("INSTANCE").Aggregate, - domain.SecondFactorTypeOTP), + domain.SecondFactorTypeTOTP), ), }, ), @@ -1050,7 +1199,7 @@ func TestCommandSide_RemoveSecondFactorDefaultLoginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, }, res: res{ want: &domain.ObjectDetails{ @@ -1058,6 +1207,97 @@ func TestCommandSide_RemoveSecondFactorDefaultLoginPolicy(t *testing.T) { }, }, }, + { + name: "remove factor email, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPEmail, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + instance.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPEmail), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPEmail, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "remove factor sms, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPSMS, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + instance.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeOTPSMS), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPSMS, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "factor added totp, removed otp sms, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecondFactorTypeTOTP, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPSMS, + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/command/instance_settings.go b/internal/command/instance_settings.go index 3105f58dcc..05684ed933 100644 --- a/internal/command/instance_settings.go +++ b/internal/command/instance_settings.go @@ -79,10 +79,26 @@ func (c *Commands) ChangeSecretGeneratorConfig(ctx context.Context, generatorTyp if err != nil { return nil, err } - if generatorWriteModel.State == domain.SecretGeneratorStateUnspecified || generatorWriteModel.State == domain.SecretGeneratorStateRemoved { - return nil, errors.ThrowNotFound(nil, "COMMAND-3n9ls", "Errors.SecretGenerator.NotFound") - } instanceAgg := InstanceAggregateFromWriteModel(&generatorWriteModel.WriteModel) + if generatorWriteModel.State == domain.SecretGeneratorStateUnspecified || generatorWriteModel.State == domain.SecretGeneratorStateRemoved { + err = c.pushAppendAndReduce(ctx, generatorWriteModel, + instance.NewSecretGeneratorAddedEvent( + ctx, + instanceAgg, + generatorType, + config.Length, + config.Expiry, + config.IncludeLowerLetters, + config.IncludeUpperLetters, + config.IncludeDigits, + config.IncludeSymbols, + ), + ) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&generatorWriteModel.WriteModel), nil + } changedEvent, hasChanged, err := generatorWriteModel.NewChangedEvent( ctx, @@ -100,12 +116,7 @@ func (c *Commands) ChangeSecretGeneratorConfig(ctx context.Context, generatorTyp if !hasChanged { return nil, errors.ThrowPreconditionFailed(nil, "COMMAND-m0o3f", "Errors.NoChangesFound") } - pushedEvents, err := c.eventstore.Push(ctx, changedEvent) - if err != nil { - return nil, err - } - err = AppendAndReduce(generatorWriteModel, pushedEvents...) - if err != nil { + if err = c.pushAppendAndReduce(ctx, generatorWriteModel, changedEvent); err != nil { return nil, err } return writeModelToObjectDetails(&generatorWriteModel.WriteModel), nil diff --git a/internal/command/instance_settings_test.go b/internal/command/instance_settings_test.go index ad95fc0612..6b0011511d 100644 --- a/internal/command/instance_settings_test.go +++ b/internal/command/instance_settings_test.go @@ -6,8 +6,8 @@ import ( "time" "github.com/stretchr/testify/assert" - "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" @@ -155,7 +155,7 @@ func TestCommandSide_AddSecretGenerator(t *testing.T) { func TestCommandSide_ChangeSecretGenerator(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -176,12 +176,10 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { { name: "empty generatortype, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), generator: &crypto.GeneratorConfig{}, generatorType: domain.SecretGeneratorTypeUnspecified, }, @@ -190,26 +188,53 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { }, }, { - name: "generator not existing, not found error", + name: "generator not existing, new added ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "INSTANCE", + instance.NewSecretGeneratorAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecretGeneratorTypeInitCode, + 4, + time.Hour*1, + true, + true, + true, + true, + ), + ), + }, + uniqueConstraintsFromEventConstraintWithInstanceID("INSTANCE", instance.NewAddSecretGeneratorTypeUniqueConstraint(domain.SecretGeneratorTypeInitCode)), + ), ), }, args: args{ - ctx: context.Background(), + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + generator: &crypto.GeneratorConfig{ + Length: 4, + Expiry: 1 * time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, generatorType: domain.SecretGeneratorTypeInitCode, }, res: res{ - err: caos_errs.IsNotFound, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { - name: "generator removed, not found error", + name: "generator removed, new added ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent( @@ -230,21 +255,49 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { domain.SecretGeneratorTypeInitCode), ), ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "INSTANCE", + instance.NewSecretGeneratorAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + domain.SecretGeneratorTypeInitCode, + 4, + time.Hour*1, + true, + true, + true, + true, + ), + ), + }, + uniqueConstraintsFromEventConstraintWithInstanceID("INSTANCE", instance.NewAddSecretGeneratorTypeUniqueConstraint(domain.SecretGeneratorTypeInitCode)), + ), ), }, args: args{ - ctx: context.Background(), + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + generator: &crypto.GeneratorConfig{ + Length: 4, + Expiry: 1 * time.Hour, + IncludeLowerLetters: true, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: true, + }, generatorType: domain.SecretGeneratorTypeInitCode, }, res: res{ - err: caos_errs.IsNotFound, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "no changes, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent( @@ -263,7 +316,7 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { ), }, args: args{ - ctx: context.Background(), + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), generator: &crypto.GeneratorConfig{ Length: 4, Expiry: 1 * time.Hour, @@ -281,8 +334,7 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { { name: "secret generator change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewSecretGeneratorAddedEvent( @@ -300,7 +352,7 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { ), expectPush( []*repository.Event{ - eventFromEventPusher( + eventFromEventPusherWithInstanceID("INSTANCE", newSecretGeneratorChangedEvent(context.Background(), domain.SecretGeneratorTypeInitCode, 8, @@ -308,14 +360,15 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { false, false, false, - false), + false, + ), ), }, ), ), }, args: args{ - ctx: context.Background(), + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), generator: &crypto.GeneratorConfig{ Length: 8, Expiry: 2 * time.Hour, @@ -336,7 +389,7 @@ func TestCommandSide_ChangeSecretGenerator(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.ChangeSecretGeneratorConfig(tt.args.ctx, tt.args.generatorType, tt.args.generator) if tt.res.err == nil { diff --git a/internal/command/org_policy_login_test.go b/internal/command/org_policy_login_test.go index f7d1accfa5..ebc96ac419 100644 --- a/internal/command/org_policy_login_test.go +++ b/internal/command/org_policy_login_test.go @@ -231,7 +231,7 @@ func TestCommandSide_AddLoginPolicy(t *testing.T) { eventFromEventPusher( org.NewLoginPolicySecondFactorAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, ), ), eventFromEventPusher( @@ -265,7 +265,7 @@ func TestCommandSide_AddLoginPolicy(t *testing.T) { MFAInitSkipLifetime: time.Hour * 3, SecondFactorCheckLifetime: time.Hour * 4, MultiFactorCheckLifetime: time.Hour * 5, - SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, MultiFactors: []domain.MultiFactorType{domain.MultiFactorTypeU2FWithPIN}, }, }, @@ -1504,7 +1504,7 @@ func TestCommandSide_AddSecondFactorLoginPolicy(t *testing.T) { eventFromEventPusher( org.NewLoginPolicySecondFactorAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, ), ), ), @@ -1512,7 +1512,7 @@ func TestCommandSide_AddSecondFactorLoginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, resourceOwner: "org1", }, res: res{ @@ -1520,7 +1520,7 @@ func TestCommandSide_AddSecondFactorLoginPolicy(t *testing.T) { }, }, { - name: "add factor, ok", + name: "add factor totp, ok", fields: fields{ eventstore: eventstoreExpect( t, @@ -1530,7 +1530,7 @@ func TestCommandSide_AddSecondFactorLoginPolicy(t *testing.T) { eventFromEventPusher( org.NewLoginPolicySecondFactorAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, - domain.SecondFactorTypeOTP), + domain.SecondFactorTypeTOTP), ), }, ), @@ -1538,11 +1538,96 @@ func TestCommandSide_AddSecondFactorLoginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, resourceOwner: "org1", }, res: res{ - want: domain.SecondFactorTypeOTP, + want: domain.SecondFactorTypeTOTP, + }, + }, + { + name: "add factor otp email, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPEmail), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPEmail, + resourceOwner: "org1", + }, + res: res{ + want: domain.SecondFactorTypeOTPEmail, + }, + }, + { + name: "add factor otp sms, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPSMS), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPSMS, + resourceOwner: "org1", + }, + res: res{ + want: domain.SecondFactorTypeOTPSMS, + }, + }, + { + name: "add factor totp, add otp sms, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeTOTP, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPSMS), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPSMS, + resourceOwner: "org1", + }, + res: res{ + want: domain.SecondFactorTypeOTPSMS, }, }, } @@ -1593,7 +1678,7 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, }, res: res{ err: caos_errs.IsErrorInvalidArgument, @@ -1624,7 +1709,7 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, resourceOwner: "org1", }, res: res{ @@ -1632,7 +1717,7 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { }, }, { - name: "factor removed, not found error", + name: "factor totp removed, not found error", fields: fields{ eventstore: eventstoreExpect( t, @@ -1640,13 +1725,13 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { eventFromEventPusher( org.NewLoginPolicySecondFactorAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, ), ), eventFromEventPusher( org.NewLoginPolicySecondFactorRemovedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeTOTP, ), ), ), @@ -1654,7 +1739,7 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, resourceOwner: "org1", }, res: res{ @@ -1662,7 +1747,7 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { }, }, { - name: "add factor, ok", + name: "factor otp email removed, not found error", fields: fields{ eventstore: eventstoreExpect( t, @@ -1670,7 +1755,67 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { eventFromEventPusher( org.NewLoginPolicySecondFactorAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, - domain.SecondFactorTypeOTP, + domain.SecondFactorTypeOTPEmail, + ), + ), + eventFromEventPusher( + org.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPEmail, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPEmail, + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "factor otp sms removed, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPSMS, + ), + ), + eventFromEventPusher( + org.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPSMS, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPSMS, + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "add factor totp, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeTOTP, ), ), ), @@ -1679,7 +1824,7 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { eventFromEventPusher( org.NewLoginPolicySecondFactorRemovedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, - domain.SecondFactorTypeOTP), + domain.SecondFactorTypeTOTP), ), }, ), @@ -1687,7 +1832,77 @@ func TestCommandSide_RemoveSecondFactoroginPolicy(t *testing.T) { }, args: args{ ctx: context.Background(), - factor: domain.SecondFactorTypeOTP, + factor: domain.SecondFactorTypeTOTP, + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "add factor otp email, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPEmail, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + org.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPEmail), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPEmail, + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "add factor otp sms, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPSMS, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + org.NewLoginPolicySecondFactorRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTPSMS), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + factor: domain.SecondFactorTypeOTPSMS, resourceOwner: "org1", }, res: res{ diff --git a/internal/command/phone.go b/internal/command/phone.go index 7f550ceeaf..9b0a422b26 100644 --- a/internal/command/phone.go +++ b/internal/command/phone.go @@ -11,6 +11,9 @@ import ( type Phone struct { Number domain.PhoneNumber Verified bool + + // ReturnCode is used if the Verified field is false + ReturnCode bool } func (c *Commands) newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCode, error) { diff --git a/internal/command/session_model.go b/internal/command/session_model.go index dce787ff76..764724e0e1 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -155,6 +155,7 @@ func (wm *SessionWriteModel) AuthenticationTime() time.Time { wm.PasskeyCheckedAt, wm.IntentCheckedAt, // TODO: add U2F and OTP check https://github.com/zitadel/zitadel/issues/5477 + // TODO: add OTP (sms and email) check https://github.com/zitadel/zitadel/issues/6224 } { if check.After(authTime) { authTime = check @@ -178,11 +179,20 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType { // TODO: add checks with https://github.com/zitadel/zitadel/issues/5477 /* if !wm.TOTPCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypeOTP) + types = append(types, domain.UserAuthMethodTypeTOTP) } if !wm.U2FCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeU2F) } */ + // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 + /* + if !wm.TOTPFactor.OTPSMSCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPSMS) + } + if !wm.TOTPFactor.OTPEmailCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPEmail) + } + */ return types } diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 1bbaec3ca1..044066aba7 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -66,6 +66,9 @@ type AddHuman struct { // EmailCode is set by the command EmailCode *string + + // PhoneCode is set by the command + PhoneCode *string } type AddLink struct { @@ -258,7 +261,6 @@ func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation. if human.Email.Verified { cmds = append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &a.Aggregate)) } - // if allowInitMail, used for v1 api (system, admin, mgmt, auth): // add init code if // email not verified or @@ -302,7 +304,10 @@ func (c *Commands) addHumanCommandPhone(ctx context.Context, filter preparation. if err != nil { return nil, err } - return append(cmds, user.NewHumanPhoneCodeAddedEvent(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry)), nil + if human.Phone.ReturnCode { + human.PhoneCode = &phoneCode.Plain + } + return append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry, human.Phone.ReturnCode)), nil } func (c *Commands) addHumanCommandCheckID(ctx context.Context, filter preparation.FilterToQueryReducer, human *AddHuman, orgID string) (err error) { diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index 8bf6f04685..a1f207b402 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -11,12 +11,11 @@ import ( "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, resourceowner string, key string) error { +func (c *Commands) ImportHumanTOTP(ctx context.Context, userID, userAgentID, resourceowner string, key string) error { encryptedSecret, err := crypto.Encrypt([]byte(key), c.multifactors.OTP.CryptoMFA) if err != nil { return err @@ -25,7 +24,7 @@ func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, reso return err } - otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceowner) + otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceowner) if err != nil { return err } @@ -41,7 +40,7 @@ func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, reso return err } -func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string) (*domain.OTP, error) { +func (c *Commands) AddHumanTOTP(ctx context.Context, userID, resourceowner string) (*domain.TOTP, error) { if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing") } @@ -49,21 +48,19 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string if err != nil { return nil, err } - _, err = c.eventstore.Push(ctx, prep.cmds...) + err = c.pushAppendAndReduce(ctx, prep.wm, prep.cmds...) if err != nil { return nil, err } - return &domain.OTP{ - ObjectRoot: models.ObjectRoot{ - AggregateID: prep.userAgg.ID, - }, - SecretString: prep.key.Secret(), - Url: prep.key.URL(), + return &domain.TOTP{ + ObjectDetails: writeModelToObjectDetails(&prep.wm.WriteModel), + Secret: prep.key.Secret(), + URI: prep.key.URL(), }, nil } type preparedTOTP struct { - wm *HumanOTPWriteModel + wm *HumanTOTPWriteModel userAgg *eventstore.Aggregate key *otp.Key cmds []eventstore.Command @@ -72,21 +69,21 @@ type preparedTOTP struct { func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner string) (*preparedTOTP, error) { human, err := c.getHuman(ctx, userID, resourceOwner) if err != nil { - logging.Log("COMMAND-DAqe1").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname") + logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname") return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-MM9fs", "Errors.User.NotFound") } org, err := c.getOrg(ctx, human.ResourceOwner) if err != nil { - logging.Log("COMMAND-Cm0ds").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org for loginname") + logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org for loginname") return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-55M9f", "Errors.Org.NotFound") } orgPolicy, err := c.getOrgDomainPolicy(ctx, org.AggregateID) if err != nil { - logging.Log("COMMAND-y5zv9").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org policy for loginname") + logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org policy for loginname") return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-8ugTs", "Errors.Org.DomainPolicy.NotFound") } - otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceOwner) + otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceOwner) if err != nil { return nil, err } @@ -103,7 +100,7 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st if issuer == "" { issuer = authz.GetInstance(ctx).RequestedDomain() } - key, secret, err := domain.NewOTPKey(issuer, accountName, c.multifactors.OTP.CryptoMFA) + key, secret, err := domain.NewTOTPKey(issuer, accountName, c.multifactors.OTP.CryptoMFA) if err != nil { return nil, err } @@ -117,12 +114,12 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st }, nil } -func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, userAgentID, resourceowner string) (*domain.ObjectDetails, error) { +func (c *Commands) HumanCheckMFATOTPSetup(ctx context.Context, userID, code, userAgentID, resourceowner string) (*domain.ObjectDetails, error) { if userID == "" { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing") } - existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceowner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceowner) if err != nil { return nil, err } @@ -132,7 +129,7 @@ func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, user if existingOTP.State == domain.MFAStateReady { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-qx4ls", "Errors.Users.MFA.OTP.AlreadyReady") } - if err := domain.VerifyMFAOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA); err != nil { + if err := domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA); err != nil { return nil, err } userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) @@ -148,11 +145,11 @@ func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, user return writeModelToObjectDetails(&existingOTP.WriteModel), nil } -func (c *Commands) HumanCheckMFAOTP(ctx context.Context, userID, code, resourceowner string, authRequest *domain.AuthRequest) error { +func (c *Commands) HumanCheckMFATOTP(ctx context.Context, userID, code, resourceowner string, authRequest *domain.AuthRequest) error { if userID == "" { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing") } - existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceowner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceowner) if err != nil { return err } @@ -160,22 +157,22 @@ func (c *Commands) HumanCheckMFAOTP(ctx context.Context, userID, code, resourceo return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotReady") } userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) - err = domain.VerifyMFAOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA) + err = domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA) if err == nil { _, err = c.eventstore.Push(ctx, user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) return err } _, pushErr := c.eventstore.Push(ctx, user.NewHumanOTPCheckFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) - logging.Log("COMMAND-9fj7s").OnError(pushErr).Error("error create password check failed event") + logging.OnError(pushErr).Error("error create password check failed event") return err } -func (c *Commands) HumanRemoveOTP(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) HumanRemoveTOTP(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing") } - existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceOwner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceOwner) if err != nil { return nil, err } @@ -194,11 +191,128 @@ func (c *Commands) HumanRemoveOTP(ctx context.Context, userID, resourceOwner str return writeModelToObjectDetails(&existingOTP.WriteModel), nil } -func (c *Commands) otpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPWriteModel, err error) { +func (c *Commands) AddHumanOTPSMS(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-QSF2s", "Errors.User.UserIDMissing") + } + if err := authz.UserIDInCTX(ctx, userID); err != nil { + return nil, err + } + otpWriteModel, err := c.otpSMSWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if otpWriteModel.otpAdded { + return nil, caos_errs.ThrowAlreadyExists(nil, "COMMAND-Ad3g2", "Errors.User.MFA.OTP.AlreadyReady") + } + if !otpWriteModel.phoneVerified { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady") + } + userAgg := UserAggregateFromWriteModel(&otpWriteModel.WriteModel) + if err = c.pushAppendAndReduce(ctx, otpWriteModel, user.NewHumanOTPSMSAddedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil +} + +func (c *Commands) RemoveHumanOTPSMS(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-S3br2", "Errors.User.UserIDMissing") + } + + existingOTP, err := c.otpSMSWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if userID != authz.GetCtxData(ctx).UserID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil { + return nil, err + } + } + if !existingOTP.otpAdded { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-Sr3h3", "Errors.User.MFA.OTP.NotExisting") + } + userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) + if err = c.pushAppendAndReduce(ctx, existingOTP, user.NewHumanOTPSMSRemovedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingOTP.WriteModel), nil +} + +func (c *Commands) AddHumanOTPEmail(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sg1hz", "Errors.User.UserIDMissing") + } + otpWriteModel, err := c.otpEmailWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if otpWriteModel.otpAdded { + return nil, caos_errs.ThrowAlreadyExists(nil, "COMMAND-MKL2s", "Errors.User.MFA.OTP.AlreadyReady") + } + if !otpWriteModel.emailVerified { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KLJ2d", "Errors.User.MFA.OTP.NotReady") + } + userAgg := UserAggregateFromWriteModel(&otpWriteModel.WriteModel) + if err = c.pushAppendAndReduce(ctx, otpWriteModel, user.NewHumanOTPEmailAddedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil +} + +func (c *Commands) RemoveHumanOTPEmail(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-S2h11", "Errors.User.UserIDMissing") + } + + existingOTP, err := c.otpEmailWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if userID != authz.GetCtxData(ctx).UserID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil { + return nil, err + } + } + if !existingOTP.otpAdded { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-b312D", "Errors.User.MFA.OTP.NotExisting") + } + userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) + if err = c.pushAppendAndReduce(ctx, existingOTP, user.NewHumanOTPEmailRemovedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingOTP.WriteModel), nil +} + +func (c *Commands) totpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanTOTPWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel = NewHumanOTPWriteModel(userID, resourceOwner) + writeModel = NewHumanTOTPWriteModel(userID, resourceOwner) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) otpSMSWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPSMSWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewHumanOTPSMSWriteModel(userID, resourceOwner) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) otpEmailWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPEmailWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewHumanOTPEmailWriteModel(userID, resourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err diff --git a/internal/command/user_human_otp_model.go b/internal/command/user_human_otp_model.go index 2e4914da50..9c9b8d2c49 100644 --- a/internal/command/user_human_otp_model.go +++ b/internal/command/user_human_otp_model.go @@ -7,15 +7,15 @@ import ( "github.com/zitadel/zitadel/internal/repository/user" ) -type HumanOTPWriteModel struct { +type HumanTOTPWriteModel struct { eventstore.WriteModel State domain.MFAState Secret *crypto.CryptoValue } -func NewHumanOTPWriteModel(userID, resourceOwner string) *HumanOTPWriteModel { - return &HumanOTPWriteModel{ +func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel { + return &HumanTOTPWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, @@ -23,7 +23,7 @@ func NewHumanOTPWriteModel(userID, resourceOwner string) *HumanOTPWriteModel { } } -func (wm *HumanOTPWriteModel) Reduce() error { +func (wm *HumanTOTPWriteModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { case *user.HumanOTPAddedEvent: @@ -40,7 +40,7 @@ func (wm *HumanOTPWriteModel) Reduce() error { return wm.WriteModel.Reduce() } -func (wm *HumanOTPWriteModel) Query() *eventstore.SearchQueryBuilder { +func (wm *HumanTOTPWriteModel) Query() *eventstore.SearchQueryBuilder { query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). AddQuery(). AggregateTypes(user.AggregateType). @@ -59,3 +59,107 @@ func (wm *HumanOTPWriteModel) Query() *eventstore.SearchQueryBuilder { } return query } + +type HumanOTPSMSWriteModel struct { + eventstore.WriteModel + + phoneVerified bool + otpAdded bool +} + +func NewHumanOTPSMSWriteModel(userID, resourceOwner string) *HumanOTPSMSWriteModel { + return &HumanOTPSMSWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *HumanOTPSMSWriteModel) Reduce() error { + for _, event := range wm.Events { + switch event.(type) { + case *user.HumanPhoneVerifiedEvent: + wm.phoneVerified = true + case *user.HumanOTPSMSAddedEvent: + wm.otpAdded = true + case *user.HumanOTPSMSRemovedEvent: + wm.otpAdded = false + case *user.HumanPhoneRemovedEvent, + *user.UserRemovedEvent: + wm.phoneVerified = false + wm.otpAdded = false + } + } + return wm.WriteModel.Reduce() +} + +func (wm *HumanOTPSMSWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(user.HumanPhoneVerifiedType, + user.HumanOTPSMSAddedType, + user.HumanOTPSMSRemovedType, + user.HumanPhoneRemovedType, + user.UserRemovedType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +type HumanOTPEmailWriteModel struct { + eventstore.WriteModel + + emailVerified bool + otpAdded bool +} + +func NewHumanOTPEmailWriteModel(userID, resourceOwner string) *HumanOTPEmailWriteModel { + return &HumanOTPEmailWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *HumanOTPEmailWriteModel) Reduce() error { + for _, event := range wm.Events { + switch event.(type) { + case *user.HumanEmailVerifiedEvent: + wm.emailVerified = true + case *user.HumanOTPEmailAddedEvent: + wm.otpAdded = true + case *user.HumanOTPEmailRemovedEvent: + wm.otpAdded = false + case *user.UserRemovedEvent: + wm.emailVerified = false + wm.otpAdded = false + } + } + return wm.WriteModel.Reduce() +} + +func (wm *HumanOTPEmailWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(user.HumanEmailVerifiedType, + user.HumanOTPEmailAddedType, + user.HumanOTPEmailRemovedType, + user.UserRemovedType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} diff --git a/internal/command/user_human_otp_test.go b/internal/command/user_human_otp_test.go index ae44489aee..28e825de4b 100644 --- a/internal/command/user_human_otp_test.go +++ b/internal/command/user_human_otp_test.go @@ -22,7 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/user" ) -func TestCommandSide_AddHumanOTP(t *testing.T) { +func TestCommandSide_AddHumanTOTP(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore } @@ -223,7 +223,7 @@ func TestCommandSide_AddHumanOTP(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore, } - got, err := r.AddHumanOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) + got, err := r.AddHumanTOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { assert.NoError(t, err) } @@ -237,7 +237,7 @@ func TestCommandSide_AddHumanOTP(t *testing.T) { } } -func TestCommands_createHumanOTP(t *testing.T) { +func TestCommands_createHumanTOTP(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore } @@ -527,11 +527,11 @@ func TestCommands_createHumanOTP(t *testing.T) { } } -func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) { +func TestCommands_HumanCheckMFATOTPSetup(t *testing.T) { ctx := authz.NewMockContext("", "org1", "user1") cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t)) - key, secret, err := domain.NewOTPKey("example.com", "user1", cryptoAlg) + key, secret, err := domain.NewTOTPKey("example.com", "user1", cryptoAlg) require.NoError(t, err) userAgg := &user.NewAggregate("user1", "org1").Aggregate @@ -697,7 +697,7 @@ func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) { }, }, } - got, err := c.HumanCheckMFAOTPSetup(ctx, tt.args.userID, tt.args.code, "agent1", tt.args.resourceOwner) + got, err := c.HumanCheckMFATOTPSetup(ctx, tt.args.userID, tt.args.code, "agent1", tt.args.resourceOwner) require.ErrorIs(t, err, tt.wantErr) if tt.want { require.NotNil(t, got) @@ -707,7 +707,7 @@ func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) { } } -func TestCommandSide_RemoveHumanOTP(t *testing.T) { +func TestCommandSide_RemoveHumanTOTP(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore } @@ -802,7 +802,7 @@ func TestCommandSide_RemoveHumanOTP(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore, } - got, err := r.HumanRemoveOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) + got, err := r.HumanRemoveTOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { assert.NoError(t, err) } @@ -815,3 +815,540 @@ func TestCommandSide_RemoveHumanOTP(t *testing.T) { }) } } + +func TestCommandSide_AddHumanOTPSMS(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-QSF2s", "Errors.User.UserIDMissing"), + }, + }, + { + name: "wrong user, permission denied error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "other", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + }, + }, + { + name: "otp sms already exists, already exists error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowAlreadyExists(nil, "COMMAND-Ad3g2", "Errors.User.MFA.OTP.AlreadyReady"), + }, + }, + { + name: "phone not verified, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "phone removed, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "+4179654321", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "+4179654321", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := r.AddHumanOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommandSide_RemoveHumanOTPSMS(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S3br2", "Errors.User.UserIDMissing"), + }, + }, + { + name: "other user not permission, permission denied error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: ctx, + userID: "other", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + name: "otp sms not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowNotFound(nil, "COMMAND-Sr3h3", "Errors.User.MFA.OTP.NotExisting"), + }, + }, + { + name: "successful remove", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.RemoveHumanOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommandSide_AddHumanOTPEmail(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sg1hz", "Errors.User.UserIDMissing"), + }, + }, + { + name: "otp email already exists, already exists error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowAlreadyExists(nil, "COMMAND-MKL2s", "Errors.User.MFA.OTP.AlreadyReady"), + }, + }, + { + name: "email not verified, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KLJ2d", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanEmailChangedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "email@test.ch", + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := r.AddHumanOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommandSide_RemoveHumanOTPEmail(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S2h11", "Errors.User.UserIDMissing"), + }, + }, + { + name: "other user not permission, permission denied error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: ctx, + userID: "other", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + name: "otp email not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowNotFound(nil, "COMMAND-b312D", "Errors.User.MFA.OTP.NotExisting"), + }, + }, + { + name: "successful remove", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.RemoveHumanOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} diff --git a/internal/command/user_human_phone_test.go b/internal/command/user_human_phone_test.go index c7f7535a68..98ee9da7c7 100644 --- a/internal/command/user_human_phone_test.go +++ b/internal/command/user_human_phone_test.go @@ -885,70 +885,70 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) { args args res res }{ - //{ - // name: "userid missing, invalid argument error", - // fields: fields{ - // eventstore: eventstoreExpect( - // t, - // ), - // }, - // args: args{ - // ctx: context.Background(), - // resourceOwner: "org1", - // }, - // res: res{ - // err: caos_errs.IsErrorInvalidArgument, - // }, - //}, - //{ - // name: "user not existing, precondition error", - // fields: fields{ - // eventstore: eventstoreExpect( - // t, - // expectFilter(), - // ), - // }, - // args: args{ - // ctx: context.Background(), - // userID: "user1", - // resourceOwner: "org1", - // }, - // res: res{ - // err: caos_errs.IsPreconditionFailed, - // }, - //}, - //{ - // name: "phone not existing, precondition error", - // fields: fields{ - // eventstore: eventstoreExpect( - // t, - // expectFilter( - // eventFromEventPusher( - // user.NewHumanAddedEvent(context.Background(), - // &user.NewAggregate("user1", "org1").Aggregate, - // "username", - // "firstname", - // "lastname", - // "nickname", - // "displayname", - // language.German, - // domain.GenderUnspecified, - // "email@test.ch", - // true, - // ), - // ), - // ), - // ), - // }, - // args: args{ - // ctx: context.Background(), - // userID: "user1", - // resourceOwner: "org1", - // }, - // res: res{ - // err: caos_errs.IsNotFound, - // }, - //}, + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "user not existing, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsPreconditionFailed, + }, + }, + { + name: "phone not existing, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, { name: "remove phone, ok", fields: fields{ diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index 384ef47578..d6964ac90a 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -1020,6 +1020,92 @@ func TestCommandSide_AddHuman(t *testing.T) { }, wantID: "user1", }, + }, { + name: "add human (with return code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", false, "+41711234567"), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2( + context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneCode"), + }, + 1*time.Hour, + true, + ), + ), + }, + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockCode("phoneCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + Phone: Phone{ + Number: "+41711234567", + ReturnCode: true, + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, }, { name: "add human with metadata, ok", diff --git a/internal/command/user_v2_passkey_test.go b/internal/command/user_v2_passkey_test.go index 8a96d4a213..4d87e4bf45 100644 --- a/internal/command/user_v2_passkey_test.go +++ b/internal/command/user_v2_passkey_test.go @@ -57,7 +57,7 @@ func TestCommands_RegisterUserPasskey(t *testing.T) { resourceOwner: "org1", authenticator: domain.AuthenticatorAttachmentCrossPlattform, }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "get human passwordless error", diff --git a/internal/command/user_v2_phone.go b/internal/command/user_v2_phone.go new file mode 100644 index 0000000000..b8e1174690 --- /dev/null +++ b/internal/command/user_v2_phone.go @@ -0,0 +1,200 @@ +package command + +import ( + "context" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" +) + +// ChangeUserPhone sets a user's phone number, generates a code +// and triggers a notification sms. +func (c *Commands) ChangeUserPhone(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) { + return c.changeUserPhoneWithCode(ctx, userID, resourceOwner, phone, alg, false) +} + +// ChangeUserPhoneReturnCode sets a user's phone number, generates a code and does not send a notification sms. +// The generated plain text code will be set in the returned Phone object. +func (c *Commands) ChangeUserPhoneReturnCode(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) { + return c.changeUserPhoneWithCode(ctx, userID, resourceOwner, phone, alg, true) +} + +// ChangeUserPhoneVerified sets a user's phone number and marks it is verified. +// No code is generated and no confirmation sms is send. +func (c *Commands) ChangeUserPhoneVerified(ctx context.Context, userID, resourceOwner, phone string) (*domain.Phone, error) { + cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err + } + if err = cmd.Change(ctx, domain.PhoneNumber(phone)); err != nil { + return nil, err + } + cmd.SetVerified(ctx) + return cmd.Push(ctx) +} + +func (c *Commands) changeUserPhoneWithCode(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm, returnCode bool) (*domain.Phone, error) { + config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode) + if err != nil { + return nil, err + } + gen := crypto.NewEncryptionGenerator(*config, alg) + return c.changeUserPhoneWithGenerator(ctx, userID, resourceOwner, phone, gen, returnCode) +} + +// changeUserPhoneWithGenerator set a user's phone number. +// returnCode controls if the plain text version of the code will be set in the return object. +// When the plain text code is returned, no notification sms will be send to the user. +func (c *Commands) changeUserPhoneWithGenerator(ctx context.Context, userID, resourceOwner, phone string, gen crypto.Generator, returnCode bool) (*domain.Phone, error) { + cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if authz.GetCtxData(ctx).UserID != userID { + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err + } + } + if err = cmd.Change(ctx, domain.PhoneNumber(phone)); err != nil { + return nil, err + } + if err = cmd.AddGeneratedCode(ctx, gen, returnCode); err != nil { + return nil, err + } + return cmd.Push(ctx) +} + +func (c *Commands) VerifyUserPhone(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) { + config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode) + if err != nil { + return nil, err + } + gen := crypto.NewEncryptionGenerator(*config, alg) + return c.verifyUserPhoneWithGenerator(ctx, userID, resourceOwner, code, gen) +} + +func (c *Commands) verifyUserPhoneWithGenerator(ctx context.Context, userID, resourceOwner, code string, gen crypto.Generator) (*domain.ObjectDetails, error) { + cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + err = cmd.VerifyCode(ctx, code, gen) + if err != nil { + return nil, err + } + if _, err = cmd.Push(ctx); err != nil { + return nil, err + } + return writeModelToObjectDetails(&cmd.model.WriteModel), nil +} + +// UserPhoneEvents allows step-by-step additions of events, +// operating on the Human Phone Model. +type UserPhoneEvents struct { + eventstore *eventstore.Eventstore + aggregate *eventstore.Aggregate + events []eventstore.Command + model *HumanPhoneWriteModel + + plainCode *string +} + +// NewUserPhoneEvents constructs a UserPhoneEvents with a Human Phone Write Model, +// filtered by userID and resourceOwner. +// If a model cannot be found, or it's state is invalid and error is returned. +func (c *Commands) NewUserPhoneEvents(ctx context.Context, userID, resourceOwner string) (*UserPhoneEvents, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing") + } + + model, err := c.phoneWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if model.UserState == domain.UserStateUnspecified || model.UserState == domain.UserStateDeleted { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-ieJ2e", "Errors.User.Phone.NotFound") + } + if model.UserState == domain.UserStateInitial { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-uz0Uu", "Errors.User.NotInitialised") + } + return &UserPhoneEvents{ + eventstore: c.eventstore, + aggregate: UserAggregateFromWriteModel(&model.WriteModel), + model: model, + }, nil +} + +// Change sets a new phone number. +// The generated event unsets any previously generated code and verified flag. +func (c *UserPhoneEvents) Change(ctx context.Context, phone domain.PhoneNumber) error { + phone, err := phone.Normalize() + if err != nil { + return err + } + event, hasChanged := c.model.NewChangedEvent(ctx, c.aggregate, phone) + if !hasChanged { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged") + } + c.events = append(c.events, event) + return nil +} + +// SetVerified sets the phone number to verified. +func (c *UserPhoneEvents) SetVerified(ctx context.Context) { + c.events = append(c.events, user.NewHumanPhoneVerifiedEvent(ctx, c.aggregate)) +} + +// AddGeneratedCode generates a new encrypted code and sets it to the phone number. +// When returnCode a plain text of the code will be returned from Push. +func (c *UserPhoneEvents) AddGeneratedCode(ctx context.Context, gen crypto.Generator, returnCode bool) error { + value, plain, err := crypto.NewCode(gen) + if err != nil { + return err + } + + c.events = append(c.events, user.NewHumanPhoneCodeAddedEventV2(ctx, c.aggregate, value, gen.Expiry(), returnCode)) + if returnCode { + c.plainCode = &plain + } + return nil +} + +func (c *UserPhoneEvents) VerifyCode(ctx context.Context, code string, gen crypto.Generator) error { + if code == "" { + return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty") + } + + err := crypto.VerifyCode(c.model.CodeCreationDate, c.model.CodeExpiry, c.model.Code, code, gen) + if err == nil { + c.events = append(c.events, user.NewHumanPhoneVerifiedEvent(ctx, c.aggregate)) + return nil + } + _, err = c.eventstore.Push(ctx, user.NewHumanPhoneVerificationFailedEvent(ctx, c.aggregate)) + logging.WithFields("id", "COMMAND-Zoo6b", "userID", c.aggregate.ID).OnError(err).Error("NewHumanPhoneVerificationFailedEvent push failed") + return caos_errs.ThrowInvalidArgument(err, "COMMAND-eis9R", "Errors.User.Code.Invalid") +} + +// Push all events to the eventstore and Reduce them into the Model. +func (c *UserPhoneEvents) Push(ctx context.Context) (*domain.Phone, error) { + pushedEvents, err := c.eventstore.Push(ctx, c.events...) + if err != nil { + return nil, err + } + err = AppendAndReduce(c.model, pushedEvents...) + if err != nil { + return nil, err + } + phone := writeModelToPhone(c.model) + phone.PlainCode = c.plainCode + + return phone, nil +} diff --git a/internal/command/user_v2_phone_test.go b/internal/command/user_v2_phone_test.go new file mode 100644 index 0000000000..05da4e260c --- /dev/null +++ b/internal/command/user_v2_phone_test.go @@ -0,0 +1,759 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestCommands_ChangeUserPhone(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + { + name: "not changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.ChangeUserPhone(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_changeUserPhoneWithGenerator + }) + } +} + +func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyEmailCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.ChangeUserPhoneReturnCode(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_changeUserPhoneWithGenerator + }) + } +} + +func TestCommands_ChangeUserPhoneVerified(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone string + } + tests := []struct { + name string + fields fields + args args + want *domain.Phone + wantErr error + }{ + { + name: "missing userID", + fields: fields{ + eventstore: eventstoreExpect(t), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing"), + }, + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + { + name: "phone changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+41791234568", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234568", + }, + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: domain.PhoneNumber("+41791234568"), + IsPhoneVerified: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := c.ChangeUserPhoneVerified(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, got, tt.want) + }) + } +} + +func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone string + returnCode bool + } + tests := []struct { + name string + fields fields + args args + want *domain.Phone + wantErr error + }{ + { + name: "missing user", + fields: fields{ + eventstore: eventstoreExpect(t), + }, + args: args{ + userID: "", + resourceOwner: "org1", + phone: "+41791234567", + returnCode: false, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing"), + }, + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + returnCode: false, + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + returnCode: false, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + { + name: "not changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + returnCode: false, + }, + wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged"), + }, + { + name: "phone changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+41791234568", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + false, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234568", + returnCode: false, + }, + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: "+41791234568", + IsPhoneVerified: false, + }, + }, + { + name: "phone changed, return code", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+41791234568", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + true, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234568", + returnCode: true, + }, + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: "+41791234568", + IsPhoneVerified: false, + PlainCode: gu.Ptr("a"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := c.changeUserPhoneWithGenerator(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, GetMockSecretGenerator(t), tt.args.returnCode) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, got, tt.want) + }) + } +} diff --git a/internal/command/user_v2_totp.go b/internal/command/user_v2_totp.go index 76840bac13..61514b0e7e 100644 --- a/internal/command/user_v2_totp.go +++ b/internal/command/user_v2_totp.go @@ -29,5 +29,5 @@ func (c *Commands) CheckUserTOTP(ctx context.Context, userID, code, resourceOwne if err := authz.UserIDInCTX(ctx, userID); err != nil { return nil, err } - return c.HumanCheckMFAOTPSetup(ctx, userID, code, "", resourceOwner) + return c.HumanCheckMFATOTPSetup(ctx, userID, code, "", resourceOwner) } diff --git a/internal/command/user_v2_totp_test.go b/internal/command/user_v2_totp_test.go index 90596428ae..32a5d9e09c 100644 --- a/internal/command/user_v2_totp_test.go +++ b/internal/command/user_v2_totp_test.go @@ -45,7 +45,7 @@ func TestCommands_AddUserTOTP(t *testing.T) { userID: "foo", resourceowner: "org1", }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "create otp error", @@ -191,7 +191,7 @@ func TestCommands_CheckUserTOTP(t *testing.T) { ctx := authz.NewMockContext("", "org1", "user1") cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t)) - key, secret, err := domain.NewOTPKey("example.com", "user1", cryptoAlg) + key, secret, err := domain.NewTOTPKey("example.com", "user1", cryptoAlg) require.NoError(t, err) userAgg := &user.NewAggregate("user1", "org1").Aggregate @@ -218,7 +218,7 @@ func TestCommands_CheckUserTOTP(t *testing.T) { args: args{ userID: "foo", }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "success", diff --git a/internal/command/user_v2_u2f_test.go b/internal/command/user_v2_u2f_test.go index ebab0a4ced..6ae5bf6e2d 100644 --- a/internal/command/user_v2_u2f_test.go +++ b/internal/command/user_v2_u2f_test.go @@ -52,7 +52,7 @@ func TestCommands_RegisterUserU2F(t *testing.T) { userID: "foo", resourceOwner: "org1", }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "get human passwordless error", diff --git a/internal/crypto/passwap.go b/internal/crypto/passwap.go index cf72a844fb..a5a293a449 100644 --- a/internal/crypto/passwap.go +++ b/internal/crypto/passwap.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/passwap/argon2" "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/passwap/md5" + "github.com/zitadel/passwap/pbkdf2" "github.com/zitadel/passwap/scrypt" "github.com/zitadel/passwap/verifier" @@ -38,6 +39,19 @@ const ( HashNameBcrypt HashName = "bcrypt" // hash and verify HashNameMd5 HashName = "md5" // verify only, as hashing with md5 is insecure and deprecated HashNameScrypt HashName = "scrypt" // hash and verify + HashNamePBKDF2 HashName = "pbkdf2" // hash and verify +) + +type HashMode string + +// HashMode defines a underlying [hash.Hash] implementation +// for algorithms like pbkdf2 +const ( + HashModeSHA1 HashMode = "sha1" + HashModeSHA224 HashMode = "sha224" + HashModeSHA256 HashMode = "sha256" + HashModeSHA384 HashMode = "sha384" + HashModeSHA512 HashMode = "sha512" ) type PasswordHashConfig struct { @@ -85,6 +99,10 @@ var knowVerifiers = map[HashName]prefixVerifier{ prefixes: []string{scrypt.Prefix, scrypt.Prefix_Linux}, verifier: scrypt.Verifier, }, + HashNamePBKDF2: { + prefixes: []string{pbkdf2.Prefix}, + verifier: pbkdf2.Verifier, + }, } func (c *PasswordHashConfig) buildVerifiers() (verifiers []verifier.Verifier, prefixes []string, err error) { @@ -116,6 +134,8 @@ func (c *HasherConfig) buildHasher() (hasher passwap.Hasher, prefixes []string, return c.bcrypt() case HashNameScrypt: return c.scrypt() + case HashNamePBKDF2: + return c.pbkdf2() case "": return nil, nil, fmt.Errorf("missing hasher algorithm") case HashNameArgon2, HashNameMd5: @@ -207,3 +227,49 @@ func (c *HasherConfig) scrypt() (passwap.Hasher, []string, error) { } return scrypt.New(p), []string{scrypt.Prefix, scrypt.Prefix_Linux}, nil } + +func (c *HasherConfig) pbkdf2Params() (p pbkdf2.Params, _ HashMode, _ error) { + var dst = struct { + Rounds uint32 `mapstructure:"Rounds"` + Hash HashMode `mapstructure:"Hash"` + }{} + if err := c.decodeParams(&dst); err != nil { + return p, "", fmt.Errorf("decode pbkdf2 params: %w", err) + } + switch dst.Hash { + case HashModeSHA1: + p = pbkdf2.RecommendedSHA1Params + case HashModeSHA224: + p = pbkdf2.RecommendedSHA224Params + case HashModeSHA256: + p = pbkdf2.RecommendedSHA256Params + case HashModeSHA384: + p = pbkdf2.RecommendedSHA384Params + case HashModeSHA512: + p = pbkdf2.RecommendedSHA512Params + } + p.Rounds = dst.Rounds + return p, dst.Hash, nil +} + +func (c *HasherConfig) pbkdf2() (passwap.Hasher, []string, error) { + p, hash, err := c.pbkdf2Params() + if err != nil { + return nil, nil, err + } + prefix := []string{pbkdf2.Prefix} + switch hash { + case HashModeSHA1: + return pbkdf2.NewSHA1(p), prefix, nil + case HashModeSHA224: + return pbkdf2.NewSHA224(p), prefix, nil + case HashModeSHA256: + return pbkdf2.NewSHA256(p), prefix, nil + case HashModeSHA384: + return pbkdf2.NewSHA384(p), prefix, nil + case HashModeSHA512: + return pbkdf2.NewSHA512(p), prefix, nil + default: + return nil, nil, fmt.Errorf("unsuppored pbkdf2 hash mode: %s", hash) + } +} diff --git a/internal/crypto/passwap_test.go b/internal/crypto/passwap_test.go index 2cc5aa80e7..b557ca4a5c 100644 --- a/internal/crypto/passwap_test.go +++ b/internal/crypto/passwap_test.go @@ -1,6 +1,9 @@ package crypto import ( + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" "testing" "github.com/stretchr/testify/assert" @@ -8,6 +11,7 @@ import ( "github.com/zitadel/passwap/argon2" "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/passwap/md5" + "github.com/zitadel/passwap/pbkdf2" "github.com/zitadel/passwap/scrypt" ) @@ -238,6 +242,101 @@ func TestPasswordHashConfig_PasswordHasher(t *testing.T) { }, wantPrefixes: []string{scrypt.Prefix, scrypt.Prefix_Linux, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, }, + { + name: "pbkdf2, parse error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "cost": "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "pbkdf2, hash mode error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 12, + "Hash": "foo", + }, + }, + }, + wantErr: true, + }, + { + name: "pbkdf2, sha1", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 12, + "Hash": HashModeSHA1, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5}, + }, + wantPrefixes: []string{pbkdf2.Prefix, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, + { + name: "pbkdf2, sha224", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 12, + "Hash": HashModeSHA224, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5}, + }, + wantPrefixes: []string{pbkdf2.Prefix, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, + { + name: "pbkdf2, sha256", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 12, + "Hash": HashModeSHA256, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5}, + }, + wantPrefixes: []string{pbkdf2.Prefix, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, + { + name: "pbkdf2, sha384", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 12, + "Hash": HashModeSHA384, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5}, + }, + wantPrefixes: []string{pbkdf2.Prefix, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, + { + name: "pbkdf2, sha512", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 12, + "Hash": HashModeSHA512, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5}, + }, + wantPrefixes: []string{pbkdf2.Prefix, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -484,3 +583,116 @@ func TestHasherConfig_scryptParams(t *testing.T) { }) } } + +func TestHasherConfig_pbkdf2Params(t *testing.T) { + type fields struct { + Params map[string]any + } + tests := []struct { + name string + fields fields + wantP pbkdf2.Params + wantHash HashMode + wantErr bool + }{ + { + name: "decode error", + fields: fields{ + Params: map[string]any{ + "foo": "bar", + }, + }, + wantErr: true, + }, + { + name: "sha1", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha1", + }, + }, + wantP: pbkdf2.Params{ + Rounds: 12, + KeyLen: sha1.Size, + SaltLen: 16, + }, + wantHash: HashModeSHA1, + }, + { + name: "sha224", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha224", + }, + }, + wantP: pbkdf2.Params{ + Rounds: 12, + KeyLen: sha256.Size224, + SaltLen: 16, + }, + wantHash: HashModeSHA224, + }, + { + name: "sha256", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha256", + }, + }, + wantP: pbkdf2.Params{ + Rounds: 12, + KeyLen: sha256.Size, + SaltLen: 16, + }, + wantHash: HashModeSHA256, + }, + { + name: "sha384", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha384", + }, + }, + wantP: pbkdf2.Params{ + Rounds: 12, + KeyLen: sha512.Size384, + SaltLen: 16, + }, + wantHash: HashModeSHA384, + }, + { + name: "sha512", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha512", + }, + }, + wantP: pbkdf2.Params{ + Rounds: 12, + KeyLen: sha512.Size, + SaltLen: 16, + }, + wantHash: HashModeSHA512, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &HasherConfig{ + Params: tt.fields.Params, + } + gotP, gotHash, err := c.pbkdf2Params() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantP, gotP) + assert.Equal(t, tt.wantHash, gotHash) + }) + } +} diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index 765fead8a9..f7f4da19f5 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -102,7 +102,7 @@ const ( type MFAType int const ( - MFATypeOTP MFAType = iota + MFATypeTOTP MFAType = iota MFATypeU2F MFATypeU2FUserVerification ) diff --git a/internal/domain/factors.go b/internal/domain/factors.go index 172b20c665..41f1a9f8c4 100644 --- a/internal/domain/factors.go +++ b/internal/domain/factors.go @@ -4,20 +4,14 @@ type SecondFactorType int32 const ( SecondFactorTypeUnspecified SecondFactorType = iota - SecondFactorTypeOTP + SecondFactorTypeTOTP SecondFactorTypeU2F + SecondFactorTypeOTPEmail + SecondFactorTypeOTPSMS secondFactorCount ) -func SecondFactorTypes() []SecondFactorType { - types := make([]SecondFactorType, 0, secondFactorCount-1) - for i := SecondFactorTypeUnspecified + 1; i < secondFactorCount; i++ { - types = append(types, i) - } - return types -} - type MultiFactorType int32 const ( @@ -27,14 +21,6 @@ const ( multiFactorCount ) -func MultiFactorTypes() []MultiFactorType { - types := make([]MultiFactorType, 0, multiFactorCount-1) - for i := MultiFactorTypeUnspecified + 1; i < multiFactorCount; i++ { - types = append(types, i) - } - return types -} - type FactorState int32 const ( diff --git a/internal/domain/human_otp.go b/internal/domain/human_otp.go index 479814340b..8dd9ddcb37 100644 --- a/internal/domain/human_otp.go +++ b/internal/domain/human_otp.go @@ -3,20 +3,11 @@ package domain import ( "github.com/pquerna/otp" "github.com/pquerna/otp/totp" + "github.com/zitadel/zitadel/internal/crypto" caos_errs "github.com/zitadel/zitadel/internal/errors" - es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) -type OTP struct { - es_models.ObjectRoot - - Secret *crypto.CryptoValue - SecretString string - Url string - State MFAState -} - type TOTP struct { *ObjectDetails @@ -24,7 +15,7 @@ type TOTP struct { URI string } -func NewOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) (*otp.Key, *crypto.CryptoValue, error) { +func NewTOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) (*otp.Key, *crypto.CryptoValue, error) { key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: accountName}) if err != nil { return nil, nil, caos_errs.ThrowInternal(err, "TOTP-ieY3o", "Errors.Internal") @@ -36,7 +27,7 @@ func NewOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) return key, encryptedSecret, nil } -func VerifyMFAOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.EncryptionAlgorithm) error { +func VerifyTOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.EncryptionAlgorithm) error { decrypt, err := crypto.DecryptString(secret, cryptoAlg) if err != nil { return err diff --git a/internal/domain/human_phone.go b/internal/domain/human_phone.go index e6f8caa6d0..44eb5fe968 100644 --- a/internal/domain/human_phone.go +++ b/internal/domain/human_phone.go @@ -30,6 +30,8 @@ type Phone struct { PhoneNumber PhoneNumber IsPhoneVerified bool + // PlainCode is set by the command and can be used to return it to the caller (API) + PlainCode *string } type PhoneCode struct { diff --git a/internal/domain/secret_generator.go b/internal/domain/secret_generator.go index 892fd463b8..4a3300a1bf 100644 --- a/internal/domain/secret_generator.go +++ b/internal/domain/secret_generator.go @@ -11,6 +11,8 @@ const ( SecretGeneratorTypePasswordResetCode SecretGeneratorTypePasswordlessInitCode SecretGeneratorTypeAppSecret + SecretGeneratorTypeOTPSMS + SecretGeneratorTypeOTPEmail secretGeneratorTypeCount ) diff --git a/internal/domain/user.go b/internal/domain/user.go index b46d4227db..18fa2db9af 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -48,11 +48,13 @@ type UserAuthMethodType int32 const ( UserAuthMethodTypeUnspecified UserAuthMethodType = iota - UserAuthMethodTypeOTP + UserAuthMethodTypeTOTP UserAuthMethodTypeU2F UserAuthMethodTypePasswordless UserAuthMethodTypePassword UserAuthMethodTypeIDP + UserAuthMethodTypeOTPSMS + UserAuthMethodTypeOTPEmail userAuthMethodTypeCount ) @@ -67,15 +69,14 @@ func HasMFA(methods []UserAuthMethodType) bool { var factors int for _, method := range methods { switch method { - case UserAuthMethodTypePassword: - factors++ case UserAuthMethodTypePasswordless: return true - case UserAuthMethodTypeU2F: - factors++ - case UserAuthMethodTypeOTP: - factors++ - case UserAuthMethodTypeIDP: + case UserAuthMethodTypePassword, + UserAuthMethodTypeU2F, + UserAuthMethodTypeTOTP, + UserAuthMethodTypeOTPSMS, + UserAuthMethodTypeOTPEmail, + UserAuthMethodTypeIDP: factors++ case UserAuthMethodTypeUnspecified, userAuthMethodTypeCount: diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index 216a29bdee..67ab235910 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -315,6 +315,9 @@ func GenericEventMapper[T any, PT BaseEventSetter[T]](event *repository.Event) ( e := PT(new(T)) e.SetBaseEvent(BaseEventFromRepo(event)) + if len(event.Data) == 0 { + return e, nil + } err := json.Unmarshal(event.Data, e) if err != nil { return nil, errors.ThrowInternal(err, "V2-Thai6", "unable to unmarshal event") diff --git a/internal/iam/model/login_policy_view.go b/internal/iam/model/login_policy_view.go index 7a39818b9c..c4aaafa828 100644 --- a/internal/iam/model/login_policy_view.go +++ b/internal/iam/model/login_policy_view.go @@ -106,8 +106,12 @@ func secondFactorsToDomain(types []domain.SecondFactorType) []domain.SecondFacto switch secondfactorType { case domain.SecondFactorTypeU2F: secondfactors[i] = domain.SecondFactorTypeU2F - case domain.SecondFactorTypeOTP: - secondfactors[i] = domain.SecondFactorTypeOTP + case domain.SecondFactorTypeTOTP: + secondfactors[i] = domain.SecondFactorTypeTOTP + case domain.SecondFactorTypeOTPEmail: + secondfactors[i] = domain.SecondFactorTypeOTPEmail + case domain.SecondFactorTypeOTPSMS: + secondfactors[i] = domain.SecondFactorTypeOTPSMS } } return secondfactors diff --git a/internal/integration/client.go b/internal/integration/client.go index 65e0e9ab1b..0daf37c404 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -91,6 +91,12 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse ReturnCode: &user.ReturnEmailVerificationCode{}, }, }, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, }) logging.OnError(err).Fatal("create human user") return resp diff --git a/internal/integration/integration.go b/internal/integration/integration.go index 8634095f3e..0912f8dc0b 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -248,7 +248,11 @@ func (s *Tester) WithInstanceAuthorization(ctx context.Context, u UserType, inst if u == SystemUser { s.ensureSystemUser() } - return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", s.Users.Get(instanceID, u).Token)) + return s.WithAuthorizationToken(ctx, s.Users.Get(instanceID, u).Token) +} + +func (s *Tester) WithAuthorizationToken(ctx context.Context, token string) context.Context { + return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", token)) } func (s *Tester) ensureSystemUser() { diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index d726fb62a1..64661d89bc 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -182,6 +182,7 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St if !ok { return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType) } + if e.CodeReturned { return crdb.NewNoOpStatement(e), nil } @@ -535,6 +536,9 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if !ok { return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-He83g", "reduce.wrong.event.type %s", user.HumanPhoneCodeAddedType) } + if e.CodeReturned { + return crdb.NewNoOpStatement(e), nil + } ctx := HandlerContext(event.Aggregate()) alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, user.UserV1PhoneCodeAddedType, user.UserV1PhoneCodeSentType, diff --git a/internal/query/login_policy_test.go b/internal/query/login_policy_test.go index 2e1900f218..9aa398d81d 100644 --- a/internal/query/login_policy_test.go +++ b/internal/query/login_policy_test.go @@ -129,7 +129,7 @@ func Test_LoginPolicyPrepares(t *testing.T) { true, true, true, - database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeOTP}, + database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeTOTP}, database.EnumArray[domain.MultiFactorType]{domain.MultiFactorTypeU2FWithPIN}, domain.PasswordlessTypeAllowed, true, @@ -157,7 +157,7 @@ func Test_LoginPolicyPrepares(t *testing.T) { AllowExternalIDPs: true, ForceMFA: true, ForceMFALocalOnly: true, - SecondFactors: database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeOTP}, + SecondFactors: database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeTOTP}, MultiFactors: database.EnumArray[domain.MultiFactorType]{domain.MultiFactorTypeU2FWithPIN}, PasswordlessType: domain.PasswordlessTypeAllowed, IsDefault: true, @@ -217,7 +217,7 @@ func Test_LoginPolicyPrepares(t *testing.T) { regexp.QuoteMeta(prepareLoginPolicy2FAsStmt), prepareLoginPolicy2FAsCols, []driver.Value{ - database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeOTP}, + database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeTOTP}, }, ), }, @@ -225,7 +225,7 @@ func Test_LoginPolicyPrepares(t *testing.T) { SearchResponse: SearchResponse{ Count: 1, }, - Factors: database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeOTP}, + Factors: database.EnumArray[domain.SecondFactorType]{domain.SecondFactorTypeTOTP}, }, }, { diff --git a/internal/query/projection/login_policy.go b/internal/query/projection/login_policy.go index 77fadc2ba8..856d02a464 100644 --- a/internal/query/projection/login_policy.go +++ b/internal/query/projection/login_policy.go @@ -115,11 +115,11 @@ func (p *loginPolicyProjection) reducers() []handler.AggregateReducer { }, { Event: org.LoginPolicySecondFactorAddedEventType, - Reduce: p.reduce2FAAdded, + Reduce: p.reduceSecondFactorAdded, }, { Event: org.LoginPolicySecondFactorRemovedEventType, - Reduce: p.reduce2FARemoved, + Reduce: p.reduceSecondFactorRemoved, }, { Event: org.OrgRemovedEventType, @@ -148,11 +148,11 @@ func (p *loginPolicyProjection) reducers() []handler.AggregateReducer { }, { Event: instance.LoginPolicySecondFactorAddedEventType, - Reduce: p.reduce2FAAdded, + Reduce: p.reduceSecondFactorAdded, }, { Event: instance.LoginPolicySecondFactorRemovedEventType, - Reduce: p.reduce2FARemoved, + Reduce: p.reduceSecondFactorRemoved, }, { Event: instance.InstanceRemovedEventType, @@ -345,7 +345,7 @@ func (p *loginPolicyProjection) reduceLoginPolicyRemoved(event eventstore.Event) ), nil } -func (p *loginPolicyProjection) reduce2FAAdded(event eventstore.Event) (*handler.Statement, error) { +func (p *loginPolicyProjection) reduceSecondFactorAdded(event eventstore.Event) (*handler.Statement, error) { var policyEvent policy.SecondFactorAddedEvent switch e := event.(type) { case *instance.LoginPolicySecondFactorAddedEvent: @@ -370,7 +370,7 @@ func (p *loginPolicyProjection) reduce2FAAdded(event eventstore.Event) (*handler ), nil } -func (p *loginPolicyProjection) reduce2FARemoved(event eventstore.Event) (*handler.Statement, error) { +func (p *loginPolicyProjection) reduceSecondFactorRemoved(event eventstore.Event) (*handler.Statement, error) { var policyEvent policy.SecondFactorRemovedEvent switch e := event.(type) { case *instance.LoginPolicySecondFactorRemovedEvent: diff --git a/internal/query/projection/login_policy_test.go b/internal/query/projection/login_policy_test.go index bd87c5c4b0..b475fa2a28 100644 --- a/internal/query/projection/login_policy_test.go +++ b/internal/query/projection/login_policy_test.go @@ -310,8 +310,8 @@ func TestLoginPolicyProjection_reduces(t *testing.T) { }, }, { - name: "org reduce2FAAdded", - reduce: (&loginPolicyProjection{}).reduce2FAAdded, + name: "org reduceSecondFactorAdded", + reduce: (&loginPolicyProjection{}).reduceSecondFactorAdded, args: args{ event: getEvent(testEvent( repository.EventType(org.LoginPolicySecondFactorAddedEventType), @@ -342,8 +342,8 @@ func TestLoginPolicyProjection_reduces(t *testing.T) { }, }, { - name: "org reduce2FARemoved", - reduce: (&loginPolicyProjection{}).reduce2FARemoved, + name: "org reduceSecondFactorRemoved", + reduce: (&loginPolicyProjection{}).reduceSecondFactorRemoved, args: args{ event: getEvent(testEvent( repository.EventType(org.LoginPolicySecondFactorRemovedEventType), @@ -558,8 +558,8 @@ func TestLoginPolicyProjection_reduces(t *testing.T) { }, }, { - name: "instance reduce2FAAdded", - reduce: (&loginPolicyProjection{}).reduce2FAAdded, + name: "instance reduceSecondFactorAdded u2f", + reduce: (&loginPolicyProjection{}).reduceSecondFactorAdded, args: args{ event: getEvent(testEvent( repository.EventType(instance.LoginPolicySecondFactorAddedEventType), @@ -590,8 +590,8 @@ func TestLoginPolicyProjection_reduces(t *testing.T) { }, }, { - name: "instance reduce2FARemoved", - reduce: (&loginPolicyProjection{}).reduce2FARemoved, + name: "instance reduceSecondFactorRemoved u2f", + reduce: (&loginPolicyProjection{}).reduceSecondFactorRemoved, args: args{ event: getEvent(testEvent( repository.EventType(instance.LoginPolicySecondFactorRemovedEventType), @@ -621,6 +621,70 @@ func TestLoginPolicyProjection_reduces(t *testing.T) { }, }, }, + { + name: "instance reduceSecondFactorAdded otp email", + reduce: (&loginPolicyProjection{}).reduceSecondFactorAdded, + args: args{ + event: getEvent(testEvent( + repository.EventType(instance.LoginPolicySecondFactorAddedEventType), + instance.AggregateType, + []byte(`{ + "mfaType": 3 + }`), + ), instance.SecondFactorAddedEventMapper), + }, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.login_policies5 SET (change_date, sequence, second_factors) = ($1, $2, array_append(second_factors, $3)) WHERE (aggregate_id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + domain.SecondFactorTypeOTPEmail, + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSecondFactorRemoved otp email", + reduce: (&loginPolicyProjection{}).reduceSecondFactorRemoved, + args: args{ + event: getEvent(testEvent( + repository.EventType(instance.LoginPolicySecondFactorRemovedEventType), + instance.AggregateType, + []byte(`{ + "mfaType": 3 + }`), + ), instance.SecondFactorRemovedEventMapper), + }, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.login_policies5 SET (change_date, sequence, second_factors) = ($1, $2, array_remove(second_factors, $3)) WHERE (aggregate_id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + domain.SecondFactorTypeOTPEmail, + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "org.reduceOwnerRemoved", reduce: (&loginPolicyProjection{}).reduceOwnerRemoved, diff --git a/internal/query/projection/user_auth_method.go b/internal/query/projection/user_auth_method.go index ee6f92a404..ddaa53c2bd 100644 --- a/internal/query/projection/user_auth_method.go +++ b/internal/query/projection/user_auth_method.go @@ -89,6 +89,14 @@ func (p *userAuthMethodProjection) reducers() []handler.AggregateReducer { Event: user.HumanMFAOTPVerifiedType, Reduce: p.reduceActivateEvent, }, + { + Event: user.HumanOTPSMSAddedType, + Reduce: p.reduceAddAuthMethod, + }, + { + Event: user.HumanOTPEmailAddedType, + Reduce: p.reduceAddAuthMethod, + }, { Event: user.HumanPasswordlessTokenRemovedType, Reduce: p.reduceRemoveAuthMethod, @@ -101,6 +109,14 @@ func (p *userAuthMethodProjection) reducers() []handler.AggregateReducer { Event: user.HumanMFAOTPRemovedType, Reduce: p.reduceRemoveAuthMethod, }, + { + Event: user.HumanOTPSMSRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, + { + Event: user.HumanOTPEmailRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, }, }, { @@ -135,7 +151,7 @@ func (p *userAuthMethodProjection) reduceInitAuthMethod(event eventstore.Event) methodType = domain.UserAuthMethodTypeU2F tokenID = e.WebAuthNTokenID case *user.HumanOTPAddedEvent: - methodType = domain.UserAuthMethodTypeOTP + methodType = domain.UserAuthMethodTypeTOTP default: return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}) } @@ -178,7 +194,7 @@ func (p *userAuthMethodProjection) reduceActivateEvent(event eventstore.Event) ( tokenID = e.WebAuthNTokenID name = e.WebAuthNTokenName case *user.HumanOTPVerifiedEvent: - methodType = domain.UserAuthMethodTypeOTP + methodType = domain.UserAuthMethodTypeTOTP default: return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}) @@ -202,6 +218,34 @@ func (p *userAuthMethodProjection) reduceActivateEvent(event eventstore.Event) ( ), nil } +func (p *userAuthMethodProjection) reduceAddAuthMethod(event eventstore.Event) (*handler.Statement, error) { + var methodType domain.UserAuthMethodType + switch event.(type) { + case *user.HumanOTPSMSAddedEvent: + methodType = domain.UserAuthMethodTypeOTPSMS + case *user.HumanOTPEmailAddedEvent: + methodType = domain.UserAuthMethodTypeOTPEmail + default: + return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-DS4g3", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanOTPSMSAddedType, user.HumanOTPEmailAddedType}) + } + + return crdb.NewCreateStatement( + event, + []handler.Column{ + handler.NewCol(UserAuthMethodTokenIDCol, ""), + handler.NewCol(UserAuthMethodCreationDateCol, event.CreationDate()), + handler.NewCol(UserAuthMethodChangeDateCol, event.CreationDate()), + handler.NewCol(UserAuthMethodResourceOwnerCol, event.Aggregate().ResourceOwner), + handler.NewCol(UserAuthMethodInstanceIDCol, event.Aggregate().InstanceID), + handler.NewCol(UserAuthMethodUserIDCol, event.Aggregate().ID), + handler.NewCol(UserAuthMethodSequenceCol, event.Sequence()), + handler.NewCol(UserAuthMethodStateCol, domain.MFAStateReady), + handler.NewCol(UserAuthMethodTypeCol, methodType), + handler.NewCol(UserAuthMethodNameCol, ""), + }, + ), nil +} + func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event) (*handler.Statement, error) { var tokenID string var methodType domain.UserAuthMethodType @@ -213,10 +257,17 @@ func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event methodType = domain.UserAuthMethodTypeU2F tokenID = e.WebAuthNTokenID case *user.HumanOTPRemovedEvent: - methodType = domain.UserAuthMethodTypeOTP + methodType = domain.UserAuthMethodTypeTOTP + case *user.HumanOTPSMSRemovedEvent, + *user.HumanPhoneRemovedEvent: + methodType = domain.UserAuthMethodTypeOTPSMS + case *user.HumanOTPEmailRemovedEvent: + methodType = domain.UserAuthMethodTypeOTPEmail default: - return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}) + return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", + []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType, user.HumanMFAOTPRemovedType, + user.HumanOTPSMSRemovedType, user.HumanPhoneRemovedType, user.HumanOTPEmailRemovedType}) } conditions := []handler.Condition{ handler.NewCond(UserAuthMethodUserIDCol, event.Aggregate().ID), diff --git a/internal/query/projection/user_auth_method_test.go b/internal/query/projection/user_auth_method_test.go index 252df8fe04..632f12ae92 100644 --- a/internal/query/projection/user_auth_method_test.go +++ b/internal/query/projection/user_auth_method_test.go @@ -98,7 +98,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, { - name: "reduceAddedOTP", + name: "reduceAddedTOTP", args: args{ event: getEvent(testEvent( repository.EventType(user.HumanMFAOTPAddedType), @@ -125,7 +125,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { "agg-id", uint64(15), domain.MFAStateNotReady, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, "", }, }, @@ -208,7 +208,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, { - name: "reduceVerifiedOTP", + name: "reduceVerifiedTOTP", args: args{ event: getEvent(testEvent( repository.EventType(user.HumanMFAOTPVerifiedType), @@ -232,7 +232,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { "", domain.MFAStateReady, "agg-id", - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, "ro-id", "", "instance-id", @@ -242,6 +242,256 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduceAddedOTPSMS", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPSMSAddedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPSMSAddedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceAddAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.user_auth_methods4 (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)", + expectedArgs: []interface{}{ + "", + anyArg{}, + anyArg{}, + "ro-id", + "instance-id", + "agg-id", + uint64(15), + domain.MFAStateReady, + domain.UserAuthMethodTypeOTPSMS, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceAddedOTPEmail", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPEmailAddedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPEmailAddedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceAddAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.user_auth_methods4 (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)", + expectedArgs: []interface{}{ + "", + anyArg{}, + anyArg{}, + "ro-id", + "instance-id", + "agg-id", + uint64(15), + domain.MFAStateReady, + domain.UserAuthMethodTypeOTPEmail, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPPasswordless", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanPasswordlessTokenRemovedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id" + }`), + ), user.HumanPasswordlessRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4) AND (token_id = $5)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypePasswordless, + "ro-id", + "instance-id", + "token-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPU2F", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanU2FTokenRemovedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id" + }`), + ), user.HumanU2FRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4) AND (token_id = $5)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeU2F, + "ro-id", + "instance-id", + "token-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveTOTP", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanMFAOTPRemovedType), + user.AggregateType, + nil, + ), user.HumanOTPRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeTOTP, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPSMS", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPSMSRemovedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPSMSRemovedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeOTPSMS, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemovePhone", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanPhoneRemovedType), + user.AggregateType, + nil, + ), user.HumanPhoneRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeOTPSMS, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPEmail", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPEmailRemovedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPEmailRemovedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeOTPEmail, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "org reduceOwnerRemoved", reduce: (&userAuthMethodProjection{}).reduceOwnerRemoved, diff --git a/internal/query/user_auth_method_test.go b/internal/query/user_auth_method_test.go index ec38d04e53..2cfe553e06 100644 --- a/internal/query/user_auth_method_test.go +++ b/internal/query/user_auth_method_test.go @@ -280,7 +280,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, { true, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, 1, }, }, @@ -292,7 +292,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, AuthMethodTypes: []domain.UserAuthMethodType{ domain.UserAuthMethodTypePasswordless, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypePassword, domain.UserAuthMethodTypeIDP, }, @@ -399,7 +399,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, { true, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, 1, true, true, @@ -411,7 +411,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { object: &testUserAuthMethodTypesRequired{ authMethods: []domain.UserAuthMethodType{ domain.UserAuthMethodTypePasswordless, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypePassword, domain.UserAuthMethodTypeIDP, }, diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index 814404ca58..8aede0d193 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -89,6 +89,14 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, HumanMFAOTPRemovedType, HumanOTPRemovedEventMapper). RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckSucceededType, HumanOTPCheckSucceededEventMapper). RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckFailedType, HumanOTPCheckFailedEventMapper). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSAddedType, eventstore.GenericEventMapper[HumanOTPSMSAddedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSRemovedType, eventstore.GenericEventMapper[HumanOTPSMSRemovedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSCheckSucceededType, eventstore.GenericEventMapper[HumanOTPSMSCheckSucceededEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSCheckFailedType, eventstore.GenericEventMapper[HumanOTPSMSCheckFailedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailAddedType, eventstore.GenericEventMapper[HumanOTPEmailAddedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailRemovedType, eventstore.GenericEventMapper[HumanOTPEmailRemovedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckSucceededType, eventstore.GenericEventMapper[HumanOTPEmailCheckSucceededEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckFailedType, eventstore.GenericEventMapper[HumanOTPEmailCheckFailedEvent]). RegisterFilterEventMapper(AggregateType, HumanU2FTokenAddedType, HumanU2FAddedEventMapper). RegisterFilterEventMapper(AggregateType, HumanU2FTokenVerifiedType, HumanU2FVerifiedEventMapper). RegisterFilterEventMapper(AggregateType, HumanU2FTokenSignCountChangedType, HumanU2FSignCountChangedEventMapper). diff --git a/internal/repository/user/human_mfa_otp.go b/internal/repository/user/human_mfa_otp.go index e28ece9c86..3427dbbfca 100644 --- a/internal/repository/user/human_mfa_otp.go +++ b/internal/repository/user/human_mfa_otp.go @@ -12,12 +12,22 @@ import ( ) const ( - otpEventPrefix = mfaEventPrefix + "otp." - HumanMFAOTPAddedType = otpEventPrefix + "added" - HumanMFAOTPVerifiedType = otpEventPrefix + "verified" - HumanMFAOTPRemovedType = otpEventPrefix + "removed" - HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded" - HumanMFAOTPCheckFailedType = otpEventPrefix + "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" + HumanOTPSMSCheckSucceededType = otpSMSEventPrefix + "check.succeeded" + HumanOTPSMSCheckFailedType = otpSMSEventPrefix + "check.failed" + otpEmailEventPrefix = otpEventPrefix + "email." + HumanOTPEmailAddedType = otpEmailEventPrefix + "added" + HumanOTPEmailRemovedType = otpEmailEventPrefix + "removed" + HumanOTPEmailCheckSucceededType = otpEmailEventPrefix + "check.succeeded" + HumanOTPEmailCheckFailedType = otpEmailEventPrefix + "check.failed" ) type HumanOTPAddedEvent struct { @@ -202,3 +212,247 @@ func HumanOTPCheckFailedEventMapper(event *repository.Event) (eventstore.Event, } return otpAdded, nil } + +type HumanOTPSMSAddedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPSMSAddedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPSMSAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPSMSAddedEvent { + return &HumanOTPSMSAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSAddedType, + ), + } +} + +type HumanOTPSMSRemovedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPSMSRemovedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPSMSRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPSMSRemovedEvent { + return &HumanOTPSMSRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSRemovedType, + ), + } +} + +type HumanOTPSMSCheckSucceededEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPSMSCheckSucceededEvent) Data() interface{} { + return e +} + +func (e *HumanOTPSMSCheckSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSCheckSucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSCheckSucceededEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPSMSCheckSucceededEvent { + return &HumanOTPSMSCheckSucceededEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSCheckSucceededType, + ), + AuthRequestInfo: info, + } +} + +type HumanOTPSMSCheckFailedEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPSMSCheckFailedEvent) Data() interface{} { + return e +} + +func (e *HumanOTPSMSCheckFailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSCheckFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSCheckFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPSMSCheckFailedEvent { + return &HumanOTPSMSCheckFailedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSCheckFailedType, + ), + AuthRequestInfo: info, + } +} + +type HumanOTPEmailAddedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPEmailAddedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPEmailAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPEmailAddedEvent { + return &HumanOTPEmailAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailAddedType, + ), + } +} + +type HumanOTPEmailRemovedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPEmailRemovedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPEmailRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPEmailRemovedEvent { + return &HumanOTPEmailRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailRemovedType, + ), + } +} + +type HumanOTPEmailCheckSucceededEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPEmailCheckSucceededEvent) Data() interface{} { + return e +} + +func (e *HumanOTPEmailCheckSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailCheckSucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailCheckSucceededEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPEmailCheckSucceededEvent { + return &HumanOTPEmailCheckSucceededEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailCheckSucceededType, + ), + AuthRequestInfo: info, + } +} + +type HumanOTPEmailCheckFailedEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPEmailCheckFailedEvent) Data() interface{} { + return e +} + +func (e *HumanOTPEmailCheckFailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailCheckFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailCheckFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPEmailCheckFailedEvent { + return &HumanOTPEmailCheckFailedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailCheckFailedType, + ), + AuthRequestInfo: info, + } +} diff --git a/internal/repository/user/human_phone.go b/internal/repository/user/human_phone.go index ea5f4ac7c6..c6586c54e5 100644 --- a/internal/repository/user/human_phone.go +++ b/internal/repository/user/human_phone.go @@ -149,8 +149,9 @@ func HumanPhoneVerificationFailedEventMapper(event *repository.Event) (eventstor type HumanPhoneCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` } func (e *HumanPhoneCodeAddedEvent) Data() interface{} { @@ -166,6 +167,15 @@ func NewHumanPhoneCodeAddedEvent( aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, +) *HumanPhoneCodeAddedEvent { + return NewHumanPhoneCodeAddedEventV2(ctx, aggregate, code, expiry, false) +} +func NewHumanPhoneCodeAddedEventV2( + ctx context.Context, + aggregate *eventstore.Aggregate, + code *crypto.CryptoValue, + expiry time.Duration, + codeReturned bool, ) *HumanPhoneCodeAddedEvent { return &HumanPhoneCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -173,8 +183,9 @@ func NewHumanPhoneCodeAddedEvent( aggregate, HumanPhoneCodeAddedType, ), - Code: code, - Expiry: expiry, + Code: code, + Expiry: expiry, + CodeReturned: codeReturned, } } diff --git a/internal/user/model/user_view.go b/internal/user/model/user_view.go index 8cb0d4346b..5bc7c9a119 100644 --- a/internal/user/model/user_view.go +++ b/internal/user/model/user_view.go @@ -156,9 +156,9 @@ func (u *UserView) MFATypesSetupPossible(level domain.MFALevel, policy *domain.L if policy.HasSecondFactors() { for _, mfaType := range policy.SecondFactors { switch mfaType { - case domain.SecondFactorTypeOTP: + case domain.SecondFactorTypeTOTP: if u.OTPState != MFAStateReady { - types = append(types, domain.MFATypeOTP) + types = append(types, domain.MFATypeTOTP) } case domain.SecondFactorTypeU2F: types = append(types, domain.MFATypeU2F) @@ -181,9 +181,9 @@ func (u *UserView) MFATypesAllowed(level domain.MFALevel, policy *domain.LoginPo if policy.HasSecondFactors() { for _, mfaType := range policy.SecondFactors { switch mfaType { - case domain.SecondFactorTypeOTP: + case domain.SecondFactorTypeTOTP: if u.OTPState == MFAStateReady { - types = append(types, domain.MFATypeOTP) + types = append(types, domain.MFATypeTOTP) } case domain.SecondFactorTypeU2F: if u.IsU2FReady() { diff --git a/internal/user/repository/view/model/user_session.go b/internal/user/repository/view/model/user_session.go index b86076177f..ec611c16fa 100644 --- a/internal/user/repository/view/model/user_session.go +++ b/internal/user/repository/view/model/user_session.go @@ -134,11 +134,11 @@ func (v *UserSessionView) AppendEvent(event *models.Event) error { return err } if v.UserAgentID == data.UserAgentID { - v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTP) + v.setSecondFactorVerification(event.CreationDate, domain.MFATypeTOTP) } case user.UserV1MFAOTPCheckSucceededType, user.HumanMFAOTPCheckSucceededType: - v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTP) + v.setSecondFactorVerification(event.CreationDate, domain.MFATypeTOTP) case user.UserV1MFAOTPCheckFailedType, user.UserV1MFAOTPRemovedType, user.HumanMFAOTPCheckFailedType, diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index 2e291e13c1..934a275709 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -647,6 +647,70 @@ service AuthService { }; } + rpc AddMyAuthFactorOTPSMS(AddMyAuthFactorOTPSMSRequest) returns (AddMyAuthFactorOTPSMSResponse) { + option (google.api.http) = { + post: "/users/me/auth_factors/otp_sms" + body: "*" + }; + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Add One-Time-Password (OTP) SMS"; + description: "Add a new One-Time-Password (OTP) SMS factor to the authenticated user. OTP SMS will enable the user to verify a OTP with the latest verified phone number. The phone number has to be verified to add the second factor." + }; + } + + rpc RemoveMyAuthFactorOTPSMS(RemoveMyAuthFactorOTPSMSRequest) returns (RemoveMyAuthFactorOTPSMSResponse) { + option (google.api.http) = { + delete: "/users/me/auth_factors/otp_sms" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Remove One-Time-Password (OTP) SMS"; + description: "Remove the configured One-Time-Password (OTP) SMS factor of the authenticated user. As only one OTP SMS per user is allowed, the user will not have OTP SMS as a second-factor afterward." + }; + } + + rpc AddMyAuthFactorOTPEmail(AddMyAuthFactorOTPEmailRequest) returns (AddMyAuthFactorOTPEmailResponse) { + option (google.api.http) = { + post: "/users/me/auth_factors/otp_email" + body: "*" + }; + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Add One-Time-Password (OTP) Email"; + description: "Add a new One-Time-Password (OTP) Email factor to the authenticated user. OTP Email will enable the user to verify a OTP with the latest verified email. The email has to be verified to add the second factor." + }; + } + + rpc RemoveMyAuthFactorOTPEmail(RemoveMyAuthFactorOTPEmailRequest) returns (RemoveMyAuthFactorOTPEmailResponse) { + option (google.api.http) = { + delete: "/users/me/auth_factors/otp_email" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Remove One-Time-Password (OTP) Email"; + description: "Remove the configured One-Time-Password (OTP) Email factor of the authenticated user. As only one OTP Email per user is allowed, the user will not have OTP Email as a second-factor afterward." + }; + } + rpc AddMyAuthFactorU2F(AddMyAuthFactorU2FRequest) returns (AddMyAuthFactorU2FResponse) { option (google.api.http) = { post: "/users/me/auth_factors/u2f" @@ -1340,6 +1404,34 @@ message RemoveMyAuthFactorOTPResponse { zitadel.v1.ObjectDetails details = 1; } +//This is an empty request +message AddMyAuthFactorOTPSMSRequest {} + +message AddMyAuthFactorOTPSMSResponse { + zitadel.v1.ObjectDetails details = 1; +} + +//This is an empty request +message RemoveMyAuthFactorOTPSMSRequest {} + +message RemoveMyAuthFactorOTPSMSResponse { + zitadel.v1.ObjectDetails details = 1; +} + +//This is an empty request +message AddMyAuthFactorOTPEmailRequest {} + +message AddMyAuthFactorOTPEmailResponse { + zitadel.v1.ObjectDetails details = 1; +} + +//This is an empty request +message RemoveMyAuthFactorOTPEmailRequest {} + +message RemoveMyAuthFactorOTPEmailResponse { + zitadel.v1.ObjectDetails details = 1; +} + message RemoveMyAuthFactorU2FRequest { string token_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/policy.proto b/proto/zitadel/policy.proto index 49dbe34dc3..4b3aa9035e 100644 --- a/proto/zitadel/policy.proto +++ b/proto/zitadel/policy.proto @@ -248,8 +248,11 @@ message LoginPolicy { enum SecondFactorType { SECOND_FACTOR_TYPE_UNSPECIFIED = 0; + // SECOND_FACTOR_TYPE_OTP is the type for TOTP SECOND_FACTOR_TYPE_OTP = 1; SECOND_FACTOR_TYPE_U2F = 2; + SECOND_FACTOR_TYPE_OTP_EMAIL = 3; + SECOND_FACTOR_TYPE_OTP_SMS = 4; } enum MultiFactorType { diff --git a/proto/zitadel/settings.proto b/proto/zitadel/settings.proto index c8e879f54b..37cc4e9569 100644 --- a/proto/zitadel/settings.proto +++ b/proto/zitadel/settings.proto @@ -48,6 +48,8 @@ enum SecretGeneratorType { SECRET_GENERATOR_TYPE_PASSWORD_RESET_CODE = 4; SECRET_GENERATOR_TYPE_PASSWORDLESS_INIT_CODE = 5; SECRET_GENERATOR_TYPE_APP_SECRET = 6; + SECRET_GENERATOR_TYPE_OTP_SMS = 7; + SECRET_GENERATOR_TYPE_OTP_EMAIL = 8; } message SMTPConfig { diff --git a/proto/zitadel/settings/v2alpha/login_settings.proto b/proto/zitadel/settings/v2alpha/login_settings.proto index 9d4b37237c..057d076ce0 100644 --- a/proto/zitadel/settings/v2alpha/login_settings.proto +++ b/proto/zitadel/settings/v2alpha/login_settings.proto @@ -113,8 +113,11 @@ message LoginSettings { enum SecondFactorType { SECOND_FACTOR_TYPE_UNSPECIFIED = 0; + // This is the type for TOTP SECOND_FACTOR_TYPE_OTP = 1; SECOND_FACTOR_TYPE_U2F = 2; + SECOND_FACTOR_TYPE_OTP_EMAIL = 3; + SECOND_FACTOR_TYPE_OTP_SMS = 4; } enum MultiFactorType { diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index 4bfec35e6e..e2e95dc9c5 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -217,7 +217,7 @@ service SystemService { }; } - // Returns the domain of an instance + // Adds a domain to an instance rpc AddDomain(AddDomainRequest) returns (AddDomainResponse) { option (google.api.http) = { post: "/instances/{instance_id}/domains"; diff --git a/proto/zitadel/user.proto b/proto/zitadel/user.proto index 73cc14f7a7..bbe4f0be52 100644 --- a/proto/zitadel/user.proto +++ b/proto/zitadel/user.proto @@ -342,12 +342,22 @@ message AuthFactor { oneof type { AuthFactorOTP otp = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "one type use OTP or U2F" + description: "one type use OTP, OTPSMS, OTPEmail or U2F" } ]; AuthFactorU2F u2f = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "one type use OTP or U2F" + description: "one type use OTP, OTPSMS, OTPEmail or U2F" + } + ]; + AuthFactorOTPSMS otp_sms = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "one type use OTP, OTPSMS, OTPEmail or U2F" + } + ]; + AuthFactorOTPEmail otp_email = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "one type use OTP, OTPSMS, OTPEmail or U2F" } ]; } @@ -361,6 +371,8 @@ enum AuthFactorState { } message AuthFactorOTP {} +message AuthFactorOTPSMS {} +message AuthFactorOTPEmail {} message AuthFactorU2F { string id = 1 [ diff --git a/proto/zitadel/user/v2alpha/phone.proto b/proto/zitadel/user/v2alpha/phone.proto new file mode 100644 index 0000000000..775bb87300 --- /dev/null +++ b/proto/zitadel/user/v2alpha/phone.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package zitadel.user.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user"; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +message SetHumanPhone { + string phone = 1 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"+41791234567\""; + } + ]; + oneof verification { + SendPhoneVerificationCode send_code = 2; + ReturnPhoneVerificationCode return_code = 3; + bool is_verified = 4 [(validate.rules).bool.const = true]; + } +} + +message SendPhoneVerificationCode {} + +message ReturnPhoneVerificationCode {} + diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index 50562bcf56..796688747c 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -6,6 +6,7 @@ import "zitadel/object/v2alpha/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2alpha/auth.proto"; import "zitadel/user/v2alpha/email.proto"; +import "zitadel/user/v2alpha/phone.proto"; import "zitadel/user/v2alpha/idp.proto"; import "zitadel/user/v2alpha/password.proto"; import "zitadel/user/v2alpha/user.proto"; @@ -158,6 +159,56 @@ service UserService { }; } + // Change the phone of a user + rpc SetPhone(SetPhoneRequest) returns (SetPhoneResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/phone" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Change the user phone"; + description: "Change the phone number of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by sms." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Verify the phone with the provided code + rpc VerifyPhone (VerifyPhoneRequest) returns (VerifyPhoneResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/phone/_verify" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Verify the phone"; + description: "Verify the phone with the generated code." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + rpc RegisterPasskey (RegisterPasskeyRequest) returns (RegisterPasskeyResponse) { option (google.api.http) = { post: "/v2alpha/users/{user_id}/passkeys" @@ -317,6 +368,96 @@ service UserService { }; } + rpc AddOTPSMS (AddOTPSMSRequest) returns (AddOTPSMSResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/otp_sms" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Add OTP SMS for a user"; + description: "Add a new One-Time-Password (OTP) SMS factor to the authenticated user. OTP SMS will enable the user to verify a OTP with the latest verified phone number. The phone number has to be verified to add the second factor." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc RemoveOTPSMS (RemoveOTPSMSRequest) returns (RemoveOTPSMSResponse) { + option (google.api.http) = { + delete: "/v2alpha/users/{user_id}/otp_sms" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Remove One-Time-Password (OTP) SMS from a user"; + description: "Remove the configured One-Time-Password (OTP) SMS factor of the authenticated user. As only one OTP SMS per user is allowed, the user will not have OTP SMS as a second-factor afterward." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc AddOTPEmail (AddOTPEmailRequest) returns (AddOTPEmailResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/otp_email" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Add OTP Email for a user"; + description: "Add a new One-Time-Password (OTP) Email factor to the authenticated user. OTP Email will enable the user to verify a OTP with the latest verified email. The email has to be verified to add the second factor." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc RemoveOTPEmail (RemoveOTPEmailRequest) returns (RemoveOTPEmailResponse) { + option (google.api.http) = { + delete: "/v2alpha/users/{user_id}/otp_email" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Remove One-Time-Password (OTP) Email from a user"; + description: "Remove the configured One-Time-Password (OTP) Email factor of the authenticated user. As only one OTP Email per user is allowed, the user will not have OTP Email as a second-factor afterward." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + // Start an IDP authentication (for external login, registration or linking) rpc StartIdentityProviderFlow (StartIdentityProviderFlowRequest) returns (StartIdentityProviderFlowResponse) { option (google.api.http) = { @@ -494,6 +635,7 @@ message AddHumanUserRequest{ (validate.rules).message.required = true, (google.api.field_behavior) = REQUIRED ]; + SetHumanPhone phone = 10; repeated SetMetadataEntry metadata = 6; oneof password_type { Password password = 7; @@ -506,6 +648,7 @@ message AddHumanUserResponse { string user_id = 1; zitadel.object.v2alpha.Details details = 2; optional string email_code = 3; + optional string phone_code = 4; } message SetEmailRequest{ @@ -567,6 +710,65 @@ message VerifyEmailResponse{ zitadel.object.v2alpha.Details details = 1; } +message SetPhoneRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + string phone = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"+41791234567\""; + } + ]; + // if no verification is specified, an sms is sent + oneof verification { + SendPhoneVerificationCode send_code = 3; + ReturnPhoneVerificationCode return_code = 4; + bool is_verified = 5 [(validate.rules).bool.const = true]; + } +} + +message SetPhoneResponse{ + zitadel.object.v2alpha.Details details = 1; + // in case the verification was set to return_code, the code will be returned + optional string verification_code = 2; +} + +message VerifyPhoneRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + string verification_code = 2 [ + (validate.rules).string = {min_len: 1, max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 20; + example: "\"SKJd342k\""; + description: "\"the verification code generated during the set phone request\""; + } + ]; +} + +message VerifyPhoneResponse{ + zitadel.object.v2alpha.Details details = 1; +} + message RegisterPasskeyRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -779,6 +981,70 @@ message VerifyTOTPRegistrationResponse { zitadel.object.v2alpha.Details details = 1; } +message AddOTPSMSRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; +} + +message AddOTPSMSResponse { + zitadel.object.v2alpha.Details details = 1; +} + +message RemoveOTPSMSRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; +} + +message RemoveOTPSMSResponse { + zitadel.object.v2alpha.Details details = 1; +} + +message AddOTPEmailRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; +} + +message AddOTPEmailResponse { + zitadel.object.v2alpha.Details details = 1; +} + +message RemoveOTPEmailRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; +} + +message RemoveOTPEmailResponse { + zitadel.object.v2alpha.Details details = 1; +} + message CreatePasskeyRegistrationLinkRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -979,4 +1245,6 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_IDP = 3; AUTHENTICATION_METHOD_TYPE_TOTP = 4; AUTHENTICATION_METHOD_TYPE_U2F = 5; + AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6; + AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; }