diff --git a/backend/v3/domain/mock/session.mock.go b/backend/v3/domain/mock/session.mock.go index ad700dab9..78fbccc75 100644 --- a/backend/v3/domain/mock/session.mock.go +++ b/backend/v3/domain/mock/session.mock.go @@ -23,7 +23,6 @@ import ( type MockSessionRepository struct { ctrl *gomock.Controller recorder *MockSessionRepositoryMockRecorder - isgomock struct{} } // MockSessionRepositoryMockRecorder is the mock recorder for MockSessionRepository. @@ -44,17 +43,17 @@ func (m *MockSessionRepository) EXPECT() *MockSessionRepositoryMockRecorder { } // ClearFactor mocks base method. -func (m *MockSessionRepository) ClearFactor(factor domain.SessionFactorType) database.Change { +func (m *MockSessionRepository) ClearFactor(arg0 domain.SessionFactorType) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClearFactor", factor) + ret := m.ctrl.Call(m, "ClearFactor", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // ClearFactor indicates an expected call of ClearFactor. -func (mr *MockSessionRepositoryMockRecorder) ClearFactor(factor any) *MockSessionRepositoryClearFactorCall { +func (mr *MockSessionRepositoryMockRecorder) ClearFactor(arg0 any) *MockSessionRepositoryClearFactorCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearFactor", reflect.TypeOf((*MockSessionRepository)(nil).ClearFactor), factor) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearFactor", reflect.TypeOf((*MockSessionRepository)(nil).ClearFactor), arg0) return &MockSessionRepositoryClearFactorCall{Call: call} } @@ -82,17 +81,17 @@ func (c *MockSessionRepositoryClearFactorCall) DoAndReturn(f func(domain.Session } // Create mocks base method. -func (m *MockSessionRepository) Create(ctx context.Context, client database.QueryExecutor, user *domain.Session) error { +func (m *MockSessionRepository) Create(arg0 context.Context, arg1 database.QueryExecutor, arg2 *domain.Session) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, client, user) + ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create. -func (mr *MockSessionRepositoryMockRecorder) Create(ctx, client, user any) *MockSessionRepositoryCreateCall { +func (mr *MockSessionRepositoryMockRecorder) Create(arg0, arg1, arg2 any) *MockSessionRepositoryCreateCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSessionRepository)(nil).Create), ctx, client, user) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSessionRepository)(nil).Create), arg0, arg1, arg2) return &MockSessionRepositoryCreateCall{Call: call} } @@ -158,17 +157,17 @@ func (c *MockSessionRepositoryCreatedAtColumnCall) DoAndReturn(f func() database } // CreatedAtCondition mocks base method. -func (m *MockSessionRepository) CreatedAtCondition(op database.NumberOperation, createdAt time.Time) database.Condition { +func (m *MockSessionRepository) CreatedAtCondition(arg0 database.NumberOperation, arg1 time.Time) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreatedAtCondition", op, createdAt) + ret := m.ctrl.Call(m, "CreatedAtCondition", arg0, arg1) ret0, _ := ret[0].(database.Condition) return ret0 } // CreatedAtCondition indicates an expected call of CreatedAtCondition. -func (mr *MockSessionRepositoryMockRecorder) CreatedAtCondition(op, createdAt any) *MockSessionRepositoryCreatedAtConditionCall { +func (mr *MockSessionRepositoryMockRecorder) CreatedAtCondition(arg0, arg1 any) *MockSessionRepositoryCreatedAtConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).CreatedAtCondition), op, createdAt) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).CreatedAtCondition), arg0, arg1) return &MockSessionRepositoryCreatedAtConditionCall{Call: call} } @@ -195,18 +194,57 @@ func (c *MockSessionRepositoryCreatedAtConditionCall) DoAndReturn(f func(databas return c } -// Delete mocks base method. -func (m *MockSessionRepository) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) error { +// CreatorIDCondition mocks base method. +func (m *MockSessionRepository) CreatorIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Delete", ctx, client, condition) - ret0, _ := ret[0].(error) + ret := m.ctrl.Call(m, "CreatorIDCondition", arg0) + ret0, _ := ret[0].(database.Condition) return ret0 } -// Delete indicates an expected call of Delete. -func (mr *MockSessionRepositoryMockRecorder) Delete(ctx, client, condition any) *MockSessionRepositoryDeleteCall { +// CreatorIDCondition indicates an expected call of CreatorIDCondition. +func (mr *MockSessionRepositoryMockRecorder) CreatorIDCondition(arg0 any) *MockSessionRepositoryCreatorIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSessionRepository)(nil).Delete), ctx, client, condition) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatorIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).CreatorIDCondition), arg0) + return &MockSessionRepositoryCreatorIDConditionCall{Call: call} +} + +// MockSessionRepositoryCreatorIDConditionCall wrap *gomock.Call +type MockSessionRepositoryCreatorIDConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionRepositoryCreatorIDConditionCall) Return(arg0 database.Condition) *MockSessionRepositoryCreatorIDConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionRepositoryCreatorIDConditionCall) Do(f func(string) database.Condition) *MockSessionRepositoryCreatorIDConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionRepositoryCreatorIDConditionCall) DoAndReturn(f func(string) database.Condition) *MockSessionRepositoryCreatorIDConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Delete mocks base method. +func (m *MockSessionRepository) Delete(arg0 context.Context, arg1 database.QueryExecutor, arg2 database.Condition) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockSessionRepositoryMockRecorder) Delete(arg0, arg1, arg2 any) *MockSessionRepositoryDeleteCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSessionRepository)(nil).Delete), arg0, arg1, arg2) return &MockSessionRepositoryDeleteCall{Call: call} } @@ -216,19 +254,95 @@ type MockSessionRepositoryDeleteCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockSessionRepositoryDeleteCall) Return(arg0 error) *MockSessionRepositoryDeleteCall { - c.Call = c.Call.Return(arg0) +func (c *MockSessionRepositoryDeleteCall) Return(arg0 int64, arg1 error) *MockSessionRepositoryDeleteCall { + c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockSessionRepositoryDeleteCall) Do(f func(context.Context, database.QueryExecutor, database.Condition) error) *MockSessionRepositoryDeleteCall { +func (c *MockSessionRepositoryDeleteCall) Do(f func(context.Context, database.QueryExecutor, database.Condition) (int64, error)) *MockSessionRepositoryDeleteCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositoryDeleteCall) DoAndReturn(f func(context.Context, database.QueryExecutor, database.Condition) error) *MockSessionRepositoryDeleteCall { +func (c *MockSessionRepositoryDeleteCall) DoAndReturn(f func(context.Context, database.QueryExecutor, database.Condition) (int64, error)) *MockSessionRepositoryDeleteCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ExistsFactor mocks base method. +func (m *MockSessionRepository) ExistsFactor(arg0 database.Condition) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExistsFactor", arg0) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// ExistsFactor indicates an expected call of ExistsFactor. +func (mr *MockSessionRepositoryMockRecorder) ExistsFactor(arg0 any) *MockSessionRepositoryExistsFactorCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsFactor", reflect.TypeOf((*MockSessionRepository)(nil).ExistsFactor), arg0) + return &MockSessionRepositoryExistsFactorCall{Call: call} +} + +// MockSessionRepositoryExistsFactorCall wrap *gomock.Call +type MockSessionRepositoryExistsFactorCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionRepositoryExistsFactorCall) Return(arg0 database.Condition) *MockSessionRepositoryExistsFactorCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionRepositoryExistsFactorCall) Do(f func(database.Condition) database.Condition) *MockSessionRepositoryExistsFactorCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionRepositoryExistsFactorCall) DoAndReturn(f func(database.Condition) database.Condition) *MockSessionRepositoryExistsFactorCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ExistsMetadata mocks base method. +func (m *MockSessionRepository) ExistsMetadata(arg0 database.Condition) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExistsMetadata", arg0) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// ExistsMetadata indicates an expected call of ExistsMetadata. +func (mr *MockSessionRepositoryMockRecorder) ExistsMetadata(arg0 any) *MockSessionRepositoryExistsMetadataCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsMetadata", reflect.TypeOf((*MockSessionRepository)(nil).ExistsMetadata), arg0) + return &MockSessionRepositoryExistsMetadataCall{Call: call} +} + +// MockSessionRepositoryExistsMetadataCall wrap *gomock.Call +type MockSessionRepositoryExistsMetadataCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionRepositoryExistsMetadataCall) Return(arg0 database.Condition) *MockSessionRepositoryExistsMetadataCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionRepositoryExistsMetadataCall) Do(f func(database.Condition) database.Condition) *MockSessionRepositoryExistsMetadataCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionRepositoryExistsMetadataCall) DoAndReturn(f func(database.Condition) database.Condition) *MockSessionRepositoryExistsMetadataCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -271,40 +385,40 @@ func (c *MockSessionRepositoryExpirationColumnCall) DoAndReturn(f func() databas return c } -// FactorColumns mocks base method. -func (m *MockSessionRepository) FactorColumns() domain.SessionFactorColumns { +// ExpirationCondition mocks base method. +func (m *MockSessionRepository) ExpirationCondition(arg0 database.NumberOperation, arg1 time.Time) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FactorColumns") - ret0, _ := ret[0].(domain.SessionFactorColumns) + ret := m.ctrl.Call(m, "ExpirationCondition", arg0, arg1) + ret0, _ := ret[0].(database.Condition) return ret0 } -// FactorColumns indicates an expected call of FactorColumns. -func (mr *MockSessionRepositoryMockRecorder) FactorColumns() *MockSessionRepositoryFactorColumnsCall { +// ExpirationCondition indicates an expected call of ExpirationCondition. +func (mr *MockSessionRepositoryMockRecorder) ExpirationCondition(arg0, arg1 any) *MockSessionRepositoryExpirationConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FactorColumns", reflect.TypeOf((*MockSessionRepository)(nil).FactorColumns)) - return &MockSessionRepositoryFactorColumnsCall{Call: call} + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpirationCondition", reflect.TypeOf((*MockSessionRepository)(nil).ExpirationCondition), arg0, arg1) + return &MockSessionRepositoryExpirationConditionCall{Call: call} } -// MockSessionRepositoryFactorColumnsCall wrap *gomock.Call -type MockSessionRepositoryFactorColumnsCall struct { +// MockSessionRepositoryExpirationConditionCall wrap *gomock.Call +type MockSessionRepositoryExpirationConditionCall struct { *gomock.Call } // Return rewrite *gomock.Call.Return -func (c *MockSessionRepositoryFactorColumnsCall) Return(arg0 domain.SessionFactorColumns) *MockSessionRepositoryFactorColumnsCall { +func (c *MockSessionRepositoryExpirationConditionCall) Return(arg0 database.Condition) *MockSessionRepositoryExpirationConditionCall { c.Call = c.Call.Return(arg0) return c } // Do rewrite *gomock.Call.Do -func (c *MockSessionRepositoryFactorColumnsCall) Do(f func() domain.SessionFactorColumns) *MockSessionRepositoryFactorColumnsCall { +func (c *MockSessionRepositoryExpirationConditionCall) Do(f func(database.NumberOperation, time.Time) database.Condition) *MockSessionRepositoryExpirationConditionCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositoryFactorColumnsCall) DoAndReturn(f func() domain.SessionFactorColumns) *MockSessionRepositoryFactorColumnsCall { +func (c *MockSessionRepositoryExpirationConditionCall) DoAndReturn(f func(database.NumberOperation, time.Time) database.Condition) *MockSessionRepositoryExpirationConditionCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -348,10 +462,10 @@ func (c *MockSessionRepositoryFactorConditionsCall) DoAndReturn(f func() domain. } // Get mocks base method. -func (m *MockSessionRepository) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Session, error) { +func (m *MockSessionRepository) Get(arg0 context.Context, arg1 database.QueryExecutor, arg2 ...database.QueryOption) (*domain.Session, error) { m.ctrl.T.Helper() - varargs := []any{ctx, client} - for _, a := range opts { + varargs := []any{arg0, arg1} + for _, a := range arg2 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) @@ -361,9 +475,9 @@ func (m *MockSessionRepository) Get(ctx context.Context, client database.QueryEx } // Get indicates an expected call of Get. -func (mr *MockSessionRepositoryMockRecorder) Get(ctx, client any, opts ...any) *MockSessionRepositoryGetCall { +func (mr *MockSessionRepositoryMockRecorder) Get(arg0, arg1 any, arg2 ...any) *MockSessionRepositoryGetCall { mr.mock.ctrl.T.Helper() - varargs := append([]any{ctx, client}, opts...) + varargs := append([]any{arg0, arg1}, arg2...) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSessionRepository)(nil).Get), varargs...) return &MockSessionRepositoryGetCall{Call: call} } @@ -430,17 +544,17 @@ func (c *MockSessionRepositoryIDColumnCall) DoAndReturn(f func() database.Column } // IDCondition mocks base method. -func (m *MockSessionRepository) IDCondition(sessionID string) database.Condition { +func (m *MockSessionRepository) IDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IDCondition", sessionID) + ret := m.ctrl.Call(m, "IDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // IDCondition indicates an expected call of IDCondition. -func (mr *MockSessionRepositoryMockRecorder) IDCondition(sessionID any) *MockSessionRepositoryIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) IDCondition(arg0 any) *MockSessionRepositoryIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IDCondition", reflect.TypeOf((*MockSessionRepository)(nil).IDCondition), sessionID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IDCondition", reflect.TypeOf((*MockSessionRepository)(nil).IDCondition), arg0) return &MockSessionRepositoryIDConditionCall{Call: call} } @@ -506,17 +620,17 @@ func (c *MockSessionRepositoryInstanceIDColumnCall) DoAndReturn(f func() databas } // InstanceIDCondition mocks base method. -func (m *MockSessionRepository) InstanceIDCondition(instanceID string) database.Condition { +func (m *MockSessionRepository) InstanceIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InstanceIDCondition", instanceID) + ret := m.ctrl.Call(m, "InstanceIDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // InstanceIDCondition indicates an expected call of InstanceIDCondition. -func (mr *MockSessionRepositoryMockRecorder) InstanceIDCondition(instanceID any) *MockSessionRepositoryInstanceIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) InstanceIDCondition(arg0 any) *MockSessionRepositoryInstanceIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).InstanceIDCondition), instanceID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).InstanceIDCondition), arg0) return &MockSessionRepositoryInstanceIDConditionCall{Call: call} } @@ -582,10 +696,10 @@ func (c *MockSessionRepositoryLifetimeColumnCall) DoAndReturn(f func() database. } // List mocks base method. -func (m *MockSessionRepository) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Session, error) { +func (m *MockSessionRepository) List(arg0 context.Context, arg1 database.QueryExecutor, arg2 ...database.QueryOption) ([]*domain.Session, error) { m.ctrl.T.Helper() - varargs := []any{ctx, client} - for _, a := range opts { + varargs := []any{arg0, arg1} + for _, a := range arg2 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) @@ -595,9 +709,9 @@ func (m *MockSessionRepository) List(ctx context.Context, client database.QueryE } // List indicates an expected call of List. -func (mr *MockSessionRepositoryMockRecorder) List(ctx, client any, opts ...any) *MockSessionRepositoryListCall { +func (mr *MockSessionRepositoryMockRecorder) List(arg0, arg1 any, arg2 ...any) *MockSessionRepositoryListCall { mr.mock.ctrl.T.Helper() - varargs := append([]any{ctx, client}, opts...) + varargs := append([]any{arg0, arg1}, arg2...) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockSessionRepository)(nil).List), varargs...) return &MockSessionRepositoryListCall{Call: call} } @@ -625,44 +739,6 @@ func (c *MockSessionRepositoryListCall) DoAndReturn(f func(context.Context, data return c } -// MetadataColumns mocks base method. -func (m *MockSessionRepository) MetadataColumns() domain.SessionMetadataColumns { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MetadataColumns") - ret0, _ := ret[0].(domain.SessionMetadataColumns) - return ret0 -} - -// MetadataColumns indicates an expected call of MetadataColumns. -func (mr *MockSessionRepositoryMockRecorder) MetadataColumns() *MockSessionRepositoryMetadataColumnsCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MetadataColumns", reflect.TypeOf((*MockSessionRepository)(nil).MetadataColumns)) - return &MockSessionRepositoryMetadataColumnsCall{Call: call} -} - -// MockSessionRepositoryMetadataColumnsCall wrap *gomock.Call -type MockSessionRepositoryMetadataColumnsCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockSessionRepositoryMetadataColumnsCall) Return(arg0 domain.SessionMetadataColumns) *MockSessionRepositoryMetadataColumnsCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockSessionRepositoryMetadataColumnsCall) Do(f func() domain.SessionMetadataColumns) *MockSessionRepositoryMetadataColumnsCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositoryMetadataColumnsCall) DoAndReturn(f func() domain.SessionMetadataColumns) *MockSessionRepositoryMetadataColumnsCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - // MetadataConditions mocks base method. func (m *MockSessionRepository) MetadataConditions() domain.SessionMetadataConditions { m.ctrl.T.Helper() @@ -740,17 +816,17 @@ func (c *MockSessionRepositoryPrimaryKeyColumnsCall) DoAndReturn(f func() []data } // PrimaryKeyCondition mocks base method. -func (m *MockSessionRepository) PrimaryKeyCondition(instanceID, sessionID string) database.Condition { +func (m *MockSessionRepository) PrimaryKeyCondition(arg0, arg1 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PrimaryKeyCondition", instanceID, sessionID) + ret := m.ctrl.Call(m, "PrimaryKeyCondition", arg0, arg1) ret0, _ := ret[0].(database.Condition) return ret0 } // PrimaryKeyCondition indicates an expected call of PrimaryKeyCondition. -func (mr *MockSessionRepositoryMockRecorder) PrimaryKeyCondition(instanceID, sessionID any) *MockSessionRepositoryPrimaryKeyConditionCall { +func (mr *MockSessionRepositoryMockRecorder) PrimaryKeyCondition(arg0, arg1 any) *MockSessionRepositoryPrimaryKeyConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyCondition", reflect.TypeOf((*MockSessionRepository)(nil).PrimaryKeyCondition), instanceID, sessionID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyCondition", reflect.TypeOf((*MockSessionRepository)(nil).PrimaryKeyCondition), arg0, arg1) return &MockSessionRepositoryPrimaryKeyConditionCall{Call: call} } @@ -778,17 +854,17 @@ func (c *MockSessionRepositoryPrimaryKeyConditionCall) DoAndReturn(f func(string } // SetChallenge mocks base method. -func (m *MockSessionRepository) SetChallenge(challenge domain.SessionChallenge) database.Change { +func (m *MockSessionRepository) SetChallenge(arg0 domain.SessionChallenge) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetChallenge", challenge) + ret := m.ctrl.Call(m, "SetChallenge", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetChallenge indicates an expected call of SetChallenge. -func (mr *MockSessionRepositoryMockRecorder) SetChallenge(challenge any) *MockSessionRepositorySetChallengeCall { +func (mr *MockSessionRepositoryMockRecorder) SetChallenge(arg0 any) *MockSessionRepositorySetChallengeCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetChallenge", reflect.TypeOf((*MockSessionRepository)(nil).SetChallenge), challenge) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetChallenge", reflect.TypeOf((*MockSessionRepository)(nil).SetChallenge), arg0) return &MockSessionRepositorySetChallengeCall{Call: call} } @@ -816,17 +892,17 @@ func (c *MockSessionRepositorySetChallengeCall) DoAndReturn(f func(domain.Sessio } // SetFactor mocks base method. -func (m *MockSessionRepository) SetFactor(factor domain.SessionFactor) database.Change { +func (m *MockSessionRepository) SetFactor(arg0 domain.SessionFactor) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetFactor", factor) + ret := m.ctrl.Call(m, "SetFactor", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetFactor indicates an expected call of SetFactor. -func (mr *MockSessionRepositoryMockRecorder) SetFactor(factor any) *MockSessionRepositorySetFactorCall { +func (mr *MockSessionRepositoryMockRecorder) SetFactor(arg0 any) *MockSessionRepositorySetFactorCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFactor", reflect.TypeOf((*MockSessionRepository)(nil).SetFactor), factor) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFactor", reflect.TypeOf((*MockSessionRepository)(nil).SetFactor), arg0) return &MockSessionRepositorySetFactorCall{Call: call} } @@ -854,17 +930,17 @@ func (c *MockSessionRepositorySetFactorCall) DoAndReturn(f func(domain.SessionFa } // SetLifetime mocks base method. -func (m *MockSessionRepository) SetLifetime(lifetime time.Duration) database.Change { +func (m *MockSessionRepository) SetLifetime(arg0 time.Duration) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetLifetime", lifetime) + ret := m.ctrl.Call(m, "SetLifetime", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetLifetime indicates an expected call of SetLifetime. -func (mr *MockSessionRepositoryMockRecorder) SetLifetime(lifetime any) *MockSessionRepositorySetLifetimeCall { +func (mr *MockSessionRepositoryMockRecorder) SetLifetime(arg0 any) *MockSessionRepositorySetLifetimeCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLifetime", reflect.TypeOf((*MockSessionRepository)(nil).SetLifetime), lifetime) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLifetime", reflect.TypeOf((*MockSessionRepository)(nil).SetLifetime), arg0) return &MockSessionRepositorySetLifetimeCall{Call: call} } @@ -892,17 +968,17 @@ func (c *MockSessionRepositorySetLifetimeCall) DoAndReturn(f func(time.Duration) } // SetMetadata mocks base method. -func (m *MockSessionRepository) SetMetadata(metadata []domain.SessionMetadata) database.Change { +func (m *MockSessionRepository) SetMetadata(arg0 []*domain.SessionMetadata) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetMetadata", metadata) + ret := m.ctrl.Call(m, "SetMetadata", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetMetadata indicates an expected call of SetMetadata. -func (mr *MockSessionRepositoryMockRecorder) SetMetadata(metadata any) *MockSessionRepositorySetMetadataCall { +func (mr *MockSessionRepositoryMockRecorder) SetMetadata(arg0 any) *MockSessionRepositorySetMetadataCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMetadata", reflect.TypeOf((*MockSessionRepository)(nil).SetMetadata), metadata) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMetadata", reflect.TypeOf((*MockSessionRepository)(nil).SetMetadata), arg0) return &MockSessionRepositorySetMetadataCall{Call: call} } @@ -918,29 +994,29 @@ func (c *MockSessionRepositorySetMetadataCall) Return(arg0 database.Change) *Moc } // Do rewrite *gomock.Call.Do -func (c *MockSessionRepositorySetMetadataCall) Do(f func([]domain.SessionMetadata) database.Change) *MockSessionRepositorySetMetadataCall { +func (c *MockSessionRepositorySetMetadataCall) Do(f func([]*domain.SessionMetadata) database.Change) *MockSessionRepositorySetMetadataCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositorySetMetadataCall) DoAndReturn(f func([]domain.SessionMetadata) database.Change) *MockSessionRepositorySetMetadataCall { +func (c *MockSessionRepositorySetMetadataCall) DoAndReturn(f func([]*domain.SessionMetadata) database.Change) *MockSessionRepositorySetMetadataCall { c.Call = c.Call.DoAndReturn(f) return c } // SetToken mocks base method. -func (m *MockSessionRepository) SetToken(token string) database.Change { +func (m *MockSessionRepository) SetToken(arg0 string) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetToken", token) + ret := m.ctrl.Call(m, "SetToken", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetToken indicates an expected call of SetToken. -func (mr *MockSessionRepositoryMockRecorder) SetToken(token any) *MockSessionRepositorySetTokenCall { +func (mr *MockSessionRepositoryMockRecorder) SetToken(arg0 any) *MockSessionRepositorySetTokenCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetToken", reflect.TypeOf((*MockSessionRepository)(nil).SetToken), token) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetToken", reflect.TypeOf((*MockSessionRepository)(nil).SetToken), arg0) return &MockSessionRepositorySetTokenCall{Call: call} } @@ -968,17 +1044,17 @@ func (c *MockSessionRepositorySetTokenCall) DoAndReturn(f func(string) database. } // SetUpdatedAt mocks base method. -func (m *MockSessionRepository) SetUpdatedAt(updatedAt time.Time) database.Change { +func (m *MockSessionRepository) SetUpdatedAt(arg0 time.Time) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetUpdatedAt", updatedAt) + ret := m.ctrl.Call(m, "SetUpdatedAt", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetUpdatedAt indicates an expected call of SetUpdatedAt. -func (mr *MockSessionRepositoryMockRecorder) SetUpdatedAt(updatedAt any) *MockSessionRepositorySetUpdatedAtCall { +func (mr *MockSessionRepositoryMockRecorder) SetUpdatedAt(arg0 any) *MockSessionRepositorySetUpdatedAtCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdatedAt", reflect.TypeOf((*MockSessionRepository)(nil).SetUpdatedAt), updatedAt) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdatedAt", reflect.TypeOf((*MockSessionRepository)(nil).SetUpdatedAt), arg0) return &MockSessionRepositorySetUpdatedAtCall{Call: call} } @@ -1005,44 +1081,6 @@ func (c *MockSessionRepositorySetUpdatedAtCall) DoAndReturn(f func(time.Time) da return c } -// SetUserAgent mocks base method. -func (m *MockSessionRepository) SetUserAgent(userAgent domain.SessionUserAgent) database.Change { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetUserAgent", userAgent) - ret0, _ := ret[0].(database.Change) - return ret0 -} - -// SetUserAgent indicates an expected call of SetUserAgent. -func (mr *MockSessionRepositoryMockRecorder) SetUserAgent(userAgent any) *MockSessionRepositorySetUserAgentCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUserAgent", reflect.TypeOf((*MockSessionRepository)(nil).SetUserAgent), userAgent) - return &MockSessionRepositorySetUserAgentCall{Call: call} -} - -// MockSessionRepositorySetUserAgentCall wrap *gomock.Call -type MockSessionRepositorySetUserAgentCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockSessionRepositorySetUserAgentCall) Return(arg0 database.Change) *MockSessionRepositorySetUserAgentCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockSessionRepositorySetUserAgentCall) Do(f func(domain.SessionUserAgent) database.Change) *MockSessionRepositorySetUserAgentCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositorySetUserAgentCall) DoAndReturn(f func(domain.SessionUserAgent) database.Change) *MockSessionRepositorySetUserAgentCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - // TokenColumn mocks base method. func (m *MockSessionRepository) TokenColumn() database.Column { m.ctrl.T.Helper() @@ -1082,10 +1120,10 @@ func (c *MockSessionRepositoryTokenColumnCall) DoAndReturn(f func() database.Col } // Update mocks base method. -func (m *MockSessionRepository) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) { +func (m *MockSessionRepository) Update(arg0 context.Context, arg1 database.QueryExecutor, arg2 database.Condition, arg3 ...database.Change) (int64, error) { m.ctrl.T.Helper() - varargs := []any{ctx, client, condition} - for _, a := range changes { + varargs := []any{arg0, arg1, arg2} + for _, a := range arg3 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Update", varargs...) @@ -1095,9 +1133,9 @@ func (m *MockSessionRepository) Update(ctx context.Context, client database.Quer } // Update indicates an expected call of Update. -func (mr *MockSessionRepositoryMockRecorder) Update(ctx, client, condition any, changes ...any) *MockSessionRepositoryUpdateCall { +func (mr *MockSessionRepositoryMockRecorder) Update(arg0, arg1, arg2 any, arg3 ...any) *MockSessionRepositoryUpdateCall { mr.mock.ctrl.T.Helper() - varargs := append([]any{ctx, client, condition}, changes...) + varargs := append([]any{arg0, arg1, arg2}, arg3...) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSessionRepository)(nil).Update), varargs...) return &MockSessionRepositoryUpdateCall{Call: call} } @@ -1164,17 +1202,17 @@ func (c *MockSessionRepositoryUpdatedAtColumnCall) DoAndReturn(f func() database } // UpdatedAtCondition mocks base method. -func (m *MockSessionRepository) UpdatedAtCondition(op database.NumberOperation, updatedAt time.Time) database.Condition { +func (m *MockSessionRepository) UpdatedAtCondition(arg0 database.NumberOperation, arg1 time.Time) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdatedAtCondition", op, updatedAt) + ret := m.ctrl.Call(m, "UpdatedAtCondition", arg0, arg1) ret0, _ := ret[0].(database.Condition) return ret0 } // UpdatedAtCondition indicates an expected call of UpdatedAtCondition. -func (mr *MockSessionRepositoryMockRecorder) UpdatedAtCondition(op, updatedAt any) *MockSessionRepositoryUpdatedAtConditionCall { +func (mr *MockSessionRepositoryMockRecorder) UpdatedAtCondition(arg0, arg1 any) *MockSessionRepositoryUpdatedAtConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).UpdatedAtCondition), op, updatedAt) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).UpdatedAtCondition), arg0, arg1) return &MockSessionRepositoryUpdatedAtConditionCall{Call: call} } @@ -1201,94 +1239,18 @@ func (c *MockSessionRepositoryUpdatedAtConditionCall) DoAndReturn(f func(databas return c } -// UserAgentColumns mocks base method. -func (m *MockSessionRepository) UserAgentColumns() domain.SessionUserAgentColumns { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserAgentColumns") - ret0, _ := ret[0].(domain.SessionUserAgentColumns) - return ret0 -} - -// UserAgentColumns indicates an expected call of UserAgentColumns. -func (mr *MockSessionRepositoryMockRecorder) UserAgentColumns() *MockSessionRepositoryUserAgentColumnsCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserAgentColumns", reflect.TypeOf((*MockSessionRepository)(nil).UserAgentColumns)) - return &MockSessionRepositoryUserAgentColumnsCall{Call: call} -} - -// MockSessionRepositoryUserAgentColumnsCall wrap *gomock.Call -type MockSessionRepositoryUserAgentColumnsCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockSessionRepositoryUserAgentColumnsCall) Return(arg0 domain.SessionUserAgentColumns) *MockSessionRepositoryUserAgentColumnsCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockSessionRepositoryUserAgentColumnsCall) Do(f func() domain.SessionUserAgentColumns) *MockSessionRepositoryUserAgentColumnsCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositoryUserAgentColumnsCall) DoAndReturn(f func() domain.SessionUserAgentColumns) *MockSessionRepositoryUserAgentColumnsCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - -// UserAgentConditions mocks base method. -func (m *MockSessionRepository) UserAgentConditions() domain.SessionUserAgentConditions { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserAgentConditions") - ret0, _ := ret[0].(domain.SessionUserAgentConditions) - return ret0 -} - -// UserAgentConditions indicates an expected call of UserAgentConditions. -func (mr *MockSessionRepositoryMockRecorder) UserAgentConditions() *MockSessionRepositoryUserAgentConditionsCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserAgentConditions", reflect.TypeOf((*MockSessionRepository)(nil).UserAgentConditions)) - return &MockSessionRepositoryUserAgentConditionsCall{Call: call} -} - -// MockSessionRepositoryUserAgentConditionsCall wrap *gomock.Call -type MockSessionRepositoryUserAgentConditionsCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockSessionRepositoryUserAgentConditionsCall) Return(arg0 domain.SessionUserAgentConditions) *MockSessionRepositoryUserAgentConditionsCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockSessionRepositoryUserAgentConditionsCall) Do(f func() domain.SessionUserAgentConditions) *MockSessionRepositoryUserAgentConditionsCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositoryUserAgentConditionsCall) DoAndReturn(f func() domain.SessionUserAgentConditions) *MockSessionRepositoryUserAgentConditionsCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - // UserAgentIDCondition mocks base method. -func (m *MockSessionRepository) UserAgentIDCondition(userAgentID string) database.Condition { +func (m *MockSessionRepository) UserAgentIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserAgentIDCondition", userAgentID) + ret := m.ctrl.Call(m, "UserAgentIDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // UserAgentIDCondition indicates an expected call of UserAgentIDCondition. -func (mr *MockSessionRepositoryMockRecorder) UserAgentIDCondition(userAgentID any) *MockSessionRepositoryUserAgentIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) UserAgentIDCondition(arg0 any) *MockSessionRepositoryUserAgentIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserAgentIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserAgentIDCondition), userAgentID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserAgentIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserAgentIDCondition), arg0) return &MockSessionRepositoryUserAgentIDConditionCall{Call: call} } @@ -1354,17 +1316,17 @@ func (c *MockSessionRepositoryUserIDColumnCall) DoAndReturn(f func() database.Co } // UserIDCondition mocks base method. -func (m *MockSessionRepository) UserIDCondition(userID string) database.Condition { +func (m *MockSessionRepository) UserIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserIDCondition", userID) + ret := m.ctrl.Call(m, "UserIDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // UserIDCondition indicates an expected call of UserIDCondition. -func (mr *MockSessionRepositoryMockRecorder) UserIDCondition(userID any) *MockSessionRepositoryUserIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) UserIDCondition(arg0 any) *MockSessionRepositoryUserIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserIDCondition), userID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserIDCondition), arg0) return &MockSessionRepositoryUserIDConditionCall{Call: call} } diff --git a/backend/v3/domain/session.go b/backend/v3/domain/session.go index 0f71216e3..1aa8989ab 100644 --- a/backend/v3/domain/session.go +++ b/backend/v3/domain/session.go @@ -8,18 +8,19 @@ import ( ) type Session struct { - InstanceID string `json:"instance_id,omitempty" db:"instance_id"` - ID string `json:"id,omitempty" db:"id"` - Token string `json:"token,omitempty" db:"token"` - Lifetime time.Duration `json:"lifetime,omitempty" db:"lifetime"` - Expiration time.Time `json:"expiration,omitzero" db:"expiration"` - UserID string `json:"user_id,omitempty" db:"user_id"` - CreatedAt time.Time `json:"created_at,omitzero" db:"created_at"` - UpdatedAt time.Time `json:"updated_at,omitzero" db:"updated_at"` - Factors SessionFactors `json:"factors,omitempty"` - Challenges SessionChallenges `json:"challenges,omitempty"` - Metadata []SessionMetadata `json:"metadata,omitempty"` - UserAgent *SessionUserAgent `json:"user_agent,omitempty"` + InstanceID string `json:"instanceId,omitempty" db:"instance_id"` + ID string `json:"id,omitempty" db:"id"` + TokenID string `json:"tokenId,omitempty" db:"token_id"` + Lifetime time.Duration `json:"lifetime,omitempty" db:"lifetime"` + Expiration time.Time `json:"expiration,omitzero" db:"expiration"` + UserID string `json:"userId,omitempty" db:"user_id"` + CreatorID string `json:"creatorId,omitempty" db:"creator_id"` + CreatedAt time.Time `json:"createdAt,omitzero" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitzero" db:"updated_at"` + Factors SessionFactors `json:"factors,omitempty"` + Challenges SessionChallenges `json:"challenges,omitempty"` + Metadata []*SessionMetadata `json:"metadata,omitempty"` + UserAgent *SessionUserAgent `json:"userAgent,omitempty"` } //go:generate mockgen -typed -package domainmock -destination ./mock/session.mock.go . SessionRepository @@ -41,7 +42,7 @@ type SessionRepository interface { // The condition must include at least the instanceID of the session to update. Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) // Delete removes sessions based on the given condition. - Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) error + Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) } // sessionColumns define all the columns of the session table. @@ -50,8 +51,8 @@ type sessionColumns interface { InstanceIDColumn() database.Column // IDColumn returns the column for the id field. IDColumn() database.Column - // TokenColumn returns the column for the token field. - TokenColumn() database.Column + // TokenIDColumn returns the column for the token id field. + TokenIDColumn() database.Column // LifetimeColumn returns the column for the lifetime field. LifetimeColumn() database.Column // ExpirationColumn returns the column for the expiration field. @@ -62,13 +63,6 @@ type sessionColumns interface { CreatedAtColumn() database.Column // UpdatedAtColumn returns the column for the updated at field. UpdatedAtColumn() database.Column - - // FactorColumns returns the columns for the factors fields. - FactorColumns() SessionFactorColumns - // MetadataColumns returns the columns for the metadata fields. - MetadataColumns() SessionMetadataColumns - // UserAgentColumns returns the columns for the user agent fields. - UserAgentColumns() SessionUserAgentColumns } // sessionConditions define all the conditions for the session table. @@ -83,17 +77,22 @@ type sessionConditions interface { UserAgentIDCondition(userAgentID string) database.Condition // UserIDCondition returns an equal filter on the user id field. UserIDCondition(userID string) database.Condition + // CreatorIDCondition returns an equal filter on the creator id field. + CreatorIDCondition(creatorID string) database.Condition + // ExpirationCondition returns a filter on the expiration field. + ExpirationCondition(op database.NumberOperation, expiration time.Time) database.Condition // CreatedAtCondition returns a filter on the created at field. CreatedAtCondition(op database.NumberOperation, createdAt time.Time) database.Condition // UpdatedAtCondition returns a filter on the updated at field. UpdatedAtCondition(op database.NumberOperation, updatedAt time.Time) database.Condition - + // ExistsFactor returns a filter on the session's factors. + ExistsFactor(condition database.Condition) database.Condition // FactorConditions returns the conditions for the factors fields. FactorConditions() SessionFactorConditions + // ExistsMetadata returns a filter on the session's metadata. + ExistsMetadata(condition database.Condition) database.Condition // MetadataConditions returns the conditions for the metadata fields. MetadataConditions() SessionMetadataConditions - // UserAgentConditions returns the conditions for the user agent fields. - UserAgentConditions() SessionUserAgentConditions } type sessionChanges interface { @@ -101,7 +100,7 @@ type sessionChanges interface { // Only use this when reducing events, // during regular updates the DB sets this column automatically. SetUpdatedAt(updatedAt time.Time) database.Change - // SetToken sets the token field of the session. + // SetToken sets the token id field of the session. SetToken(token string) database.Change // SetLifetime sets the lifetime field of the session and will update the computed expiration field. SetLifetime(lifetime time.Duration) database.Change @@ -110,10 +109,8 @@ type sessionChanges interface { SetChallenge(challenge SessionChallenge) database.Change // SetFactor adds or updates the factor of the corresponding type. SetFactor(factor SessionFactor) database.Change - // ClearFactor resets the factor of the corresponding type. + // ClearFactor resets the factor's verification. ClearFactor(factor SessionFactorType) database.Change // SetMetadata adds or updates the metadata of the session. - SetMetadata(metadata []SessionMetadata) database.Change - // SetUserAgent adds or updates the user agent of the session. - SetUserAgent(userAgent SessionUserAgent) database.Change + SetMetadata(metadata []*SessionMetadata) database.Change } diff --git a/backend/v3/domain/session_factor.go b/backend/v3/domain/session_factor.go index 7df3666da..0e45284da 100644 --- a/backend/v3/domain/session_factor.go +++ b/backend/v3/domain/session_factor.go @@ -8,94 +8,112 @@ import ( "github.com/zitadel/zitadel/internal/domain" ) -type SessionFactorType int +//go:generate enumer -type=SessionFactorType -trimprefix SessionFactorType -transform snake -linecomment -sql +type SessionFactorType uint8 const ( SessionFactorTypeUnknown SessionFactorType = iota SessionFactorTypeUser SessionFactorTypePassword - SessionFactorTypePasskey // formerly known as WebAuthn + // SessionFactorTypePasskey was formerly known as WebAuthn + SessionFactorTypePasskey SessionFactorTypeIdentityProviderIntent + // SessionFactorTypeTOTP used to be called OTP in some places SessionFactorTypeTOTP - SessionFactorTypeOTPSMS + SessionFactorTypeOTPSMS // otp_sms SessionFactorTypeOTPEmail + SessionFactorTypeRecoveryCode ) type SessionFactor interface { - sessionFactorType() SessionFactorType + SessionFactorType() SessionFactorType } type SessionFactorUser struct { - UserID string - LastVerifiedAt time.Time + UserID string `json:"userId,omitempty" db:"user_id"` + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` } -// sessionFactorType implements [SessionFactor]. -func (s *SessionFactorUser) sessionFactorType() SessionFactorType { +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorUser) SessionFactorType() SessionFactorType { return SessionFactorTypeUser } type SessionFactorPassword struct { - LastVerifiedAt time.Time - LastFailedAt time.Time + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` + LastFailedAt time.Time `json:"lastFailedAt,omitzero" db:"last_failed_at"` } -// sessionFactorType implements [SessionFactor]. -func (s *SessionFactorPassword) sessionFactorType() SessionFactorType { +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorPassword) SessionFactorType() SessionFactorType { return SessionFactorTypePassword } type SessionFactorIdentityProviderIntent struct { - LastVerifiedAt time.Time + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` } -// sessionFactorType implements [SessionFactor]. -func (s *SessionFactorIdentityProviderIntent) sessionFactorType() SessionFactorType { +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorIdentityProviderIntent) SessionFactorType() SessionFactorType { return SessionFactorTypeIdentityProviderIntent } type SessionFactorPasskey struct { - LastVerifiedAt time.Time - UserVerified bool + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` + UserVerified bool `json:"userVerified,omitempty" db:"user_verified"` } -// sessionFactorType implements [SessionFactor]. -func (s *SessionFactorPasskey) sessionFactorType() SessionFactorType { +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorPasskey) SessionFactorType() SessionFactorType { return SessionFactorTypePasskey } type SessionFactorTOTP struct { - LastVerifiedAt time.Time - LastFailedAt time.Time + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` + LastFailedAt time.Time `json:"lastFailedAt,omitzero" db:"last_failed_at"` } -// sessionFactorType implements [SessionFactor]. -func (s *SessionFactorTOTP) sessionFactorType() SessionFactorType { +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorTOTP) SessionFactorType() SessionFactorType { return SessionFactorTypeTOTP } type SessionFactorOTPSMS struct { - LastVerifiedAt time.Time - LastFailedAt time.Time + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` + LastFailedAt time.Time `json:"lastFailedAt,omitzero" db:"last_failed_at"` } -// sessionFactorType implements [SessionFactor]. -func (s *SessionFactorOTPSMS) sessionFactorType() SessionFactorType { +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorOTPSMS) SessionFactorType() SessionFactorType { return SessionFactorTypeOTPSMS } type SessionFactorOTPEmail struct { - LastVerifiedAt time.Time - LastFailedAt time.Time + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` + LastFailedAt time.Time `json:"lastFailedAt,omitzero" db:"last_failed_at"` } -// sessionFactorType implements [SessionFactor]. -func (s *SessionFactorOTPEmail) sessionFactorType() SessionFactorType { +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorOTPEmail) SessionFactorType() SessionFactorType { return SessionFactorTypeOTPEmail } +type SessionFactorRecoveryCode struct { + LastVerifiedAt time.Time `json:"lastVerifiedAt,omitzero" db:"last_verified_at"` + LastFailedAt time.Time `json:"lastFailedAt,omitzero" db:"last_failed_at"` +} + +// SessionFactorType implements [SessionFactor]. +func (s *SessionFactorRecoveryCode) SessionFactorType() SessionFactorType { + return SessionFactorTypeRecoveryCode +} + type SessionFactors []SessionFactor +func (s *SessionFactors) AppendTo(factor SessionFactor) { + *s = append(*s, factor) +} + // GetUserFactor returns the user factor from the factors. func (s SessionFactors) GetUserFactor() *SessionFactorUser { factor, _ := GetFactor[*SessionFactorUser](s) @@ -138,6 +156,12 @@ func (s SessionFactors) GetOTPEmailFactor() *SessionFactorOTPEmail { return factor } +// GetRecoveryCodeFactor returns the recovery code factor from the factors. +func (s SessionFactors) GetRecoveryCodeFactor() *SessionFactorRecoveryCode { + factor, _ := GetFactor[*SessionFactorRecoveryCode](s) + return factor +} + // GetFactor returns the factor of the given type from the factors. func GetFactor[T SessionFactor](factors SessionFactors) (T, bool) { var nilT T @@ -150,52 +174,56 @@ func GetFactor[T SessionFactor](factors SessionFactors) (T, bool) { } type SessionChallenge interface { - sessionChallengeType() SessionFactorType + SessionChallengeType() SessionFactorType } type SessionChallengePasskey struct { - LastChallengedAt time.Time - Challenge string - AllowedCredentialIDs [][]byte - UserVerification domain.UserVerificationRequirement - RPID string + LastChallengedAt time.Time `json:"lastChallengedAt,omitzero" db:"last_challenged_at"` + Challenge string `json:"challenge,omitempty" db:"challenge"` + AllowedCredentialIDs [][]byte `json:"allowedCredentialIDs,omitempty" db:"allowed_credential_ids"` + UserVerification domain.UserVerificationRequirement `json:"userVerification,omitempty" db:"user_verification"` + RPID string `json:"rpId,omitempty" db:"rp_id"` } -// sessionChallengeType implements [SessionChallenge]. -func (s *SessionChallengePasskey) sessionChallengeType() SessionFactorType { +// SessionChallengeType implements [SessionChallenge]. +func (s *SessionChallengePasskey) SessionChallengeType() SessionFactorType { return SessionFactorTypePasskey } type SessionChallengeOTPSMS struct { - LastChallengedAt time.Time - Code *crypto.CryptoValue - Expiry time.Duration - CodeReturned bool - GeneratorID string - TriggeredAtOrigin string + LastChallengedAt time.Time `json:"lastChallengedAt,omitzero" db:"last_challenged_at"` + Code *crypto.CryptoValue `json:"code,omitempty" db:"code"` + Expiry time.Duration `json:"expiry,omitzero" db:"expiry"` + CodeReturned bool `json:"codeReturned,omitempty" db:"code_returned"` + GeneratorID string `json:"generatorId,omitempty" db:"generator_id"` + TriggeredAtOrigin string `json:"triggeredAtOrigin,omitempty" db:"triggered_at_origin"` } -// sessionChallengeType implements [SessionChallenge]. -func (s *SessionChallengeOTPSMS) sessionChallengeType() SessionFactorType { +// SessionChallengeType implements [SessionChallenge]. +func (s *SessionChallengeOTPSMS) SessionChallengeType() SessionFactorType { return SessionFactorTypeOTPSMS } type SessionChallengeOTPEmail struct { - LastChallengedAt time.Time - Code *crypto.CryptoValue - Expiry time.Duration - CodeReturned bool - URLTmpl string - TriggeredAtOrigin string + LastChallengedAt time.Time `json:"lastChallengedAt,omitzero" db:"last_challenged_at"` + Code *crypto.CryptoValue `json:"code,omitempty" db:"code"` + Expiry time.Duration `json:"expiry,omitzero" db:"expiry"` + CodeReturned bool `json:"codeReturned,omitempty" db:"code_returned"` + URLTemplate string `json:"urlTemplate,omitempty" db:"url_template"` + TriggeredAtOrigin string `json:"triggeredAtOrigin,omitempty" db:"triggered_at_origin"` } -// sessionChallengeType implements [SessionChallenge]. -func (s *SessionChallengeOTPEmail) sessionChallengeType() SessionFactorType { +// SessionChallengeType implements [SessionChallenge]. +func (s *SessionChallengeOTPEmail) SessionChallengeType() SessionFactorType { return SessionFactorTypeOTPEmail } type SessionChallenges []SessionChallenge +func (s *SessionChallenges) AppendTo(challenge SessionChallenge) { + *s = append(*s, challenge) +} + // GetPasskeyChallenge returns the passkey challenge from the challenges. func (s SessionChallenges) GetPasskeyChallenge() *SessionChallengePasskey { challenge, _ := GetChallenge[*SessionChallengePasskey](s) @@ -225,30 +253,9 @@ func GetChallenge[T SessionChallenge](challenges SessionChallenges) (T, bool) { return nilT, false } -type SessionFactorColumns interface { - // InstanceIDColumn returns the column for the instance id field. - InstanceIDColumn() database.Column - // SessionIDColumn returns the column for the session id field. - SessionIDColumn() database.Column - // FactorTypeColumn returns the column for the factor type field. - FactorTypeColumn() database.Column - // LastChallengedAtColumn returns the column for the last challenged at field. - LastChallengedAtColumn() database.Column - // LastFailedAtColumn returns the column for the last failed at field. - LastFailedAtColumn() database.Column - // LastVerifiedAtColumn returns the column for the last verified at field. - LastVerifiedAtColumn() database.Column -} - type SessionFactorConditions interface { - // PrimaryKeyCondition returns a filter on the primary key fields. - PrimaryKeyCondition(instanceID, sessionID string, factorType SessionFactorType) database.Condition - // InstanceIDCondition returns an equal filter on the instance id field. - InstanceIDCondition(instanceID string) database.Condition - // SessionIDCondition returns an equal filter on the session id field. - SessionIDCondition(sessionID string) database.Condition // FactorTypeCondition returns an equal filter on the factor type field. FactorTypeCondition(factorType SessionFactorType) database.Condition - // FactorLastVerifiedBeforeCondition returns a filter on the factor last verified field before the given time. - FactorLastVerifiedBeforeCondition(lastVerifiedAt time.Time) database.Condition + // LastVerifiedBeforeCondition returns a filter on the factor last verified field before the given time. + LastVerifiedBeforeCondition(lastVerifiedAt time.Time) database.Condition } diff --git a/backend/v3/domain/session_user_agent.go b/backend/v3/domain/session_user_agent.go index 97fd900b2..d85101205 100644 --- a/backend/v3/domain/session_user_agent.go +++ b/backend/v3/domain/session_user_agent.go @@ -8,11 +8,10 @@ import ( ) type SessionUserAgent struct { - InstanceID string `json:"instanceId,omitempty" db:"instance_id"` FingerprintID *string `json:"fingerprintId,omitempty" db:"fingerprint_id"` Description *string `json:"description,omitempty" db:"description"` IP net.IP `json:"ip,omitempty" db:"ip"` - Header http.Header `json:"header,omitempty" db:"headers"` + Header http.Header `json:"headers,omitempty" db:"headers"` } type SessionUserAgentColumns interface { diff --git a/backend/v3/domain/sessionfactortype_enumer.go b/backend/v3/domain/sessionfactortype_enumer.go new file mode 100644 index 000000000..0f1bda5b7 --- /dev/null +++ b/backend/v3/domain/sessionfactortype_enumer.go @@ -0,0 +1,137 @@ +// Code generated by "enumer -type=SessionFactorType -trimprefix SessionFactorType -transform snake -linecomment -sql"; DO NOT EDIT. + +package domain + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +const _SessionFactorTypeName = "unknownuserpasswordpasskeyidentity_provider_intenttotpotp_smsotp_emailrecovery_code" + +var _SessionFactorTypeIndex = [...]uint8{0, 7, 11, 19, 26, 50, 54, 61, 70, 83} + +const _SessionFactorTypeLowerName = "unknownuserpasswordpasskeyidentity_provider_intenttotpotp_smsotp_emailrecovery_code" + +func (i SessionFactorType) String() string { + if i >= SessionFactorType(len(_SessionFactorTypeIndex)-1) { + return fmt.Sprintf("SessionFactorType(%d)", i) + } + return _SessionFactorTypeName[_SessionFactorTypeIndex[i]:_SessionFactorTypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _SessionFactorTypeNoOp() { + var x [1]struct{} + _ = x[SessionFactorTypeUnknown-(0)] + _ = x[SessionFactorTypeUser-(1)] + _ = x[SessionFactorTypePassword-(2)] + _ = x[SessionFactorTypePasskey-(3)] + _ = x[SessionFactorTypeIdentityProviderIntent-(4)] + _ = x[SessionFactorTypeTOTP-(5)] + _ = x[SessionFactorTypeOTPSMS-(6)] + _ = x[SessionFactorTypeOTPEmail-(7)] + _ = x[SessionFactorTypeRecoveryCode-(8)] +} + +var _SessionFactorTypeValues = []SessionFactorType{SessionFactorTypeUnknown, SessionFactorTypeUser, SessionFactorTypePassword, SessionFactorTypePasskey, SessionFactorTypeIdentityProviderIntent, SessionFactorTypeTOTP, SessionFactorTypeOTPSMS, SessionFactorTypeOTPEmail, SessionFactorTypeRecoveryCode} + +var _SessionFactorTypeNameToValueMap = map[string]SessionFactorType{ + _SessionFactorTypeName[0:7]: SessionFactorTypeUnknown, + _SessionFactorTypeLowerName[0:7]: SessionFactorTypeUnknown, + _SessionFactorTypeName[7:11]: SessionFactorTypeUser, + _SessionFactorTypeLowerName[7:11]: SessionFactorTypeUser, + _SessionFactorTypeName[11:19]: SessionFactorTypePassword, + _SessionFactorTypeLowerName[11:19]: SessionFactorTypePassword, + _SessionFactorTypeName[19:26]: SessionFactorTypePasskey, + _SessionFactorTypeLowerName[19:26]: SessionFactorTypePasskey, + _SessionFactorTypeName[26:50]: SessionFactorTypeIdentityProviderIntent, + _SessionFactorTypeLowerName[26:50]: SessionFactorTypeIdentityProviderIntent, + _SessionFactorTypeName[50:54]: SessionFactorTypeTOTP, + _SessionFactorTypeLowerName[50:54]: SessionFactorTypeTOTP, + _SessionFactorTypeName[54:61]: SessionFactorTypeOTPSMS, + _SessionFactorTypeLowerName[54:61]: SessionFactorTypeOTPSMS, + _SessionFactorTypeName[61:70]: SessionFactorTypeOTPEmail, + _SessionFactorTypeLowerName[61:70]: SessionFactorTypeOTPEmail, + _SessionFactorTypeName[70:83]: SessionFactorTypeRecoveryCode, + _SessionFactorTypeLowerName[70:83]: SessionFactorTypeRecoveryCode, +} + +var _SessionFactorTypeNames = []string{ + _SessionFactorTypeName[0:7], + _SessionFactorTypeName[7:11], + _SessionFactorTypeName[11:19], + _SessionFactorTypeName[19:26], + _SessionFactorTypeName[26:50], + _SessionFactorTypeName[50:54], + _SessionFactorTypeName[54:61], + _SessionFactorTypeName[61:70], + _SessionFactorTypeName[70:83], +} + +// SessionFactorTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func SessionFactorTypeString(s string) (SessionFactorType, error) { + if val, ok := _SessionFactorTypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _SessionFactorTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to SessionFactorType values", s) +} + +// SessionFactorTypeValues returns all values of the enum +func SessionFactorTypeValues() []SessionFactorType { + return _SessionFactorTypeValues +} + +// SessionFactorTypeStrings returns a slice of all String values of the enum +func SessionFactorTypeStrings() []string { + strs := make([]string, len(_SessionFactorTypeNames)) + copy(strs, _SessionFactorTypeNames) + return strs +} + +// IsASessionFactorType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i SessionFactorType) IsASessionFactorType() bool { + for _, v := range _SessionFactorTypeValues { + if i == v { + return true + } + } + return false +} + +func (i SessionFactorType) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *SessionFactorType) Scan(value interface{}) error { + if value == nil { + return nil + } + + var str string + switch v := value.(type) { + case []byte: + str = string(v) + case string: + str = v + case fmt.Stringer: + str = v.String() + default: + return fmt.Errorf("invalid value of SessionFactorType: %[1]T(%[1]v)", value) + } + + val, err := SessionFactorTypeString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/backend/v3/storage/database/change.go b/backend/v3/storage/database/change.go index 849dd90d7..ba9129a5d 100644 --- a/backend/v3/storage/database/change.go +++ b/backend/v3/storage/database/change.go @@ -17,6 +17,12 @@ type Change interface { IsOnColumn(col Column) bool } +// NoChange can be implemented by Changes that might not result in any actual change, +// resp. when the change has no set statement, e.g. [CTEChange] with no change function. +type NoChange interface { + NoChange() bool +} + type change[V Value] struct { column Column value V @@ -88,15 +94,23 @@ func (c Changes) IsOnColumn(col Column) bool { } // Write implements [Change]. -func (m Changes) Write(builder *StatementBuilder) error { - for i, change := range m { - if i > 0 { +func (c Changes) Write(builder *StatementBuilder) error { + var hadChanges bool + for _, change := range c { + ch, ok := change.(NoChange) + hasChanges := !ok || !ch.NoChange() + // if the previous change actually wrote a change to the builder + // and this change has changes, add a comma + if hadChanges && hasChanges { builder.WriteString(", ") } - err := change.Write(builder) - if err != nil { + if err := change.Write(builder); err != nil { return err } + // if the change had changes, mark that we had changes for the next iteration + if hasChanges { + hadChanges = hasChanges + } } return nil } @@ -123,6 +137,21 @@ func (c Changes) String() string { return "database.Changes" } +// NoChange implements [NoChange]. +// It returns false if any of the changes writes to the builder. +func (c Changes) NoChange() bool { + for _, change := range c { + no, ok := change.(NoChange) + if !ok { + return false + } + if no.NoChange() { + return true + } + } + return true +} + var _ Change = Changes(nil) func NewChangeToNull(col Column) Change { @@ -319,7 +348,7 @@ func (c *cteChange) Matches(x any) bool { return slices.Equal(expectedCTEBuilder.Args(), actualCTEBuilder.Args()) } -// Name implements [CTEChange]. +// SetName implements [CTEChange]. func (c *cteChange) SetName(name string) { c.name = name } @@ -342,3 +371,9 @@ func (c *cteChange) Write(builder *StatementBuilder) error { func (c *cteChange) WriteCTE(builder *StatementBuilder) { c.cte(builder) } + +// NoChange implements [NoChange]. +// It returns true if there is no change function defined. +func (c *cteChange) NoChange() bool { + return c.change == nil +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql index 1527a84a8..cdbdbfa9e 100644 --- a/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql +++ b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql @@ -1,3 +1,3 @@ -DROP TYPE zitadel.project_grant_state; +DROP TYPE IF EXISTS zitadel.project_grant_state CASCADE ; DROP TABLE IF EXISTS zitadel.project_grant_roles; DROP TABLE IF EXISTS zitadel.project_grants; diff --git a/backend/v3/storage/database/dialect/postgres/migration/011_session_table.go b/backend/v3/storage/database/dialect/postgres/migration/011_session_table.go new file mode 100644 index 000000000..ecc119c81 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/011_session_table.go @@ -0,0 +1,16 @@ +package migration + +import ( + _ "embed" +) + +var ( + //go:embed 011_session_table/up.sql + up011SessionTable string + //go:embed 011_session_table/down.sql + down011SessionTable string +) + +func init() { + registerSQLMigration(11, up011SessionTable, down011SessionTable) +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/011_session_table/down.sql b/backend/v3/storage/database/dialect/postgres/migration/011_session_table/down.sql new file mode 100644 index 000000000..226baaf15 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/011_session_table/down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS zitadel.session_metadata CASCADE; +DROP TABLE IF EXISTS zitadel.session_user_agents CASCADE; +DROP TYPE IF EXISTS zitadel.session_factor_type CASCADE; +DROP TABLE IF EXISTS zitadel.session_factors CASCADE; +DROP TABLE IF EXISTS zitadel.sessions CASCADE; +DROP FUNCTION IF EXISTS zitadel.update_expiration(); \ No newline at end of file diff --git a/backend/v3/storage/database/dialect/postgres/migration/011_session_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/011_session_table/up.sql new file mode 100644 index 000000000..8104923c6 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/011_session_table/up.sql @@ -0,0 +1,93 @@ +CREATE TABLE zitadel.session_user_agents ( + instance_id TEXT NOT NULL + , fingerprint_id TEXT NOT NULL CHECK (fingerprint_id <> '') + , ip INET + , description TEXT + , headers JSONB + + , PRIMARY KEY (instance_id, fingerprint_id) +); + +CREATE TABLE zitadel.sessions ( + instance_id TEXT NOT NULL + , id TEXT NOT NULL CHECK (id <> '') + , token_id TEXT + , user_agent_id TEXT + , lifetime INTERVAL + , expiration TIMESTAMPTZ + , user_id TEXT -- this column is used for referential integrity + , creator_id TEXT + , created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + , updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + + , PRIMARY KEY (instance_id, id) + , FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id) +-- , FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE + , FOREIGN KEY (instance_id, user_agent_id) REFERENCES zitadel.session_user_agents(instance_id, fingerprint_id) ON DELETE SET NULL (user_agent_id) +); + +CREATE OR REPLACE FUNCTION zitadel.update_expiration() + RETURNS TRIGGER AS $$ +BEGIN + NEW.expiration := NEW.updated_at + NEW.lifetime; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_expiration + BEFORE INSERT OR UPDATE OF lifetime ON zitadel.sessions + FOR EACH ROW +EXECUTE FUNCTION zitadel.update_expiration(); + +CREATE TYPE zitadel.session_factor_type AS ENUM ( + 'user', + 'password', + 'passkey', -- is also a challenge + 'identity_provider_intent', + 'totp', + 'otp_sms', -- is also a challenge + 'otp_email', -- is also a challenge + 'recovery_code' +); + +CREATE TABLE zitadel.session_factors ( + instance_id TEXT NOT NULL + , session_id TEXT NOT NULL + , type zitadel.session_factor_type NOT NULL + , last_challenged_at TIMESTAMPTZ -- this is only set if the type is a challenge + , challenged_payload JSONB + , last_verified_at TIMESTAMPTZ + , verified_payload JSONB + + , PRIMARY KEY (instance_id, session_id, type) + , FOREIGN KEY (instance_id, session_id) REFERENCES zitadel.sessions(instance_id, id) ON DELETE CASCADE +); + +CREATE TABLE zitadel.session_metadata ( + instance_id TEXT NOT NULL + , session_id TEXT NOT NULL + , key TEXT NOT NULL CHECK (key <> '') + , value BYTEA NOT NULL + + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + + , PRIMARY KEY (instance_id, session_id, key) + + , CONSTRAINT fk_session_metadata_session FOREIGN KEY (instance_id, session_id) REFERENCES zitadel.sessions (instance_id, id) ON DELETE CASCADE +); + +CREATE INDEX idx_session_metadata_key ON zitadel.session_metadata (key); +CREATE INDEX idx_session_metadata_value ON zitadel.session_metadata (sha256(value)); + +-- TODO(adlerhurst): these indexes can currently not be used by Postgres, because of the type conversion +-- the value can be a json but doesn't have to be. +-- CREATE INDEX idx_session_metadata_value_number ON zitadel.session_metadata ((value::NUMERIC)) WHERE jsonb_typeof(value) = 'number'; +-- CREATE INDEX idx_session_metadata_value_string ON zitadel.session_metadata ((value#>>'{}')) WHERE jsonb_typeof(value) = 'string'; +-- CREATE INDEX idx_session_metadata_value_boolean ON zitadel.session_metadata ((value::BOOLEAN)) WHERE jsonb_typeof(value) = 'boolean'; + +CREATE TRIGGER trg_set_updated_at_session_metadata + BEFORE INSERT OR UPDATE ON zitadel.session_metadata + FOR EACH ROW + WHEN (NEW.updated_at IS NULL) +EXECUTE FUNCTION zitadel.set_updated_at(); \ No newline at end of file diff --git a/backend/v3/storage/database/events_testing/events_test.go b/backend/v3/storage/database/events_testing/events_test.go index 45b4a1bb9..6cc43a6c3 100644 --- a/backend/v3/storage/database/events_testing/events_test.go +++ b/backend/v3/storage/database/events_testing/events_test.go @@ -22,8 +22,9 @@ import ( mgmt "github.com/zitadel/zitadel/pkg/grpc/management" v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" v2beta_project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" "github.com/zitadel/zitadel/pkg/grpc/system" - user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func getEnv(key, fallback string) string { @@ -51,9 +52,10 @@ var ( SystemClient system.SystemServiceClient OrgClient v2beta_org.OrganizationServiceClient ProjectClient v2beta_project.ProjectServiceClient + SessionClient session.SessionServiceClient + UserClient user.UserServiceClient AdminClient admin.AdminServiceClient MgmtClient mgmt.ManagementServiceClient - UserClient user_v2.UserServiceClient AuthorizationClient authorization.AuthorizationServiceClient ) @@ -71,9 +73,10 @@ func TestMain(m *testing.M) { SystemClient = integration.SystemClient() OrgClient = Instance.Client.OrgV2beta ProjectClient = Instance.Client.Projectv2Beta + SessionClient = Instance.Client.SessionV2 + UserClient = Instance.Client.UserV2 AdminClient = Instance.Client.Admin MgmtClient = Instance.Client.Mgmt - UserClient = Instance.Client.UserV2 AuthorizationClient = Instance.Client.AuthorizationV2 defer func() { diff --git a/backend/v3/storage/database/events_testing/session_test.go b/backend/v3/storage/database/events_testing/session_test.go new file mode 100644 index 000000000..88de27a4c --- /dev/null +++ b/backend/v3/storage/database/events_testing/session_test.go @@ -0,0 +1,626 @@ +//go:build integration + +package events_test + +import ( + "bytes" + "context" + "net" + "net/http" + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/crypto" + zdomain "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/integration/sink" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_SessionReduces(t *testing.T) { + instanceID := Instance.ID() + sessionRepo := repository.SessionRepository() + + email := integration.Email() + testUser := Instance.CreateUserTypeHuman(CTX, email) + _, err := UserClient.VerifyEmail(CTX, &user.VerifyEmailRequest{ + UserId: testUser.GetId(), + VerificationCode: testUser.GetEmailCode(), + }) + require.NoError(t, err) + _, err = UserClient.SetPassword(CTX, &user.SetPasswordRequest{ + UserId: testUser.GetId(), + NewPassword: &user.Password{ + Password: integration.UserPassword, + }, + Verification: nil, + }) + require.NoError(t, err) + totpSecret := registerTOTP(CTX, t, testUser.GetId()) + Instance.RegisterUserPasskey(CTX, testUser.GetId()) + registerOTPEmail(CTX, t, testUser.GetId()) + _, err = UserClient.SetPhone(CTX, &user.SetPhoneRequest{ + UserId: testUser.GetId(), + Phone: integration.Phone(), + Verification: &user.SetPhoneRequest_IsVerified{ + IsVerified: true, + }, + }) + require.NoError(t, err) + registerOTPSMS(CTX, t, testUser.GetId()) + + idpID := Instance.AddGenericOAuthProvider(CTX, integration.IDPName()).GetId() + idpUserID := integration.ID() + _, err = UserClient.AddIDPLink(CTX, &user.AddIDPLinkRequest{ + UserId: testUser.GetId(), + IdpLink: &user.IDPLink{ + IdpId: idpID, + UserId: idpUserID, + UserName: integration.Username(), + }, + }) + require.NoError(t, err) + + recoveryCodes := createRecoveryCodes(CTX, t, testUser.GetId()) + + lifetime := time.Hour + creatorID := "tester" + userAgent := &domain.SessionUserAgent{ + FingerprintID: gu.Ptr(integration.ID()), + Description: gu.Ptr("description"), + IP: net.IPv4(127, 0, 0, 1), + Header: http.Header{ + "User-Agent": []string{"ZITADEL-Integration-Test"}, + }, + } + createdSession, err := SessionClient.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: nil, + Metadata: nil, + Challenges: nil, + UserAgent: &session.UserAgent{ + FingerprintId: userAgent.FingerprintID, + Ip: gu.Ptr(userAgent.IP.String()), + Description: userAgent.Description, + Header: map[string]*session.UserAgent_HeaderValues{ + "User-Agent": {Values: []string{"ZITADEL-Integration-Test"}}, + }, + }, + Lifetime: durationpb.New(lifetime), + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Second) + var updatedSession *session.SetSessionResponse + + t.Run("create session reduces", func(t *testing.T) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition( + sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()), + )) + require.NoError(collect, err) + assertSessionsEqual(collect, &domain.Session{ + InstanceID: instanceID, + ID: createdSession.GetSessionId(), + TokenID: createdSession.GetSessionToken(), + Lifetime: lifetime, + Expiration: createdSession.GetDetails().GetChangeDate().AsTime().Add(lifetime), + UserID: "", + CreatorID: creatorID, + CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(), + UpdatedAt: createdSession.GetDetails().GetChangeDate().AsTime(), + Factors: nil, + Challenges: nil, + Metadata: nil, + UserAgent: userAgent, + }, dbSession) + }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) + }) + + metadata := []*domain.SessionMetadata{ + { + Metadata: domain.Metadata{ + InstanceID: instanceID, + Key: "key1", + Value: []byte("value1"), + }, + SessionID: createdSession.GetSessionId(), + }, + } + + t.Run("metadata set reduces", func(t *testing.T) { + updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createdSession.GetSessionId(), + Checks: nil, + Metadata: map[string][]byte{"key1": []byte("value1")}, + }) + require.NoError(t, err) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition( + sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()), + )) + require.NoError(collect, err) + assertSessionsEqual(collect, &domain.Session{ + InstanceID: instanceID, + ID: createdSession.GetSessionId(), + TokenID: createdSession.GetSessionToken(), + Lifetime: lifetime, + Expiration: createdSession.GetDetails().GetChangeDate().AsTime().Add(lifetime), + UserID: "", + CreatorID: creatorID, + CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(), + UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + Factors: nil, + Challenges: nil, + Metadata: metadata, + UserAgent: userAgent, + }, dbSession) + }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) + }) + + var lifetimeUpdated time.Time + + t.Run("lifetime set reduces", func(t *testing.T) { + updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createdSession.GetSessionId(), + Lifetime: durationpb.New(lifetime), + }) + require.NoError(t, err) + lifetimeUpdated = updatedSession.GetDetails().GetChangeDate().AsTime() + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition( + sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()), + )) + require.NoError(collect, err) + assertSessionsEqual(collect, &domain.Session{ + InstanceID: instanceID, + ID: createdSession.GetSessionId(), + TokenID: createdSession.GetSessionToken(), + Lifetime: lifetime, + Expiration: lifetimeUpdated.Add(lifetime), + UserID: "", + CreatorID: creatorID, + CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(), + UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + Factors: nil, + Challenges: nil, + Metadata: metadata, + UserAgent: userAgent, + }, dbSession) + }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) + }) + + var firstFactorCheckTime time.Time + + t.Run("checks set reduces", func(t *testing.T) { + _, err := url.Parse(Instance.CreateIntent(CTX, idpID).GetAuthUrl()) + require.NoError(t, err) + expiry := time.Now().Add(1 * time.Hour) + intentID, intentToken, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, idpUserID, testUser.GetId(), expiry) + require.NoError(t, err) + updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createdSession.GetSessionId(), + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_LoginName{ + LoginName: email, + }, + }, + Password: &session.CheckPassword{ + Password: integration.UserPassword, + }, + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: intentToken, + }, + Totp: &session.CheckTOTP{ + Code: func() string { + code, err := totp.GenerateCode(totpSecret, time.Now()) + require.NoError(t, err) + return code + }(), + }, + RecoveryCode: &session.CheckRecoveryCode{ + Code: recoveryCodes[0], + }, + }, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Instance.Domain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, + OtpEmail: &session.RequestChallenges_OTPEmail{ + DeliveryType: &session.RequestChallenges_OTPEmail_ReturnCode_{ + ReturnCode: &session.RequestChallenges_OTPEmail_ReturnCode{}, + }, + }, + OtpSms: &session.RequestChallenges_OTPSMS{ + ReturnCode: true, + }, + }, + }) + require.NoError(t, err) + firstFactorCheckTime = updatedSession.GetDetails().GetChangeDate().AsTime() + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition( + sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()), + )) + require.NoError(collect, err) + assertSessionsEqual(collect, &domain.Session{ + InstanceID: instanceID, + ID: createdSession.GetSessionId(), + TokenID: createdSession.GetSessionToken(), + Lifetime: lifetime, + Expiration: lifetimeUpdated.Add(lifetime), + UserID: testUser.GetId(), + CreatorID: creatorID, + CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(), + UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + Factors: domain.SessionFactors{ + &domain.SessionFactorUser{ + UserID: testUser.GetId(), + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorPassword{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorIdentityProviderIntent{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorTOTP{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorRecoveryCode{ + LastVerifiedAt: firstFactorCheckTime, + }, + }, + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{ + LastChallengedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + Challenge: "challenge", // placeholder + AllowedCredentialIDs: [][]byte{Instance.WebAuthN.KeyID()}, + UserVerification: zdomain.UserVerificationRequirementRequired, + RPID: Instance.Domain, + }, + &domain.SessionChallengeOTPEmail{ + LastChallengedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "", + KeyID: "", + Crypted: nil, + }, + Expiry: 0, + CodeReturned: true, + URLTemplate: "", + TriggeredAtOrigin: "", + }, + &domain.SessionChallengeOTPSMS{ + LastChallengedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "", + KeyID: "", + Crypted: nil, + }, + Expiry: 0, + CodeReturned: true, + GeneratorID: "", + TriggeredAtOrigin: "", + }, + }, + Metadata: metadata, + UserAgent: userAgent, + }, dbSession) + }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) + }) + + t.Run("checks with challenges set reduces", func(t *testing.T) { + assertionData, err := Instance.WebAuthN.CreateAssertionResponse(updatedSession.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) + require.NoError(t, err) + + updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createdSession.GetSessionId(), + Checks: &session.Checks{ + WebAuthN: &session.CheckWebAuthN{ + CredentialAssertionData: assertionData, + }, + OtpEmail: &session.CheckOTP{ + Code: updatedSession.GetChallenges().GetOtpEmail(), + }, + OtpSms: &session.CheckOTP{ + Code: updatedSession.GetChallenges().GetOtpSms(), + }, + }, + }) + require.NoError(t, err) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition( + sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()), + )) + require.NoError(collect, err) + assertSessionsEqual(collect, &domain.Session{ + InstanceID: instanceID, + ID: createdSession.GetSessionId(), + TokenID: createdSession.GetSessionToken(), + Lifetime: lifetime, + Expiration: lifetimeUpdated.Add(lifetime), + UserID: testUser.GetId(), + CreatorID: creatorID, + CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(), + UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + Factors: domain.SessionFactors{ + &domain.SessionFactorUser{ + UserID: testUser.GetId(), + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorPassword{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorIdentityProviderIntent{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorPasskey{ + LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + UserVerified: true, + }, + &domain.SessionFactorTOTP{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorOTPEmail{ + LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + }, + &domain.SessionFactorOTPSMS{ + LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + }, + &domain.SessionFactorRecoveryCode{ + LastVerifiedAt: firstFactorCheckTime, + }, + }, + Challenges: domain.SessionChallenges{}, + Metadata: metadata, + UserAgent: userAgent, + }, dbSession) + }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) + }) + + t.Run("password changed reduces", func(t *testing.T) { + passwordSet, err := UserClient.SetPassword(CTX, &user.SetPasswordRequest{ + UserId: testUser.GetId(), + NewPassword: &user.Password{ + Password: integration.UserPassword + integration.RandString(5), + }, + Verification: &user.SetPasswordRequest_CurrentPassword{ + CurrentPassword: integration.UserPassword, + }, + }) + require.NoError(t, err) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition( + sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()), + )) + require.NoError(collect, err) + assertSessionsEqual(collect, &domain.Session{ + InstanceID: instanceID, + ID: createdSession.GetSessionId(), + TokenID: createdSession.GetSessionToken(), + Lifetime: lifetime, + Expiration: lifetimeUpdated.Add(lifetime), + UserID: testUser.GetId(), + CreatorID: creatorID, + CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(), + UpdatedAt: passwordSet.GetDetails().GetChangeDate().AsTime(), + Factors: domain.SessionFactors{ + &domain.SessionFactorUser{ + UserID: testUser.GetId(), + LastVerifiedAt: firstFactorCheckTime, + }, + // password factor removed + &domain.SessionFactorIdentityProviderIntent{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorPasskey{ + LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + UserVerified: true, + }, + &domain.SessionFactorTOTP{ + LastVerifiedAt: firstFactorCheckTime, + }, + &domain.SessionFactorOTPEmail{ + LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + }, + &domain.SessionFactorOTPSMS{ + LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(), + }, + &domain.SessionFactorRecoveryCode{ + LastVerifiedAt: firstFactorCheckTime, + }, + }, + Challenges: domain.SessionChallenges{}, + Metadata: metadata, + UserAgent: userAgent, + }, dbSession) + }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) + }) + + t.Run("terminated reduces", func(t *testing.T) { + _, err := SessionClient.DeleteSession(CTX, &session.DeleteSessionRequest{ + SessionId: createdSession.GetSessionId(), + SessionToken: gu.Ptr(updatedSession.GetSessionToken()), + }) + require.NoError(t, err) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition( + sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()), + )) + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + assert.Nil(collect, dbSession) + }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) + }) +} + +func assertSessionsEqual(t *assert.CollectT, expected, actual *domain.Session) { + t.Helper() + assert.Equal(t, expected.InstanceID, actual.InstanceID) + assert.Equal(t, expected.ID, actual.ID) + assert.NotNil(t, actual.TokenID) + assert.Equal(t, expected.Lifetime, actual.Lifetime) + assert.Equal(t, expected.Expiration.UTC(), actual.Expiration.UTC()) + assert.Equal(t, expected.UserID, actual.UserID) + assert.Equal(t, expected.CreatorID, actual.CreatorID) + assert.NotNil(t, actual.CreatedAt) + assert.NotNil(t, actual.UpdatedAt) + assertSessionFactorsEqual(t, expected.Factors, actual.Factors) + assertSessionChallengesEqual(t, expected.Challenges, actual.Challenges) + assertSessionMetadataEqual(t, expected.Metadata, actual.Metadata) + assert.Equal(t, expected.UserAgent, actual.UserAgent) +} + +func assertSessionMetadataEqual(t *assert.CollectT, expected, actual []*domain.SessionMetadata) { + t.Helper() + assert.Len(t, actual, len(expected)) + for _, exp := range expected { + found := false + for _, act := range actual { + if exp.InstanceID == act.InstanceID && + exp.SessionID == act.SessionID && + exp.Key == act.Key && + bytes.Equal(exp.Value, act.Value) { + found = true + break + } + } + assert.Truef(t, found, "expected metadata not found: %+v", exp) + } +} + +func assertSessionFactorsEqual(t *assert.CollectT, expected, actual domain.SessionFactors) { + t.Helper() + assert.Len(t, actual, len(expected)) + for _, exp := range expected { + found := false + for _, act := range actual { + if exp.SessionFactorType() == act.SessionFactorType() { + switch expTyped := exp.(type) { + case *domain.SessionFactorUser: + actTyped := act.(*domain.SessionFactorUser) + assert.Equal(t, expTyped.UserID, actTyped.UserID) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + case *domain.SessionFactorPassword: + actTyped := act.(*domain.SessionFactorPassword) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + case *domain.SessionFactorIdentityProviderIntent: + actTyped := act.(*domain.SessionFactorIdentityProviderIntent) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + case *domain.SessionFactorPasskey: + actTyped := act.(*domain.SessionFactorPasskey) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + assert.Equal(t, expTyped.UserVerified, actTyped.UserVerified) + case *domain.SessionFactorTOTP: + actTyped := act.(*domain.SessionFactorTOTP) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + case *domain.SessionFactorOTPEmail: + actTyped := act.(*domain.SessionFactorOTPEmail) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + case *domain.SessionFactorOTPSMS: + actTyped := act.(*domain.SessionFactorOTPSMS) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + case *domain.SessionFactorRecoveryCode: + actTyped := act.(*domain.SessionFactorRecoveryCode) + assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC()) + } + found = true + break + } + } + assert.Truef(t, found, "expected factor not found: %+v", exp) + } +} + +func assertSessionChallengesEqual(t *assert.CollectT, expected, actual domain.SessionChallenges) { + t.Helper() + assert.Len(t, actual, len(expected)) + for _, exp := range expected { + found := false + for _, act := range actual { + if exp.SessionChallengeType() == act.SessionChallengeType() { + switch expTyped := exp.(type) { + case *domain.SessionChallengePasskey: + actTyped := act.(*domain.SessionChallengePasskey) + assert.Equal(t, expTyped.LastChallengedAt.UTC(), actTyped.LastChallengedAt.UTC()) + assert.NotEmpty(t, actTyped.Challenge) + assert.Equal(t, expTyped.AllowedCredentialIDs, actTyped.AllowedCredentialIDs) + assert.Equal(t, expTyped.RPID, actTyped.RPID) + assert.Equal(t, expTyped.UserVerification, actTyped.UserVerification) + case *domain.SessionChallengeOTPEmail: + actTyped := act.(*domain.SessionChallengeOTPEmail) + assert.Equal(t, expTyped.LastChallengedAt.UTC(), actTyped.LastChallengedAt.UTC()) + assert.Equal(t, expTyped.Code.CryptoType, actTyped.Code.CryptoType) + case *domain.SessionChallengeOTPSMS: + actTyped := act.(*domain.SessionChallengeOTPSMS) + assert.Equal(t, expTyped.LastChallengedAt.UTC(), actTyped.LastChallengedAt.UTC()) + assert.Equal(t, expTyped.Code.CryptoType, actTyped.Code.CryptoType) + } + found = true + break + } + } + assert.Truef(t, found, "expected challenge not found: %+v", exp) + } +} + +func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) { + resp, err := UserClient.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ + UserId: userID, + }) + require.NoError(t, err) + secret = resp.GetSecret() + code, err := totp.GenerateCode(secret, time.Now()) + require.NoError(t, err) + + _, err = UserClient.VerifyTOTPRegistration(ctx, &user.VerifyTOTPRegistrationRequest{ + UserId: userID, + Code: code, + }) + require.NoError(t, err) + return secret +} + +func registerOTPSMS(ctx context.Context, t *testing.T, userID string) { + _, err := UserClient.AddOTPSMS(ctx, &user.AddOTPSMSRequest{ + UserId: userID, + }) + require.NoError(t, err) +} + +func registerOTPEmail(ctx context.Context, t *testing.T, userID string) { + _, err := UserClient.AddOTPEmail(ctx, &user.AddOTPEmailRequest{ + UserId: userID, + }) + require.NoError(t, err) +} + +func createRecoveryCodes(ctx context.Context, t *testing.T, userID string) (secret []string) { + resp, err := UserClient.GenerateRecoveryCodes(ctx, &user.GenerateRecoveryCodesRequest{ + UserId: userID, + Count: 5, + }) + require.NoError(t, err) + return resp.GetRecoveryCodes() +} diff --git a/backend/v3/storage/database/operators.go b/backend/v3/storage/database/operators.go index ca779cd07..c07a08bb9 100644 --- a/backend/v3/storage/database/operators.go +++ b/backend/v3/storage/database/operators.go @@ -60,9 +60,9 @@ const ( // TextOperationContainsIgnoreCase checks if the first string contains the second ignoring case TextOperationContainsIgnoreCase // LIKE - // TextOperationEndsWith checks if the first string ends with the second ignoring case + // TextOperationEndsWithIgnoreCase checks if the first string ends with the second ignoring case TextOperationEndsWithIgnoreCase // LIKE - // TextOperationStartsWith checks if the first string starts with the second ignoring case + // TextOperationStartsWithIgnoreCase checks if the first string starts with the second ignoring case TextOperationStartsWithIgnoreCase // LIKE // TextOperationEqualIgnoreCase compares two strings for equality ignoring case TextOperationEqualIgnoreCase // = @@ -133,11 +133,11 @@ const ( // NumberOperationLessThan compares two numbers to check if the first is less than the second. NumberOperationLessThan // < // NumberOperationLessThanOrEqual compares two numbers to check if the first is less than or equal to the second. - NumberOperationAtLeast // <= + NumberOperationLessThanOrEqual // <= // NumberOperationGreaterThan compares two numbers to check if the first is greater than the second. NumberOperationGreaterThan // > // NumberOperationGreaterThanOrEqual compares two numbers to check if the first is greater than or equal to the second. - NumberOperationAtMost // >= + NumberOperationGreaterThanOrEqual // >= ) func writeNumberOperation[T Number](builder *StatementBuilder, col Column, op NumberOperation, value any) { diff --git a/backend/v3/storage/database/operators_enumer.go b/backend/v3/storage/database/operators_enumer.go index 8589cf023..bca87028a 100644 --- a/backend/v3/storage/database/operators_enumer.go +++ b/backend/v3/storage/database/operators_enumer.go @@ -28,12 +28,12 @@ func _NumberOperationNoOp() { _ = x[NumberOperationEqual-(1)] _ = x[NumberOperationNotEqual-(2)] _ = x[NumberOperationLessThan-(3)] - _ = x[NumberOperationAtLeast-(4)] + _ = x[NumberOperationLessThanOrEqual-(4)] _ = x[NumberOperationGreaterThan-(5)] - _ = x[NumberOperationAtMost-(6)] + _ = x[NumberOperationGreaterThanOrEqual-(6)] } -var _NumberOperationValues = []NumberOperation{NumberOperationEqual, NumberOperationNotEqual, NumberOperationLessThan, NumberOperationAtLeast, NumberOperationGreaterThan, NumberOperationAtMost} +var _NumberOperationValues = []NumberOperation{NumberOperationEqual, NumberOperationNotEqual, NumberOperationLessThan, NumberOperationLessThanOrEqual, NumberOperationGreaterThan, NumberOperationGreaterThanOrEqual} var _NumberOperationNameToValueMap = map[string]NumberOperation{ _NumberOperationName[0:1]: NumberOperationEqual, @@ -42,12 +42,12 @@ var _NumberOperationNameToValueMap = map[string]NumberOperation{ _NumberOperationLowerName[1:3]: NumberOperationNotEqual, _NumberOperationName[3:4]: NumberOperationLessThan, _NumberOperationLowerName[3:4]: NumberOperationLessThan, - _NumberOperationName[4:6]: NumberOperationAtLeast, - _NumberOperationLowerName[4:6]: NumberOperationAtLeast, + _NumberOperationName[4:6]: NumberOperationLessThanOrEqual, + _NumberOperationLowerName[4:6]: NumberOperationLessThanOrEqual, _NumberOperationName[6:7]: NumberOperationGreaterThan, _NumberOperationLowerName[6:7]: NumberOperationGreaterThan, - _NumberOperationName[7:9]: NumberOperationAtMost, - _NumberOperationLowerName[7:9]: NumberOperationAtMost, + _NumberOperationName[7:9]: NumberOperationGreaterThanOrEqual, + _NumberOperationLowerName[7:9]: NumberOperationGreaterThanOrEqual, } var _NumberOperationNames = []string{ diff --git a/backend/v3/storage/database/operators_test.go b/backend/v3/storage/database/operators_test.go index 5c8a5120b..e8d0d2f98 100644 --- a/backend/v3/storage/database/operators_test.go +++ b/backend/v3/storage/database/operators_test.go @@ -181,7 +181,7 @@ func Test_writeOperation(t *testing.T) { { name: "number less than or equal", write: func(builder *StatementBuilder) { - writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationAtLeast, 123) + writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationLessThanOrEqual, 123) }, want: want{ stmt: "table.column <= $1", @@ -201,7 +201,7 @@ func Test_writeOperation(t *testing.T) { { name: "number greater than or equal", write: func(builder *StatementBuilder) { - writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationAtMost, 123) + writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationGreaterThanOrEqual, 123) }, want: want{ stmt: "table.column >= $1", diff --git a/backend/v3/storage/database/repository/json.go b/backend/v3/storage/database/repository/json.go new file mode 100644 index 000000000..8b9928be8 --- /dev/null +++ b/backend/v3/storage/database/repository/json.go @@ -0,0 +1,48 @@ +package repository + +import ( + "encoding/json" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +type JSON[T any] struct { + Value T +} + +func (j *JSON[T]) Scan(src any) (err error) { + var rawJSON []byte + switch s := src.(type) { + case string: + rawJSON = []byte(s) + case []byte: + rawJSON = s + case nil: + return nil + default: + return ErrScanSource + } + if err = j.UnmarshalJSON(rawJSON); err != nil { + return database.NewScanError(err) + } + return nil +} + +// UnmarshalJSON allows encoding/json to decode nested JSON into JSON[T] fields. +// This makes [json.Unmarshal] work for fields inside aggregated JSON (e.g. JSON arrays). +func (j *JSON[T]) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + return nil + } + var v T + if err := json.Unmarshal(data, &v); err != nil { + return err + } + j.Value = v + return nil +} + +// MarshalJSON encodes the wrapped value or null if nil. +func (j JSON[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(j.Value) +} diff --git a/backend/v3/storage/database/repository/json_test.go b/backend/v3/storage/database/repository/json_test.go new file mode 100644 index 000000000..bfbf2f178 --- /dev/null +++ b/backend/v3/storage/database/repository/json_test.go @@ -0,0 +1,102 @@ +package repository_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" +) + +func TestJSON_Scan(t *testing.T) { + type want struct { + err error + res *testObject + } + tests := []struct { + name string + src any + want want + }{ + { + name: "nil", + src: nil, + want: want{ + err: nil, + res: nil, + }, + }, + { + name: "[]byte with data", + src: []byte(`{"id":"1","number":1,"active":true}`), + want: want{ + err: nil, + res: &testObject{ID: "1", Number: 1, Active: true}, + }, + }, + { + name: "[]byte without data", + src: []byte(`{}`), + want: want{ + err: nil, + res: &testObject{}, + }, + }, + { + name: "nil []byte", + src: []byte(nil), + want: want{ + err: nil, + res: nil, + }, + }, + { + name: "string with data", + src: `{"id":"2","number":2,"active":false}`, + want: want{ + err: nil, + res: &testObject{ID: "2", Number: 2, Active: false}, + }, + }, + { + name: "string without data", + src: `{}`, + want: want{ + err: nil, + res: &testObject{}, + }, + }, + { + name: "empty string", + src: ``, + want: want{ + err: nil, + res: nil, + }, + }, + { + name: "unsupported type", + src: 12345, + want: want{ + err: repository.ErrScanSource, + res: nil, + }, + }, + { + name: "invalid json", + src: []byte(`this is not json`), + want: want{ + err: new(database.ScanError), + res: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var a repository.JSON[testObject] + gotErr := a.Scan(tt.src) + require.ErrorIs(t, gotErr, tt.want.err) + }) + } +} diff --git a/backend/v3/storage/database/repository/session.go b/backend/v3/storage/database/repository/session.go new file mode 100644 index 000000000..47e21cce1 --- /dev/null +++ b/backend/v3/storage/database/repository/session.go @@ -0,0 +1,655 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +// ------------------------------------------------------------- +// repository +// ------------------------------------------------------------- + +var _ domain.SessionRepository = (*session)(nil) + +type session struct { + factorRepo sessionFactor + metadataRepo sessionMetadata + userAgentRepo sessionUserAgent +} + +func (s session) qualifiedTableName() string { + return "zitadel.sessions" +} + +func (s session) unqualifiedTableName() string { + return "sessions" +} + +func SessionRepository() domain.SessionRepository { + return new(session) +} + +const querySessionStmt = ` +SELECT + sessions.instance_id + , sessions.id + , sessions.token_id + , sessions.lifetime + , sessions.expiration + , sessions.user_id + , sessions.creator_id + , sessions.created_at + , sessions.updated_at + , jsonb_agg(distinct + jsonb_build_object( + 'instanceId', session_factors.instance_id + , 'sessionId', session_factors.session_id + , 'type', session_factors.type + , 'lastChallengedAt', session_factors.last_challenged_at + , 'challengedPayload', session_factors.challenged_payload + , 'lastVerifiedAt', session_factors.last_verified_at + , 'verifiedPayload', session_factors.verified_payload + ) + ) + FILTER (WHERE session_factors.session_id IS NOT NULL) AS factors + , jsonb_agg(distinct + jsonb_build_object( + 'instanceId', session_metadata.instance_id + , 'sessionId', session_metadata.session_id + , 'key', session_metadata.key + , 'value', encode(session_metadata.value, 'base64') + , 'createdAt', session_metadata.created_at + , 'updatedAt', session_metadata.updated_at + ) + ) + FILTER (WHERE session_metadata.session_id IS NOT NULL) AS metadata + , CASE WHEN session_user_agents.fingerprint_id IS NOT NULL THEN + jsonb_build_object( + 'fingerprintId', session_user_agents.fingerprint_id + , 'description', session_user_agents.description + , 'ip', session_user_agents.ip + , 'headers', session_user_agents.headers + ) + END as user_agent + FROM zitadel.sessions` + +// Get implements [domain.SessionRepository]. +func (s session) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Session, error) { + opts = append(opts, + s.joinFactors(), + s.joinMetadata(), + s.joinUserAgent(), + database.WithGroupBy( + s.InstanceIDColumn(), + s.IDColumn(), + s.userAgentRepo.fingerprintIDColumn(), + s.userAgentRepo.descriptionColumn(), + s.userAgentRepo.ipColumn(), + s.userAgentRepo.headersColumn(), + ), + ) + + options := new(database.QueryOpts) + for _, opt := range opts { + opt(options) + } + + if !options.Condition.IsRestrictingColumn(s.InstanceIDColumn()) { + return nil, database.NewMissingConditionError(s.InstanceIDColumn()) + } + + var builder database.StatementBuilder + builder.WriteString(querySessionStmt) + options.Write(&builder) + + return scanSession(ctx, client, &builder) +} + +// List implements [domain.SessionRepository]. +func (s session) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Session, error) { + opts = append(opts, + s.joinFactors(), + s.joinMetadata(), + s.joinUserAgent(), + database.WithGroupBy( + s.InstanceIDColumn(), + s.IDColumn(), + s.userAgentRepo.fingerprintIDColumn(), + s.userAgentRepo.descriptionColumn(), + s.userAgentRepo.ipColumn(), + s.userAgentRepo.headersColumn(), + ), + ) + + options := new(database.QueryOpts) + for _, opt := range opts { + opt(options) + } + + if options.Condition == nil || !options.Condition.IsRestrictingColumn(s.InstanceIDColumn()) { + return nil, database.NewMissingConditionError(s.InstanceIDColumn()) + } + + var builder database.StatementBuilder + builder.WriteString(querySessionStmt) + options.Write(&builder) + + return scanSessions(ctx, client, &builder) +} + +const upsertSessionUserAgentStmt = ` +WITH user_agent AS ( + INSERT INTO zitadel.session_user_agents( + instance_id, fingerprint_id, description, ip, headers + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (instance_id, fingerprint_id) + DO UPDATE SET description = EXCLUDED.description, ip = EXCLUDED.ip, headers = excluded.headers +) ` + +// Create implements [domain.SessionRepository]. +func (s session) Create(ctx context.Context, client database.QueryExecutor, session *domain.Session) error { + var createdAt any = database.DefaultInstruction + if !session.CreatedAt.IsZero() { + createdAt = session.CreatedAt + } + var updatedAt = createdAt + if !session.UpdatedAt.IsZero() { + updatedAt = session.UpdatedAt + } + + builder := new(database.StatementBuilder) + var fingerprintID *string + if session.UserAgent != nil { + builder = database.NewStatementBuilder(upsertSessionUserAgentStmt, + session.InstanceID, + session.UserAgent.FingerprintID, + session.UserAgent.Description, + session.UserAgent.IP, + session.UserAgent.Header, + ) + fingerprintID = session.UserAgent.FingerprintID + } + builder.WriteString(`INSERT INTO ` + s.qualifiedTableName() + ` (instance_id, id, lifetime, creator_id, user_agent_id, created_at, updated_at) VALUES ( `) + builder.WriteArgs(session.InstanceID, session.ID, session.Lifetime, session.CreatorID, fingerprintID, createdAt, updatedAt) + builder.WriteString(` ) RETURNING created_at, updated_at`) + return client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(&session.CreatedAt, &session.UpdatedAt) +} + +// Update implements [domain.SessionRepository]. +func (s session) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) { + if len(changes) == 0 { + return 0, database.ErrNoChanges + } + if !condition.IsRestrictingColumn(s.InstanceIDColumn()) { + return 0, database.NewMissingConditionError(s.InstanceIDColumn()) + } + if !database.Changes(changes).IsOnColumn(s.UpdatedAtColumn()) { + changes = append(changes, database.NewChange(s.UpdatedAtColumn(), database.DefaultInstruction)) + } + + var builder database.StatementBuilder + builder.WriteString("WITH existing_session AS (SELECT * FROM zitadel.sessions ") + writeCondition(&builder, condition) + builder.WriteString(") ") + for i, change := range changes { + sessionCTE(change, i, 0, &builder) + } + builder.WriteString("UPDATE zitadel.sessions SET ") + if err := database.Changes(changes).Write(&builder); err != nil { + return 0, err + } + writeCondition(&builder, condition) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +func sessionCTE(change database.Change, i, j int, builder *database.StatementBuilder) { + if multi, ok := change.(database.Changes); ok { + for j, ch := range multi { + sessionCTE(ch, i, j, builder) + } + } + if cte, ok := change.(database.CTEChange); ok { + name := fmt.Sprintf("cte_%d_%d", i, j) + fmt.Fprintf(builder, ", %s as (", name) + cte.SetName(name) + cte.WriteCTE(builder) + builder.WriteString(") ") + } +} + +// Delete implements [domain.SessionRepository]. +func (s session) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + if !condition.IsRestrictingColumn(s.InstanceIDColumn()) { + return 0, database.NewMissingConditionError(s.InstanceIDColumn()) + } + var builder database.StatementBuilder + builder.WriteString("DELETE FROM zitadel.sessions") + writeCondition(&builder, condition) + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +// ------------------------------------------------------------- +// changes +// ------------------------------------------------------------- + +// SetUpdatedAt implements [domain.sessionChanges]. +func (s session) SetUpdatedAt(updatedAt time.Time) database.Change { + return database.NewChange(s.UpdatedAtColumn(), updatedAt) +} + +// SetToken implements [domain.sessionChanges]. +func (s session) SetToken(token string) database.Change { + return database.NewChange(s.TokenIDColumn(), token) +} + +// SetLifetime implements [domain.sessionChanges]. +func (s session) SetLifetime(lifetime time.Duration) database.Change { + return database.NewChange(s.LifetimeColumn(), lifetime) +} + +// SetChallenge implements [domain.sessionChanges]. +func (s session) SetChallenge(challenge domain.SessionChallenge) database.Change { + switch c := challenge.(type) { + case *domain.SessionChallengePasskey: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_challenged_at, challenged_payload) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypePasskey, c.LastChallengedAt, c) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_challenged_at = EXCLUDED.last_challenged_at, challenged_payload = EXCLUDED.challenged_payload") + }, nil, + ) + case *domain.SessionChallengeOTPSMS: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_challenged_at, challenged_payload) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeOTPSMS, c.LastChallengedAt, c) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_challenged_at = EXCLUDED.last_challenged_at, challenged_payload = EXCLUDED.challenged_payload") + }, nil, + ) + case *domain.SessionChallengeOTPEmail: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_challenged_at, challenged_payload) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeOTPEmail, c.LastChallengedAt, c) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_challenged_at = EXCLUDED.last_challenged_at, challenged_payload = EXCLUDED.challenged_payload") + }, nil, + ) + } + return nil +} + +// SetFactor implements [domain.sessionChanges]. +func (s session) SetFactor(factor domain.SessionFactor) database.Change { + switch f := factor.(type) { + case *domain.SessionFactorUser: + return database.NewChanges( + database.NewChange(s.UserIDColumn(), f.UserID), + database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at, verified_payload) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeUser, f.LastVerifiedAt, f) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at, verified_payload = EXCLUDED.verified_payload") + }, nil, + ), + ) + case *domain.SessionFactorPassword: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypePassword, f.LastVerifiedAt) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at") + }, nil, + ) + case *domain.SessionFactorIdentityProviderIntent: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeIdentityProviderIntent, f.LastVerifiedAt) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at") + }, nil, + ) + case *domain.SessionFactorPasskey: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at, verified_payload) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypePasskey, f.LastVerifiedAt, f) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at, verified_payload = EXCLUDED.verified_payload") + }, nil, + ) + case *domain.SessionFactorTOTP: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeTOTP, f.LastVerifiedAt) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at") + }, nil, + ) + case *domain.SessionFactorOTPSMS: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeOTPSMS, f.LastVerifiedAt) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at") + }, nil, + ) + case *domain.SessionFactorOTPEmail: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeOTPEmail, f.LastVerifiedAt) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at") + }, nil, + ) + case *domain.SessionFactorRecoveryCode: + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ") + builder.WriteArgs(domain.SessionFactorTypeRecoveryCode, f.LastVerifiedAt) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at") + }, nil, + ) + default: + return nil + } +} + +// ClearFactor implements [domain.sessionChanges]. +func (s session) ClearFactor(factorType domain.SessionFactorType) database.Change { + return database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("UPDATE zitadel.session_factors sf SET last_verified_at = NULL FROM existing_session es WHERE sf.instance_id = es.instance_id AND sf.session_id = es.id AND sf.type = ") + builder.WriteArg(factorType) + }, nil) +} + +// SetMetadata implements [domain.sessionChanges]. +func (s session) SetMetadata(metadata []*domain.SessionMetadata) database.Change { + changes := make([]database.Change, len(metadata)+1) + keys := make([]any, len(metadata)) + for i, md := range metadata { + keys[i] = md.Key + changes[i] = database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("INSERT INTO zitadel.session_metadata (instance_id, session_id, key, value) SELECT instance_id, id, ") + builder.WriteArgs(md.Key, md.Value) + builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, key) DO UPDATE SET value = EXCLUDED.value") + }, nil, + ) + } + changes[len(metadata)] = database.NewCTEChange( + func(builder *database.StatementBuilder) { + builder.WriteString("DELETE FROM zitadel.session_metadata WHERE instance_id = (SELECT instance_id FROM existing_session) AND session_id = (SELECT id from existing_session) AND key NOT IN (") + builder.WriteArgs(keys...) + builder.WriteString(")") + }, nil) + return database.NewChanges(changes...) +} + +// ------------------------------------------------------------- +// conditions +// ------------------------------------------------------------- + +// PrimaryKeyCondition implements [domain.sessionConditions]. +func (s session) PrimaryKeyCondition(instanceID, sessionID string) database.Condition { + return database.And( + s.InstanceIDCondition(instanceID), + s.IDCondition(sessionID), + ) +} + +// InstanceIDCondition implements [domain.sessionConditions]. +func (s session) InstanceIDCondition(instanceID string) database.Condition { + return database.NewTextCondition(s.InstanceIDColumn(), database.TextOperationEqual, instanceID) +} + +// IDCondition implements [domain.sessionConditions]. +func (s session) IDCondition(sessionID string) database.Condition { + return database.NewTextCondition(s.IDColumn(), database.TextOperationEqual, sessionID) +} + +// UserAgentIDCondition implements [domain.sessionConditions]. +func (s session) UserAgentIDCondition(userAgentID string) database.Condition { + return database.NewTextCondition(s.userAgentIDColumn(), database.TextOperationEqual, userAgentID) +} + +// UserIDCondition implements [domain.sessionConditions]. +func (s session) UserIDCondition(userID string) database.Condition { + return database.NewTextCondition(s.UserIDColumn(), database.TextOperationEqual, userID) +} + +// CreatorIDCondition implements [domain.sessionConditions]. +func (s session) CreatorIDCondition(creatorID string) database.Condition { + return database.NewTextCondition(s.CreatorIDColumn(), database.TextOperationEqual, creatorID) +} + +// ExpirationCondition implements [domain.sessionConditions]. +func (s session) ExpirationCondition(op database.NumberOperation, expiration time.Time) database.Condition { + return database.NewNumberCondition(s.ExpirationColumn(), op, expiration) +} + +// CreatedAtCondition implements [domain.sessionConditions]. +func (s session) CreatedAtCondition(op database.NumberOperation, createdAt time.Time) database.Condition { + return database.NewNumberCondition(s.CreatedAtColumn(), op, createdAt) +} + +// UpdatedAtCondition implements [domain.sessionConditions]. +func (s session) UpdatedAtCondition(op database.NumberOperation, updatedAt time.Time) database.Condition { + return database.NewNumberCondition(s.UpdatedAtColumn(), op, updatedAt) +} + +// ExistsFactor implements [domain.sessionConditions]. +func (s session) ExistsFactor(cond database.Condition) database.Condition { + return database.Exists( + s.factorRepo.qualifiedTableName(), + database.And( + database.NewColumnCondition(s.InstanceIDColumn(), s.factorRepo.instanceIDColumn()), + database.NewColumnCondition(s.IDColumn(), s.factorRepo.sessionIDColumn()), + cond, + ), + ) +} + +// FactorConditions implements [domain.sessionConditions]. +func (s session) FactorConditions() domain.SessionFactorConditions { + return s.factorRepo +} + +// ExistsMetadata implements [domain.sessionConditions]. +func (s session) ExistsMetadata(cond database.Condition) database.Condition { + return database.Exists( + s.metadataRepo.qualifiedTableName(), + database.And( + database.NewColumnCondition(s.InstanceIDColumn(), s.metadataRepo.instanceIDColumn()), + database.NewColumnCondition(s.IDColumn(), s.metadataRepo.sessionIDColumn()), + cond, + ), + ) +} + +// MetadataConditions implements [domain.sessionConditions]. +func (s session) MetadataConditions() domain.SessionMetadataConditions { + return s.metadataRepo +} + +// ------------------------------------------------------------- +// columns +// ------------------------------------------------------------- + +// PrimaryKeyColumns implements [domain.Repository]. +func (s session) PrimaryKeyColumns() []database.Column { + return []database.Column{ + s.InstanceIDColumn(), + s.IDColumn(), + } +} + +// InstanceIDColumn implements [domain.sessionColumns]. +func (s session) InstanceIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "instance_id") +} + +// IDColumn implements [domain.sessionColumns]. +func (s session) IDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "id") +} + +// TokenIDColumn implements [domain.sessionColumns]. +func (s session) TokenIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "token_id") +} + +// LifetimeColumn implements [domain.sessionColumns]. +func (s session) LifetimeColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "lifetime") +} + +// ExpirationColumn implements [domain.sessionColumns]. +func (s session) ExpirationColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "expiration") +} + +// UserIDColumn implements [domain.sessionColumns]. +func (s session) UserIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "user_id") +} + +// UserAgentIDColumn implements [domain.sessionColumns]. +func (s session) userAgentIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "user_agent_id") +} + +// CreatorIDColumn implements [domain.sessionColumns]. +func (s session) CreatorIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "creator_id") +} + +// CreatedAtColumn implements [domain.sessionColumns]. +func (s session) CreatedAtColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "created_at") +} + +// UpdatedAtColumn implements [domain.sessionColumns]. +func (s session) UpdatedAtColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "updated_at") +} + +// ------------------------------------------------------------- +// scanners +// ------------------------------------------------------------- + +type rawSession struct { + *domain.Session + TokenID *string `json:"tokenID" db:"token_id"` + Lifetime *time.Duration `json:"lifetime" db:"lifetime"` + Expiration *time.Time `json:"expiration" db:"expiration"` + UserID *string `json:"userID" db:"user_id"` + CreatorID *string `json:"creatorID" db:"creator_id"` + Factors JSONArray[rawFactor] `json:"factors,omitempty" db:"factors"` + Metadata JSONArray[domain.SessionMetadata] `json:"metadata,omitempty" db:"metadata"` +} + +func scanSession(ctx context.Context, querier database.Querier, builder *database.StatementBuilder) (*domain.Session, error) { + rows, err := querier.Query(ctx, builder.String(), builder.Args()...) + if err != nil { + return nil, err + } + + raw := new(rawSession) + if err := rows.(database.CollectableRows).CollectExactlyOneRow(raw); err != nil { + return nil, err + } + return rawSessionToDomain(raw) +} + +func scanSessions(ctx context.Context, querier database.Querier, builder *database.StatementBuilder) ([]*domain.Session, error) { + rows, err := querier.Query(ctx, builder.String(), builder.Args()...) + if err != nil { + return nil, err + } + + var sessions []*rawSession + if err := rows.(database.CollectableRows).Collect(&sessions); err != nil { + return nil, err + } + + result := make([]*domain.Session, len(sessions)) + for i, session := range sessions { + result[i], err = rawSessionToDomain(session) + if err != nil { + return nil, err + } + } + + return result, nil +} + +func rawSessionToDomain(raw *rawSession) (*domain.Session, error) { + raw.Session.TokenID = gu.Value(raw.TokenID) + raw.Session.Lifetime = gu.Value(raw.Lifetime) + raw.Session.Expiration = gu.Value(raw.Expiration) + raw.Session.UserID = gu.Value(raw.UserID) + raw.Session.CreatorID = gu.Value(raw.CreatorID) + + for _, factor := range raw.Factors { + f, ch, err := factor.ToDomain() + if err != nil { + return nil, err + } + if f != nil { + raw.Session.Factors.AppendTo(f) + } + if ch != nil { + raw.Challenges.AppendTo(ch) + } + } + raw.Session.Metadata = raw.Metadata + return raw.Session, nil +} + +// ------------------------------------------------------------- +// sub repositories +// ------------------------------------------------------------- + +func (s session) joinUserAgent() database.QueryOption { + columns := make([]database.Condition, 0, 3) + columns = append(columns, + database.NewColumnCondition(s.InstanceIDColumn(), s.userAgentRepo.instanceIDColumn()), + database.NewColumnCondition(s.userAgentIDColumn(), s.userAgentRepo.fingerprintIDColumn()), + ) + return database.WithLeftJoin( + s.userAgentRepo.qualifiedTableName(), + database.And(columns...), + ) +} + +func (s session) joinFactors() database.QueryOption { + columns := make([]database.Condition, 0, 3) + columns = append(columns, + database.NewColumnCondition(s.InstanceIDColumn(), s.factorRepo.instanceIDColumn()), + database.NewColumnCondition(s.IDColumn(), s.factorRepo.sessionIDColumn()), + ) + return database.WithLeftJoin( + s.factorRepo.qualifiedTableName(), + database.And(columns...), + ) +} + +func (s session) joinMetadata() database.QueryOption { + columns := make([]database.Condition, 0, 3) + columns = append(columns, + database.NewColumnCondition(s.InstanceIDColumn(), s.metadataRepo.instanceIDColumn()), + database.NewColumnCondition(s.IDColumn(), s.metadataRepo.sessionIDColumn()), + ) + return database.WithLeftJoin( + s.metadataRepo.qualifiedTableName(), + database.And(columns...), + ) +} diff --git a/backend/v3/storage/database/repository/session_factor.go b/backend/v3/storage/database/repository/session_factor.go new file mode 100644 index 000000000..8c1839513 --- /dev/null +++ b/backend/v3/storage/database/repository/session_factor.go @@ -0,0 +1,190 @@ +package repository + +import ( + "encoding/json" + "time" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +type sessionFactor struct { +} + +func (s sessionFactor) qualifiedTableName() string { + return "zitadel.session_factors" +} + +func (s sessionFactor) unqualifiedTableName() string { + return "session_factors" +} + +func (s sessionFactor) PrimaryKeyColumns() []database.Column { + return []database.Column{ + s.instanceIDColumn(), + s.sessionIDColumn(), + s.typeColumn(), + } +} + +func (s sessionFactor) instanceIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "instance_id") +} + +func (s sessionFactor) sessionIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "session_id") +} + +func (s sessionFactor) typeColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "type") +} + +func (s sessionFactor) lastVerifiedAtColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "last_verified_at") +} + +// ------------------------------------------------------------- +// conditions +// ------------------------------------------------------------- + +func (s sessionFactor) FactorTypeCondition(factorType domain.SessionFactorType) database.Condition { + return database.NewNumberCondition(s.typeColumn(), database.NumberOperationEqual, factorType) +} + +func (s sessionFactor) LastVerifiedBeforeCondition(lastVerifiedAt time.Time) database.Condition { + return database.NewNumberCondition(s.lastVerifiedAtColumn(), database.NumberOperationLessThan, lastVerifiedAt) +} + +// ------------------------------------------------------------- +// scanners +// ------------------------------------------------------------- + +type rawFactor struct { + Type string `json:"type" db:"type"` + LastChallengedAt time.Time `json:"lastChallengedAt" db:"last_challenged_at"` + ChallengedPayload JSON[json.RawMessage] `json:"challengedPayload" db:"challenged_payload"` + LastVerifiedAt time.Time `json:"lastVerifiedAt" db:"last_verified_at"` + VerifiedPayload JSON[json.RawMessage] `json:"verifiedPayload" db:"verified_payload"` +} + +func (f *rawFactor) ToDomain() (domain.SessionFactor, domain.SessionChallenge, error) { + factorType, err := domain.SessionFactorTypeString(f.Type) + if err != nil { + return nil, nil, err + } + switch factorType { + case domain.SessionFactorTypeUser: + return f.userFactorToDomain() + case domain.SessionFactorTypePassword: + return f.passwordFactorToDomain() + case domain.SessionFactorTypePasskey: + return f.passkeyFactorToDomain() + case domain.SessionFactorTypeIdentityProviderIntent: + return f.identityProviderIntentFactorToDomain() + case domain.SessionFactorTypeTOTP: + return f.totpFactorToDomain() + case domain.SessionFactorTypeOTPSMS: + return f.otpSMSFactorToDomain() + case domain.SessionFactorTypeOTPEmail: + return f.otpEmailFactorToDomain() + case domain.SessionFactorTypeRecoveryCode: + return f.recoveryCodeFactorToDomain() + case domain.SessionFactorTypeUnknown: + return nil, nil, nil + default: + return nil, nil, nil + } +} + +func (f *rawFactor) userFactorToDomain() (factor domain.SessionFactor, _ domain.SessionChallenge, err error) { + if f.LastVerifiedAt.IsZero() { + return nil, nil, nil + } + factor = new(domain.SessionFactorUser) + if err := json.Unmarshal(f.VerifiedPayload.Value, factor); err != nil { + return nil, nil, err + } + return factor, nil, nil +} + +func (f *rawFactor) passwordFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) { + if f.LastVerifiedAt.IsZero() { + return nil, nil, nil + } + return &domain.SessionFactorPassword{ + LastVerifiedAt: f.LastVerifiedAt, + }, nil, nil +} + +func (f *rawFactor) passkeyFactorToDomain() (factor domain.SessionFactor, challenge domain.SessionChallenge, err error) { + if !f.LastChallengedAt.IsZero() && f.LastChallengedAt.After(f.LastVerifiedAt) { + challenge = new(domain.SessionChallengePasskey) + if err := json.Unmarshal(f.ChallengedPayload.Value, &challenge); err != nil { + return nil, nil, err + } + } + if !f.LastVerifiedAt.IsZero() { + factor = new(domain.SessionFactorPasskey) + if err := json.Unmarshal(f.VerifiedPayload.Value, factor); err != nil { + return nil, nil, err + } + } + return factor, challenge, nil +} + +func (f *rawFactor) identityProviderIntentFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) { + if f.LastVerifiedAt.IsZero() { + return nil, nil, nil + } + return &domain.SessionFactorIdentityProviderIntent{ + LastVerifiedAt: f.LastVerifiedAt, + }, nil, nil +} + +func (f *rawFactor) totpFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) { + if f.LastVerifiedAt.IsZero() { + return nil, nil, nil + } + return &domain.SessionFactorTOTP{ + LastVerifiedAt: f.LastVerifiedAt, + }, nil, nil +} + +func (f *rawFactor) otpSMSFactorToDomain() (factor domain.SessionFactor, challenge domain.SessionChallenge, err error) { + if !f.LastChallengedAt.IsZero() && f.LastChallengedAt.After(f.LastVerifiedAt) { + challenge = new(domain.SessionChallengeOTPSMS) + if err := json.Unmarshal(f.ChallengedPayload.Value, &challenge); err != nil { + return nil, nil, err + } + } + if !f.LastVerifiedAt.IsZero() { + factor = &domain.SessionFactorOTPSMS{ + LastVerifiedAt: f.LastVerifiedAt, + } + } + return factor, challenge, nil +} + +func (f *rawFactor) otpEmailFactorToDomain() (factor domain.SessionFactor, challenge domain.SessionChallenge, err error) { + if !f.LastChallengedAt.IsZero() && f.LastChallengedAt.After(f.LastVerifiedAt) { + challenge = new(domain.SessionChallengeOTPEmail) + if err := json.Unmarshal(f.ChallengedPayload.Value, &challenge); err != nil { + return nil, nil, err + } + } + if !f.LastVerifiedAt.IsZero() { + factor = &domain.SessionFactorOTPEmail{ + LastVerifiedAt: f.LastVerifiedAt, + } + } + return factor, challenge, nil +} + +func (f *rawFactor) recoveryCodeFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) { + if f.LastVerifiedAt.IsZero() { + return nil, nil, nil + } + return &domain.SessionFactorRecoveryCode{ + LastVerifiedAt: f.LastVerifiedAt, + }, nil, nil +} diff --git a/backend/v3/storage/database/repository/session_metadata.go b/backend/v3/storage/database/repository/session_metadata.go new file mode 100644 index 000000000..25a78383e --- /dev/null +++ b/backend/v3/storage/database/repository/session_metadata.go @@ -0,0 +1,71 @@ +package repository + +import "github.com/zitadel/zitadel/backend/v3/storage/database" + +type sessionMetadata struct { +} + +func (s sessionMetadata) qualifiedTableName() string { + return "zitadel.session_metadata" +} + +func (s sessionMetadata) unqualifiedTableName() string { + return "session_metadata" +} + +func (s sessionMetadata) PrimaryKeyColumns() []database.Column { + return []database.Column{ + s.instanceIDColumn(), + s.sessionIDColumn(), + s.keyColumn(), + } +} + +func (s sessionMetadata) instanceIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "instance_id") +} + +func (s sessionMetadata) sessionIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "session_id") +} + +func (s sessionMetadata) keyColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "key") +} + +func (s sessionMetadata) valueColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "value") +} + +// ------------------------------------------------------------- +// conditions +// ------------------------------------------------------------- + +// PrimaryKeyCondition implements [domain.SessionMetadataRepository]. +func (s sessionMetadata) PrimaryKeyCondition(instanceID string, sessionID string, key string) database.Condition { + return database.And( + s.InstanceIDCondition(instanceID), + s.SessionIDCondition(sessionID), + s.KeyCondition(database.TextOperationEqual, key), + ) +} + +// InstanceIDCondition implements [domain.SessionMetadataRepository]. +func (s sessionMetadata) InstanceIDCondition(instanceID string) database.Condition { + return database.NewTextCondition(s.instanceIDColumn(), database.TextOperationEqual, instanceID) +} + +// SessionIDCondition implements [domain.SessionMetadataRepository]. +func (s sessionMetadata) SessionIDCondition(sessionID string) database.Condition { + return database.NewTextCondition(s.sessionIDColumn(), database.TextOperationEqual, sessionID) +} + +// KeyCondition implements [domain.SessionMetadataRepository]. +func (s sessionMetadata) KeyCondition(op database.TextOperation, key string) database.Condition { + return database.NewTextCondition(s.keyColumn(), op, key) +} + +// ValueCondition implements [domain.SessionMetadataRepository]. +func (s sessionMetadata) ValueCondition(op database.BytesOperation, value []byte) database.Condition { + return database.NewBytesCondition[[]byte](database.SHA256Column(s.valueColumn()), op, database.SHA256Value(value)) +} diff --git a/backend/v3/storage/database/repository/session_test.go b/backend/v3/storage/database/repository/session_test.go new file mode 100644 index 000000000..1bc2e2957 --- /dev/null +++ b/backend/v3/storage/database/repository/session_test.go @@ -0,0 +1,1346 @@ +package repository_test + +import ( + "net" + "net/http" + "slices" + "strconv" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/crypto" + zdomain "github.com/zitadel/zitadel/internal/domain" +) + +func TestSession_Create(t *testing.T) { + beforeCreate := time.Now() + tx, err := pool.Begin(t.Context(), nil) + require.NoError(t, err) + defer func() { + err := tx.Rollback(t.Context()) + if err != nil { + t.Logf("error during rollback: %v", err) + } + }() + + sessionRepo := repository.SessionRepository() + + instanceRepo := repository.InstanceRepository() + instanceId := gofakeit.Name() + instance := domain.Instance{ + ID: instanceId, + Name: gofakeit.Name(), + DefaultOrgID: "defaultOrgId", + IAMProjectID: "iamProject", + ConsoleClientID: "consoleClient", + ConsoleAppID: "consoleApp", + DefaultLanguage: "defaultLanguage", + } + err = instanceRepo.Create(t.Context(), tx, &instance) + require.NoError(t, err) + tests := []struct { + name string + session *domain.Session + err error + }{ + { + name: "create valid session", + session: &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + CreatedAt: beforeCreate, + UpdatedAt: beforeCreate, + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + UserAgent: &domain.SessionUserAgent{ + FingerprintID: gu.Ptr(gofakeit.Name()), + Description: gu.Ptr(gofakeit.Name()), + IP: net.IPv4(127, 0, 0, 1), + Header: http.Header{"User-Agent": []string{"user-agent"}}, + }, + }, + err: nil, + }, + { + name: "create valid session no dates", + session: &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + UserAgent: &domain.SessionUserAgent{ + FingerprintID: gu.Ptr(gofakeit.Name()), + Description: gu.Ptr(gofakeit.Name()), + IP: net.IPv4(127, 0, 0, 1), + Header: http.Header{"User-Agent": []string{"user-agent"}}, + }, + }, + err: nil, + }, + { + name: "create session user agent fingerprint only", + session: &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + UserAgent: &domain.SessionUserAgent{ + FingerprintID: gu.Ptr(gofakeit.Name()), + }, + }, + err: nil, + }, + { + name: "create session empty fingerprint", + session: &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + UserAgent: &domain.SessionUserAgent{ + FingerprintID: gu.Ptr(""), + }, + }, + err: new(database.CheckError), + }, + { + name: "create session without user agent", + session: &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + }, + err: nil, + }, + { + name: "create session without lifetime", + session: &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + CreatorID: gofakeit.Name(), + }, + err: nil, + }, + { + name: "create session without creatorID", + session: &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + }, + err: nil, + }, + { + name: "create session without id", + session: &domain.Session{ + InstanceID: instanceId, + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + }, + err: new(database.CheckError), + }, + { + name: "create session without instanceID", + session: &domain.Session{ + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + }, + err: new(database.ForeignKeyError), + }, + { + name: "create session without existent instanceID", + session: &domain.Session{ + InstanceID: gofakeit.Name(), + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + }, + err: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, err := tx.Begin(t.Context()) + require.NoError(t, err) + defer func() { + err = savepoint.Rollback(t.Context()) + if err != nil { + t.Logf("error during rollback: %v", err) + } + }() + + err = sessionRepo.Create(t.Context(), savepoint, tt.session) + assert.ErrorIs(t, err, tt.err) + if err != nil { + return + } + afterCreate := time.Now() + + // check session values + createdSession, err := sessionRepo.Get(t.Context(), savepoint, + database.WithCondition(sessionRepo.PrimaryKeyCondition(tt.session.InstanceID, tt.session.ID)), + ) + require.NoError(t, err) + + assert.Equal(t, tt.session.ID, createdSession.ID) + assert.Equal(t, tt.session.InstanceID, createdSession.InstanceID) + assert.Equal(t, tt.session.Lifetime, createdSession.Lifetime) + assert.Equal(t, createdSession.UpdatedAt.Add(tt.session.Lifetime), createdSession.Expiration) + assert.Equal(t, tt.session.CreatorID, createdSession.CreatorID) + assert.Equal(t, tt.session.UserAgent, createdSession.UserAgent) + assert.WithinRange(t, createdSession.CreatedAt, beforeCreate.Add(-time.Second), afterCreate.Add(time.Second)) + assert.WithinRange(t, createdSession.UpdatedAt, beforeCreate.Add(-time.Second), afterCreate.Add(time.Second)) + }) + } +} + +func TestSession_Update(t *testing.T) { + beforeUpdate := time.Now() + tx, err := pool.Begin(t.Context(), nil) + require.NoError(t, err) + defer func() { + err := tx.Rollback(t.Context()) + if err != nil { + t.Logf("error during rollback: %v", err) + } + }() + + instanceRepo := repository.InstanceRepository() + sessionRepo := repository.SessionRepository() + + // create instance + instanceId := gofakeit.Name() + instance := domain.Instance{ + ID: instanceId, + Name: gofakeit.Name(), + DefaultOrgID: "defaultOrgId", + IAMProjectID: "iamProject", + ConsoleClientID: "consoleClient", + ConsoleAppID: "consoleApp", + DefaultLanguage: "defaultLanguage", + } + err = instanceRepo.Create(t.Context(), tx, &instance) + require.NoError(t, err) + + testNow := time.Now() + tests := []struct { + name string + testFunc func(t *testing.T) *domain.Session + update []database.Change + rowsAffected int64 + err error + }{ + { + name: "update no changes", + testFunc: func(t *testing.T) *domain.Session { + return &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + }, + update: []database.Change{}, + rowsAffected: 0, + err: database.ErrNoChanges, + }, + { + name: "update non existent session", + testFunc: func(t *testing.T) *domain.Session { + return &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + }, + update: []database.Change{ + sessionRepo.SetLifetime(time.Hour * 48), + }, + rowsAffected: 0, + }, + { + name: "update session updated at", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.UpdatedAt = testNow + return session + }, + update: []database.Change{ + sessionRepo.SetUpdatedAt(testNow), + }, + rowsAffected: 1, + }, + { + name: "update session lifetime", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Lifetime = time.Hour * 48 + return session + }, + update: []database.Change{ + sessionRepo.SetLifetime(time.Hour * 48), + }, + rowsAffected: 1, + }, + { + name: "update session token", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.TokenID = "new-token" + return session + }, + update: []database.Change{ + sessionRepo.SetToken("new-token"), + }, + rowsAffected: 1, + }, + { + name: "update session passkey challenge", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Challenges = []domain.SessionChallenge{ + &domain.SessionChallengePasskey{ + LastChallengedAt: testNow, + Challenge: "challenge", + AllowedCredentialIDs: [][]byte{[]byte("allowed-id-1"), []byte("allowed-id-2")}, + UserVerification: zdomain.UserVerificationRequirementRequired, + RPID: "rp-id", + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetChallenge(&domain.SessionChallengePasskey{ + LastChallengedAt: testNow, + Challenge: "challenge", + AllowedCredentialIDs: [][]byte{[]byte("allowed-id-1"), []byte("allowed-id-2")}, + UserVerification: zdomain.UserVerificationRequirementRequired, + RPID: "rp-id", + }), + }, + rowsAffected: 1, + }, + { + name: "update session otp sms challenge", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Challenges = []domain.SessionChallenge{ + &domain.SessionChallengeOTPSMS{ + LastChallengedAt: testNow, + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "key-id", + Crypted: []byte("code"), + }, + Expiry: time.Minute * 10, + CodeReturned: false, + GeneratorID: "", + TriggeredAtOrigin: "https://example.com", + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetChallenge(&domain.SessionChallengeOTPSMS{ + LastChallengedAt: testNow, + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "key-id", + Crypted: []byte("code"), + }, + Expiry: time.Minute * 10, + CodeReturned: false, + GeneratorID: "", + TriggeredAtOrigin: "https://example.com", + }), + }, + rowsAffected: 1, + }, + { + name: "update session otp email challenge", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Challenges = []domain.SessionChallenge{ + &domain.SessionChallengeOTPEmail{ + LastChallengedAt: testNow, + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "key-id", + Crypted: []byte("code"), + }, + Expiry: time.Minute * 10, + CodeReturned: false, + TriggeredAtOrigin: "https://example.com", + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetChallenge(&domain.SessionChallengeOTPEmail{ + LastChallengedAt: testNow, + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "key-id", + Crypted: []byte("code"), + }, + Expiry: time.Minute * 10, + CodeReturned: false, + TriggeredAtOrigin: "https://example.com", + }), + }, + rowsAffected: 1, + }, + { + name: "update session user factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorUser{ + UserID: "user-id", + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-id", + LastVerifiedAt: testNow, + }), + }, + rowsAffected: 1, + }, + { + name: "update session password factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorPassword{ + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorPassword{ + LastVerifiedAt: testNow, + }), + }, + rowsAffected: 1, + }, + { + name: "update session identity provider intent factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorIdentityProviderIntent{ + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorIdentityProviderIntent{ + LastVerifiedAt: testNow, + }), + }, + rowsAffected: 1, + }, + { + name: "update session passkey factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorPasskey{ + LastVerifiedAt: testNow, + UserVerified: true, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorPasskey{ + LastVerifiedAt: testNow, + UserVerified: true, + }), + }, + rowsAffected: 1, + }, + { + name: "update session totp factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorTOTP{ + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorTOTP{ + LastVerifiedAt: testNow, + }), + }, + rowsAffected: 1, + }, + { + name: "update session otp sms factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorOTPSMS{ + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorOTPSMS{ + LastVerifiedAt: testNow, + }), + }, + rowsAffected: 1, + }, + { + name: "update session otp email factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorOTPEmail{ + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorOTPEmail{ + LastVerifiedAt: testNow, + }), + }, + rowsAffected: 1, + }, + { + name: "update session recovery code factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorRecoveryCode{ + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastVerifiedAt: testNow, + }), + }, + rowsAffected: 1, + }, + { + name: "update session metadata", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.Metadata = []*domain.SessionMetadata{ + { + Metadata: domain.Metadata{ + InstanceID: instanceId, + Key: "key1", + Value: []byte("value1"), + }, + SessionID: session.ID, + }, + { + Metadata: domain.Metadata{ + InstanceID: instanceId, + Key: "key2", + Value: []byte("value2"), + }, + SessionID: session.ID, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetMetadata([]*domain.SessionMetadata{ + { + Metadata: domain.Metadata{ + Key: "key1", + Value: []byte("value1"), + }, + }, + { + Metadata: domain.Metadata{ + Key: "key2", + Value: []byte("value2"), + }, + }, + }), + }, + rowsAffected: 1, + }, + { + name: "update session multiple fields", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + // update with updated values + session.UpdatedAt = testNow + session.Lifetime = time.Hour * 48 + session.TokenID = "new-token" + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorUser{ + UserID: "user-id", + LastVerifiedAt: testNow, + }, + &domain.SessionFactorPassword{ + LastVerifiedAt: testNow, + }, + } + session.Challenges = []domain.SessionChallenge{ + &domain.SessionChallengeOTPSMS{ + LastChallengedAt: testNow, + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "key-id", + Crypted: []byte("code"), + }, + Expiry: time.Minute * 10, + CodeReturned: false, + GeneratorID: "", + TriggeredAtOrigin: "https://example.com", + }, + } + session.Metadata = []*domain.SessionMetadata{ + { + Metadata: domain.Metadata{ + InstanceID: instanceId, + Key: "key1", + Value: []byte("value1"), + }, + SessionID: session.ID, + }, + { + Metadata: domain.Metadata{ + InstanceID: instanceId, + Key: "key2", + Value: []byte("value2"), + }, + SessionID: session.ID, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.SetUpdatedAt(testNow), + sessionRepo.SetLifetime(time.Hour * 48), + sessionRepo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-id", + LastVerifiedAt: testNow, + }), + sessionRepo.SetFactor(&domain.SessionFactorPassword{ + LastVerifiedAt: testNow, + }), + sessionRepo.SetChallenge(&domain.SessionChallengeOTPSMS{ + LastChallengedAt: testNow, + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "key-id", + Crypted: []byte("code"), + }, + Expiry: time.Minute * 10, + CodeReturned: false, + GeneratorID: "", + TriggeredAtOrigin: "https://example.com", + }), + sessionRepo.SetMetadata([]*domain.SessionMetadata{ + { + Metadata: domain.Metadata{ + Key: "key1", + Value: []byte("value1"), + }, + }, + { + Metadata: domain.Metadata{ + Key: "key2", + Value: []byte("value2"), + }, + }, + }), + sessionRepo.SetToken("new-token"), + }, + rowsAffected: 1, + }, + { + name: "update session clear factor", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + updatedRows, err := sessionRepo.Update(t.Context(), tx, + sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID), + sessionRepo.SetFactor(&domain.SessionFactorUser{ + UserID: "user-id", + LastVerifiedAt: testNow, + }), + sessionRepo.SetFactor(&domain.SessionFactorPassword{ + LastVerifiedAt: testNow, + }), + ) + require.NoError(t, err) + require.Equal(t, int64(1), updatedRows) + + // update with updated values + session.Factors = []domain.SessionFactor{ + &domain.SessionFactorUser{ + UserID: "user-id", + LastVerifiedAt: testNow, + }, + } + return session + }, + update: []database.Change{ + sessionRepo.ClearFactor(domain.SessionFactorTypePassword), + }, + rowsAffected: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createdSession := tt.testFunc(t) + + // update org + rowsAffected, err := sessionRepo.Update(t.Context(), tx, + sessionRepo.PrimaryKeyCondition(createdSession.InstanceID, createdSession.ID), + tt.update..., + ) + afterUpdate := time.Now() + require.ErrorIs(t, err, tt.err) + + assert.Equal(t, tt.rowsAffected, rowsAffected) + + if rowsAffected == 0 { + return + } + + // check session values + session, err := sessionRepo.Get(t.Context(), tx, + database.WithCondition( + sessionRepo.PrimaryKeyCondition(createdSession.InstanceID, createdSession.ID), + ), + ) + require.NoError(t, err) + + assert.Equal(t, createdSession.ID, session.ID) + assert.Equal(t, createdSession.Lifetime, session.Lifetime) + assert.Equal(t, createdSession.TokenID, session.TokenID) + assert.WithinRange(t, session.Expiration, beforeUpdate.Add(createdSession.Lifetime), afterUpdate.Add(createdSession.Lifetime)) + assert.Equal(t, createdSession.CreatorID, session.CreatorID) + assert.Equal(t, createdSession.UserAgent, session.UserAgent) + assert.WithinRange(t, session.UpdatedAt, beforeUpdate, afterUpdate) + assert.EqualExportedValues(t, createdSession.Challenges, session.Challenges) + slices.SortFunc(session.Factors, func(a, b domain.SessionFactor) int { // sort to make comparison possible + return int(a.SessionFactorType()) - int(b.SessionFactorType()) + }) + assert.EqualExportedValues(t, createdSession.Factors, session.Factors) + assert.EqualExportedValues(t, createdSession.Metadata, session.Metadata) + }) + } +} + +func TestSession_Delete(t *testing.T) { + tx, err := pool.Begin(t.Context(), nil) + require.NoError(t, err) + defer func() { + err := tx.Rollback(t.Context()) + if err != nil { + t.Logf("error during rollback: %v", err) + } + }() + + instanceRepo := repository.InstanceRepository() + sessionRepo := repository.SessionRepository() + + // create instance + instanceId := gofakeit.Name() + instance := domain.Instance{ + ID: instanceId, + Name: gofakeit.Name(), + DefaultOrgID: "defaultOrgId", + IAMProjectID: "iamProject", + ConsoleClientID: "consoleClient", + ConsoleAppID: "consoleApp", + DefaultLanguage: "defaultLanguage", + } + err = instanceRepo.Create(t.Context(), tx, &instance) + require.NoError(t, err) + + type test struct { + name string + testFunc func(t *testing.T) *domain.Session + noOfDeletedRows int64 + } + tests := []test{ + { + name: "delete existent session", + testFunc: func(t *testing.T) *domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + return session + }, + noOfDeletedRows: 1, + }, + { + name: "delete non existent session", + testFunc: func(t *testing.T) *domain.Session { + return &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + }, + noOfDeletedRows: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := tt.testFunc(t) + + // delete session + deletedRows, err := sessionRepo.Delete(t.Context(), tx, + sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID), + ) + require.NoError(t, err) + assert.Equal(t, tt.noOfDeletedRows, deletedRows) + + // verify session deletion + deletedSession, err := sessionRepo.Get(t.Context(), tx, + database.WithCondition( + sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID), + ), + ) + require.Error(t, err, new(database.NoRowFoundError)) + assert.Nil(t, deletedSession) + }) + } +} + +func TestSession_Get(t *testing.T) { + tx, err := pool.Begin(t.Context(), nil) + require.NoError(t, err) + defer func() { + err := tx.Rollback(t.Context()) + if err != nil { + t.Logf("error during rollback: %v", err) + } + }() + + instanceRepo := repository.InstanceRepository() + sessionRepo := repository.SessionRepository() + + // create instance + instanceId := gofakeit.Name() + instance := domain.Instance{ + ID: instanceId, + Name: gofakeit.Name(), + DefaultOrgID: "defaultOrgId", + IAMProjectID: "iamProject", + ConsoleClientID: "consoleClient", + ConsoleAppID: "consoleApp", + DefaultLanguage: "defaultLanguage", + } + err = instanceRepo.Create(t.Context(), tx, &instance) + require.NoError(t, err) + + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err = sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + err error + }{ + { + name: "get existent session by primary key", + condition: sessionRepo.PrimaryKeyCondition(instanceId, session.ID), + err: nil, + }, + { + name: "get without instanceID condition", + condition: sessionRepo.IDCondition(session.ID), + err: new(database.MissingConditionError), + }, + { + name: "get non existent session", + condition: sessionRepo.PrimaryKeyCondition(instanceId, gofakeit.Name()), + err: new(database.NoRowFoundError), + }, + // other conditions are tested in the List tests + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // get session + got, err := sessionRepo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + assert.ErrorIs(t, err, tt.err) + if err != nil { + return + } + + // check session values + assert.Equal(t, session.ID, got.ID) + assert.Equal(t, session.InstanceID, got.InstanceID) + assert.Equal(t, session.Lifetime, got.Lifetime) + assert.Equal(t, session.CreatorID, got.CreatorID) + }) + } +} + +func TestSession_List(t *testing.T) { + tx, err := pool.Begin(t.Context(), nil) + require.NoError(t, err) + defer func() { + err := tx.Rollback(t.Context()) + if err != nil { + t.Logf("error during rollback: %v", err) + } + }() + + instanceRepo := repository.InstanceRepository() + sessionRepo := repository.SessionRepository() + + // create instance + instanceId := gofakeit.Name() + instance := domain.Instance{ + ID: instanceId, + Name: gofakeit.Name(), + DefaultOrgID: "defaultOrgId", + IAMProjectID: "iamProject", + ConsoleClientID: "consoleClient", + ConsoleAppID: "consoleApp", + DefaultLanguage: "defaultLanguage", + } + err = instanceRepo.Create(t.Context(), tx, &instance) + require.NoError(t, err) + + // create sessions + sessions := make([]*domain.Session, 5) + for i := 0; i < 5; i++ { + session := &domain.Session{ + InstanceID: instanceId, + ID: strconv.Itoa(i), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + CreatedAt: time.Now(), + UserAgent: &domain.SessionUserAgent{ + FingerprintID: gu.Ptr(gofakeit.UUID()), + Description: gu.Ptr(gofakeit.Name()), + IP: net.ParseIP(gofakeit.IPv4Address()), + Header: http.Header{"user-agent": []string{gofakeit.Name()}}, + }, + } + err = sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + session.Expiration = session.CreatedAt.Add(session.Lifetime) + + changes := make([]database.Change, 0, 4) + updated := time.Now() + changes = append(changes, sessionRepo.SetUpdatedAt(updated)) + session.UpdatedAt = updated + metadata := []*domain.SessionMetadata{ + { + Metadata: domain.Metadata{ + InstanceID: instanceId, + Key: "key" + strconv.Itoa(i), + Value: []byte{uint8(i)}, + }, + SessionID: session.ID, + }, + } + session.Metadata = metadata + changes = append(changes, sessionRepo.SetMetadata(metadata)) + userFactor := &domain.SessionFactorUser{ + UserID: "user-" + strconv.Itoa(i%2), // two users: user-0 and user-1 + LastVerifiedAt: time.Now(), + } + session.Factors.AppendTo(userFactor) + changes = append(changes, sessionRepo.SetFactor(userFactor)) + if i < 3 { + passwordFactor := &domain.SessionFactorPassword{ + LastVerifiedAt: time.Now(), + } + session.Factors.AppendTo(passwordFactor) + changes = append(changes, sessionRepo.SetFactor(passwordFactor)) + } + _, err = sessionRepo.Update(t.Context(), tx, sessionRepo.PrimaryKeyCondition(instanceId, session.ID), + changes..., + ) + require.NoError(t, err) + session.UserID = userFactor.UserID + sessions[i] = session + } + + tests := []struct { + name string + options []database.QueryOption + expected []*domain.Session + err error + }{ + { + name: "list sessions by instanceID", + options: []database.QueryOption{ + database.WithCondition( + sessionRepo.InstanceIDCondition(instanceId), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + }, + expected: sessions, + err: nil, + }, + { + name: "list single session by id", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.IDCondition(sessions[0].ID), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + }, + expected: []*domain.Session{sessions[0]}, + err: nil, + }, + { + name: "list sessions with non matching condition", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.IDCondition("non-existent-id"), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + }, + expected: []*domain.Session{}, + err: nil, + }, + { + name: "list sessions without conditions", + options: nil, + expected: nil, + err: new(database.MissingConditionError), + }, + { + name: "list sessions by user agent fingerprintID", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.UserAgentIDCondition(gu.Value(sessions[1].UserAgent.FingerprintID)), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + }, + expected: []*domain.Session{sessions[1]}, + err: nil, + }, + { + name: "list sessions by userID", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.UserIDCondition(sessions[0].Factors.GetUserFactor().UserID), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + database.WithLimit(2), + }, + expected: []*domain.Session{sessions[0], sessions[2]}, + err: nil, + }, + { + name: "list sessions by creatorID", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.CreatorIDCondition(sessions[3].CreatorID), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + }, + expected: []*domain.Session{sessions[3]}, + }, + { + name: "list sessions by expiration", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.ExpirationCondition(database.NumberOperationGreaterThanOrEqual, sessions[3].Expiration), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + }, + expected: []*domain.Session{sessions[3], sessions[4]}, + }, + { + name: "list sessions by created at", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.CreatedAtCondition(database.NumberOperationLessThan, sessions[3].CreatedAt), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + }, + expected: []*domain.Session{sessions[0], sessions[1], sessions[2]}, + }, + { + name: "list sessions by factor type", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.ExistsFactor(sessionRepo.FactorConditions().FactorTypeCondition(domain.SessionFactorTypePassword)), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + }, + expected: []*domain.Session{sessions[0], sessions[1], sessions[2]}, + }, + { + name: "list sessions by factor verification time", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.ExistsFactor(sessionRepo.FactorConditions().LastVerifiedBeforeCondition(sessions[2].Factors.GetUserFactor().LastVerifiedAt)), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + }, + expected: []*domain.Session{sessions[0], sessions[1]}, + }, + { + name: "list sessions by metadata key", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.ExistsMetadata(sessionRepo.MetadataConditions().KeyCondition(database.TextOperationEqual, "key3")), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + }, + expected: []*domain.Session{sessions[3]}, + }, + { + name: "list sessions by metadata value", + options: []database.QueryOption{ + database.WithCondition( + database.And( + sessionRepo.ExistsMetadata(sessionRepo.MetadataConditions().ValueCondition(database.BytesOperationEqual, []byte{3})), + sessionRepo.InstanceIDCondition(instanceId), + ), + ), + database.WithOrderByAscending(sessionRepo.IDColumn()), + }, + expected: []*domain.Session{sessions[3]}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // list sessions + got, err := sessionRepo.List(t.Context(), tx, tt.options...) + assert.ErrorIs(t, err, tt.err) + if err != nil { + return + } + assert.Len(t, got, len(tt.expected)) + for _, session := range got { + slices.SortFunc(session.Factors, func(a, b domain.SessionFactor) int { // sort to make comparison possible + return int(a.SessionFactorType()) - int(b.SessionFactorType()) + }) + } + assert.EqualExportedValues(t, tt.expected, got) + }) + } +} diff --git a/backend/v3/storage/database/repository/session_user_agent.go b/backend/v3/storage/database/repository/session_user_agent.go new file mode 100644 index 000000000..8a99eb0c6 --- /dev/null +++ b/backend/v3/storage/database/repository/session_user_agent.go @@ -0,0 +1,43 @@ +package repository + +import ( + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +type sessionUserAgent struct { +} + +func (s sessionUserAgent) qualifiedTableName() string { + return "zitadel.session_user_agents" +} + +func (s sessionUserAgent) unqualifiedTableName() string { + return "session_user_agents" +} + +func (s sessionUserAgent) PrimaryKeyColumns() []database.Column { + return []database.Column{ + s.instanceIDColumn(), + s.fingerprintIDColumn(), + } +} + +func (s sessionUserAgent) instanceIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "instance_id") +} + +func (s sessionUserAgent) fingerprintIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "fingerprint_id") +} + +func (s sessionUserAgent) descriptionColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "description") +} + +func (s sessionUserAgent) ipColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "ip") +} + +func (s sessionUserAgent) headersColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "headers") +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 0cac0c9ac..ba7131e47 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -99,6 +99,7 @@ var ( OrganizationMetadataRelationalProjection *handler.Handler AuthorizationRelationalProjection *handler.Handler ProjectGrantRelationalProjection *handler.Handler + SessionRelationalProjection *handler.Handler SettingsRelationalProjection *handler.Handler ProjectGrantFields *handler.FieldHandler @@ -228,6 +229,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, OrganizationMetadataRelationalProjection = newOrgMetadataRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["organization_metadata_relational"])) AuthorizationRelationalProjection = newAuthorizationRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["authorizations_relational"])) ProjectGrantRelationalProjection = newProjectGrantRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_grant_relational"])) + SessionRelationalProjection = newSessionRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["session_relational"])) newProjectionsList() newFieldsList() @@ -424,5 +426,6 @@ func newProjectionsList() { OrganizationMetadataRelationalProjection, AuthorizationRelationalProjection, ProjectGrantRelationalProjection, + SessionRelationalProjection, } } diff --git a/internal/query/projection/session_relational.go b/internal/query/projection/session_relational.go new file mode 100644 index 000000000..f22891fff --- /dev/null +++ b/internal/query/projection/session_relational.go @@ -0,0 +1,575 @@ +package projection + +import ( + "context" + "database/sql" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + v3_sql "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/sql" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + SessionsRelationalProjectionTable = "zitadel.sessions" +) + +type sessionRelationalProjection struct{} + +func (*sessionRelationalProjection) Name() string { + return SessionsRelationalProjectionTable +} + +func newSessionRelationalProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(sessionRelationalProjection)) +} + +func (p *sessionRelationalProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: session.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: session.AddedType, + Reduce: p.reduceSessionAdded, + }, + { + Event: session.UserCheckedType, + Reduce: p.reduceUserChecked, + }, + { + Event: session.PasswordCheckedType, + Reduce: p.reducePasswordChecked, + }, + { + Event: session.IntentCheckedType, + Reduce: p.reduceIntentChecked, + }, + { + Event: session.WebAuthNChallengedType, + Reduce: p.reduceWebAuthNChallenged, + }, + { + Event: session.WebAuthNCheckedType, + Reduce: p.reduceWebAuthNChecked, + }, + { + Event: session.TOTPCheckedType, + Reduce: p.reduceTOTPChecked, + }, + { + Event: session.OTPSMSChallengedType, + Reduce: p.reduceOTPSMSChallenged, + }, + { + Event: session.OTPSMSCheckedType, + Reduce: p.reduceOTPSMSChecked, + }, + { + Event: session.OTPEmailChallengedType, + Reduce: p.reduceOTPEmailChallenged, + }, + { + Event: session.OTPEmailCheckedType, + Reduce: p.reduceOTPEmailChecked, + }, + { + Event: session.RecoveryCodeCheckedType, + Reduce: p.reduceRecoveryCodeChecked, + }, + { + Event: session.TokenSetType, + Reduce: p.reduceTokenSet, + }, + { + Event: session.MetadataSetType, + Reduce: p.reduceMetadataSet, + }, + { + Event: session.LifetimeSetType, + Reduce: p.reduceLifetimeSet, + }, + { + Event: session.TerminateType, + Reduce: p.reduceSessionTerminated, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(SMSColumnInstanceID), + }, + }, + }, + { + Aggregate: user.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: user.HumanPasswordChangedType, + Reduce: p.reducePasswordChanged, + }, + }, + }, + } +} + +func (p *sessionRelationalProjection) reduceSessionAdded(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.AddedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-iZGH3", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + var userAgent *domain.SessionUserAgent + if e.UserAgent != nil { + userAgent = &domain.SessionUserAgent{ + FingerprintID: e.UserAgent.FingerprintID, + Description: e.UserAgent.Description, + IP: e.UserAgent.IP, + Header: e.UserAgent.Header, + } + } + + sessionRepo := repository.SessionRepository() + return sessionRepo.Create(ctx, v3Tx, &domain.Session{ + InstanceID: e.Aggregate().InstanceID, + ID: e.Aggregate().ID, + CreatorID: e.User, + CreatedAt: e.CreationDate(), + UpdatedAt: e.CreationDate(), + UserAgent: userAgent, + }) + }), nil +} + +func (p *sessionRelationalProjection) reduceUserChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.UserCheckedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-UHE92", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorUser{ + UserID: e.UserID, + LastVerifiedAt: e.CreatedAt(), + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reducePasswordChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.PasswordCheckedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-3krfa", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorPassword{ + LastVerifiedAt: e.CreatedAt(), + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceIntentChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.IntentCheckedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-ajkd2", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorIdentityProviderIntent{ + LastVerifiedAt: e.CreatedAt(), + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceWebAuthNChallenged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.WebAuthNChallengedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-do35d", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetChallenge(&domain.SessionChallengePasskey{ + LastChallengedAt: e.CreatedAt(), + Challenge: e.Challenge, + AllowedCredentialIDs: e.AllowedCrentialIDs, + UserVerification: e.UserVerification, + RPID: e.RPID, + }), + ) + return err + }), nil +} +func (p *sessionRelationalProjection) reduceWebAuthNChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.WebAuthNCheckedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-djk22", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorPasskey{ + LastVerifiedAt: e.CreatedAt(), + UserVerified: e.UserVerified, + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceTOTPChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.TOTPCheckedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-cklr9", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorTOTP{ + LastVerifiedAt: e.CreatedAt(), + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.OTPSMSChallengedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-fk4f9", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetChallenge(&domain.SessionChallengeOTPSMS{ + LastChallengedAt: e.CreatedAt(), + Code: e.Code, + Expiry: e.Expiry, + CodeReturned: e.CodeReturned, + GeneratorID: e.GeneratorID, + TriggeredAtOrigin: e.TriggeredAtOrigin, + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceOTPSMSChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.OTPSMSCheckedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-dk39f", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorOTPSMS{ + LastVerifiedAt: e.CreatedAt(), + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.OTPEmailChallengedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-fkf93", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetChallenge(&domain.SessionChallengeOTPEmail{ + LastChallengedAt: e.CreatedAt(), + Code: e.Code, + Expiry: e.Expiry, + CodeReturned: e.ReturnCode, + URLTemplate: e.URLTmpl, + TriggeredAtOrigin: e.TriggeredAtOrigin, + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceOTPEmailChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.OTPEmailCheckedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-3kll0", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorOTPEmail{ + LastVerifiedAt: e.CreatedAt(), + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceRecoveryCodeChecked(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.RecoveryCodeCheckedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-fk45a", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastVerifiedAt: e.CreatedAt(), + }), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.TokenSetEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-asddt", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetToken(e.TokenID), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceMetadataSet(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.MetadataSetEvent](event) + if err != nil { + return nil, err + } + + metadataList := make([]*domain.SessionMetadata, 0, len(e.Metadata)) + for key, value := range e.Metadata { + metadataList = append(metadataList, &domain.SessionMetadata{ + Metadata: domain.Metadata{ + Key: key, + Value: value, + }, + }) + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-iZGH3", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetMetadata(metadataList), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceLifetimeSet(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.LifetimeSetEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-iZGH3", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.SetLifetime(e.Lifetime), + ) + return err + }), nil +} + +func (p *sessionRelationalProjection) reduceSessionTerminated(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*session.TerminateEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-iZGH3", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) + _, err := sessionRepo.Delete(ctx, v3Tx, condition) + return err + }), nil +} + +func (p *sessionRelationalProjection) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*user.HumanPasswordChangedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-iZGH3", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := database.And( + sessionRepo.InstanceIDCondition(e.Aggregate().InstanceID), + sessionRepo.UserIDCondition(e.Aggregate().ID), + sessionRepo.ExistsFactor( + database.And( + sessionRepo.FactorConditions().FactorTypeCondition(domain.SessionFactorTypePassword), + sessionRepo.FactorConditions().LastVerifiedBeforeCondition(e.CreatedAt()), + ), + ), + ) + _, err = sessionRepo.Update(ctx, v3Tx, condition, + sessionRepo.SetUpdatedAt(e.CreatedAt()), + sessionRepo.ClearFactor(domain.SessionFactorTypePassword), + ) + return err + }), nil +} diff --git a/internal/webauthn/client.go b/internal/webauthn/client.go index ff00a89c3..7603d7e33 100644 --- a/internal/webauthn/client.go +++ b/internal/webauthn/client.go @@ -80,3 +80,7 @@ func (c *Client) CreateAssertionResponse(optionsPb *structpb.Struct, verifyUser } return resp, nil } + +func (c *Client) KeyID() []byte { + return c.credential.ID +}