From fc6154cffc6cf40226ff82838cf339637a886911 Mon Sep 17 00:00:00 2001 From: Livio Amstutz Date: Wed, 3 Nov 2021 08:35:24 +0100 Subject: [PATCH] feat: token revocation and OP certification (#2594) * fix: try using only user session if no user is set (id_token_hint) on prompt none * fix caos errors As implementation * implement request mode * return explicit error on invalid refresh token use * begin token revocation * token revocation * tests * tests * cleanup * set op config * add revocation endpoint to config * add revocation endpoint to config * migration version * error handling in token revocation * migration version * update oidc lib to 1.0.0 --- cmd/zitadel/startup.yaml | 7 +- docs/docs/apis/proto/management.md | 2 +- go.mod | 4 +- go.sum | 16 +- internal/api/oidc/auth_request.go | 32 ++++ internal/api/oidc/auth_request_converter.go | 4 + internal/api/oidc/op.go | 4 + .../eventsourcing/eventstore/auth_request.go | 20 +++ .../repository/eventsourcing/handler/token.go | 33 +++- .../repository/eventsourcing/view/token.go | 8 + internal/command/user.go | 39 +++- .../command/user_human_access_token_model.go | 106 +++++++++++ internal/command/user_human_refresh_token.go | 166 ++++++++++++------ .../command/user_human_refresh_token_model.go | 9 +- .../command/user_human_refresh_token_test.go | 157 ++--------------- internal/command/user_test.go | 130 ++++++++++++++ internal/domain/token.go | 4 +- internal/errors/caos_error.go | 4 + internal/repository/user/eventstore.go | 1 + internal/repository/user/user.go | 47 ++++- internal/user/model/token_view.go | 2 + internal/user/repository/view/model/token.go | 71 +++++--- .../user/repository/view/model/token_query.go | 2 + internal/user/repository/view/token_view.go | 5 + .../cockroach/V1.90__token_refresh_id.sql | 1 + 25 files changed, 638 insertions(+), 236 deletions(-) create mode 100644 internal/command/user_human_access_token_model.go create mode 100644 migrations/cockroach/V1.90__token_refresh_id.sql diff --git a/cmd/zitadel/startup.yaml b/cmd/zitadel/startup.yaml index 414122a286..1295c2861f 100644 --- a/cmd/zitadel/startup.yaml +++ b/cmd/zitadel/startup.yaml @@ -290,8 +290,6 @@ API: OPConfig: Issuer: $ZITADEL_ISSUER DefaultLogoutRedirectURI: $ZITADEL_ACCOUNTS/logout/done - CodeMethodS256: true - AuthMethodPrivateKeyJWT: true StorageConfig: DefaultLoginURL: $ZITADEL_ACCOUNTS/login?authRequestID= DefaultAccessTokenLifetime: 12h @@ -318,6 +316,9 @@ API: Introspection: Path: 'introspect' URL: '$ZITADEL_OAUTH/introspect' + Revocation: + Path: 'revoke' + URL: '$ZITADEL_OAUTH/revoke' EndSession: Path: 'endsession' URL: '$ZITADEL_AUTHORIZE/endsession' @@ -409,4 +410,4 @@ Notification: ConcurrentWorkers: 1 BulkLimit: 10000 FailureCountUntilSkip: 5 - Handlers: \ No newline at end of file + Handlers: diff --git a/docs/docs/apis/proto/management.md b/docs/docs/apis/proto/management.md index 1f6f9659c2..6ebc734fb8 100644 --- a/docs/docs/apis/proto/management.md +++ b/docs/docs/apis/proto/management.md @@ -6700,7 +6700,7 @@ This is an empty response | Field | Type | Description | Validation | | ----- | ---- | ----------- | ----------- | | user_id | string | - | string.min_len: 1
string.max_len: 200
| -| email | string | - | string.email: true
| +| email | string | - | string.email: true
string.ignore_empty: true
| diff --git a/go.mod b/go.mod index d0096bd5b4..a137077fa0 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.17.0 github.com/boombuler/barcode v1.0.1 github.com/caos/logging v0.0.2 - github.com/caos/oidc v0.15.12 - github.com/caos/orbos v1.5.14-0.20211022145449-6bd09d384fa8 + github.com/caos/oidc v1.0.0 + github.com/caos/orbos v1.5.14-0.20211102124704-34db02bceed2 github.com/cockroachdb/cockroach-go/v2 v2.2.1 github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06 github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d diff --git a/go.sum b/go.sum index 9c6db9d3e4..4cd5807dc4 100644 --- a/go.sum +++ b/go.sum @@ -165,11 +165,10 @@ github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyX github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/caos/logging v0.0.2 h1:ebg5C/HN0ludYR+WkvnFjwSExF4wvyiWPyWGcKMYsoo= github.com/caos/logging v0.0.2/go.mod h1:9LKiDE2ChuGv6CHYif/kiugrfEXu9AwDiFWSreX7Wp0= -github.com/caos/oidc v0.15.7/go.mod h1:doQ1B/mGnQWbgS+UOANIQCPJe1+KACyxQ8wjV2d11h0= -github.com/caos/oidc v0.15.12 h1:vBVOsQlFCfW7fV43acxkg2V0NHh0NWA4lWzWiJ6LgOk= -github.com/caos/oidc v0.15.12/go.mod h1:4l0PPwdc6BbrdCFhNrRTUddsG292uHGa7gE2DSEIqoU= -github.com/caos/orbos v1.5.14-0.20211022145449-6bd09d384fa8 h1:Bs1MQ0QMtX9RnNEEYGLDV9pqifS8DD12vhzsw/MweVA= -github.com/caos/orbos v1.5.14-0.20211022145449-6bd09d384fa8/go.mod h1:rCFhwdWo7dS0viJftFdBNjoT9U0fHS3UFsbsAmpKwlI= +github.com/caos/oidc v1.0.0 h1:3sHkYf8zsuARR89qO9CyvfYhHGdliWPcou4glzGMXmQ= +github.com/caos/oidc v1.0.0/go.mod h1:4l0PPwdc6BbrdCFhNrRTUddsG292uHGa7gE2DSEIqoU= +github.com/caos/orbos v1.5.14-0.20211102124704-34db02bceed2 h1:WqWpTGAUUC07G44CsNw4WyotuqRY4nSF9xadcEC4Yrc= +github.com/caos/orbos v1.5.14-0.20211102124704-34db02bceed2/go.mod h1:/49/y2DW47eHx38cNFFTM/sd4KA5L5SjASdYBQ5lraY= github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ= @@ -303,6 +302,7 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= +github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= @@ -339,8 +339,12 @@ github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= @@ -459,7 +463,6 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -664,6 +667,7 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtB github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index d3bb52182f..8928b02f9a 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -116,6 +116,9 @@ func (o *OPStorage) CreateAccessAndRefreshTokens(ctx context.Context, req op.Tok refreshToken, req.GetAudience(), req.GetScopes(), authMethodsReferences, o.defaultAccessTokenLifetime, o.defaultRefreshTokenIdleExpiration, o.defaultRefreshTokenExpiration, authTime) //PLANNED: lifetime from client if err != nil { + if errors.IsErrorInvalidArgument(err) { + err = oidc.ErrInvalidGrant().WithParent(err) + } return "", "", time.Time{}, err } return resp.TokenID, token, resp.Expiration, nil @@ -162,6 +165,35 @@ func (o *OPStorage) TerminateSession(ctx context.Context, userID, clientID strin return err } +func (o *OPStorage) RevokeToken(ctx context.Context, token, userID, clientID string) *oidc.Error { + refreshToken, err := o.repo.RefreshTokenByID(ctx, token) + if err == nil { + if refreshToken.ClientID != clientID { + return oidc.ErrInvalidClient().WithDescription("token was not issued for this client") + } + _, err = o.command.RevokeRefreshToken(ctx, refreshToken.UserID, refreshToken.ResourceOwner, refreshToken.ID) + if errors.IsNotFound(err) { + return nil + } + return oidc.ErrServerError().WithParent(err) + } + accessToken, err := o.repo.TokenByID(ctx, userID, token) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return oidc.ErrServerError().WithParent(err) + } + if accessToken.ApplicationID != clientID { + return oidc.ErrInvalidClient().WithDescription("token was not issued for this client") + } + _, err = o.command.RevokeAccessToken(ctx, userID, accessToken.ResourceOwner, accessToken.ID) + if err == nil || errors.IsNotFound(err) { + return nil + } + return oidc.ErrServerError().WithParent(err) +} + func (o *OPStorage) GetSigningKey(ctx context.Context, keyCh chan<- jose.SigningKey) { o.repo.GetSigningKey(ctx, keyCh, o.signingKeyAlgorithm) } diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 8af7ef836e..71526adcb7 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -81,6 +81,10 @@ func (a *AuthRequest) GetResponseType() oidc.ResponseType { return ResponseTypeToOIDC(a.oidc().ResponseType) } +func (a *AuthRequest) GetResponseMode() oidc.ResponseMode { + return "" +} + func (a *AuthRequest) GetScopes() []string { return a.oidc().Scopes } diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index beb8860ba8..436e95519a 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -44,6 +44,7 @@ type EndpointConfig struct { Token *Endpoint Introspection *Endpoint Userinfo *Endpoint + Revocation *Endpoint EndSession *Endpoint Keys *Endpoint } @@ -76,6 +77,8 @@ func NewProvider(ctx context.Context, config OPHandlerConfig, command *command.C } copy(config.OPConfig.CryptoKey[:], cryptoKey) config.OPConfig.CodeMethodS256 = true + config.OPConfig.AuthMethodPost = true + config.OPConfig.AuthMethodPrivateKeyJWT = true config.OPConfig.GrantTypeRefreshToken = true supportedLanguages, err := getSupportedLanguages() logging.Log("OIDC-GBd3t").OnError(err).Panic("cannot get supported languages") @@ -96,6 +99,7 @@ func NewProvider(ctx context.Context, config OPHandlerConfig, command *command.C op.WithCustomTokenEndpoint(op.NewEndpointWithURL(config.Endpoints.Token.Path, config.Endpoints.Token.URL)), op.WithCustomIntrospectionEndpoint(op.NewEndpointWithURL(config.Endpoints.Introspection.Path, config.Endpoints.Introspection.URL)), op.WithCustomUserinfoEndpoint(op.NewEndpointWithURL(config.Endpoints.Userinfo.Path, config.Endpoints.Userinfo.URL)), + op.WithCustomRevocationEndpoint(op.NewEndpointWithURL(config.Endpoints.Revocation.Path, config.Endpoints.Revocation.URL)), op.WithCustomEndSessionEndpoint(op.NewEndpointWithURL(config.Endpoints.EndSession.Path, config.Endpoints.EndSession.URL)), op.WithCustomKeysEndpoint(op.NewEndpointWithURL(config.Endpoints.Keys.Path, config.Endpoints.Keys.URL)), ) diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index ef7fa9ae42..102b970bdc 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -136,6 +136,10 @@ func (repo *AuthRequestRepo) CreateAuthRequest(ctx context.Context, request *dom err = repo.checkLoginName(ctx, request, request.LoginHint) logging.LogWithFields("EVENT-aG311", "login name", request.LoginHint, "id", request.ID, "applicationID", request.ApplicationID, "traceID", tracing.TraceIDFromCtx(ctx)).OnError(err).Debug("login hint invalid") } + if request.UserID == "" && request.LoginHint == "" && domain.IsPrompt(request.Prompt, domain.PromptNone) { + err = repo.tryUsingOnlyUserSession(request) + logging.LogWithFields("EVENT-SDf3g", "id", request.ID, "applicationID", request.ApplicationID, "traceID", tracing.TraceIDFromCtx(ctx)).OnError(err).Debug("unable to select only user session") + } err = repo.AuthRequests.SaveAuthRequest(ctx, request) if err != nil { @@ -567,6 +571,22 @@ func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.A return nil } +func (repo *AuthRequestRepo) tryUsingOnlyUserSession(request *domain.AuthRequest) error { + userSessions, err := userSessionsByUserAgentID(repo.UserSessionViewProvider, request.AgentID) + if err != nil { + return err + } + if len(userSessions) == 1 { + user := userSessions[0] + username := user.UserName + if request.RequestedOrgID == "" { + username = user.LoginName + } + request.SetUserInfo(user.UserID, username, user.LoginName, user.DisplayName, user.AvatarKey, user.ResourceOwner) + } + return nil +} + func (repo *AuthRequestRepo) checkLoginName(ctx context.Context, request *domain.AuthRequest, loginName string) (err error) { user := new(user_view_model.UserView) if request.RequestedOrgID != "" { diff --git a/internal/auth/repository/eventsourcing/handler/token.go b/internal/auth/repository/eventsourcing/handler/token.go index 596cd56e48..87103a357d 100644 --- a/internal/auth/repository/eventsourcing/handler/token.go +++ b/internal/auth/repository/eventsourcing/handler/token.go @@ -7,7 +7,7 @@ import ( "github.com/caos/logging" caos_errs "github.com/caos/zitadel/internal/errors" - "github.com/caos/zitadel/internal/eventstore/v1" + v1 "github.com/caos/zitadel/internal/eventstore/v1" es_models "github.com/caos/zitadel/internal/eventstore/v1/models" "github.com/caos/zitadel/internal/eventstore/v1/query" es_sdk "github.com/caos/zitadel/internal/eventstore/v1/sdk" @@ -15,6 +15,7 @@ import ( proj_model "github.com/caos/zitadel/internal/project/model" project_es_model "github.com/caos/zitadel/internal/project/repository/eventsourcing/model" proj_view "github.com/caos/zitadel/internal/project/repository/view" + user_repo "github.com/caos/zitadel/internal/repository/user" user_es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" view_model "github.com/caos/zitadel/internal/user/repository/view/model" ) @@ -111,6 +112,18 @@ func (t *Token) Reduce(event *es_models.Event) (err error) { user_es_model.UserDeactivated, user_es_model.UserRemoved: return t.view.DeleteUserTokens(event.AggregateID, event) + case es_models.EventType(user_repo.UserTokenRemovedType): + id, err := tokenIDFromRemovedEvent(event) + if err != nil { + return err + } + return t.view.DeleteToken(id, event) + case es_models.EventType(user_repo.HumanRefreshTokenRemovedType): + id, err := refreshTokenIDFromRemovedEvent(event) + if err != nil { + return err + } + return t.view.DeleteTokensFromRefreshToken(id, event) case project_es_model.ApplicationDeactivated, project_es_model.ApplicationRemoved: application, err := applicationFromSession(event) @@ -157,6 +170,24 @@ func applicationFromSession(event *es_models.Event) (*project_es_model.Applicati return application, nil } +func tokenIDFromRemovedEvent(event *es_models.Event) (string, error) { + removed := make(map[string]interface{}) + if err := json.Unmarshal(event.Data, &removed); err != nil { + logging.Log("EVEN-Sdff3").WithError(err).Error("could not unmarshal event data") + return "", caos_errs.ThrowInternal(nil, "MODEL-Sff32", "could not unmarshal data") + } + return removed["tokenId"].(string), nil +} + +func refreshTokenIDFromRemovedEvent(event *es_models.Event) (string, error) { + removed := make(map[string]interface{}) + if err := json.Unmarshal(event.Data, &removed); err != nil { + logging.Log("EVEN-Ff23g").WithError(err).Error("could not unmarshal event data") + return "", caos_errs.ThrowInternal(nil, "MODEL-Dfb3w", "could not unmarshal data") + } + return removed["tokenId"].(string), nil +} + func (t *Token) OnSuccess() error { return spooler.HandleSuccess(t.view.UpdateTokenSpoolerRunTimestamp) } diff --git a/internal/auth/repository/eventsourcing/view/token.go b/internal/auth/repository/eventsourcing/view/token.go index d6a547f8e3..e160d11964 100644 --- a/internal/auth/repository/eventsourcing/view/token.go +++ b/internal/auth/repository/eventsourcing/view/token.go @@ -68,6 +68,14 @@ func (v *View) DeleteApplicationTokens(event *models.Event, ids ...string) error return v.ProcessedTokenSequence(event) } +func (v *View) DeleteTokensFromRefreshToken(refreshTokenID string, event *models.Event) error { + err := usr_view.DeleteTokensFromRefreshToken(v.Db, tokenTable, refreshTokenID) + if err != nil && !errors.IsNotFound(err) { + return err + } + return v.ProcessedTokenSequence(event) +} + func (v *View) GetLatestTokenSequence() (*repository.CurrentSequence, error) { return v.latestSequence(tokenTable) } diff --git a/internal/command/user.go b/internal/command/user.go index b2670550e5..bcb37f3dfd 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -223,7 +223,7 @@ func (c *Commands) AddUserToken(ctx context.Context, orgID, agentID, clientID, u return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Dbge4", "Errors.IDMissing") } userWriteModel := NewUserWriteModel(userID, orgID) - event, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, lifetime) + event, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, "", audience, scopes, lifetime) if err != nil { return nil, err } @@ -234,7 +234,23 @@ func (c *Commands) AddUserToken(ctx context.Context, orgID, agentID, clientID, u return accessToken, nil } -func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteModel, agentID, clientID string, audience, scopes []string, lifetime time.Duration) (*user.UserTokenAddedEvent, *domain.Token, error) { +func (c *Commands) RevokeAccessToken(ctx context.Context, userID, orgID, tokenID string) (*domain.ObjectDetails, error) { + removeEvent, accessTokenWriteModel, err := c.removeAccessToken(ctx, userID, orgID, tokenID) + if err != nil { + return nil, err + } + events, err := c.eventstore.PushEvents(ctx, removeEvent) + if err != nil { + return nil, err + } + err = AppendAndReduce(accessTokenWriteModel, events...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&accessTokenWriteModel.WriteModel), nil +} + +func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteModel, agentID, clientID, refreshTokenID string, audience, scopes []string, lifetime time.Duration) (*user.UserTokenAddedEvent, *domain.Token, error) { err := c.eventstore.FilterToQueryReducer(ctx, userWriteModel) if err != nil { return nil, nil, err @@ -257,7 +273,7 @@ func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteMo } userAgg := UserAggregateFromWriteModel(&userWriteModel.WriteModel) - return user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, audience, scopes, expiration), + return user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, refreshTokenID, audience, scopes, expiration), &domain.Token{ ObjectRoot: models.ObjectRoot{ AggregateID: userWriteModel.AggregateID, @@ -265,6 +281,7 @@ func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteMo TokenID: tokenID, UserAgentID: agentID, ApplicationID: clientID, + RefreshTokenID: refreshTokenID, Audience: audience, Scopes: scopes, Expiration: expiration, @@ -272,6 +289,22 @@ func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteMo }, nil } +func (c *Commands) removeAccessToken(ctx context.Context, userID, orgID, tokenID string) (*user.UserTokenRemovedEvent, *UserAccessTokenWriteModel, error) { + if userID == "" || orgID == "" || tokenID == "" { + return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Dng42", "Errors.IDMissing") + } + refreshTokenWriteModel := NewUserAccessTokenWriteModel(userID, orgID, tokenID) + err := c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel) + if err != nil { + return nil, nil, err + } + if refreshTokenWriteModel.UserState != domain.UserStateActive { + return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-BF4hd", "Errors.User.AccessToken.NotFound") + } + userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) + return user.NewUserTokenRemovedEvent(ctx, userAgg, tokenID), refreshTokenWriteModel, nil +} + func (c *Commands) userDomainClaimed(ctx context.Context, userID string) (events []eventstore.EventPusher, _ *UserWriteModel, err error) { existingUser, err := c.userWriteModelByID(ctx, userID, "") if err != nil { diff --git a/internal/command/user_human_access_token_model.go b/internal/command/user_human_access_token_model.go new file mode 100644 index 0000000000..d505cae36d --- /dev/null +++ b/internal/command/user_human_access_token_model.go @@ -0,0 +1,106 @@ +package command + +import ( + "time" + + "github.com/caos/zitadel/internal/eventstore" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/repository/user" +) + +type UserAccessTokenWriteModel struct { + eventstore.WriteModel + + TokenID string + ApplicationID string + UserAgentID string + Audience []string + Scopes []string + Expiration time.Time + PreferredLanguage string + + UserState domain.UserState +} + +func NewUserAccessTokenWriteModel(userID, resourceOwner, tokenID string) *UserAccessTokenWriteModel { + return &UserAccessTokenWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + TokenID: tokenID, + } +} + +func (wm *UserAccessTokenWriteModel) AppendEvents(events ...eventstore.EventReader) { + for _, event := range events { + switch e := event.(type) { + case *user.UserTokenAddedEvent: + if wm.TokenID != e.TokenID { + continue + } + wm.WriteModel.AppendEvents(e) + case *user.UserTokenRemovedEvent: + if wm.TokenID != e.TokenID { + continue + } + wm.WriteModel.AppendEvents(e) + case *user.HumanSignedOutEvent: + if wm.UserAgentID != e.UserAgentID { + continue + } + wm.WriteModel.AppendEvents(e) + case *user.UserLockedEvent, + *user.UserDeactivatedEvent, + *user.UserRemovedEvent: + wm.WriteModel.AppendEvents(e) + } + } +} + +func (wm *UserAccessTokenWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.UserTokenAddedEvent: + wm.TokenID = e.TokenID + wm.ApplicationID = e.ApplicationID + wm.UserAgentID = e.UserAgentID + wm.Audience = e.Audience + wm.Scopes = e.Scopes + wm.Expiration = e.Expiration + wm.PreferredLanguage = e.PreferredLanguage + wm.UserState = domain.UserStateActive + if e.Expiration.Before(time.Now()) { + wm.UserState = domain.UserStateDeleted + } + case *user.UserTokenRemovedEvent, + *user.HumanSignedOutEvent, + *user.UserLockedEvent, + *user.UserDeactivatedEvent, + *user.UserRemovedEvent: + wm.UserState = domain.UserStateDeleted + } + } + return wm.WriteModel.Reduce() +} + +func (wm *UserAccessTokenWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + user.UserTokenAddedType, + user.UserTokenRemovedType, + user.HumanSignedOutType, + user.UserLockedType, + user.UserDeactivatedType, + user.UserRemovedType). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} diff --git a/internal/command/user_human_refresh_token.go b/internal/command/user_human_refresh_token.go index 5fd678cd9e..fbe2cdb24d 100644 --- a/internal/command/user_human_refresh_token.go +++ b/internal/command/user_human_refresh_token.go @@ -10,22 +10,54 @@ import ( "github.com/caos/zitadel/internal/repository/user" ) -func (c *Commands) AddAccessAndRefreshToken(ctx context.Context, orgID, agentID, clientID, userID, refreshToken string, audience, scopes, authMethodsReferences []string, accessLifetime, refreshIdleExpiration, refreshExpiration time.Duration, authTime time.Time) (*domain.Token, string, error) { +func (c *Commands) AddAccessAndRefreshToken( + ctx context.Context, + orgID, + agentID, + clientID, + userID, + refreshToken string, + audience, + scopes, + authMethodsReferences []string, + accessLifetime, + refreshIdleExpiration, + refreshExpiration time.Duration, + authTime time.Time, +) (accessToken *domain.Token, newRefreshToken string, err error) { + if refreshToken == "" { + return c.AddNewRefreshTokenAndAccessToken(ctx, userID, orgID, agentID, clientID, audience, scopes, authMethodsReferences, refreshExpiration, accessLifetime, refreshIdleExpiration, authTime) + } + return c.RenewRefreshTokenAndAccessToken(ctx, userID, orgID, refreshToken, agentID, clientID, audience, scopes, refreshIdleExpiration, accessLifetime) +} + +func (c *Commands) AddNewRefreshTokenAndAccessToken( + ctx context.Context, + userID, + orgID, + agentID, + clientID string, + audience, + scopes, + authMethodsReferences []string, + refreshExpiration, + accessLifetime, + refreshIdleExpiration time.Duration, + authTime time.Time, +) (accessToken *domain.Token, newRefreshToken string, err error) { + if userID == "" || agentID == "" || clientID == "" { + return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-adg4r", "Errors.IDMissing") + } userWriteModel := NewUserWriteModel(userID, orgID) - accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, accessLifetime) + refreshTokenID, err := c.idGenerator.Next() if err != nil { return nil, "", err } - - creator := func() (eventstore.EventPusher, string, error) { - return c.addRefreshToken(ctx, accessToken, authMethodsReferences, authTime, refreshIdleExpiration, refreshExpiration) + accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, refreshTokenID, audience, scopes, accessLifetime) + if err != nil { + return nil, "", err } - if refreshToken != "" { - creator = func() (eventstore.EventPusher, string, error) { - return c.renewRefreshToken(ctx, userID, orgID, refreshToken, refreshIdleExpiration) - } - } - refreshTokenEvent, token, err := creator() + refreshTokenEvent, newRefreshToken, err := c.addRefreshToken(ctx, accessToken, authMethodsReferences, authTime, refreshIdleExpiration, refreshExpiration) if err != nil { return nil, "", err } @@ -33,61 +65,35 @@ func (c *Commands) AddAccessAndRefreshToken(ctx context.Context, orgID, agentID, if err != nil { return nil, "", err } - return accessToken, token, nil + return accessToken, newRefreshToken, nil } -func (c *Commands) addRefreshToken(ctx context.Context, accessToken *domain.Token, authMethodsReferences []string, authTime time.Time, idleExpiration, expiration time.Duration) (*user.HumanRefreshTokenAddedEvent, string, error) { - tokenID, err := c.idGenerator.Next() +func (c *Commands) RenewRefreshTokenAndAccessToken( + ctx context.Context, + userID, + orgID, + refreshToken, + agentID, + clientID string, + audience, + scopes []string, + idleExpiration, + accessLifetime time.Duration, +) (accessToken *domain.Token, newRefreshToken string, err error) { + refreshTokenEvent, refreshTokenID, newRefreshToken, err := c.renewRefreshToken(ctx, userID, orgID, refreshToken, idleExpiration) if err != nil { return nil, "", err } - refreshToken, err := domain.NewRefreshToken(accessToken.AggregateID, tokenID, c.keyAlgorithm) + userWriteModel := NewUserWriteModel(userID, orgID) + accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, refreshTokenID, audience, scopes, accessLifetime) if err != nil { return nil, "", err } - refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(accessToken.AggregateID, accessToken.ResourceOwner, tokenID) - userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) - return user.NewHumanRefreshTokenAddedEvent(ctx, userAgg, tokenID, accessToken.ApplicationID, accessToken.UserAgentID, - accessToken.PreferredLanguage, accessToken.Audience, accessToken.Scopes, authMethodsReferences, authTime, idleExpiration, expiration), - refreshToken, nil -} - -func (c *Commands) renewRefreshToken(ctx context.Context, userID, orgID, refreshToken string, idleExpiration time.Duration) (event *user.HumanRefreshTokenRenewedEvent, newRefreshToken string, err error) { - if refreshToken == "" { - return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-DHrr3", "Errors.IDMissing") - } - - tokenUserID, tokenID, token, err := domain.FromRefreshToken(refreshToken, c.keyAlgorithm) - if err != nil { - return nil, "", caos_errs.ThrowInvalidArgument(err, "COMMAND-Dbfe4", "Errors.User.RefreshToken.Invalid") - } - if tokenUserID != userID { - return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Ht2g2", "Errors.User.RefreshToken.Invalid") - } - refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(userID, orgID, tokenID) - err = c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel) + _, err = c.eventstore.PushEvents(ctx, accessTokenEvent, refreshTokenEvent) if err != nil { return nil, "", err } - if refreshTokenWriteModel.UserState != domain.UserStateActive { - return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-BHnhs", "Errors.User.RefreshToken.Invalid") - } - if refreshTokenWriteModel.RefreshToken != token || - refreshTokenWriteModel.IdleExpiration.Before(time.Now()) || - refreshTokenWriteModel.Expiration.Before(time.Now()) { - return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Vr43e", "Errors.User.RefreshToken.Invalid") - } - - newToken, err := c.idGenerator.Next() - if err != nil { - return nil, "", err - } - newRefreshToken, err = domain.RefreshToken(userID, tokenID, newToken, c.keyAlgorithm) - if err != nil { - return nil, "", err - } - userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) - return user.NewHumanRefreshTokenRenewedEvent(ctx, userAgg, tokenID, newToken, idleExpiration), newRefreshToken, nil + return accessToken, newRefreshToken, nil } func (c *Commands) RevokeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*domain.ObjectDetails, error) { @@ -122,6 +128,56 @@ func (c *Commands) RevokeRefreshTokens(ctx context.Context, userID, orgID string return err } +func (c *Commands) addRefreshToken(ctx context.Context, accessToken *domain.Token, authMethodsReferences []string, authTime time.Time, idleExpiration, expiration time.Duration) (*user.HumanRefreshTokenAddedEvent, string, error) { + refreshToken, err := domain.NewRefreshToken(accessToken.AggregateID, accessToken.RefreshTokenID, c.keyAlgorithm) + if err != nil { + return nil, "", err + } + refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(accessToken.AggregateID, accessToken.ResourceOwner, accessToken.RefreshTokenID) + userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) + return user.NewHumanRefreshTokenAddedEvent(ctx, userAgg, accessToken.RefreshTokenID, accessToken.ApplicationID, accessToken.UserAgentID, + accessToken.PreferredLanguage, accessToken.Audience, accessToken.Scopes, authMethodsReferences, authTime, idleExpiration, expiration), + refreshToken, nil +} + +func (c *Commands) renewRefreshToken(ctx context.Context, userID, orgID, refreshToken string, idleExpiration time.Duration) (event *user.HumanRefreshTokenRenewedEvent, refreshTokenID, newRefreshToken string, err error) { + if refreshToken == "" { + return nil, "", "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-DHrr3", "Errors.IDMissing") + } + + tokenUserID, tokenID, token, err := domain.FromRefreshToken(refreshToken, c.keyAlgorithm) + if err != nil { + return nil, "", "", caos_errs.ThrowInvalidArgument(err, "COMMAND-Dbfe4", "Errors.User.RefreshToken.Invalid") + } + if tokenUserID != userID { + return nil, "", "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Ht2g2", "Errors.User.RefreshToken.Invalid") + } + refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(userID, orgID, tokenID) + err = c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel) + if err != nil { + return nil, "", "", err + } + if refreshTokenWriteModel.UserState != domain.UserStateActive { + return nil, "", "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-BHnhs", "Errors.User.RefreshToken.Invalid") + } + if refreshTokenWriteModel.RefreshToken != token || + refreshTokenWriteModel.IdleExpiration.Before(time.Now()) || + refreshTokenWriteModel.Expiration.Before(time.Now()) { + return nil, "", "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Vr43e", "Errors.User.RefreshToken.Invalid") + } + + newToken, err := c.idGenerator.Next() + if err != nil { + return nil, "", "", err + } + newRefreshToken, err = domain.RefreshToken(userID, tokenID, newToken, c.keyAlgorithm) + if err != nil { + return nil, "", "", err + } + userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) + return user.NewHumanRefreshTokenRenewedEvent(ctx, userAgg, tokenID, newToken, idleExpiration), tokenID, newRefreshToken, nil +} + func (c *Commands) removeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*user.HumanRefreshTokenRemovedEvent, *HumanRefreshTokenWriteModel, error) { if userID == "" || orgID == "" || tokenID == "" { return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-GVDgf", "Errors.IDMissing") diff --git a/internal/command/user_human_refresh_token_model.go b/internal/command/user_human_refresh_token_model.go index e3138a02e5..6476aee443 100644 --- a/internal/command/user_human_refresh_token_model.go +++ b/internal/command/user_human_refresh_token_model.go @@ -67,7 +67,11 @@ func (wm *HumanRefreshTokenWriteModel) Reduce() error { } wm.RefreshToken = e.RefreshToken wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration) - case *user.HumanRefreshTokenRemovedEvent: + case *user.HumanRefreshTokenRemovedEvent, + *user.HumanSignedOutEvent, + *user.UserLockedEvent, + *user.UserDeactivatedEvent, + *user.UserRemovedEvent: wm.UserState = domain.UserStateDeleted } } @@ -83,6 +87,9 @@ func (wm *HumanRefreshTokenWriteModel) Query() *eventstore.SearchQueryBuilder { user.HumanRefreshTokenAddedType, user.HumanRefreshTokenRenewedType, user.HumanRefreshTokenRemovedType, + user.HumanSignedOutType, + user.UserLockedType, + user.UserDeactivatedType, user.UserRemovedType). Builder() diff --git a/internal/command/user_human_refresh_token_test.go b/internal/command/user_human_refresh_token_test.go index 9e93596ad3..c41b2af57e 100644 --- a/internal/command/user_human_refresh_token_test.go +++ b/internal/command/user_human_refresh_token_test.go @@ -9,7 +9,6 @@ import ( "github.com/caos/oidc/pkg/oidc" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" - "golang.org/x/text/language" "github.com/caos/zitadel/internal/crypto" "github.com/caos/zitadel/internal/domain" @@ -56,15 +55,13 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { res res }{ { - name: "error access token, error", + name: "missing ID, error", fields: fields{ - eventstore: eventstoreExpect(t, - expectFilter(), - ), + eventstore: eventstoreExpect(t), }, args: args{}, res: res{ - err: caos_errs.IsNotFound, + err: caos_errs.IsErrorInvalidArgument, }, }, { @@ -73,8 +70,15 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter(), ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "refreshTokenID1"), + }, + args: args{ + ctx: context.Background(), + orgID: "orgID", + agentID: "agentID", + userID: "userID", + clientID: "clientID", }, - args: args{}, res: res{ err: caos_errs.IsNotFound, }, @@ -82,39 +86,7 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { { name: "renew refresh token, invalid token, error", fields: fields{ - eventstore: eventstoreExpect(t, - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"), + eventstore: eventstoreExpect(t), keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), }, args: args{ @@ -128,39 +100,7 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { { name: "renew refresh token, invalid token (invalid userID), error", fields: fields{ - eventstore: eventstoreExpect(t, - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"), + eventstore: eventstoreExpect(t), keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), }, args: args{ @@ -177,36 +117,6 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { name: "renew refresh token, token inactive, error", fields: fields{ eventstore: eventstoreExpect(t, - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), expectFilter( eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( context.Background(), @@ -229,7 +139,6 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { )), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"), keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), }, args: args{ @@ -246,36 +155,6 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { name: "renew refresh token, token expired, error", fields: fields{ eventstore: eventstoreExpect(t, - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), - expectFilter( - eventFromEventPusher(user.NewHumanAddedEvent( - context.Background(), - &user.NewAggregate("userID", "orgID").Aggregate, - "username", - "firstname", - "lastname", - "nickname", - "displayname", - language.German, - domain.GenderUnspecified, - "email", - true, - )), - ), expectFilter( eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( context.Background(), @@ -293,7 +172,6 @@ func TestCommands_AddAccessAndRefreshToken(t *testing.T) { )), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"), keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), }, args: args{ @@ -809,7 +687,6 @@ func TestCommands_addRefreshToken(t *testing.T) { authTime := time.Now().Add(-1 * time.Hour) type fields struct { eventstore *eventstore.Eventstore - idGenerator id.Generator keyAlgorithm crypto.EncryptionAlgorithm } type args struct { @@ -836,7 +713,6 @@ func TestCommands_addRefreshToken(t *testing.T) { name: "add refresh Token", fields: fields{ eventstore: eventstoreExpect(t), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "refreshTokenID"), keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), }, args: args{ @@ -849,6 +725,7 @@ func TestCommands_addRefreshToken(t *testing.T) { TokenID: "accessTokenID1", ApplicationID: "clientID", UserAgentID: "agentID", + RefreshTokenID: "refreshTokenID", Audience: []string{"clientID1"}, Expiration: time.Now().Add(5 * time.Minute), Scopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, @@ -882,7 +759,6 @@ func TestCommands_addRefreshToken(t *testing.T) { t.Run(tt.name, func(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, keyAlgorithm: tt.fields.keyAlgorithm, } gotEvent, gotRefreshToken, err := c.addRefreshToken(tt.args.ctx, tt.args.accessToken, tt.args.authMethodsReferences, tt.args.authTime, tt.args.idleExpiration, tt.args.expiration) @@ -915,6 +791,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { } type res struct { event *user.HumanRefreshTokenRenewedEvent + refreshTokenID string newRefreshToken string err func(error) bool } @@ -1076,6 +953,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { "refreshToken1", 1*time.Hour, ), + refreshTokenID: "tokenID", newRefreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:refreshToken1")), }, }, @@ -1087,7 +965,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { idGenerator: tt.fields.idGenerator, keyAlgorithm: tt.fields.keyAlgorithm, } - gotEvent, gotNewRefreshToken, err := c.renewRefreshToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.refreshToken, tt.args.idleExpiration) + gotEvent, gotRefreshTokenID, gotNewRefreshToken, err := c.renewRefreshToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.refreshToken, tt.args.idleExpiration) if tt.res.err == nil { assert.NoError(t, err) } @@ -1096,6 +974,7 @@ func TestCommands_renewRefreshToken(t *testing.T) { } if tt.res.err == nil { assert.Equal(t, tt.res.event, gotEvent) + assert.Equal(t, tt.res.refreshTokenID, gotRefreshTokenID) assert.Equal(t, tt.res.newRefreshToken, gotNewRefreshToken) } }) diff --git a/internal/command/user_test.go b/internal/command/user_test.go index 3e2479587d..1d322d87ea 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -1335,6 +1335,136 @@ func TestCommandSide_AddUserToken(t *testing.T) { } } +func TestCommands_RevokeAccessToken(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + orgID string + tokenID string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "id missing error", + fields{ + eventstoreExpect(t), + }, + args{ + context.Background(), + "userID", + "orgID", + "", + }, + res{ + nil, + caos_errs.IsErrorInvalidArgument, + }, + }, + { + "not active error", + fields{ + eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewUserTokenAddedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "clientID", + "agentID", + "de", + "refreshTokenID", + []string{"clientID"}, + []string{"openid"}, + time.Now(), + ), + ), + ), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + "tokenID", + }, + res{ + nil, + caos_errs.IsNotFound, + }, + }, + { + "active ok", + fields{ + eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewUserTokenAddedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "clientID", + "agentID", + "de", + "refreshTokenID", + []string{"clientID"}, + []string{"openid"}, + time.Now().Add(5*time.Hour), + ), + ), + ), + expectPush( + eventPusherToEvents( + user.NewUserTokenRemovedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + ), + ), + ), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + "tokenID", + }, + res{ + &domain.ObjectDetails{ + ResourceOwner: "orgID", + }, + nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := c.RevokeAccessToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.tokenID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} + func TestCommandSide_UserDomainClaimedSent(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore diff --git a/internal/domain/token.go b/internal/domain/token.go index a5a560c303..e41ab106ec 100644 --- a/internal/domain/token.go +++ b/internal/domain/token.go @@ -1,9 +1,10 @@ package domain import ( - es_models "github.com/caos/zitadel/internal/eventstore/v1/models" "strings" "time" + + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" ) type Token struct { @@ -12,6 +13,7 @@ type Token struct { TokenID string ApplicationID string UserAgentID string + RefreshTokenID string Audience []string Expiration time.Time Scopes []string diff --git a/internal/errors/caos_error.go b/internal/errors/caos_error.go index b4a04d615e..08a659875d 100644 --- a/internal/errors/caos_error.go +++ b/internal/errors/caos_error.go @@ -58,6 +58,10 @@ func (err *CaosError) Is(target error) bool { } func (err *CaosError) As(target interface{}) bool { + _, ok := target.(**CaosError) + if !ok { + return false + } reflect.Indirect(reflect.ValueOf(target)).Set(reflect.ValueOf(err)) return true } diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index b496500a8d..e29ca2a1b2 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -42,6 +42,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(UserReactivatedType, UserReactivatedEventMapper). RegisterFilterEventMapper(UserRemovedType, UserRemovedEventMapper). RegisterFilterEventMapper(UserTokenAddedType, UserTokenAddedEventMapper). + RegisterFilterEventMapper(UserTokenRemovedType, UserTokenRemovedEventMapper). RegisterFilterEventMapper(UserDomainClaimedType, DomainClaimedEventMapper). RegisterFilterEventMapper(UserDomainClaimedSentType, DomainClaimedSentEventMapper). RegisterFilterEventMapper(UserUserNameChangedType, UsernameChangedEventMapper). diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go index c3369d8306..9117d48b6b 100644 --- a/internal/repository/user/user.go +++ b/internal/repository/user/user.go @@ -21,6 +21,7 @@ const ( UserReactivatedType = userEventTypePrefix + "reactivated" UserRemovedType = userEventTypePrefix + "removed" UserTokenAddedType = userEventTypePrefix + "token.added" + UserTokenRemovedType = userEventTypePrefix + "token.removed" UserDomainClaimedType = userEventTypePrefix + "domain.claimed" UserDomainClaimedSentType = userEventTypePrefix + "domain.claimed.sent" UserUserNameChangedType = userEventTypePrefix + "username.changed" @@ -213,6 +214,7 @@ type UserTokenAddedEvent struct { TokenID string `json:"tokenId"` ApplicationID string `json:"applicationId"` UserAgentID string `json:"userAgentId"` + RefreshTokenID string `json:"refreshTokenID,omitempty"` Audience []string `json:"audience"` Scopes []string `json:"scopes"` Expiration time.Time `json:"expiration"` @@ -233,7 +235,8 @@ func NewUserTokenAddedEvent( tokenID, applicationID, userAgentID, - preferredLanguage string, + preferredLanguage, + refreshTokenID string, audience, scopes []string, expiration time.Time, @@ -247,6 +250,7 @@ func NewUserTokenAddedEvent( TokenID: tokenID, ApplicationID: applicationID, UserAgentID: userAgentID, + RefreshTokenID: refreshTokenID, Audience: audience, Scopes: scopes, Expiration: expiration, @@ -266,6 +270,47 @@ func UserTokenAddedEventMapper(event *repository.Event) (eventstore.EventReader, return tokenAdded, nil } +type UserTokenRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenId"` +} + +func (e *UserTokenRemovedEvent) Data() interface{} { + return e +} + +func (e *UserTokenRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewUserTokenRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID string, +) *UserTokenRemovedEvent { + return &UserTokenRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + UserTokenRemovedType, + ), + TokenID: tokenID, + } +} + +func UserTokenRemovedEventMapper(event *repository.Event) (eventstore.EventReader, error) { + tokenRemoved := &UserTokenRemovedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, tokenRemoved) + if err != nil { + return nil, errors.ThrowInternal(err, "USER-7M9sd", "unable to unmarshal token added") + } + + return tokenRemoved, nil +} + type DomainClaimedEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/internal/user/model/token_view.go b/internal/user/model/token_view.go index 2f797fbc73..bd47d65875 100644 --- a/internal/user/model/token_view.go +++ b/internal/user/model/token_view.go @@ -20,6 +20,7 @@ type TokenView struct { Scopes []string Sequence uint64 PreferredLanguage string + RefreshTokenID string } type TokenSearchRequest struct { @@ -36,6 +37,7 @@ const ( TokenSearchKeyUnspecified TokenSearchKey = iota TokenSearchKeyTokenID TokenSearchKeyUserID + TokenSearchKeyRefreshTokenID TokenSearchKeyApplicationID TokenSearchKeyUserAgentID TokenSearchKeyExpiration diff --git a/internal/user/repository/view/model/token.go b/internal/user/repository/view/model/token.go index f5bd1489c2..c904b4ec23 100644 --- a/internal/user/repository/view/model/token.go +++ b/internal/user/repository/view/model/token.go @@ -8,6 +8,7 @@ import ( caos_errs "github.com/caos/zitadel/internal/errors" es_models "github.com/caos/zitadel/internal/eventstore/v1/models" + user_repo "github.com/caos/zitadel/internal/repository/user" usr_model "github.com/caos/zitadel/internal/user/model" usr_es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" @@ -15,12 +16,13 @@ import ( ) const ( - TokenKeyTokenID = "id" - TokenKeyUserID = "user_id" - TokenKeyApplicationID = "application_id" - TokenKeyUserAgentID = "user_agent_id" - TokenKeyExpiration = "expiration" - TokenKeyResourceOwner = "resource_owner" + TokenKeyTokenID = "id" + TokenKeyUserID = "user_id" + TokenKeyRefreshTokenID = "refresh_token_id" + TokenKeyApplicationID = "application_id" + TokenKeyUserAgentID = "user_agent_id" + TokenKeyExpiration = "expiration" + TokenKeyResourceOwner = "resource_owner" ) type TokenView struct { @@ -36,26 +38,10 @@ type TokenView struct { Expiration time.Time `json:"expiration" gorm:"column:expiration"` Sequence uint64 `json:"-" gorm:"column:sequence"` PreferredLanguage string `json:"preferredLanguage" gorm:"column:preferred_language"` + RefreshTokenID string `json:"refreshTokenID,omitempty" gorm:"refresh_token_id"` Deactivated bool `json:"-" gorm:"-"` } -func TokenViewFromModel(token *usr_model.TokenView) *TokenView { - return &TokenView{ - ID: token.ID, - CreationDate: token.CreationDate, - ChangeDate: token.ChangeDate, - ResourceOwner: token.ResourceOwner, - UserID: token.UserID, - ApplicationID: token.ApplicationID, - UserAgentID: token.UserAgentID, - Audience: token.Audience, - Scopes: token.Scopes, - Expiration: token.Expiration, - Sequence: token.Sequence, - PreferredLanguage: token.PreferredLanguage, - } -} - func TokenViewToModel(token *TokenView) *usr_model.TokenView { return &usr_model.TokenView{ ID: token.ID, @@ -70,6 +56,7 @@ func TokenViewToModel(token *TokenView) *usr_model.TokenView { Expiration: token.Expiration, Sequence: token.Sequence, PreferredLanguage: token.PreferredLanguage, + RefreshTokenID: token.RefreshTokenID, } } @@ -79,6 +66,10 @@ func (t *TokenView) AppendEventIfMyToken(event *es_models.Event) (err error) { case usr_es_model.UserTokenAdded: view.setRootData(event) err = view.setData(event) + case es_models.EventType(user_repo.UserTokenRemovedType): + return t.appendTokenRemoved(event) + case es_models.EventType(user_repo.HumanRefreshTokenRemovedType): + return t.appendRefreshTokenRemoved(event) case usr_es_model.SignedOut, usr_es_model.HumanSignedOut: id, err := agentIDFromSession(event) @@ -103,6 +94,9 @@ func (t *TokenView) AppendEventIfMyToken(event *es_models.Event) (err error) { default: return nil } + if err != nil { + return err + } if view.ID == t.ID { return t.AppendEvent(event) } @@ -145,3 +139,34 @@ func agentIDFromSession(event *es_models.Event) (string, error) { } return session["userAgentID"].(string), nil } + +func (t *TokenView) appendTokenRemoved(event *es_models.Event) error { + token, err := eventToMap(event) + if err != nil { + return err + } + if token["tokenId"] == t.ID { + t.Deactivated = true + } + return nil +} + +func (t *TokenView) appendRefreshTokenRemoved(event *es_models.Event) error { + refreshToken, err := eventToMap(event) + if err != nil { + return err + } + if refreshToken["tokenId"] == t.RefreshTokenID { + t.Deactivated = true + } + return nil +} + +func eventToMap(event *es_models.Event) (map[string]interface{}, error) { + m := make(map[string]interface{}) + if err := json.Unmarshal(event.Data, &m); err != nil { + logging.Log("EVEN-Dbffe").WithError(err).Error("could not unmarshal event data") + return nil, caos_errs.ThrowInternal(nil, "MODEL-SDAfw", "could not unmarshal data") + } + return m, nil +} diff --git a/internal/user/repository/view/model/token_query.go b/internal/user/repository/view/model/token_query.go index d1e17ccdb0..948e262bef 100644 --- a/internal/user/repository/view/model/token_query.go +++ b/internal/user/repository/view/model/token_query.go @@ -57,6 +57,8 @@ func (key TokenSearchKey) ToColumnName() string { return TokenKeyUserAgentID case model.TokenSearchKeyUserID: return TokenKeyUserID + case model.TokenSearchKeyRefreshTokenID: + return TokenKeyRefreshTokenID case model.TokenSearchKeyApplicationID: return TokenKeyApplicationID case model.TokenSearchKeyExpiration: diff --git a/internal/user/repository/view/token_view.go b/internal/user/repository/view/token_view.go index 7daa4cdcab..caf195b107 100644 --- a/internal/user/repository/view/token_view.go +++ b/internal/user/repository/view/token_view.go @@ -67,6 +67,11 @@ func DeleteUserTokens(db *gorm.DB, table, userID string) error { return delete(db) } +func DeleteTokensFromRefreshToken(db *gorm.DB, table, refreshTokenID string) error { + delete := repository.PrepareDeleteByKey(table, usr_model.TokenSearchKey(model.TokenSearchKeyRefreshTokenID), refreshTokenID) + return delete(db) +} + func DeleteApplicationTokens(db *gorm.DB, table string, appIDs []string) error { delete := repository.PrepareDeleteByKey(table, usr_model.TokenSearchKey(model.TokenSearchKeyApplicationID), pq.StringArray(appIDs)) return delete(db) diff --git a/migrations/cockroach/V1.90__token_refresh_id.sql b/migrations/cockroach/V1.90__token_refresh_id.sql new file mode 100644 index 0000000000..c6bd19d8b2 --- /dev/null +++ b/migrations/cockroach/V1.90__token_refresh_id.sql @@ -0,0 +1 @@ +ALTER TABLE auth.tokens ADD COLUMN refresh_token_id TEXT;