diff --git a/internal/domain/user.go b/internal/domain/user.go index 487c1ed5b4..de79f58328 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -39,3 +39,17 @@ const ( func (f UserType) Valid() bool { return f >= 0 && f < userTypeCount } + +type UserAuthMethodType int32 + +const ( + UserAuthMethodTypeUnspecified UserAuthMethodType = iota + UserAuthMethodTypeOTP + UserAuthMethodTypeU2F + UserAuthMethodTypePasswordless + userAuthMethodTypeCount +) + +func (f UserAuthMethodType) Valid() bool { + return f >= 0 && f < userAuthMethodTypeCount +} diff --git a/internal/notification/channels/chat/channel.go b/internal/notification/channels/chat/channel.go index e1f05f9890..88f21f0e0b 100644 --- a/internal/notification/channels/chat/channel.go +++ b/internal/notification/channels/chat/channel.go @@ -39,7 +39,10 @@ func InitChatChannel(config ChatConfig) (channels.NotificationChannel, error) { } func sendMessage(message string, chatUrl *url.URL) error { - req, err := json.Marshal(message) + chatMsg := &struct { + Text string `json:"text"` + }{Text: message} + req, err := json.Marshal(chatMsg) if err != nil { return caos_errs.ThrowInternal(err, "PROVI-s8uie", "Could not unmarshal content") } diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index b311ba97d3..39d341112e 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -66,6 +66,7 @@ func Start(ctx context.Context, sqlClient *sql.DB, es *eventstore.Eventstore, co NewAuthNKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["authn_keys"])) NewUserGrantProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_grants"])) NewUserMetadataProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_metadata"])) + NewUserAuthMethodProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_auth_method"])) _, err := NewKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["keys"]), defaults.KeyConfig, keyChan) return err diff --git a/internal/query/projection/user_auth_method.go b/internal/query/projection/user_auth_method.go new file mode 100644 index 0000000000..d5c5c0a2a3 --- /dev/null +++ b/internal/query/projection/user_auth_method.go @@ -0,0 +1,190 @@ +package projection + +import ( + "context" + + "github.com/caos/logging" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/handler/crdb" + "github.com/caos/zitadel/internal/repository/user" +) + +type UserAuthMethodProjection struct { + crdb.StatementHandler +} + +const ( + UserAuthMethodTable = "zitadel.projections.user_auth_methods" +) + +func NewUserAuthMethodProjection(ctx context.Context, config crdb.StatementHandlerConfig) *UserAuthMethodProjection { + p := &UserAuthMethodProjection{} + config.ProjectionName = UserAuthMethodTable + config.Reducers = p.reducers() + p.StatementHandler = crdb.NewStatementHandler(ctx, config) + return p +} + +const ( + UserAuthMethodTokenIDCol = "token_id" + UserAuthMethodCreationDateCol = "creation_date" + UserAuthMethodChangeUseCol = "change_date" + UserAuthMethodResourceOwnerCol = "resource_owner" + UserAuthMethodUserIDCol = "user_id" + UserAuthMethodSequenceCol = "sequence" + UserAuthMethodNameCol = "name" + UserAuthMethodStateCol = "state" + UserAuthMethodTypeCol = "method_type" +) + +func (p *UserAuthMethodProjection) reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: user.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: user.HumanPasswordlessTokenAddedType, + Reduce: p.reduceInitAuthMethod, + }, + { + Event: user.HumanU2FTokenAddedType, + Reduce: p.reduceInitAuthMethod, + }, + { + Event: user.HumanMFAOTPAddedType, + Reduce: p.reduceInitAuthMethod, + }, + { + Event: user.HumanPasswordlessTokenVerifiedType, + Reduce: p.reduceActivateEvent, + }, + { + Event: user.HumanU2FTokenVerifiedType, + Reduce: p.reduceActivateEvent, + }, + { + Event: user.HumanMFAOTPVerifiedType, + Reduce: p.reduceActivateEvent, + }, + { + Event: user.HumanPasswordlessTokenRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, + { + Event: user.HumanU2FTokenRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, + { + Event: user.HumanMFAOTPRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, + }, + }, + } +} + +func (p *UserAuthMethodProjection) reduceInitAuthMethod(event eventstore.Event) (*handler.Statement, error) { + tokenID := "" + var methodType domain.UserAuthMethodType + switch e := event.(type) { + case *user.HumanPasswordlessAddedEvent: + methodType = domain.UserAuthMethodTypePasswordless + tokenID = e.WebAuthNTokenID + case *user.HumanU2FAddedEvent: + methodType = domain.UserAuthMethodTypeU2F + tokenID = e.WebAuthNTokenID + case *user.HumanOTPAddedEvent: + methodType = domain.UserAuthMethodTypeOTP + default: + logging.LogWithFields("PROJE-9j3f", "seq", event.Sequence(), "expectedTypes", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-f92f", "reduce.wrong.event.type") + } + + return crdb.NewUpsertStatement( + event, + []handler.Column{ + handler.NewCol(UserAuthMethodTokenIDCol, tokenID), + handler.NewCol(UserAuthMethodCreationDateCol, event.CreationDate()), + handler.NewCol(UserAuthMethodChangeUseCol, event.CreationDate()), + handler.NewCol(UserAuthMethodResourceOwnerCol, event.Aggregate().ResourceOwner), + handler.NewCol(UserAuthMethodUserIDCol, event.Aggregate().ID), + handler.NewCol(UserAuthMethodSequenceCol, event.Sequence()), + handler.NewCol(UserAuthMethodStateCol, domain.MFAStateNotReady), + handler.NewCol(UserAuthMethodTypeCol, methodType), + handler.NewCol(UserAuthMethodNameCol, ""), + }, + ), nil +} + +func (p *UserAuthMethodProjection) reduceActivateEvent(event eventstore.Event) (*handler.Statement, error) { + tokenID := "" + name := "" + var methodType domain.UserAuthMethodType + + switch e := event.(type) { + case *user.HumanPasswordlessVerifiedEvent: + methodType = domain.UserAuthMethodTypePasswordless + tokenID = e.WebAuthNTokenID + name = e.WebAuthNTokenName + case *user.HumanU2FVerifiedEvent: + methodType = domain.UserAuthMethodTypeU2F + tokenID = e.WebAuthNTokenID + name = e.WebAuthNTokenName + case *user.HumanOTPVerifiedEvent: + methodType = domain.UserAuthMethodTypeOTP + + default: + logging.LogWithFields("PROJE-9j3f", "seq", event.Sequence(), "expectedTypes", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-f92f", "reduce.wrong.event.type") + } + + return crdb.NewUpdateStatement( + event, + []handler.Column{ + handler.NewCol(UserAuthMethodChangeUseCol, event.CreationDate()), + handler.NewCol(UserAuthMethodSequenceCol, event.Sequence()), + handler.NewCol(UserAuthMethodNameCol, name), + handler.NewCol(UserAuthMethodStateCol, domain.MFAStateReady), + }, + []handler.Condition{ + handler.NewCond(UserAuthMethodUserIDCol, event.Aggregate().ID), + handler.NewCond(UserAuthMethodTypeCol, methodType), + handler.NewCond(UserAuthMethodResourceOwnerCol, event.Aggregate().ResourceOwner), + handler.NewCond(UserAuthMethodTokenIDCol, tokenID), + }, + ), nil +} + +func (p *UserAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event) (*handler.Statement, error) { + var tokenID string + var methodType domain.UserAuthMethodType + switch e := event.(type) { + case *user.HumanPasswordlessRemovedEvent: + methodType = domain.UserAuthMethodTypePasswordless + tokenID = e.WebAuthNTokenID + case *user.HumanU2FRemovedEvent: + methodType = domain.UserAuthMethodTypeU2F + tokenID = e.WebAuthNTokenID + case *user.HumanOTPRemovedEvent: + methodType = domain.UserAuthMethodTypeOTP + + default: + logging.LogWithFields("PROJE-9j3f", "seq", event.Sequence(), "expectedTypes", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-f92f", "reduce.wrong.event.type") + } + conditions := []handler.Condition{ + handler.NewCond(UserAuthMethodUserIDCol, event.Aggregate().ID), + handler.NewCond(UserAuthMethodTypeCol, methodType), + handler.NewCond(UserAuthMethodResourceOwnerCol, event.Aggregate().ResourceOwner), + } + if tokenID != "" { + conditions = append(conditions, handler.NewCond(UserAuthMethodTokenIDCol, tokenID)) + } + return crdb.NewDeleteStatement( + event, + conditions, + ), nil +} diff --git a/internal/query/projection/user_auth_method_test.go b/internal/query/projection/user_auth_method_test.go new file mode 100644 index 0000000000..f4c96c1382 --- /dev/null +++ b/internal/query/projection/user_auth_method_test.go @@ -0,0 +1,257 @@ +package projection + +import ( + "testing" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/repository/user" +) + +func TestUserAuthMethodProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceAddedPasswordless", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanPasswordlessTokenAddedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id" + }`), + ), user.HumanPasswordlessAddedEventMapper), + }, + reduce: (&UserAuthMethodProjection{}).reduceInitAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserAuthMethodTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPSERT INTO zitadel.projections.user_auth_methods (token_id, creation_date, change_date, resource_owner, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedArgs: []interface{}{ + "token-id", + anyArg{}, + anyArg{}, + "ro-id", + "agg-id", + uint64(15), + domain.MFAStateNotReady, + domain.UserAuthMethodTypePasswordless, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceAddedU2F", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanU2FTokenAddedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id" + }`), + ), user.HumanU2FAddedEventMapper), + }, + reduce: (&UserAuthMethodProjection{}).reduceInitAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserAuthMethodTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPSERT INTO zitadel.projections.user_auth_methods (token_id, creation_date, change_date, resource_owner, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedArgs: []interface{}{ + "token-id", + anyArg{}, + anyArg{}, + "ro-id", + "agg-id", + uint64(15), + domain.MFAStateNotReady, + domain.UserAuthMethodTypeU2F, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceAddedOTP", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanMFAOTPAddedType), + user.AggregateType, + []byte(`{ + }`), + ), user.HumanOTPAddedEventMapper), + }, + reduce: (&UserAuthMethodProjection{}).reduceInitAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserAuthMethodTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPSERT INTO zitadel.projections.user_auth_methods (token_id, creation_date, change_date, resource_owner, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedArgs: []interface{}{ + "", + anyArg{}, + anyArg{}, + "ro-id", + "agg-id", + uint64(15), + domain.MFAStateNotReady, + domain.UserAuthMethodTypeOTP, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceVerifiedPasswordless", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanPasswordlessTokenVerifiedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id", + "webAuthNTokenName": "name" + }`), + ), user.HumanPasswordlessVerifiedEventMapper), + }, + reduce: (&UserAuthMethodProjection{}).reduceActivateEvent, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserAuthMethodTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.user_auth_methods SET (change_date, sequence, name, state) = ($1, $2, $3, $4) WHERE (user_id = $5) AND (method_type = $6) AND (resource_owner = $7) AND (token_id = $8)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "name", + domain.MFAStateReady, + "agg-id", + domain.UserAuthMethodTypePasswordless, + "ro-id", + "token-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceVerifiedU2F", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanU2FTokenVerifiedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id", + "webAuthNTokenName": "name" + }`), + ), user.HumanU2FVerifiedEventMapper), + }, + reduce: (&UserAuthMethodProjection{}).reduceActivateEvent, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserAuthMethodTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.user_auth_methods SET (change_date, sequence, name, state) = ($1, $2, $3, $4) WHERE (user_id = $5) AND (method_type = $6) AND (resource_owner = $7) AND (token_id = $8)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "name", + domain.MFAStateReady, + "agg-id", + domain.UserAuthMethodTypeU2F, + "ro-id", + "token-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceVerifiedOTP", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanMFAOTPVerifiedType), + user.AggregateType, + []byte(`{ + }`), + ), user.HumanOTPVerifiedEventMapper), + }, + reduce: (&UserAuthMethodProjection{}).reduceActivateEvent, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserAuthMethodTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.user_auth_methods SET (change_date, sequence, name, state) = ($1, $2, $3, $4) WHERE (user_id = $5) AND (method_type = $6) AND (resource_owner = $7) AND (token_id = $8)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "", + domain.MFAStateReady, + "agg-id", + domain.UserAuthMethodTypeOTP, + "ro-id", + "", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if _, ok := err.(errors.InvalidArgument); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, tt.want) + }) + } +} diff --git a/migrations/cockroach/V1.107__user_type.sql b/migrations/cockroach/V1.107__user_type.sql new file mode 100644 index 0000000000..60e63de07a --- /dev/null +++ b/migrations/cockroach/V1.107__user_type.sql @@ -0,0 +1,15 @@ +CREATE TABLE zitadel.projections.user_auth_methods ( + token_id STRING NOT NULL + , resource_owner STRING NOT NULL + , creation_date TIMESTAMPTZ NOT NULL + , change_date TIMESTAMPTZ NOT NULL + , sequence INT8 NOT NULL + + , user_id STRING NOT NULL + , state INT2 NOT NULL + , method_type INT2 NOT NULL + , name STRING NOT NULL + + , PRIMARY KEY (user_id, method_type, token_id) + , INDEX idx_ro (resource_owner) +);