2023-07-14 11:16:16 +00:00
|
|
|
package query
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2024-05-16 05:07:56 +00:00
|
|
|
"golang.org/x/text/language"
|
|
|
|
|
2023-07-14 11:16:16 +00:00
|
|
|
"github.com/zitadel/zitadel/internal/domain"
|
|
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
|
|
"github.com/zitadel/zitadel/internal/repository/oidcsession"
|
2023-07-19 11:17:39 +00:00
|
|
|
"github.com/zitadel/zitadel/internal/repository/session"
|
2024-02-28 09:30:05 +00:00
|
|
|
"github.com/zitadel/zitadel/internal/repository/user"
|
2023-07-14 11:16:16 +00:00
|
|
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
2023-12-08 14:30:55 +00:00
|
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
2023-07-14 11:16:16 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type OIDCSessionAccessTokenReadModel struct {
|
2024-06-12 09:11:36 +00:00
|
|
|
eventstore.ReadModel
|
2023-07-14 11:16:16 +00:00
|
|
|
|
|
|
|
UserID string
|
|
|
|
SessionID string
|
|
|
|
ClientID string
|
|
|
|
Audience []string
|
|
|
|
Scope []string
|
|
|
|
AuthMethods []domain.UserAuthMethodType
|
|
|
|
AuthTime time.Time
|
2024-05-16 05:07:56 +00:00
|
|
|
Nonce string
|
2023-07-14 11:16:16 +00:00
|
|
|
State domain.OIDCSessionState
|
|
|
|
AccessTokenID string
|
|
|
|
AccessTokenCreation time.Time
|
|
|
|
AccessTokenExpiration time.Time
|
2024-05-16 05:07:56 +00:00
|
|
|
PreferredLanguage *language.Tag
|
|
|
|
UserAgent *domain.UserAgent
|
2024-03-20 10:18:46 +00:00
|
|
|
Reason domain.TokenReason
|
|
|
|
Actor *domain.TokenActor
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
|
2023-07-19 11:17:39 +00:00
|
|
|
func newOIDCSessionAccessTokenReadModel(id string) *OIDCSessionAccessTokenReadModel {
|
2023-07-14 11:16:16 +00:00
|
|
|
return &OIDCSessionAccessTokenReadModel{
|
2024-06-12 09:11:36 +00:00
|
|
|
ReadModel: eventstore.ReadModel{
|
2023-07-14 11:16:16 +00:00
|
|
|
AggregateID: id,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (wm *OIDCSessionAccessTokenReadModel) Reduce() error {
|
|
|
|
for _, event := range wm.Events {
|
|
|
|
switch e := event.(type) {
|
|
|
|
case *oidcsession.AddedEvent:
|
|
|
|
wm.reduceAdded(e)
|
|
|
|
case *oidcsession.AccessTokenAddedEvent:
|
|
|
|
wm.reduceAccessTokenAdded(e)
|
2023-07-17 12:33:37 +00:00
|
|
|
case *oidcsession.AccessTokenRevokedEvent,
|
|
|
|
*oidcsession.RefreshTokenRevokedEvent:
|
|
|
|
wm.reduceTokenRevoked(event)
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
}
|
2024-06-12 09:11:36 +00:00
|
|
|
return wm.ReadModel.Reduce()
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (wm *OIDCSessionAccessTokenReadModel) Query() *eventstore.SearchQueryBuilder {
|
|
|
|
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
|
|
|
AddQuery().
|
|
|
|
AggregateTypes(oidcsession.AggregateType).
|
|
|
|
AggregateIDs(wm.AggregateID).
|
|
|
|
EventTypes(
|
|
|
|
oidcsession.AddedType,
|
|
|
|
oidcsession.AccessTokenAddedType,
|
2023-07-17 12:33:37 +00:00
|
|
|
oidcsession.AccessTokenRevokedType,
|
|
|
|
oidcsession.RefreshTokenRevokedType,
|
2023-07-14 11:16:16 +00:00
|
|
|
).
|
|
|
|
Builder()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (wm *OIDCSessionAccessTokenReadModel) reduceAdded(e *oidcsession.AddedEvent) {
|
|
|
|
wm.UserID = e.UserID
|
|
|
|
wm.SessionID = e.SessionID
|
|
|
|
wm.ClientID = e.ClientID
|
|
|
|
wm.Audience = e.Audience
|
|
|
|
wm.Scope = e.Scope
|
|
|
|
wm.AuthMethods = e.AuthMethods
|
|
|
|
wm.AuthTime = e.AuthTime
|
2024-05-16 05:07:56 +00:00
|
|
|
wm.Nonce = e.Nonce
|
|
|
|
wm.PreferredLanguage = e.PreferredLanguage
|
|
|
|
wm.UserAgent = e.UserAgent
|
2023-07-14 11:16:16 +00:00
|
|
|
wm.State = domain.OIDCSessionStateActive
|
|
|
|
}
|
|
|
|
|
|
|
|
func (wm *OIDCSessionAccessTokenReadModel) reduceAccessTokenAdded(e *oidcsession.AccessTokenAddedEvent) {
|
|
|
|
wm.AccessTokenID = e.ID
|
|
|
|
wm.AccessTokenCreation = e.CreationDate()
|
|
|
|
wm.AccessTokenExpiration = e.CreationDate().Add(e.Lifetime)
|
2024-03-20 10:18:46 +00:00
|
|
|
wm.Reason = e.Reason
|
|
|
|
wm.Actor = e.Actor
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
|
2023-07-17 12:33:37 +00:00
|
|
|
func (wm *OIDCSessionAccessTokenReadModel) reduceTokenRevoked(e eventstore.Event) {
|
|
|
|
wm.AccessTokenID = ""
|
2023-10-19 10:19:10 +00:00
|
|
|
wm.AccessTokenExpiration = e.CreatedAt()
|
2023-07-17 12:33:37 +00:00
|
|
|
}
|
|
|
|
|
2023-07-14 11:16:16 +00:00
|
|
|
// ActiveAccessTokenByToken will check if the token is active by retrieving the OIDCSession events from the eventstore.
|
2023-07-19 11:17:39 +00:00
|
|
|
// Refreshed or expired tokens will return an error as well as if the underlying sessions has been terminated.
|
2023-07-14 11:16:16 +00:00
|
|
|
func (q *Queries) ActiveAccessTokenByToken(ctx context.Context, token string) (model *OIDCSessionAccessTokenReadModel, err error) {
|
|
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
|
|
|
|
split := strings.Split(token, "-")
|
|
|
|
if len(split) != 2 {
|
2024-08-26 10:15:40 +00:00
|
|
|
return nil, zerrors.ThrowUnauthenticated(nil, "QUERY-LJK2W", "Errors.OIDCSession.Token.Invalid")
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
model, err = q.accessTokenByOIDCSessionAndTokenID(ctx, split[0], split[1])
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if !model.AccessTokenExpiration.After(time.Now()) {
|
2024-08-26 10:15:40 +00:00
|
|
|
return nil, zerrors.ThrowUnauthenticated(nil, "QUERY-SAF3rf", "Errors.OIDCSession.Token.Expired")
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
2024-06-12 09:11:36 +00:00
|
|
|
if err = q.checkSessionNotTerminatedAfter(ctx, model.SessionID, model.UserID, model.Position, model.UserAgent.GetFingerprintID()); err != nil {
|
2023-07-19 11:17:39 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return model, nil
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (q *Queries) accessTokenByOIDCSessionAndTokenID(ctx context.Context, oidcSessionID, tokenID string) (model *OIDCSessionAccessTokenReadModel, err error) {
|
|
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
|
2023-07-19 11:17:39 +00:00
|
|
|
model = newOIDCSessionAccessTokenReadModel(oidcSessionID)
|
2023-07-14 11:16:16 +00:00
|
|
|
if err = q.eventstore.FilterToQueryReducer(ctx, model); err != nil {
|
2024-08-26 10:15:40 +00:00
|
|
|
return nil, zerrors.ThrowUnauthenticated(err, "QUERY-ASfe2", "Errors.OIDCSession.Token.Invalid")
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
if model.AccessTokenID != tokenID {
|
2024-08-26 10:15:40 +00:00
|
|
|
return nil, zerrors.ThrowUnauthenticated(nil, "QUERY-M2u9w", "Errors.OIDCSession.Token.Invalid")
|
2023-07-14 11:16:16 +00:00
|
|
|
}
|
|
|
|
return model, nil
|
|
|
|
}
|
2023-07-19 11:17:39 +00:00
|
|
|
|
2024-05-16 05:07:56 +00:00
|
|
|
// checkSessionNotTerminatedAfter checks if a [session.TerminateType] event (or user events leading to a session termination)
|
|
|
|
// occurred after a certain time and will return an error if so.
|
2024-09-24 16:43:29 +00:00
|
|
|
func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID string, position float64, fingerprintID string) (err error) {
|
2023-07-19 11:17:39 +00:00
|
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
|
perf(oidc): nest position clause for session terminated query (#8738)
# Which Problems Are Solved
Optimize the query that checks for terminated sessions in the access
token verifier. The verifier is used in auth middleware, userinfo and
introspection.
# How the Problems Are Solved
The previous implementation built a query for certain events and then
appended a single `PositionAfter` clause. This caused the postgreSQL
planner to use indexes only for the instance ID, aggregate IDs,
aggregate types and event types. Followed by an expensive sequential
scan for the position. This resulting in internal over-fetching of rows
before the final filter was applied.
![Screenshot_20241007_105803](https://github.com/user-attachments/assets/f2d91976-be87-428b-b604-a211399b821c)
Furthermore, the query was searching for events which are not always
applicable. For example, there was always a session ID search and if
there was a user ID, we would also search for a browser fingerprint in
event payload (expensive). Even if those argument string would be empty.
This PR changes:
1. Nest the position query, so that a full `instance_id, aggregate_id,
aggregate_type, event_type, "position"` index can be matched.
2. Redefine the `es_wm` index to include the `position` column.
3. Only search for events for the IDs that actually have a value. Do not
search (noop) if none of session ID, user ID or fingerpint ID are set.
New query plan:
![Screenshot_20241007_110648](https://github.com/user-attachments/assets/c3234c33-1b76-4b33-a4a9-796f69f3d775)
# Additional Changes
- cleanup how we load multi-statement migrations and make that a bit
more reusable.
# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/7639
2024-10-07 12:49:55 +00:00
|
|
|
if sessionID == "" && userID == "" && fingerprintID == "" {
|
|
|
|
return nil
|
|
|
|
}
|
2024-02-28 09:30:05 +00:00
|
|
|
model := &sessionTerminatedModel{
|
2024-05-16 05:07:56 +00:00
|
|
|
sessionID: sessionID,
|
2024-06-12 09:11:36 +00:00
|
|
|
position: position,
|
2024-05-16 05:07:56 +00:00
|
|
|
userID: userID,
|
|
|
|
fingerPrintID: fingerprintID,
|
2024-02-28 09:30:05 +00:00
|
|
|
}
|
|
|
|
err = q.eventstore.FilterToQueryReducer(ctx, model)
|
|
|
|
if err != nil {
|
2024-08-26 10:15:40 +00:00
|
|
|
return zerrors.ThrowUnauthenticated(err, "QUERY-SJ642", "Errors.Internal")
|
2024-02-28 09:30:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if model.terminated {
|
2024-08-26 10:15:40 +00:00
|
|
|
return zerrors.ThrowUnauthenticated(nil, "QUERY-IJL3H", "Errors.OIDCSession.Token.Invalid")
|
2024-02-28 09:30:05 +00:00
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type sessionTerminatedModel struct {
|
2024-09-24 16:43:29 +00:00
|
|
|
position float64
|
2024-05-16 05:07:56 +00:00
|
|
|
sessionID string
|
|
|
|
userID string
|
|
|
|
fingerPrintID string
|
2024-02-28 09:30:05 +00:00
|
|
|
|
|
|
|
events int
|
|
|
|
terminated bool
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *sessionTerminatedModel) Reduce() error {
|
|
|
|
s.terminated = s.events > 0
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *sessionTerminatedModel) AppendEvents(events ...eventstore.Event) {
|
|
|
|
s.events += len(events)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *sessionTerminatedModel) Query() *eventstore.SearchQueryBuilder {
|
perf(oidc): nest position clause for session terminated query (#8738)
# Which Problems Are Solved
Optimize the query that checks for terminated sessions in the access
token verifier. The verifier is used in auth middleware, userinfo and
introspection.
# How the Problems Are Solved
The previous implementation built a query for certain events and then
appended a single `PositionAfter` clause. This caused the postgreSQL
planner to use indexes only for the instance ID, aggregate IDs,
aggregate types and event types. Followed by an expensive sequential
scan for the position. This resulting in internal over-fetching of rows
before the final filter was applied.
![Screenshot_20241007_105803](https://github.com/user-attachments/assets/f2d91976-be87-428b-b604-a211399b821c)
Furthermore, the query was searching for events which are not always
applicable. For example, there was always a session ID search and if
there was a user ID, we would also search for a browser fingerprint in
event payload (expensive). Even if those argument string would be empty.
This PR changes:
1. Nest the position query, so that a full `instance_id, aggregate_id,
aggregate_type, event_type, "position"` index can be matched.
2. Redefine the `es_wm` index to include the `position` column.
3. Only search for events for the IDs that actually have a value. Do not
search (noop) if none of session ID, user ID or fingerpint ID are set.
New query plan:
![Screenshot_20241007_110648](https://github.com/user-attachments/assets/c3234c33-1b76-4b33-a4a9-796f69f3d775)
# Additional Changes
- cleanup how we load multi-statement migrations and make that a bit
more reusable.
# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/7639
2024-10-07 12:49:55 +00:00
|
|
|
builder := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent)
|
|
|
|
if s.sessionID != "" {
|
|
|
|
builder = builder.AddQuery().
|
|
|
|
AggregateTypes(session.AggregateType).
|
|
|
|
AggregateIDs(s.sessionID).
|
|
|
|
EventTypes(
|
|
|
|
session.TerminateType,
|
|
|
|
).
|
|
|
|
PositionAfter(s.position).
|
|
|
|
Builder()
|
2023-07-19 11:17:39 +00:00
|
|
|
}
|
perf(oidc): nest position clause for session terminated query (#8738)
# Which Problems Are Solved
Optimize the query that checks for terminated sessions in the access
token verifier. The verifier is used in auth middleware, userinfo and
introspection.
# How the Problems Are Solved
The previous implementation built a query for certain events and then
appended a single `PositionAfter` clause. This caused the postgreSQL
planner to use indexes only for the instance ID, aggregate IDs,
aggregate types and event types. Followed by an expensive sequential
scan for the position. This resulting in internal over-fetching of rows
before the final filter was applied.
![Screenshot_20241007_105803](https://github.com/user-attachments/assets/f2d91976-be87-428b-b604-a211399b821c)
Furthermore, the query was searching for events which are not always
applicable. For example, there was always a session ID search and if
there was a user ID, we would also search for a browser fingerprint in
event payload (expensive). Even if those argument string would be empty.
This PR changes:
1. Nest the position query, so that a full `instance_id, aggregate_id,
aggregate_type, event_type, "position"` index can be matched.
2. Redefine the `es_wm` index to include the `position` column.
3. Only search for events for the IDs that actually have a value. Do not
search (noop) if none of session ID, user ID or fingerpint ID are set.
New query plan:
![Screenshot_20241007_110648](https://github.com/user-attachments/assets/c3234c33-1b76-4b33-a4a9-796f69f3d775)
# Additional Changes
- cleanup how we load multi-statement migrations and make that a bit
more reusable.
# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/7639
2024-10-07 12:49:55 +00:00
|
|
|
if s.userID != "" {
|
|
|
|
builder = builder.AddQuery().
|
|
|
|
AggregateTypes(user.AggregateType).
|
|
|
|
AggregateIDs(s.userID).
|
|
|
|
EventTypes(
|
|
|
|
user.UserDeactivatedType,
|
|
|
|
user.UserLockedType,
|
|
|
|
user.UserRemovedType,
|
|
|
|
).
|
|
|
|
PositionAfter(s.position).
|
|
|
|
Builder()
|
|
|
|
if s.fingerPrintID != "" {
|
|
|
|
// for specific logout on v1 sessions from the same user agent
|
|
|
|
builder = builder.AddQuery().
|
|
|
|
AggregateTypes(user.AggregateType).
|
|
|
|
AggregateIDs(s.userID).
|
|
|
|
EventTypes(
|
|
|
|
user.HumanSignedOutType,
|
|
|
|
).
|
|
|
|
EventData(map[string]interface{}{"userAgentID": s.fingerPrintID}).
|
|
|
|
PositionAfter(s.position).
|
|
|
|
Builder()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return builder
|
2023-07-19 11:17:39 +00:00
|
|
|
}
|