From e4a4b7cfbe532d302be8d922359864bb004daeb2 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 26 Apr 2023 07:47:57 +0200 Subject: [PATCH] feat(api): add user creation to user service (#5745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(proto): update versions * change protoc plugin * some cleanups * define api for setting emails in new api * implement user.SetEmail * move SetEmail buisiness logic into command * resuse newCryptoCode * command: add ChangeEmail unit tests Not complete, was not able to mock the generator. * Revert "resuse newCryptoCode" This reverts commit c89e90ae35ae924a3f706a0a7394f933910c2e65. * undo change to crypto code generators * command: use a generator so we can test properly * command: reorganise ChangeEmail improve test coverage * implement VerifyEmail including unit tests * add URL template tests * begin user creation * change protos * implement metadata and move context * merge commands * proto: change context to object * remove old auth option * remove old auth option * fix linting errors run gci on modified files * add permission checks and fix some errors * comments * comments * update email requests * rename proto requests * cleanup and docs * simplify * simplify * fix setup * remove unused proto messages / fields --------- Co-authored-by: adlerhurst Co-authored-by: Tim Möhlmann --- docs/docusaurus.config.js | 7 + docs/sidebars.js | 16 +- internal/api/grpc/management/user.go | 11 +- .../server/middleware/auth_interceptor.go | 7 + internal/api/grpc/user/v2/email.go | 2 +- internal/api/grpc/user/v2/user.go | 107 ++++ internal/api/grpc/user/v2/user_test.go | 80 +++ internal/command/command.go | 2 + internal/command/crypto.go | 23 +- internal/command/email.go | 9 +- internal/command/instance.go | 3 +- internal/command/org.go | 2 +- internal/command/phone.go | 3 +- internal/command/user.go | 2 +- internal/command/user_human.go | 289 ++++++---- internal/command/user_human_test.go | 521 ++++++++++++++---- internal/domain/human.go | 5 - .../notification/handlers/usernotifier.go | 1 - pkg/grpc/user/v2alpha/user.go | 5 + proto/zitadel/object/v2alpha/object.proto | 6 +- proto/zitadel/user/v2alpha/email.proto | 17 + proto/zitadel/user/v2alpha/password.proto | 53 ++ proto/zitadel/user/v2alpha/user.proto | 81 +++ proto/zitadel/user/v2alpha/user_service.proto | 149 +++++ 24 files changed, 1175 insertions(+), 226 deletions(-) create mode 100644 internal/api/grpc/user/v2/user.go create mode 100644 internal/api/grpc/user/v2/user_test.go create mode 100644 pkg/grpc/user/v2alpha/user.go create mode 100644 proto/zitadel/user/v2alpha/password.proto diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 43cfb96a2c..a660dae88d 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -259,6 +259,13 @@ module.exports = { sidebarOptions: { groupPathsBy: "tag", }, + }, + user: { + specPath: ".artifacts/openapi/zitadel/user/v2alpha/user_service.swagger.json", + outputDir: "docs/apis/user_service", + sidebarOptions: { + groupPathsBy: "tag", + }, } } }, diff --git a/docs/sidebars.js b/docs/sidebars.js index 6c478cf380..82def05fd0 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -374,6 +374,20 @@ module.exports = { }, items: require("./docs/apis/system/sidebar.js"), }, + { + type: "category", + label: "User Lifecycle (Alpha)", + link: { + type: "generated-index", + title: "User Service API (Alpha)", + slug: "/apis/user_service", + description: + "This API is intended to manage users in a ZITADEL instance.\n"+ + "\n"+ + "This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.", + }, + items: require("./docs/apis/user_service/sidebar.js"), + }, { type: "category", label: "Assets", @@ -508,4 +522,4 @@ module.exports = { ], }, ], -}; \ No newline at end of file +}; diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 58092dc739..4c81243fb6 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -210,17 +210,14 @@ func (s *Server) BulkRemoveUserMetadata(ctx context.Context, req *mgmt_pb.BulkRe } func (s *Server) AddHumanUser(ctx context.Context, req *mgmt_pb.AddHumanUserRequest) (*mgmt_pb.AddHumanUserResponse, error) { - details, err := s.command.AddHuman(ctx, authz.GetCtxData(ctx).OrgID, AddHumanUserRequestToAddHuman(req)) + human := AddHumanUserRequestToAddHuman(req) + err := s.command.AddHuman(ctx, authz.GetCtxData(ctx).OrgID, human, true) if err != nil { return nil, err } return &mgmt_pb.AddHumanUserResponse{ - UserId: details.ID, - Details: obj_grpc.AddToDetailsPb( - details.Sequence, - details.EventDate, - details.ResourceOwner, - ), + UserId: human.ID, + Details: obj_grpc.DomainToAddDetailsPb(human.Details), }, nil } diff --git a/internal/api/grpc/server/middleware/auth_interceptor.go b/internal/api/grpc/server/middleware/auth_interceptor.go index e1c6e6e02d..f02a822d86 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor.go +++ b/internal/api/grpc/server/middleware/auth_interceptor.go @@ -34,6 +34,9 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, } orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID) + if o, ok := req.(AuthContext); ok { + orgID = o.AuthContext() + } ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, verifier, authConfig, authOpt, info.FullMethod) if err != nil { @@ -42,3 +45,7 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, span.End() return handler(ctxSetter(ctx), req) } + +type AuthContext interface { + AuthContext() string +} diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 94530167cd..95928e2e61 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -12,7 +12,7 @@ import ( ) func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { - resourceOwner := "" // TODO: check if still needed + var resourceOwner string // TODO: check if still needed var email *domain.Email switch v := req.GetVerification().(type) { diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go new file mode 100644 index 0000000000..a058e7e750 --- /dev/null +++ b/internal/api/grpc/user/v2/user.go @@ -0,0 +1,107 @@ +package user + +import ( + "context" + "io" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { + human, err := addUserRequestToAddHuman(req) + if err != nil { + return nil, err + } + err = s.command.AddHuman(ctx, req.GetOrganisation().GetOrgId(), human, false) + if err != nil { + return nil, err + } + return &user.AddHumanUserResponse{ + UserId: human.ID, + Details: object.DomainToDetailsPb(human.Details), + EmailCode: human.EmailCode, + }, nil +} + +func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { + username := req.GetUsername() + if username == "" { + username = req.GetEmail().GetEmail() + } + var urlTemplate string + if req.GetEmail().GetSendCode() != nil { + urlTemplate = req.GetEmail().GetSendCode().GetUrlTemplate() + // test the template execution so the async notification will not fail because of it and the user won't realize + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil { + return nil, err + } + } + bcryptedPassword, err := hashedPasswordToCommand(req.GetHashedPassword()) + if err != nil { + return nil, err + } + passwordChangeRequired := req.GetPassword().GetChangeRequired() || req.GetHashedPassword().GetChangeRequired() + metadata := make([]*command.AddMetadataEntry, len(req.Metadata)) + for i, metadataEntry := range req.Metadata { + metadata[i] = &command.AddMetadataEntry{ + Key: metadataEntry.GetKey(), + Value: metadataEntry.GetValue(), + } + } + return &command.AddHuman{ + ID: req.GetUserId(), + Username: username, + FirstName: req.GetProfile().GetFirstName(), + LastName: req.GetProfile().GetLastName(), + NickName: req.GetProfile().GetNickName(), + DisplayName: req.GetProfile().GetDisplayName(), + Email: command.Email{ + Address: domain.EmailAddress(req.GetEmail().GetEmail()), + Verified: req.GetEmail().GetIsVerified(), + ReturnCode: req.GetEmail().GetReturnCode() != nil, + URLTemplate: urlTemplate, + }, + PreferredLanguage: language.Make(req.GetProfile().GetPreferredLanguage()), + Gender: genderToDomain(req.GetProfile().GetGender()), + Phone: command.Phone{}, // TODO: add as soon as possible + Password: req.GetPassword().GetPassword(), + BcryptedPassword: bcryptedPassword, + PasswordChangeRequired: passwordChangeRequired, + Passwordless: false, + ExternalIDP: false, + Register: false, + Metadata: metadata, + }, nil +} + +func genderToDomain(gender user.Gender) domain.Gender { + switch gender { + case user.Gender_GENDER_UNSPECIFIED: + return domain.GenderUnspecified + case user.Gender_GENDER_FEMALE: + return domain.GenderFemale + case user.Gender_GENDER_MALE: + return domain.GenderMale + case user.Gender_GENDER_DIVERSE: + return domain.GenderDiverse + default: + return domain.GenderUnspecified + } +} + +func hashedPasswordToCommand(hashed *user.HashedPassword) (string, error) { + if hashed == nil { + return "", nil + } + // we currently only handle bcrypt + if hashed.GetAlgorithm() != "bcrypt" { + return "", errors.ThrowInvalidArgument(nil, "USER-JDk4t", "Errors.InvalidArgument") + } + return hashed.GetHash(), nil +} diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go new file mode 100644 index 0000000000..d697e9ae4f --- /dev/null +++ b/internal/api/grpc/user/v2/user_test.go @@ -0,0 +1,80 @@ +package user + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + caos_errs "github.com/zitadel/zitadel/internal/errors" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func Test_hashedPasswordToCommand(t *testing.T) { + type args struct { + hashed *user.HashedPassword + } + type res struct { + want string + err func(error) bool + } + tests := []struct { + name string + args args + res res + }{ + { + "not hashed", + args{ + hashed: nil, + }, + res{ + "", + nil, + }, + }, + { + "hashed, not bcrypt", + args{ + hashed: &user.HashedPassword{ + Hash: "hash", + Algorithm: "custom", + }, + }, + res{ + "", + func(err error) bool { + return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "USER-JDk4t", "Errors.InvalidArgument")) + }, + }, + }, + { + "hashed, bcrypt", + args{ + hashed: &user.HashedPassword{ + Hash: "hash", + Algorithm: "bcrypt", + }, + }, + res{ + "hash", + nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := hashedPasswordToCommand(tt.args.hashed) + if tt.res.err == nil { + require.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} diff --git a/internal/command/command.go b/internal/command/command.go index 4499b84b51..82f2a4c455 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -30,6 +30,7 @@ type Commands struct { httpClient *http.Client checkPermission permissionCheck + newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) eventstore *eventstore.Eventstore static static.Storage @@ -109,6 +110,7 @@ func StartCommands( checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf) }, + newEmailCode: newEmailCode, } instance_repo.RegisterEventMappers(repo.eventstore) diff --git a/internal/command/crypto.go b/internal/command/crypto.go index 2313e86b62..404b2221f9 100644 --- a/internal/command/crypto.go +++ b/internal/command/crypto.go @@ -10,24 +10,33 @@ import ( "github.com/zitadel/zitadel/internal/errors" ) -func newCryptoCodeWithExpiry(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (value *crypto.CryptoValue, expiry time.Duration, err error) { +type CryptoCodeWithExpiry struct { + Crypted *crypto.CryptoValue + Plain string + Expiry time.Duration +} + +func newCryptoCodeWithExpiry(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error) { config, err := secretGeneratorConfig(ctx, filter, typ) if err != nil { - return nil, -1, err + return nil, err } + code := new(CryptoCodeWithExpiry) switch a := alg.(type) { case crypto.HashAlgorithm: - value, _, err = crypto.NewCode(crypto.NewHashGenerator(*config, a)) + code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewHashGenerator(*config, a)) case crypto.EncryptionAlgorithm: - value, _, err = crypto.NewCode(crypto.NewEncryptionGenerator(*config, a)) + code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewEncryptionGenerator(*config, a)) default: - return nil, -1, errors.ThrowInternal(nil, "COMMA-RreV6", "Errors.Internal") + return nil, errors.ThrowInternal(nil, "COMMA-RreV6", "Errors.Internal") } if err != nil { - return nil, -1, err + return nil, err } - return value, config.Expiry, nil + + code.Expiry = config.Expiry + return code, nil } func newCryptoCodeWithPlain(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (value *crypto.CryptoValue, plain string, err error) { diff --git a/internal/command/email.go b/internal/command/email.go index 4e2c3d973b..0bfbcd6af6 100644 --- a/internal/command/email.go +++ b/internal/command/email.go @@ -2,7 +2,6 @@ package command import ( "context" - "time" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" @@ -12,12 +11,18 @@ import ( type Email struct { Address domain.EmailAddress Verified bool + + // ReturnCode is used if the Verified field is false + ReturnCode bool + + // URLTemplate can be used to specify a custom link to be sent in the mail verification + URLTemplate string } func (e *Email) Validate() error { return e.Address.Validate() } -func newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (value *crypto.CryptoValue, expiry time.Duration, err error) { +func newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) { return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg) } diff --git a/internal/command/instance.go b/internal/command/instance.go index 436b61b2bf..b8e00c1c0a 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -333,8 +333,9 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str validations = append(validations, prepareAddUserMachineKey(machineKey, c.machineKeySize)) } } else if setup.Org.Human != nil { + setup.Org.Human.ID = userAgg.ID validations = append(validations, - AddHumanCommand(userAgg, setup.Org.Human, c.userPasswordAlg, c.userEncryption), + c.AddHumanCommand(userAgg, setup.Org.Human, c.userPasswordAlg, c.userEncryption, true), ) } diff --git a/internal/command/org.go b/internal/command/org.go index 04f38877d4..71e2c3e185 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -51,7 +51,7 @@ func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID, user var pat *PersonalAccessToken var machineKey *MachineKey if o.Human != nil { - validations = append(validations, AddHumanCommand(userAgg, o.Human, c.userPasswordAlg, c.userEncryption)) + validations = append(validations, c.AddHumanCommand(userAgg, o.Human, c.userPasswordAlg, c.userEncryption, true)) } else if o.Machine != nil { validations = append(validations, AddMachineCommand(userAgg, o.Machine.Machine)) if o.Machine.Pat != nil { diff --git a/internal/command/phone.go b/internal/command/phone.go index 109a60975e..30cabb6fcb 100644 --- a/internal/command/phone.go +++ b/internal/command/phone.go @@ -2,7 +2,6 @@ package command import ( "context" - "time" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" @@ -14,6 +13,6 @@ type Phone struct { Verified bool } -func newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (value *crypto.CryptoValue, expiry time.Duration, err error) { +func newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) { return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeVerifyPhoneCode, alg) } diff --git a/internal/command/user.go b/internal/command/user.go index 96ca0d1e68..34ba7c521a 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -439,7 +439,7 @@ func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id return exists, nil } -func newUserInitCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (value *crypto.CryptoValue, expiry time.Duration, err error) { +func newUserInitCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) { return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeInitCode, alg) } diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 3d84eeb12b..688beaa3ff 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -27,6 +27,8 @@ func (c *Commands) getHuman(ctx context.Context, userID, resourceowner string) ( } type AddHuman struct { + // ID is optional + ID string // Username is required Username string // FirstName is required @@ -43,63 +45,99 @@ type AddHuman struct { PreferredLanguage language.Tag // Gender is required Gender domain.Gender - //Phone represents an international phone number + // Phone represents an international phone number Phone Phone - //Password is optional + // Password is optional Password string - //BcryptedPassword is optional + // BcryptedPassword is optional BcryptedPassword string - //PasswordChangeRequired is used if the `Password`-field is set + // PasswordChangeRequired is used if the `Password`-field is set PasswordChangeRequired bool Passwordless bool ExternalIDP bool Register bool + Metadata []*AddMetadataEntry + + // Details are set after a successful execution of the command + Details *domain.ObjectDetails + + // EmailCode is set by the command + EmailCode *string } -func (c *Commands) AddHumanWithID(ctx context.Context, resourceOwner string, userID string, human *AddHuman) (*domain.HumanDetails, error) { - existingHuman, err := c.getHumanWriteModelByID(ctx, userID, resourceOwner) - if err != nil { - return nil, err +func (h *AddHuman) Validate() (err error) { + if err := h.Email.Validate(); err != nil { + return err } - if isUserStateExists(existingHuman.UserState) { - return nil, errors.ThrowPreconditionFailed(nil, "COMMAND-k2unb", "Errors.User.AlreadyExisting") + if h.Username = strings.TrimSpace(h.Username); h.Username == "" { + return errors.ThrowInvalidArgument(nil, "V2-zzad3", "Errors.Invalid.Argument") } - return c.addHumanWithID(ctx, resourceOwner, userID, human) + if h.FirstName = strings.TrimSpace(h.FirstName); h.FirstName == "" { + return errors.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty") + } + if h.LastName = strings.TrimSpace(h.LastName); h.LastName == "" { + return errors.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty") + } + h.ensureDisplayName() + + if h.Phone.Number != "" { + if h.Phone.Number, err = h.Phone.Number.Normalize(); err != nil { + return err + } + } + + for _, metadataEntry := range h.Metadata { + if err := metadataEntry.Valid(); err != nil { + return err + } + } + return nil } -func (c *Commands) addHumanWithID(ctx context.Context, resourceOwner string, userID string, human *AddHuman) (*domain.HumanDetails, error) { - agg := user.NewAggregate(userID, resourceOwner) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, AddHumanCommand(agg, human, c.userPasswordAlg, c.userEncryption)) +type AddMetadataEntry struct { + Key string + Value []byte +} + +func (m *AddMetadataEntry) Valid() error { + if m.Key = strings.TrimSpace(m.Key); m.Key == "" { + return errors.ThrowInvalidArgument(nil, "USER-Drght", "Errors.User.Metadata.KeyEmpty") + } + if len(m.Value) == 0 { + return errors.ThrowInvalidArgument(nil, "USER-Dbgth", "Errors.User.Metadata.ValueEmpty") + } + return nil +} + +func (c *Commands) AddHuman(ctx context.Context, resourceOwner string, human *AddHuman, allowInitMail bool) (err error) { + if resourceOwner == "" { + return errors.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal") + } + agg := user.NewAggregate(human.ID, resourceOwner) + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, + c.AddHumanCommand( + agg, + human, + c.userPasswordAlg, + c.userEncryption, + allowInitMail, + )) if err != nil { - return nil, err + return err } events, err := c.eventstore.Push(ctx, cmds...) if err != nil { - return nil, err + return err + } + human.Details = &domain.ObjectDetails{ + Sequence: events[len(events)-1].Sequence(), + EventDate: events[len(events)-1].CreationDate(), + ResourceOwner: events[len(events)-1].Aggregate().ResourceOwner, } - return &domain.HumanDetails{ - ID: userID, - ObjectDetails: domain.ObjectDetails{ - Sequence: events[len(events)-1].Sequence(), - EventDate: events[len(events)-1].CreationDate(), - ResourceOwner: events[len(events)-1].Aggregate().ResourceOwner, - }, - }, nil -} - -func (c *Commands) AddHuman(ctx context.Context, resourceOwner string, human *AddHuman) (*domain.HumanDetails, error) { - if resourceOwner == "" { - return nil, errors.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal") - } - userID, err := c.idGenerator.Next() - if err != nil { - return nil, err - } - - return c.addHumanWithID(ctx, resourceOwner, userID, human) + return nil } type humanCreationCommand interface { @@ -108,30 +146,17 @@ type humanCreationCommand interface { AddPasswordData(secret *crypto.CryptoValue, changeRequired bool) } -func AddHumanCommand(a *user.Aggregate, human *AddHuman, passwordAlg crypto.HashAlgorithm, codeAlg crypto.EncryptionAlgorithm) preparation.Validation { +func (c *Commands) AddHumanCommand(a *user.Aggregate, human *AddHuman, passwordAlg crypto.HashAlgorithm, codeAlg crypto.EncryptionAlgorithm, allowInitMail bool) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if err := human.Email.Validate(); err != nil { + if err := human.Validate(); err != nil { return nil, err } - if human.Username = strings.TrimSpace(human.Username); human.Username == "" { - return nil, errors.ThrowInvalidArgument(nil, "V2-zzad3", "Errors.Invalid.Argument") - } - - if human.FirstName = strings.TrimSpace(human.FirstName); human.FirstName == "" { - return nil, errors.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty") - } - if human.LastName = strings.TrimSpace(human.LastName); human.LastName == "" { - return nil, errors.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty") - } - human.ensureDisplayName() - - if human.Phone.Number != "" { - if human.Phone.Number, err = human.Phone.Number.Normalize(); err != nil { - return nil, err - } - } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + if err := c.addHumanCommandCheckID(ctx, filter, a, human); err != nil { + return nil, err + } + domainPolicy, err := domainPolicyWriteModel(ctx, filter, a.ResourceOwner) if err != nil { return nil, err @@ -176,55 +201,30 @@ func AddHumanCommand(a *user.Aggregate, human *AddHuman, passwordAlg crypto.Hash createCmd.AddPhoneData(human.Phone.Number) } - if human.Password != "" { - if err = humanValidatePassword(ctx, filter, human.Password); err != nil { - return nil, err - } - - secret, err := crypto.Hash([]byte(human.Password), passwordAlg) - if err != nil { - return nil, err - } - createCmd.AddPasswordData(secret, human.PasswordChangeRequired) - } - - if human.BcryptedPassword != "" { - createCmd.AddPasswordData(crypto.FillHash([]byte(human.BcryptedPassword), passwordAlg), human.PasswordChangeRequired) + if err := addHumanCommandPassword(ctx, filter, createCmd, human, passwordAlg); err != nil { + return nil, err } cmds := make([]eventstore.Command, 0, 3) cmds = append(cmds, createCmd) - if human.Email.Verified { - cmds = append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &a.Aggregate)) - } - //add init code if - // email not verified or - // user not registered and password set - if human.shouldAddInitCode() { - value, expiry, err := newUserInitCode(ctx, filter, codeAlg) - if err != nil { - return nil, err - } - cmds = append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, value, expiry)) - } else { - if !human.Email.Verified { - value, expiry, err := newEmailCode(ctx, filter, codeAlg) - if err != nil { - return nil, err - } - cmds = append(cmds, user.NewHumanEmailCodeAddedEvent(ctx, &a.Aggregate, value, expiry)) - } + cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, a, human, codeAlg, allowInitMail) + if err != nil { + return nil, err } - if human.Phone.Verified { - cmds = append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &a.Aggregate)) - } else if human.Phone.Number != "" { - value, expiry, err := newPhoneCode(ctx, filter, codeAlg) - if err != nil { - return nil, err - } - cmds = append(cmds, user.NewHumanPhoneCodeAddedEvent(ctx, &a.Aggregate, value, expiry)) + cmds, err = c.addHumanCommandPhone(ctx, filter, cmds, a, human, codeAlg) + if err != nil { + return nil, err + } + + for _, metadataEntry := range human.Metadata { + cmds = append(cmds, user.NewMetadataSetEvent( + ctx, + &a.Aggregate, + metadataEntry.Key, + metadataEntry.Value, + )) } return cmds, nil @@ -232,6 +232,87 @@ func AddHumanCommand(a *user.Aggregate, human *AddHuman, passwordAlg crypto.Hash } } +func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation.FilterToQueryReducer, cmds []eventstore.Command, a *user.Aggregate, human *AddHuman, codeAlg crypto.EncryptionAlgorithm, allowInitMail bool) ([]eventstore.Command, error) { + if human.Email.Verified { + cmds = append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &a.Aggregate)) + } + + // if allowInitMail, used for v1 api (system, admin, mgmt, auth): + // add init code if + // email not verified or + // user not registered and password set + if allowInitMail && human.shouldAddInitCode() { + initCode, err := newUserInitCode(ctx, filter, codeAlg) + if err != nil { + return nil, err + } + return append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, initCode.Crypted, initCode.Expiry)), nil + } + if !human.Email.Verified { + emailCode, err := c.newEmailCode(ctx, filter, codeAlg) + if err != nil { + return nil, err + } + if human.Email.ReturnCode { + human.EmailCode = &emailCode.Plain + } + return append(cmds, user.NewHumanEmailCodeAddedEventV2(ctx, &a.Aggregate, emailCode.Crypted, emailCode.Expiry, human.Email.URLTemplate, human.Email.ReturnCode)), nil + } + return cmds, nil +} +func (c *Commands) addHumanCommandPhone(ctx context.Context, filter preparation.FilterToQueryReducer, cmds []eventstore.Command, a *user.Aggregate, human *AddHuman, codeAlg crypto.EncryptionAlgorithm) ([]eventstore.Command, error) { + if human.Phone.Number == "" { + return cmds, nil + } + if human.Phone.Verified { + return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &a.Aggregate)), nil + } + phoneCode, err := newPhoneCode(ctx, filter, codeAlg) + if err != nil { + return nil, err + } + return append(cmds, user.NewHumanPhoneCodeAddedEvent(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry)), nil +} + +func (c *Commands) addHumanCommandCheckID(ctx context.Context, filter preparation.FilterToQueryReducer, a *user.Aggregate, human *AddHuman) (err error) { + if human.ID != "" { + existingHuman, err := humanWriteModelByID(ctx, filter, human.ID, a.ResourceOwner) + if err != nil { + return err + } + if isUserStateExists(existingHuman.UserState) { + return errors.ThrowPreconditionFailed(nil, "COMMAND-k2unb", "Errors.User.AlreadyExisting") + } + return nil + } + human.ID, err = c.idGenerator.Next() + if err != nil { + return err + } + a.ID = human.ID + return nil +} + +func addHumanCommandPassword(ctx context.Context, filter preparation.FilterToQueryReducer, createCmd humanCreationCommand, human *AddHuman, passwordAlg crypto.HashAlgorithm) (err error) { + if human.Password != "" { + if err = humanValidatePassword(ctx, filter, human.Password); err != nil { + return err + } + + secret, err := crypto.Hash([]byte(human.Password), passwordAlg) + if err != nil { + return err + } + createCmd.AddPasswordData(secret, human.PasswordChangeRequired) + return nil + } + + if human.BcryptedPassword != "" { + createCmd.AddPasswordData(crypto.FillHash([]byte(human.BcryptedPassword), passwordAlg), human.PasswordChangeRequired) + } + return nil +} + func userValidateDomain(ctx context.Context, a *user.Aggregate, username string, mustBeDomain bool, filter preparation.FilterToQueryReducer) error { if mustBeDomain { return nil @@ -651,3 +732,17 @@ func (c *Commands) getHumanWriteModelByID(ctx context.Context, userID, resourceo } return humanWriteModel, nil } + +func humanWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, resourceowner string) (*HumanWriteModel, error) { + humanWriteModel := NewHumanWriteModel(userID, resourceowner) + events, err := filter(ctx, humanWriteModel.Query()) + if err != nil { + return nil, err + } + if len(events) == 0 { + return humanWriteModel, nil + } + humanWriteModel.AppendEvents(events...) + err = humanWriteModel.Reduce() + return humanWriteModel, err +} diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index b56ed01c1e..7e408ed9b3 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -2,17 +2,19 @@ package command import ( "context" + "errors" "testing" "time" "github.com/golang/mock/gomock" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/errors" + caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -29,16 +31,20 @@ func TestCommandSide_AddHuman(t *testing.T) { idGenerator id.Generator userPasswordAlg crypto.HashAlgorithm codeAlg crypto.EncryptionAlgorithm + newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) } type args struct { ctx context.Context orgID string human *AddHuman secretGenerator crypto.Generator + allowInitMail bool } type res struct { - want *domain.HumanDetails - err func(error) bool + want *domain.ObjectDetails + wantID string + wantEmailCode string + err func(error) bool } userAgg := user.NewAggregate("user1", "org1") @@ -68,9 +74,67 @@ func TestCommandSide_AddHuman(t *testing.T) { Address: "email@test.ch", }, }, + allowInitMail: true, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal")) + }, + }, + }, + { + name: "user invalid, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + }, + allowInitMail: true, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "EMAIL-spblu", "Errors.User.Email.Empty")) + }, + }, + }, + { + name: "with id, already exists, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + newAddHumanEvent("password", true, ""), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + ID: "user1", + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + }, + allowInitMail: true, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-k2unb", "Errors.User.AlreadyExisting")) + }, }, }, { @@ -95,9 +159,12 @@ func TestCommandSide_AddHuman(t *testing.T) { }, PreferredLanguage: language.English, }, + allowInitMail: true, }, res: res{ - err: errors.IsInternal, + err: func(err error) bool { + return errors.Is(err, caos_errs.ThrowInternal(nil, "USER-Ggk9n", "Errors.Internal")) + }, }, }, { @@ -134,30 +201,13 @@ func TestCommandSide_AddHuman(t *testing.T) { }, PreferredLanguage: language.English, }, + allowInitMail: true, }, res: res{ - err: errors.IsInternal, - }, - }, - { - name: "user invalid, invalid argument error", - fields: fields{ - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &AddHuman{ - Username: "username", - FirstName: "firstname", + err: func(err error) bool { + return errors.Is(err, caos_errs.ThrowInternal(nil, "USER-uQ96e", "Errors.Internal")) }, }, - res: res{ - err: errors.IsErrorInvalidArgument, - }, }, { name: "add human (with initial code), ok", @@ -237,16 +287,15 @@ func TestCommandSide_AddHuman(t *testing.T) { PreferredLanguage: language.English, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - Sequence: 0, - EventDate: time.Time{}, - ResourceOwner: "org1", - }, + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", }, + wantID: "user1", }, }, { @@ -330,14 +379,172 @@ func TestCommandSide_AddHuman(t *testing.T) { PreferredLanguage: language.English, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - ResourceOwner: "org1", - }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, + wantID: "user1", + }, + }, + { + name: "add human (with password and email code custom template), ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newAddHumanEvent("password", false, ""), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailCode"), + }, + 1*time.Hour, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}", + false, + ), + ), + }, + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newEmailCode: mockEmailCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}", + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with password and return email code), ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newAddHumanEvent("password", false, ""), + ), + eventFromEventPusher( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailCode"), + }, + 1*time.Hour, + "", + true, + ), + ), + }, + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newEmailCode: mockEmailCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + ReturnCode: true, + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + wantEmailCode: "emailCode", }, }, { @@ -400,14 +607,13 @@ func TestCommandSide_AddHuman(t *testing.T) { PasswordChangeRequired: true, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - ResourceOwner: "org1", - }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, + wantID: "user1", }, }, { @@ -470,14 +676,13 @@ func TestCommandSide_AddHuman(t *testing.T) { PasswordChangeRequired: true, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - ResourceOwner: "org1", - }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, + wantID: "user1", }, }, { @@ -540,14 +745,13 @@ func TestCommandSide_AddHuman(t *testing.T) { PasswordChangeRequired: true, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - ResourceOwner: "org1", - }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, + wantID: "user1", }, }, { @@ -594,9 +798,12 @@ func TestCommandSide_AddHuman(t *testing.T) { PasswordChangeRequired: true, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: func(err error) bool { + return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "COMMAND-SFd21", "Errors.User.DomainNotAllowedAsUsername")) + }, }, }, { @@ -687,15 +894,14 @@ func TestCommandSide_AddHuman(t *testing.T) { PasswordChangeRequired: true, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - ResourceOwner: "org1", - }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, + wantID: "user1", }, }, { @@ -787,14 +993,13 @@ func TestCommandSide_AddHuman(t *testing.T) { PreferredLanguage: language.English, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - ResourceOwner: "org1", - }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, + wantID: "user1", }, }, { @@ -875,14 +1080,104 @@ func TestCommandSide_AddHuman(t *testing.T) { PreferredLanguage: language.English, }, secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, }, res: res{ - want: &domain.HumanDetails{ - ID: "user1", - ObjectDetails: domain.ObjectDetails{ - ResourceOwner: "org1", + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human with metadata, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent( + context.Background(), + &instanceAgg.Aggregate, + domain.SecretGeneratorTypeInitCode, + 0, + 1*time.Hour, + true, + true, + true, + true, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newAddHumanEvent("", false, ""), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent( + context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte(""), + }, + 1*time.Hour, + ), + ), + eventFromEventPusher( + user.NewMetadataSetEvent( + context.Background(), + &userAgg.Aggregate, + "testKey", + []byte("testValue"), + ), + ), + }, + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + Metadata: []*AddMetadataEntry{ + { + Key: "testKey", + Value: []byte("testValue"), + }, }, }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", }, }, } @@ -893,8 +1188,9 @@ func TestCommandSide_AddHuman(t *testing.T) { userPasswordAlg: tt.fields.userPasswordAlg, userEncryption: tt.fields.codeAlg, idGenerator: tt.fields.idGenerator, + newEmailCode: tt.fields.newEmailCode, } - got, err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human) + err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail) if tt.res.err == nil { if !assert.NoError(t, err) { t.FailNow() @@ -904,7 +1200,9 @@ func TestCommandSide_AddHuman(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assert.Equal(t, tt.res.want, tt.args.human.Details) + assert.Equal(t, tt.res.wantID, tt.args.human.ID) + assert.Equal(t, tt.res.wantEmailCode, gu.Value(tt.args.human.EmailCode)) } }) } @@ -958,7 +1256,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { }, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -985,7 +1283,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { }, }, res: res{ - err: errors.IsPreconditionFailed, + err: caos_errs.IsPreconditionFailed, }, }, { @@ -1022,7 +1320,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { }, }, res: res{ - err: errors.IsPreconditionFailed, + err: caos_errs.IsPreconditionFailed, }, }, { @@ -1065,7 +1363,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { }, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -1869,7 +2167,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -1899,7 +2197,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, res: res{ - err: errors.IsPreconditionFailed, + err: caos_errs.IsPreconditionFailed, }, }, { @@ -1939,7 +2237,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, res: res{ - err: errors.IsPreconditionFailed, + err: caos_errs.IsPreconditionFailed, }, }, { @@ -1987,7 +2285,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, res: res{ - err: errors.IsPreconditionFailed, + err: caos_errs.IsPreconditionFailed, }, }, { @@ -2056,7 +2354,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, res: res{ - err: errors.IsPreconditionFailed, + err: caos_errs.IsPreconditionFailed, }, }, { @@ -2125,7 +2423,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -2211,7 +2509,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -3147,7 +3445,7 @@ func TestCommandSide_HumanMFASkip(t *testing.T) { userID: "", }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -3164,7 +3462,7 @@ func TestCommandSide_HumanMFASkip(t *testing.T) { userID: "user1", }, res: res{ - err: errors.IsNotFound, + err: caos_errs.IsNotFound, }, }, { @@ -3261,7 +3559,7 @@ func TestCommandSide_HumanSignOut(t *testing.T) { userIDs: []string{"user1"}, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -3277,7 +3575,7 @@ func TestCommandSide_HumanSignOut(t *testing.T) { userIDs: []string{}, }, res: res{ - err: errors.IsErrorInvalidArgument, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -3479,18 +3777,23 @@ func newRegisterHumanEvent(username, password string, changeRequired bool, phone } func TestAddHumanCommand(t *testing.T) { + type fields struct { + idGenerator id.Generator + } type args struct { - a *user.Aggregate - human *AddHuman - passwordAlg crypto.HashAlgorithm - filter preparation.FilterToQueryReducer - codeAlg crypto.EncryptionAlgorithm + a *user.Aggregate + human *AddHuman + passwordAlg crypto.HashAlgorithm + filter preparation.FilterToQueryReducer + codeAlg crypto.EncryptionAlgorithm + allowInitMail bool } agg := user.NewAggregate("id", "ro") tests := []struct { - name string - args args - want Want + name string + fields fields + args args + want Want }{ { name: "invalid email", @@ -3503,7 +3806,7 @@ func TestAddHumanCommand(t *testing.T) { }, }, want: Want{ - ValidationErr: errors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid"), + ValidationErr: caos_errs.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid"), }, }, { @@ -3519,7 +3822,7 @@ func TestAddHumanCommand(t *testing.T) { }, }, want: Want{ - ValidationErr: errors.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty"), + ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty"), }, }, { @@ -3534,11 +3837,14 @@ func TestAddHumanCommand(t *testing.T) { }, }, want: Want{ - ValidationErr: errors.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty"), + ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty"), }, }, { name: "invalid password", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"), + }, args: args{ a: agg, human: &AddHuman{ @@ -3578,11 +3884,14 @@ func TestAddHumanCommand(t *testing.T) { Filter(), }, want: Want{ - CreateErr: errors.ThrowInvalidArgument(nil, "COMMA-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"), + CreateErr: caos_errs.ThrowInvalidArgument(nil, "COMMA-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"), }, }, { name: "correct", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"), + }, args: args{ a: agg, human: &AddHuman{ @@ -3654,7 +3963,25 @@ func TestAddHumanCommand(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - AssertValidation(t, context.Background(), AddHumanCommand(tt.args.a, tt.args.human, tt.args.passwordAlg, tt.args.codeAlg), tt.args.filter, tt.want) + c := &Commands{ + idGenerator: tt.fields.idGenerator, + } + AssertValidation(t, context.Background(), c.AddHumanCommand(tt.args.a, tt.args.human, tt.args.passwordAlg, tt.args.codeAlg, tt.args.allowInitMail), tt.args.filter, tt.want) }) } } + +func mockEmailCode(code string, exp time.Duration) func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) { + return func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) { + return &CryptoCodeWithExpiry{ + Crypted: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte(code), + }, + Plain: code, + Expiry: exp, + }, nil + } +} diff --git a/internal/domain/human.go b/internal/domain/human.go index 05e56ace67..619eaf4807 100644 --- a/internal/domain/human.go +++ b/internal/domain/human.go @@ -10,11 +10,6 @@ import ( es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) -type HumanDetails struct { - ID string - ObjectDetails -} - type Human struct { es_models.ObjectRoot diff --git a/internal/notification/handlers/usernotifier.go b/internal/notification/handlers/usernotifier.go index 9a779c6cdc..10b0952cee 100644 --- a/internal/notification/handlers/usernotifier.go +++ b/internal/notification/handlers/usernotifier.go @@ -185,7 +185,6 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St if e.CodeReturned { return crdb.NewNoOpStatement(e), nil } - ctx := HandlerContext(event.Aggregate()) alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType, diff --git a/pkg/grpc/user/v2alpha/user.go b/pkg/grpc/user/v2alpha/user.go new file mode 100644 index 0000000000..f419594dfb --- /dev/null +++ b/pkg/grpc/user/v2alpha/user.go @@ -0,0 +1,5 @@ +package user + +func (r *AddHumanUserRequest) AuthContext() string { + return r.GetOrganisation().GetOrgId() +} diff --git a/proto/zitadel/object/v2alpha/object.proto b/proto/zitadel/object/v2alpha/object.proto index c11b122575..3a209b6371 100644 --- a/proto/zitadel/object/v2alpha/object.proto +++ b/proto/zitadel/object/v2alpha/object.proto @@ -6,11 +6,11 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha;object"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; -message OrgContext { - oneof ctx { +message Organisation { + oneof org { string org_id = 1; - string org_domain = 2; } } diff --git a/proto/zitadel/user/v2alpha/email.proto b/proto/zitadel/user/v2alpha/email.proto index daa744fe14..151348b55a 100644 --- a/proto/zitadel/user/v2alpha/email.proto +++ b/proto/zitadel/user/v2alpha/email.proto @@ -9,6 +9,23 @@ import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; +message SetHumanEmail { + string email = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200, email: true}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"mini@mouse.com\""; + } + ]; + // if no verification is specified, an email is sent with the default url + oneof verification { + SendEmailVerificationCode send_code = 2; + ReturnEmailVerificationCode return_code = 3; + bool is_verified = 4 [(validate.rules).bool.const = true]; + } +} message SendEmailVerificationCode { optional string url_template = 1 [ diff --git a/proto/zitadel/user/v2alpha/password.proto b/proto/zitadel/user/v2alpha/password.proto new file mode 100644 index 0000000000..eb168f0436 --- /dev/null +++ b/proto/zitadel/user/v2alpha/password.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package zitadel.user.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user"; + +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +message SetUserPassword { + oneof type { + Password password = 1; + HashedPassword hashed_password = 2; + } +} + +message Password { + string password = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Secr3tP4ssw0rd!\""; + min_length: 1, + max_length: 200; + } + ]; + bool change_required = 2; +} + +message HashedPassword { + string hash = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"$2a$12$lJ08fqVr8bFJilRVnDT9QeULI7YW.nT3iwUv6dyg0aCrfm3UY8XR2\""; + description: "\"hashed password\""; + min_length: 1, + max_length: 200; + } + ]; + string algorithm = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200, const: "bcrypt"}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"bcrypt\""; + description: "\"algorithm used for the hash. currently only bcrypt is supported\""; + min_length: 1, + max_length: 200; + } + ]; + bool change_required = 3; +} diff --git a/proto/zitadel/user/v2alpha/user.proto b/proto/zitadel/user/v2alpha/user.proto index 9b8426f517..d6312eb3e1 100644 --- a/proto/zitadel/user/v2alpha/user.proto +++ b/proto/zitadel/user/v2alpha/user.proto @@ -4,6 +4,87 @@ package zitadel.user.v2alpha; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + message User { string id = 1; } + +enum Gender { + GENDER_UNSPECIFIED = 0; + GENDER_FEMALE = 1; + GENDER_MALE = 2; + GENDER_DIVERSE = 3; +} + +message SetHumanProfile { + string first_name = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Minnie\""; + } + ]; + string last_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Mouse\""; + } + ]; + optional string nick_name = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Mini\""; + } + ]; + optional string display_name = 4 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Minnie Mouse\""; + } + ]; + optional string preferred_language = 5 [ + (validate.rules).string = {max_len: 10}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 10; + example: "\"en\""; + } + ]; + optional zitadel.user.v2alpha.Gender gender = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"GENDER_FEMALE\""; + } + ]; +} + + +message SetMetadataEntry { + string key = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"my-key\""; + min_length: 1, + max_length: 200; + } + ]; + bytes value = 2 [ + (validate.rules).bytes = {min_len: 1, max_len: 500000}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The value has to be base64 encoded."; + example: "\"VGhpcyBpcyBteSB0ZXN0IHZhbHVl\""; + min_length: 1, + max_length: 500000; + } + ]; +} diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index a9a1f26d49..2a4629779e 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -5,6 +5,8 @@ package zitadel.user.v2alpha; import "zitadel/options.proto"; import "zitadel/object/v2alpha/object.proto"; import "zitadel/user/v2alpha/email.proto"; +import "zitadel/user/v2alpha/password.proto"; +import "zitadel/user/v2alpha/user.proto"; import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; @@ -12,8 +14,91 @@ import "validate/validate.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user"; +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "User Service"; + version: "2.0-alpha"; + description: "This API is intended to manage users in a ZITADEL instance. This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$ZITADEL_DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + service UserService { + // Create a new human user + rpc AddHumanUser (AddHumanUserRequest) returns (AddHumanUserResponse) { + option (google.api.http) = { + post: "/v2alpha/users/human" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "user.write" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Create a user (Human)"; + description: "Create/import a new user with the type human. The newly created user will get a verification email if either the email address is not marked as verified and you did not request the verification to be returned." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Change the email of a user rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) { option (google.api.http) = { post: "/v2alpha/users/{user_id}/email" @@ -23,8 +108,20 @@ service UserService { option (zitadel.v1.auth_option) = { permission: "authenticated" }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Change the user email"; + description: "Change the email address of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by email." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; } + // Verify the email with the provided code rpc VerifyEmail (VerifyEmailRequest) returns (VerifyEmailResponse) { option (google.api.http) = { post: "/v2alpha/users/{user_id}/email/_verify" @@ -34,9 +131,61 @@ service UserService { option (zitadel.v1.auth_option) = { permission: "authenticated" }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Verify the email"; + description: "Verify the email with the generated code." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; } } +message AddHumanUserRequest{ + // optionally set your own id unique for the user + optional string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + // optionally set a unique username, if none is provided the email will be used + optional string username = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"minnie-mouse\""; + } + ]; + zitadel.object.v2alpha.Organisation organisation = 3; + SetHumanProfile profile = 4 [ + (validate.rules).message.required = true, + (google.api.field_behavior) = REQUIRED + ]; + SetHumanEmail email = 5 [ + (validate.rules).message.required = true, + (google.api.field_behavior) = REQUIRED + ]; + repeated SetMetadataEntry metadata = 6; + oneof password_type { + Password password = 7; + HashedPassword hashed_password = 8; + } +} + +message AddHumanUserResponse { + string user_id = 1; + zitadel.object.v2alpha.Details details = 2; + optional string email_code = 3; +} + message SetEmailRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200},