diff --git a/cmd/setup/32.go b/cmd/setup/32.go new file mode 100644 index 000000000..224a5ee8c --- /dev/null +++ b/cmd/setup/32.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 32.sql + addAuthSessionID string +) + +type AddAuthSessionID struct { + dbClient *database.DB +} + +func (mig *AddAuthSessionID) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addAuthSessionID) + return err +} + +func (mig *AddAuthSessionID) String() string { + return "32_add_auth_sessionID" +} diff --git a/cmd/setup/32.sql b/cmd/setup/32.sql new file mode 100644 index 000000000..9c1e8116d --- /dev/null +++ b/cmd/setup/32.sql @@ -0,0 +1,3 @@ +ALTER TABLE IF EXISTS auth.user_sessions ADD COLUMN IF NOT EXISTS id TEXT; + +CREATE INDEX IF NOT EXISTS user_session_id ON auth.user_sessions (id, instance_id); \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 6ac1767ca..02f1f2aa2 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -116,6 +116,7 @@ type Steps struct { s29FillFieldsForProjectGrant *FillFieldsForProjectGrant s30FillFieldsForOrgDomainVerified *FillFieldsForOrgDomainVerified s31AddAggregateIndexToFields *AddAggregateIndexToFields + s32AddAuthSessionID *AddAuthSessionID } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index f9a40697a..3cfc28fad 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -160,6 +160,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient} steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient} steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient} + steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -216,6 +217,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s21AddBlockFieldToLimits, steps.s25User11AddLowerFieldsToVerifiedEmail, steps.s27IDPTemplate6SAMLNameIDFormat, + steps.s32AddAuthSessionID, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index dd765a304..474e12810 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -564,6 +564,7 @@ func (s *Server) authResponseToken(authReq *AuthRequest, authorizer op.Authorize domain.TokenReasonAuthRequest, nil, slices.Contains(scope, oidc.ScopeOfflineAccess), + authReq.SessionID, ) if err != nil { op.AuthRequestError(w, r, authReq, err, authorizer) diff --git a/internal/api/oidc/token_client_credentials.go b/internal/api/oidc/token_client_credentials.go index 5d19e398a..2ad429d2b 100644 --- a/internal/api/oidc/token_client_credentials.go +++ b/internal/api/oidc/token_client_credentials.go @@ -45,6 +45,7 @@ func (s *Server) ClientCredentialsExchange(ctx context.Context, r *op.ClientRequ domain.TokenReasonClientCredentials, nil, false, + "", ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_code.go b/internal/api/oidc/token_code.go index b4705e9f2..e6899bed0 100644 --- a/internal/api/oidc/token_code.go +++ b/internal/api/oidc/token_code.go @@ -85,6 +85,7 @@ func (s *Server) codeExchangeV1(ctx context.Context, client *Client, req *oidc.A domain.TokenReasonAuthRequest, nil, slices.Contains(scope, oidc.ScopeOfflineAccess), + authReq.SessionID, ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_exchange.go b/internal/api/oidc/token_exchange.go index c94621a4d..ec4372969 100644 --- a/internal/api/oidc/token_exchange.go +++ b/internal/api/oidc/token_exchange.go @@ -298,6 +298,7 @@ func (s *Server) createExchangeAccessToken( reason, actor, slices.Contains(scope, oidc.ScopeOfflineAccess), + "", ) if err != nil { return "", "", "", 0, err @@ -342,6 +343,7 @@ func (s *Server) createExchangeJWT( reason, actor, slices.Contains(scope, oidc.ScopeOfflineAccess), + "", ) accessToken, err = s.createJWT(ctx, client, session, getUserInfo, roleAssertion, getSigner) if err != nil { diff --git a/internal/api/oidc/token_jwt_profile.go b/internal/api/oidc/token_jwt_profile.go index 399fa5302..92d70d33d 100644 --- a/internal/api/oidc/token_jwt_profile.go +++ b/internal/api/oidc/token_jwt_profile.go @@ -53,6 +53,7 @@ func (s *Server) JWTProfile(ctx context.Context, r *op.Request[oidc.JWTProfileGr domain.TokenReasonJWTProfile, nil, false, + "", ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_refresh.go b/internal/api/oidc/token_refresh.go index 1dcce2879..62f3c6dd3 100644 --- a/internal/api/oidc/token_refresh.go +++ b/internal/api/oidc/token_refresh.go @@ -67,6 +67,7 @@ func (s *Server) refreshTokenV1(ctx context.Context, client *Client, r *op.Clien domain.TokenReasonRefresh, refreshToken.Actor, true, + "", ) if err != nil { return nil, err diff --git a/internal/api/ui/login/device_auth.go b/internal/api/ui/login/device_auth.go index ca26fb956..e7ddaccd0 100644 --- a/internal/api/ui/login/device_auth.go +++ b/internal/api/ui/login/device_auth.go @@ -162,7 +162,7 @@ func (l *Login) handleDeviceAuthAction(w http.ResponseWriter, r *http.Request) { action := mux.Vars(r)["action"] switch action { case deviceAuthAllowed: - _, err = l.command.ApproveDeviceAuth(r.Context(), authDev.DeviceCode, authReq.UserID, authReq.UserOrgID, authReq.UserAuthMethodTypes(), authReq.AuthTime, authReq.PreferredLanguage, authReq.ToUserAgent()) + _, err = l.command.ApproveDeviceAuth(r.Context(), authDev.DeviceCode, authReq.UserID, authReq.UserOrgID, authReq.UserAuthMethodTypes(), authReq.AuthTime, authReq.PreferredLanguage, authReq.ToUserAgent(), authReq.SessionID) case deviceAuthDenied: _, err = l.command.CancelDeviceAuth(r.Context(), authDev.DeviceCode, domain.DeviceAuthCanceledDenied) default: diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index c49ade2a0..8791bb4f6 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -1048,6 +1048,7 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth if err != nil { return nil, err } + request.SessionID = userSession.ID request.DisplayName = userSession.DisplayName request.AvatarKey = userSession.AvatarKey if user.HumanView != nil && user.HumanView.PreferredLanguage != "" { diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go index dedb1bce4..557890265 100644 --- a/internal/auth/repository/eventsourcing/handler/handler.go +++ b/internal/auth/repository/eventsourcing/handler/handler.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" handler2 "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/id" query2 "github.com/zitadel/zitadel/internal/query" ) @@ -40,6 +41,7 @@ func Register(ctx context.Context, configs Config, view *view.View, queries *que configs.overwrite("UserSession"), view, queries, + id.SonyFlakeGenerator(), )) projections = append(projections, newToken(ctx, diff --git a/internal/auth/repository/eventsourcing/handler/user_session.go b/internal/auth/repository/eventsourcing/handler/user_session.go index 3147b336f..98890c381 100644 --- a/internal/auth/repository/eventsourcing/handler/user_session.go +++ b/internal/auth/repository/eventsourcing/handler/user_session.go @@ -2,12 +2,14 @@ package handler import ( "context" + "slices" "time" auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/id" query2 "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" @@ -18,12 +20,15 @@ import ( const ( userSessionTable = "auth.user_sessions" + + IDPrefixV1 = "V1_" ) type UserSession struct { - queries *query2.Queries - view *auth_view.View - es handler.EventStore + queries *query2.Queries + view *auth_view.View + es handler.EventStore + idGenerator id.Generator } var _ handler.Projection = (*UserSession)(nil) @@ -33,14 +38,16 @@ func newUserSession( config handler.Config, view *auth_view.View, queries *query2.Queries, + idGenerator id.Generator, ) *handler.Handler { return handler.NewHandler( ctx, &config, &UserSession{ - queries: queries, - view: view, - es: config.Eventstore, + queries: queries, + view: view, + es: config.Eventstore, + idGenerator: idGenerator, }, ) } @@ -187,7 +194,7 @@ func (s *UserSession) Reducers() []handler.AggregateReducer { } } -func sessionColumns(event eventstore.Event, columns ...handler.Column) ([]handler.Column, error) { +func (u *UserSession) sessionColumns(event eventstore.Event, columns ...handler.Column) ([]handler.Column, error) { userAgent, err := agentIDFromSession(event) if err != nil { return nil, err @@ -203,14 +210,34 @@ func sessionColumns(event eventstore.Event, columns ...handler.Column) ([]handle }, columns...), nil } +func (u *UserSession) sessionColumnsActivate(event eventstore.Event, columns ...handler.Column) ([]handler.Column, error) { + sessionID, err := u.idGenerator.Next() + if err != nil { + return nil, err + } + sessionID = IDPrefixV1 + sessionID + columns = slices.Grow(columns, 2) + columns = append(columns, + handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), + handler.NewCol(view_model.UserSessionKeyID, + handler.OnlySetValueInCase(userSessionTable, sessionID, + handler.ConditionOr( + handler.ColumnChangedCondition(userSessionTable, view_model.UserSessionKeyState, domain.UserSessionStateTerminated, domain.UserSessionStateActive), + handler.ColumnIsNullCondition(userSessionTable, view_model.UserSessionKeyID), + ), + ), + ), + ) + return u.sessionColumns(event, columns...) +} + func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err error) { // in case anything needs to be change here check if appendEvent function needs the change as well switch event.Type() { case user.UserV1PasswordCheckSucceededType, user.HumanPasswordCheckSucceededType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordVerification, event.CreatedAt()), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -218,9 +245,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewUpsertStatement(event, columns[0:3], columns), nil case user.UserV1PasswordCheckFailedType, user.HumanPasswordCheckFailedType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordVerification, time.Time{}), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -228,10 +254,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewUpsertStatement(event, columns[0:3], columns), nil case user.UserV1MFAOTPCheckSucceededType, user.HumanMFAOTPCheckSucceededType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeySecondFactorVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeySecondFactorVerificationType, domain.MFATypeTOTP), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -240,9 +265,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err case user.UserV1MFAOTPCheckFailedType, user.HumanMFAOTPCheckFailedType, user.HumanU2FTokenCheckFailedType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeySecondFactorVerification, time.Time{}), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -250,7 +274,7 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewUpsertStatement(event, columns[0:3], columns), nil case user.UserV1SignedOutType, user.HumanSignedOutType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumns(event, handler.NewCol(view_model.UserSessionKeyPasswordlessVerification, time.Time{}), handler.NewCol(view_model.UserSessionKeyPasswordVerification, time.Time{}), handler.NewCol(view_model.UserSessionKeySecondFactorVerification, time.Time{}), @@ -270,10 +294,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyExternalLoginVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeySelectedIDPConfigID, data.SelectedIDPConfigID), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -285,10 +308,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeySecondFactorVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeySecondFactorVerificationType, domain.MFATypeU2F), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -300,11 +322,10 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordlessVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeyMultiFactorVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeyMultiFactorVerificationType, domain.MFATypeU2FUserVerification), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -316,10 +337,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordlessVerification, time.Time{}), handler.NewCol(view_model.UserSessionKeyMultiFactorVerification, time.Time{}), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -429,8 +449,7 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err }, ), nil case user.HumanRegisteredType: - columns, err := sessionColumns(event, - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordVerification, event.CreatedAt()), ) if err != nil { diff --git a/internal/command/device_auth.go b/internal/command/device_auth.go index 67e42b763..1a151437b 100644 --- a/internal/command/device_auth.go +++ b/internal/command/device_auth.go @@ -50,6 +50,7 @@ func (c *Commands) ApproveDeviceAuth( authTime time.Time, preferredLanguage *language.Tag, userAgent *domain.UserAgent, + sessionID string, ) (*domain.ObjectDetails, error) { model, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, deviceCode) if err != nil { @@ -58,7 +59,7 @@ func (c *Commands) ApproveDeviceAuth( if !model.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound") } - pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, model.aggregate, userID, userOrgID, authMethods, authTime, preferredLanguage, userAgent)) + pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, model.aggregate, userID, userOrgID, authMethods, authTime, preferredLanguage, userAgent, sessionID)) if err != nil { return nil, err } @@ -151,7 +152,7 @@ func (c *Commands) CreateOIDCSessionFromDeviceAuth(ctx context.Context, deviceCo cmd.AddSession(ctx, deviceAuthModel.UserID, deviceAuthModel.UserOrgID, - "", + deviceAuthModel.SessionID, deviceAuthModel.ClientID, deviceAuthModel.Audience, deviceAuthModel.Scopes, diff --git a/internal/command/device_auth_model.go b/internal/command/device_auth_model.go index a97b8b852..21ab1b29e 100644 --- a/internal/command/device_auth_model.go +++ b/internal/command/device_auth_model.go @@ -28,6 +28,7 @@ type DeviceAuthWriteModel struct { PreferredLanguage *language.Tag UserAgent *domain.UserAgent NeedRefreshToken bool + SessionID string } func NewDeviceAuthWriteModel(deviceCode, resourceOwner string) *DeviceAuthWriteModel { @@ -60,6 +61,7 @@ func (m *DeviceAuthWriteModel) Reduce() error { m.AuthTime = e.AuthTime m.PreferredLanguage = e.PreferredLanguage m.UserAgent = e.UserAgent + m.SessionID = e.SessionID case *deviceauth.CanceledEvent: m.State = e.Reason.State() case *deviceauth.DoneEvent: diff --git a/internal/command/device_auth_test.go b/internal/command/device_auth_test.go index 2988d058c..500c7b8f5 100644 --- a/internal/command/device_auth_test.go +++ b/internal/command/device_auth_test.go @@ -137,6 +137,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { authTime time.Time preferredLanguage *language.Tag userAgent *domain.UserAgent + sessionID string } tests := []struct { name string @@ -161,6 +162,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", }, wantErr: zerrors.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound"), }, @@ -188,6 +190,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), @@ -201,6 +204,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", }, wantErr: pushErr, }, @@ -228,6 +232,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), @@ -241,6 +246,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", }, wantDetails: &domain.ObjectDetails{ ResourceOwner: "instance1", @@ -252,7 +258,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore, } - gotDetails, err := c.ApproveDeviceAuth(tt.args.ctx, tt.args.id, tt.args.userID, tt.args.userOrgID, tt.args.authMethods, tt.args.authTime, tt.args.preferredLanguage, tt.args.userAgent) + gotDetails, err := c.ApproveDeviceAuth(tt.args.ctx, tt.args.id, tt.args.userID, tt.args.userOrgID, tt.args.authMethods, tt.args.authTime, tt.args.preferredLanguage, tt.args.userAgent, tt.args.sessionID) require.ErrorIs(t, err, tt.wantErr) assertObjectDetails(t, tt.wantDetails, gotDetails) }) @@ -607,13 +613,14 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "userID", "org1", "", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "", &language.Afrikaans, &domain.UserAgent{ FingerprintID: gu.Ptr("fp1"), IP: net.ParseIP("1.2.3.4"), @@ -657,7 +664,8 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, - Reason: domain.TokenReasonAuthRequest, + Reason: domain.TokenReasonAuthRequest, + SessionID: "sessionID", }, }, { @@ -687,13 +695,14 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "userID", "org1", "", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "", &language.Afrikaans, &domain.UserAgent{ FingerprintID: gu.Ptr("fp1"), IP: net.ParseIP("1.2.3.4"), @@ -742,6 +751,7 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { }, Reason: domain.TokenReasonAuthRequest, RefreshToken: "VjJfb2lkY1Nlc3Npb25JRC1ydF9yZWZyZXNoVG9rZW5JRDp1c2VySUQ", //V2_oidcSessionID-rt_refreshTokenID:userID + SessionID: "sessionID", }, }, } diff --git a/internal/command/oidc_session.go b/internal/command/oidc_session.go index 1fe82198b..ae201feb7 100644 --- a/internal/command/oidc_session.go +++ b/internal/command/oidc_session.go @@ -136,6 +136,7 @@ func (c *Commands) CreateOIDCSession(ctx context.Context, reason domain.TokenReason, actor *domain.TokenActor, needRefreshToken bool, + sessionID string, ) (session *OIDCSession, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -151,7 +152,7 @@ func (c *Commands) CreateOIDCSession(ctx context.Context, cmd.UserImpersonated(ctx, userID, resourceOwner, clientID, actor) } - cmd.AddSession(ctx, userID, resourceOwner, "", clientID, audience, scope, authMethods, authTime, nonce, preferredLanguage, userAgent) + cmd.AddSession(ctx, userID, resourceOwner, sessionID, clientID, audience, scope, authMethods, authTime, nonce, preferredLanguage, userAgent) if err = cmd.AddAccessToken(ctx, scope, userID, resourceOwner, reason, actor); err != nil { return nil, err } diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index 483528b95..a3af86d25 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -479,6 +479,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { reason domain.TokenReason actor *domain.TokenActor needRefreshToken bool + sessionID string } tests := []struct { name string @@ -684,6 +685,89 @@ func TestCommands_CreateOIDCSession(t *testing.T) { RefreshToken: "VjJfb2lkY1Nlc3Npb25JRC1ydF9yZWZyZXNoVG9rZW5JRDp1c2VySUQ", //V2_oidcSessionID-rt_refreshTokenID:userID }, }, + { + name: "with sessionID", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // token lifetime + expectPush( + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + oidcsession.NewAccessTokenAddedEvent(context.Background(), + &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, + &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + ), + user.NewUserTokenV2AddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, "at_accessTokenID"), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID", "accessTokenID"), + defaultAccessTokenLifetime: time.Hour, + defaultRefreshTokenLifetime: 7 * 24 * time.Hour, + defaultRefreshTokenIdleLifetime: 24 * time.Hour, + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + resourceOwner: "org1", + clientID: "clientID", + audience: []string{"audience"}, + scope: []string{"openid", "offline_access"}, + authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + authTime: testNow, + nonce: "nonce", + preferredLanguage: &language.Afrikaans, + userAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + reason: domain.TokenReasonAuthRequest, + actor: &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + needRefreshToken: false, + sessionID: "sessionID", + }, + want: &OIDCSession{ + TokenID: "V2_oidcSessionID-at_accessTokenID", + ClientID: "clientID", + UserID: "userID", + Audience: []string{"audience"}, + Expiration: time.Time{}.Add(time.Hour), + Scope: []string{"openid", "offline_access"}, + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + AuthTime: testNow, + Nonce: "nonce", + PreferredLanguage: &language.Afrikaans, + UserAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + Reason: domain.TokenReasonAuthRequest, + Actor: &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + SessionID: "sessionID", + }, + }, { name: "impersonation not allowed", fields: fields{ @@ -839,6 +923,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { tt.args.reason, tt.args.actor, tt.args.needRefreshToken, + tt.args.sessionID, ) require.ErrorIs(t, err, tt.wantErr) if got != nil { diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index e302d67cb..01b6ae25d 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -62,6 +62,8 @@ type AuthRequest struct { SAMLRequestID string // orgID the policies were last loaded with policyOrgID string + // SessionID is set to the computed sessionID of the login session table + SessionID string } func (a *AuthRequest) SetPolicyOrgID(id string) { diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 2eb919308..43d181848 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -146,18 +146,17 @@ func NewUpsertStatement(event eventstore.Event, conflictCols []Column, values [] conflictTarget[i] = col.Name } - config := execConfig{ - args: args, - } + config := execConfig{} if len(values) == 0 { config.err = ErrNoValues } - updateCols, updateVals := getUpdateCols(values, conflictTarget) + updateCols, updateVals, args := getUpdateCols(values, conflictTarget, params, args) if len(updateCols) == 0 || len(updateVals) == 0 { config.err = ErrNoValues } + config.args = args q := func(config execConfig) string { var updateStmt string @@ -194,18 +193,78 @@ func OnlySetValueOnInsert(table string, value interface{}) *onlySetValueOnInsert } } -func getUpdateCols(cols []Column, conflictTarget []string) (updateCols, updateVals []string) { +type onlySetValueInCase struct { + Table string + Value interface{} + Condition Condition +} + +func (c *onlySetValueInCase) GetValue() interface{} { + return c.Value +} + +// ColumnChangedCondition checks the current value and if it changed to a specific new value +func ColumnChangedCondition(table, column string, currentValue, newValue interface{}) Condition { + return func(param string) (string, []any) { + index, _ := strconv.Atoi(param) + return fmt.Sprintf("%[1]s.%[2]s = $%[3]d AND EXCLUDED.%[2]s = $%[4]d", table, column, index, index+1), []any{currentValue, newValue} + } +} + +// ColumnIsNullCondition checks if the current value is null +func ColumnIsNullCondition(table, column string) Condition { + return func(param string) (string, []any) { + return fmt.Sprintf("%[1]s.%[2]s IS NULL", table, column), nil + } +} + +// ConditionOr links multiple Conditions by OR +func ConditionOr(conditions ...Condition) Condition { + return func(param string) (_ string, args []any) { + if len(conditions) == 0 { + return "", nil + } + b := strings.Builder{} + s, arg := conditions[0](param) + b.WriteString(s) + args = append(args, arg...) + for i := 1; i < len(conditions); i++ { + b.WriteString(" OR ") + s, condArgs := conditions[i](param) + b.WriteString(s) + args = append(args, condArgs...) + } + return b.String(), args + } +} + +// OnlySetValueInCase will only update to the desired value if the condition applies +func OnlySetValueInCase(table string, value interface{}, condition Condition) *onlySetValueInCase { + return &onlySetValueInCase{ + Table: table, + Value: value, + Condition: condition, + } +} + +func getUpdateCols(cols []Column, conflictTarget, params []string, args []interface{}) (updateCols, updateVals []string, updatedArgs []interface{}) { updateCols = make([]string, len(cols)) updateVals = make([]string, len(cols)) + updatedArgs = args for i := len(cols) - 1; i >= 0; i-- { col := cols[i] - table := "EXCLUDED" - if onlyOnInsert, ok := col.Value.(*onlySetValueOnInsert); ok { - table = onlyOnInsert.Table - } updateCols[i] = col.Name - updateVals[i] = table + "." + col.Name + switch v := col.Value.(type) { + case *onlySetValueOnInsert: + updateVals[i] = v.Table + "." + col.Name + case *onlySetValueInCase: + s, condArgs := v.Condition(strconv.Itoa(len(params) + 1)) + updatedArgs = append(updatedArgs, condArgs...) + updateVals[i] = fmt.Sprintf("CASE WHEN %[1]s THEN EXCLUDED.%[2]s ELSE %[3]s.%[2]s END", s, col.Name, v.Table) + default: + updateVals[i] = "EXCLUDED" + "." + col.Name + } for _, conflict := range conflictTarget { if conflict == col.Name { copy(updateCols[i:], updateCols[i+1:]) @@ -221,7 +280,7 @@ func getUpdateCols(cols []Column, conflictTarget []string) (updateCols, updateVa } } - return updateCols, updateVals + return updateCols, updateVals, updatedArgs } func NewUpdateStatement(event eventstore.Event, values []Column, conditions []Condition, opts ...execOption) *Statement { diff --git a/internal/eventstore/handler/v2/statement_test.go b/internal/eventstore/handler/v2/statement_test.go index a2bae8b53..787ec105e 100644 --- a/internal/eventstore/handler/v2/statement_test.go +++ b/internal/eventstore/handler/v2/statement_test.go @@ -451,6 +451,55 @@ func TestNewUpsertStatement(t *testing.T) { }, }, }, + { + name: "correct all *onlySetValueInCase", + args: args{ + table: "my_table", + event: &testEvent{ + aggregateType: "agg", + sequence: 1, + previousSequence: 0, + }, + conflictCols: []Column{ + NewCol("col1", nil), + }, + values: []Column{ + { + Name: "col1", + Value: "val1", + }, + { + Name: "col2", + Value: &onlySetValueInCase{ + Table: "some.table", + Value: "val2", + Condition: ConditionOr( + ColumnChangedCondition("some.table", "val3", 0, 1), + ColumnIsNullCondition("some.table", "val3"), + ), + }, + }, + }, + }, + want: want{ + table: "my_table", + aggregateType: "agg", + sequence: 1, + previousSequence: 1, + executer: &wantExecuter{ + params: []params{ + { + query: "INSERT INTO my_table (col1, col2) VALUES ($1, $2) ON CONFLICT (col1) DO UPDATE SET col2 = CASE WHEN some.table.val3 = $3 AND EXCLUDED.val3 = $4 OR some.table.val3 IS NULL THEN EXCLUDED.col2 ELSE some.table.col2 END", + args: []interface{}{"val1", "val2", 0, 1}, + }, + }, + shouldExecute: true, + }, + isErr: func(err error) bool { + return err == nil + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/repository/deviceauth/device_auth.go b/internal/repository/deviceauth/device_auth.go index d2f688669..7d96d8c2c 100644 --- a/internal/repository/deviceauth/device_auth.go +++ b/internal/repository/deviceauth/device_auth.go @@ -72,6 +72,7 @@ type ApprovedEvent struct { AuthTime time.Time PreferredLanguage *language.Tag UserAgent *domain.UserAgent + SessionID string } func (e *ApprovedEvent) SetBaseEvent(b *eventstore.BaseEvent) { @@ -95,17 +96,19 @@ func NewApprovedEvent( authTime time.Time, preferredLanguage *language.Tag, userAgent *domain.UserAgent, + sessionID string, ) *ApprovedEvent { return &ApprovedEvent{ - eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, ApprovedEventType, ), - userID, - userOrgID, - userAuthMethods, - authTime, - preferredLanguage, - userAgent, + UserID: userID, + UserOrgID: userOrgID, + UserAuthMethods: userAuthMethods, + AuthTime: authTime, + PreferredLanguage: preferredLanguage, + UserAgent: userAgent, + SessionID: sessionID, } } diff --git a/internal/user/model/user_session_view.go b/internal/user/model/user_session_view.go index 7a23fa2a6..4335c6db7 100644 --- a/internal/user/model/user_session_view.go +++ b/internal/user/model/user_session_view.go @@ -27,6 +27,7 @@ type UserSessionView struct { MultiFactorVerification time.Time MultiFactorVerificationType domain.MFAType Sequence uint64 + ID string } type UserSessionSearchRequest struct { diff --git a/internal/user/repository/view/model/user_session.go b/internal/user/repository/view/model/user_session.go index 48eafd50a..03ecc592b 100644 --- a/internal/user/repository/view/model/user_session.go +++ b/internal/user/repository/view/model/user_session.go @@ -32,6 +32,7 @@ const ( UserSessionKeyPasswordlessVerification = "passwordless_verification" UserSessionKeyExternalLoginVerification = "external_login_verification" UserSessionKeySelectedIDPConfigID = "selected_idp_config_id" + UserSessionKeyID = "id" ) type UserSessionView struct { @@ -59,6 +60,7 @@ type UserSessionView struct { MultiFactorVerificationType sql.NullInt32 `json:"-" gorm:"column:multi_factor_verification_type"` Sequence uint64 `json:"-" gorm:"column:sequence"` InstanceID string `json:"instanceID" gorm:"column:instance_id;primary_key"` + ID sql.NullString `json:"id" gorm:"-"` } type userAgentIDPayload struct { @@ -95,6 +97,7 @@ func UserSessionToModel(userSession *UserSessionView) *model.UserSessionView { MultiFactorVerification: userSession.MultiFactorVerification.Time, MultiFactorVerificationType: domain.MFAType(userSession.MultiFactorVerificationType.Int32), Sequence: userSession.Sequence, + ID: userSession.ID.String, } } diff --git a/internal/user/repository/view/user_session_by_id.sql b/internal/user/repository/view/user_session_by_id.sql index 884ace168..a41d48093 100644 --- a/internal/user/repository/view/user_session_by_id.sql +++ b/internal/user/repository/view/user_session_by_id.sql @@ -17,7 +17,8 @@ SELECT s.creation_date, s.multi_factor_verification, s.multi_factor_verification_type, s.sequence, - s.instance_id + s.instance_id, + s.id FROM auth.user_sessions s LEFT JOIN projections.users13 u ON s.user_id = u.id AND s.instance_id = u.instance_id LEFT JOIN projections.users13_humans h ON s.user_id = h.user_id AND s.instance_id = h.instance_id diff --git a/internal/user/repository/view/user_session_view.go b/internal/user/repository/view/user_session_view.go index 2de623379..35c1bfc74 100644 --- a/internal/user/repository/view/user_session_view.go +++ b/internal/user/repository/view/user_session_view.go @@ -65,6 +65,7 @@ func scanUserSession(row *sql.Row) (*model.UserSessionView, error) { &session.MultiFactorVerificationType, &session.Sequence, &session.InstanceID, + &session.ID, ) if errors.Is(err, sql.ErrNoRows) { return nil, zerrors.ThrowNotFound(nil, "VIEW-NGBs1", "Errors.UserSession.NotFound") @@ -97,6 +98,7 @@ func scanUserSessions(rows *sql.Rows) ([]*model.UserSessionView, error) { &session.MultiFactorVerificationType, &session.Sequence, &session.InstanceID, + &session.ID, ) if err != nil { return nil, err diff --git a/internal/user/repository/view/user_sessions_by_user_agent.sql b/internal/user/repository/view/user_sessions_by_user_agent.sql index 476f43ba8..cb2171c09 100644 --- a/internal/user/repository/view/user_sessions_by_user_agent.sql +++ b/internal/user/repository/view/user_sessions_by_user_agent.sql @@ -17,7 +17,8 @@ SELECT s.creation_date, s.multi_factor_verification, s.multi_factor_verification_type, s.sequence, - s.instance_id + s.instance_id, + s.id FROM auth.user_sessions s LEFT JOIN projections.users13 u ON s.user_id = u.id AND s.instance_id = u.instance_id LEFT JOIN projections.users13_humans h ON s.user_id = h.user_id AND s.instance_id = h.instance_id