From 7c494fd21921f94a69474c17ef79f2ea0962dfd6 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 15 Aug 2023 14:47:05 +0200 Subject: [PATCH] feat(login): add OTP (email and sms) (#6353) * feat: login with otp * fix(i18n): japanese translation * add missing files * fix provider change * add event types translations to en * add tests * resourceOwner * remove unused handler * fix: secret generators and add comments * add setup step * rename * linting * fix setup * improve otp handling * fix autocomplete * translations for login and notifications * translations for event types * changes from review * check selected mfa type --- cmd/setup/12.go | 26 + cmd/setup/12/12_add_otp_columns.sql | 2 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 3 + .../dialog-add-secret-generator.component.ts | 4 +- internal/api/grpc/auth/phone.go | 6 +- internal/api/grpc/management/user.go | 6 +- internal/api/oidc/auth_request_converter.go | 4 +- internal/api/ui/login/custom_action.go | 2 + internal/api/ui/login/login.go | 8 + internal/api/ui/login/mfa_init_sms.go | 125 ++ internal/api/ui/login/mfa_prompt_handler.go | 20 + internal/api/ui/login/mfa_verify_handler.go | 6 + .../api/ui/login/mfa_verify_otp_handler.go | 126 ++ internal/api/ui/login/renderer.go | 10 +- internal/api/ui/login/router.go | 5 + internal/api/ui/login/static/i18n/bg.yaml | 13 + internal/api/ui/login/static/i18n/de.yaml | 21 + internal/api/ui/login/static/i18n/en.yaml | 23 +- internal/api/ui/login/static/i18n/es.yaml | 14 + internal/api/ui/login/static/i18n/fr.yaml | 14 + internal/api/ui/login/static/i18n/it.yaml | 14 + internal/api/ui/login/static/i18n/ja.yaml | 14 + internal/api/ui/login/static/i18n/mk.yaml | 14 + internal/api/ui/login/static/i18n/pl.yaml | 14 + internal/api/ui/login/static/i18n/pt.yaml | 14 + internal/api/ui/login/static/i18n/zh.yaml | 14 + .../ui/login/static/resources/scripts/edit.js | 6 + .../themes/scss/styles/mfa/mfa_base.scss | 5 +- .../static/templates/mfa_init_otp_sms.html | 63 + .../ui/login/static/templates/mfa_prompt.html | 10 + .../static/templates/mfa_verify_otp.html | 21 +- .../static/templates/mfa_verify_totp.html | 48 + internal/auth/repository/auth_request.go | 4 + .../eventsourcing/eventstore/auth_request.go | 42 + .../repository/eventsourcing/handler/user.go | 4 + internal/command/user_converter.go | 3 + internal/command/user_human_otp.go | 291 +++- internal/command/user_human_otp_model.go | 156 +- internal/command/user_human_otp_test.go | 1366 +++++++++++++++++ internal/command/user_human_phone.go | 16 +- internal/command/user_human_phone_test.go | 40 +- internal/crypto/code_mocker.go | 29 +- internal/domain/auth_request.go | 2 + .../notification/handlers/user_notifier.go | 141 +- internal/notification/static/i18n/bg.yaml | 5 +- internal/notification/static/i18n/de.yaml | 5 +- internal/notification/static/i18n/en.yaml | 7 +- internal/notification/static/i18n/es.yaml | 5 +- internal/notification/static/i18n/fr.yaml | 5 +- internal/notification/static/i18n/it.yaml | 5 +- internal/notification/static/i18n/ja.yaml | 7 +- internal/notification/static/i18n/mk.yaml | 5 +- internal/notification/static/i18n/pl.yaml | 5 +- internal/notification/static/i18n/pt.yaml | 5 +- internal/notification/static/i18n/zh.yaml | 5 +- internal/notification/types/otp.go | 29 + .../types/phone_verification_code.go | 3 +- internal/query/projection/user_auth_method.go | 8 + .../repository/instance/secret_generator.go | 2 +- internal/repository/user/eventstore.go | 4 + internal/repository/user/human_mfa_otp.go | 149 ++ internal/static/i18n/bg.yaml | 18 + internal/static/i18n/de.yaml | 18 + internal/static/i18n/en.yaml | 18 + internal/static/i18n/es.yaml | 18 + internal/static/i18n/fr.yaml | 18 + internal/static/i18n/it.yaml | 18 + internal/static/i18n/ja.yaml | 18 + internal/static/i18n/mk.yaml | 18 + internal/static/i18n/pl.yaml | 18 + internal/static/i18n/pt.yaml | 18 + internal/static/i18n/zh.yaml | 18 + internal/user/model/user_view.go | 20 +- internal/user/repository/view/model/user.go | 23 +- .../repository/view/model/user_session.go | 26 +- 76 files changed, 3203 insertions(+), 88 deletions(-) create mode 100644 cmd/setup/12.go create mode 100644 cmd/setup/12/12_add_otp_columns.sql create mode 100644 internal/api/ui/login/mfa_init_sms.go create mode 100644 internal/api/ui/login/mfa_verify_otp_handler.go create mode 100644 internal/api/ui/login/static/resources/scripts/edit.js create mode 100644 internal/api/ui/login/static/templates/mfa_init_otp_sms.html create mode 100644 internal/api/ui/login/static/templates/mfa_verify_totp.html create mode 100644 internal/notification/types/otp.go diff --git a/cmd/setup/12.go b/cmd/setup/12.go new file mode 100644 index 000000000..ce0e23aa0 --- /dev/null +++ b/cmd/setup/12.go @@ -0,0 +1,26 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" +) + +var ( + //go:embed 12/12_add_otp_columns.sql + addOTPColumns string +) + +type AddOTPColumns struct { + dbClient *database.DB +} + +func (mig *AddOTPColumns) Execute(ctx context.Context) error { + _, err := mig.dbClient.ExecContext(ctx, addOTPColumns) + return err +} + +func (mig *AddOTPColumns) String() string { + return "12_auth_users_otp_columns" +} diff --git a/cmd/setup/12/12_add_otp_columns.sql b/cmd/setup/12/12_add_otp_columns.sql new file mode 100644 index 000000000..4386bf9fe --- /dev/null +++ b/cmd/setup/12/12_add_otp_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE auth.users2 ADD COLUMN otp_sms_added BOOL DEFAULT false; +ALTER TABLE auth.users2 ADD COLUMN otp_email_added BOOL DEFAULT false; \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 2f1bfbd6f..939fc9adf 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -67,6 +67,7 @@ type Steps struct { s9EventstoreIndexes2 *EventstoreIndexesNew CorrectCreationDate *CorrectCreationDate AddEventCreatedAt *AddEventCreatedAt + s12AddOTPColumns *AddOTPColumns } type encryptionKeyConfig struct { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 25f2f4fa4..beba81611 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -94,6 +94,7 @@ func Setup(config *Config, steps *Steps, masterKey string) { steps.CorrectCreationDate.dbClient = dbClient steps.AddEventCreatedAt.dbClient = dbClient steps.AddEventCreatedAt.step10 = steps.CorrectCreationDate + steps.s12AddOTPColumns = &AddOTPColumns{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -134,6 +135,8 @@ func Setup(config *Config, steps *Steps, masterKey string) { logging.OnError(err).Fatal("unable to migrate step 10") err = migration.Migrate(ctx, eventstoreClient, steps.AddEventCreatedAt) logging.OnError(err).Fatal("unable to migrate step 11") + err = migration.Migrate(ctx, eventstoreClient, steps.s12AddOTPColumns) + logging.OnError(err).Fatal("unable to migrate step 12") for _, repeatableStep := range repeatableSteps { err = migration.Migrate(ctx, eventstoreClient, repeatableStep) 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 6d7228611..0161625c2 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 @@ -32,8 +32,8 @@ export class DialogAddSecretGeneratorComponent { 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]], + includeSymbols: [data.config?.includeSymbols ?? true, [requiredValidator]], + includeLowerLetters: [data.config?.includeLowerLetters ?? true, [requiredValidator]], includeUpperLetters: [data.config?.includeUpperLetters ?? true, [requiredValidator]], }); } diff --git a/internal/api/grpc/auth/phone.go b/internal/api/grpc/auth/phone.go index 6c474831b..5d5b722af 100644 --- a/internal/api/grpc/auth/phone.go +++ b/internal/api/grpc/auth/phone.go @@ -62,11 +62,7 @@ func (s *Server) VerifyMyPhone(ctx context.Context, req *auth_pb.VerifyMyPhoneRe func (s *Server) ResendMyPhoneVerification(ctx context.Context, _ *auth_pb.ResendMyPhoneVerificationRequest) (*auth_pb.ResendMyPhoneVerificationResponse, error) { ctxData := authz.GetCtxData(ctx) - phoneCodeGenerator, err := s.query.InitEncryptionGenerator(ctx, domain.SecretGeneratorTypeVerifyPhoneCode, s.userCodeAlg) - if err != nil { - return nil, err - } - objectDetails, err := s.command.CreateHumanPhoneVerificationCode(ctx, ctxData.UserID, ctxData.ResourceOwner, phoneCodeGenerator) + objectDetails, err := s.command.CreateHumanPhoneVerificationCode(ctx, ctxData.UserID, ctxData.ResourceOwner) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index c37ce3728..804a90521 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -549,11 +549,7 @@ func (s *Server) RemoveHumanPhone(ctx context.Context, req *mgmt_pb.RemoveHumanP } func (s *Server) ResendHumanPhoneVerification(ctx context.Context, req *mgmt_pb.ResendHumanPhoneVerificationRequest) (*mgmt_pb.ResendHumanPhoneVerificationResponse, error) { - phoneCodeGenerator, err := s.query.InitEncryptionGenerator(ctx, domain.SecretGeneratorTypeVerifyPhoneCode, s.userCodeAlg) - if err != nil { - return nil, err - } - objectDetails, err := s.command.CreateHumanPhoneVerificationCode(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, phoneCodeGenerator) + objectDetails, err := s.command.CreateHumanPhoneVerificationCode(ctx, req.UserId, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index d424abfc5..5999f21e5 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -261,7 +261,9 @@ func CodeChallengeToOIDC(challenge *domain.OIDCCodeChallenge) *oidc.CodeChalleng func AMRFromMFAType(mfaType domain.MFAType) string { switch mfaType { - case domain.MFATypeTOTP: + case domain.MFATypeTOTP, + domain.MFATypeOTPSMS, + domain.MFATypeOTPEmail: return OTP case domain.MFATypeU2F, domain.MFATypeU2FUserVerification: diff --git a/internal/api/ui/login/custom_action.go b/internal/api/ui/login/custom_action.go index d6dbe6cdd..516f7bc3d 100644 --- a/internal/api/ui/login/custom_action.go +++ b/internal/api/ui/login/custom_action.go @@ -149,6 +149,8 @@ type authMethod string const ( authMethodPassword authMethod = "password" authMethodOTP authMethod = "OTP" + authMethodOTPSMS authMethod = "OTP SMS" + authMethodOTPEmail authMethod = "OTP Email" authMethodU2F authMethod = "U2F" authMethodPasswordless authMethod = "passwordless" ) diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index de42f7e68..1a76ad178 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -171,6 +171,14 @@ func setContext(ctx context.Context, resourceOwner string) context.Context { return authz.SetCtxData(ctx, data) } +func setUserContext(ctx context.Context, userID, resourceOwner string) context.Context { + data := authz.CtxData{ + UserID: userID, + OrgID: resourceOwner, + } + return authz.SetCtxData(ctx, data) +} + func (l *Login) baseURL(ctx context.Context) string { return http_utils.BuildOrigin(authz.GetInstance(ctx).RequestedHost(), l.externalSecure) + HandlerPrefix } diff --git a/internal/api/ui/login/mfa_init_sms.go b/internal/api/ui/login/mfa_init_sms.go new file mode 100644 index 000000000..6443d6172 --- /dev/null +++ b/internal/api/ui/login/mfa_init_sms.go @@ -0,0 +1,125 @@ +package login + +import ( + "net/http" + + "github.com/zitadel/zitadel/internal/domain" +) + +const ( + tmplMFASMSInit = "mfainitsms" +) + +type smsInitData struct { + userData + Edit bool + MFAType domain.MFAType + Phone string +} + +type smsInitFormData struct { + Edit bool `schema:"edit"` + Resend bool `schema:"resend"` + Phone string `schema:"phone"` + NewPhone string `schema:"newPhone"` + Code string `schema:"code"` +} + +// handleRegisterOTPSMS checks if the user has a verified phone number and will directly add OTP SMS as 2FA. +// It will also add a successful OTP SMS check to the auth request. +// If there's no verified phone number, the potential last phone number will be used to render the registration page +func (l *Login) handleRegisterOTPSMS(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest) { + user, err := l.query.GetNotifyUserByID(r.Context(), true, authReq.UserID, false) + if err != nil { + l.renderError(w, r, authReq, err) + return + } + if user.VerifiedPhone == "" { + data := new(smsInitData) + data.Phone = user.LastPhone + data.Edit = user.LastPhone == "" + l.renderRegisterSMS(w, r, authReq, data, nil) + return + } + _, err = l.command.AddHumanOTPSMSWithCheckSucceeded(setUserContext(r.Context(), authReq.UserID, authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, authReq) + if err != nil { + l.renderError(w, r, authReq, err) + return + } + done := &mfaDoneData{ + MFAType: domain.MFATypeOTPSMS, + } + l.renderMFAInitDone(w, r, authReq, done) +} + +func (l *Login) renderRegisterSMS(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *smsInitData, err error) { + var errID, errMessage string + if err != nil { + errID, errMessage = l.getErrorMessage(r, err) + } + data.baseData = l.getBaseData(r, authReq, "InitMFAOTP.Title", "InitMFAOTP.Description", errID, errMessage) + data.profileData = l.getProfileData(authReq) + data.MFAType = domain.MFATypeOTPSMS + l.renderer.RenderTemplate(w, r, l.getTranslator(r.Context(), authReq), l.renderer.Templates[tmplMFASMSInit], data, nil) +} + +// handleRegisterSMSCheck handles form submissions of the SMS registration. +// The user can be either in edit mode, where a phone number can be entered / changed. +// If a phone was set, the user can either switch to edit mode, have a resend of the code or verify the code by entering it. +// On successful code verification, the phone will be added to the user as well as his MFA +// and a successful OTP SMS check will be added to the auth request. +func (l *Login) handleRegisterSMSCheck(w http.ResponseWriter, r *http.Request) { + formData := new(smsInitFormData) + authReq, err := l.getAuthRequestAndParseData(r, formData) + if err != nil { + l.renderError(w, r, authReq, err) + return + } + + ctx := setUserContext(r.Context(), authReq.UserID, authReq.UserOrgID) + // save the current state + data := &smsInitData{Phone: formData.Phone} + + if formData.Edit { + data.Edit = true + l.renderRegisterSMS(w, r, authReq, data, err) + return + } + + if formData.Resend { + _, err = l.command.CreateHumanPhoneVerificationCode(ctx, authReq.UserID, authReq.UserOrgID) + l.renderRegisterSMS(w, r, authReq, data, err) + return + } + + // if the user is currently in edit mode, + // he can either change the phone number + // or just return to the code verification again + if formData.Code == "" { + data.Phone = formData.NewPhone + if formData.NewPhone != formData.Phone { + _, err = l.command.ChangeUserPhone(ctx, authReq.UserID, authReq.UserOrgID, formData.NewPhone, l.userCodeAlg) + if err != nil { + // stay in edit more + data.Edit = true + } + } + l.renderRegisterSMS(w, r, authReq, data, err) + return + } + + _, err = l.command.VerifyUserPhone(ctx, authReq.UserID, authReq.UserOrgID, formData.Code, l.userCodeAlg) + if err != nil { + l.renderRegisterSMS(w, r, authReq, data, err) + return + } + _, err = l.command.AddHumanOTPSMSWithCheckSucceeded(ctx, authReq.UserID, authReq.UserOrgID, authReq) + if err != nil { + l.renderRegisterSMS(w, r, authReq, data, err) + return + } + done := &mfaDoneData{ + MFAType: domain.MFATypeOTPSMS, + } + l.renderMFAInitDone(w, r, authReq, done) +} diff --git a/internal/api/ui/login/mfa_prompt_handler.go b/internal/api/ui/login/mfa_prompt_handler.go index c18511d31..9f4e8be40 100644 --- a/internal/api/ui/login/mfa_prompt_handler.go +++ b/internal/api/ui/login/mfa_prompt_handler.go @@ -83,6 +83,12 @@ func (l *Login) handleMFACreation(w http.ResponseWriter, r *http.Request, authRe case domain.MFATypeTOTP: l.handleTOTPCreation(w, r, authReq, data) return + case domain.MFATypeOTPSMS: + l.handleRegisterOTPSMS(w, r, authReq) + return + case domain.MFATypeOTPEmail: + l.handleRegisterOTPEmail(w, r, authReq) + return case domain.MFATypeU2F: l.renderRegisterU2F(w, r, authReq, nil) return @@ -103,3 +109,17 @@ func (l *Login) handleTOTPCreation(w http.ResponseWriter, r *http.Request, authR } l.renderMFAInitVerify(w, r, authReq, data, nil) } + +// handleRegisterOTPEmail will directly add OTP Email as 2FA. +// It will also add a successful OTP Email check to the auth request. +func (l *Login) handleRegisterOTPEmail(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest) { + _, err := l.command.AddHumanOTPEmailWithCheckSucceeded(setUserContext(r.Context(), authReq.UserID, authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, authReq) + if err != nil { + l.renderError(w, r, authReq, err) + return + } + done := &mfaDoneData{ + MFAType: domain.MFATypeOTPEmail, + } + l.renderMFAInitDone(w, r, authReq, done) +} diff --git a/internal/api/ui/login/mfa_verify_handler.go b/internal/api/ui/login/mfa_verify_handler.go index b65809c89..addb7347f 100644 --- a/internal/api/ui/login/mfa_verify_handler.go +++ b/internal/api/ui/login/mfa_verify_handler.go @@ -84,6 +84,12 @@ func (l *Login) renderMFAVerifySelected(w http.ResponseWriter, r *http.Request, data.SelectedMFAProvider = domain.MFATypeTOTP data.Title = translator.LocalizeWithoutArgs("VerifyMFAOTP.Title") data.Description = translator.LocalizeWithoutArgs("VerifyMFAOTP.Description") + case domain.MFATypeOTPSMS: + l.handleOTPVerification(w, r, authReq, verificationStep.MFAProviders, domain.MFATypeOTPSMS, nil) + return + case domain.MFATypeOTPEmail: + l.handleOTPVerification(w, r, authReq, verificationStep.MFAProviders, domain.MFATypeOTPEmail, nil) + return default: l.renderError(w, r, authReq, err) return diff --git a/internal/api/ui/login/mfa_verify_otp_handler.go b/internal/api/ui/login/mfa_verify_otp_handler.go new file mode 100644 index 000000000..88aa37c94 --- /dev/null +++ b/internal/api/ui/login/mfa_verify_otp_handler.go @@ -0,0 +1,126 @@ +package login + +import ( + "context" + "fmt" + "net/http" + + http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/domain" +) + +const ( + tmplOTPVerification = "otpverification" + querySelectedProvider = "selectedProvider" +) + +type mfaOTPData struct { + userData + MFAProviders []domain.MFAType + SelectedProvider domain.MFAType +} + +type mfaOTPFormData struct { + Resend bool `schema:"resend"` + Code string `schema:"code"` + SelectedProvider domain.MFAType `schema:"selectedProvider"` + Provider domain.MFAType `schema:"provider"` +} + +func OTPLink(origin, authRequestID, code string, provider domain.MFAType) string { + return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%d", externalLink(origin), EndpointMFAOTPVerify, QueryAuthRequestID, authRequestID, queryCode, code, querySelectedProvider, provider) +} + +// renderOTPVerification renders the OTP verification for SMS and Email based on the passed MFAType. +// It will send a new code to either phone or email first. +func (l *Login) handleOTPVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, providers []domain.MFAType, selectedProvider domain.MFAType, err error) { + if err != nil { + l.renderOTPVerification(w, r, authReq, providers, selectedProvider, err) + return + } + userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) + var sendCode func(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) error + switch selectedProvider { + case domain.MFATypeOTPSMS: + sendCode = l.authRepo.SendMFAOTPSMS + case domain.MFATypeOTPEmail: + sendCode = l.authRepo.SendMFAOTPEmail + // another type should never be passed, but just making sure + case domain.MFATypeU2F, + domain.MFATypeTOTP, + domain.MFATypeU2FUserVerification: + l.renderError(w, r, authReq, err) + return + } + err = sendCode(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, authReq.ID, userAgentID) + l.renderOTPVerification(w, r, authReq, providers, selectedProvider, err) +} + +func (l *Login) renderOTPVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, providers []domain.MFAType, selectedProvider domain.MFAType, err error) { + var errID, errMessage string + if err != nil { + errID, errMessage = l.getErrorMessage(r, err) + } + data := &mfaOTPData{ + userData: l.getUserData(r, authReq, "VerifyMFAU2F.Title", "VerifyMFAU2F.Description", errID, errMessage), + MFAProviders: removeSelectedProviderFromList(providers, selectedProvider), + SelectedProvider: selectedProvider, + } + l.renderer.RenderTemplate(w, r, l.getTranslator(r.Context(), authReq), l.renderer.Templates[tmplOTPVerification], data, nil) +} + +// handleOTPVerificationCheck handles form submissions of the OTP verification. +// On successful code verification, the check will be added to the auth request. +// A user is also able to request a code resend or choose another provider. +func (l *Login) handleOTPVerificationCheck(w http.ResponseWriter, r *http.Request) { + formData := new(mfaOTPFormData) + authReq, err := l.getAuthRequestAndParseData(r, formData) + if err != nil { + l.renderError(w, r, authReq, err) + return + } + step, ok := authReq.PossibleSteps[0].(*domain.MFAVerificationStep) + if !ok { + l.renderError(w, r, authReq, err) + return + } + if formData.Resend { + l.handleOTPVerification(w, r, authReq, step.MFAProviders, formData.SelectedProvider, nil) + return + } + if formData.Code == "" { + l.renderMFAVerifySelected(w, r, authReq, step, formData.Provider, nil) + return + } + userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) + var actionType authMethod + var verifyCode func(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) error + switch formData.SelectedProvider { + case domain.MFATypeOTPSMS: + actionType = authMethodOTPSMS + verifyCode = l.authRepo.VerifyMFAOTPSMS + case domain.MFATypeOTPEmail: + actionType = authMethodOTPEmail + verifyCode = l.authRepo.VerifyMFAOTPEmail + // another type should never be passed, but just making sure + case domain.MFATypeU2F, + domain.MFATypeTOTP, + domain.MFATypeU2FUserVerification: + l.renderOTPVerification(w, r, authReq, step.MFAProviders, formData.SelectedProvider, err) + return + } + err = verifyCode(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, formData.Code, authReq.ID, userAgentID, domain.BrowserInfoFromRequest(r)) + + metadata, actionErr := l.runPostInternalAuthenticationActions(authReq, r, actionType, err) + if err == nil && actionErr == nil && len(metadata) > 0 { + _, err = l.command.BulkSetUserMetadata(r.Context(), authReq.UserID, authReq.UserOrgID, metadata...) + } else if actionErr != nil && err == nil { + err = actionErr + } + + if err != nil { + l.renderOTPVerification(w, r, authReq, step.MFAProviders, formData.SelectedProvider, err) + return + } + l.renderNextStep(w, r, authReq) +} diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index bcbe4ad9a..29fed4eda 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -54,9 +54,11 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage tmplPasswordlessRegistration: "passwordless_registration.html", tmplPasswordlessRegistrationDone: "passwordless_registration_done.html", tmplPasswordlessPrompt: "passwordless_prompt.html", - tmplMFAVerify: "mfa_verify_otp.html", + tmplMFAVerify: "mfa_verify_totp.html", tmplMFAPrompt: "mfa_prompt.html", tmplMFAInitVerify: "mfa_init_otp.html", + tmplMFASMSInit: "mfa_init_otp_sms.html", + tmplOTPVerification: "mfa_verify_otp.html", tmplMFAU2FInit: "mfa_init_u2f.html", tmplU2FVerification: "mfa_verification_u2f.html", tmplMFAInitDone: "mfa_init_done.html", @@ -170,6 +172,12 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage "mfaInitVerifyUrl": func() string { return path.Join(r.pathPrefix, EndpointMFAInitVerify) }, + "mfaInitSMSVerifyUrl": func() string { + return path.Join(r.pathPrefix, EndpointMFASMSInitVerify) + }, + "mfaOTPVerifyUrl": func() string { + return path.Join(r.pathPrefix, EndpointMFAOTPVerify) + }, "mfaInitU2FVerifyUrl": func() string { return path.Join(r.pathPrefix, EndpointMFAInitU2FVerify) }, diff --git a/internal/api/ui/login/router.go b/internal/api/ui/login/router.go index 8ad27d757..62ac4d9af 100644 --- a/internal/api/ui/login/router.go +++ b/internal/api/ui/login/router.go @@ -31,6 +31,8 @@ const ( EndpointMFAVerify = "/mfa/verify" EndpointMFAPrompt = "/mfa/prompt" EndpointMFAInitVerify = "/mfa/init/verify" + EndpointMFASMSInitVerify = "/mfa/init/sms/verify" + EndpointMFAOTPVerify = "/mfa/otp/verify" EndpointMFAInitU2FVerify = "/mfa/init/u2f/verify" EndpointU2FVerification = "/mfa/u2f/verify" EndpointMailVerification = "/mail/verification" @@ -89,6 +91,9 @@ func CreateRouter(login *Login, staticDir http.FileSystem, interceptors ...mux.M router.HandleFunc(EndpointMFAPrompt, login.handleMFAPromptSelection).Methods(http.MethodGet) router.HandleFunc(EndpointMFAPrompt, login.handleMFAPrompt).Methods(http.MethodPost) router.HandleFunc(EndpointMFAInitVerify, login.handleMFAInitVerify).Methods(http.MethodPost) + router.HandleFunc(EndpointMFASMSInitVerify, login.handleRegisterSMSCheck).Methods(http.MethodPost) + router.HandleFunc(EndpointMFAOTPVerify, login.handleOTPVerificationCheck).Methods(http.MethodGet) + router.HandleFunc(EndpointMFAOTPVerify, login.handleOTPVerificationCheck).Methods(http.MethodPost) router.HandleFunc(EndpointMFAInitU2FVerify, login.handleRegisterU2F).Methods(http.MethodPost) router.HandleFunc(EndpointU2FVerification, login.handleU2FVerification).Methods(http.MethodPost) router.HandleFunc(EndpointMailVerification, login.handleMailVerification).Methods(http.MethodGet) diff --git a/internal/api/ui/login/static/i18n/bg.yaml b/internal/api/ui/login/static/i18n/bg.yaml index 33e963c63..ab1ebe768 100644 --- a/internal/api/ui/login/static/i18n/bg.yaml +++ b/internal/api/ui/login/static/i18n/bg.yaml @@ -85,6 +85,8 @@ InitMFAPrompt: потребителски акаунт. Provider0: 'Приложение за удостоверяване (напр. Google/Microsoft Authenticator, Authy)' Provider1: 'Зависи от устройството (напр. FaceID, Windows Hello, пръстов отпечатък)' + Provider3: OTP SMS + Provider4: OTP имейл NextButtonText: следващия SkipButtonText: пропуснете InitMFAOTP: @@ -98,6 +100,15 @@ InitMFAOTP: CodeLabel: Код NextButtonText: следващия CancelButtonText: анулиране +InitMFAOTPSMS: + Title: 2-факторна проверка + DescriptionPhone: Създайте своя 2-фактор. Въведете телефонния си номер, за да го потвърдите. + DescriptionCode: Създайте своя 2-фактор. Въведете получения код, за да потвърдите своя телефонен номер. + PhoneLabel: Тайна + CodeLabel: Код + EditButtonText: редактиране + ResendButtonText: код за препращане + NextButtonText: следващия InitMFAU2F: Title: Добавете ключ за сигурност Description: >- @@ -118,6 +129,8 @@ InitMFADone: MFAProvider: Provider0: 'Приложение за удостоверяване (напр. Google/Microsoft Authenticator, Authy)' Provider1: 'Зависи от устройството (напр. FaceID, Windows Hello, пръстов отпечатък)' + Provider3: OTP SMS + Provider4: OTP имейл ChooseOther: или изберете друга опция VerifyMFAOTP: Title: Проверете 2-фактора diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index 490d9ebc0..eb39591ea 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: 2-Faktor-Authentifizierung gibt dir eine zusätzliche Sicherheit für dein Benutzerkonto. Damit stellst du sicher, dass nur du Zugriff auf deinen Account hast. Provider0: Authenticator App (e.g Google/Microsoft Authenticator, Authy) Provider1: Geräte abhängig (e.g FaceID, Windows Hello, Fingerprint) + Provider3: OTP SMS + Provider4: OTP Email NextButtonText: weiter SkipButtonText: überspringen @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: weiter CancelButtonText: abbrechen +InitMFAOTPSMS: + Title: 2-Faktor hinzufügen + DescriptionPhone: Erstelle deinen 2-Faktor. Gib deine Telefonnummer ein, um sie zu verifizieren. + DescriptionCode: Erstelle deinen 2-Faktor. Gib den erhaltenen Code ein um deinen Telefonnummer zu verifizieren. + PhoneLabel: Telefonnummer + CodeLabel: Code + EditButtonText: bearbeiten + ResendButtonText: Code erneut senden + NextButtonText: weiter + InitMFAU2F: Title: Sicherheitsschlüssel hinzufügen Description: Ein Sicherheitsschlüssel ist eine Verifizierungsmethode, die in Ihrem Telefon integriert sein kann, Bluetooth verwenden oder direkt an den USB-Anschluss Ihres Computers angeschlossen werden. @@ -118,9 +130,18 @@ InitMFADone: MFAProvider: Provider0: Authenticator App (e.g Google/Microsoft Authenticator, Authy) Provider1: Geräte abhängig (e.g FaceID, Windows Hello, Fingerprint) + Provider3: OTP SMS + Provider4: OTP Email ChooseOther: oder wähle eine andere Option aus VerifyMFAOTP: + Title: 2-Faktor verifizieren + Description: Verifiziere deinen Zweitfaktor + CodeLabel: Code + ResendButtonText: Code erneut senden + NextButtonText: next + +VerifyOTP: Title: 2-Faktor verifizieren Description: Verifiziere deinen Zweitfaktor CodeLabel: Code diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index 76c7e0ff0..b174ef74b 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: 2-factor authentication gives you an additional security for your user account. This ensures that only you have access to your account. Provider0: Authenticator App (e.g Google/Microsoft Authenticator, Authy) Provider1: Device dependent (e.g FaceID, Windows Hello, Fingerprint) + Provider3: OTP SMS + Provider4: OTP Email NextButtonText: next SkipButtonText: skip @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: next CancelButtonText: cancel +InitMFAOTPSMS: + Title: 2-Factor Verification + DescriptionPhone: Create your 2-factor. Enter your phone number to verify it. + DescriptionCode: Create your 2-factor. Enter the received code to verify your phone number. + PhoneLabel: Phone + CodeLabel: Code + EditButtonText: edit + ResendButtonText: resend code + NextButtonText: next + InitMFAU2F: Title: Add security key Description: A security key is a verification method that can be built into your phone, use Bluetooth, or plug directly into your computer's USB port. @@ -110,7 +122,7 @@ InitMFAU2F: ErrorRetry: Retry, create a new challenge or choose a different method. InitMFADone: - Title: Security key verified + Title: 2-factor verified Description: Awesome! You just successfully set up your 2-factor and made your account way more secure. The Factor has to be entered on each login. NextButtonText: next CancelButtonText: cancel @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: Authenticator App (e.g Google/Microsoft Authenticator, Authy) Provider1: Device dependent (e.g FaceID, Windows Hello, Fingerprint) + Provider3: OTP SMS + Provider4: OTP Email ChooseOther: or choose another option VerifyMFAOTP: @@ -126,6 +140,13 @@ VerifyMFAOTP: CodeLabel: Code NextButtonText: next +VerifyOTP: + Title: Verify 2-Factor + Description: Verify your second factor + CodeLabel: Code + ResendButtonText: resend code + NextButtonText: next + VerifyMFAU2F: Title: 2-Factor Verification Description: Verify your 2-Factor with the registered device (e.g FaceID, Windows Hello, Fingerprint) diff --git a/internal/api/ui/login/static/i18n/es.yaml b/internal/api/ui/login/static/i18n/es.yaml index ed98842db..3d41001a5 100644 --- a/internal/api/ui/login/static/i18n/es.yaml +++ b/internal/api/ui/login/static/i18n/es.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: La autenticación de doble factor te proporciona seguridad adicional para tu cuenta de usuario. Ésta asegura que solo tú tienes acceso a tu cuenta. Provider0: App autenticadora (p.e Google/Microsoft Authenticator, Authy) Provider1: Dependiente de un dispositivo (p.e FaceID, Windows Hello, Huella dactilar) + Provider3: OTP SMS + Provider4: OTP email NextButtonText: siguiente SkipButtonText: saltar @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: siguiente CancelButtonText: cancelar +InitMFASMS: + Title: Verificación de doble factor + DescriptionPhone: Crea tu doble factor de autenticación. Introduce tu número de teléfono para verificarlo. + DescriptionCode: Crea tu doble factor de autenticación. Ingrese el código recibido para verificar su número de teléfono. + PhoneLabel: Número de teléfono + CodeLabel: Código + EditButtonText: editar + ResendButtonText: reenviar código + NextButtonText: siguiente + InitMFAU2F: Title: Añadir clave de seguridad Description: Una clave de seguridad es un método de verificación que puede integrarse en tu teléfono móvil, con Bluetooth, o conectándolo directamente en el puerto USB de tu ordenador. @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: App autenticadora (p.e Google/Microsoft Authenticator, Authy) Provider1: Dependiente de un dispositivo (p.e FaceID, Windows Hello, Huella dactilar) + Provider3: OTP SMS + Provider4: OTP email ChooseOther: o elige otra opción VerifyMFAOTP: diff --git a/internal/api/ui/login/static/i18n/fr.yaml b/internal/api/ui/login/static/i18n/fr.yaml index 29db5b3e2..95a58d282 100644 --- a/internal/api/ui/login/static/i18n/fr.yaml +++ b/internal/api/ui/login/static/i18n/fr.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: L'authentification à deux facteurs vous offre une sécurité supplémentaire pour votre compte d'utilisateur. Vous êtes ainsi assuré d'être le seul à avoir accès à votre compte. Provider0: Application d'authentification (par exemple, Google/Microsoft Authenticator, Authy) Provider1: Dépend de l'appareil (par ex. FaceID, Windows Hello, empreinte digitale) + Provider3: OTP SMS + Provider4: OTP e-mail NextButtonText: Suivant SkipButtonText: Passer @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: Suivant CancelButtonText: Annuler +InitMFASMS: + Title: Vérification à deux facteurs + DescriptionPhone: Créez votre 2-facteurs. Entrez votre numéro de téléphone pour le vérifier. + DescriptionCode: Créez votre 2-facteurs. Entrez le code reçu pour vérifier votre numéro de téléphone. + PhoneLabel: Numéro de téléphone + CodeLabel: Code + EditButtonText: Modifier + ResendButtonText: Renvoyer le code + NextButtonText: Suivant + InitMFAU2F: Title: Ajouter une clé de sécurité Description: Une clé de sécurité est une méthode de vérification qui peut être intégrée à votre téléphone, utiliser Bluetooth ou se brancher directement sur le port USB de votre ordinateur. @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: Application d'authentification (par exemple, Google/Microsoft Authenticator, Authy) Provider1: Dépend de l'appareil (par ex. FaceID, Windows Hello, empreinte digitale) + Provider3: OTP SMS + Provider4: OTP e-mail ChooseOther: ou choisissez une autre option VerifyMFAOTP: diff --git a/internal/api/ui/login/static/i18n/it.yaml b/internal/api/ui/login/static/i18n/it.yaml index 5e998c2e5..3ed763f3e 100644 --- a/internal/api/ui/login/static/i18n/it.yaml +++ b/internal/api/ui/login/static/i18n/it.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: L'autenticazione a due fattori offre un'ulteriore sicurezza al vostro account utente. Questo garantisce che solo voi possiate accedere al vostro account. Provider0: App Autenticatore (ad esempio Google/Microsoft Authenticator, Authy) Provider1: Dipende dal dispositivo (ad es. FaceID, Windows Hello, impronta digitale) + Provider3: OTP SMS + Provider4: OTP e-mail NextButtonText: Avanti SkipButtonText: salta @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: Avanti CancelButtonText: annulla +InitMFASMS: + Title: Verificazione a 2 fattori + DescriptionPhone: Crea il tuo 2 fattori. Inserisci il tuo numero di telefono per verificarlo. + DescriptionCode: Crea il tuo 2 fattori. Inserisci il codice ricevuto per verificare il tuo numero di telefono. + PhoneLabel: Numero di telefono + CodeLabel: Codice + EditButtonText: Modifica + ResendButtonText: Reinvia codice + NextButtonText: Avanti + InitMFAU2F: Title: Aggiungi chiave di sicurezza Description: Una chiave di sicurezza è un metodo di verifica che può essere integrato nel telefono, utilizzare il Bluetooth o collegarlo direttamente alla porta USB del computer. @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: App Autenticatore (ad esempio Google/Microsoft Authenticator, Authy) Provider1: Dipende dal dispositivo (ad es. FaceID, Windows Hello, impronta digitale) + Provider3: OTP SMS + Provider4: OTP e-mail ChooseOther: o scegli un'altra opzione VerifyMFAOTP: diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index f51a1ca3d..c43975f54 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -82,6 +82,8 @@ InitMFAPrompt: Description: 二要素認証でアカウントのセキュリティを強化します。 Provider0: 認証アプリ(Google/Microsoft Authenticator、Authyなど) Provider1: デバイス依存(例:FaceID、Windows Hello、指紋など) + Provider3: OTP SMS + Provider4: OTPメール NextButtonText: 次へ SkipButtonText: スキップ @@ -94,6 +96,16 @@ InitMFAOTP: NextButtonText: 次へ CancelButtonText: キャンセル +InitMFASMS: + Title: 二要素認証 + DescriptionPhone: 二要素認証を作成します。確認するには電話番号を入力してください。 + DescriptionCode: 二要素認証を作成します。受信したコードを入力して電話番号を確認します。 + PhoneLabel: 電話番号 + CodeLabel: コード + EditButtonText: 編集 + ResendButtonText: コードを再送信 + NextButtonText: 次へ + InitMFAU2F: Title: セキュリティキーの追加 Description: セキュリティキーは、携帯電話への組み込みや、Bluetoothの使用、パソコンのUSBポートに直接差し込むことなどで認証する方法です。 @@ -111,6 +123,8 @@ InitMFADone: MFAProvider: Provider0: Authenticatorアプリ(Google/Microsoft Authenticator、Authyなど) Provider1: デバイス依存(FaceID、Windows Hello、指紋など) + Provider3: OTP SMS + Provider4: OTPメール ChooseOther: または、他のオプションを選択 VerifyMFAOTP: diff --git a/internal/api/ui/login/static/i18n/mk.yaml b/internal/api/ui/login/static/i18n/mk.yaml index 217ab7e2f..e0a8c20d8 100644 --- a/internal/api/ui/login/static/i18n/mk.yaml +++ b/internal/api/ui/login/static/i18n/mk.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: 2-факторската автентикација ви дава дополнителна безбедност за вашата корисничка сметка. Ова обезбедува само вие да имате пристап до вашата сметка. Provider0: Апликација за автентикација (на пример Google/Microsoft Authenticator, Authy) Provider1: Во зависност од вашиот уред (на пример FaceID, Windows Hello, отпечаток од прст) + Provider3: ОТП СМС + Provider4: ОТП е-пошта NextButtonText: следно SkipButtonText: прескокни @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: следно CancelButtonText: откажи +InitMFASMS: + Title: Потврда на 2-факторска автентикација + DescriptionPhone: Направете двофакторна автентикација. Внесете го вашиот телефонски број за да го потврдите. + DescriptionCode: Направете двофакторна автентикација. Внесете го примениот код за да го потврдите вашиот телефонски број. + PhoneLabel: Телефонски број + CodeLabel: Код + EditButtonText: Уредување + ResendButtonText: повторно испрати код + NextButtonText: следно + InitMFAU2F: Title: Додајте безбедносен клуч Description: Безбедносниот клуч е метод на верификација кој може да се интегрира во вашиот телефон, да користи Bluetooth или директно да се поврзе во USB приклучокот на вашиот компјутер. @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: Апликација за автентикација (на пример Google/Microsoft Authenticator, Authy) Provider1: Во зависност од вашиот уред (на пример FaceID, Windows Hello, отпечаток од прст) + Provider3: ОТП СМС + Provider4: ОТП е-пошта ChooseOther: или изберете друга опција VerifyMFAOTP: diff --git a/internal/api/ui/login/static/i18n/pl.yaml b/internal/api/ui/login/static/i18n/pl.yaml index 8561cec9e..38c4b2860 100644 --- a/internal/api/ui/login/static/i18n/pl.yaml +++ b/internal/api/ui/login/static/i18n/pl.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: 2-etapowe uwierzytelnianie daje Ci dodatkową ochronę dla Twojego konta użytkownika. Dzięki temu masz pewność, że tylko Ty masz dostęp do swojego konta. Provider0: Aplikacja uwierzytelniająca (np. Google/Microsoft Authenticator, Authy) Provider1: Zależny od urządzenia (np. FaceID, Windows Hello, Odcisk palca) + Provider3: OTP SMS + Provider4: OTP e-mail NextButtonText: dalej SkipButtonText: pomiń @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: dalej CancelButtonText: anuluj +InitMFASMS: + Title: Weryfikacja 2-etapowa + DescriptionPhone: Utwórz uwierzytelnianie dwuskładnikowe. Wprowadź swój numer telefonu, aby go zweryfikować. + DescriptionCode: Utwórz uwierzytelnianie dwuskładnikowe. Wprowadź otrzymany kod, aby zweryfikować swój numer telefonu. + PhoneLabel: Numer telefonu + CodeLabel: Kod + EditButtonText: edytować + ResendButtonText: wyślij kod ponownie + NextButtonText: dalej + InitMFAU2F: Title: Dodaj klucz zabezpieczeń Description: Klucz zabezpieczeń to metoda weryfikacji, która może być zintegrowana z twoim telefonem, używająca Bluetooth lub podłączana bezpośrednio do portu USB komputera. @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: Aplikacja uwierzytelniająca (np. Google/Microsoft Authenticator, Authy) Provider1: Zależny od urządzenia (np. FaceID, Windows Hello, Odcisk palca) + Provider3: OTP SMS + Provider4: OTP e-mail ChooseOther: lub wybierz inną opcję VerifyMFAOTP: diff --git a/internal/api/ui/login/static/i18n/pt.yaml b/internal/api/ui/login/static/i18n/pt.yaml index 42c6c27a0..54d65fb1e 100644 --- a/internal/api/ui/login/static/i18n/pt.yaml +++ b/internal/api/ui/login/static/i18n/pt.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: A autenticação de 2 fatores fornece uma segurança adicional para sua conta de usuário. Isso garante que apenas você tenha acesso à sua conta. Provider0: Aplicativo de autenticação (por exemplo, Google/Microsoft Authenticator, Authy) Provider1: Dependente do dispositivo (por exemplo, FaceID, Windows Hello, Impressão digital) + Provider3: OTP SMS + Provider4: OTP e-mail NextButtonText: próximo SkipButtonText: pular @@ -101,6 +103,16 @@ InitMFAOTP: NextButtonText: próximo CancelButtonText: cancelar +InitMFASMS: + Title: Verificação de 2 fatores + DescriptionPhone: Crie sua verificação de 2 fatores. Digite seu número de telefone para verificá-lo. + DescriptionCode: Crie sua verificação de 2 fatores. Digite o código recebido para verificar seu número de telefone. + PhoneLabel: Número de telefone + CodeLabel: Código + EditButtonText: editar + ResendButtonText: reenviar código + NextButtonText: próximo + InitMFAU2F: Title: Adicionar chave de segurança Description: Uma chave de segurança é um método de verificação que pode ser incorporado ao seu telefone, usar Bluetooth ou conectar diretamente à porta USB do seu computador. @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: Aplicativo de autenticação (por exemplo, Google/Microsoft Authenticator, Authy) Provider1: Dependente do dispositivo (por exemplo, FaceID, Windows Hello, Impressão digital) + Provider3: OTP SMS + Provider4: OTP e-mail ChooseOther: ou escolha outra opção VerifyMFAOTP: diff --git a/internal/api/ui/login/static/i18n/zh.yaml b/internal/api/ui/login/static/i18n/zh.yaml index 204e1a45a..e2c0f2e43 100644 --- a/internal/api/ui/login/static/i18n/zh.yaml +++ b/internal/api/ui/login/static/i18n/zh.yaml @@ -89,6 +89,8 @@ InitMFAPrompt: Description: 两步验证为您的账户提供了额外的安全保障。这确保只有你能访问你的账户。 Provider0: 软件应用(如 Google/Migrosoft Authenticator、Authy) Provider1: 硬件设备(如 Face ID、Windows Hello、指纹) + Provider3: 一次性密码短信 + Provider4: 一次性密码电子邮件 NextButtonText: 继续 SkipButtonText: 跳过 @@ -96,6 +98,16 @@ InitMFAOTP: Title: 双因素验证 Description: 创建你的双因素。如果你还没有,请下载一个认证器应用程序。 OTPDescription: 使用您的身份验证器应用程序(例如 Google Authenticator)扫描代码或复制密码并在下方插入生成的代码。 + PhoneLabel: 电话号码 + CodeLabel: 验证码 + EditButtonText: 编辑 + ResendButtonText: 重发代码 + NextButtonText: 继续 + +InitMFASMS: + Title: 双因素验证 + DescriptionPhone: 创建双因素身份验证。输入您的电话号码进行验证。 + DescriptionCode: 创建双因素身份验证。输入收到的代码以验证您的电话号码。 SecretLabel: 秘钥 CodeLabel: 验证码 NextButtonText: 继续 @@ -118,6 +130,8 @@ InitMFADone: MFAProvider: Provider0: 软件应用(如 Google/Migrosoft Authenticator、Authy) Provider1: 硬件设备(如 Face ID、Windows Hello、指纹) + Provider3: 一次性密码短信 + Provider4: 一次性密码电子邮件 ChooseOther: 或选择其他选项 VerifyMFAOTP: diff --git a/internal/api/ui/login/static/resources/scripts/edit.js b/internal/api/ui/login/static/resources/scripts/edit.js new file mode 100644 index 000000000..562e3effa --- /dev/null +++ b/internal/api/ui/login/static/resources/scripts/edit.js @@ -0,0 +1,6 @@ +let form = document.getElementsByTagName('form')[0]; +let editButton = document.getElementById('edit'); +editButton.addEventListener('click', function () { + form.submit(); +}); + diff --git a/internal/api/ui/login/static/resources/themes/scss/styles/mfa/mfa_base.scss b/internal/api/ui/login/static/resources/themes/scss/styles/mfa/mfa_base.scss index 040f6aaf5..baba57416 100644 --- a/internal/api/ui/login/static/resources/themes/scss/styles/mfa/mfa_base.scss +++ b/internal/api/ui/login/static/resources/themes/scss/styles/mfa/mfa_base.scss @@ -1,15 +1,16 @@ @mixin lgn-mfa-base { display: flex; flex-direction: row; + flex-wrap: wrap; justify-content: space-evenly; - margin: 2rem 0; + margin: 1rem 0; .mfa { display: flex; flex-direction: column; align-items: center; flex: 1; - padding: 0 0.5rem; + padding: 1rem 0.5rem; label { display: flex; diff --git a/internal/api/ui/login/static/templates/mfa_init_otp_sms.html b/internal/api/ui/login/static/templates/mfa_init_otp_sms.html new file mode 100644 index 000000000..e409096c5 --- /dev/null +++ b/internal/api/ui/login/static/templates/mfa_init_otp_sms.html @@ -0,0 +1,63 @@ +{{template "main-top" .}} + +
+

{{t "InitMFAOTPSMS.Title"}}

+ + {{ template "user-profile" . }} + + {{if .Edit}} +

{{t "InitMFAOTPSMS.DescriptionPhone"}}

+ {{else}} +

{{t "InitMFAOTPSMS.DescriptionCode"}}

+ {{end}} +
+ +
+ + {{ .CSRF }} + + + + + +
+ {{if .Edit}} +
+ + +
+ {{else}} +
+

{{.Phone}}

+ + +
+ {{end}} + {{if and .Phone (not .Edit) }} +
+ + +
+ {{end}} +
+ + {{ template "error-message" .}} + +
+ + + + + + {{if and .Phone (not .Edit) }} + + + {{end}} +
+
+ + + + + +{{template "main-bottom" .}} diff --git a/internal/api/ui/login/static/templates/mfa_prompt.html b/internal/api/ui/login/static/templates/mfa_prompt.html index f6f936648..17170eb0b 100644 --- a/internal/api/ui/login/static/templates/mfa_prompt.html +++ b/internal/api/ui/login/static/templates/mfa_prompt.html @@ -34,6 +34,16 @@ OTP + {{ end }} {{ if eq $provider 3 }} +
+ OTP SMS // TODO: image +
+ {{ end }}{{ if eq $provider 4 }} +
+ OTP Email // TODO: image +
{{ end }} {{ $providerName }} diff --git a/internal/api/ui/login/static/templates/mfa_verify_otp.html b/internal/api/ui/login/static/templates/mfa_verify_otp.html index 345391660..95046ba45 100644 --- a/internal/api/ui/login/static/templates/mfa_verify_otp.html +++ b/internal/api/ui/login/static/templates/mfa_verify_otp.html @@ -1,34 +1,37 @@ {{template "main-top" .}}
-

{{t "VerifyMFAOTP.Title"}}

+

{{t "VerifyOTP.Title"}}

{{ template "user-profile" . }} -

{{t "VerifyMFAOTP.Description"}}

+

{{t "VerifyOTP.Description"}}

-
+ {{ .CSRF }} - +
- - + +
{{ template "error-message" .}} -
+
+ + - + +
{{ if .MFAProviders }} @@ -45,4 +48,4 @@ -{{template "main-bottom" .}} \ No newline at end of file +{{template "main-bottom" .}} diff --git a/internal/api/ui/login/static/templates/mfa_verify_totp.html b/internal/api/ui/login/static/templates/mfa_verify_totp.html new file mode 100644 index 000000000..345391660 --- /dev/null +++ b/internal/api/ui/login/static/templates/mfa_verify_totp.html @@ -0,0 +1,48 @@ +{{template "main-top" .}} + +
+

{{t "VerifyMFAOTP.Title"}}

+ + {{ template "user-profile" . }} + +

{{t "VerifyMFAOTP.Description"}}

+
+ + + + {{ .CSRF }} + + + + +
+ + +
+ + {{ template "error-message" .}} + +
+ + + + + + +
+ + {{ if .MFAProviders }} +
+

{{t "MFAProvider.ChooseOther"}}

+ {{ range $provider := .MFAProviders}} + {{ $providerName := (t (printf "MFAProvider.Provider%v" $provider)) }} + + {{ end }} +
+ {{ end }} + + + + +{{template "main-bottom" .}} \ No newline at end of file diff --git a/internal/auth/repository/auth_request.go b/internal/auth/repository/auth_request.go index 8851b4f67..db2270505 100644 --- a/internal/auth/repository/auth_request.go +++ b/internal/auth/repository/auth_request.go @@ -23,6 +23,10 @@ type AuthRequestRepository interface { VerifyPassword(ctx context.Context, id, userID, resourceOwner, password, userAgentID string, info *domain.BrowserInfo) error VerifyMFAOTP(ctx context.Context, authRequestID, userID, resourceOwner, code, userAgentID string, info *domain.BrowserInfo) error + SendMFAOTPSMS(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) error + VerifyMFAOTPSMS(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) error + SendMFAOTPEmail(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) error + VerifyMFAOTPEmail(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) error BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (*domain.WebAuthNLogin, error) VerifyMFAU2F(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string, credentialData []byte, info *domain.BrowserInfo) error BeginPasswordlessSetup(ctx context.Context, userID, resourceOwner string, preferredPlatformType domain.AuthenticatorAttachment) (login *domain.WebAuthNToken, err error) diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 80be2a8e3..0bfa0fe82 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -376,6 +376,48 @@ func (repo *AuthRequestRepo) VerifyMFAOTP(ctx context.Context, authRequestID, us return repo.Command.HumanCheckMFATOTP(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) } +func (repo *AuthRequestRepo) SendMFAOTPSMS(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + request, err := repo.getAuthRequestEnsureUser(ctx, authRequestID, userAgentID, userID) + if err != nil { + return err + } + return repo.Command.HumanSendOTPSMS(ctx, userID, resourceOwner, request) +} + +func (repo *AuthRequestRepo) VerifyMFAOTPSMS(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + request, err := repo.getAuthRequestEnsureUser(ctx, authRequestID, userAgentID, userID) + if err != nil { + return err + } + return repo.Command.HumanCheckOTPSMS(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) +} + +func (repo *AuthRequestRepo) SendMFAOTPEmail(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + request, err := repo.getAuthRequestEnsureUser(ctx, authRequestID, userAgentID, userID) + if err != nil { + return err + } + return repo.Command.HumanSendOTPEmail(ctx, userID, resourceOwner, request) +} + +func (repo *AuthRequestRepo) VerifyMFAOTPEmail(ctx context.Context, userID, resourceOwner, code, authRequestID, userAgentID string, info *domain.BrowserInfo) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + request, err := repo.getAuthRequestEnsureUser(ctx, authRequestID, userAgentID, userID) + if err != nil { + return err + } + return repo.Command.HumanCheckOTPEmail(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) +} + func (repo *AuthRequestRepo) BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (login *domain.WebAuthNLogin, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/auth/repository/eventsourcing/handler/user.go b/internal/auth/repository/eventsourcing/handler/user.go index 847f483ea..4224a0ca6 100644 --- a/internal/auth/repository/eventsourcing/handler/user.go +++ b/internal/auth/repository/eventsourcing/handler/user.go @@ -138,6 +138,10 @@ func (u *User) ProcessUser(event *es_models.Event) (err error) { user_repo.HumanMFAOTPAddedType, user_repo.HumanMFAOTPVerifiedType, user_repo.HumanMFAOTPRemovedType, + user_repo.HumanOTPSMSAddedType, + user_repo.HumanOTPSMSRemovedType, + user_repo.HumanOTPEmailAddedType, + user_repo.HumanOTPEmailRemovedType, user_repo.HumanU2FTokenAddedType, user_repo.HumanU2FTokenVerifiedType, user_repo.HumanU2FTokenRemovedType, diff --git a/internal/command/user_converter.go b/internal/command/user_converter.go index 7febe6853..076b0bfd5 100644 --- a/internal/command/user_converter.go +++ b/internal/command/user_converter.go @@ -137,6 +137,9 @@ func writeModelToWebAuthN(wm *HumanWebAuthNWriteModel) *domain.WebAuthNToken { } func authRequestDomainToAuthRequestInfo(authRequest *domain.AuthRequest) *user.AuthRequestInfo { + if authRequest == nil { + return nil + } info := &user.AuthRequestInfo{ ID: authRequest.ID, UserAgentID: authRequest.AgentID, diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index a1f207b40..c3a8e84f0 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -2,6 +2,7 @@ package command import ( "context" + "time" "github.com/pquerna/otp" "github.com/zitadel/logging" @@ -15,16 +16,16 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func (c *Commands) ImportHumanTOTP(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 } - if err = c.checkUserExists(ctx, userID, resourceowner); err != nil { + if err = c.checkUserExists(ctx, userID, resourceOwner); err != nil { return err } - otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceowner) + otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceOwner) if err != nil { return err } @@ -40,11 +41,11 @@ func (c *Commands) ImportHumanTOTP(ctx context.Context, userID, userAgentID, res return err } -func (c *Commands) AddHumanTOTP(ctx context.Context, userID, resourceowner string) (*domain.TOTP, 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") } - prep, err := c.createHumanTOTP(ctx, userID, resourceowner) + prep, err := c.createHumanTOTP(ctx, userID, resourceOwner) if err != nil { return nil, err } @@ -114,12 +115,12 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st }, nil } -func (c *Commands) HumanCheckMFATOTPSetup(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.totpWriteModelByID(ctx, userID, resourceowner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceOwner) if err != nil { return nil, err } @@ -145,11 +146,11 @@ func (c *Commands) HumanCheckMFATOTPSetup(ctx context.Context, userID, code, use return writeModelToObjectDetails(&existingOTP.WriteModel), nil } -func (c *Commands) HumanCheckMFATOTP(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.totpWriteModelByID(ctx, userID, resourceowner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceOwner) if err != nil { return err } @@ -191,7 +192,26 @@ func (c *Commands) HumanRemoveTOTP(ctx context.Context, userID, resourceOwner st return writeModelToObjectDetails(&existingOTP.WriteModel), nil } +// AddHumanOTPSMS adds the OTP SMS factor to a user. +// It can only be added if it not already is and the phone has to be verified. func (c *Commands) AddHumanOTPSMS(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + return c.addHumanOTPSMS(ctx, userID, resourceOwner) +} + +// AddHumanOTPSMSWithCheckSucceeded adds the OTP SMS factor to a user. +// It can only be added if it's not already and the phone has to be verified. +// An OTPSMSCheckSucceededEvent will be added to the passed AuthRequest, if not nil. +func (c *Commands) AddHumanOTPSMSWithCheckSucceeded(ctx context.Context, userID, resourceOwner string, authRequest *domain.AuthRequest) (*domain.ObjectDetails, error) { + if authRequest == nil { + return c.addHumanOTPSMS(ctx, userID, resourceOwner) + } + event := func(ctx context.Context, userAgg *eventstore.Aggregate) eventstore.Command { + return user.NewHumanOTPSMSCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)) + } + return c.addHumanOTPSMS(ctx, userID, resourceOwner, event) +} + +func (c *Commands) addHumanOTPSMS(ctx context.Context, userID, resourceOwner string, events ...eventCallback) (*domain.ObjectDetails, error) { if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-QSF2s", "Errors.User.UserIDMissing") } @@ -209,7 +229,12 @@ func (c *Commands) AddHumanOTPSMS(ctx context.Context, userID, resourceOwner str 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 { + cmds := make([]eventstore.Command, len(events)+1) + cmds[0] = user.NewHumanOTPSMSAddedEvent(ctx, userAgg) + for i, event := range events { + cmds[i+1] = event(ctx, userAgg) + } + if err = c.pushAppendAndReduce(ctx, otpWriteModel, cmds...); err != nil { return nil, err } return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil @@ -225,7 +250,7 @@ func (c *Commands) RemoveHumanOTPSMS(ctx context.Context, userID, resourceOwner return nil, err } if userID != authz.GetCtxData(ctx).UserID { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.WriteModel.ResourceOwner, userID); err != nil { return nil, err } } @@ -239,7 +264,77 @@ func (c *Commands) RemoveHumanOTPSMS(ctx context.Context, userID, resourceOwner return writeModelToObjectDetails(&existingOTP.WriteModel), nil } +func (c *Commands) HumanSendOTPSMS(ctx context.Context, userID, resourceOwner string, authRequest *domain.AuthRequest) error { + smsWriteModel := func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error) { + return c.otpSMSWriteModelByID(ctx, userID, resourceOwner) + } + codeAddedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo) eventstore.Command { + return user.NewHumanOTPSMSCodeAddedEvent(ctx, aggregate, code, expiry, info) + } + return c.sendHumanOTP( + ctx, + userID, + resourceOwner, + authRequest, + smsWriteModel, + domain.SecretGeneratorTypeOTPSMS, + codeAddedEvent, + ) +} + +func (c *Commands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string) (err error) { + smsWriteModel := func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error) { + return c.otpSMSWriteModelByID(ctx, userID, resourceOwner) + } + codeSentEvent := func(ctx context.Context, aggregate *eventstore.Aggregate) eventstore.Command { + return user.NewHumanOTPSMSCodeSentEvent(ctx, aggregate) + } + return c.humanOTPSent(ctx, userID, resourceOwner, smsWriteModel, codeSentEvent) +} + +func (c *Commands) HumanCheckOTPSMS(ctx context.Context, userID, code, resourceOwner string, authRequest *domain.AuthRequest) error { + writeModel := func(ctx context.Context, userID string, resourceOwner string) (OTPCodeWriteModel, error) { + return c.otpSMSCodeWriteModelByID(ctx, userID, resourceOwner) + } + succeededEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command { + return user.NewHumanOTPSMSCheckSucceededEvent(ctx, aggregate, authRequestDomainToAuthRequestInfo(authRequest)) + } + failedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command { + return user.NewHumanOTPSMSCheckFailedEvent(ctx, aggregate, authRequestDomainToAuthRequestInfo(authRequest)) + } + return c.humanCheckOTP( + ctx, + userID, + code, + resourceOwner, + authRequest, + writeModel, + domain.SecretGeneratorTypeOTPSMS, + succeededEvent, + failedEvent, + ) +} + +// AddHumanOTPEmail adds the OTP Email factor to a user. +// It can only be added if it not already is and the phone has to be verified. func (c *Commands) AddHumanOTPEmail(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + return c.addHumanOTPEmail(ctx, userID, resourceOwner) +} + +// AddHumanOTPEmailWithCheckSucceeded adds the OTP Email factor to a user. +// It can only be added if it's not already and the email has to be verified. +// An OTPEmailCheckSucceededEvent will be added to the passed AuthRequest, if not nil. +func (c *Commands) AddHumanOTPEmailWithCheckSucceeded(ctx context.Context, userID, resourceOwner string, authRequest *domain.AuthRequest) (*domain.ObjectDetails, error) { + if authRequest == nil { + return c.addHumanOTPEmail(ctx, userID, resourceOwner) + } + event := func(ctx context.Context, userAgg *eventstore.Aggregate) eventstore.Command { + return user.NewHumanOTPEmailCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)) + } + return c.addHumanOTPEmail(ctx, userID, resourceOwner, event) +} + +func (c *Commands) addHumanOTPEmail(ctx context.Context, userID, resourceOwner string, events ...eventCallback) (*domain.ObjectDetails, error) { if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sg1hz", "Errors.User.UserIDMissing") } @@ -254,7 +349,12 @@ func (c *Commands) AddHumanOTPEmail(ctx context.Context, userID, resourceOwner s 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 { + cmds := make([]eventstore.Command, len(events)+1) + cmds[0] = user.NewHumanOTPEmailAddedEvent(ctx, userAgg) + for i, event := range events { + cmds[i+1] = event(ctx, userAgg) + } + if err = c.pushAppendAndReduce(ctx, otpWriteModel, cmds...); err != nil { return nil, err } return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil @@ -270,7 +370,7 @@ func (c *Commands) RemoveHumanOTPEmail(ctx context.Context, userID, resourceOwne return nil, err } if userID != authz.GetCtxData(ctx).UserID { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.WriteModel.ResourceOwner, userID); err != nil { return nil, err } } @@ -284,6 +384,147 @@ func (c *Commands) RemoveHumanOTPEmail(ctx context.Context, userID, resourceOwne return writeModelToObjectDetails(&existingOTP.WriteModel), nil } +func (c *Commands) HumanSendOTPEmail(ctx context.Context, userID, resourceOwner string, authRequest *domain.AuthRequest) error { + smsWriteModel := func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error) { + return c.otpEmailWriteModelByID(ctx, userID, resourceOwner) + } + codeAddedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo) eventstore.Command { + return user.NewHumanOTPEmailCodeAddedEvent(ctx, aggregate, code, expiry, info) + } + return c.sendHumanOTP( + ctx, + userID, + resourceOwner, + authRequest, + smsWriteModel, + domain.SecretGeneratorTypeOTPEmail, + codeAddedEvent, + ) +} + +func (c *Commands) HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) (err error) { + smsWriteModel := func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error) { + return c.otpEmailWriteModelByID(ctx, userID, resourceOwner) + } + codeSentEvent := func(ctx context.Context, aggregate *eventstore.Aggregate) eventstore.Command { + return user.NewHumanOTPEmailCodeSentEvent(ctx, aggregate) + } + return c.humanOTPSent(ctx, userID, resourceOwner, smsWriteModel, codeSentEvent) +} + +func (c *Commands) HumanCheckOTPEmail(ctx context.Context, userID, code, resourceOwner string, authRequest *domain.AuthRequest) error { + writeModel := func(ctx context.Context, userID string, resourceOwner string) (OTPCodeWriteModel, error) { + return c.otpEmailCodeWriteModelByID(ctx, userID, resourceOwner) + } + succeededEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command { + return user.NewHumanOTPEmailCheckSucceededEvent(ctx, aggregate, authRequestDomainToAuthRequestInfo(authRequest)) + } + failedEvent := func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command { + return user.NewHumanOTPEmailCheckFailedEvent(ctx, aggregate, authRequestDomainToAuthRequestInfo(authRequest)) + } + return c.humanCheckOTP( + ctx, + userID, + code, + resourceOwner, + authRequest, + writeModel, + domain.SecretGeneratorTypeOTPEmail, + succeededEvent, + failedEvent, + ) +} + +// sendHumanOTP creates a code for a registered mechanism (sms / email), which is used for a check (during login) +func (c *Commands) sendHumanOTP( + ctx context.Context, + userID, resourceOwner string, + authRequest *domain.AuthRequest, + writeModelByID func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error), + secretGeneratorType domain.SecretGeneratorType, + codeAddedEvent func(ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, info *user.AuthRequestInfo) eventstore.Command, +) (err error) { + if userID == "" { + return caos_errs.ThrowInvalidArgument(nil, "COMMAND-S3SF1", "Errors.User.UserIDMissing") + } + existingOTP, err := writeModelByID(ctx, userID, resourceOwner) + if err != nil { + return err + } + if !existingOTP.OTPAdded() { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SFD52", "Errors.User.MFA.OTP.NotReady") + } + config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, secretGeneratorType) + if err != nil { + return err + } + gen := crypto.NewEncryptionGenerator(*config, c.userEncryption) + value, _, err := crypto.NewCode(gen) + if err != nil { + return err + } + userAgg := &user.NewAggregate(userID, resourceOwner).Aggregate + _, err = c.eventstore.Push(ctx, codeAddedEvent(ctx, userAgg, value, gen.Expiry(), authRequestDomainToAuthRequestInfo(authRequest))) + return err +} + +func (c *Commands) humanOTPSent( + ctx context.Context, + userID, resourceOwner string, + writeModelByID func(ctx context.Context, userID string, resourceOwner string) (OTPWriteModel, error), + codeSentEvent func(ctx context.Context, aggregate *eventstore.Aggregate) eventstore.Command, +) (err error) { + if userID == "" { + return caos_errs.ThrowInvalidArgument(nil, "COMMAND-AE2h2", "Errors.User.UserIDMissing") + } + existingOTP, err := writeModelByID(ctx, userID, resourceOwner) + if err != nil { + return err + } + if !existingOTP.OTPAdded() { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SD3gh", "Errors.User.MFA.OTP.NotReady") + } + userAgg := &user.NewAggregate(userID, resourceOwner).Aggregate + _, err = c.eventstore.Push(ctx, codeSentEvent(ctx, userAgg)) + return err +} + +func (c *Commands) humanCheckOTP( + ctx context.Context, + userID, code, resourceOwner string, + authRequest *domain.AuthRequest, + writeModelByID func(ctx context.Context, userID string, resourceOwner string) (OTPCodeWriteModel, error), + secretGeneratorType domain.SecretGeneratorType, + checkSucceededEvent func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command, + checkFailedEvent func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command, +) error { + if userID == "" { + return caos_errs.ThrowInvalidArgument(nil, "COMMAND-S453v", "Errors.User.UserIDMissing") + } + if code == "" { + return caos_errs.ThrowInvalidArgument(nil, "COMMAND-SJl2g", "Errors.User.Code.Empty") + } + existingOTP, err := writeModelByID(ctx, userID, resourceOwner) + if err != nil { + return err + } + if !existingOTP.OTPAdded() { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-d2r52", "Errors.User.MFA.OTP.NotReady") + } + if existingOTP.Code() == nil { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-S34gh", "Errors.User.Code.NotFound") + } + userAgg := &user.NewAggregate(userID, existingOTP.ResourceOwner()).Aggregate + err = crypto.VerifyCodeWithAlgorithm(existingOTP.CodeCreationDate(), existingOTP.CodeExpiry(), existingOTP.Code(), code, c.userEncryption) + if err == nil { + _, err = c.eventstore.Push(ctx, checkSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) + return err + } + _, pushErr := c.eventstore.Push(ctx, checkFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) + logging.WithFields("userID", userID).OnError(pushErr).Error("otp failure check push failed") + return err +} + func (c *Commands) totpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanTOTPWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -308,6 +549,18 @@ func (c *Commands) otpSMSWriteModelByID(ctx context.Context, userID, resourceOwn return writeModel, nil } +func (c *Commands) otpSMSCodeWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPSMSCodeWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewHumanOTPSMSCodeWriteModel(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) }() @@ -319,3 +572,15 @@ func (c *Commands) otpEmailWriteModelByID(ctx context.Context, userID, resourceO } return writeModel, nil } + +func (c *Commands) otpEmailCodeWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPEmailCodeWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewHumanOTPEmailCodeWriteModel(userID, resourceOwner) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/user_human_otp_model.go b/internal/command/user_human_otp_model.go index 9c9b8d2c4..25bd4c9ef 100644 --- a/internal/command/user_human_otp_model.go +++ b/internal/command/user_human_otp_model.go @@ -1,6 +1,8 @@ package command import ( + "time" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -60,6 +62,18 @@ func (wm *HumanTOTPWriteModel) Query() *eventstore.SearchQueryBuilder { return query } +type OTPWriteModel interface { + OTPAdded() bool + ResourceOwner() string +} + +type OTPCodeWriteModel interface { + OTPWriteModel + CodeCreationDate() time.Time + CodeExpiry() time.Duration + Code() *crypto.CryptoValue +} + type HumanOTPSMSWriteModel struct { eventstore.WriteModel @@ -67,6 +81,14 @@ type HumanOTPSMSWriteModel struct { otpAdded bool } +func (wm *HumanOTPSMSWriteModel) OTPAdded() bool { + return wm.otpAdded +} + +func (wm *HumanOTPSMSWriteModel) ResourceOwner() string { + return wm.WriteModel.ResourceOwner +} + func NewHumanOTPSMSWriteModel(userID, resourceOwner string) *HumanOTPSMSWriteModel { return &HumanOTPSMSWriteModel{ WriteModel: eventstore.WriteModel{ @@ -107,8 +129,66 @@ func (wm *HumanOTPSMSWriteModel) Query() *eventstore.SearchQueryBuilder { ). Builder() - if wm.ResourceOwner != "" { - query.ResourceOwner(wm.ResourceOwner) + if wm.WriteModel.ResourceOwner != "" { + query.ResourceOwner(wm.WriteModel.ResourceOwner) + } + return query +} + +type HumanOTPSMSCodeWriteModel struct { + *HumanOTPSMSWriteModel + + code *crypto.CryptoValue + codeCreationDate time.Time + codeExpiry time.Duration +} + +func (wm *HumanOTPSMSCodeWriteModel) CodeCreationDate() time.Time { + return wm.codeCreationDate +} + +func (wm *HumanOTPSMSCodeWriteModel) CodeExpiry() time.Duration { + return wm.codeExpiry +} + +func (wm *HumanOTPSMSCodeWriteModel) Code() *crypto.CryptoValue { + return wm.code +} + +func NewHumanOTPSMSCodeWriteModel(userID, resourceOwner string) *HumanOTPSMSCodeWriteModel { + return &HumanOTPSMSCodeWriteModel{ + HumanOTPSMSWriteModel: NewHumanOTPSMSWriteModel(userID, resourceOwner), + } +} + +func (wm *HumanOTPSMSCodeWriteModel) Reduce() error { + for _, event := range wm.Events { + if e, ok := event.(*user.HumanOTPSMSCodeAddedEvent); ok { + wm.code = e.Code + wm.codeCreationDate = e.CreationDate() + wm.codeExpiry = e.Expiry + } + } + return wm.HumanOTPSMSWriteModel.Reduce() +} + +func (wm *HumanOTPSMSCodeWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + user.HumanOTPSMSCodeAddedType, + user.HumanPhoneVerifiedType, + user.HumanOTPSMSAddedType, + user.HumanOTPSMSRemovedType, + user.HumanPhoneRemovedType, + user.UserRemovedType, + ). + Builder() + + if wm.WriteModel.ResourceOwner != "" { + query.ResourceOwner(wm.WriteModel.ResourceOwner) } return query } @@ -120,6 +200,14 @@ type HumanOTPEmailWriteModel struct { otpAdded bool } +func (wm *HumanOTPEmailWriteModel) OTPAdded() bool { + return wm.otpAdded +} + +func (wm *HumanOTPEmailWriteModel) ResourceOwner() string { + return wm.WriteModel.ResourceOwner +} + func NewHumanOTPEmailWriteModel(userID, resourceOwner string) *HumanOTPEmailWriteModel { return &HumanOTPEmailWriteModel{ WriteModel: eventstore.WriteModel{ @@ -151,15 +239,73 @@ func (wm *HumanOTPEmailWriteModel) Query() *eventstore.SearchQueryBuilder { AddQuery(). AggregateTypes(user.AggregateType). AggregateIDs(wm.AggregateID). - EventTypes(user.HumanEmailVerifiedType, + EventTypes( + user.HumanEmailVerifiedType, user.HumanOTPEmailAddedType, user.HumanOTPEmailRemovedType, user.UserRemovedType, ). Builder() - if wm.ResourceOwner != "" { - query.ResourceOwner(wm.ResourceOwner) + if wm.WriteModel.ResourceOwner != "" { + query.ResourceOwner(wm.WriteModel.ResourceOwner) + } + return query +} + +type HumanOTPEmailCodeWriteModel struct { + *HumanOTPEmailWriteModel + + code *crypto.CryptoValue + codeCreationDate time.Time + codeExpiry time.Duration +} + +func (wm *HumanOTPEmailCodeWriteModel) CodeCreationDate() time.Time { + return wm.codeCreationDate +} + +func (wm *HumanOTPEmailCodeWriteModel) CodeExpiry() time.Duration { + return wm.codeExpiry +} + +func (wm *HumanOTPEmailCodeWriteModel) Code() *crypto.CryptoValue { + return wm.code +} + +func NewHumanOTPEmailCodeWriteModel(userID, resourceOwner string) *HumanOTPEmailCodeWriteModel { + return &HumanOTPEmailCodeWriteModel{ + HumanOTPEmailWriteModel: NewHumanOTPEmailWriteModel(userID, resourceOwner), + } +} + +func (wm *HumanOTPEmailCodeWriteModel) Reduce() error { + for _, event := range wm.Events { + if e, ok := event.(*user.HumanOTPEmailCodeAddedEvent); ok { + wm.code = e.Code + wm.codeCreationDate = e.CreationDate() + wm.codeExpiry = e.Expiry + } + } + return wm.HumanOTPEmailWriteModel.Reduce() +} + +func (wm *HumanOTPEmailCodeWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + user.HumanOTPEmailCodeAddedType, + user.HumanEmailVerifiedType, + user.HumanOTPEmailAddedType, + user.HumanOTPEmailRemovedType, + user.UserRemovedType, + ). + Builder() + + if wm.WriteModel.ResourceOwner != "" { + query.ResourceOwner(wm.WriteModel.ResourceOwner) } return query } diff --git a/internal/command/user_human_otp_test.go b/internal/command/user_human_otp_test.go index 28e825de4..5ec52582a 100644 --- a/internal/command/user_human_otp_test.go +++ b/internal/command/user_human_otp_test.go @@ -3,6 +3,7 @@ package command import ( "context" "io" + "net" "testing" "time" @@ -18,6 +19,7 @@ import ( 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/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/user" ) @@ -989,6 +991,143 @@ func TestCommandSide_AddHumanOTPSMS(t *testing.T) { } } +func TestCommandSide_AddHumanOTPSMSWithCheckSucceeded(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 + authRequest *domain.AuthRequest + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + 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", + }, + }, + }, + { + name: "successful add with auth request", + 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, + ), + ), + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSCheckSucceededEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + 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.AddHumanOTPSMSWithCheckSucceeded(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest) + 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 { @@ -1108,6 +1247,551 @@ func TestCommandSide_RemoveHumanOTPSMS(t *testing.T) { } } +func TestCommandSide_HumanSendOTPSMS(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + authRequest *domain.AuthRequest + } + ) + 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-S3SF1", "Errors.User.UserIDMissing"), + }, + }, + { + name: "otp sms not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SFD52", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + domain.SecretGeneratorTypeOTPSMS, + 8, + time.Hour, + true, + true, + true, + true, + )), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, + time.Hour, + nil, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "successful add with auth request", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + domain.SecretGeneratorTypeOTPSMS, + 8, + time.Hour, + true, + true, + true, + true, + )), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, + time.Hour, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + 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), + userEncryption: tt.fields.userEncryption, + } + err := r.HumanSendOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommandSide_HumanOTPSMSCodeSent(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-AE2h2", "Errors.User.UserIDMissing"), + }, + }, + { + name: "otp sms not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SD3gh", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSCodeSentEvent(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), + } + err := r.HumanOTPSMSCodeSent(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + } + type ( + args struct { + ctx context.Context + userID string + code string + resourceOwner string + authRequest *domain.AuthRequest + } + ) + 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: "", + code: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S453v", "Errors.User.UserIDMissing"), + }, + }, + { + name: "code missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SJl2g", "Errors.User.Code.Empty"), + }, + }, + { + name: "otp sms not added, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-d2r52", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "otp sms code not added, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-S34gh", "Errors.User.Code.NotFound"), + }, + }, + { + name: "invalid code, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanOTPSMSCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("other-code"), + }, + time.Hour, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSCheckFailedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid"), + }, + }, + { + name: "code ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanOTPSMSCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSCheckSucceededEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + 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), + userEncryption: tt.fields.userEncryption, + } + err := r.HumanCheckOTPSMS(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + func TestCommandSide_AddHumanOTPEmail(t *testing.T) { ctx := authz.NewMockContext("inst1", "org1", "user1") type fields struct { @@ -1234,6 +1918,143 @@ func TestCommandSide_AddHumanOTPEmail(t *testing.T) { } } +func TestCommandSide_AddHumanOTPEmailWithCheckSucceeded(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 + authRequest *domain.AuthRequest + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + 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", + }, + }, + }, + { + name: "successful add with auth request", + 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, + ), + ), + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailCheckSucceededEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + 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.AddHumanOTPEmailWithCheckSucceeded(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest) + 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 { @@ -1352,3 +2173,548 @@ func TestCommandSide_RemoveHumanOTPEmail(t *testing.T) { }) } } + +func TestCommandSide_HumanSendOTPEmail(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + authRequest *domain.AuthRequest + } + ) + 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-S3SF1", "Errors.User.UserIDMissing"), + }, + }, + { + name: "otp email not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SFD52", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + domain.SecretGeneratorTypeOTPEmail, + 8, + time.Hour, + true, + true, + true, + true, + )), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, + time.Hour, + nil, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "successful add with auth request", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + domain.SecretGeneratorTypeOTPEmail, + 8, + time.Hour, + true, + true, + true, + true, + )), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, + time.Hour, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + 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), + userEncryption: tt.fields.userEncryption, + } + err := r.HumanSendOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommandSide_HumanOTPEmailCodeSent(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-AE2h2", "Errors.User.UserIDMissing"), + }, + }, + { + name: "otp email not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SD3gh", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailCodeSentEvent(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), + } + err := r.HumanOTPEmailCodeSent(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + } + type ( + args struct { + ctx context.Context + userID string + code string + resourceOwner string + authRequest *domain.AuthRequest + } + ) + 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: "", + code: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S453v", "Errors.User.UserIDMissing"), + }, + }, + { + name: "code missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SJl2g", "Errors.User.Code.Empty"), + }, + }, + { + name: "otp email not added, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-d2r52", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "otp email code not added, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-S34gh", "Errors.User.Code.NotFound"), + }, + }, + { + name: "invalid code, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanOTPEmailCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("other-code"), + }, + time.Hour, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailCheckFailedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid"), + }, + }, + { + name: "code ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanOTPEmailCodeAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailCheckSucceededEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "authRequestID", + UserAgentID: "userAgentID", + BrowserInfo: &user.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + ), + ), + }, + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: ctx, + userID: "user1", + code: "code", + resourceOwner: "org1", + authRequest: &domain.AuthRequest{ + ID: "authRequestID", + AgentID: "userAgentID", + BrowserInfo: &domain.BrowserInfo{ + UserAgent: "user-agent", + AcceptLanguage: "en", + RemoteIP: net.IP{192, 0, 2, 1}, + }, + }, + }, + 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), + userEncryption: tt.fields.userEncryption, + } + err := r.HumanCheckOTPEmail(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} diff --git a/internal/command/user_human_phone.go b/internal/command/user_human_phone.go index 525333957..43395acd1 100644 --- a/internal/command/user_human_phone.go +++ b/internal/command/user_human_phone.go @@ -97,7 +97,7 @@ func (c *Commands) VerifyHumanPhone(ctx context.Context, userID, code, resourceo return nil, caos_errs.ThrowInvalidArgument(err, "COMMAND-sM0cs", "Errors.User.Code.Invalid") } -func (c *Commands) CreateHumanPhoneVerificationCode(ctx context.Context, userID, resourceowner string, phoneCodeGenerator crypto.Generator) (*domain.ObjectDetails, error) { +func (c *Commands) CreateHumanPhoneVerificationCode(ctx context.Context, userID, resourceowner string) (*domain.ObjectDetails, error) { if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-4M0ds", "Errors.User.UserIDMissing") } @@ -116,19 +116,17 @@ func (c *Commands) CreateHumanPhoneVerificationCode(ctx context.Context, userID, if existingPhone.IsPhoneVerified { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9sf", "Errors.User.Phone.AlreadyVerified") } - - phoneCode, err := domain.NewPhoneCode(phoneCodeGenerator) + config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode) + if err != nil { + return nil, err + } + phoneCode, err := domain.NewPhoneCode(crypto.NewEncryptionGenerator(*config, c.userEncryption)) if err != nil { return nil, err } userAgg := UserAggregateFromWriteModel(&existingPhone.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.Code, phoneCode.Expiry)) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingPhone, pushedEvents...) - if err != nil { + if err = c.pushAppendAndReduce(ctx, existingPhone, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.Code, phoneCode.Expiry)); err != nil { return nil, err } return writeModelToObjectDetails(&existingPhone.WriteModel), nil diff --git a/internal/command/user_human_phone_test.go b/internal/command/user_human_phone_test.go index 98ee9da7c..8a2b426cb 100644 --- a/internal/command/user_human_phone_test.go +++ b/internal/command/user_human_phone_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "golang.org/x/text/language" @@ -14,6 +15,7 @@ import ( "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" ) @@ -584,13 +586,13 @@ func TestCommandSide_VerifyHumanPhone(t *testing.T) { func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm } type args struct { - ctx context.Context - userID string - resourceOwner string - secretGenerator crypto.Generator + ctx context.Context + userID string + resourceOwner string } type res struct { want *domain.ObjectDetails @@ -704,6 +706,19 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + domain.SecretGeneratorTypeVerifyPhoneCode, + 8, + time.Hour, + true, + true, + true, + true, + )), + ), expectPush( []*repository.Event{ eventFromEventPusher( @@ -713,7 +728,7 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "id", - Crypted: []byte("a"), + Crypted: []byte("12345678"), }, time.Hour*1, ), @@ -721,12 +736,12 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(t *testing.T) { }, ), ), + userEncryption: crypto.CreateMockEncryptionAlgWithCode(gomock.NewController(t), "12345678"), }, args: args{ - ctx: context.Background(), - userID: "user1", - resourceOwner: "org1", - secretGenerator: GetMockSecretGenerator(t), + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", }, res: res{ want: &domain.ObjectDetails{ @@ -738,9 +753,10 @@ func TestCommandSide_CreateVerificationCodeHumanPhone(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, + userEncryption: tt.fields.userEncryption, } - got, err := r.CreateHumanPhoneVerificationCode(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.secretGenerator) + got, err := r.CreateHumanPhoneVerificationCode(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/crypto/code_mocker.go b/internal/crypto/code_mocker.go index 181ec771d..b29940181 100644 --- a/internal/crypto/code_mocker.go +++ b/internal/crypto/code_mocker.go @@ -9,14 +9,37 @@ import ( ) func CreateMockEncryptionAlg(ctrl *gomock.Controller) EncryptionAlgorithm { + return createMockEncryptionAlgorithm( + ctrl, + func(code []byte) ([]byte, error) { + return code, nil + }, + ) +} + +// CreateMockEncryptionAlgWithCode compares the length of the value to be encrypted with the length of the provided code. +// It will return an error if they do not match. +// The provided code will be used to encrypt in favor of the value passed to the encryption. +// This function is intended to be used where the passed value is not in control, but where the returned encryption requires a static value. +func CreateMockEncryptionAlgWithCode(ctrl *gomock.Controller, code string) EncryptionAlgorithm { + return createMockEncryptionAlgorithm( + ctrl, + func(c []byte) ([]byte, error) { + if len(c) != len(code) { + return nil, errors.ThrowInvalidArgumentf(nil, "id", "invalid code length - expected %d, got %d", len(code), len(c)) + } + return []byte(code), nil + }, + ) +} + +func createMockEncryptionAlgorithm(ctrl *gomock.Controller, encryptFunction func(c []byte) ([]byte, error)) *MockEncryptionAlgorithm { mCrypto := NewMockEncryptionAlgorithm(ctrl) mCrypto.EXPECT().Algorithm().AnyTimes().Return("enc") mCrypto.EXPECT().EncryptionKeyID().AnyTimes().Return("id") mCrypto.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{"id"}) mCrypto.EXPECT().Encrypt(gomock.Any()).AnyTimes().DoAndReturn( - func(code []byte) ([]byte, error) { - return code, nil - }, + encryptFunction, ) mCrypto.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( func(code []byte, keyID string) (string, error) { diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index f7f4da19f..d5b1bf4fe 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -105,6 +105,8 @@ const ( MFATypeTOTP MFAType = iota MFATypeU2F MFATypeU2FUserVerification + MFATypeOTPSMS + MFATypeOTPEmail ) type MFALevel int diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 64661d89b..d5ad60b46 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" @@ -106,6 +107,14 @@ func (u *userNotifier) reducers() []handler.AggregateReducer { Event: user.HumanPasswordChangedType, Reduce: u.reducePasswordChanged, }, + { + Event: user.HumanOTPSMSCodeAddedType, + Reduce: u.reduceOTPSMSCodeAdded, + }, + { + Event: user.HumanOTPEmailCodeAddedType, + Reduce: u.reduceOTPEmailCodeAdded, + }, }, }, } @@ -332,6 +341,136 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler return crdb.NewNoOpStatement(e), nil } +func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanOTPSMSCodeAddedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType) + } + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.HumanOTPSMSCodeAddedType, user.HumanOTPSMSCodeSentType) + if err != nil { + return nil, err + } + if alreadyHandled { + return crdb.NewNoOpStatement(e), nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return nil, err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return nil, err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID, false) + if err != nil { + return nil, err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifySMSOTPMessageType) + if err != nil { + return nil, err + } + + ctx, origin, err := u.queries.Origin(ctx) + if err != nil { + return nil, err + } + notify := types.SendSMSTwilio( + ctx, + translator, + notifyUser, + u.queries.GetTwilioConfig, + u.queries.GetFileSystemProvider, + u.queries.GetLogProvider, + colors, + u.assetsPrefix(ctx), + e, + u.metricSuccessfulDeliveriesSMS, + u.metricFailedDeliveriesSMS, + ) + err = notify.SendOTPSMSCode(authz.GetInstance(ctx).RequestedDomain(), origin, code, e.Expiry) + if err != nil { + return nil, err + } + err = u.commands.HumanOTPSMSCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + if err != nil { + return nil, err + } + return crdb.NewNoOpStatement(e), nil +} + +func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanOTPEmailCodeAddedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType) + } + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.HumanOTPEmailCodeAddedType, user.HumanOTPEmailCodeSentType) + if err != nil { + return nil, err + } + if alreadyHandled { + return crdb.NewNoOpStatement(e), nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return nil, err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return nil, err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return nil, err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID, false) + if err != nil { + return nil, err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyEmailOTPMessageType) + if err != nil { + return nil, err + } + + ctx, origin, err := u.queries.Origin(ctx) + if err != nil { + return nil, err + } + var authRequestID string + if e.AuthRequestInfo != nil { + authRequestID = e.AuthRequestInfo.ID + } + notify := types.SendEmail( + ctx, + string(template.Template), + translator, + notifyUser, + u.queries.GetSMTPConfig, + u.queries.GetFileSystemProvider, + u.queries.GetLogProvider, + colors, + u.assetsPrefix(ctx), + e, + u.metricSuccessfulDeliveriesEmail, + u.metricFailedDeliveriesEmail, + ) + err = notify.SendOTPEmailCode(notifyUser, authz.GetInstance(ctx).RequestedDomain(), origin, code, authRequestID, e.Expiry) + if err != nil { + return nil, err + } + err = u.commands.HumanOTPEmailCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + if err != nil { + return nil, err + } + return crdb.NewNoOpStatement(e), nil +} + func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*user.DomainClaimedEvent) if !ok { @@ -583,7 +722,7 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St e, u.metricSuccessfulDeliveriesSMS, u.metricFailedDeliveriesSMS, - ).SendPhoneVerificationCode(notifyUser, origin, code) + ).SendPhoneVerificationCode(notifyUser, origin, code, authz.GetInstance(ctx).RequestedDomain()) if err != nil { return nil, err } diff --git a/internal/notification/static/i18n/bg.yaml b/internal/notification/static/i18n/bg.yaml index 127163e38..7f67c24df 100644 --- a/internal/notification/static/i18n/bg.yaml +++ b/internal/notification/static/i18n/bg.yaml @@ -40,7 +40,10 @@ VerifyEmailOTP: Text: Моля, използвай бутона 'Удостовери' или копирай временната парола {{.OTP}} и я постави на екрана за удостоверяване, за да се удостовериш в ZITADEL в рамките на следващите пет минути. ButtonText: Удостовери VerifySMSOTP: - Text: Моля, посети {{ .VerifyURL }} или копирай временната парола {{.OTP}} и я постави на екрана за удостоверяване, за да се удостовериш в ZITADEL в рамките на следващите пет минути. + Text: >- + {{.OTP}} е вашата еднократна парола за {{ .Domain }}. Използвайте го в рамките на следващия {{.Expiry}}. + + @{{.Domain}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Домейнът е заявен PreHeader: Промяна на имейл/потребителско име diff --git a/internal/notification/static/i18n/de.yaml b/internal/notification/static/i18n/de.yaml index c882f2b79..bf3fe133c 100644 --- a/internal/notification/static/i18n/de.yaml +++ b/internal/notification/static/i18n/de.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: Bitte nutze den 'Authentifizieren'-Button oder kopiere das Einmalpasswort {{.OTP}} und füge es in den Authentifizierungsbildschirm ein, um dich innerhalb der nächsten fünf Minuten bei ZITADEL zu authentifizieren. ButtonText: Authentifizieren VerifySMSOTP: - Text: Bitte besuche {{ .VerifyURL }} oder kopiere das Einmalpasswort {{.OTP}} und füge es in den Authentifizierungsbildschirm ein, um dich innerhalb der nächsten fünf Minuten bei ZITADEL zu authentifizieren. + Text: >- + {{.OTP}} ist Ihr Einmalpasswort für {{ .Domain }}. Verwenden Sie es innerhalb der nächsten {{.Expiry}}. + + @{{.Domain}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Domain wurde beansprucht PreHeader: Email / Username ändern diff --git a/internal/notification/static/i18n/en.yaml b/internal/notification/static/i18n/en.yaml index bb2a841ad..c6257188e 100644 --- a/internal/notification/static/i18n/en.yaml +++ b/internal/notification/static/i18n/en.yaml @@ -31,10 +31,13 @@ VerifyEmailOTP: PreHeader: Verify One-Time Password Subject: Verify One-Time Password Greeting: Hello {{.DisplayName}}, - Text: Please use the "Authenticate" button or copy the one-time password {{.OTP}} and paste it to to the authentication screen in order to authenticate at ZITADEL within the next five minutes. + Text: Please use the one-time password {{.OTP}} to authenticate at ZITADEL within the next five minutes or click the "Authenticate" button. ButtonText: Authenticate VerifySMSOTP: - Text: Please visit {{ .VerifyURL }} or copy the one-time password {{.OTP}} and paste it to to the authentication screen in order to authenticate at ZITADEL within the next five minutes. + Text: >- + {{.OTP}} is your one-time-password for {{ .Domain }}. Use it within the next {{.Expiry}}. + + @{{.Domain}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Domain has been claimed PreHeader: Change email / username diff --git a/internal/notification/static/i18n/es.yaml b/internal/notification/static/i18n/es.yaml index 9a1413219..b2534e240 100644 --- a/internal/notification/static/i18n/es.yaml +++ b/internal/notification/static/i18n/es.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: Por favor, utiliza el botón 'Autenticar' o copia la contraseña de un solo uso {{.OTP}} y pégala en la pantalla de autenticación para autenticarte en ZITADEL en los próximos cinco minutos. ButtonText: Autenticar VerifySMSOTP: - Text: Por favor, visita {{ .VerifyURL }} o copia la contraseña de un solo uso {{.OTP}} y pégala en la pantalla de autenticación para autenticarte en ZITADEL en los próximos cinco minutos. + Text: >- + {{.OTP}} es su contraseña de un solo uso para {{ .Domain }}. Úselo dentro de los próximos {{.Expiry}}. + + @{{.Dominio}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Se ha reclamado un dominio PreHeader: Cambiar dirección de correo electrónico / nombre de usuario diff --git a/internal/notification/static/i18n/fr.yaml b/internal/notification/static/i18n/fr.yaml index fccaaa61f..43d03253a 100644 --- a/internal/notification/static/i18n/fr.yaml +++ b/internal/notification/static/i18n/fr.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: Utilise le bouton 'Authentifier' ou copie le mot de passe à usage unique {{.OTP}} et colle-le à l'écran d'authentification pour t'authentifier sur ZITADEL dans les cinq prochaines minutes. ButtonText: Authentifier VerifySMSOTP: - Text: Visite {{ .VerifyURL }} ou copie le mot de passe à usage unique {{.OTP}} et colle-le à l'écran d'authentification pour t'authentifier sur ZITADEL dans les cinq prochaines minutes. + Text: >- + {{.OTP}} est votre mot de passe à usage unique pour {{ .Domain }}. Utilisez-le dans le prochain {{.Expiry}}. + + @{{.Domaine}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Le domaine a été réclamé PreHeader: Modifier l'email / le nom d'utilisateur diff --git a/internal/notification/static/i18n/it.yaml b/internal/notification/static/i18n/it.yaml index 352aec4af..ef6d4c5c8 100644 --- a/internal/notification/static/i18n/it.yaml +++ b/internal/notification/static/i18n/it.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: Per favore, utilizza il pulsante 'Autentica' o copia la password monouso {{.OTP}} e incollala nella schermata di autenticazione per autenticarti a ZITADEL entro i prossimi cinque minuti. ButtonText: Autentica VerifySMSOTP: - Text: Per favore, visita {{ .VerifyURL }} o copia la password monouso {{.OTP}} e incollala nella schermata di autenticazione per autenticarti a ZITADEL entro i prossimi cinque minuti. + Text: >- + {{.OTP}} è la tua password monouso per {{ .Domain }}. Usalo entro il prossimo {{.Expiry}}. + + @{{.Dominio}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Il dominio è stato rivendicato PreHeader: Cambiare email / nome utente diff --git a/internal/notification/static/i18n/ja.yaml b/internal/notification/static/i18n/ja.yaml index 53c1ac368..1ff5e1eb5 100644 --- a/internal/notification/static/i18n/ja.yaml +++ b/internal/notification/static/i18n/ja.yaml @@ -31,10 +31,13 @@ VerifyEmailOTP: PreHeader: ワンタイムパスワードを確認する Subject: ワンタイムパスワードを確認する Greeting: こんにちは、{{.DisplayName}}さん - Text: '認証'ボタンを使用するか、ワンタイムパスワード {{.OTP}} をコピーして認証画面に貼り付け、次の5分以内にZITADELで認証してください。 + Text: 認証ボタンを使用するか、ワンタイムパスワード {{.OTP}} をコピーして認証画面に貼り付け、次の5分以内にZITADELで認証してください。 ButtonText: 認証 VerifySMSOTP: - Text: {{ .VerifyURL }} を訪れるか、ワンタイムパスワード {{.OTP}} をコピーして認証画面に貼り付け、次の5分以内にZITADELで認証してください。 + Text: >- + {{.OTP}} は、{{ .Domain }} のワンタイムパスワードです。次の {{.Expiry}} 以内に使用してください。 + + @{{.ドメイン}} #{{.OTP}} DomainClaimed: Title: ZITADEL - ドメインの登録 PreHeader: メールアドレス・ユーザー名の変更 diff --git a/internal/notification/static/i18n/mk.yaml b/internal/notification/static/i18n/mk.yaml index f68cc4c8b..0614efde7 100644 --- a/internal/notification/static/i18n/mk.yaml +++ b/internal/notification/static/i18n/mk.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: Ве молам, користи го копчето 'Автентицирај' или копирај ја еднократната лозинка {{.OTP}} и стави ја на екранот за автентикација за да се автентицираш на ZITADEL во следните пет минути. ButtonText: Автентицирај VerifySMSOTP: - Text: Ве молам, посети го {{ .VerifyURL }} или копирај ја еднократната лозинка {{.OTP}} и стави ја на екранот за автентикација за да се автентицираш на ZITADEL во следните пет минути. + Text: >- + {{.OTP}} е вашата еднократна лозинка за {{ .Домен }}. Користете го во следниот {{.Истек}}. + + @{{.Домен}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Доменот е преземен PreHeader: Промена на е-пошта / корисничко име diff --git a/internal/notification/static/i18n/pl.yaml b/internal/notification/static/i18n/pl.yaml index 723e4d3b3..3f16dab3e 100644 --- a/internal/notification/static/i18n/pl.yaml +++ b/internal/notification/static/i18n/pl.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: Proszę, użyj przycisku 'Uwierzytelnij' lub skopiuj hasło jednorazowe {{.OTP}} i wklej go na ekran uwierzytelniania, aby uwierzytelnić się w ZITADEL w ciągu najbliższych pięciu minut. ButtonText: Uwierzytelnij VerifySMSOTP: - Text: Proszę, odwiedź {{ .VerifyURL }} lub skopiuj hasło jednorazowe {{.OTP}} i wklej go na ekran uwierzytelniania, aby uwierzytelnić się w ZITADEL w ciągu najbliższych pięciu minut. + Text: >- + {{.OTP}} to Twoje jednorazowe hasło do domeny {{ .Domain }}. Użyj go w ciągu najbliższych {{.Expiry}}. + + @{{.Domena}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Domena została zarejestrowana PreHeader: Zmiana adresu e-mail / nazwy użytkownika diff --git a/internal/notification/static/i18n/pt.yaml b/internal/notification/static/i18n/pt.yaml index 95f90fdd3..a7a2480b8 100644 --- a/internal/notification/static/i18n/pt.yaml +++ b/internal/notification/static/i18n/pt.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: Por favor, usa o botão 'Autenticar' ou copia a senha de uso único {{.OTP}} e cola-a na tela de autenticação para te autenticares no ZITADEL nos próximos cinco minutos. ButtonText: Autenticar VerifySMSOTP: - Text: Por favor, visita {{ .VerifyURL }} ou copia a senha de uso único {{.OTP}} e cola-a na tela de autenticação para te autenticares no ZITADEL nos próximos cinco minutos. + Text: >- + {{.OTP}} é sua senha única para {{ .Domain }}. Use-o nos próximos {{.Expiry}}. + + @{{.Domain}} #{{.OTP}} DomainClaimed: Title: ZITADEL - Domínio foi reivindicado PreHeader: Alterar e-mail / nome de usuário diff --git a/internal/notification/static/i18n/zh.yaml b/internal/notification/static/i18n/zh.yaml index f36b2fdb2..b0dd21cad 100644 --- a/internal/notification/static/i18n/zh.yaml +++ b/internal/notification/static/i18n/zh.yaml @@ -34,7 +34,10 @@ VerifyEmailOTP: Text: 请使用 '验证' 按钮,或复制一次性密码 {{.OTP}} 并将其粘贴到验证屏幕中,以在接下来的五分钟内在 ZITADEL 中进行验证。 ButtonText: 验证 VerifySMSOTP: - Text: 请访问 {{ .VerifyURL }} 或复制一次性密码 {{.OTP}} 并将其粘贴到身份验证屏幕,以在接下来的五分钟内在ZITADEL进行身份验证。 + Text: >- + {{.OTP}} 是您的 {{ .Domain }} 的一次性密码。在下一个 {{.Expiry}} 内使用它。 + + @{{.Domain}} #{{.OTP}} DomainClaimed: Title: ZITADEL - 域名所有权验证 PreHeader: 更改电子邮件/用户名 diff --git a/internal/notification/types/otp.go b/internal/notification/types/otp.go new file mode 100644 index 000000000..aea3a5c12 --- /dev/null +++ b/internal/notification/types/otp.go @@ -0,0 +1,29 @@ +package types + +import ( + "time" + + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendOTPSMSCode(requestedDomain, origin, code string, expiry time.Duration) error { + args := otpArgs(code, origin, requestedDomain, expiry) + return notify("", args, domain.VerifySMSOTPMessageType, false) +} + +func (notify Notify) SendOTPEmailCode(user *query.NotifyUser, requestedDomain, origin, code, authRequestID string, expiry time.Duration) error { + url := login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail) + args := otpArgs(code, origin, requestedDomain, expiry) + return notify(url, args, domain.VerifyEmailOTPMessageType, false) +} + +func otpArgs(code, origin, requestedDomain string, expiry time.Duration) map[string]interface{} { + args := make(map[string]interface{}) + args["OTP"] = code + args["Origin"] = origin + args["Domain"] = requestedDomain + args["Expiry"] = expiry + return args +} diff --git a/internal/notification/types/phone_verification_code.go b/internal/notification/types/phone_verification_code.go index 88eb744c4..f833aeb33 100644 --- a/internal/notification/types/phone_verification_code.go +++ b/internal/notification/types/phone_verification_code.go @@ -5,8 +5,9 @@ import ( "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPhoneVerificationCode(user *query.NotifyUser, origin, code string) error { +func (notify Notify) SendPhoneVerificationCode(user *query.NotifyUser, origin, code, requestedDomain string) error { args := make(map[string]interface{}) args["Code"] = code + args["Domain"] = requestedDomain return notify("", args, domain.VerifyPhoneMessageType, true) } diff --git a/internal/query/projection/user_auth_method.go b/internal/query/projection/user_auth_method.go index ddaa53c2b..13b9c6db7 100644 --- a/internal/query/projection/user_auth_method.go +++ b/internal/query/projection/user_auth_method.go @@ -113,6 +113,14 @@ func (p *userAuthMethodProjection) reducers() []handler.AggregateReducer { Event: user.HumanOTPSMSRemovedType, Reduce: p.reduceRemoveAuthMethod, }, + { + Event: user.HumanPhoneRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, + { + Event: user.UserV1PhoneRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, { Event: user.HumanOTPEmailRemovedType, Reduce: p.reduceRemoveAuthMethod, diff --git a/internal/repository/instance/secret_generator.go b/internal/repository/instance/secret_generator.go index 013bd53d9..d2f25422d 100644 --- a/internal/repository/instance/secret_generator.go +++ b/internal/repository/instance/secret_generator.go @@ -168,7 +168,7 @@ func ChangeSecretGeneratorIncludeDigits(includeDigits bool) func(event *SecretGe func ChangeSecretGeneratorIncludeSymbols(includeSymbols bool) func(event *SecretGeneratorChangedEvent) { return func(e *SecretGeneratorChangedEvent) { - e.IncludeDigits = &includeSymbols + e.IncludeSymbols = &includeSymbols } } diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index 8aede0d19..414895d5a 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -91,10 +91,14 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckFailedType, HumanOTPCheckFailedEventMapper). RegisterFilterEventMapper(AggregateType, HumanOTPSMSAddedType, eventstore.GenericEventMapper[HumanOTPSMSAddedEvent]). RegisterFilterEventMapper(AggregateType, HumanOTPSMSRemovedType, eventstore.GenericEventMapper[HumanOTPSMSRemovedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSCodeAddedType, eventstore.GenericEventMapper[HumanOTPSMSCodeAddedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSCodeSentType, eventstore.GenericEventMapper[HumanOTPSMSCodeSentEvent]). 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, HumanOTPEmailCodeAddedType, eventstore.GenericEventMapper[HumanOTPEmailCodeAddedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailCodeSentType, eventstore.GenericEventMapper[HumanOTPEmailCodeSentEvent]). RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckSucceededType, eventstore.GenericEventMapper[HumanOTPEmailCheckSucceededEvent]). RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckFailedType, eventstore.GenericEventMapper[HumanOTPEmailCheckFailedEvent]). RegisterFilterEventMapper(AggregateType, HumanU2FTokenAddedType, HumanU2FAddedEventMapper). diff --git a/internal/repository/user/human_mfa_otp.go b/internal/repository/user/human_mfa_otp.go index 3427dbbfc..52d5ad7a0 100644 --- a/internal/repository/user/human_mfa_otp.go +++ b/internal/repository/user/human_mfa_otp.go @@ -3,6 +3,7 @@ package user import ( "context" "encoding/json" + "time" "github.com/zitadel/zitadel/internal/eventstore" @@ -21,11 +22,15 @@ const ( otpSMSEventPrefix = otpEventPrefix + "sms." HumanOTPSMSAddedType = otpSMSEventPrefix + "added" HumanOTPSMSRemovedType = otpSMSEventPrefix + "removed" + HumanOTPSMSCodeAddedType = otpSMSEventPrefix + "code.added" + HumanOTPSMSCodeSentType = otpSMSEventPrefix + "code.sent" HumanOTPSMSCheckSucceededType = otpSMSEventPrefix + "check.succeeded" HumanOTPSMSCheckFailedType = otpSMSEventPrefix + "check.failed" otpEmailEventPrefix = otpEventPrefix + "email." HumanOTPEmailAddedType = otpEmailEventPrefix + "added" HumanOTPEmailRemovedType = otpEmailEventPrefix + "removed" + HumanOTPEmailCodeAddedType = otpEmailEventPrefix + "code.added" + HumanOTPEmailCodeSentType = otpEmailEventPrefix + "code.sent" HumanOTPEmailCheckSucceededType = otpEmailEventPrefix + "check.succeeded" HumanOTPEmailCheckFailedType = otpEmailEventPrefix + "check.failed" ) @@ -271,6 +276,78 @@ func NewHumanOTPSMSRemovedEvent( } } +type HumanOTPSMSCodeAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + *AuthRequestInfo +} + +func (e *HumanOTPSMSCodeAddedEvent) Data() interface{} { + return e +} + +func (e *HumanOTPSMSCodeAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSCodeAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSCodeAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + code *crypto.CryptoValue, + expiry time.Duration, + info *AuthRequestInfo, +) *HumanOTPSMSCodeAddedEvent { + return &HumanOTPSMSCodeAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSCodeAddedType, + ), + Code: code, + Expiry: expiry, + AuthRequestInfo: info, + } +} + +type HumanOTPSMSCodeSentEvent struct { + eventstore.BaseEvent `json:"-"` + + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + *AuthRequestInfo +} + +func (e *HumanOTPSMSCodeSentEvent) Data() interface{} { + return e +} + +func (e *HumanOTPSMSCodeSentEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSCodeSentEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPSMSCodeSentEvent { + return &HumanOTPSMSCodeSentEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSCodeSentType, + ), + } +} + type HumanOTPSMSCheckSucceededEvent struct { eventstore.BaseEvent `json:"-"` *AuthRequestInfo @@ -393,6 +470,78 @@ func NewHumanOTPEmailRemovedEvent( } } +type HumanOTPEmailCodeAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + *AuthRequestInfo +} + +func (e *HumanOTPEmailCodeAddedEvent) Data() interface{} { + return e +} + +func (e *HumanOTPEmailCodeAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailCodeAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailCodeAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + code *crypto.CryptoValue, + expiry time.Duration, + info *AuthRequestInfo, +) *HumanOTPEmailCodeAddedEvent { + return &HumanOTPEmailCodeAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailCodeAddedType, + ), + Code: code, + Expiry: expiry, + AuthRequestInfo: info, + } +} + +type HumanOTPEmailCodeSentEvent struct { + eventstore.BaseEvent `json:"-"` + + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + *AuthRequestInfo +} + +func (e *HumanOTPEmailCodeSentEvent) Data() interface{} { + return e +} + +func (e *HumanOTPEmailCodeSentEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailCodeSentEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPEmailCodeSentEvent { + return &HumanOTPEmailCodeSentEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailCodeSentType, + ), + } +} + type HumanOTPEmailCheckSucceededEvent struct { eventstore.BaseEvent `json:"-"` *AuthRequestInfo diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index b719bf191..319d4af86 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -630,6 +630,24 @@ EventTypes: check: succeeded: Многофакторната OTP проверка е успешна failed: Многофакторната OTP проверка е неуспешна + sms: + added: Добавен Многофакторната OTP SMS + removed: Премахнато многофакторно OTP SMS + code: + added: Добавен многофакторен OTP SMS код + sent: Многофакторната OTP SMS код е изпратен + check: + succeeded: Успешна многофакторна OTP SMS проверка + failed: Многофакторната OTP проверка на SMS не бе успешна + email: + added: Добавен Многофакторната OTP имейл + removed: Премахнат многофакторен OTP имейл + code: + added: Добавен многофакторен OTP имейл код + sent: Многофакторният OTP имейл код е изпратен + check: + succeeded: Многофакторната еднократна имейл потвърждение е успешна + failed: Многофакторната OTP проверка на имейл не бе успешна u2f: token: added: Добавен е многофакторен U2F токен diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index ff6c85d6a..774f87f7d 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -618,6 +618,24 @@ EventTypes: check: succeeded: Multifaktor OTP Verifikation erfolgreich failed: Multifaktor OTP Verifikation fehlgeschlagen + sms: + added: Multifaktor OTP SMS hinzugefügt + removed: Multifaktor OTP SMS entfernt + code: + added: Multifaktor OTP SMS Code hinzugefügt + sent: Multifaktor OTP SMS Code versendet + check: + succeeded: Multifaktor OTP SMS Verifikation erfolgreich + failed: Multifaktor OTP SMS Verifikation fehlgeschlagen + email: + added: Multifaktor OTP Email hinzugefügt + removed: Multifaktor OTP Email entfernt + code: + added: Multifaktor OTP Email Code hinzugefügt + sent: Multifaktor OTP Email Code versendet + check: + succeeded: Multifaktor OTP Email Verifikation erfolgreich + failed: Multifaktor OTP Email Verifikation fehlgeschlagen u2f: token: added: Multifaktor U2F Token hinzugefügt diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 9058c15d9..d6e84e581 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -618,6 +618,24 @@ EventTypes: check: succeeded: Multifactor OTP check succeeded failed: Multifactor OTP check failed + sms: + added: Multifactor OTP SMS added + removed: Multifactor OTP SMS removed + code: + added: Multifactor OTP SMS code added + sent: Multifactor OTP SMS code sent + check: + succeeded: Multifactor OTP SMS check succeeded + failed: Multifactor OTP SMS check failed + email: + added: Multifactor OTP Email added + removed: Multifactor OTP Email removed + code: + added: Multifactor OTP Email code added + sent: Multifactor OTP Email code sent + check: + succeeded: Multifactor OTP Email check succeeded + failed: Multifactor OTP Email check failed u2f: token: added: Multifactor U2F Token added diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 475280504..95af0e071 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -618,6 +618,24 @@ EventTypes: check: succeeded: Comprobación exitosa de Multifactor OTP failed: Comprobación fallida de Multifactor OTP + sms: + added: Multifactor OTP SMS añadido + removed: Multifactor OTP SMS elimonado + code: + added: Código Multifactor OTP SMS añadido + sent: Código Multifactor OTP SMS enviado + check: + succeeded: Comprobación Multifactor OTP SMS exitosa + failed: Comprobación Multifactor OTP SMS fallida + email: + added: Multifactor OTP email añadido + removed: Multifactor OTP email elimonado + code: + added: Código Multifactor OTP email añadido + sent: Código Multifactor OTP email enviado + check: + succeeded: Comprobación Multifactor OTP email exitosa + failed: Comprobación Multifactor OTP email fallida u2f: token: added: Multifactor U2F Token añadido diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 54ad40236..f0a1b1df4 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -616,6 +616,24 @@ EventTypes: check: succeeded: Vérification de l'OTP multifactorielle réussie failed: La vérification de l'OTP multifactorielle a échoué + sms: + added: Ajout de SMS OTP multifactoriels + removed: Suppression des SMS OTP multifactoriels + code: + added: Ajout du code SMS OTP multifactoriel + sent: Code SMS OTP multifacteur envoyé + check: + succeeded: Vérification par SMS OTP multifacteur réussie + failed: Échec de la vérification par SMS OTP multifacteur + email: + added: Ajout d'un e-mail OTP multifactoriel + removed: Suppression de l'e-mail OTP multifacteur + code: + added: Ajout d'un code de messagerie OTP multifactoriel + sent: Code de messagerie OTP multifacteur envoyé + check: + succeeded: Vérification de l'e-mail OTP multifacteur réussie + failed: Échec de la vérification de l'e-mail OTP multifacteur u2f: token: added: Ajout d'un jeton U2F multifacteur diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 958eea5b1..3e70d4f6a 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -616,6 +616,24 @@ EventTypes: check: succeeded: Controllo OTP riuscito failed: Controllo OTP fallito + sms: + added: Aggiunto SMS OTP + removed: OTP SMS rimosso + code: + added: Aggiunto codice OTP SMS + sent: Codice OTP SMS inviato + check: + succeeded: Controllo OTP SMS riuscito + failed: Controllo OTP SMS fallito + email: + added: Aggiunto OTP e-mail + removed: OTP e-mail rimosso + code: + added: Aggiunto codice OTP e-mail + sent: Codice OTP e-mail inviato + check: + succeeded: OTP Controllo e-mail riuscito + failed: OTP Controllo e-mail fallito u2f: token: added: Aggiunto il U2F Token diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index c0a735c1e..d1e5cdfa4 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -603,6 +603,24 @@ EventTypes: check: succeeded: MFA OTPチェックの成功 failed: MFA OTPチェックの失敗 + sms: + added: 多要素 OTP SMS を追加しました + removed: 多要素 OTP SMS を削除しました + code: + added: 多要素 OTP SMS コードを追加しました + sent: 多要素 OTP SMS コードが送信されました + check: + succeeded: 多要素 OTP SMS 検証が成功しました + failed: 多要素 OTP SMS 検証が失敗しました + email: + added: 多要素 OTP 電子メールを追加しました + removed: 多要素 OTP 電子メールを削除しました + code: + added: 多要素 OTP 電子メール コードを追加しました + sent: 多要素 OTP 電子メール コードが送信されました + check: + succeeded: 多要素 OTP 電子メール検証が成功しました + failed: 多要素 OTP 電子メール検証が失敗しました u2f: token: added: MFA U2Fトークンの追加 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 36d1b1c74..4d2afce3c 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -614,6 +614,24 @@ EventTypes: check: succeeded: Проверката на мултифактор OTP е успешна failed: Проверката на мултифактор OTP е неуспешна + sms: + added: Додадена е мултифакторна OTP SMS + removed: Отстранета мултифакторна OTP SMS + code: + added: Додаден мултифакторски OTP SMS код + sent: Мултифакторен OTP СМС-код е испратен + check: + succeeded: Мултифакторна OTP СМС-верификација е успешна + failed: Мултифакторна OTP СМС-верификација не успеа + email: + added: Додадена е повеќефакторна OTP е-пошта + removed: Отстранета мултифакторна OTP е-пошта + code: + added: Додаден мултифакторски OTP код за е-пошта + sent: Испратен е-пошта OTP-код со повеќе фактори + check: + succeeded: Успешна е-пошта OTP-верификација на мултифактор + failed: Неуспешна потврда на е-пошта OTP со повеќе фактори u2f: token: added: Додаден мултифактор U2F токен diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 1e831f682..adde4501a 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -618,6 +618,24 @@ EventTypes: check: succeeded: Sprawdzenie wielofaktorowego OTP zakończone powodzeniem failed: Sprawdzenie wielofaktorowego OTP nie powiodło się + sms: + added: Dodano wieloskładnikowy SMS OTP + removed: Usunięto wieloskładnikowy SMS OTP + code: + added: Dodano wieloczynnikowy kod SMS OTP + sent: Wysłano kod SMS Multifactor OTP + check: + succeeded: Pomyślna weryfikacja wieloczynnikowego SMS-a OTP + failed: Wieloskładnikowa weryfikacja wiadomości SMS OTP nie powiodła się + email: + added: Dodano wieloskładnikowy e-mail OTP + removed: Usunięto wieloskładnikowy e-mail OTP + code: + added: Dodano wieloskładnikowy kod e-mail OTP + sent: Wysłano kod e-mail Multifactor OTP + check: + succeeded: Pomyślna wieloczynnikowa weryfikacja adresu e-mail OTP + failed: Wieloczynnikowa weryfikacja adresu e-mail OTP nie powiodła się u2f: token: added: Dodano token wielofaktorowego U2F diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 9b823dbfe..00f3f1a05 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -609,6 +609,24 @@ EventTypes: check: succeeded: Verificação de OTP de autenticação multifator bem-sucedida failed: Verificação de OTP de autenticação multifator falhou + sms: + added: Adicionado SMS OTP multifator + removed: SMS OTP multifator removido + code: + added: Adicionado código SMS OTP multifator + sent: Código SMS OTP multifator enviado + check: + succeeded: Verificação multifator OTP SMS bem-sucedida + failed: Falha na verificação multifator OTP SMS + email: + added: Adicionado e-mail OTP multifator + removed: E-mail OTP multifator removido + code: + added: Adicionado código de e-mail OTP multifator + sent: Código de e-mail OTP multifator enviado + check: + succeeded: Verificação de e-mail OTP multifator bem-sucedida + failed: Falha na verificação de e-mail OTP multifator u2f: token: added: Token U2F de autenticação multifator adicionado diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index c1d7229cc..fb1d158c6 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -612,6 +612,24 @@ EventTypes: check: succeeded: 验证 MFA OTP 成功 failed: 验证 MFA OTP 失败 + sms: + added: 添加了多因素 OTP 短信 + removed: 删除了多因素 OTP 短信 + code: + added: 添加了多因素 OTP 短信代码 + sent: 已发送多因素 OTP 短信代码 + check: + succeeded: 多因素 OTP 短信验证成功 + failed: 多因素 OTP 短信验证失败 + email: + added: 添加了多因素 OTP 电子邮件 + removed: 删除了多因素 OTP 电子邮件 + code: + added: 添加了多因素 OTP 电子邮件代码 + sent: 已发送多因素 OTP 电子邮件代码 + check: + succeeded: 多因素 OTP 电子邮件验证成功 + failed: 多因素 OTP 电子邮件验证失败 u2f: token: added: 添加 MFA U2F 令牌 diff --git a/internal/user/model/user_view.go b/internal/user/model/user_view.go index 5bc7c9a11..dac8b8a55 100644 --- a/internal/user/model/user_view.go +++ b/internal/user/model/user_view.go @@ -49,6 +49,8 @@ type HumanView struct { Region string StreetAddress string OTPState MFAState + OTPSMSAdded bool + OTPEmailAdded bool U2FTokens []*WebAuthNView PasswordlessTokens []*WebAuthNView MFAMaxSetUp domain.MFALevel @@ -162,10 +164,17 @@ func (u *UserView) MFATypesSetupPossible(level domain.MFALevel, policy *domain.L } case domain.SecondFactorTypeU2F: types = append(types, domain.MFATypeU2F) + case domain.SecondFactorTypeOTPSMS: + if !u.OTPSMSAdded { + types = append(types, domain.MFATypeOTPSMS) + } + case domain.SecondFactorTypeOTPEmail: + if !u.OTPEmailAdded { + types = append(types, domain.MFATypeOTPEmail) + } } } } - //PLANNED: add sms } return types } @@ -189,10 +198,17 @@ func (u *UserView) MFATypesAllowed(level domain.MFALevel, policy *domain.LoginPo if u.IsU2FReady() { types = append(types, domain.MFATypeU2F) } + case domain.SecondFactorTypeOTPSMS: + if u.OTPSMSAdded { + types = append(types, domain.MFATypeOTPSMS) + } + case domain.SecondFactorTypeOTPEmail: + if u.OTPEmailAdded { + types = append(types, domain.MFATypeOTPEmail) + } } } } - //PLANNED: add sms } return types, required } diff --git a/internal/user/repository/view/model/user.go b/internal/user/repository/view/model/user.go index d5d913478..db9f50371 100644 --- a/internal/user/repository/view/model/user.go +++ b/internal/user/repository/view/model/user.go @@ -89,6 +89,8 @@ type HumanView struct { Region string `json:"region" gorm:"column:region"` StreetAddress string `json:"streetAddress" gorm:"column:street_address"` OTPState int32 `json:"-" gorm:"column:otp_state"` + OTPSMSAdded bool `json:"-" gorm:"column:otp_sms_added"` + OTPEmailAdded bool `json:"-" gorm:"column:otp_email_added"` U2FTokens WebAuthNTokens `json:"-" gorm:"column:u2f_tokens"` MFAMaxSetUp int32 `json:"-" gorm:"column:mfa_max_set_up"` MFAInitSkipped time.Time `json:"-" gorm:"column:mfa_init_skipped"` @@ -178,6 +180,8 @@ func UserToModel(user *UserView) *model.UserView { Region: user.Region, StreetAddress: user.StreetAddress, OTPState: model.MFAState(user.OTPState), + OTPSMSAdded: user.OTPSMSAdded, + OTPEmailAdded: user.OTPEmailAdded, MFAMaxSetUp: domain.MFALevel(user.MFAMaxSetUp), MFAInitSkipped: user.MFAInitSkipped, InitRequired: user.InitRequired, @@ -301,6 +305,8 @@ func (u *UserView) AppendEvent(event *models.Event) (err error) { user.HumanPhoneRemovedType: u.Phone = "" u.IsPhoneVerified = false + u.OTPSMSAdded = false + u.MFAInitSkipped = time.Time{} case user.UserDeactivatedType: u.State = int32(model.UserStateInactive) case user.UserReactivatedType, @@ -326,6 +332,16 @@ func (u *UserView) AppendEvent(event *models.Event) (err error) { case user.UserV1MFAOTPRemovedType, user.HumanMFAOTPRemovedType: u.OTPState = int32(model.MFAStateUnspecified) + case user.HumanOTPSMSAddedType: + u.OTPSMSAdded = true + case user.HumanOTPSMSRemovedType: + u.OTPSMSAdded = false + u.MFAInitSkipped = time.Time{} + case user.HumanOTPEmailAddedType: + u.OTPEmailAdded = true + case user.HumanOTPEmailRemovedType: + u.OTPEmailAdded = false + u.MFAInitSkipped = time.Time{} case user.HumanU2FTokenAddedType: err = u.addU2FToken(event) case user.HumanU2FTokenVerifiedType: @@ -520,7 +536,8 @@ func (u *UserView) ComputeMFAMaxSetUp() { return } } - if u.OTPState == int32(model.MFAStateReady) { + if u.OTPState == int32(model.MFAStateReady) || + u.OTPSMSAdded || u.OTPEmailAdded { u.MFAMaxSetUp = int32(domain.MFALevelSecondFactor) return } @@ -575,6 +592,10 @@ func (u *UserView) EventTypes() []models.EventType { models.EventType(user.HumanMFAOTPVerifiedType), models.EventType(user.UserV1MFAOTPRemovedType), models.EventType(user.HumanMFAOTPRemovedType), + models.EventType(user.HumanOTPSMSAddedType), + models.EventType(user.HumanOTPSMSRemovedType), + models.EventType(user.HumanOTPEmailAddedType), + models.EventType(user.HumanOTPEmailRemovedType), models.EventType(user.HumanU2FTokenAddedType), models.EventType(user.HumanU2FTokenVerifiedType), models.EventType(user.HumanU2FTokenRemovedType), diff --git a/internal/user/repository/view/model/user_session.go b/internal/user/repository/view/model/user_session.go index ec611c16f..63eb5e0a7 100644 --- a/internal/user/repository/view/model/user_session.go +++ b/internal/user/repository/view/model/user_session.go @@ -139,12 +139,32 @@ func (v *UserSessionView) AppendEvent(event *models.Event) error { case user.UserV1MFAOTPCheckSucceededType, user.HumanMFAOTPCheckSucceededType: v.setSecondFactorVerification(event.CreationDate, domain.MFATypeTOTP) + case user.HumanOTPSMSCheckSucceededType: + data := new(es_model.OTPVerified) + err := data.SetData(event) + if err != nil { + return err + } + if v.UserAgentID == data.UserAgentID { + v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTPSMS) + } + case user.HumanOTPEmailCheckSucceededType: + data := new(es_model.OTPVerified) + err := data.SetData(event) + if err != nil { + return err + } + if v.UserAgentID == data.UserAgentID { + v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTPEmail) + } case user.UserV1MFAOTPCheckFailedType, user.UserV1MFAOTPRemovedType, user.HumanMFAOTPCheckFailedType, user.HumanMFAOTPRemovedType, user.HumanU2FTokenCheckFailedType, - user.HumanU2FTokenRemovedType: + user.HumanU2FTokenRemovedType, + user.HumanOTPSMSCheckFailedType, + user.HumanOTPEmailCheckFailedType: v.SecondFactorVerification = time.Time{} case user.HumanU2FTokenVerifiedType: data := new(es_model.WebAuthNVerify) @@ -218,6 +238,10 @@ func (v *UserSessionView) EventTypes() []models.EventType { models.EventType(user.UserV1MFAOTPRemovedType), models.EventType(user.HumanMFAOTPCheckFailedType), models.EventType(user.HumanMFAOTPRemovedType), + models.EventType(user.HumanOTPSMSCheckSucceededType), + models.EventType(user.HumanOTPSMSCheckFailedType), + models.EventType(user.HumanOTPEmailCheckSucceededType), + models.EventType(user.HumanOTPEmailCheckFailedType), models.EventType(user.HumanU2FTokenCheckFailedType), models.EventType(user.HumanU2FTokenRemovedType), models.EventType(user.HumanU2FTokenVerifiedType),