diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 9d599fe04a..f513b40bd4 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -356,6 +356,51 @@ SystemDefaults: PasswordSaltCost: 14 MachineKeySize: 2048 ApplicationKeySize: 2048 + PasswordHasher: + # Set hasher configuration for user passwords. + # Passwords previously hashed with a different algorithm + # or cost are automatically re-hashed using this config, + # upon password validation or update. + Hasher: + Algorithm: "bcrypt" + Cost: 14 + + # Other supported Hasher configs: + + # Hasher: + # Algorithm: "argon2i" + # Time: 3 + # Memory: 32768 + # Threads: 4 + + # Hasher: + # Algorithm: "argon2id" + # Time: 1 + # Memory: 65536 + # Threads: 4 + + # Hasher: + # Algorithm: "scrypt" + # Cost: 15 + + # Verifiers enable the possibility of verifying + # passwords that are previously hashed using another + # algorithm then the Hasher. + # This can be used when migrating from one algorithm to another, + # or when importing users with hashed passwords. + # There is no need to enable a Verifier of the same algorithm + # as the Hasher. + # + # The format of the encoded hash strings must comply + # with https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md + # https://passlib.readthedocs.io/en/stable/modular_crypt_format.html + # + # Supported verifiers: (uncomment to enable) + # Verifiers: + # - "argon2" # verifier for both argon2i and argon2id. + # - "bcrypt" + # - "md5" + # - "scrypt" Multifactors: OTP: # If this is empty, the issuer is the requested domain diff --git a/cmd/setup/config_change.go b/cmd/setup/config_change.go index 7f35f7ee74..28d66394c8 100644 --- a/cmd/setup/config_change.go +++ b/cmd/setup/config_change.go @@ -17,6 +17,7 @@ type externalConfigChange struct { currentExternalDomain string currentExternalSecure bool currentExternalPort uint16 + defaults systemdefaults.SystemDefaults } func (mig *externalConfigChange) SetLastExecution(lastRun map[string]interface{}) { @@ -35,7 +36,7 @@ func (mig *externalConfigChange) Check() bool { func (mig *externalConfigChange) Execute(ctx context.Context) error { cmd, err := command.StartCommands( mig.es, - systemdefaults.SystemDefaults{}, + mig.defaults, nil, nil, nil, diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 76f9637a2e..25f2f4fa4f 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -104,6 +104,7 @@ func Setup(config *Config, steps *Steps, masterKey string) { ExternalDomain: config.ExternalDomain, ExternalPort: config.ExternalPort, ExternalSecure: config.ExternalSecure, + defaults: config.SystemDefaults, }, &projectionTables{ es: eventstoreClient, diff --git a/go.mod b/go.mod index 962e107dd3..db53ffc42e 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( github.com/ttacon/libphonenumber v1.2.1 github.com/zitadel/logging v0.3.4 github.com/zitadel/oidc/v2 v2.6.3 + github.com/zitadel/passwap v0.2.0 github.com/zitadel/saml v0.0.11 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.40.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.40.0 diff --git a/go.sum b/go.sum index 0c5921a608..323c6e5170 100644 --- a/go.sum +++ b/go.sum @@ -911,6 +911,8 @@ github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0= github.com/zitadel/oidc/v2 v2.6.3 h1:YY87cAcdI+3voZqcRU2RGz3Pxky/2KsjDmYDVb6EgWw= github.com/zitadel/oidc/v2 v2.6.3/go.mod h1:2LrbdKYLSgKxXBfct56ev4e186J7TXotlZxb6tExOO4= +github.com/zitadel/passwap v0.2.0 h1:rkYrax9hfRIpVdXJ7pS8JHkQOhuQTdZQxEhsY0dFFrU= +github.com/zitadel/passwap v0.2.0/go.mod h1:KRTL4LL8ugJIn2xLoQYZf5t4kDyr7w41uq3XqvUlO6w= github.com/zitadel/saml v0.0.11 h1:kObucnBrcu1PHCO7RGT0iVeuJL/5I50gUgr40S41nMs= github.com/zitadel/saml v0.0.11/go.mod h1:YGWAvPZRv4DbEZ78Ht/2P0AWzGn+6WGhFf90PMXl0Po= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index bd5387364a..1c0503fa7d 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -577,15 +577,14 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w } if withPasswords { ctx, pwspan := tracing.NewSpan(ctx) - hashedPassword, hashAlgorithm, err := s.query.GetHumanPassword(ctx, org, user.ID) + encodedHash, err := s.query.GetHumanPassword(ctx, org, user.ID) pwspan.EndWithError(err) if err != nil && !caos_errors.IsNotFound(err) { return nil, nil, nil, nil, err } - if err == nil && hashedPassword != nil { + if err == nil && encodedHash != "" { dataUser.User.HashedPassword = &management_pb.ImportHumanUserRequest_HashedPassword{ - Value: string(hashedPassword), - Algorithm: hashAlgorithm, + Value: encodedHash, } } } diff --git a/internal/api/grpc/management/user_converter.go b/internal/api/grpc/management/user_converter.go index 3c969c2a1c..a3f7760b06 100644 --- a/internal/api/grpc/management/user_converter.go +++ b/internal/api/grpc/management/user_converter.go @@ -121,9 +121,7 @@ func ImportHumanUserRequestToDomain(req *mgmt_pb.ImportHumanUserRequest) (human human.Password.ChangeRequired = req.PasswordChangeRequired } - if req.HashedPassword != nil && req.HashedPassword.Value != "" && req.HashedPassword.Algorithm != "" { - human.HashedPassword = domain.NewHashedPassword(req.HashedPassword.Value, req.HashedPassword.Algorithm) - } + human.HashedPassword = req.GetHashedPassword().GetValue() links = make([]*domain.UserIDPLink, len(req.Idps)) for i, idp := range req.Idps { links[i] = &domain.UserIDPLink{ diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 9e28b50291..b115d83ebb 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -48,10 +48,6 @@ func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, 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 { @@ -85,7 +81,7 @@ func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, Gender: genderToDomain(req.GetProfile().GetGender()), Phone: command.Phone{}, // TODO: add as soon as possible Password: req.GetPassword().GetPassword(), - BcryptedPassword: bcryptedPassword, + EncodedPasswordHash: req.GetHashedPassword().GetHash(), PasswordChangeRequired: passwordChangeRequired, Passwordless: false, Register: false, @@ -109,17 +105,6 @@ func genderToDomain(gender user.Gender) domain.Gender { } } -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 -} - func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { orgID := authz.GetCtxData(ctx).OrgID details, err := s.command.AddUserIDPLink(ctx, req.UserId, orgID, &domain.UserIDPLink{ diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index 299027d4ed..a23ebbad9b 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -392,6 +392,79 @@ func TestServer_AddHumanUser(t *testing.T) { }, }, }, + { + name: "hashed password", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "unsupported hashed password", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + wantErr: true, + }, } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go index 8332c87030..fa3ce0b0da 100644 --- a/internal/api/grpc/user/v2/user_test.go +++ b/internal/api/grpc/user/v2/user_test.go @@ -1,7 +1,6 @@ package user import ( - "errors" "testing" "time" @@ -25,74 +24,6 @@ import ( var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration", "google.protobuf.Struct"} -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) - } - }) - } -} - func Test_intentToIDPInformationPb(t *testing.T) { decryption := func(err error) crypto.EncryptionAlgorithm { mCrypto := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) diff --git a/internal/command/command.go b/internal/command/command.go index 2c89b78a2d..9a6a87cb75 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -49,7 +49,8 @@ type Commands struct { smtpEncryption crypto.EncryptionAlgorithm smsEncryption crypto.EncryptionAlgorithm userEncryption crypto.EncryptionAlgorithm - userPasswordAlg crypto.HashAlgorithm + userPasswordHasher *crypto.PasswordHasher + codeAlg crypto.HashAlgorithm machineKeySize int applicationKeySize int domainVerificationAlg crypto.EncryptionAlgorithm @@ -140,7 +141,11 @@ func StartCommands( oidcsession.RegisterEventMappers(repo.eventstore) milestone.RegisterEventMappers(repo.eventstore) - repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost) + repo.codeAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost) + repo.userPasswordHasher, err = defaults.PasswordHasher.PasswordHasher() + if err != nil { + return nil, err + } repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize) repo.applicationKeySize = int(defaults.SecretGenerators.ApplicationKeySize) diff --git a/internal/command/instance.go b/internal/command/instance.go index 0bfd1591a0..b1d5d5ded8 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -335,7 +335,7 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str } else if setup.Org.Human != nil { setup.Org.Human.ID = userID validations = append(validations, - c.AddHumanCommand(setup.Org.Human, orgID, c.userPasswordAlg, c.userEncryption, true), + c.AddHumanCommand(setup.Org.Human, orgID, c.userPasswordHasher, c.userEncryption, true), ) } diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 9f778af7bf..d1a2b3de55 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -3,10 +3,13 @@ package command import ( "context" "database/sql" + "strings" "testing" "time" "github.com/golang/mock/gomock" + "github.com/zitadel/passwap" + "github.com/zitadel/passwap/verifier" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" @@ -293,3 +296,38 @@ func newMockPermissionCheckNotAllowed() domain.PermissionCheck { return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") } } + +type plainHasher struct { + x string // arbitrary info that triggers update when different from encoding +} + +func (h plainHasher) Hash(password string) (string, error) { + return strings.Join([]string{"", "plain", h.x, password}, "$"), nil +} + +func (h plainHasher) Verify(encoded, password string) (verifier.Result, error) { + nodes := strings.Split(encoded, "$") + if len(nodes) != 4 || nodes[1] != "plain" { + return verifier.Skip, nil + } + if nodes[3] != password { + return verifier.Fail, nil + } + if nodes[2] != h.x { + return verifier.NeedUpdate, nil + } + return verifier.OK, nil +} + +// mockPasswordHasher creates a swapper for plain (cleartext) password used in tests. +// x can be set to arbitrary info which triggers updates when different from the +// setting in the encoded hashes. (normally cost parameters) +// +// With `x` set to "foo", the following encoded string would be produced by Hash: +// $plain$foo$password +func mockPasswordHasher(x string) *crypto.PasswordHasher { + return &crypto.PasswordHasher{ + Swapper: passwap.NewSwapper(plainHasher{x: x}), + Prefixes: []string{"$plain$"}, + } +} diff --git a/internal/command/org.go b/internal/command/org.go index c9fd3dbd4a..ba64dccb5f 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -42,7 +42,7 @@ func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID strin var pat *PersonalAccessToken if o.Human != nil { o.Human.ID = userID - validations = append(validations, c.AddHumanCommand(o.Human, orgID, c.userPasswordAlg, c.userEncryption, true)) + validations = append(validations, c.AddHumanCommand(o.Human, orgID, c.userPasswordHasher, 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/project_application_api.go b/internal/command/project_application_api.go index 7d1fb45468..16ff044db9 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -244,7 +244,7 @@ func (c *Commands) VerifyAPIClientSecret(ctx context.Context, projectID, appID, projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") - err = crypto.CompareHash(app.ClientSecret, []byte(secret), c.userPasswordAlg) + err = crypto.CompareHash(app.ClientSecret, []byte(secret), c.codeAlg) spanPasswordComparison.EndWithError(err) if err == nil { _, err = c.eventstore.Push(ctx, project_repo.NewAPIConfigSecretCheckSucceededEvent(ctx, projectAgg, app.AppID)) diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 0128657465..2d14ecf3ad 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -326,7 +326,7 @@ func (c *Commands) VerifyOIDCClientSecret(ctx context.Context, projectID, appID, projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") - err = crypto.CompareHash(app.ClientSecret, []byte(secret), c.userPasswordAlg) + err = crypto.CompareHash(app.ClientSecret, []byte(secret), c.codeAlg) spanPasswordComparison.EndWithError(err) if err == nil { _, err = c.eventstore.Push(ctx, project_repo.NewOIDCConfigSecretCheckSucceededEvent(ctx, projectAgg, app.AppID)) diff --git a/internal/command/session.go b/internal/command/session.go index fb1dd42d04..274a46b88d 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -1,6 +1,7 @@ package command import ( + "bytes" "context" "encoding/base64" "fmt" @@ -13,30 +14,34 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" ) type SessionCommand func(ctx context.Context, cmd *SessionCommands) error type SessionCommands struct { - cmds []SessionCommand + sessionCommands []SessionCommand sessionWriteModel *SessionWriteModel passwordWriteModel *HumanPasswordWriteModel intentWriteModel *IDPIntentWriteModel eventstore *eventstore.Eventstore - userPasswordAlg crypto.HashAlgorithm - intentAlg crypto.EncryptionAlgorithm - createToken func(sessionID string) (id string, token string, err error) - now func() time.Time + eventCommands []eventstore.Command + + hasher *crypto.PasswordHasher + intentAlg crypto.EncryptionAlgorithm + createToken func(sessionID string) (id string, token string, err error) + now func() time.Time } func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWriteModel) *SessionCommands { return &SessionCommands{ - cmds: cmds, + sessionCommands: cmds, sessionWriteModel: session, eventstore: c.eventstore, - userPasswordAlg: c.userPasswordAlg, + hasher: c.userPasswordHasher, intentAlg: c.idpConfigEncryption, createToken: c.sessionTokenCreator, now: time.Now, @@ -49,7 +54,7 @@ func CheckUser(id string) SessionCommand { if cmd.sessionWriteModel.UserID != "" && id != "" && cmd.sessionWriteModel.UserID != id { return caos_errs.ThrowInvalidArgument(nil, "", "user change not possible") } - return cmd.sessionWriteModel.UserChecked(ctx, id, cmd.now()) + return cmd.UserChecked(ctx, id, cmd.now()) } } @@ -68,17 +73,21 @@ func CheckPassword(password string) SessionCommand { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.User.NotFound") } - if cmd.passwordWriteModel.Secret == nil { + if cmd.passwordWriteModel.EncodedHash == "" { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-WEf3t", "Errors.User.Password.NotSet") } - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") - err = crypto.CompareHash(cmd.passwordWriteModel.Secret, []byte(password), cmd.userPasswordAlg) + ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") + updated, err := cmd.hasher.Verify(cmd.passwordWriteModel.EncodedHash, password) spanPasswordComparison.EndWithError(err) if err != nil { //TODO: maybe we want to reset the session in the future https://github.com/zitadel/zitadel/issues/5807 return caos_errs.ThrowInvalidArgument(err, "COMMAND-SAF3g", "Errors.User.Password.Invalid") } - cmd.sessionWriteModel.PasswordChecked(ctx, cmd.now()) + if updated != "" { + cmd.eventCommands = append(cmd.eventCommands, user.NewHumanPasswordHashUpdatedEvent(ctx, UserAggregateFromWriteModel(&cmd.passwordWriteModel.WriteModel), updated)) + } + + cmd.PasswordChecked(ctx, cmd.now()) return nil } } @@ -114,14 +123,14 @@ func CheckIntent(intentID, token string) SessionCommand { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser") } } - cmd.sessionWriteModel.IntentChecked(ctx, cmd.now()) + cmd.IntentChecked(ctx, cmd.now()) return nil } } // Exec will execute the commands specified and returns an error on the first occurrence func (s *SessionCommands) Exec(ctx context.Context) error { - for _, cmd := range s.cmds { + for _, cmd := range s.sessionCommands { if err := cmd(ctx, s); err != nil { return err } @@ -129,6 +138,66 @@ func (s *SessionCommands) Exec(ctx context.Context) error { return nil } +func (s *SessionCommands) Start(ctx context.Context, domain string) { + s.eventCommands = append(s.eventCommands, session.NewAddedEvent(ctx, s.sessionWriteModel.aggregate, domain)) + // set the domain so checks can use it + s.sessionWriteModel.Domain = domain +} + +func (s *SessionCommands) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error { + s.eventCommands = append(s.eventCommands, session.NewUserCheckedEvent(ctx, s.sessionWriteModel.aggregate, userID, checkedAt)) + // set the userID so other checks can use it + s.sessionWriteModel.UserID = userID + return nil +} + +func (s *SessionCommands) PasswordChecked(ctx context.Context, checkedAt time.Time) { + s.eventCommands = append(s.eventCommands, session.NewPasswordCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) +} + +func (s *SessionCommands) IntentChecked(ctx context.Context, checkedAt time.Time) { + s.eventCommands = append(s.eventCommands, session.NewIntentCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) +} + +func (s *SessionCommands) PasskeyChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement) { + s.eventCommands = append(s.eventCommands, session.NewPasskeyChallengedEvent(ctx, s.sessionWriteModel.aggregate, challenge, allowedCrentialIDs, userVerification)) +} + +func (s *SessionCommands) PasskeyChecked(ctx context.Context, checkedAt time.Time, tokenID string, signCount uint32) { + s.eventCommands = append(s.eventCommands, + session.NewPasskeyCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt), + usr_repo.NewHumanPasswordlessSignCountChangedEvent(ctx, s.sessionWriteModel.aggregate, tokenID, signCount), + ) +} + +func (s *SessionCommands) SetToken(ctx context.Context, tokenID string) { + s.eventCommands = append(s.eventCommands, session.NewTokenSetEvent(ctx, s.sessionWriteModel.aggregate, tokenID)) +} + +func (s *SessionCommands) ChangeMetadata(ctx context.Context, metadata map[string][]byte) { + var changed bool + for key, value := range metadata { + currentValue, exists := s.sessionWriteModel.Metadata[key] + + if len(value) != 0 { + // if a value is provided, and it's not equal, change it + if !bytes.Equal(currentValue, value) { + s.sessionWriteModel.Metadata[key] = value + changed = true + } + } else { + // if there's no / an empty value, we only need to remove it on existing entries + if exists { + delete(s.sessionWriteModel.Metadata, key) + changed = true + } + } + } + if changed { + s.eventCommands = append(s.eventCommands, session.NewMetadataSetEvent(ctx, s.sessionWriteModel.aggregate, s.sessionWriteModel.Metadata)) + } +} + func (s *SessionCommands) gethumanWriteModel(ctx context.Context) (*HumanWriteModel, error) { if s.sessionWriteModel.UserID == "" { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing") @@ -145,7 +214,7 @@ func (s *SessionCommands) gethumanWriteModel(ctx context.Context) (*HumanWriteMo } func (s *SessionCommands) commands(ctx context.Context) (string, []eventstore.Command, error) { - if len(s.sessionWriteModel.commands) == 0 { + if len(s.eventCommands) == 0 { return "", nil, nil } @@ -153,8 +222,8 @@ func (s *SessionCommands) commands(ctx context.Context) (string, []eventstore.Co if err != nil { return "", nil, err } - s.sessionWriteModel.SetToken(ctx, tokenID) - return token, s.sessionWriteModel.commands, nil + s.SetToken(ctx, tokenID) + return token, s.eventCommands, nil } func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, sessionDomain string, metadata map[string][]byte) (set *SessionChanged, err error) { @@ -167,8 +236,8 @@ func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, ses if err != nil { return nil, err } - sessionWriteModel.Start(ctx, sessionDomain) cmd := c.NewSessionCommands(cmds, sessionWriteModel) + cmd.Start(ctx, sessionDomain) return c.updateSession(ctx, cmd, metadata) } @@ -217,7 +286,7 @@ func (c *Commands) updateSession(ctx context.Context, checks *SessionCommands, m // TODO: how to handle failed checks (e.g. pw wrong) https://github.com/zitadel/zitadel/issues/5807 return nil, err } - checks.sessionWriteModel.ChangeMetadata(ctx, metadata) + checks.ChangeMetadata(ctx, metadata) sessionToken, cmds, err := checks.commands(ctx) if err != nil { return nil, err diff --git a/internal/command/session_model.go b/internal/command/session_model.go index 261de4cd98..dce787ff76 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -1,15 +1,12 @@ package command import ( - "bytes" - "context" "time" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/session" - usr_repo "github.com/zitadel/zitadel/internal/repository/user" ) type PasskeyChallengeModel struct { @@ -48,7 +45,6 @@ type SessionWriteModel struct { PasskeyChallenge *PasskeyChallengeModel - commands []eventstore.Command aggregate *eventstore.Aggregate } @@ -151,66 +147,6 @@ func (wm *SessionWriteModel) reduceTerminate() { wm.State = domain.SessionStateTerminated } -func (wm *SessionWriteModel) Start(ctx context.Context, domain string) { - wm.commands = append(wm.commands, session.NewAddedEvent(ctx, wm.aggregate, domain)) - // set the domain so checks can use it - wm.Domain = domain -} - -func (wm *SessionWriteModel) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error { - wm.commands = append(wm.commands, session.NewUserCheckedEvent(ctx, wm.aggregate, userID, checkedAt)) - // set the userID so other checks can use it - wm.UserID = userID - return nil -} - -func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time.Time) { - wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt)) -} - -func (wm *SessionWriteModel) IntentChecked(ctx context.Context, checkedAt time.Time) { - wm.commands = append(wm.commands, session.NewIntentCheckedEvent(ctx, wm.aggregate, checkedAt)) -} - -func (wm *SessionWriteModel) PasskeyChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement) { - wm.commands = append(wm.commands, session.NewPasskeyChallengedEvent(ctx, wm.aggregate, challenge, allowedCrentialIDs, userVerification)) -} - -func (wm *SessionWriteModel) PasskeyChecked(ctx context.Context, checkedAt time.Time, tokenID string, signCount uint32) { - wm.commands = append(wm.commands, - session.NewPasskeyCheckedEvent(ctx, wm.aggregate, checkedAt), - usr_repo.NewHumanPasswordlessSignCountChangedEvent(ctx, wm.aggregate, tokenID, signCount), - ) -} - -func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) { - wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID)) -} - -func (wm *SessionWriteModel) ChangeMetadata(ctx context.Context, metadata map[string][]byte) { - var changed bool - for key, value := range metadata { - currentValue, exists := wm.Metadata[key] - - if len(value) != 0 { - // if a value is provided, and it's not equal, change it - if !bytes.Equal(currentValue, value) { - wm.Metadata[key] = value - changed = true - } - } else { - // if there's no / an empty value, we only need to remove it on existing entries - if exists { - delete(wm.Metadata, key) - changed = true - } - } - } - if changed { - wm.commands = append(wm.commands, session.NewMetadataSetEvent(ctx, wm.aggregate, wm.Metadata)) - } -} - // AuthenticationTime returns the time the user authenticated using the latest time of all checks func (wm *SessionWriteModel) AuthenticationTime() time.Time { var authTime time.Time diff --git a/internal/command/session_passkey.go b/internal/command/session_passkey.go index 6de850b23f..6ef3fccb2f 100644 --- a/internal/command/session_passkey.go +++ b/internal/command/session_passkey.go @@ -51,7 +51,7 @@ func (c *Commands) CreatePasskeyChallenge(userVerification domain.UserVerificati return caos_errs.ThrowInternal(err, "COMMAND-Yah6A", "Errors.Internal") } - cmd.sessionWriteModel.PasskeyChallenged(ctx, webAuthNLogin.Challenge, webAuthNLogin.AllowedCredentialIDs, webAuthNLogin.UserVerification) + cmd.PasskeyChallenged(ctx, webAuthNLogin.Challenge, webAuthNLogin.AllowedCredentialIDs, webAuthNLogin.UserVerification) return nil } } @@ -78,7 +78,7 @@ func (c *Commands) CheckPasskey(credentialAssertionData json.Marshaler) SessionC if token == nil { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Aej7i", "Errors.User.WebAuthN.NotFound") } - cmd.sessionWriteModel.PasskeyChecked(ctx, cmd.now(), token.WebAuthNTokenID, signCount) + cmd.PasskeyChecked(ctx, cmd.now(), token.WebAuthNTokenID, signCount) return nil } } diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 92d19ac243..2eee78cc02 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -140,7 +140,6 @@ func TestSessionCommands_getHumanWriteModel(t *testing.T) { func TestCommands_CreateSession(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore idGenerator id.Generator tokenCreator func(sessionID string) (string, string, error) } @@ -158,6 +157,7 @@ func TestCommands_CreateSession(t *testing.T) { name string fields fields args args + expect []expect res res }{ { @@ -168,6 +168,7 @@ func TestCommands_CreateSession(t *testing.T) { args{ ctx: context.Background(), }, + []expect{}, res{ err: caos_errs.ThrowInternal(nil, "id", "generator failed"), }, @@ -176,13 +177,13 @@ func TestCommands_CreateSession(t *testing.T) { "eventstore failed", fields{ idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), - eventstore: eventstoreExpect(t, - expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), - ), }, args{ ctx: context.Background(), }, + []expect{ + expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), + }, res{ err: caos_errs.ThrowInternal(nil, "id", "filter failed"), }, @@ -191,17 +192,6 @@ func TestCommands_CreateSession(t *testing.T) { "empty session", fields{ idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), - eventstore: eventstoreExpect(t, - expectFilter(), - expectPush( - eventPusherToEvents( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, ""), - session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, - "tokenID", - ), - ), - ), - ), tokenCreator: func(sessionID string) (string, string, error) { return "tokenID", "token", @@ -211,6 +201,17 @@ func TestCommands_CreateSession(t *testing.T) { args{ ctx: authz.NewMockContext("", "org1", ""), }, + []expect{ + expectFilter(), + expectPush( + eventPusherToEvents( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, ""), + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID", + ), + ), + ), + }, res{ want: &SessionChanged{ ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"}, @@ -223,17 +224,6 @@ func TestCommands_CreateSession(t *testing.T) { "empty session with domain", fields{ idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), - eventstore: eventstoreExpect(t, - expectFilter(), - expectPush( - eventPusherToEvents( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld"), - session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, - "tokenID", - ), - ), - ), - ), tokenCreator: func(sessionID string) (string, string, error) { return "tokenID", "token", @@ -244,6 +234,17 @@ func TestCommands_CreateSession(t *testing.T) { ctx: authz.NewMockContext("", "org1", ""), domain: "domain.tld", }, + []expect{ + expectFilter(), + expectPush( + eventPusherToEvents( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld"), + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID", + ), + ), + ), + }, res{ want: &SessionChanged{ ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"}, @@ -257,7 +258,7 @@ func TestCommands_CreateSession(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: eventstoreExpect(t, tt.expect...), idGenerator: tt.fields.idGenerator, sessionTokenCreator: tt.fields.tokenCreator, } @@ -432,7 +433,7 @@ func TestCommands_updateSession(t *testing.T) { ctx: context.Background(), checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - cmds: []SessionCommand{ + sessionCommands: []SessionCommand{ func(ctx context.Context, cmd *SessionCommands) error { return caos_errs.ThrowInternal(nil, "id", "check failed") }, @@ -452,7 +453,7 @@ func TestCommands_updateSession(t *testing.T) { ctx: context.Background(), checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - cmds: []SessionCommand{}, + sessionCommands: []SessionCommand{}, }, }, res{ @@ -487,7 +488,7 @@ func TestCommands_updateSession(t *testing.T) { ctx: context.Background(), checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - cmds: []SessionCommand{ + sessionCommands: []SessionCommand{ CheckUser("userID"), CheckPassword("password"), }, @@ -499,12 +500,7 @@ func TestCommands_updateSession(t *testing.T) { ), eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, false, ""), + "$plain$x$password", false, ""), ), ), ), @@ -513,7 +509,7 @@ func TestCommands_updateSession(t *testing.T) { "token", nil }, - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + hasher: mockPasswordHasher("x"), now: func() time.Time { return testNow }, @@ -541,7 +537,7 @@ func TestCommands_updateSession(t *testing.T) { ctx: context.Background(), checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - cmds: []SessionCommand{ + sessionCommands: []SessionCommand{ CheckUser("userID"), CheckIntent("intent", "aW50ZW50"), }, @@ -580,7 +576,7 @@ func TestCommands_updateSession(t *testing.T) { ctx: context.Background(), checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - cmds: []SessionCommand{ + sessionCommands: []SessionCommand{ CheckUser("userID"), CheckIntent("intent", "aW50ZW50"), }, @@ -629,7 +625,7 @@ func TestCommands_updateSession(t *testing.T) { ctx: context.Background(), checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - cmds: []SessionCommand{ + sessionCommands: []SessionCommand{ CheckUser("userID"), CheckIntent("intent2", "aW50ZW50"), }, @@ -674,7 +670,7 @@ func TestCommands_updateSession(t *testing.T) { ctx: context.Background(), checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - cmds: []SessionCommand{ + sessionCommands: []SessionCommand{ CheckUser("userID"), CheckIntent("intent", "aW50ZW50"), }, diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 6b3015f803..1bbaec3ca1 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -49,8 +49,8 @@ type AddHuman struct { Phone Phone // Password is optional Password string - // BcryptedPassword is optional - BcryptedPassword string + // EncodedPasswordHash is optional + EncodedPasswordHash string // PasswordChangeRequired is used if the `Password`-field is set PasswordChangeRequired bool Passwordless bool @@ -74,7 +74,7 @@ type AddLink struct { IDPExternalID string } -func (h *AddHuman) Validate() (err error) { +func (h *AddHuman) Validate(hasher *crypto.PasswordHasher) (err error) { if err := h.Email.Validate(); err != nil { return err } @@ -101,6 +101,11 @@ func (h *AddHuman) Validate() (err error) { return err } } + if h.EncodedPasswordHash != "" { + if !hasher.EncodingSupported(h.EncodedPasswordHash) { + return errors.ThrowInvalidArgument(nil, "USER-JDk4t", "Errors.User.Password.NotSupported") + } + } return nil } @@ -127,7 +132,7 @@ func (c *Commands) AddHuman(ctx context.Context, resourceOwner string, human *Ad c.AddHumanCommand( human, resourceOwner, - c.userPasswordAlg, + c.userPasswordHasher, c.userEncryption, allowInitMail, )) @@ -151,12 +156,13 @@ func (c *Commands) AddHuman(ctx context.Context, resourceOwner string, human *Ad type humanCreationCommand interface { eventstore.Command AddPhoneData(phoneNumber domain.PhoneNumber) - AddPasswordData(secret *crypto.CryptoValue, changeRequired bool) + AddPasswordData(encoded string, changeRequired bool) } -func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, passwordAlg crypto.HashAlgorithm, codeAlg crypto.EncryptionAlgorithm, allowInitMail bool) preparation.Validation { +//nolint:gocognit +func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, hasher *crypto.PasswordHasher, codeAlg crypto.EncryptionAlgorithm, allowInitMail bool) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if err := human.Validate(); err != nil { + if err := human.Validate(hasher); err != nil { return nil, err } @@ -210,7 +216,7 @@ func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, passwordAlg cr createCmd.AddPhoneData(human.Phone.Number) } - if err := addHumanCommandPassword(ctx, filter, createCmd, human, passwordAlg); err != nil { + if err := addHumanCommandPassword(ctx, filter, createCmd, human, hasher); err != nil { return nil, err } @@ -316,13 +322,13 @@ func (c *Commands) addHumanCommandCheckID(ctx context.Context, filter preparatio return nil } -func addHumanCommandPassword(ctx context.Context, filter preparation.FilterToQueryReducer, createCmd humanCreationCommand, human *AddHuman, passwordAlg crypto.HashAlgorithm) (err error) { +func addHumanCommandPassword(ctx context.Context, filter preparation.FilterToQueryReducer, createCmd humanCreationCommand, human *AddHuman, hasher *crypto.PasswordHasher) (err error) { if human.Password != "" { if err = humanValidatePassword(ctx, filter, human.Password); err != nil { return err } - secret, err := crypto.Hash([]byte(human.Password), passwordAlg) + secret, err := hasher.Hash(human.Password) if err != nil { return err } @@ -330,8 +336,8 @@ func addHumanCommandPassword(ctx context.Context, filter preparation.FilterToQue return nil } - if human.BcryptedPassword != "" { - createCmd.AddPasswordData(crypto.FillHash([]byte(human.BcryptedPassword), passwordAlg), human.PasswordChangeRequired) + if human.EncodedPasswordHash != "" { + createCmd.AddPasswordData(human.EncodedPasswordHash, human.PasswordChangeRequired) } return nil } @@ -578,7 +584,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. human.EnsureDisplayName() if human.Password != nil { - if err := human.HashPasswordIfExisting(pwPolicy, c.userPasswordAlg, human.Password.ChangeRequired); err != nil { + if err := human.HashPasswordIfExisting(pwPolicy, c.userPasswordHasher, human.Password.ChangeRequired); err != nil { return nil, nil, err } } @@ -677,10 +683,10 @@ func createAddHumanEvent(ctx context.Context, aggregate *eventstore.Aggregate, h human.StreetAddress) } if human.Password != nil { - addEvent.AddPasswordData(human.Password.SecretCrypto, human.Password.ChangeRequired) + addEvent.AddPasswordData(human.Password.EncodedSecret, human.Password.ChangeRequired) } - if human.HashedPassword != nil { - addEvent.AddPasswordData(human.HashedPassword.SecretCrypto, false) + if human.HashedPassword != "" { + addEvent.AddPasswordData(human.HashedPassword, false) } return addEvent } @@ -711,10 +717,10 @@ func createRegisterHumanEvent(ctx context.Context, aggregate *eventstore.Aggrega human.StreetAddress) } if human.Password != nil { - addEvent.AddPasswordData(human.Password.SecretCrypto, human.Password.ChangeRequired) + addEvent.AddPasswordData(human.Password.EncodedSecret, human.Password.ChangeRequired) } - if human.HashedPassword != nil { - addEvent.AddPasswordData(human.HashedPassword.SecretCrypto, false) + if human.HashedPassword != "" { + addEvent.AddPasswordData(human.HashedPassword, false) } return addEvent } diff --git a/internal/command/user_human_init.go b/internal/command/user_human_init.go index 74deb4ceda..5af8eb9a53 100644 --- a/internal/command/user_human_init.go +++ b/internal/command/user_human_init.go @@ -4,6 +4,7 @@ import ( "context" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" @@ -49,7 +50,7 @@ func (c *Commands) ResendInitialMail(ctx context.Context, userID string, email d return writeModelToObjectDetails(&existingCode.WriteModel), nil } -func (c *Commands) HumanVerifyInitCode(ctx context.Context, userID, resourceOwner, code, passwordString string, initCodeGenerator crypto.Generator) error { +func (c *Commands) HumanVerifyInitCode(ctx context.Context, userID, resourceOwner, code, password string, initCodeGenerator crypto.Generator) error { if userID == "" { return caos_errs.ThrowInvalidArgument(nil, "COMMAND-mkM9f", "Errors.User.UserIDMissing") } @@ -72,26 +73,22 @@ func (c *Commands) HumanVerifyInitCode(ctx context.Context, userID, resourceOwne logging.WithFields("userID", userAgg.ID).OnError(err).Error("NewHumanInitializedCheckFailedEvent push failed") return caos_errs.ThrowInvalidArgument(err, "COMMAND-11v6G", "Errors.User.Code.Invalid") } - events := []eventstore.Command{ + commands := []eventstore.Command{ user.NewHumanInitializedCheckSucceededEvent(ctx, userAgg), } if !existingCode.IsEmailVerified { - events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg)) + commands = append(commands, user.NewHumanEmailVerifiedEvent(ctx, userAgg)) } - if passwordString != "" { + if password != "" { passwordWriteModel := NewHumanPasswordWriteModel(userID, existingCode.ResourceOwner) passwordWriteModel.UserState = domain.UserStateActive - password := &domain.Password{ - SecretString: passwordString, - ChangeRequired: false, - } - passwordEvent, err := c.changePassword(ctx, "", password, userAgg, passwordWriteModel) + passwordCommand, err := c.setPasswordCommand(ctx, passwordWriteModel, password, false) if err != nil { return err } - events = append(events, passwordEvent) + commands = append(commands, passwordCommand) } - _, err = c.eventstore.Push(ctx, events...) + _, err = c.eventstore.Push(ctx, commands...) return err } diff --git a/internal/command/user_human_init_test.go b/internal/command/user_human_init_test.go index 838459bd2c..69fce83668 100644 --- a/internal/command/user_human_init_test.go +++ b/internal/command/user_human_init_test.go @@ -5,7 +5,6 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "golang.org/x/text/language" @@ -305,8 +304,8 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { func TestCommandSide_VerifyInitCode(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - userPasswordAlg crypto.HashAlgorithm + eventstore *eventstore.Eventstore + userPasswordHasher *crypto.PasswordHasher } type args struct { ctx context.Context @@ -584,18 +583,13 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, + "$plain$x$password", false, "")), }, ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -615,8 +609,8 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - userPasswordAlg: tt.fields.userPasswordAlg, + eventstore: tt.fields.eventstore, + userPasswordHasher: tt.fields.userPasswordHasher, } err := r.HumanVerifyInitCode(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.code, tt.args.password, tt.args.secretGenerator) if tt.res.err == nil { diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index 8d54727e2c..0a2992a46e 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -2,8 +2,10 @@ package command import ( "context" + "errors" "github.com/zitadel/logging" + "github.com/zitadel/passwap" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" @@ -13,80 +15,75 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func (c *Commands) SetPassword(ctx context.Context, orgID, userID, passwordString string, oneTime bool) (objectDetails *domain.ObjectDetails, err error) { +func (c *Commands) SetPassword(ctx context.Context, orgID, userID, password string, oneTime bool) (objectDetails *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M0fs", "Errors.IDMissing") } - existingPassword, err := c.passwordWriteModel(ctx, userID, orgID) + wm, err := c.passwordWriteModel(ctx, userID, orgID) if err != nil { return nil, err } - if !existingPassword.UserState.Exists() { + if !wm.UserState.Exists() { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M0fs", "Errors.User.NotFound") } - if err = c.checkPermission(ctx, domain.PermissionUserWrite, existingPassword.ResourceOwner, userID); err != nil { + if err = c.checkPermission(ctx, domain.PermissionUserWrite, wm.ResourceOwner, userID); err != nil { return nil, err } - password := &domain.Password{ - SecretString: passwordString, - ChangeRequired: oneTime, - } - userAgg := UserAggregateFromWriteModel(&existingPassword.WriteModel) - passwordEvent, err := c.changePassword(ctx, "", password, userAgg, existingPassword) - if err != nil { - return nil, err - } - pushedEvents, err := c.eventstore.Push(ctx, passwordEvent) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingPassword, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&existingPassword.WriteModel), nil + return c.setPassword(ctx, wm, password, oneTime) } -func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, passwordString, userAgentID string) (objectDetails *domain.ObjectDetails, err error) { +func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, password, userAgentID string) (objectDetails *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M9fs", "Errors.IDMissing") } - if passwordString == "" { + if password == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Mf0sd", "Errors.User.Password.Empty") } - existingCode, err := c.passwordWriteModel(ctx, userID, orgID) + wm, err := c.passwordWriteModel(ctx, userID, orgID) if err != nil { return nil, err } - if existingCode.Code == nil || existingCode.UserState == domain.UserStateUnspecified || existingCode.UserState == domain.UserStateDeleted { + if wm.Code == nil || wm.UserState == domain.UserStateUnspecified || wm.UserState == domain.UserStateDeleted { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound") } - err = crypto.VerifyCodeWithAlgorithm(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, c.userEncryption) + err = crypto.VerifyCodeWithAlgorithm(wm.CodeCreationDate, wm.CodeExpiry, wm.Code, code, c.userEncryption) if err != nil { return nil, err } - password := &domain.Password{ - SecretString: passwordString, - ChangeRequired: false, - } - userAgg := UserAggregateFromWriteModel(&existingCode.WriteModel) - passwordEvent, err := c.changePassword(ctx, userAgentID, password, userAgg, existingCode) + return c.setPassword(ctx, wm, password, false) +} + +func (c *Commands) setPassword(ctx context.Context, wm *HumanPasswordWriteModel, password string, changeRequired bool) (objectDetails *domain.ObjectDetails, err error) { + command, err := c.setPasswordCommand(ctx, wm, password, changeRequired) if err != nil { return nil, err } - err = c.pushAppendAndReduce(ctx, existingCode, passwordEvent) + err = c.pushAppendAndReduce(ctx, wm, command) if err != nil { return nil, err } - return writeModelToObjectDetails(&existingCode.WriteModel), nil + return writeModelToObjectDetails(&wm.WriteModel), nil +} + +func (c *Commands) setPasswordCommand(ctx context.Context, wm *HumanPasswordWriteModel, password string, changeRequired bool) (_ eventstore.Command, err error) { + if err = c.canUpdatePassword(ctx, password, wm); err != nil { + return nil, err + } + ctx, span := tracing.NewNamedSpan(ctx, "passwap.Hash") + encoded, err := c.userPasswordHasher.Hash(password) + span.EndWithError(err) + if err = convertPasswapErr(err); err != nil { + return nil, err + } + return user.NewHumanPasswordChangedEvent(ctx, UserAggregateFromWriteModel(&wm.WriteModel), encoded, changeRequired, ""), nil } func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPassword, newPassword, userAgentID string) (objectDetails *domain.ObjectDetails, err error) { @@ -99,59 +96,50 @@ func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPasswor if oldPassword == "" || newPassword == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M0fs", "Errors.User.Password.Empty") } - existingPassword, err := c.passwordWriteModel(ctx, userID, orgID) + wm, err := c.passwordWriteModel(ctx, userID, orgID) if err != nil { return nil, err } - if existingPassword.Secret == nil { + if wm.EncodedHash == "" { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Fds3s", "Errors.User.Password.Empty") } - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") - err = crypto.CompareHash(existingPassword.Secret, []byte(oldPassword), c.userPasswordAlg) - spanPasswordComparison.EndWithError(err) - - if err != nil { - return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M0fs", "Errors.User.Password.Invalid") - } - password := &domain.Password{ - SecretString: newPassword, - ChangeRequired: false, + if err = c.canUpdatePassword(ctx, newPassword, wm); err != nil { + return nil, err } - userAgg := UserAggregateFromWriteModel(&existingPassword.WriteModel) - command, err := c.changePassword(ctx, userAgentID, password, userAgg, existingPassword) + ctx, spanPasswap := tracing.NewNamedSpan(ctx, "passwap.VerifyAndUpdate") + updated, err := c.userPasswordHasher.VerifyAndUpdate(wm.EncodedHash, oldPassword, newPassword) + spanPasswap.EndWithError(err) + if err = convertPasswapErr(err); err != nil { + return nil, err + } + err = c.pushAppendAndReduce(ctx, wm, + user.NewHumanPasswordChangedEvent(ctx, UserAggregateFromWriteModel(&wm.WriteModel), updated, false, userAgentID)) if err != nil { return nil, err } - pushedEvents, err := c.eventstore.Push(ctx, command) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingPassword, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&existingPassword.WriteModel), nil + return writeModelToObjectDetails(&wm.WriteModel), nil } -func (c *Commands) changePassword(ctx context.Context, userAgentID string, password *domain.Password, userAgg *eventstore.Aggregate, existingPassword *HumanPasswordWriteModel) (event eventstore.Command, err error) { +func (c *Commands) canUpdatePassword(ctx context.Context, newPassword string, wm *HumanPasswordWriteModel) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if existingPassword.UserState == domain.UserStateUnspecified || existingPassword.UserState == domain.UserStateDeleted { - return nil, caos_errs.ThrowNotFound(nil, "COMMAND-G8dh3", "Errors.User.Password.NotFound") + if wm.UserState == domain.UserStateUnspecified || wm.UserState == domain.UserStateDeleted { + return caos_errs.ThrowNotFound(nil, "COMMAND-G8dh3", "Errors.User.Password.NotFound") } - if existingPassword.UserState == domain.UserStateInitial { - return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-M9dse", "Errors.User.NotInitialised") + if wm.UserState == domain.UserStateInitial { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-M9dse", "Errors.User.NotInitialised") } - pwPolicy, err := c.getOrgPasswordComplexityPolicy(ctx, userAgg.ResourceOwner) + policy, err := c.getOrgPasswordComplexityPolicy(ctx, wm.ResourceOwner) if err != nil { - return nil, err + return err } - if err := password.HashPasswordIfExisting(pwPolicy, c.userPasswordAlg); err != nil { - return nil, err + + if err := policy.Check(newPassword); err != nil { + return err } - return user.NewHumanPasswordChangedEvent(ctx, userAgg, password.SecretCrypto, password.ChangeRequired, userAgentID), nil + return nil } func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, passwordVerificationCode crypto.Generator) (objectDetails *domain.ObjectDetails, err error) { @@ -238,37 +226,44 @@ func (c *Commands) HumanCheckPassword(ctx context.Context, orgID, userID, passwo return caos_errs.ThrowPreconditionFailed(err, "COMMAND-Dft32", "Errors.Org.LoginPolicy.UsernamePasswordNotAllowed") } - existingPassword, err := c.passwordWriteModel(ctx, userID, orgID) + wm, err := c.passwordWriteModel(ctx, userID, orgID) if err != nil { return err } - if existingPassword.UserState == domain.UserStateUnspecified || existingPassword.UserState == domain.UserStateDeleted { + if wm.UserState == domain.UserStateUnspecified || wm.UserState == domain.UserStateDeleted { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.NotFound") } - if existingPassword.Secret == nil { + if wm.EncodedHash == "" { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.Password.NotSet") } - userAgg := UserAggregateFromWriteModel(&existingPassword.WriteModel) - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") - err = crypto.CompareHash(existingPassword.Secret, []byte(password), c.userPasswordAlg) + userAgg := UserAggregateFromWriteModel(&wm.WriteModel) + ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") + updated, err := c.userPasswordHasher.Verify(wm.EncodedHash, password) spanPasswordComparison.EndWithError(err) + err = convertPasswapErr(err) + + commands := make([]eventstore.Command, 0, 2) if err == nil { - _, err = c.eventstore.Push(ctx, user.NewHumanPasswordCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) + commands = append(commands, user.NewHumanPasswordCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) + if updated != "" { + commands = append(commands, user.NewHumanPasswordHashUpdatedEvent(ctx, userAgg, updated)) + } + _, err = c.eventstore.Push(ctx, commands...) return err } - events := make([]eventstore.Command, 0) - events = append(events, user.NewHumanPasswordCheckFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) + + commands = append(commands, user.NewHumanPasswordCheckFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) if lockoutPolicy != nil && lockoutPolicy.MaxPasswordAttempts > 0 { - if existingPassword.PasswordCheckFailedCount+1 >= lockoutPolicy.MaxPasswordAttempts { - events = append(events, user.NewUserLockedEvent(ctx, userAgg)) + if wm.PasswordCheckFailedCount+1 >= lockoutPolicy.MaxPasswordAttempts { + commands = append(commands, user.NewUserLockedEvent(ctx, userAgg)) } } - _, err = c.eventstore.Push(ctx, events...) - logging.Log("COMMAND-9fj7s").OnError(err).Error("error create password check failed event") - return caos_errs.ThrowInvalidArgument(nil, "COMMAND-452ad", "Errors.User.Password.Invalid") + _, pushErr := c.eventstore.Push(ctx, commands...) + logging.OnError(pushErr).Error("error create password check failed event") + return err } func (c *Commands) passwordWriteModel(ctx context.Context, userID, resourceOwner string) (writeModel *HumanPasswordWriteModel, err error) { @@ -282,3 +277,16 @@ func (c *Commands) passwordWriteModel(ctx context.Context, userID, resourceOwner } return writeModel, nil } + +func convertPasswapErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, passwap.ErrPasswordMismatch) { + return caos_errs.ThrowInvalidArgument(err, "COMMAND-3M0fs", "Errors.User.Password.Invalid") + } + if errors.Is(err, passwap.ErrPasswordNoChange) { + return caos_errs.ThrowPreconditionFailed(err, "COMMAND-Aesh5", "Errors.User.Password.NotChanged") + } + return caos_errs.ThrowInternal(err, "COMMAND-CahN2", "Errors.Internal") +} diff --git a/internal/command/user_human_password_model.go b/internal/command/user_human_password_model.go index 68513a8530..c0ca58f58a 100644 --- a/internal/command/user_human_password_model.go +++ b/internal/command/user_human_password_model.go @@ -13,7 +13,7 @@ import ( type HumanPasswordWriteModel struct { eventstore.WriteModel - Secret *crypto.CryptoValue + EncodedHash string SecretChangeRequired bool Code *crypto.CryptoValue @@ -37,11 +37,11 @@ func (wm *HumanPasswordWriteModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { case *user.HumanAddedEvent: - wm.Secret = e.Secret + wm.EncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) wm.SecretChangeRequired = e.ChangeRequired wm.UserState = domain.UserStateActive case *user.HumanRegisteredEvent: - wm.Secret = e.Secret + wm.EncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) wm.SecretChangeRequired = e.ChangeRequired wm.UserState = domain.UserStateActive case *user.HumanInitialCodeAddedEvent: @@ -49,7 +49,7 @@ func (wm *HumanPasswordWriteModel) Reduce() error { case *user.HumanInitializedCheckSucceededEvent: wm.UserState = domain.UserStateActive case *user.HumanPasswordChangedEvent: - wm.Secret = e.Secret + wm.EncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) wm.SecretChangeRequired = e.ChangeRequired wm.Code = nil wm.PasswordCheckFailedCount = 0 @@ -69,6 +69,8 @@ func (wm *HumanPasswordWriteModel) Reduce() error { wm.PasswordCheckFailedCount = 0 case *user.UserRemovedEvent: wm.UserState = domain.UserStateDeleted + case *user.HumanPasswordHashUpdatedEvent: + wm.EncodedHash = e.EncodedHash } } return wm.WriteModel.Reduce() @@ -98,7 +100,8 @@ func (wm *HumanPasswordWriteModel) Query() *eventstore.SearchQueryBuilder { user.UserV1PasswordCodeAddedType, user.UserV1EmailVerifiedType, user.UserV1PasswordCheckFailedType, - user.UserV1PasswordCheckSucceededType). + user.UserV1PasswordCheckSucceededType, + user.UserV1PasswordHashUpdatedType). Builder() if wm.ResourceOwner != "" { diff --git a/internal/command/user_human_password_test.go b/internal/command/user_human_password_test.go index 6eabda9529..27bd02850d 100644 --- a/internal/command/user_human_password_test.go +++ b/internal/command/user_human_password_test.go @@ -3,11 +3,13 @@ package command import ( "context" "errors" + "io" "testing" "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + "github.com/zitadel/passwap" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" @@ -21,9 +23,9 @@ import ( func TestCommandSide_SetOneTimePassword(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - userPasswordAlg crypto.HashAlgorithm - checkPermission domain.PermissionCheck + eventstore *eventstore.Eventstore + userPasswordHasher *crypto.PasswordHasher + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -101,8 +103,8 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { ), ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - checkPermission: newMockPermissionCheckNotAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + checkPermission: newMockPermissionCheckNotAllowed(), }, args: args{ ctx: context.Background(), @@ -160,12 +162,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, + "$plain$x$password", true, "", ), @@ -173,8 +170,8 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { }, ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -232,12 +229,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, + "$plain$x$password", false, "", ), @@ -245,8 +237,8 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { }, ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - checkPermission: newMockPermissionCheckAllowed(), + userPasswordHasher: mockPasswordHasher("x"), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -265,9 +257,9 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - userPasswordAlg: tt.fields.userPasswordAlg, - checkPermission: tt.fields.checkPermission, + eventstore: tt.fields.eventstore, + userPasswordHasher: tt.fields.userPasswordHasher, + checkPermission: tt.fields.checkPermission, } got, err := r.SetPassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.password, tt.args.oneTime) if tt.res.err == nil { @@ -285,9 +277,9 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - userEncryption crypto.EncryptionAlgorithm - userPasswordAlg crypto.HashAlgorithm + eventstore *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + userPasswordHasher *crypto.PasswordHasher } type args struct { ctx context.Context @@ -494,12 +486,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, + "$plain$x$password", false, "", ), @@ -507,8 +494,8 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { }, ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: context.Background(), @@ -527,9 +514,9 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - userPasswordAlg: tt.fields.userPasswordAlg, - userEncryption: tt.fields.userEncryption, + eventstore: tt.fields.eventstore, + userPasswordHasher: tt.fields.userPasswordHasher, + userEncryption: tt.fields.userEncryption, } got, err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID) if tt.res.err == nil { @@ -547,8 +534,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { func TestCommandSide_ChangePassword(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - userPasswordAlg crypto.HashAlgorithm + userPasswordHasher *crypto.PasswordHasher } type args struct { ctx context.Context @@ -566,67 +552,54 @@ func TestCommandSide_ChangePassword(t *testing.T) { name string fields fields args args + expect []expect res res }{ { - name: "userid missing, invalid argument error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, + name: "userid missing, invalid argument error", + fields: fields{}, args: args{ ctx: context.Background(), oldPassword: "password", newPassword: "password1", resourceOwner: "org1", }, + expect: []expect{}, res: res{ err: caos_errs.IsErrorInvalidArgument, }, }, { - name: "old password missing, invalid argument error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, + name: "old password missing, invalid argument error", + fields: fields{}, args: args{ ctx: context.Background(), userID: "user1", newPassword: "password1", resourceOwner: "org1", }, + expect: []expect{}, res: res{ err: caos_errs.IsErrorInvalidArgument, }, }, { - name: "new password missing, invalid argument error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, + name: "new password missing, invalid argument error", + fields: fields{}, args: args{ ctx: context.Background(), userID: "user1", oldPassword: "password", resourceOwner: "org1", }, + expect: []expect{}, res: res{ err: caos_errs.IsErrorInvalidArgument, }, }, { - name: "user not existing, precondition error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), - }, + name: "user not existing, precondition error", + fields: fields{}, args: args{ ctx: context.Background(), userID: "user1", @@ -634,6 +607,9 @@ func TestCommandSide_ChangePassword(t *testing.T) { oldPassword: "password", newPassword: "password1", }, + expect: []expect{ + expectFilter(), + }, res: res{ err: caos_errs.IsPreconditionFailed, }, @@ -641,26 +617,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { { name: "existing password empty, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email@test.ch", - true, - ), - ), - ), - ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -669,49 +626,32 @@ func TestCommandSide_ChangePassword(t *testing.T) { newPassword: "password1", resourceOwner: "org1", }, + expect: []expect{ + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + }, res: res{ err: caos_errs.IsPreconditionFailed, }, }, { - name: "password not matching, precondition error", + name: "password not matching, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email@test.ch", - true, - ), - ), - eventFromEventPusher( - user.NewHumanEmailVerifiedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - ), - ), - eventFromEventPusher( - user.NewHumanPasswordChangedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, - false, - "")), - ), - ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -720,6 +660,47 @@ func TestCommandSide_ChangePassword(t *testing.T) { newPassword: "password1", resourceOwner: "org1", }, + expect: []expect{ + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + false, + "")), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + }, res: res{ err: caos_errs.IsErrorInvalidArgument, }, @@ -727,71 +708,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { { name: "change password, ok", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email@test.ch", - true, - ), - ), - eventFromEventPusher( - user.NewHumanEmailVerifiedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - ), - ), - eventFromEventPusher( - user.NewHumanPasswordChangedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, - false, - "")), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectPush( - []*repository.Event{ - eventFromEventPusher( - user.NewHumanPasswordChangedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password1"), - }, - false, - "", - ), - ), - }, - ), - ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -800,6 +717,59 @@ func TestCommandSide_ChangePassword(t *testing.T) { oldPassword: "password", newPassword: "password1", }, + expect: []expect{ + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + false, + "")), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password1", + false, + "", + ), + ), + }, + ), + }, res: res{ want: &domain.ObjectDetails{ ResourceOwner: "org1", @@ -810,8 +780,8 @@ func TestCommandSide_ChangePassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - userPasswordAlg: tt.fields.userPasswordAlg, + eventstore: eventstoreExpect(t, tt.expect...), + userPasswordHasher: tt.fields.userPasswordHasher, } got, err := r.ChangePassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.oldPassword, tt.args.newPassword, tt.args.agentID) if tt.res.err == nil { @@ -1123,8 +1093,8 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { func TestCommandSide_CheckPassword(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - userPasswordAlg crypto.HashAlgorithm + eventstore *eventstore.Eventstore + userPasswordHasher *crypto.PasswordHasher } type args struct { ctx context.Context @@ -1320,7 +1290,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1383,12 +1353,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, + "$plain$x$password", false, "")), ), @@ -1406,7 +1371,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { }, ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1474,12 +1439,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, + "$plain$x$password", false, "")), ), @@ -1502,7 +1462,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { }, ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1572,12 +1532,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { eventFromEventPusher( user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, + "$plain$x$password", false, "")), ), @@ -1595,7 +1550,195 @@ func TestCommandSide_CheckPassword(t *testing.T) { }, ), ), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + password: "password", + authReq: &domain.AuthRequest{ + ID: "request1", + AgentID: "agent1", + }, + }, + res: res{}, + }, + { + name: "check password, ok, updated hash", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + false, + false, + false, + false, + false, + false, + false, + false, + domain.PasswordlessTypeNotAllowed, + "", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$v$password", + false, + "")), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPasswordCheckSucceededEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "request1", + UserAgentID: "agent1", + }, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordHashUpdatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + ), + ), + }, + ), + ), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + password: "password", + authReq: &domain.AuthRequest{ + ID: "request1", + AgentID: "agent1", + }, + }, + res: res{}, + }, + { + name: "regression test old version event", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + false, + false, + false, + false, + false, + false, + false, + false, + domain.PasswordlessTypeNotAllowed, + "", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + user.HumanPasswordChangedType, + ), + Secret: &crypto.CryptoValue{ + CryptoType: crypto.TypeHash, + Algorithm: "plain", + KeyID: "", + Crypted: []byte("$plain$v$password"), + }, + ChangeRequired: false, + }, + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPasswordCheckSucceededEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "request1", + UserAgentID: "agent1", + }, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordHashUpdatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + ), + ), + }, + ), + ), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1613,8 +1756,8 @@ func TestCommandSide_CheckPassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - userPasswordAlg: tt.fields.userPasswordAlg, + eventstore: tt.fields.eventstore, + userPasswordHasher: tt.fields.userPasswordHasher, } err := r.HumanCheckPassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.password, tt.args.authReq, tt.args.lockoutPolicy) if tt.res.err == nil { @@ -1626,3 +1769,41 @@ func TestCommandSide_CheckPassword(t *testing.T) { }) } } + +func Test_convertPasswapErr(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args args + wantErr error + }{ + { + name: "nil", + args: args{nil}, + wantErr: nil, + }, + { + name: "mismatch", + args: args{passwap.ErrPasswordMismatch}, + wantErr: caos_errs.ThrowInvalidArgument(passwap.ErrPasswordMismatch, "COMMAND-3M0fs", "Errors.User.Password.Invalid"), + }, + { + name: "no change", + args: args{passwap.ErrPasswordNoChange}, + wantErr: caos_errs.ThrowPreconditionFailed(passwap.ErrPasswordNoChange, "COMMAND-Aesh5", "Errors.User.Password.NotChanged"), + }, + { + name: "other", + args: args{io.ErrClosedPipe}, + wantErr: caos_errs.ThrowInternal(io.ErrClosedPipe, "COMMAND-CahN2", "Errors.Internal"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := convertPasswapErr(tt.args.err) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index d996b9768b..4039800977 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -26,11 +26,11 @@ import ( func TestCommandSide_AddHuman(t *testing.T) { type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - idGenerator id.Generator - userPasswordAlg crypto.HashAlgorithm - codeAlg crypto.EncryptionAlgorithm - newCode cryptoCodeFunc + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + userPasswordHasher *crypto.PasswordHasher + codeAlg crypto.EncryptionAlgorithm + newCode cryptoCodeFunc } type args struct { ctx context.Context @@ -104,7 +104,7 @@ func TestCommandSide_AddHuman(t *testing.T) { eventstore: expectEventstore( expectFilter( eventFromEventPusher( - newAddHumanEvent("password", true, ""), + newAddHumanEvent("$plain$x$password", true, ""), ), ), ), @@ -307,7 +307,7 @@ func TestCommandSide_AddHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, ""), + newAddHumanEvent("$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -325,10 +325,10 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockCode("userinit", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockCode("userinit", time.Hour), }, args: args{ ctx: context.Background(), @@ -383,7 +383,7 @@ func TestCommandSide_AddHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, ""), + newAddHumanEvent("$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanEmailCodeAddedEventV2(context.Background(), @@ -403,10 +403,10 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockCode("emailCode", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockCode("emailCode", time.Hour), }, args: args{ ctx: context.Background(), @@ -462,7 +462,7 @@ func TestCommandSide_AddHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, ""), + newAddHumanEvent("$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanEmailCodeAddedEventV2(context.Background(), @@ -482,10 +482,10 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockCode("emailCode", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockCode("emailCode", time.Hour), }, args: args{ ctx: context.Background(), @@ -542,7 +542,7 @@ func TestCommandSide_AddHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", true, ""), + newAddHumanEvent("$plain$x$password", true, ""), ), eventFromEventPusher( user.NewHumanEmailVerifiedEvent(context.Background(), @@ -552,9 +552,9 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: context.Background(), @@ -611,7 +611,7 @@ func TestCommandSide_AddHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", true, ""), + newAddHumanEvent("$plain$x$password", true, ""), ), eventFromEventPusher( user.NewHumanEmailVerifiedEvent(context.Background(), @@ -621,9 +621,9 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: context.Background(), @@ -680,7 +680,7 @@ func TestCommandSide_AddHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", true, ""), + newAddHumanEvent("$plain$x$password", true, ""), ), eventFromEventPusher( user.NewHumanEmailVerifiedEvent(context.Background(), @@ -690,9 +690,9 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", false)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: context.Background(), @@ -743,9 +743,9 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: context.Background(), @@ -822,12 +822,7 @@ func TestCommandSide_AddHuman(t *testing.T) { "email@test.ch", true, ) - event.AddPasswordData(&crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, true) + event.AddPasswordData("$plain$x$password", true) return event }(), ), @@ -839,9 +834,9 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username@test.ch", "org1", false)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: context.Background(), @@ -899,7 +894,7 @@ func TestCommandSide_AddHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, "+41711234567"), + newAddHumanEvent("$plain$x$password", false, "+41711234567"), ), eventFromEventPusher( user.NewHumanEmailVerifiedEvent( @@ -921,10 +916,10 @@ func TestCommandSide_AddHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - newCode: mockCode("phonecode", time.Hour), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockCode("phonecode", time.Hour), }, args: args{ ctx: context.Background(), @@ -1107,11 +1102,11 @@ func TestCommandSide_AddHuman(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore(t), - userPasswordAlg: tt.fields.userPasswordAlg, - userEncryption: tt.fields.codeAlg, - idGenerator: tt.fields.idGenerator, - newCode: tt.fields.newCode, + eventstore: tt.fields.eventstore(t), + userPasswordHasher: tt.fields.userPasswordHasher, + userEncryption: tt.fields.codeAlg, + idGenerator: tt.fields.idGenerator, + newCode: tt.fields.newCode, } err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail) if tt.res.err == nil { @@ -1133,9 +1128,9 @@ func TestCommandSide_AddHuman(t *testing.T) { func TestCommandSide_ImportHuman(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator - userPasswordAlg crypto.HashAlgorithm + eventstore *eventstore.Eventstore + idGenerator id.Generator + userPasswordHasher *crypto.PasswordHasher } type args struct { ctx context.Context @@ -1319,7 +1314,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", true, ""), + newAddHumanEvent("$plain$x$password", true, ""), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -1337,8 +1332,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1410,7 +1405,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, ""), + newAddHumanEvent("$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanEmailVerifiedEvent(context.Background(), @@ -1420,8 +1415,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1519,8 +1514,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1", "code1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1", "code1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1603,7 +1598,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, ""), + newAddHumanEvent("$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanEmailVerifiedEvent(context.Background(), @@ -1626,8 +1621,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1", "code1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1", "code1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1713,7 +1708,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, "+41711234567"), + newAddHumanEvent("$plain$x$password", false, "+41711234567"), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -1741,8 +1736,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1820,7 +1815,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newAddHumanEvent("password", false, "+41711234567"), + newAddHumanEvent("$plain$x$password", false, "+41711234567"), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -1842,8 +1837,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -1973,8 +1968,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUserIDPLinkUniqueConstraint("idpID", "externalID")), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -2025,9 +2020,9 @@ func TestCommandSide_ImportHuman(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, - userPasswordAlg: tt.fields.userPasswordAlg, + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + userPasswordHasher: tt.fields.userPasswordHasher, } gotHuman, gotCode, err := r.ImportHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.passwordless, tt.args.links, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator) if tt.res.err == nil { @@ -2046,9 +2041,9 @@ func TestCommandSide_ImportHuman(t *testing.T) { func TestCommandSide_RegisterHuman(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator - userPasswordAlg crypto.HashAlgorithm + eventstore *eventstore.Eventstore + idGenerator id.Generator + userPasswordHasher *crypto.PasswordHasher } type args struct { ctx context.Context @@ -2521,7 +2516,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newRegisterHumanEvent("email@test.ch", "password", false, ""), + newRegisterHumanEvent("email@test.ch", "$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -2539,8 +2534,8 @@ func TestCommandSide_RegisterHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("email@test.ch", "org1", false)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -2632,7 +2627,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newRegisterHumanEvent("username", "password", false, ""), + newRegisterHumanEvent("username", "$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -2650,8 +2645,8 @@ func TestCommandSide_RegisterHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", false)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -2744,7 +2739,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newRegisterHumanEvent("username", "password", false, ""), + newRegisterHumanEvent("username", "$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -2762,8 +2757,8 @@ func TestCommandSide_RegisterHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -2856,7 +2851,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newRegisterHumanEvent("username", "password", false, ""), + newRegisterHumanEvent("username", "$plain$x$password", false, ""), ), eventFromEventPusher( user.NewHumanEmailVerifiedEvent(context.Background(), @@ -2866,8 +2861,8 @@ func TestCommandSide_RegisterHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -2962,7 +2957,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newRegisterHumanEvent("username", "password", false, "+41711234567"), + newRegisterHumanEvent("username", "$plain$x$password", false, "+41711234567"), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -2990,8 +2985,8 @@ func TestCommandSide_RegisterHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -3090,7 +3085,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newRegisterHumanEvent("username", "password", false, "+41711234567"), + newRegisterHumanEvent("username", "$plain$x$password", false, "+41711234567"), ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -3112,8 +3107,8 @@ func TestCommandSide_RegisterHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -3245,7 +3240,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusher( - newRegisterHumanEvent("username", "password", false, ""), + newRegisterHumanEvent("username", "$plain$x$password", false, ""), ), eventFromEventPusher( user.NewUserIDPLinkAddedEvent(context.Background(), @@ -3264,8 +3259,8 @@ func TestCommandSide_RegisterHuman(t *testing.T) { uniqueConstraintsFromEventConstraint(user.NewAddUserIDPLinkUniqueConstraint("idpID", "externalID")), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), }, args: args{ ctx: context.Background(), @@ -3316,9 +3311,9 @@ func TestCommandSide_RegisterHuman(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, - userPasswordAlg: tt.fields.userPasswordAlg, + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + userPasswordHasher: tt.fields.userPasswordHasher, } got, err := r.RegisterHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.link, tt.args.orgMemberRoles, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator) if tt.res.err == nil { @@ -3657,13 +3652,7 @@ func newAddHumanEvent(password string, changeRequired bool, phone string) *user. true, ) if password != "" { - event.AddPasswordData(&crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte(password), - }, - changeRequired) + event.AddPasswordData(password, changeRequired) } if phone != "" { event.AddPhoneData(domain.PhoneNumber(phone)) @@ -3685,13 +3674,7 @@ func newRegisterHumanEvent(username, password string, changeRequired bool, phone true, ) if password != "" { - event.AddPasswordData(&crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte(password), - }, - changeRequired) + event.AddPasswordData(password, changeRequired) } if phone != "" { event.AddPhoneData(domain.PhoneNumber(phone)) @@ -3706,7 +3689,7 @@ func TestAddHumanCommand(t *testing.T) { type args struct { human *AddHuman orgID string - passwordAlg crypto.HashAlgorithm + hasher *crypto.PasswordHasher filter preparation.FilterToQueryReducer codeAlg crypto.EncryptionAlgorithm allowInitMail bool @@ -3763,6 +3746,24 @@ func TestAddHumanCommand(t *testing.T) { ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty"), }, }, + { + name: "unsupported password hash encoding", + args: args{ + human: &AddHuman{ + Email: Email{Address: "support@zitadel.com", Verified: true}, + PreferredLanguage: language.English, + FirstName: "gigi", + LastName: "giraffe", + EncodedPasswordHash: "$foo$x$password", + Username: "username", + }, + orgID: "ro", + hasher: mockPasswordHasher("x"), + }, + want: Want{ + ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-JDk4t", "Errors.User.Password.NotSupported"), + }, + }, { name: "invalid password", fields: fields{ @@ -3828,9 +3829,9 @@ func TestAddHumanCommand(t *testing.T) { Password: "password", Username: "username", }, - orgID: "ro", - passwordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), - codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + orgID: "ro", + hasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), filter: NewMultiFilter().Append( func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { return []eventstore.Event{}, nil @@ -3879,12 +3880,79 @@ func TestAddHumanCommand(t *testing.T) { "support@zitadel.com", true, ) - event.AddPasswordData(&crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: "hash", - KeyID: "", - Crypted: []byte("password"), - }, false) + event.AddPasswordData("$plain$x$password", false) + return event + }(), + user.NewHumanEmailVerifiedEvent(context.Background(), &agg.Aggregate), + }, + }, + }, + { + name: "hashed password", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"), + }, + args: args{ + human: &AddHuman{ + Email: Email{Address: "support@zitadel.com", Verified: true}, + PreferredLanguage: language.English, + FirstName: "gigi", + LastName: "giraffe", + EncodedPasswordHash: "$plain$x$password", + Username: "username", + }, + orgID: "ro", + hasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + filter: NewMultiFilter().Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return []eventstore.Event{}, nil + }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return []eventstore.Event{ + org.NewDomainPolicyAddedEvent( + ctx, + &org.NewAggregate("id").Aggregate, + true, + true, + true, + ), + }, nil + }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return []eventstore.Event{ + org.NewPasswordComplexityPolicyAddedEvent( + ctx, + &org.NewAggregate("id").Aggregate, + 2, + false, + false, + false, + false, + ), + }, nil + }). + Filter(), + }, + want: Want{ + Commands: []eventstore.Command{ + func() *user.HumanAddedEvent { + event := user.NewHumanAddedEvent( + context.Background(), + &agg.Aggregate, + "username", + "gigi", + "giraffe", + "", + "gigi giraffe", + language.English, + 0, + "support@zitadel.com", + true, + ) + event.AddPasswordData("$plain$x$password", false) return event }(), user.NewHumanEmailVerifiedEvent(context.Background(), &agg.Aggregate), @@ -3897,7 +3965,7 @@ func TestAddHumanCommand(t *testing.T) { c := &Commands{ idGenerator: tt.fields.idGenerator, } - AssertValidation(t, context.Background(), c.AddHumanCommand(tt.args.human, tt.args.orgID, tt.args.passwordAlg, tt.args.codeAlg, tt.args.allowInitMail), tt.args.filter, tt.want) + AssertValidation(t, context.Background(), c.AddHumanCommand(tt.args.human, tt.args.orgID, tt.args.hasher, tt.args.codeAlg, tt.args.allowInitMail), tt.args.filter, tt.want) }) } } diff --git a/internal/command/user_machine_secret.go b/internal/command/user_machine_secret.go index a7a91b247f..8f28b5e5db 100644 --- a/internal/command/user_machine_secret.go +++ b/internal/command/user_machine_secret.go @@ -113,7 +113,7 @@ func prepareRemoveMachineSecret(a *user.Aggregate) preparation.Validation { func (c *Commands) VerifyMachineSecret(ctx context.Context, userID string, resourceOwner string, secret string) (*domain.ObjectDetails, error) { agg := user.NewAggregate(userID, resourceOwner) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareVerifyMachineSecret(agg, secret, c.userPasswordAlg)) + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareVerifyMachineSecret(agg, secret, c.codeAlg)) if err != nil { return nil, err } diff --git a/internal/command/user_machine_secret_test.go b/internal/command/user_machine_secret_test.go index 490797b96d..42df770d94 100644 --- a/internal/command/user_machine_secret_test.go +++ b/internal/command/user_machine_secret_test.go @@ -529,8 +529,8 @@ func TestCommandSide_VerifyMachineSecret(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - userPasswordAlg: crypto.NewBCrypt(14), + eventstore: tt.fields.eventstore, + codeAlg: crypto.NewBCrypt(14), } got, err := r.VerifyMachineSecret(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.secret) if tt.res.err == nil { diff --git a/internal/config/systemdefaults/system_defaults.go b/internal/config/systemdefaults/system_defaults.go index 7ca33b04b5..012c37ac07 100644 --- a/internal/config/systemdefaults/system_defaults.go +++ b/internal/config/systemdefaults/system_defaults.go @@ -8,6 +8,7 @@ import ( type SystemDefaults struct { SecretGenerators SecretGenerators + PasswordHasher crypto.PasswordHashConfig Multifactors MultifactorConfig DomainVerification DomainVerification Notifications Notifications diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 748a124b5c..5cc0575907 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -10,7 +10,7 @@ import ( const ( TypeEncryption CryptoType = iota - TypeHash + TypeHash // Depcrecated: use [passwap.Swapper] instead ) type Crypto interface { @@ -26,6 +26,7 @@ type EncryptionAlgorithm interface { DecryptString(hashed []byte, keyID string) (string, error) } +// Depcrecated: use [passwap.Swapper] instead type HashAlgorithm interface { Crypto Hash(value []byte) ([]byte, error) diff --git a/internal/crypto/passwap.go b/internal/crypto/passwap.go new file mode 100644 index 0000000000..cf72a844fb --- /dev/null +++ b/internal/crypto/passwap.go @@ -0,0 +1,209 @@ +package crypto + +import ( + "fmt" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/zitadel/passwap" + "github.com/zitadel/passwap/argon2" + "github.com/zitadel/passwap/bcrypt" + "github.com/zitadel/passwap/md5" + "github.com/zitadel/passwap/scrypt" + "github.com/zitadel/passwap/verifier" + + "github.com/zitadel/zitadel/internal/errors" +) + +type PasswordHasher struct { + *passwap.Swapper + Prefixes []string +} + +func (h *PasswordHasher) EncodingSupported(encodedHash string) bool { + for _, prefix := range h.Prefixes { + if strings.HasPrefix(encodedHash, prefix) { + return true + } + } + return false +} + +type HashName string + +const ( + HashNameArgon2 HashName = "argon2" // used for the common argon2 verifier + HashNameArgon2i HashName = "argon2i" // hash only + HashNameArgon2id HashName = "argon2id" // hash only + HashNameBcrypt HashName = "bcrypt" // hash and verify + HashNameMd5 HashName = "md5" // verify only, as hashing with md5 is insecure and deprecated + HashNameScrypt HashName = "scrypt" // hash and verify +) + +type PasswordHashConfig struct { + Verifiers []HashName + Hasher HasherConfig +} + +func (c *PasswordHashConfig) PasswordHasher() (*PasswordHasher, error) { + verifiers, vPrefixes, err := c.buildVerifiers() + if err != nil { + return nil, errors.ThrowInvalidArgument(err, "CRYPT-sahW9", "password hash config invalid") + } + hasher, hPrefixes, err := c.Hasher.buildHasher() + if err != nil { + return nil, errors.ThrowInvalidArgument(err, "CRYPT-Que4r", "password hash config invalid") + } + return &PasswordHasher{ + Swapper: passwap.NewSwapper(hasher, verifiers...), + Prefixes: append(hPrefixes, vPrefixes...), + }, nil +} + +type prefixVerifier struct { + prefixes []string + verifier verifier.Verifier +} + +// map HashNames to Verifier instances. +var knowVerifiers = map[HashName]prefixVerifier{ + HashNameArgon2: { + // only argon2i and argon2id are suppored. + // The Prefix constant also covers argon2d. + prefixes: []string{argon2.Prefix}, + verifier: argon2.Verifier, + }, + HashNameBcrypt: { + prefixes: []string{bcrypt.Prefix}, + verifier: bcrypt.Verifier, + }, + HashNameMd5: { + prefixes: []string{md5.Prefix}, + verifier: md5.Verifier, + }, + HashNameScrypt: { + prefixes: []string{scrypt.Prefix, scrypt.Prefix_Linux}, + verifier: scrypt.Verifier, + }, +} + +func (c *PasswordHashConfig) buildVerifiers() (verifiers []verifier.Verifier, prefixes []string, err error) { + verifiers = make([]verifier.Verifier, len(c.Verifiers)) + prefixes = make([]string, 0, len(c.Verifiers)+1) + for i, name := range c.Verifiers { + v, ok := knowVerifiers[name] + if !ok { + return nil, nil, fmt.Errorf("invalid verifier %q", name) + } + verifiers[i] = v.verifier + prefixes = append(prefixes, v.prefixes...) + } + return verifiers, prefixes, nil +} + +type HasherConfig struct { + Algorithm HashName + Params map[string]any `mapstructure:",remain"` +} + +func (c *HasherConfig) buildHasher() (hasher passwap.Hasher, prefixes []string, err error) { + switch c.Algorithm { + case HashNameArgon2i: + return c.argon2i() + case HashNameArgon2id: + return c.argon2id() + case HashNameBcrypt: + return c.bcrypt() + case HashNameScrypt: + return c.scrypt() + case "": + return nil, nil, fmt.Errorf("missing hasher algorithm") + case HashNameArgon2, HashNameMd5: + fallthrough + default: + return nil, nil, fmt.Errorf("invalid algorithm %q", c.Algorithm) + } +} + +func (c *HasherConfig) decodeParams(dst any) error { + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + ErrorUnused: true, + ErrorUnset: true, + Result: dst, + }) + if err != nil { + return err + } + return decoder.Decode(c.Params) +} + +// argon2Params decodes [HasherConfig.Params] into a [argon2.Params] used as defaults. +// p is passed a copy and therfore will not be modified. +func (c *HasherConfig) argon2Params(p argon2.Params) (argon2.Params, error) { + var dst struct { + Time uint32 `mapstructure:"Time"` + Memory uint32 `mapstructure:"Memory"` + Threads uint8 `mapstructure:"Threads"` + } + if err := c.decodeParams(&dst); err != nil { + return argon2.Params{}, fmt.Errorf("decode argon2i params: %w", err) + } + p.Time = dst.Time + p.Memory = dst.Memory + p.Threads = dst.Threads + return p, nil +} + +func (c *HasherConfig) argon2i() (passwap.Hasher, []string, error) { + p, err := c.argon2Params(argon2.RecommendedIParams) + if err != nil { + return nil, nil, err + } + return argon2.NewArgon2i(p), []string{argon2.Prefix}, nil +} + +func (c *HasherConfig) argon2id() (passwap.Hasher, []string, error) { + p, err := c.argon2Params(argon2.RecommendedIDParams) + if err != nil { + return nil, nil, err + } + return argon2.NewArgon2id(p), []string{argon2.Prefix}, nil +} + +func (c *HasherConfig) bcryptCost() (int, error) { + var dst = struct { + Cost int `mapstructure:"Cost"` + }{} + if err := c.decodeParams(&dst); err != nil { + return 0, fmt.Errorf("decode bcrypt params: %w", err) + } + return dst.Cost, nil +} + +func (c *HasherConfig) bcrypt() (passwap.Hasher, []string, error) { + cost, err := c.bcryptCost() + if err != nil { + return nil, nil, err + } + return bcrypt.New(cost), []string{bcrypt.Prefix}, nil +} + +func (c *HasherConfig) scryptParams() (scrypt.Params, error) { + var dst = struct { + Cost int `mapstructure:"Cost"` + }{} + if err := c.decodeParams(&dst); err != nil { + return scrypt.Params{}, fmt.Errorf("decode scrypt params: %w", err) + } + p := scrypt.RecommendedParams // copy + p.N = 1 << dst.Cost + return p, nil +} + +func (c *HasherConfig) scrypt() (passwap.Hasher, []string, error) { + p, err := c.scryptParams() + if err != nil { + return nil, nil, err + } + return scrypt.New(p), []string{scrypt.Prefix, scrypt.Prefix_Linux}, nil +} diff --git a/internal/crypto/passwap_test.go b/internal/crypto/passwap_test.go new file mode 100644 index 0000000000..2cc5aa80e7 --- /dev/null +++ b/internal/crypto/passwap_test.go @@ -0,0 +1,486 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/passwap/argon2" + "github.com/zitadel/passwap/bcrypt" + "github.com/zitadel/passwap/md5" + "github.com/zitadel/passwap/scrypt" +) + +func TestPasswordHasher_EncodingSupported(t *testing.T) { + tests := []struct { + name string + encodedHash string + want bool + }{ + { + name: "empty string, false", + encodedHash: "", + want: false, + }, + { + name: "scrypt, false", + encodedHash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + want: false, + }, + { + name: "bcrypt, true", + encodedHash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + want: true, + }, + { + name: "argo2i, true", + encodedHash: "$argon2i$v=19$m=4096,t=3,p=1$cmFuZG9tc2FsdGlzaGFyZA$YMvo8AUoNtnKYGqeODruCjHdiEbl1pKL2MsYy9VgU/E", + want: true, + }, + { + name: "argo2id, true", + encodedHash: "$argon2d$v=19$m=4096,t=3,p=1$cmFuZG9tc2FsdGlzaGFyZA$CB0Du96aj3fQVcVSqb0LIA6Z6fpStjzjVkaC3RlpK9A", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &PasswordHasher{ + Prefixes: []string{bcrypt.Prefix, argon2.Prefix}, + } + got := h.EncodingSupported(tt.encodedHash) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPasswordHashConfig_PasswordHasher(t *testing.T) { + type fields struct { + Verifiers []HashName + Hasher HasherConfig + } + tests := []struct { + name string + fields fields + wantPrefixes []string + wantErr bool + }{ + { + name: "invalid verifier", + fields: fields{ + Verifiers: []HashName{ + HashNameArgon2, + HashNameBcrypt, + HashNameMd5, + HashNameScrypt, + "foobar", + }, + Hasher: HasherConfig{ + Algorithm: HashNameBcrypt, + Params: map[string]any{ + "cost": 5, + }, + }, + }, + wantErr: true, + }, + { + name: "invalid hasher", + fields: fields{ + Verifiers: []HashName{ + HashNameArgon2, + HashNameBcrypt, + HashNameMd5, + HashNameScrypt, + }, + Hasher: HasherConfig{ + Algorithm: "foobar", + Params: map[string]any{ + "cost": 5, + }, + }, + }, + wantErr: true, + }, + { + name: "missing algorithm", + fields: fields{ + Hasher: HasherConfig{}, + }, + wantErr: true, + }, + { + name: "invalid md5", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameMd5, + }, + }, + wantErr: true, + }, + { + name: "invalid argon2", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameArgon2, + Params: map[string]any{ + "time": 3, + "memory": 32768, + "threads": 4, + }, + }, + }, + wantErr: true, + }, + { + name: "argon2i, error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameArgon2i, + Params: map[string]any{ + "time": 3, + "threads": 4, + }, + }, + }, + wantErr: true, + }, + { + name: "argon2i, ok", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameArgon2i, + Params: map[string]any{ + "time": 3, + "memory": 32768, + "threads": 4, + }, + }, + Verifiers: []HashName{HashNameBcrypt, HashNameMd5, HashNameScrypt}, + }, + wantPrefixes: []string{argon2.Prefix, bcrypt.Prefix, md5.Prefix, scrypt.Prefix, scrypt.Prefix_Linux}, + }, + { + name: "argon2id, error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameArgon2id, + Params: map[string]any{ + "time": 3, + "threads": 4, + }, + }, + }, + wantErr: true, + }, + { + name: "argon2id, ok", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameArgon2id, + Params: map[string]any{ + "time": 3, + "memory": 32768, + "threads": 4, + }, + }, + Verifiers: []HashName{HashNameBcrypt, HashNameMd5, HashNameScrypt}, + }, + wantPrefixes: []string{argon2.Prefix, bcrypt.Prefix, md5.Prefix, scrypt.Prefix, scrypt.Prefix_Linux}, + }, + { + name: "bcrypt, error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameBcrypt, + Params: map[string]any{ + "foo": 3, + }, + }, + }, + wantErr: true, + }, + { + name: "bcrypt, ok", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameBcrypt, + Params: map[string]any{ + "cost": 3, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameMd5, HashNameScrypt}, + }, + wantPrefixes: []string{bcrypt.Prefix, argon2.Prefix, md5.Prefix, scrypt.Prefix, scrypt.Prefix_Linux}, + }, + { + name: "scrypt, error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameScrypt, + Params: map[string]any{ + "cost": "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "scrypt, ok", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameScrypt, + Params: map[string]any{ + "cost": 3, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5}, + }, + wantPrefixes: []string{scrypt.Prefix, scrypt.Prefix_Linux, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &PasswordHashConfig{ + Verifiers: tt.fields.Verifiers, + Hasher: tt.fields.Hasher, + } + got, err := c.PasswordHasher() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantPrefixes != nil { + require.NotNil(t, got) + assert.Equal(t, tt.wantPrefixes, got.Prefixes) + encoded, err := got.Hash("password") + require.NoError(t, err) + assert.NotEmpty(t, encoded) + } + }) + } +} + +func TestHasherConfig_decodeParams(t *testing.T) { + type dst struct { + A int + B uint32 + } + tests := []struct { + name string + params map[string]any + want dst + wantErr bool + }{ + { + name: "unused", + params: map[string]any{ + "a": 1, + "b": 2, + "c": 3, + }, + wantErr: true, + }, + { + name: "unset", + params: map[string]any{ + "a": 1, + }, + wantErr: true, + }, + { + name: "wrong type", + params: map[string]any{ + "a": 1, + "b": "2", + }, + wantErr: true, + }, + { + name: "ok", + params: map[string]any{ + "a": 1, + "b": 2, + }, + want: dst{ + A: 1, + B: 2, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &HasherConfig{ + Params: tt.params, + } + var got dst + err := c.decodeParams(&got) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHasherConfig_argon2Params(t *testing.T) { + type fields struct { + Params map[string]any + } + type args struct { + p argon2.Params + } + tests := []struct { + name string + fields fields + args args + want argon2.Params + wantErr bool + }{ + { + name: "decode error", + fields: fields{ + Params: map[string]any{ + "foo": "bar", + }, + }, + args: args{ + p: argon2.RecommendedIDParams, + }, + wantErr: true, + }, + { + name: "ok", + fields: fields{ + Params: map[string]any{ + "time": 2, + "memory": 256, + "threads": 8, + }, + }, + args: args{ + p: argon2.RecommendedIDParams, + }, + want: argon2.Params{ + Time: 2, + Memory: 256, + Threads: 8, + KeyLen: 32, + SaltLen: 16, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &HasherConfig{ + Params: tt.fields.Params, + } + got, err := c.argon2Params(tt.args.p) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHasherConfig_bcryptCost(t *testing.T) { + type fields struct { + Params map[string]any + } + tests := []struct { + name string + fields fields + want int + wantErr bool + }{ + { + name: "decode error", + fields: fields{ + Params: map[string]any{ + "foo": "bar", + }, + }, + wantErr: true, + }, + { + name: "ok", + fields: fields{ + Params: map[string]any{ + "cost": 12, + }, + }, + want: 12, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &HasherConfig{ + Params: tt.fields.Params, + } + got, err := c.bcryptCost() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHasherConfig_scryptParams(t *testing.T) { + type fields struct { + Params map[string]any + } + tests := []struct { + name string + fields fields + want scrypt.Params + wantErr bool + }{ + { + name: "decode error", + fields: fields{ + Params: map[string]any{ + "foo": "bar", + }, + }, + wantErr: true, + }, + { + name: "ok", + fields: fields{ + Params: map[string]any{ + "cost": 2, + }, + }, + want: scrypt.Params{ + N: 4, + R: 8, + P: 1, + KeyLen: 32, + SaltLen: 16, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &HasherConfig{ + Params: tt.fields.Params, + } + got, err := c.scryptParams() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/domain/human.go b/internal/domain/human.go index 619eaf4807..0454a36651 100644 --- a/internal/domain/human.go +++ b/internal/domain/human.go @@ -16,7 +16,7 @@ type Human struct { Username string State UserState *Password - *HashedPassword + HashedPassword string *Profile *Email *Phone @@ -103,10 +103,10 @@ func (u *Human) EnsureDisplayName() { u.DisplayName = u.Username } -func (u *Human) HashPasswordIfExisting(policy *PasswordComplexityPolicy, passwordAlg crypto.HashAlgorithm, onetime bool) error { +func (u *Human) HashPasswordIfExisting(policy *PasswordComplexityPolicy, hasher *crypto.PasswordHasher, onetime bool) error { if u.Password != nil { u.Password.ChangeRequired = onetime - return u.Password.HashPasswordIfExisting(policy, passwordAlg) + return u.Password.HashPasswordIfExisting(policy, hasher) } return nil } @@ -115,7 +115,7 @@ func (u *Human) IsInitialState(passwordless, externalIDPs bool) bool { if externalIDPs { return false } - return u.Email == nil || !u.IsEmailVerified || !passwordless && (u.Password == nil || u.Password.SecretString == "") && (u.HashedPassword == nil || u.HashedPassword.SecretString == "") + return u.Email == nil || !u.IsEmailVerified || !passwordless && (u.Password == nil || u.Password.SecretString == "") && u.HashedPassword == "" } func NewInitUserCode(generator crypto.Generator) (*InitUserCode, error) { diff --git a/internal/domain/human_hashed_password.go b/internal/domain/human_hashed_password.go deleted file mode 100644 index 5aa144fe52..0000000000 --- a/internal/domain/human_hashed_password.go +++ /dev/null @@ -1,24 +0,0 @@ -package domain - -import ( - "github.com/zitadel/zitadel/internal/crypto" - es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" -) - -type HashedPassword struct { - es_models.ObjectRoot - - SecretString string - SecretCrypto *crypto.CryptoValue -} - -func NewHashedPassword(password, algorithm string) *HashedPassword { - return &HashedPassword{ - SecretString: password, - SecretCrypto: &crypto.CryptoValue{ - CryptoType: crypto.TypeHash, - Algorithm: algorithm, - Crypted: []byte(password), - }, - } -} diff --git a/internal/domain/human_password.go b/internal/domain/human_password.go index 070d00aca2..35bcbeaf28 100644 --- a/internal/domain/human_password.go +++ b/internal/domain/human_password.go @@ -12,7 +12,7 @@ type Password struct { es_models.ObjectRoot SecretString string - SecretCrypto *crypto.CryptoValue + EncodedSecret string ChangeRequired bool } @@ -30,7 +30,7 @@ type PasswordCode struct { NotificationType NotificationType } -func (p *Password) HashPasswordIfExisting(policy *PasswordComplexityPolicy, passwordAlg crypto.HashAlgorithm) error { +func (p *Password) HashPasswordIfExisting(policy *PasswordComplexityPolicy, hasher *crypto.PasswordHasher) error { if p.SecretString == "" { return nil } @@ -40,11 +40,11 @@ func (p *Password) HashPasswordIfExisting(policy *PasswordComplexityPolicy, pass if err := policy.Check(p.SecretString); err != nil { return err } - secret, err := crypto.Hash([]byte(p.SecretString), passwordAlg) + encoded, err := hasher.Hash(p.SecretString) if err != nil { return err } - p.SecretCrypto = secret + p.EncodedSecret = encoded return nil } diff --git a/internal/query/user_password.go b/internal/query/user_password.go index 715c9ed871..97ca99541d 100644 --- a/internal/query/user_password.go +++ b/internal/query/user_password.go @@ -15,7 +15,7 @@ import ( type HumanPasswordReadModel struct { *eventstore.ReadModel - Secret *crypto.CryptoValue + EncodedHash string SecretChangeRequired bool Code *crypto.CryptoValue @@ -26,26 +26,21 @@ type HumanPasswordReadModel struct { UserState domain.UserState } -func (q *Queries) GetHumanPassword(ctx context.Context, orgID, userID string) (passwordHash []byte, algorithm string, err error) { +func (q *Queries) GetHumanPassword(ctx context.Context, orgID, userID string) (encodedHash string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() if userID == "" { - return nil, "", errors.ThrowInvalidArgument(nil, "QUERY-4Mfsf", "Errors.User.UserIDMissing") + return "", errors.ThrowInvalidArgument(nil, "QUERY-4Mfsf", "Errors.User.UserIDMissing") } existingPassword, err := q.passwordReadModel(ctx, userID, orgID) if err != nil { - return nil, "", errors.ThrowInternal(nil, "QUERY-p1k1n2i", "Errors.User.NotFound") + return "", errors.ThrowInternal(nil, "QUERY-p1k1n2i", "Errors.User.NotFound") } if existingPassword.UserState == domain.UserStateUnspecified || existingPassword.UserState == domain.UserStateDeleted { - return nil, "", errors.ThrowPreconditionFailed(nil, "QUERY-3n77z", "Errors.User.NotFound") + return "", errors.ThrowPreconditionFailed(nil, "QUERY-3n77z", "Errors.User.NotFound") } - - if existingPassword.Secret != nil && existingPassword.Secret.Crypted != nil { - return existingPassword.Secret.Crypted, existingPassword.Secret.Algorithm, nil - } - - return nil, "", nil + return existingPassword.EncodedHash, nil } func (q *Queries) passwordReadModel(ctx context.Context, userID, resourceOwner string) (readModel *HumanPasswordReadModel, err error) { @@ -77,11 +72,11 @@ func (wm *HumanPasswordReadModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { case *user.HumanAddedEvent: - wm.Secret = e.Secret + wm.EncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) wm.SecretChangeRequired = e.ChangeRequired wm.UserState = domain.UserStateActive case *user.HumanRegisteredEvent: - wm.Secret = e.Secret + wm.EncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) wm.SecretChangeRequired = e.ChangeRequired wm.UserState = domain.UserStateActive case *user.HumanInitialCodeAddedEvent: @@ -89,7 +84,7 @@ func (wm *HumanPasswordReadModel) Reduce() error { case *user.HumanInitializedCheckSucceededEvent: wm.UserState = domain.UserStateActive case *user.HumanPasswordChangedEvent: - wm.Secret = e.Secret + wm.EncodedHash = user.SecretOrEncodedHash(e.Secret, e.EncodedHash) wm.SecretChangeRequired = e.ChangeRequired wm.Code = nil wm.PasswordCheckFailedCount = 0 @@ -109,6 +104,8 @@ func (wm *HumanPasswordReadModel) Reduce() error { wm.PasswordCheckFailedCount = 0 case *user.UserRemovedEvent: wm.UserState = domain.UserStateDeleted + case *user.HumanPasswordHashUpdatedEvent: + wm.EncodedHash = e.EncodedHash } } return wm.ReadModel.Reduce() @@ -139,7 +136,8 @@ func (wm *HumanPasswordReadModel) Query() *eventstore.SearchQueryBuilder { user.UserV1PasswordCodeAddedType, user.UserV1EmailVerifiedType, user.UserV1PasswordCheckFailedType, - user.UserV1PasswordCheckSucceededType). + user.UserV1PasswordCheckSucceededType, + user.UserV1PasswordHashUpdatedType). Builder() if wm.ResourceOwner != "" { diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index d216c910c6..847736b60b 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -17,6 +17,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, UserV1PasswordCodeSentType, HumanPasswordCodeSentEventMapper). RegisterFilterEventMapper(AggregateType, UserV1PasswordCheckSucceededType, HumanPasswordCheckSucceededEventMapper). RegisterFilterEventMapper(AggregateType, UserV1PasswordCheckFailedType, HumanPasswordCheckFailedEventMapper). + RegisterFilterEventMapper(AggregateType, UserV1PasswordHashUpdatedType, eventstore.GenericEventMapper[HumanPasswordHashUpdatedEvent]). RegisterFilterEventMapper(AggregateType, UserV1EmailChangedType, HumanEmailChangedEventMapper). RegisterFilterEventMapper(AggregateType, UserV1EmailVerifiedType, HumanEmailVerifiedEventMapper). RegisterFilterEventMapper(AggregateType, UserV1EmailVerificationFailedType, HumanEmailVerificationFailedEventMapper). diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index 6184b7d071..075fd55cac 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -48,7 +48,10 @@ type HumanAddedEvent struct { Region string `json:"region,omitempty"` StreetAddress string `json:"streetAddress,omitempty"` + // New events only use EncodedHash. However, the secret field + // is preserved to handle events older than the switch to Passwap. Secret *crypto.CryptoValue `json:"secret,omitempty"` + EncodedHash string `json:"encodedHash,omitempty"` ChangeRequired bool `json:"changeRequired,omitempty"` } @@ -81,10 +84,10 @@ func (e *HumanAddedEvent) AddPhoneData( } func (e *HumanAddedEvent) AddPasswordData( - secret *crypto.CryptoValue, + encoded string, changeRequired bool, ) { - e.Secret = secret + e.EncodedHash = encoded e.ChangeRequired = changeRequired } @@ -149,8 +152,12 @@ type HumanRegisteredEvent struct { PostalCode string `json:"postalCode,omitempty"` Region string `json:"region,omitempty"` StreetAddress string `json:"streetAddress,omitempty"` - Secret *crypto.CryptoValue `json:"secret,omitempty"` - ChangeRequired bool `json:"changeRequired,omitempty"` + + // New events only use EncodedHash. However, the secret field + // is preserved to handle events older than the switch to Passwap. + Secret *crypto.CryptoValue `json:"secret,omitempty"` // legacy + EncodedHash string `json:"encodedHash,omitempty"` + ChangeRequired bool `json:"changeRequired,omitempty"` } func (e *HumanRegisteredEvent) Data() interface{} { @@ -182,10 +189,10 @@ func (e *HumanRegisteredEvent) AddPhoneData( } func (e *HumanRegisteredEvent) AddPasswordData( - secret *crypto.CryptoValue, + encoded string, changeRequired bool, ) { - e.Secret = secret + e.EncodedHash = encoded e.ChangeRequired = changeRequired } diff --git a/internal/repository/user/human_password.go b/internal/repository/user/human_password.go index ccd38527d4..f2f40cd6ce 100644 --- a/internal/repository/user/human_password.go +++ b/internal/repository/user/human_password.go @@ -26,7 +26,10 @@ const ( type HumanPasswordChangedEvent struct { eventstore.BaseEvent `json:"-"` + // New events only use EncodedHash. However, the secret field + // is preserved to handle events older than the switch to Passwap. Secret *crypto.CryptoValue `json:"secret,omitempty"` + EncodedHash string `json:"encodedHash,omitempty"` ChangeRequired bool `json:"changeRequired"` UserAgentID string `json:"userAgentID,omitempty"` } @@ -42,7 +45,7 @@ func (e *HumanPasswordChangedEvent) UniqueConstraints() []*eventstore.EventUniqu func NewHumanPasswordChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, - secret *crypto.CryptoValue, + encodeHash string, changeRequired bool, userAgentID string, ) *HumanPasswordChangedEvent { @@ -52,7 +55,7 @@ func NewHumanPasswordChangedEvent( aggregate, HumanPasswordChangedType, ), - Secret: secret, + EncodedHash: encodeHash, ChangeRequired: changeRequired, UserAgentID: userAgentID, } @@ -268,3 +271,44 @@ func HumanPasswordCheckFailedEventMapper(event *repository.Event) (eventstore.Ev return humanAdded, nil } + +type HumanPasswordHashUpdatedEvent struct { + eventstore.BaseEvent `json:"-"` + EncodedHash string `json:"encodedHash,omitempty"` +} + +func (e *HumanPasswordHashUpdatedEvent) Data() interface{} { + return e +} + +func (e *HumanPasswordHashUpdatedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanPasswordHashUpdatedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = *base +} + +func NewHumanPasswordHashUpdatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + encoded string, +) *HumanPasswordHashUpdatedEvent { + return &HumanPasswordHashUpdatedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanPasswordCheckFailedType, + ), + EncodedHash: encoded, + } +} + +// SecretOrEncodedHash returns the legacy *crypto.CryptoValue if it is not nil. +// orherwise it will returns the encoded hash string. +func SecretOrEncodedHash(secret *crypto.CryptoValue, encoded string) string { + if secret != nil { + return string(secret.Crypted) + } + return encoded +} diff --git a/internal/repository/user/v1.go b/internal/repository/user/v1.go index 010e1a235d..a2974bad98 100644 --- a/internal/repository/user/v1.go +++ b/internal/repository/user/v1.go @@ -15,6 +15,7 @@ const ( UserV1PasswordCodeSentType = userV1PasswordEventTypePrefix + "code.sent" UserV1PasswordCheckSucceededType = userV1PasswordEventTypePrefix + "check.succeeded" UserV1PasswordCheckFailedType = userV1PasswordEventTypePrefix + "check.failed" + UserV1PasswordHashUpdatedType = userV1PasswordEventTypePrefix + "hash.updated" userV1EmailEventTypePrefix = userEventTypePrefix + "email." UserV1EmailChangedType = userV1EmailEventTypePrefix + "changed" diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 289300493d..15f6d41a84 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -122,6 +122,8 @@ Errors: Empty: Паролата е празна Invalid: Паролата е невалидна NotSet: Потребителят не е задал парола + NotChanged: Паролата не е променена + NotSupported: Хеш кодирането на паролата не се поддържа PasswordComplexityPolicy: NotFound: Политиката за парола не е намерена MinLength: Паролата е твърде кратка diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 1e941ffbf1..cc053aaa04 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -120,6 +120,8 @@ Errors: Empty: Passwort ist leer Invalid: Passwort ungültig NotSet: Benutzer hat kein Passwort gesetzt + NotChanged: Passwort nicht geändert + NotSupported: Passwort-Hash-Kodierung wird nicht unterstützt PasswordComplexityPolicy: NotFound: Passwort Policy konnte nicht gefunden werden MinLength: Passwort ist zu kurz diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 688b75af2a..30e14168f6 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -120,6 +120,8 @@ Errors: Empty: Password is empty Invalid: Password is invalid NotSet: User has not set a password + NotChanged: Password not changed + NotSupported: Password hash encoding not supported PasswordComplexityPolicy: NotFound: Password policy not found MinLength: Password is too short diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 748a90aabb..63b8030682 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -120,6 +120,8 @@ Errors: Empty: La contraseña está vacía Invalid: La contraseña no es válida NotSet: El usuario no ha establecido una contraseña + NotChanged: Contraseña no modificada + NotSupported: No se admite la codificación hash de contraseña PasswordComplexityPolicy: NotFound: Política de contraseñas no encontrada MinLength: La contraseña es demasiado corta diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index feb89a8302..71b4fa6840 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -120,6 +120,8 @@ Errors: Empty: Le mot de passe est vide Invalid: Le mot de passe n'est pas valide NotSet: L'utilisateur n'a pas défini de mot de passe + NotChanged: Mot de passe non modifié + NotSupported: Encodage de hachage de mot de passe non pris en charge PasswordComplexityPolicy: NotFound: Politique de mot de passe non trouvée MinLength: Le mot de passe est trop court diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index afe26d6bcb..5011db4507 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -120,6 +120,8 @@ Errors: Empty: La password è vuota Invalid: La password non è valida NotSet: L'utente non ha impostato una password + NotChanged: Password non modificata + NotSupported: Codifica hash password non supportata PasswordComplexityPolicy: NotFound: Impostazioni di complessità password non trovati MinLength: La password è troppo corta diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 4dfe8c42e6..f3ef77c96c 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -112,6 +112,8 @@ Errors: Empty: パスワードは空です Invalid: 無効なパスワードです NotSet: パスワードが未設置です + NotChanged: パスワードは変更されていません + NotSupported: パスワードハッシュエンコードはサポートされていません PasswordComplexityPolicy: NotFound: パスワードポリシーが見つかりません MinLength: パスワードが短すぎます diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 65435c086b..5b32170b8d 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -120,6 +120,8 @@ Errors: Empty: Лозинката е празна Invalid: Невалидна лозинка NotSet: Корисникот нема поставено лозинка + NotChanged: Лозинката не е променета + NotSupported: Не е поддржано хаш-кодирањето на лозинката PasswordComplexityPolicy: NotFound: Политиката за комплексност на лозинката не е пронајдена MinLength: Лозинката е прекратка diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 0c9e39f7fb..3042832581 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -120,6 +120,8 @@ Errors: Empty: Hasło jest puste Invalid: Hasło jest nieprawidłowe NotSet: Użytkownik nie ustawił hasła + NotChanged: Hasło nie zostało zmienione + NotSupported: Kodowanie skrótu hasła nie jest obsługiwane PasswordComplexityPolicy: NotFound: Polityka hasła nie znaleziona MinLength: Hasło jest zbyt krótkie diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 8e76e9fbfc..e4e29151aa 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -120,6 +120,8 @@ Errors: Empty: 密码为空 Invalid: 密码无效 NotSet: 用户未设置密码 + NotChanged: 密码未更改 + NotSupported: 不支持密码哈希编码 PasswordComplexityPolicy: NotFound: 未找到密码策略 MinLength: 密码太短 diff --git a/internal/user/repository/eventsourcing/model/password.go b/internal/user/repository/eventsourcing/model/password.go index 1699c8c995..6c74141113 100644 --- a/internal/user/repository/eventsourcing/model/password.go +++ b/internal/user/repository/eventsourcing/model/password.go @@ -15,6 +15,7 @@ type Password struct { es_models.ObjectRoot Secret *crypto.CryptoValue `json:"secret,omitempty"` + EncodedHash string `json:"encodedHash,omitempty"` ChangeRequired bool `json:"changeRequired,omitempty"` } diff --git a/internal/user/repository/view/model/notify_user.go b/internal/user/repository/view/model/notify_user.go index a2ff48deb8..7390bc504f 100644 --- a/internal/user/repository/view/model/notify_user.go +++ b/internal/user/repository/view/model/notify_user.go @@ -128,6 +128,6 @@ func (u *NotifyUser) setPasswordData(event *models.Event) error { logging.Log("MODEL-dfhw6").WithError(err).Error("could not unmarshal event data") return caos_errs.ThrowInternal(nil, "MODEL-BHFD2", "could not unmarshal data") } - u.PasswordSet = password.Secret != nil + u.PasswordSet = password.Secret != nil || password.EncodedHash != "" return nil } diff --git a/internal/user/repository/view/model/user.go b/internal/user/repository/view/model/user.go index 3d3693aad9..4c68711a0c 100644 --- a/internal/user/repository/view/model/user.go +++ b/internal/user/repository/view/model/user.go @@ -380,7 +380,7 @@ func (u *UserView) setPasswordData(event *models.Event) error { logging.Log("MODEL-sdw4r").WithError(err).Error("could not unmarshal event data") return errors.ThrowInternal(nil, "MODEL-6jhsw", "could not unmarshal data") } - u.PasswordSet = password.Secret != nil + u.PasswordSet = password.Secret != nil || password.EncodedHash != "" u.PasswordInitRequired = !u.PasswordSet u.PasswordChangeRequired = password.ChangeRequired u.PasswordChanged = event.CreationDate diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index e0bb970430..f5a48a27e6 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -7406,8 +7406,14 @@ message ImportHumanUserRequest { description: "Use this to import hashed passwords from another system." } }; - string value = 1; - string algorithm = 2; + string value = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm\""; + description: "Encoded hash of a password in Modular Crypt Format: https://passlib.readthedocs.io/en/stable/modular_crypt_format.html" + } + ]; + reserved 2; // was algortithm, which is actually obtained from the encoded hash + reserved "algortithm"; } message IDP { string config_id = 1 [ diff --git a/proto/zitadel/user/v2alpha/password.proto b/proto/zitadel/user/v2alpha/password.proto index 38089b0b06..cb5415b12d 100644 --- a/proto/zitadel/user/v2alpha/password.proto +++ b/proto/zitadel/user/v2alpha/password.proto @@ -32,17 +32,7 @@ message HashedPassword { 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; + bool change_required = 2; } message SendPasswordResetLink {