diff --git a/internal/api/grpc/auth/email.go b/internal/api/grpc/auth/email.go index c6153546e..ced1ca42d 100644 --- a/internal/api/grpc/auth/email.go +++ b/internal/api/grpc/auth/email.go @@ -50,7 +50,7 @@ func (s *Server) VerifyMyEmail(ctx context.Context, req *auth_pb.VerifyMyEmailRe return nil, err } ctxData := authz.GetCtxData(ctx) - objectDetails, err := s.command.VerifyHumanEmail(ctx, ctxData.UserID, req.Code, ctxData.ResourceOwner, emailCodeGenerator) + objectDetails, err := s.command.VerifyHumanEmail(ctx, ctxData.UserID, req.Code, ctxData.ResourceOwner, "", "", emailCodeGenerator) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index f662b8c59..d3cab6296 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -586,11 +586,7 @@ func (s *Server) SetHumanPassword(ctx context.Context, req *mgmt_pb.SetHumanPass } func (s *Server) SendHumanResetPasswordNotification(ctx context.Context, req *mgmt_pb.SendHumanResetPasswordNotificationRequest) (*mgmt_pb.SendHumanResetPasswordNotificationResponse, error) { - passwordCodeGenerator, err := s.query.InitEncryptionGenerator(ctx, domain.SecretGeneratorTypePasswordResetCode, s.userCodeAlg) - if err != nil { - return nil, err - } - objectDetails, err := s.command.RequestSetPassword(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, notifyTypeToDomain(req.Type), passwordCodeGenerator, "") + objectDetails, err := s.command.RequestSetPassword(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, notifyTypeToDomain(req.Type), "") if err != nil { return nil, err } diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index 91a197ef6..4b9c173a2 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -97,12 +97,7 @@ func (l *Login) resendPasswordSet(w http.ResponseWriter, r *http.Request, authRe userID = authReq.UserID authReqID = authReq.ID } - passwordCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypePasswordResetCode, l.userCodeAlg) - if err != nil { - l.renderInitPassword(w, r, authReq, userID, "", err) - return - } - _, err = l.command.RequestSetPassword(setContext(r.Context(), userOrg), userID, userOrg, domain.NotificationTypeEmail, passwordCodeGenerator, authReqID) + _, err := l.command.RequestSetPassword(setContext(r.Context(), userOrg), userID, userOrg, domain.NotificationTypeEmail, authReqID) l.renderInitPassword(w, r, authReq, userID, "", err) } diff --git a/internal/api/ui/login/mail_verify_handler.go b/internal/api/ui/login/mail_verify_handler.go index aa1496900..85c611457 100644 --- a/internal/api/ui/login/mail_verify_handler.go +++ b/internal/api/ui/login/mail_verify_handler.go @@ -1,10 +1,16 @@ package login import ( + "context" "net/http" "net/url" + "github.com/zitadel/logging" + + http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" ) const ( @@ -16,15 +22,25 @@ const ( ) type mailVerificationFormData struct { - Code string `schema:"code"` - UserID string `schema:"userID"` - Resend bool `schema:"resend"` + Code string `schema:"code"` + UserID string `schema:"userID"` + Resend bool `schema:"resend"` + PasswordInit bool `schema:"passwordInit"` + Password string `schema:"password"` + PasswordConfirm string `schema:"passwordconfirm"` } type mailVerificationData struct { baseData profileData - UserID string + UserID string + Code string + PasswordInit bool + MinLength uint64 + HasUppercase string + HasLowercase string + HasNumber string + HasSymbol string } func MailVerificationLink(origin, userID, code, orgID, authRequestID string) string { @@ -40,11 +56,32 @@ func (l *Login) handleMailVerification(w http.ResponseWriter, r *http.Request) { authReq := l.checkOptionalAuthRequestOfEmailLinks(r) userID := r.FormValue(queryUserID) code := r.FormValue(queryCode) - if code != "" { - l.checkMailCode(w, r, authReq, userID, code) + if userID == "" && authReq == nil { + l.renderError(w, r, authReq, nil) return } - l.renderMailVerification(w, r, authReq, userID, nil) + if userID == "" { + userID = authReq.UserID + } + passwordInit := l.checkUserNoFirstFactor(r.Context(), userID) + if code != "" && !passwordInit { + l.checkMailCode(w, r, authReq, userID, code, "") + return + } + l.renderMailVerification(w, r, authReq, userID, code, passwordInit, nil) +} + +func (l *Login) checkUserNoFirstFactor(ctx context.Context, userID string) bool { + userIDQuery, err := query.NewUserAuthMethodUserIDSearchQuery(userID) + logging.WithFields("userID", userID).OnError(err).Warn("error creating NewUserAuthMethodUserIDSearchQuery") + authMethodsQuery, err := query.NewUserAuthMethodTypesSearchQuery(domain.UserAuthMethodTypeIDP, domain.UserAuthMethodTypePassword, domain.UserAuthMethodTypePasswordless) + logging.WithFields("userID", userID).OnError(err).Warn("error creating NewUserAuthMethodTypesSearchQuery") + authMethods, err := l.query.SearchUserAuthMethods(ctx, &query.UserAuthMethodSearchQueries{Queries: []query.SearchQuery{userIDQuery, authMethodsQuery}}, false) + if err != nil { + logging.WithFields("userID", userID).OnError(err).Warn("unable to load user's auth methods for mail verification") + return false + } + return len(authMethods.AuthMethods) == 0 } func (l *Login) handleMailVerificationCheck(w http.ResponseWriter, r *http.Request) { @@ -55,7 +92,12 @@ func (l *Login) handleMailVerificationCheck(w http.ResponseWriter, r *http.Reque return } if !data.Resend { - l.checkMailCode(w, r, authReq, data.UserID, data.Code) + if data.PasswordInit && data.Password != data.PasswordConfirm { + err := zerrors.ThrowInvalidArgument(nil, "VIEW-fsdfd", "Errors.User.Password.ConfirmationWrong") + l.renderMailVerification(w, r, authReq, data.UserID, data.Code, data.PasswordInit, err) + return + } + l.checkMailCode(w, r, authReq, data.UserID, data.Code, data.Password) return } var userOrg, authReqID string @@ -65,14 +107,14 @@ func (l *Login) handleMailVerificationCheck(w http.ResponseWriter, r *http.Reque } emailCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypeVerifyEmailCode, l.userCodeAlg) if err != nil { - l.checkMailCode(w, r, authReq, data.UserID, data.Code) + l.renderMailVerification(w, r, authReq, data.UserID, "", data.PasswordInit, err) return } _, err = l.command.CreateHumanEmailVerificationCode(setContext(r.Context(), userOrg), data.UserID, userOrg, emailCodeGenerator, authReqID) - l.renderMailVerification(w, r, authReq, data.UserID, err) + l.renderMailVerification(w, r, authReq, data.UserID, "", data.PasswordInit, err) } -func (l *Login) checkMailCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, code string) { +func (l *Login) checkMailCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, code, password string) { userOrg := "" if authReq != nil { userID = authReq.UserID @@ -80,31 +122,52 @@ func (l *Login) checkMailCode(w http.ResponseWriter, r *http.Request, authReq *d } emailCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypeVerifyEmailCode, l.userCodeAlg) if err != nil { - l.renderMailVerification(w, r, authReq, userID, err) + l.renderMailVerification(w, r, authReq, userID, "", password != "", err) return } - _, err = l.command.VerifyHumanEmail(setContext(r.Context(), userOrg), userID, code, userOrg, emailCodeGenerator) + userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) + _, err = l.command.VerifyHumanEmail(setContext(r.Context(), userOrg), userID, code, userOrg, password, userAgentID, emailCodeGenerator) if err != nil { - l.renderMailVerification(w, r, authReq, userID, err) + l.renderMailVerification(w, r, authReq, userID, "", password != "", err) return } l.renderMailVerified(w, r, authReq, userOrg) } -func (l *Login) renderMailVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID string, err error) { +func (l *Login) renderMailVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, code string, passwordInit bool, err error) { var errID, errMessage string if err != nil { errID, errMessage = l.getErrorMessage(r, err) } - if userID == "" { + if userID == "" && authReq != nil { userID = authReq.UserID } translator := l.getTranslator(r.Context(), authReq) data := mailVerificationData{ - baseData: l.getBaseData(r, authReq, translator, "EmailVerification.Title", "EmailVerification.Description", errID, errMessage), - UserID: userID, - profileData: l.getProfileData(authReq), + baseData: l.getBaseData(r, authReq, translator, "EmailVerification.Title", "EmailVerification.Description", errID, errMessage), + UserID: userID, + profileData: l.getProfileData(authReq), + Code: code, + PasswordInit: passwordInit, + } + if passwordInit { + policy := l.getPasswordComplexityPolicyByUserID(r, userID) + if policy != nil { + data.MinLength = policy.MinLength + if policy.HasUppercase { + data.HasUppercase = UpperCaseRegex + } + if policy.HasLowercase { + data.HasLowercase = LowerCaseRegex + } + if policy.HasSymbol { + data.HasSymbol = SymbolRegex + } + if policy.HasNumber { + data.HasNumber = NumberRegex + } + } } if authReq == nil { user, err := l.query.GetUserByID(r.Context(), false, userID) diff --git a/internal/api/ui/login/password_reset_handler.go b/internal/api/ui/login/password_reset_handler.go index bc9a5069d..f4f98806c 100644 --- a/internal/api/ui/login/password_reset_handler.go +++ b/internal/api/ui/login/password_reset_handler.go @@ -25,15 +25,7 @@ func (l *Login) handlePasswordReset(w http.ResponseWriter, r *http.Request) { l.renderPasswordResetDone(w, r, authReq, err) return } - passwordCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypePasswordResetCode, l.userCodeAlg) - if err != nil { - if authReq.LoginPolicy.IgnoreUnknownUsernames && zerrors.IsNotFound(err) { - err = nil - } - l.renderPasswordResetDone(w, r, authReq, err) - return - } - _, err = l.command.RequestSetPassword(setContext(r.Context(), authReq.UserOrgID), user.ID, authReq.UserOrgID, domain.NotificationTypeEmail, passwordCodeGenerator, authReq.ID) + _, err = l.command.RequestSetPassword(setContext(r.Context(), authReq.UserOrgID), user.ID, authReq.UserOrgID, domain.NotificationTypeEmail, authReq.ID) l.renderPasswordResetDone(w, r, authReq, err) } diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index 40339954d..462e3fed9 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -313,7 +313,7 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq * case *domain.ChangePasswordStep: l.renderChangePassword(w, r, authReq, err) case *domain.VerifyEMailStep: - l.renderMailVerification(w, r, authReq, "", err) + l.renderMailVerification(w, r, authReq, authReq.UserID, "", step.InitPassword, err) case *domain.MFAPromptStep: l.renderMFAPrompt(w, r, authReq, step, err) case *domain.InitUserStep: diff --git a/internal/api/ui/login/static/templates/mail_verification.html b/internal/api/ui/login/static/templates/mail_verification.html index 8c7ddee8c..011947c17 100644 --- a/internal/api/ui/login/static/templates/mail_verification.html +++ b/internal/api/ui/login/static/templates/mail_verification.html @@ -17,13 +17,29 @@
- +
+ {{ if .PasswordInit }} +
+ + +
+
+ + + {{ template "password-complexity-policy-description" . }} +
+ {{ end }} + {{ template "error-message" .}}
- @@ -40,6 +56,7 @@
- + + {{template "main-bottom" .}} diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 79d986dc5..ec8e85bb6 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -52,6 +52,7 @@ type AuthRequestRepo struct { ProjectProvider projectProvider ApplicationProvider applicationProvider CustomTextProvider customTextProvider + PasswordReset passwordReset IdGenerator id.Generator } @@ -96,6 +97,7 @@ type idpUserLinksProvider interface { type userEventProvider interface { UserEventsByID(ctx context.Context, id string, changeDate time.Time, eventTypes []eventstore.EventType) ([]eventstore.Event, error) + PasswordCodeExists(ctx context.Context, userID string) (exists bool, err error) } type userCommandProvider interface { @@ -125,6 +127,10 @@ type customTextProvider interface { CustomTextListByTemplate(ctx context.Context, aggregateID string, text string, withOwnerRemoved bool) (texts *query.CustomTexts, err error) } +type passwordReset interface { + RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, authRequestID string) (objectDetails *domain.ObjectDetails, err error) +} + func (repo *AuthRequestRepo) Health(ctx context.Context) error { return repo.AuthRequests.Health(ctx) } @@ -1046,7 +1052,7 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth } } if isInternalLogin || (!isInternalLogin && len(request.LinkingUsers) > 0) { - step := repo.firstFactorChecked(request, user, userSession) + step := repo.firstFactorChecked(ctx, request, user, userSession) if step != nil { return append(steps, step), nil } @@ -1065,7 +1071,9 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth steps = append(steps, &domain.ChangePasswordStep{Expired: expired}) } if !user.IsEmailVerified { - steps = append(steps, &domain.VerifyEMailStep{}) + steps = append(steps, &domain.VerifyEMailStep{ + InitPassword: !user.PasswordSet, + }) } if user.UsernameChangeRequired { steps = append(steps, &domain.ChangeUsernameStep{}) @@ -1204,7 +1212,7 @@ func (repo *AuthRequestRepo) usersForUserSelection(ctx context.Context, request return users, nil } -func (repo *AuthRequestRepo) firstFactorChecked(request *domain.AuthRequest, user *user_model.UserView, userSession *user_model.UserSessionView) domain.NextStep { +func (repo *AuthRequestRepo) firstFactorChecked(ctx context.Context, request *domain.AuthRequest, user *user_model.UserView, userSession *user_model.UserSessionView) domain.NextStep { if user.InitRequired { return &domain.InitUserStep{PasswordSet: user.PasswordSet} } @@ -1226,6 +1234,15 @@ func (repo *AuthRequestRepo) firstFactorChecked(request *domain.AuthRequest, use } if user.PasswordInitRequired { + if !user.IsEmailVerified { + return &domain.VerifyEMailStep{InitPassword: true} + } + exists, err := repo.UserEventProvider.PasswordCodeExists(ctx, user.ID) + logging.WithFields("userID", user.ID).OnError(err).Error("unable to check if password code exists") + if err == nil && !exists { + _, err = repo.PasswordReset.RequestSetPassword(ctx, user.ID, user.ResourceOwner, domain.NotificationTypeEmail, request.ID) + logging.WithFields("userID", user.ID).OnError(err).Error("unable to create password code") + } return &domain.InitPasswordStep{} } diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index e64c7fed9..4f05d75b4 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -108,7 +108,8 @@ func (m *mockViewNoUser) UserByID(string, string) (*user_view_model.UserView, er } type mockEventUser struct { - Event eventstore.Event + Event eventstore.Event + CodeExists bool } func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDate time.Time, types []eventstore.EventType) ([]eventstore.Event, error) { @@ -118,6 +119,10 @@ func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDat return nil, nil } +func (m *mockEventUser) PasswordCodeExists(ctx context.Context, userID string) (bool, error) { + return m.CodeExists, nil +} + func (m *mockEventUser) GetLatestUserSessionSequence(ctx context.Context, instanceID string) (*query.CurrentState, error) { return &query.CurrentState{State: query.State{Sequence: 0}}, nil } @@ -132,8 +137,8 @@ func (m *mockEventErrUser) UserEventsByID(ctx context.Context, id string, change return nil, zerrors.ThrowInternal(nil, "id", "internal error") } -func (m *mockEventErrUser) BulkAddExternalIDPs(ctx context.Context, userID string, externalIDPs []*user_model.ExternalIDP) error { - return zerrors.ThrowInternal(nil, "id", "internal error") +func (m *mockEventErrUser) PasswordCodeExists(ctx context.Context, userID string) (bool, error) { + return false, zerrors.ThrowInternal(nil, "id", "internal error") } type mockViewUser struct { @@ -298,6 +303,28 @@ func (m *mockIDPUserLinks) IDPUserLinks(ctx context.Context, queries *query.IDPU return &query.IDPUserLinks{Links: m.idps}, nil } +type mockPasswordReset struct { + t *testing.T + expectCall bool +} + +func newMockPasswordReset(expectCall bool) func(*testing.T) passwordReset { + return func(t *testing.T) passwordReset { + return &mockPasswordReset{ + t: t, + expectCall: expectCall, + } + } +} + +func (m *mockPasswordReset) RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { + if !m.expectCall { + m.t.Error("unexpected call to RequestSetPassword") + return nil, nil + } + return nil, err +} + func TestAuthRequestRepo_nextSteps(t *testing.T) { type fields struct { AuthRequests cache.AuthRequestCache @@ -316,6 +343,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { labelPolicyProvider labelPolicyProvider passwordAgePolicyProvider passwordAgePolicyProvider customTextProvider customTextProvider + passwordReset func(t *testing.T) passwordReset } type args struct { request *domain.AuthRequest @@ -687,7 +715,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { fields{ userViewProvider: &mockViewUser{}, userEventProvider: &mockEventUser{ - &es_models.Event{ + Event: &es_models.Event{ AggregateType: user_repo.AggregateType, Typ: user_repo.UserDeactivatedType, }, @@ -709,7 +737,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { fields{ userViewProvider: &mockViewUser{}, userEventProvider: &mockEventUser{ - &es_models.Event{ + Event: &es_models.Event{ AggregateType: user_repo.AggregateType, Typ: user_repo.UserLockedType, }, @@ -929,7 +957,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { nil, }, { - "password not set, init password step", + "password not set (email not verified), init password step", fields{ userSessionViewProvider: &mockViewUserSession{}, userViewProvider: &mockViewUser{ @@ -945,6 +973,54 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { idpUserLinksProvider: &mockIDPUserLinks{}, }, args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + []domain.NextStep{&domain.VerifyEMailStep{InitPassword: true}}, + nil, + }, + { + "password not set (email verified), init password step", + fields{ + userSessionViewProvider: &mockViewUserSession{}, + userViewProvider: &mockViewUser{ + PasswordInitRequired: true, + IsEmailVerified: true, + }, + userEventProvider: &mockEventUser{ + CodeExists: true, + }, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + idpUserLinksProvider: &mockIDPUserLinks{}, + passwordReset: newMockPasswordReset(false), + }, + args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, + []domain.NextStep{&domain.InitPasswordStep{}}, + nil, + }, + { + "password not set (email verified, password code not exists), create code, init password step", + fields{ + userSessionViewProvider: &mockViewUserSession{}, + userViewProvider: &mockViewUser{ + PasswordInitRequired: true, + IsEmailVerified: true, + }, + userEventProvider: &mockEventUser{ + CodeExists: false, + }, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + idpUserLinksProvider: &mockIDPUserLinks{}, + passwordReset: newMockPasswordReset(true), + }, + args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false}, []domain.NextStep{&domain.InitPasswordStep{}}, nil, }, @@ -1720,6 +1796,9 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { PasswordAgePolicyProvider: tt.fields.passwordAgePolicyProvider, CustomTextProvider: tt.fields.customTextProvider, } + if tt.fields.passwordReset != nil { + repo.PasswordReset = tt.fields.passwordReset(t) + } got, err := repo.nextSteps(context.Background(), tt.args.request, tt.args.checkLoggedIn) if (err != nil && tt.wantErr == nil) || (tt.wantErr != nil && !tt.wantErr(err)) { t.Errorf("nextSteps() wrong error = %v", err) @@ -2201,7 +2280,7 @@ func Test_userSessionByIDs(t *testing.T) { agentID: "agentID", user: &user_model.UserView{ID: "id", HumanView: &user_model.HumanView{FirstName: "FirstName"}}, eventProvider: &mockEventUser{ - &es_models.Event{ + Event: &es_models.Event{ AggregateType: user_repo.AggregateType, Typ: user_repo.UserV1MFAOTPCheckSucceededType, CreationDate: testNow, @@ -2224,7 +2303,7 @@ func Test_userSessionByIDs(t *testing.T) { agentID: "agentID", user: &user_model.UserView{ID: "id"}, eventProvider: &mockEventUser{ - &es_models.Event{ + Event: &es_models.Event{ AggregateType: user_repo.AggregateType, Typ: user_repo.UserV1MFAOTPCheckSucceededType, CreationDate: testNow, @@ -2251,7 +2330,7 @@ func Test_userSessionByIDs(t *testing.T) { agentID: "agentID", user: &user_model.UserView{ID: "id", HumanView: &user_model.HumanView{FirstName: "FirstName"}}, eventProvider: &mockEventUser{ - &es_models.Event{ + Event: &es_models.Event{ AggregateType: user_repo.AggregateType, Typ: user_repo.UserV1MFAOTPCheckSucceededType, CreationDate: testNow, @@ -2278,7 +2357,7 @@ func Test_userSessionByIDs(t *testing.T) { agentID: "agentID", user: &user_model.UserView{ID: "id"}, eventProvider: &mockEventUser{ - &es_models.Event{ + Event: &es_models.Event{ AggregateType: user_repo.AggregateType, Typ: user_repo.UserRemovedType, }, @@ -2367,7 +2446,7 @@ func Test_userByID(t *testing.T) { PasswordChangeRequired: true, }, eventProvider: &mockEventUser{ - &es_models.Event{ + Event: &es_models.Event{ AggregateType: user_repo.AggregateType, Typ: user_repo.UserV1PasswordChangedType, CreationDate: testNow, diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go index cfa573e7e..f35b2f0cf 100644 --- a/internal/auth/repository/eventsourcing/eventstore/user.go +++ b/internal/auth/repository/eventsourcing/eventstore/user.go @@ -10,7 +10,9 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/user" usr_view "github.com/zitadel/zitadel/internal/user/repository/view" + "github.com/zitadel/zitadel/internal/zerrors" ) type UserRepo struct { @@ -46,3 +48,40 @@ func (repo *UserRepo) UserEventsByID(ctx context.Context, id string, changeDate } return repo.Eventstore.Filter(ctx, query) //nolint:staticcheck } + +type passwordCodeCheck struct { + userID string + + exists bool + events int +} + +func (p *passwordCodeCheck) Reduce() error { + p.exists = p.events > 0 + return nil +} + +func (p *passwordCodeCheck) AppendEvents(events ...eventstore.Event) { + p.events += len(events) +} + +func (p *passwordCodeCheck) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(p.userID). + EventTypes(user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType, + user.HumanPasswordCodeAddedType, user.HumanPasswordCodeSentType). + Builder() +} + +func (repo *UserRepo) PasswordCodeExists(ctx context.Context, userID string) (exists bool, err error) { + model := &passwordCodeCheck{ + userID: userID, + } + err = repo.Eventstore.FilterToQueryReducer(ctx, model) + if err != nil { + return false, zerrors.ThrowPermissionDenied(err, "EVENT-SJ642", "Errors.Internal") + } + return model.exists, nil +} diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index a9ec6aa05..c76efab28 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -78,6 +78,7 @@ func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, c ProjectProvider: queryView, ApplicationProvider: queries, CustomTextProvider: queries, + PasswordReset: command, IdGenerator: id.SonyFlakeGenerator(), }, eventstore.TokenRepo{ diff --git a/internal/command/user_human_email.go b/internal/command/user_human_email.go index 18bdc1caa..c954b3246 100644 --- a/internal/command/user_human_email.go +++ b/internal/command/user_human_email.go @@ -64,7 +64,7 @@ func (c *Commands) ChangeHumanEmail(ctx context.Context, email *domain.Email, em return writeModelToEmail(existingEmail), nil } -func (c *Commands) VerifyHumanEmail(ctx context.Context, userID, code, resourceowner string, emailCodeGenerator crypto.Generator) (*domain.ObjectDetails, error) { +func (c *Commands) VerifyHumanEmail(ctx context.Context, userID, code, resourceowner, optionalPassword, optionalUserAgentID string, emailCodeGenerator crypto.Generator) (*domain.ObjectDetails, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4M0ds", "Errors.User.UserIDMissing") } @@ -82,21 +82,30 @@ func (c *Commands) VerifyHumanEmail(ctx context.Context, userID, code, resourceo userAgg := UserAggregateFromWriteModel(&existingCode.WriteModel) err = crypto.VerifyCode(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, emailCodeGenerator.Alg()) - if err == nil { - pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanEmailVerifiedEvent(ctx, userAgg)) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingCode, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&existingCode.WriteModel), nil + if err != nil { + _, err = c.eventstore.Push(ctx, user.NewHumanEmailVerificationFailedEvent(ctx, userAgg)) + logging.WithFields("userID", userAgg.ID).OnError(err).Error("NewHumanEmailVerificationFailedEvent push failed") + return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Gdsgs", "Errors.User.Code.Invalid") } - - _, err = c.eventstore.Push(ctx, user.NewHumanEmailVerificationFailedEvent(ctx, userAgg)) - logging.LogWithFields("COMMAND-Dg2z5", "userID", userAgg.ID).OnError(err).Error("NewHumanEmailVerificationFailedEvent push failed") - return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Gdsgs", "Errors.User.Code.Invalid") + commands := []eventstore.Command{ + user.NewHumanEmailVerifiedEvent(ctx, userAgg), + } + if optionalPassword != "" { + passwordCommand, err := c.setPasswordCommand(ctx, userAgg, domain.UserStateActive, optionalPassword, "", optionalUserAgentID, false, nil) + if err != nil { + return nil, err + } + commands = append(commands, passwordCommand) + } + pushedEvents, err := c.eventstore.Push(ctx, commands...) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingCode, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingCode.WriteModel), nil } func (c *Commands) CreateHumanEmailVerificationCode(ctx context.Context, userID, resourceOwner string, emailCodeGenerator crypto.Generator, authRequestID string) (*domain.ObjectDetails, error) { diff --git a/internal/command/user_human_email_test.go b/internal/command/user_human_email_test.go index 53b95ac80..fca5ba260 100644 --- a/internal/command/user_human_email_test.go +++ b/internal/command/user_human_email_test.go @@ -12,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -387,14 +388,17 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { func TestCommandSide_VerifyHumanEmail(t *testing.T) { type fields struct { - eventstore func(*testing.T) *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore + userPasswordHasher *crypto.Hasher } type args struct { - ctx context.Context - userID string - code string - resourceOwner string - secretGenerator crypto.Generator + ctx context.Context + userID string + code string + resourceOwner string + optionalUserAgentID string + optionalPassword string + secretGenerator crypto.Generator } type res struct { want *domain.ObjectDetails @@ -587,13 +591,96 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { }, }, }, + { + name: "valid code (with password and user agent), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanEmailCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + false, + "userAgentID", + ), + ), + ), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + code: "a", + resourceOwner: "org1", + optionalPassword: "password", + optionalUserAgentID: "userAgentID", + secretGenerator: GetMockSecretGenerator(t), + }, + 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), + eventstore: tt.fields.eventstore(t), + userPasswordHasher: tt.fields.userPasswordHasher, } - got, err := r.VerifyHumanEmail(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.secretGenerator) + got, err := r.VerifyHumanEmail( + tt.args.ctx, + tt.args.userID, + tt.args.code, + tt.args.resourceOwner, + tt.args.optionalPassword, + tt.args.optionalUserAgentID, + tt.args.secretGenerator, + ) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index 839977b38..6978f81ce 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -228,7 +228,7 @@ func (c *Commands) checkPasswordComplexity(ctx context.Context, newPassword stri } // RequestSetPassword generate and send out new code to change password for a specific user -func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, passwordVerificationCode crypto.Generator, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { +func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-M00oL", "Errors.User.UserIDMissing") } @@ -244,11 +244,11 @@ func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M9sd", "Errors.User.NotInitialised") } userAgg := UserAggregateFromWriteModel(&existingHuman.WriteModel) - passwordCode, err := domain.NewPasswordCode(passwordVerificationCode) + passwordCode, err := c.newEncryptedCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption) //nolint:staticcheck if err != nil { return nil, err } - pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPasswordCodeAddedEvent(ctx, userAgg, passwordCode.Code, passwordCode.Expiry, notifyType, authRequestID)) + pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPasswordCodeAddedEvent(ctx, userAgg, passwordCode.Crypted, passwordCode.Expiry, notifyType, authRequestID)) if err != nil { return nil, err } diff --git a/internal/command/user_human_password_test.go b/internal/command/user_human_password_test.go index d944f5e1c..032696749 100644 --- a/internal/command/user_human_password_test.go +++ b/internal/command/user_human_password_test.go @@ -1111,14 +1111,14 @@ func TestCommandSide_ChangePassword(t *testing.T) { func TestCommandSide_RequestSetPassword(t *testing.T) { type fields struct { eventstore func(*testing.T) *eventstore.Eventstore + newCode encrypedCodeFunc } type args struct { - ctx context.Context - userID string - resourceOwner string - notifyType domain.NotificationType - secretGenerator crypto.Generator - authRequestID string + ctx context.Context + userID string + resourceOwner string + notifyType domain.NotificationType + authRequestID string } type res struct { want *domain.ObjectDetails @@ -1251,12 +1251,12 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { ), ), ), + newCode: mockEncryptedCode("a", 1*time.Hour), }, args: args{ - ctx: context.Background(), - userID: "user1", - resourceOwner: "org1", - secretGenerator: GetMockSecretGenerator(t), + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", }, res: res{ want: &domain.ObjectDetails{ @@ -1307,13 +1307,13 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { ), ), ), + newCode: mockEncryptedCode("a", 1*time.Hour), }, args: args{ - ctx: context.Background(), - userID: "user1", - resourceOwner: "org1", - secretGenerator: GetMockSecretGenerator(t), - authRequestID: "authRequestID", + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + authRequestID: "authRequestID", }, res: res{ want: &domain.ObjectDetails{ @@ -1325,9 +1325,10 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), + eventstore: tt.fields.eventstore(t), + newEncryptedCode: tt.fields.newCode, } - got, err := r.RequestSetPassword(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.notifyType, tt.args.secretGenerator, tt.args.authRequestID) + got, err := r.RequestSetPassword(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.notifyType, tt.args.authRequestID) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index e3dccde36..997124e1f 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -319,7 +319,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { }, }, { - name: "add human (with initial code), ok", + name: "add human (email not verified, no password), ok (init code)", fields: fields{ eventstore: expectEventstore( expectFilter(), @@ -389,7 +389,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { }, }, { - name: "add human (with password and initial code), ok", + name: "add human (email not verified, with password), ok (init code)", fields: fields{ eventstore: expectEventstore( expectFilter(), @@ -459,6 +459,65 @@ func TestCommandSide_AddUserHuman(t *testing.T) { wantID: "user1", }, }, + { + name: "add human (email not verified, no password, no allowInitMail), ok (email verification with passwordInit)", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + newAddHumanEvent("", false, true, "", language.English), + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + 1*time.Hour, + "", + false, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: false, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, { name: "add human (with password and email code custom template), ok", fields: fields{ @@ -609,7 +668,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { }, }, { - name: "add human email verified, ok", + name: "add human email verified and password, ok", fields: fields{ eventstore: expectEventstore( expectFilter(), @@ -1084,8 +1143,9 @@ func TestCommandSide_AddUserHuman(t *testing.T) { }, wantID: "user1", }, - }, { - name: "add human (with return code), ok", + }, + { + name: "add human (with phone return code), ok", fields: fields{ eventstore: expectEventstore( expectFilter(), diff --git a/internal/domain/next_step.go b/internal/domain/next_step.go index c87101758..009989956 100644 --- a/internal/domain/next_step.go +++ b/internal/domain/next_step.go @@ -137,7 +137,9 @@ func (s *ChangeUsernameStep) Type() NextStepType { return NextStepChangeUsername } -type VerifyEMailStep struct{} +type VerifyEMailStep struct { + InitPassword bool +} func (s *VerifyEMailStep) Type() NextStepType { return NextStepVerifyEmail