diff --git a/cmd/zitadel/startup.yaml b/cmd/zitadel/startup.yaml index 73a81d25b6..01072dffb0 100644 --- a/cmd/zitadel/startup.yaml +++ b/cmd/zitadel/startup.yaml @@ -224,6 +224,8 @@ API: DefaultLoginURL: $ZITADEL_ACCOUNTS/login?authRequestID= DefaultAccessTokenLifetime: 12h DefaultIdTokenLifetime: 12h + DefaultRefreshTokenIdleExpiration: 720h #30d + DefaultRefreshTokenExpiration: 2160h #90d SigningKeyAlgorithm: RS256 UserAgentCookieConfig: Name: caos.zitadel.useragent diff --git a/docs/docs/apis/proto/auth.md b/docs/docs/apis/proto/auth.md index 69cdf2efc7..8780902757 100644 --- a/docs/docs/apis/proto/auth.md +++ b/docs/docs/apis/proto/auth.md @@ -47,6 +47,36 @@ Returns the user sessions of the authorized user of the current useragent +### ListMyRefreshTokens + +> **rpc** ListMyRefreshTokens([ListMyRefreshTokensRequest](#listmyrefreshtokensrequest)) +[ListMyRefreshTokensResponse](#listmyrefreshtokensresponse) + +Returns the refresh tokens of the authorized user + + + + +### RevokeMyRefreshToken + +> **rpc** RevokeMyRefreshToken([RevokeMyRefreshTokenRequest](#revokemyrefreshtokenrequest)) +[RevokeMyRefreshTokenResponse](#revokemyrefreshtokenresponse) + +Revokes a single refresh token of the authorized user by its (token) id + + + + +### RevokeAllMyRefreshTokens + +> **rpc** RevokeAllMyRefreshTokens([RevokeAllMyRefreshTokensRequest](#revokeallmyrefreshtokensrequest)) +[RevokeAllMyRefreshTokensResponse](#revokeallmyrefreshtokensresponse) + +Revokes all refresh tokens of the authorized user + + + + ### UpdateMyUserName > **rpc** UpdateMyUserName([UpdateMyUserNameRequest](#updatemyusernamerequest)) @@ -636,6 +666,24 @@ This is an empty request +### ListMyRefreshTokensRequest +This is an empty request + + + + +### ListMyRefreshTokensResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ListDetails | - | | +| result | repeated zitadel.user.v1.RefreshToken | - | | + + + + ### ListMyUserChangesRequest @@ -868,6 +916,40 @@ This is an empty request +### RevokeAllMyRefreshTokensRequest +This is an empty request + + + + +### RevokeAllMyRefreshTokensResponse +This is an empty response + + + + +### RevokeMyRefreshTokenRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| id | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### RevokeMyRefreshTokenResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ObjectDetails | - | | + + + + ### SetMyEmailRequest diff --git a/docs/docs/apis/proto/user.md b/docs/docs/apis/proto/user.md index 2b8ce80a53..e4d841e12b 100644 --- a/docs/docs/apis/proto/user.md +++ b/docs/docs/apis/proto/user.md @@ -241,6 +241,24 @@ this query is always equals +### RefreshToken + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| id | string | - | | +| details | zitadel.v1.ObjectDetails | - | | +| client_id | string | - | | +| auth_time | google.protobuf.Timestamp | - | | +| idle_expiration | google.protobuf.Timestamp | - | | +| expiration | google.protobuf.Timestamp | - | | +| scopes | repeated string | - | | +| audience | repeated string | - | | + + + + ### SearchQuery diff --git a/go.mod b/go.mod index 29c7cf183b..72e178ba69 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/allegro/bigcache v1.2.1 github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc github.com/caos/logging v0.0.2 - github.com/caos/oidc v0.14.7 + github.com/caos/oidc v0.15.0 github.com/caos/orbos v1.5.14-0.20210428081839-983ffc569980 github.com/cockroachdb/cockroach-go/v2 v2.1.0 github.com/duo-labs/webauthn v0.0.0-20200714211715-1daaee874e43 diff --git a/go.sum b/go.sum index 006922a536..2518ec435d 100644 --- a/go.sum +++ b/go.sum @@ -137,8 +137,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW 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.14.4/go.mod h1:H5Y2zw3YIrWqQOoy0wcmZva2a66bumDyU2iOhXiM9uA= -github.com/caos/oidc v0.14.7 h1:HDxQhEWIOdmp1MoJAj8aRMP62Cga4yAk5M9UEJtdS1Y= -github.com/caos/oidc v0.14.7/go.mod h1:JiK5RXSOgag66wiSOMEkS+yS4R46Baz6dGwfr60VfvI= +github.com/caos/oidc v0.15.0 h1:lSykVX6yfUbWpJPAZ9/ZCuowo95h7AgfgPaC15lzf4Y= +github.com/caos/oidc v0.15.0/go.mod h1:JiK5RXSOgag66wiSOMEkS+yS4R46Baz6dGwfr60VfvI= github.com/caos/orbos v1.5.14-0.20210428081839-983ffc569980 h1:Fz0aYUwGMA2tsu5w7SryqFGjqGClJVHbyhBMT5SXtPU= github.com/caos/orbos v1.5.14-0.20210428081839-983ffc569980/go.mod h1:2I8oiZb5SMRm/qTLvwpSmdV0M6ex8J/UKyxUGfKaqJo= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= @@ -436,6 +436,7 @@ github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2c github.com/googleinterns/cloud-operations-api-mock v0.0.0-20200709193332-a1e58c29bdd3 h1:eHv/jVY/JNop1xg2J9cBb4EzyMpWZoNCP1BslSAIkOI= github.com/googleinterns/cloud-operations-api-mock v0.0.0-20200709193332-a1e58c29bdd3/go.mod h1:h/KNeRx7oYU4SpA4SoY7W2/NxDKEEVuwA6j9A27L4OI= github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/csrf v1.7.0 h1:mMPjV5/3Zd460xCavIkppUdvnl5fPXMpv2uz2Zyg7/Y= @@ -577,6 +578,7 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= @@ -841,7 +843,9 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= diff --git a/internal/api/grpc/auth/refresh_token.go b/internal/api/grpc/auth/refresh_token.go new file mode 100644 index 0000000000..219e65c612 --- /dev/null +++ b/internal/api/grpc/auth/refresh_token.go @@ -0,0 +1,58 @@ +package auth + +import ( + "context" + + "github.com/caos/zitadel/internal/api/authz" + "github.com/caos/zitadel/internal/api/grpc/object" + user_grpc "github.com/caos/zitadel/internal/api/grpc/user" + "github.com/caos/zitadel/internal/user/model" + "github.com/caos/zitadel/pkg/grpc/auth" +) + +func (s *Server) ListMyRefreshTokens(ctx context.Context, req *auth.ListMyRefreshTokensRequest) (*auth.ListMyRefreshTokensResponse, error) { + res, err := s.repo.SearchMyRefreshTokens(ctx, authz.GetCtxData(ctx).UserID, ListMyRefreshTokensRequestToModel(req)) + if err != nil { + return nil, err + } + return &auth.ListMyRefreshTokensResponse{ + Result: user_grpc.RefreshTokensToPb(res.Result), + Details: object.ToListDetails( + res.TotalResult, + res.Sequence, + res.Timestamp, + ), + }, nil +} + +func (s *Server) RevokeMyRefreshToken(ctx context.Context, req *auth.RevokeMyRefreshTokenRequest) (*auth.RevokeMyRefreshTokenResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.RevokeRefreshToken(ctx, ctxData.UserID, ctxData.ResourceOwner, req.Id) + if err != nil { + return nil, err + } + return &auth.RevokeMyRefreshTokenResponse{ + Details: object.DomainToChangeDetailsPb(details), + }, nil +} + +func (s *Server) RevokeAllMyRefreshTokens(ctx context.Context, _ *auth.RevokeAllMyRefreshTokensRequest) (*auth.RevokeAllMyRefreshTokensResponse, error) { + ctxData := authz.GetCtxData(ctx) + res, err := s.repo.SearchMyRefreshTokens(ctx, ctxData.UserID, ListMyRefreshTokensRequestToModel(nil)) + if err != nil { + return nil, err + } + tokenIDs := make([]string, len(res.Result)) + for i, view := range res.Result { + tokenIDs[i] = view.ID + } + err = s.command.RevokeRefreshTokens(ctx, ctxData.UserID, ctxData.ResourceOwner, tokenIDs) + if err != nil { + return nil, err + } + return &auth.RevokeAllMyRefreshTokensResponse{}, nil +} + +func ListMyRefreshTokensRequestToModel(_ *auth.ListMyRefreshTokensRequest) *model.RefreshTokenSearchRequest { + return &model.RefreshTokenSearchRequest{} //add sorting, queries, ... when possible +} diff --git a/internal/api/grpc/user/refresh_token.go b/internal/api/grpc/user/refresh_token.go new file mode 100644 index 0000000000..315a07b632 --- /dev/null +++ b/internal/api/grpc/user/refresh_token.go @@ -0,0 +1,30 @@ +package user + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/caos/zitadel/internal/api/grpc/object" + "github.com/caos/zitadel/internal/user/model" + "github.com/caos/zitadel/pkg/grpc/user" +) + +func RefreshTokensToPb(refreshTokens []*model.RefreshTokenView) []*user.RefreshToken { + tokens := make([]*user.RefreshToken, len(refreshTokens)) + for i, token := range refreshTokens { + tokens[i] = RefreshTokenToPb(token) + } + return tokens +} + +func RefreshTokenToPb(token *model.RefreshTokenView) *user.RefreshToken { + return &user.RefreshToken{ + Id: token.ID, + Details: object.ToViewDetailsPb(token.Sequence, token.CreationDate, token.ChangeDate, token.ResourceOwner), + ClientId: token.ClientID, + AuthTime: timestamppb.New(token.AuthTime), + IdleExpiration: timestamppb.New(token.IdleExpiration), + Expiration: timestamppb.New(token.Expiration), + Scopes: token.Scopes, + Audience: token.Audience, + } +} diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index c10d08b30f..06de8b00d4 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -80,7 +80,7 @@ func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) (err error return o.repo.DeleteAuthRequest(ctx, id) } -func (o *OPStorage) CreateToken(ctx context.Context, req op.TokenRequest) (_ string, _ time.Time, err error) { +func (o *OPStorage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (_ string, _ time.Time, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() var userAgentID, applicationID, userOrgID string @@ -107,6 +107,37 @@ func grantsToScopes(grants []*grant_model.UserGrantView) []string { return scopes } +func (o *OPStorage) CreateAccessAndRefreshTokens(ctx context.Context, req op.TokenRequest, refreshToken string) (_, _ string, _ time.Time, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + var userAgentID, applicationID, userOrgID string + var authTime time.Time + var authMethodsReferences []string + authReq, ok := req.(*AuthRequest) + if ok { + userAgentID = authReq.AgentID + applicationID = authReq.ApplicationID + userOrgID = authReq.UserOrgID + authTime = authReq.AuthTime + authMethodsReferences = authReq.GetAMR() + } + resp, token, err := o.command.AddAccessAndRefreshToken(ctx, userOrgID, userAgentID, applicationID, req.GetSubject(), + refreshToken, req.GetAudience(), req.GetScopes(), authMethodsReferences, o.defaultAccessTokenLifetime, + o.defaultRefreshTokenIdleExpiration, o.defaultRefreshTokenExpiration, authTime) //PLANNED: lifetime from client + if err != nil { + return "", "", time.Time{}, err + } + return resp.TokenID, token, resp.Expiration, nil +} + +func (o *OPStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) { + tokenView, err := o.repo.RefreshTokenByID(ctx, refreshToken) + if err != nil { + return nil, err + } + return RefreshTokenRequestFromBusiness(tokenView), nil +} + func (o *OPStorage) TerminateSession(ctx context.Context, userID, clientID string) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 6a3fc44298..73756500b2 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -2,7 +2,6 @@ package oidc import ( "context" - "github.com/caos/zitadel/internal/domain" "net" "time" @@ -11,7 +10,9 @@ import ( "golang.org/x/text/language" http_utils "github.com/caos/zitadel/internal/api/http" + "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/user/model" ) const ( @@ -255,3 +256,39 @@ func AMRFromMFAType(mfaType domain.MFAType) string { return "" } } + +func RefreshTokenRequestFromBusiness(tokenView *model.RefreshTokenView) op.RefreshTokenRequest { + return &RefreshTokenRequest{tokenView} +} + +type RefreshTokenRequest struct { + *model.RefreshTokenView +} + +func (r *RefreshTokenRequest) GetAMR() []string { + return r.AuthMethodsReferences +} + +func (r *RefreshTokenRequest) GetAudience() []string { + return r.Audience +} + +func (r *RefreshTokenRequest) GetAuthTime() time.Time { + return r.AuthTime +} + +func (r *RefreshTokenRequest) GetClientID() string { + return r.ClientID +} + +func (r *RefreshTokenRequest) GetScopes() []string { + return r.Scopes +} + +func (r *RefreshTokenRequest) GetSubject() string { + return r.UserID +} + +func (r *RefreshTokenRequest) SetCurrentScopes(scopes oidc.Scopes) { + r.Scopes = scopes +} diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index a6a3152baa..af175dd897 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -28,10 +28,12 @@ type OPHandlerConfig struct { } type StorageConfig struct { - DefaultLoginURL string - SigningKeyAlgorithm string - DefaultAccessTokenLifetime types.Duration - DefaultIdTokenLifetime types.Duration + DefaultLoginURL string + SigningKeyAlgorithm string + DefaultAccessTokenLifetime types.Duration + DefaultIdTokenLifetime types.Duration + DefaultRefreshTokenIdleExpiration types.Duration + DefaultRefreshTokenExpiration types.Duration } type EndpointConfig struct { @@ -49,13 +51,15 @@ type Endpoint struct { } type OPStorage struct { - repo repository.Repository - command *command.Commands - query *query.Queries - defaultLoginURL string - defaultAccessTokenLifetime time.Duration - defaultIdTokenLifetime time.Duration - signingKeyAlgorithm string + repo repository.Repository + command *command.Commands + query *query.Queries + defaultLoginURL string + defaultAccessTokenLifetime time.Duration + defaultIdTokenLifetime time.Duration + signingKeyAlgorithm string + defaultRefreshTokenIdleExpiration time.Duration + defaultRefreshTokenExpiration time.Duration } func NewProvider(ctx context.Context, config OPHandlerConfig, command *command.Commands, query *query.Queries, repo repository.Repository, keyConfig *crypto.KeyConfig, localDevMode bool) op.OpenIDProvider { @@ -94,13 +98,15 @@ func NewProvider(ctx context.Context, config OPHandlerConfig, command *command.C func newStorage(config StorageConfig, command *command.Commands, query *query.Queries, repo repository.Repository) *OPStorage { return &OPStorage{ - repo: repo, - command: command, - query: query, - defaultLoginURL: config.DefaultLoginURL, - signingKeyAlgorithm: config.SigningKeyAlgorithm, - defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime.Duration, - defaultIdTokenLifetime: config.DefaultIdTokenLifetime.Duration, + repo: repo, + command: command, + query: query, + defaultLoginURL: config.DefaultLoginURL, + signingKeyAlgorithm: config.SigningKeyAlgorithm, + defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime.Duration, + defaultIdTokenLifetime: config.DefaultIdTokenLifetime.Duration, + defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration.Duration, + defaultRefreshTokenExpiration: config.DefaultRefreshTokenExpiration.Duration, } } diff --git a/internal/auth/repository/eventsourcing/eventstore/refresh_token.go b/internal/auth/repository/eventsourcing/eventstore/refresh_token.go new file mode 100644 index 0000000000..6c8d4907fc --- /dev/null +++ b/internal/auth/repository/eventsourcing/eventstore/refresh_token.go @@ -0,0 +1,94 @@ +package eventstore + +import ( + "context" + "time" + + "github.com/caos/logging" + + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/zitadel/internal/eventstore/v1/models" + usr_view "github.com/caos/zitadel/internal/user/repository/view" + + "github.com/caos/zitadel/internal/auth/repository/eventsourcing/view" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/telemetry/tracing" + usr_model "github.com/caos/zitadel/internal/user/model" + "github.com/caos/zitadel/internal/user/repository/view/model" +) + +type RefreshTokenRepo struct { + Eventstore v1.Eventstore + View *view.View + SearchLimit uint64 + KeyAlgorithm crypto.EncryptionAlgorithm +} + +func (r *RefreshTokenRepo) RefreshTokenByID(ctx context.Context, refreshToken string) (*usr_model.RefreshTokenView, error) { + userID, tokenID, token, err := domain.FromRefreshToken(refreshToken, r.KeyAlgorithm) + if err != nil { + return nil, err + } + tokenView, viewErr := r.View.RefreshTokenByID(tokenID) + if viewErr != nil && !errors.IsNotFound(viewErr) { + return nil, viewErr + } + if errors.IsNotFound(viewErr) { + tokenView = new(model.RefreshTokenView) + tokenView.ID = tokenID + tokenView.UserID = userID + } + + events, esErr := r.getUserEvents(ctx, userID, tokenView.Sequence) + if errors.IsNotFound(viewErr) && len(events) == 0 { + return nil, errors.ThrowNotFound(nil, "EVENT-BHB52", "Errors.User.RefreshToken.Invalid") + } + + if esErr != nil { + logging.Log("EVENT-AE462").WithError(viewErr).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("error retrieving new events") + return model.RefreshTokenViewToModel(tokenView), nil + } + viewToken := *tokenView + for _, event := range events { + err := tokenView.AppendEventIfMyRefreshToken(event) + if err != nil { + return model.RefreshTokenViewToModel(&viewToken), nil + } + } + if !tokenView.Expiration.After(time.Now()) || tokenView.Token != token { + return nil, errors.ThrowNotFound(nil, "EVENT-5Bm9s", "Errors.User.RefreshToken.Invalid") + } + return model.RefreshTokenViewToModel(tokenView), nil +} + +func (r *RefreshTokenRepo) SearchMyRefreshTokens(ctx context.Context, userID string, request *usr_model.RefreshTokenSearchRequest) (*usr_model.RefreshTokenSearchResponse, error) { + err := request.EnsureLimit(r.SearchLimit) + if err != nil { + return nil, err + } + sequence, err := r.View.GetLatestRefreshTokenSequence() + logging.Log("EVENT-GBdn4").OnError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Warn("could not read latest refresh token sequence") + request.Queries = append(request.Queries, &usr_model.RefreshTokenSearchQuery{Key: usr_model.RefreshTokenSearchKeyUserID, Method: domain.SearchMethodEquals, Value: userID}) + tokens, count, err := r.View.SearchRefreshTokens(request) + if err != nil { + return nil, err + } + return &usr_model.RefreshTokenSearchResponse{ + Offset: request.Offset, + Limit: request.Limit, + TotalResult: count, + Sequence: sequence.CurrentSequence, + Timestamp: sequence.LastSuccessfulSpoolerRun, + Result: model.RefreshTokenViewsToModel(tokens), + }, nil +} + +func (r *RefreshTokenRepo) getUserEvents(ctx context.Context, userID string, sequence uint64) ([]*models.Event, error) { + query, err := usr_view.UserByIDQuery(userID, sequence) + if err != nil { + return nil, err + } + return r.Eventstore.FilterEvents(ctx, query) +} diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go index 7c59700081..0f60096f07 100644 --- a/internal/auth/repository/eventsourcing/handler/handler.go +++ b/internal/auth/repository/eventsourcing/handler/handler.go @@ -69,6 +69,7 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es newProjectRole(handler{view, bulkLimit, configs.cycleDuration("ProjectRole"), errorCount, es}), newLabelPolicy(handler{view, bulkLimit, configs.cycleDuration("LabelPolicy"), errorCount, es}), newFeatures(handler{view, bulkLimit, configs.cycleDuration("Features"), errorCount, es}), + newRefreshToken(handler{view, bulkLimit, configs.cycleDuration("RefreshToken"), errorCount, es}), } } diff --git a/internal/auth/repository/eventsourcing/handler/refresh_token.go b/internal/auth/repository/eventsourcing/handler/refresh_token.go new file mode 100644 index 0000000000..e88a2b632d --- /dev/null +++ b/internal/auth/repository/eventsourcing/handler/refresh_token.go @@ -0,0 +1,123 @@ +package handler + +import ( + "encoding/json" + + "github.com/caos/logging" + + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "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" + "github.com/caos/zitadel/internal/eventstore/v1/spooler" + project_es_model "github.com/caos/zitadel/internal/project/repository/eventsourcing/model" + 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" +) + +const ( + refreshTokenTable = "auth.refresh_tokens" +) + +type RefreshToken struct { + handler + subscription *v1.Subscription +} + +func newRefreshToken( + handler handler, +) *RefreshToken { + h := &RefreshToken{ + handler: handler, + } + + h.subscribe() + + return h +} + +func (t *RefreshToken) subscribe() { + t.subscription = t.es.Subscribe(t.AggregateTypes()...) + go func() { + for event := range t.subscription.Events { + query.ReduceEvent(t, event) + } + }() +} + +func (t *RefreshToken) ViewModel() string { + return refreshTokenTable +} + +func (t *RefreshToken) AggregateTypes() []es_models.AggregateType { + return []es_models.AggregateType{user_es_model.UserAggregate, project_es_model.ProjectAggregate} +} + +func (t *RefreshToken) CurrentSequence() (uint64, error) { + sequence, err := t.view.GetLatestRefreshTokenSequence() + if err != nil { + return 0, err + } + return sequence.CurrentSequence, nil +} + +func (t *RefreshToken) EventQuery() (*es_models.SearchQuery, error) { + sequence, err := t.view.GetLatestRefreshTokenSequence() + if err != nil { + return nil, err + } + return es_models.NewSearchQuery(). + AggregateTypeFilter(user_es_model.UserAggregate, project_es_model.ProjectAggregate). + LatestSequenceFilter(sequence.CurrentSequence), nil +} + +func (t *RefreshToken) Reduce(event *es_models.Event) (err error) { + switch eventstore.EventType(event.Type) { + case user_repo.HumanRefreshTokenAddedType: + token := new(view_model.RefreshTokenView) + err := token.AppendEvent(event) + if err != nil { + return err + } + return t.view.PutRefreshToken(token, event) + case user_repo.HumanRefreshTokenRenewedType: + e := new(user_repo.HumanRefreshTokenRenewedEvent) + if err := json.Unmarshal(event.Data, e); err != nil { + logging.Log("EVEN-DBbn4").WithError(err).Error("could not unmarshal event data") + return caos_errs.ThrowInternal(nil, "MODEL-BHn75", "could not unmarshal data") + } + token, err := t.view.RefreshTokenByID(e.TokenID) + if err != nil { + return err + } + err = token.AppendEvent(event) + if err != nil { + return err + } + return t.view.PutRefreshToken(token, event) + case user_repo.HumanRefreshTokenRemovedType: + e := new(user_repo.HumanRefreshTokenRemovedEvent) + if err := json.Unmarshal(event.Data, e); err != nil { + logging.Log("EVEN-BDbh3").WithError(err).Error("could not unmarshal event data") + return caos_errs.ThrowInternal(nil, "MODEL-Bz653", "could not unmarshal data") + } + return t.view.DeleteRefreshToken(e.TokenID, event) + case user_repo.UserLockedType, + user_repo.UserDeactivatedType, + user_repo.UserRemovedType: + return t.view.DeleteUserRefreshTokens(event.AggregateID, event) + default: + return t.view.ProcessedRefreshTokenSequence(event) + } +} + +func (t *RefreshToken) OnError(event *es_models.Event, err error) error { + logging.LogWithFields("SPOOL-3jkl4", "id", event.AggregateID).WithError(err).Warn("something went wrong in token handler") + return spooler.HandleError(event, err, t.view.GetLatestTokenFailedEvent, t.view.ProcessedTokenFailedEvent, t.view.ProcessedTokenSequence, t.errorCountUntilSkip) +} + +func (t *RefreshToken) OnSuccess() error { + return spooler.HandleSuccess(t.view.UpdateTokenSpoolerRunTimestamp) +} diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index e0710162c4..283fe1959d 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -36,6 +36,7 @@ type EsRepository struct { eventstore.UserRepo eventstore.AuthRequestRepo eventstore.TokenRepo + eventstore.RefreshTokenRepo eventstore.KeyRepository eventstore.ApplicationRepo eventstore.UserSessionRepo @@ -110,6 +111,12 @@ func Start(conf Config, authZ authz.Config, systemDefaults sd.SystemDefaults, co View: view, Eventstore: es, }, + eventstore.RefreshTokenRepo{ + View: view, + Eventstore: es, + SearchLimit: conf.SearchLimit, + KeyAlgorithm: keyAlgorithm, + }, eventstore.KeyRepository{ View: view, Commands: command, diff --git a/internal/auth/repository/eventsourcing/view/refresh_token.go b/internal/auth/repository/eventsourcing/view/refresh_token.go new file mode 100644 index 0000000000..01acb1ba3e --- /dev/null +++ b/internal/auth/repository/eventsourcing/view/refresh_token.go @@ -0,0 +1,86 @@ +package view + +import ( + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1/models" + user_model "github.com/caos/zitadel/internal/user/model" + usr_view "github.com/caos/zitadel/internal/user/repository/view" + "github.com/caos/zitadel/internal/user/repository/view/model" + "github.com/caos/zitadel/internal/view/repository" +) + +const ( + refreshTokenTable = "auth.refresh_tokens" +) + +func (v *View) RefreshTokenByID(tokenID string) (*model.RefreshTokenView, error) { + return usr_view.RefreshTokenByID(v.Db, refreshTokenTable, tokenID) +} + +func (v *View) RefreshTokensByUserID(userID string) ([]*model.RefreshTokenView, error) { + return usr_view.RefreshTokensByUserID(v.Db, refreshTokenTable, userID) +} + +func (v *View) SearchRefreshTokens(request *user_model.RefreshTokenSearchRequest) ([]*model.RefreshTokenView, uint64, error) { + return usr_view.SearchRefreshTokens(v.Db, refreshTokenTable, request) +} + +func (v *View) PutRefreshToken(token *model.RefreshTokenView, event *models.Event) error { + err := usr_view.PutRefreshToken(v.Db, refreshTokenTable, token) + if err != nil { + return err + } + return v.ProcessedTokenSequence(event) +} + +func (v *View) PutRefreshTokens(token []*model.RefreshTokenView, event *models.Event) error { + err := usr_view.PutRefreshTokens(v.Db, refreshTokenTable, token...) + if err != nil { + return err + } + return v.ProcessedRefreshTokenSequence(event) +} + +func (v *View) DeleteRefreshToken(tokenID string, event *models.Event) error { + err := usr_view.DeleteRefreshToken(v.Db, refreshTokenTable, tokenID) + if err != nil && !errors.IsNotFound(err) { + return err + } + return v.ProcessedRefreshTokenSequence(event) +} + +func (v *View) DeleteUserRefreshTokens(userID string, event *models.Event) error { + err := usr_view.DeleteUserRefreshTokens(v.Db, refreshTokenTable, userID) + if err != nil && !errors.IsNotFound(err) { + return err + } + return v.ProcessedRefreshTokenSequence(event) +} + +func (v *View) DeleteApplicationRefreshTokens(event *models.Event, ids ...string) error { + err := usr_view.DeleteApplicationTokens(v.Db, refreshTokenTable, ids) + if err != nil && !errors.IsNotFound(err) { + return err + } + return v.ProcessedRefreshTokenSequence(event) +} + +func (v *View) GetLatestRefreshTokenSequence() (*repository.CurrentSequence, error) { + return v.latestSequence(refreshTokenTable) +} + +func (v *View) ProcessedRefreshTokenSequence(event *models.Event) error { + return v.saveCurrentSequence(refreshTokenTable, event) +} + +func (v *View) UpdateRefreshTokenSpoolerRunTimestamp() error { + return v.updateSpoolerRunSequence(refreshTokenTable) +} + +func (v *View) GetLatestRefreshTokenFailedEvent(sequence uint64) (*repository.FailedEvent, error) { + return v.latestFailedEvent(refreshTokenTable, sequence) +} + +func (v *View) ProcessedRefreshTokenFailedEvent(failedEvent *repository.FailedEvent) error { + return v.saveFailedEvent(failedEvent) +} diff --git a/internal/auth/repository/refresh_token.go b/internal/auth/repository/refresh_token.go new file mode 100644 index 0000000000..ea4c7010dd --- /dev/null +++ b/internal/auth/repository/refresh_token.go @@ -0,0 +1,12 @@ +package repository + +import ( + "context" + + "github.com/caos/zitadel/internal/user/model" +) + +type RefreshTokenRepository interface { + RefreshTokenByID(ctx context.Context, refreshToken string) (*model.RefreshTokenView, error) + SearchMyRefreshTokens(ctx context.Context, userID string, request *model.RefreshTokenSearchRequest) (*model.RefreshTokenSearchResponse, error) +} diff --git a/internal/auth/repository/repository.go b/internal/auth/repository/repository.go index 28888ad678..6a78027e03 100644 --- a/internal/auth/repository/repository.go +++ b/internal/auth/repository/repository.go @@ -17,4 +17,5 @@ type Repository interface { OrgRepository IAMRepository FeaturesRepository + RefreshTokenRepository } diff --git a/internal/command/command.go b/internal/command/command.go index 913e9ec2c4..9261fae5f9 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -2,19 +2,20 @@ package command import ( "context" + "time" + "github.com/caos/zitadel/internal/api/authz" authz_repo "github.com/caos/zitadel/internal/authz/repository/eventsourcing" "github.com/caos/zitadel/internal/config/types" "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/eventstore" - "time" "github.com/caos/zitadel/internal/api/http" sd "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/crypto" "github.com/caos/zitadel/internal/id" iam_repo "github.com/caos/zitadel/internal/repository/iam" - keypair "github.com/caos/zitadel/internal/repository/keypair" + "github.com/caos/zitadel/internal/repository/keypair" "github.com/caos/zitadel/internal/repository/org" proj_repo "github.com/caos/zitadel/internal/repository/project" usr_repo "github.com/caos/zitadel/internal/repository/user" diff --git a/internal/command/user.go b/internal/command/user.go index 7c789e631a..75f9360e23 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -3,12 +3,14 @@ package command import ( "context" "fmt" - "github.com/caos/zitadel/internal/eventstore" "time" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/v1/models" "github.com/caos/logging" + "github.com/caos/zitadel/internal/domain" caos_errs "github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/repository/user" @@ -209,50 +211,57 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, } func (c *Commands) AddUserToken(ctx context.Context, orgID, agentID, clientID, userID string, audience, scopes []string, lifetime time.Duration) (*domain.Token, error) { - if userID == "" { - return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-55n8M", "Errors.IDMissing") + if orgID == "" || userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Dbge4", "Errors.IDMissing") } - - existingUser, err := c.userWriteModelByID(ctx, userID, orgID) + userWriteModel := NewUserWriteModel(userID, orgID) + event, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, lifetime) if err != nil { return nil, err } - if !isUserStateExists(existingUser.UserState) { - return nil, caos_errs.ThrowNotFound(nil, "COMMAND-1d6Gg", "Errors.User.NotFound") + _, err = c.eventstore.PushEvents(ctx, event) + if err != nil { + return nil, err + } + 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) { + err := c.eventstore.FilterToQueryReducer(ctx, userWriteModel) + if err != nil { + return nil, nil, err + } + if !isUserStateExists(userWriteModel.UserState) { + return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-1d6Gg", "Errors.User.NotFound") } audience = domain.AddAudScopeToAudience(audience, scopes) preferredLanguage := "" - existingHuman, err := c.getHumanWriteModelByID(ctx, userID, orgID) + existingHuman, err := c.getHumanWriteModelByID(ctx, userWriteModel.AggregateID, userWriteModel.ResourceOwner) if existingHuman != nil { preferredLanguage = existingHuman.PreferredLanguage.String() } expiration := time.Now().UTC().Add(lifetime) tokenID, err := c.idGenerator.Next() if err != nil { - return nil, err + return nil, nil, err } - userAgg := UserAggregateFromWriteModel(&existingUser.WriteModel) - _, err = c.eventstore.PushEvents(ctx, - user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, audience, scopes, expiration)) - if err != nil { - return nil, err - } - - return &domain.Token{ - ObjectRoot: models.ObjectRoot{ - AggregateID: userID, - }, - TokenID: tokenID, - UserAgentID: agentID, - ApplicationID: clientID, - Audience: audience, - Scopes: scopes, - Expiration: expiration, - PreferredLanguage: preferredLanguage, - }, nil + userAgg := UserAggregateFromWriteModel(&userWriteModel.WriteModel) + return user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, audience, scopes, expiration), + &domain.Token{ + ObjectRoot: models.ObjectRoot{ + AggregateID: userWriteModel.AggregateID, + }, + TokenID: tokenID, + UserAgentID: agentID, + ApplicationID: clientID, + Audience: audience, + Scopes: scopes, + Expiration: expiration, + PreferredLanguage: preferredLanguage, + }, nil } func (c *Commands) userDomainClaimed(ctx context.Context, userID string) (events []eventstore.EventPusher, _ *UserWriteModel, err error) { diff --git a/internal/command/user_human_refresh_token.go b/internal/command/user_human_refresh_token.go new file mode 100644 index 0000000000..5fd678cd9e --- /dev/null +++ b/internal/command/user_human_refresh_token.go @@ -0,0 +1,139 @@ +package command + +import ( + "context" + "time" + + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "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) { + userWriteModel := NewUserWriteModel(userID, orgID) + accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, accessLifetime) + if err != nil { + return nil, "", err + } + + creator := func() (eventstore.EventPusher, string, error) { + return c.addRefreshToken(ctx, accessToken, authMethodsReferences, authTime, refreshIdleExpiration, refreshExpiration) + } + if refreshToken != "" { + creator = func() (eventstore.EventPusher, string, error) { + return c.renewRefreshToken(ctx, userID, orgID, refreshToken, refreshIdleExpiration) + } + } + refreshTokenEvent, token, err := creator() + if err != nil { + return nil, "", err + } + _, err = c.eventstore.PushEvents(ctx, accessTokenEvent, refreshTokenEvent) + if err != nil { + return nil, "", err + } + return accessToken, token, 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() + if err != nil { + return nil, "", err + } + refreshToken, err := domain.NewRefreshToken(accessToken.AggregateID, tokenID, c.keyAlgorithm) + 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) + 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 +} + +func (c *Commands) RevokeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*domain.ObjectDetails, error) { + removeEvent, refreshTokenWriteModel, err := c.removeRefreshToken(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(refreshTokenWriteModel, events...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&refreshTokenWriteModel.WriteModel), nil +} + +func (c *Commands) RevokeRefreshTokens(ctx context.Context, userID, orgID string, tokenIDs []string) (err error) { + if len(tokenIDs) == 0 { + return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Gfj42", "Errors.IDMissing") + } + events := make([]eventstore.EventPusher, len(tokenIDs)) + for i, tokenID := range tokenIDs { + event, _, err := c.removeRefreshToken(ctx, userID, orgID, tokenID) + if err != nil { + return err + } + events[i] = event + } + _, err = c.eventstore.PushEvents(ctx, events...) + return err +} + +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") + } + refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(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-BHt2w", "Errors.User.RefreshToken.NotFound") + } + userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel) + return user.NewHumanRefreshTokenRemovedEvent(ctx, userAgg, tokenID), refreshTokenWriteModel, nil +} diff --git a/internal/command/user_human_refresh_token_model.go b/internal/command/user_human_refresh_token_model.go new file mode 100644 index 0000000000..4992977a85 --- /dev/null +++ b/internal/command/user_human_refresh_token_model.go @@ -0,0 +1,89 @@ +package command + +import ( + "time" + + "github.com/caos/zitadel/internal/eventstore" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/repository/user" +) + +type HumanRefreshTokenWriteModel struct { + eventstore.WriteModel + + TokenID string + RefreshToken string + + UserState domain.UserState + IdleExpiration time.Time + Expiration time.Time +} + +func NewHumanRefreshTokenWriteModel(userID, resourceOwner, tokenID string) *HumanRefreshTokenWriteModel { + return &HumanRefreshTokenWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + TokenID: tokenID, + } +} + +func (wm *HumanRefreshTokenWriteModel) AppendEvents(events ...eventstore.EventReader) { + for _, event := range events { + switch e := event.(type) { + case *user.HumanRefreshTokenAddedEvent: + if wm.TokenID != e.TokenID { + continue + } + wm.WriteModel.AppendEvents(e) + case *user.HumanRefreshTokenRenewedEvent: + if wm.TokenID != e.TokenID { + continue + } + wm.WriteModel.AppendEvents(e) + case *user.HumanRefreshTokenRemovedEvent: + if wm.TokenID != e.TokenID { + continue + } + wm.WriteModel.AppendEvents(e) + } + } +} + +func (wm *HumanRefreshTokenWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.HumanRefreshTokenAddedEvent: + wm.TokenID = e.TokenID + wm.RefreshToken = e.TokenID + wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration) + wm.Expiration = e.CreationDate().Add(e.Expiration) + wm.UserState = domain.UserStateActive + case *user.HumanRefreshTokenRenewedEvent: + if wm.UserState == domain.UserStateActive { + wm.RefreshToken = e.RefreshToken + } + wm.RefreshToken = e.RefreshToken + wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration) + case *user.HumanRefreshTokenRemovedEvent: + wm.UserState = domain.UserStateDeleted + } + } + return wm.WriteModel.Reduce() +} + +func (wm *HumanRefreshTokenWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent, user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + user.HumanRefreshTokenAddedType, + user.HumanRefreshTokenRenewedType, + user.HumanRefreshTokenRemovedType, + user.UserRemovedType) + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} diff --git a/internal/command/user_human_refresh_token_test.go b/internal/command/user_human_refresh_token_test.go new file mode 100644 index 0000000000..9e93596ad3 --- /dev/null +++ b/internal/command/user_human_refresh_token_test.go @@ -0,0 +1,1103 @@ +package command + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "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" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/id" + id_mock "github.com/caos/zitadel/internal/id/mock" + "github.com/caos/zitadel/internal/repository/user" +) + +func TestCommands_AddAccessAndRefreshToken(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + iamDomain string + keyAlgorithm crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + orgID string + agentID string + clientID string + userID string + refreshToken string + audience []string + scopes []string + authMethodsReferences []string + lifetime time.Duration + authTime time.Time + refreshIdleExpiration time.Duration + refreshExpiration time.Duration + } + type res struct { + token *domain.Token + refreshToken string + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "error access token, error", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args: args{}, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "add refresh token, user inactive, error", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args: args{}, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + 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"), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + refreshToken: "invalid", + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + 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"), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + orgID: "orgID", + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID2:tokenID:token")), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + 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(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "applicationID", + "userAgentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 24*time.Hour, + )), + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + )), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + orgID: "orgID", + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:token")), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + 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(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "applicationID", + "userAgentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + -1*time.Hour, + 24*time.Hour, + )), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + orgID: "orgID", + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + //fails because of timestamp equality + //{ + // name: "push failed, 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( + // eventFromEventPusherWithCreationDateNow(user.NewHumanAddedEvent( + // context.Background(), + // &user.NewAggregate("userID", "orgID").Aggregate, + // "username", + // "firstname", + // "lastname", + // "nickname", + // "displayname", + // language.German, + // domain.GenderUnspecified, + // "email", + // true, + // )), + // ), + // expectFilter( + // eventFromEventPusherWithCreationDateNow(user.NewHumanRefreshTokenAddedEvent( + // context.Background(), + // &user.NewAggregate("userID", "orgID").Aggregate, + // "tokenID", + // "applicationID", + // "userAgentID", + // "de", + // []string{"clientID1"}, + // []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + // []string{"password"}, + // time.Now(), + // 1*time.Hour, + // 24*time.Hour, + // )), + // ), + // expectPushFailed( + // caos_errs.ThrowInternal(nil, "ERROR", "internal"), + // []*repository.Event{ + // eventFromEventPusher(user.NewUserTokenAddedEvent( + // context.Background(), + // &user.NewAggregate("userID", "orgID").Aggregate, + // "accessTokenID1", + // "clientID", + // "agentID", + // "de", + // []string{"clientID1"}, + // []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + // time.Now().Add(5*time.Minute), + // )), + // eventFromEventPusher(user.NewHumanRefreshTokenRenewedEvent( + // context.Background(), + // &user.NewAggregate("userID", "orgID").Aggregate, + // "tokenID", + // "refreshToken1", + // 1*time.Hour, + // )), + // }, + // ), + // ), + // idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1", "refreshToken1"), + // keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + // }, + // args: args{ + // ctx: context.Background(), + // orgID: "orgID", + // agentID: "agentID", + // clientID: "clientID", + // userID: "userID", + // refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), + // audience: []string{"clientID1"}, + // scopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + // authMethodsReferences: []string{"password"}, + // lifetime: 5 * time.Minute, + // authTime: time.Now(), + // }, + // res: res{ + // err: caos_errs.IsInternal, + // }, + //}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + iamDomain: tt.fields.iamDomain, + keyAlgorithm: tt.fields.keyAlgorithm, + } + got, gotRefresh, err := c.AddAccessAndRefreshToken(tt.args.ctx, tt.args.orgID, tt.args.agentID, tt.args.clientID, tt.args.userID, tt.args.refreshToken, + tt.args.audience, tt.args.scopes, tt.args.authMethodsReferences, tt.args.lifetime, tt.args.refreshIdleExpiration, tt.args.refreshExpiration, tt.args.authTime) + 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.token, got) + assert.Equal(t, tt.res.refreshToken, gotRefresh) + } + }) + } +} + +func TestCommands_RevokeRefreshToken(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 + }{ + { + "missing param, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{}, + res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + "token not active, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + "tokenID", + }, + res{ + err: caos_errs.IsNotFound, + }, + }, + { + "push failed, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "clientID", + "agentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 10*time.Hour, + )), + ), + expectPushFailed(caos_errs.ThrowInternal(nil, "ERROR", "internal"), + []*repository.Event{ + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + )), + }, + ), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + "tokenID", + }, + res{ + err: caos_errs.IsInternal, + }, + }, + { + "revoke, ok", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "clientID", + "agentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 10*time.Hour, + )), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + )), + }, + ), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + "tokenID", + }, + res{ + want: &domain.ObjectDetails{ + ResourceOwner: "orgID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := c.RevokeRefreshToken(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 TestCommands_RevokeRefreshTokens(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + orgID string + tokenIDs []string + } + type res struct { + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "missing tokenIDs, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + context.Background(), + "userID", + "orgID", + nil, + }, + res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + "one token not active, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "clientID", + "agentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 10*time.Hour, + )), + ), + expectFilter(), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + []string{"tokenID", "tokenID2"}, + }, + res{ + err: caos_errs.IsNotFound, + }, + }, + { + "push failed, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "clientID", + "agentID", + "de", + []string{"clientID"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 10*time.Hour, + )), + ), + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID2", + "clientID2", + "agentID", + "de", + []string{"clientID2"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 10*time.Hour, + )), + ), + expectPushFailed(caos_errs.ThrowInternal(nil, "ERROR", "internal"), + []*repository.Event{ + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + )), + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID2", + )), + }, + ), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + []string{"tokenID", "tokenID2"}, + }, + res{ + err: caos_errs.IsInternal, + }, + }, + { + "revoke, ok", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "clientID", + "agentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 10*time.Hour, + )), + ), + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID2", + "clientID2", + "agentID", + "de", + []string{"clientID2"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 10*time.Hour, + )), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + )), + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID2", + )), + }, + ), + ), + }, + args{ + context.Background(), + "userID", + "orgID", + []string{"tokenID", "tokenID2"}, + }, + res{ + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + err := c.RevokeRefreshTokens(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.tokenIDs) + 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) + } + }) + } +} + +func refreshTokenEncryptionAlgorithm(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mCrypto := crypto.NewMockEncryptionAlgorithm(ctrl) + mCrypto.EXPECT().Algorithm().AnyTimes().Return("enc") + mCrypto.EXPECT().EncryptionKeyID().AnyTimes().Return("id") + mCrypto.EXPECT().Encrypt(gomock.Any()).AnyTimes().DoAndReturn( + func(refrehToken []byte) ([]byte, error) { + return refrehToken, nil + }, + ) + mCrypto.EXPECT().Decrypt(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(refrehToken []byte, keyID string) ([]byte, error) { + if keyID != "id" { + return nil, caos_errs.ThrowInternal(nil, "id", "invalid key id") + } + return refrehToken, nil + }, + ) + return mCrypto +} + +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 { + ctx context.Context + accessToken *domain.Token + authMethodsReferences []string + authTime time.Time + idleExpiration time.Duration + expiration time.Duration + } + type res struct { + event *user.HumanRefreshTokenAddedEvent + refreshToken string + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + + { + name: "add refresh Token", + fields: fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "refreshTokenID"), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + accessToken: &domain.Token{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "userID", + ResourceOwner: "org1", + }, + TokenID: "accessTokenID1", + ApplicationID: "clientID", + UserAgentID: "agentID", + Audience: []string{"clientID1"}, + Expiration: time.Now().Add(5 * time.Minute), + Scopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + PreferredLanguage: "de", + }, + authMethodsReferences: []string{"password"}, + authTime: authTime, + idleExpiration: 1 * time.Hour, + expiration: 10 * time.Hour, + }, + res: res{ + event: user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "refreshTokenID", + "clientID", + "agentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + authTime, + 1*time.Hour, + 10*time.Hour, + ), + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:refreshTokenID:refreshTokenID")), + }, + }, + } + for _, tt := range tests { + 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) + 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.event, gotEvent) + assert.Equal(t, tt.res.refreshToken, gotRefreshToken) + } + }) + } +} + +func TestCommands_renewRefreshToken(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + keyAlgorithm crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + userID string + orgID string + refreshToken string + idleExpiration time.Duration + } + type res struct { + event *user.HumanRefreshTokenRenewedEvent + newRefreshToken string + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "empty token, error", + fields: fields{ + eventstore: eventstoreExpect(t), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "invalid token, error", + fields: fields{ + eventstore: eventstoreExpect(t), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + refreshToken: "invalid", + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "invalid token (invalid userID), error", + fields: fields{ + eventstore: eventstoreExpect(t), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + orgID: "orgID", + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID2:tokenID:token")), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "token inactive, error", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "applicationID", + "userAgentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 24*time.Hour, + )), + eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + )), + ), + ), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + orgID: "orgID", + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:token")), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "token expired, error", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "applicationID", + "userAgentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 24*time.Hour, + )), + ), + ), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + orgID: "orgID", + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "token renewed, ok", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusherWithCreationDateNow(user.NewHumanRefreshTokenAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "applicationID", + "userAgentID", + "de", + []string{"clientID1"}, + []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess}, + []string{"password"}, + time.Now(), + 1*time.Hour, + 24*time.Hour, + )), + ), + ), + keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "refreshToken1"), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + orgID: "orgID", + refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")), + idleExpiration: 1 * time.Hour, + }, + res: res{ + event: user.NewHumanRefreshTokenRenewedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + "refreshToken1", + 1*time.Hour, + ), + newRefreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:refreshToken1")), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + 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) + 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.event, gotEvent) + assert.Equal(t, tt.res.newRefreshToken, gotNewRefreshToken) + } + }) + } +} diff --git a/internal/domain/refresh_token.go b/internal/domain/refresh_token.go new file mode 100644 index 0000000000..5a0d203015 --- /dev/null +++ b/internal/domain/refresh_token.go @@ -0,0 +1,37 @@ +package domain + +import ( + "encoding/base64" + "strings" + + "github.com/caos/zitadel/internal/crypto" + caos_errors "github.com/caos/zitadel/internal/errors" +) + +func NewRefreshToken(userID, tokenID string, algorithm crypto.EncryptionAlgorithm) (string, error) { + return RefreshToken(userID, tokenID, tokenID, algorithm) +} + +func RefreshToken(userID, tokenID, token string, algorithm crypto.EncryptionAlgorithm) (string, error) { + encrypted, err := algorithm.Encrypt([]byte(userID + ":" + tokenID + ":" + token)) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(encrypted), nil +} + +func FromRefreshToken(refreshToken string, algorithm crypto.EncryptionAlgorithm) (userID, tokenID, token string, err error) { + decoded, err := base64.RawURLEncoding.DecodeString(refreshToken) + if err != nil { + return "", "", "", err + } + decrypted, err := algorithm.Decrypt(decoded, algorithm.EncryptionKeyID()) + if err != nil { + return "", "", "", err + } + split := strings.Split(string(decrypted), ":") + if len(split) != 3 { + return "", "", "", caos_errors.ThrowInternal(nil, "DOMAIN-BGDhn", "Errors.User.RefreshToken.Invalid") + } + return split[0], split[1], split[2], nil +} diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index c95d98b371..64c54302ac 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -94,6 +94,9 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(HumanPasswordlessTokenBeginLoginType, HumanPasswordlessBeginLoginEventMapper). RegisterFilterEventMapper(HumanPasswordlessTokenCheckSucceededType, HumanPasswordlessCheckSucceededEventMapper). RegisterFilterEventMapper(HumanPasswordlessTokenCheckFailedType, HumanPasswordlessCheckFailedEventMapper). + RegisterFilterEventMapper(HumanRefreshTokenAddedType, HumanRefreshTokenAddedEventMapper). + RegisterFilterEventMapper(HumanRefreshTokenRenewedType, HumanRefreshTokenRenewedEventEventMapper). + RegisterFilterEventMapper(HumanRefreshTokenRemovedType, HumanRefreshTokenRemovedEventEventMapper). RegisterFilterEventMapper(MachineAddedEventType, MachineAddedEventMapper). RegisterFilterEventMapper(MachineChangedEventType, MachineChangedEventMapper). RegisterFilterEventMapper(MachineKeyAddedEventType, MachineKeyAddedEventMapper). diff --git a/internal/repository/user/human_refresh_token.go b/internal/repository/user/human_refresh_token.go new file mode 100644 index 0000000000..7c4922dabf --- /dev/null +++ b/internal/repository/user/human_refresh_token.go @@ -0,0 +1,187 @@ +package user + +import ( + "context" + "encoding/json" + "time" + + "github.com/caos/zitadel/internal/eventstore" + + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/repository" +) + +const ( + refreshTokenEventPrefix = humanEventPrefix + "refresh.token." + HumanRefreshTokenAddedType = refreshTokenEventPrefix + "added" + HumanRefreshTokenRenewedType = refreshTokenEventPrefix + "renewed" + HumanRefreshTokenRemovedType = refreshTokenEventPrefix + "removed" +) + +type HumanRefreshTokenAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenId"` + ClientID string `json:"clientId"` + UserAgentID string `json:"userAgentId"` + Audience []string `json:"audience"` + Scopes []string `json:"scopes"` + AuthMethodsReferences []string `json:"authMethodReferences"` + AuthTime time.Time `json:"authTime"` + IdleExpiration time.Duration `json:"idleExpiration"` + Expiration time.Duration `json:"expiration"` + PreferredLanguage string `json:"preferredLanguage"` +} + +func (e *HumanRefreshTokenAddedEvent) Data() interface{} { + return e +} + +func (e *HumanRefreshTokenAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanRefreshTokenAddedEvent) Assets() []*eventstore.Asset { + return nil +} + +func NewHumanRefreshTokenAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID, + clientID, + userAgentID, + preferredLanguage string, + audience, + scopes, + authMethodsReferences []string, + authTime time.Time, + idleExpiration, + expiration time.Duration, +) *HumanRefreshTokenAddedEvent { + return &HumanRefreshTokenAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanRefreshTokenAddedType, + ), + TokenID: tokenID, + ClientID: clientID, + UserAgentID: userAgentID, + Audience: audience, + Scopes: scopes, + AuthMethodsReferences: authMethodsReferences, + AuthTime: authTime, + IdleExpiration: idleExpiration, + Expiration: expiration, + PreferredLanguage: preferredLanguage, + } +} + +func HumanRefreshTokenAddedEventMapper(event *repository.Event) (eventstore.EventReader, error) { + refreshTokenAdded := &HumanRefreshTokenAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, refreshTokenAdded) + if err != nil { + return nil, errors.ThrowInternal(err, "USER-DGr14", "unable to unmarshal refresh token added") + } + + return refreshTokenAdded, nil +} + +type HumanRefreshTokenRenewedEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenId"` + RefreshToken string `json:"refreshToken"` + IdleExpiration time.Duration `json:"idleExpiration"` +} + +func (e *HumanRefreshTokenRenewedEvent) Data() interface{} { + return e +} + +func (e *HumanRefreshTokenRenewedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanRefreshTokenRenewedEvent) Assets() []*eventstore.Asset { + return nil +} + +func NewHumanRefreshTokenRenewedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID, + refreshToken string, + idleExpiration time.Duration, +) *HumanRefreshTokenRenewedEvent { + return &HumanRefreshTokenRenewedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanRefreshTokenRenewedType, + ), + TokenID: tokenID, + IdleExpiration: idleExpiration, + RefreshToken: refreshToken, + } +} + +func HumanRefreshTokenRenewedEventEventMapper(event *repository.Event) (eventstore.EventReader, error) { + tokenAdded := &HumanRefreshTokenRenewedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, tokenAdded) + if err != nil { + return nil, errors.ThrowInternal(err, "USER-GBt21", "unable to unmarshal refresh token renewed") + } + + return tokenAdded, nil +} + +type HumanRefreshTokenRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenId"` +} + +func (e *HumanRefreshTokenRemovedEvent) Data() interface{} { + return e +} + +func (e *HumanRefreshTokenRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanRefreshTokenRemovedEvent) Assets() []*eventstore.Asset { + return nil +} + +func NewHumanRefreshTokenRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID string, +) *HumanRefreshTokenRemovedEvent { + return &HumanRefreshTokenRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanRefreshTokenRemovedType, + ), + TokenID: tokenID, + } +} + +func HumanRefreshTokenRemovedEventEventMapper(event *repository.Event) (eventstore.EventReader, error) { + tokenAdded := &HumanRefreshTokenRemovedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, tokenAdded) + if err != nil { + return nil, errors.ThrowInternal(err, "USER-Dggs2", "unable to unmarshal refresh token removed") + } + + return tokenAdded, nil +} diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go index a7a6c8d14d..5fecdfe2a7 100644 --- a/internal/repository/user/user.go +++ b/internal/repository/user/user.go @@ -3,9 +3,10 @@ package user import ( "context" "encoding/json" - "github.com/caos/zitadel/internal/eventstore" "time" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/eventstore/repository" ) @@ -202,7 +203,7 @@ type UserTokenAddedEvent struct { ApplicationID string `json:"applicationId"` UserAgentID string `json:"userAgentId"` Audience []string `json:"audience"` - Scopes []string `json:"scopes""` + Scopes []string `json:"scopes"` Expiration time.Time `json:"expiration"` PreferredLanguage string `json:"preferredLanguage"` } diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 72971c6432..1a987aa178 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -108,6 +108,9 @@ Errors: BeginLoginFailed: Es ist ein Fehler beim WebAuthN Login aufgetreten ValidateLoginFailed: Zugangsdaten konnten nicht validiert werden CloneWarning: Authentifizierungsdaten wurden möglicherweise geklont + RefreshToken: + Invalid: Refresh Token ist ungültig + NotFound: Refresh Token nicht gefunden Org: AlreadyExist: Organisationsname existiert bereits Invalid: Organisation ist ungültig diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 0e8b3eb332..2f2015c520 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -108,6 +108,9 @@ Errors: BeginLoginFailed: WebAuthN begin login failed ValidateLoginFailed: Error on validate login credentials CloneWarning: Credentials may be cloned + RefreshToken: + Invalid: Refresh Token is invalid + NotFound: Refresh Token not found Org: AlreadyExist: Organisationname already taken Invalid: Organisation is invalid diff --git a/internal/user/model/refresh_token.go b/internal/user/model/refresh_token.go new file mode 100644 index 0000000000..f7277049f5 --- /dev/null +++ b/internal/user/model/refresh_token.go @@ -0,0 +1,18 @@ +package model + +import ( + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" + "time" +) + +type RefreshToken struct { + es_models.ObjectRoot + + TokenID string + ApplicationID string + UserAgentID string + Audience []string + Expiration time.Time + Scopes []string + PreferredLanguage string +} diff --git a/internal/user/model/refresh_token_view.go b/internal/user/model/refresh_token_view.go new file mode 100644 index 0000000000..76688fe8de --- /dev/null +++ b/internal/user/model/refresh_token_view.go @@ -0,0 +1,71 @@ +package model + +import ( + "github.com/caos/zitadel/internal/domain" + caos_errors "github.com/caos/zitadel/internal/errors" + + "time" +) + +type RefreshTokenView struct { + ID string + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + UserID string + ClientID string + UserAgentID string + AuthMethodsReferences []string + Audience []string + AuthTime time.Time + IdleExpiration time.Time + Expiration time.Time + Scopes []string + Sequence uint64 + Token string +} + +type RefreshTokenSearchRequest struct { + Offset uint64 + Limit uint64 + SortingColumn RefreshTokenSearchKey + Asc bool + Queries []*RefreshTokenSearchQuery +} + +type RefreshTokenSearchKey int32 + +const ( + RefreshTokenSearchKeyUnspecified RefreshTokenSearchKey = iota + RefreshTokenSearchKeyRefreshTokenID + RefreshTokenSearchKeyUserID + RefreshTokenSearchKeyApplicationID + RefreshTokenSearchKeyUserAgentID + RefreshTokenSearchKeyExpiration + RefreshTokenSearchKeyResourceOwner +) + +type RefreshTokenSearchQuery struct { + Key RefreshTokenSearchKey + Method domain.SearchMethod + Value interface{} +} + +type RefreshTokenSearchResponse struct { + Offset uint64 + Limit uint64 + TotalResult uint64 + Sequence uint64 + Timestamp time.Time + Result []*RefreshTokenView +} + +func (r *RefreshTokenSearchRequest) EnsureLimit(limit uint64) error { + if r.Limit > limit { + return caos_errors.ThrowInvalidArgument(nil, "SEARCH-M0fse", "Errors.Limit.ExceedsDefault") + } + if r.Limit == 0 { + r.Limit = limit + } + return nil +} diff --git a/internal/user/repository/view/model/refresh_token.go b/internal/user/repository/view/model/refresh_token.go new file mode 100644 index 0000000000..beed817751 --- /dev/null +++ b/internal/user/repository/view/model/refresh_token.go @@ -0,0 +1,154 @@ +package model + +import ( + "encoding/json" + "time" + + "github.com/caos/logging" + "github.com/lib/pq" + + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + 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" +) + +const ( + RefreshTokenKeyTokenID = "id" + RefreshTokenKeyUserID = "user_id" + RefreshTokenKeyApplicationID = "application_id" + RefreshTokenKeyUserAgentID = "user_agent_id" + RefreshTokenKeyExpiration = "expiration" + RefreshTokenKeyResourceOwner = "resource_owner" +) + +type RefreshTokenView struct { + ID string `json:"tokenId" gorm:"column:id"` + CreationDate time.Time `json:"-" gorm:"column:creation_date"` + ChangeDate time.Time `json:"-" gorm:"column:change_date"` + ResourceOwner string `json:"-" gorm:"column:resource_owner"` + Token string `json:"-" gorm:"column:token"` + UserID string `json:"-" gorm:"column:user_id;primary_key"` + ClientID string `json:"clientID" gorm:"column:client_id;primary_key"` + UserAgentID string `json:"userAgentId" gorm:"column:user_agent_id;primary_key"` + Audience pq.StringArray `json:"audience" gorm:"column:audience"` + Scopes pq.StringArray `json:"scopes" gorm:"column:scopes"` + AuthMethodsReferences pq.StringArray `json:"authMethodsReference" gorm:"column:amr"` + AuthTime time.Time `json:"authTime" gorm:"column:auth_time"` + IdleExpiration time.Time `json:"-" gorm:"column:idle_expiration"` + Expiration time.Time `json:"-" gorm:"column:expiration"` + Sequence uint64 `json:"-" gorm:"column:sequence"` +} + +func RefreshTokenViewsToModel(tokens []*RefreshTokenView) []*usr_model.RefreshTokenView { + result := make([]*usr_model.RefreshTokenView, len(tokens)) + for i, g := range tokens { + result[i] = RefreshTokenViewToModel(g) + } + return result +} + +func RefreshTokenViewToModel(token *RefreshTokenView) *usr_model.RefreshTokenView { + return &usr_model.RefreshTokenView{ + ID: token.ID, + CreationDate: token.CreationDate, + ChangeDate: token.ChangeDate, + ResourceOwner: token.ResourceOwner, + Token: token.Token, + UserID: token.UserID, + ClientID: token.ClientID, + UserAgentID: token.UserAgentID, + Audience: token.Audience, + Scopes: token.Scopes, + AuthMethodsReferences: token.AuthMethodsReferences, + AuthTime: token.AuthTime, + IdleExpiration: token.IdleExpiration, + Expiration: token.Expiration, + Sequence: token.Sequence, + } +} + +func (t *RefreshTokenView) AppendEventIfMyRefreshToken(event *es_models.Event) (err error) { + view := new(RefreshTokenView) + switch eventstore.EventType(event.Type) { + case user_repo.HumanRefreshTokenAddedType: + view.setRootData(event) + err = view.appendAddedEvent(event) + if err != nil { + return err + } + case user_repo.HumanRefreshTokenRenewedType: + view.setRootData(event) + err = view.appendRenewedEvent(event) + if err != nil { + return err + } + case user_repo.HumanRefreshTokenRemovedType, + user_repo.UserRemovedType, + user_repo.UserDeactivatedType, + user_repo.UserLockedType: + view.appendRemovedEvent(event) + default: + return nil + } + if view.ID == t.ID { + return t.AppendEvent(event) + } + return nil +} + +func (t *RefreshTokenView) AppendEvent(event *es_models.Event) error { + t.ChangeDate = event.CreationDate + t.Sequence = event.Sequence + switch eventstore.EventType(event.Type) { + case user_repo.HumanRefreshTokenAddedType: + t.setRootData(event) + return t.appendAddedEvent(event) + case user_repo.HumanRefreshTokenRenewedType: + t.setRootData(event) + return t.appendRenewedEvent(event) + } + return nil +} + +func (t *RefreshTokenView) setRootData(event *es_models.Event) { + t.UserID = event.AggregateID + t.ResourceOwner = event.ResourceOwner +} + +func (t *RefreshTokenView) appendAddedEvent(event *es_models.Event) error { + e := new(user_repo.HumanRefreshTokenAddedEvent) + if err := json.Unmarshal(event.Data, e); err != nil { + logging.Log("EVEN-Dbb31").WithError(err).Error("could not unmarshal event data") + return caos_errs.ThrowInternal(err, "MODEL-Bbr42", "could not unmarshal event") + } + t.ID = e.TokenID + t.CreationDate = event.CreationDate + t.AuthMethodsReferences = e.AuthMethodsReferences + t.AuthTime = e.AuthTime + t.Audience = e.Audience + t.ClientID = e.ClientID + t.Expiration = event.CreationDate.Add(e.Expiration) + t.IdleExpiration = event.CreationDate.Add(e.IdleExpiration) + t.Scopes = e.Scopes + t.Token = e.TokenID + t.UserAgentID = e.UserAgentID + return nil +} + +func (t *RefreshTokenView) appendRenewedEvent(event *es_models.Event) error { + e := new(user_repo.HumanRefreshTokenRenewedEvent) + if err := json.Unmarshal(event.Data, e); err != nil { + logging.Log("EVEN-Vbbn2").WithError(err).Error("could not unmarshal event data") + return caos_errs.ThrowInternal(err, "MODEL-Bbrn4", "could not unmarshal event") + } + t.ID = e.TokenID + t.IdleExpiration = event.CreationDate.Add(e.IdleExpiration) + t.Token = e.RefreshToken + return nil +} + +func (t *RefreshTokenView) appendRemovedEvent(event *es_models.Event) { + t.Expiration = event.CreationDate +} diff --git a/internal/user/repository/view/model/refresh_token_query.go b/internal/user/repository/view/model/refresh_token_query.go new file mode 100644 index 0000000000..0ac1d439db --- /dev/null +++ b/internal/user/repository/view/model/refresh_token_query.go @@ -0,0 +1,69 @@ +package model + +import ( + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/user/model" + "github.com/caos/zitadel/internal/view/repository" +) + +type RefreshTokenSearchRequest model.RefreshTokenSearchRequest +type RefreshTokenSearchQuery model.RefreshTokenSearchQuery +type RefreshTokenSearchKey model.RefreshTokenSearchKey + +func (req RefreshTokenSearchRequest) GetLimit() uint64 { + return req.Limit +} + +func (req RefreshTokenSearchRequest) GetOffset() uint64 { + return req.Offset +} + +func (req RefreshTokenSearchRequest) GetSortingColumn() repository.ColumnKey { + if req.SortingColumn == model.RefreshTokenSearchKeyUnspecified { + return nil + } + return RefreshTokenSearchKey(req.SortingColumn) +} + +func (req RefreshTokenSearchRequest) GetAsc() bool { + return req.Asc +} + +func (req RefreshTokenSearchRequest) GetQueries() []repository.SearchQuery { + result := make([]repository.SearchQuery, len(req.Queries)) + for i, q := range req.Queries { + result[i] = RefreshTokenSearchQuery{Key: q.Key, Value: q.Value, Method: q.Method} + } + return result +} + +func (req RefreshTokenSearchQuery) GetKey() repository.ColumnKey { + return RefreshTokenSearchKey(req.Key) +} + +func (req RefreshTokenSearchQuery) GetMethod() domain.SearchMethod { + return req.Method +} + +func (req RefreshTokenSearchQuery) GetValue() interface{} { + return req.Value +} + +func (key RefreshTokenSearchKey) ToColumnName() string { + switch model.RefreshTokenSearchKey(key) { + case model.RefreshTokenSearchKeyRefreshTokenID: + return RefreshTokenKeyTokenID + case model.RefreshTokenSearchKeyUserAgentID: + return RefreshTokenKeyUserAgentID + case model.RefreshTokenSearchKeyUserID: + return RefreshTokenKeyUserID + case model.RefreshTokenSearchKeyApplicationID: + return RefreshTokenKeyApplicationID + case model.RefreshTokenSearchKeyExpiration: + return RefreshTokenKeyExpiration + case model.RefreshTokenSearchKeyResourceOwner: + return RefreshTokenKeyResourceOwner + default: + return "" + } +} diff --git a/internal/user/repository/view/refresh_token_view.go b/internal/user/repository/view/refresh_token_view.go new file mode 100644 index 0000000000..ca342e87c0 --- /dev/null +++ b/internal/user/repository/view/refresh_token_view.go @@ -0,0 +1,79 @@ +package view + +import ( + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/user/model" + usr_model "github.com/caos/zitadel/internal/user/repository/view/model" + "github.com/caos/zitadel/internal/view/repository" + "github.com/jinzhu/gorm" + "github.com/lib/pq" +) + +func RefreshTokenByID(db *gorm.DB, table, tokenID string) (*usr_model.RefreshTokenView, error) { + token := new(usr_model.RefreshTokenView) + query := repository.PrepareGetByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyRefreshTokenID), tokenID) + err := query(db, token) + if errors.IsNotFound(err) { + return nil, errors.ThrowNotFound(nil, "VIEW-6ub3p", "Errors.RefreshToken.NotFound") + } + return token, err +} + +func RefreshTokensByUserID(db *gorm.DB, table, userID string) ([]*usr_model.RefreshTokenView, error) { + tokens := make([]*usr_model.RefreshTokenView, 0) + userIDQuery := &model.RefreshTokenSearchQuery{ + Key: model.RefreshTokenSearchKeyUserID, + Method: domain.SearchMethodEquals, + Value: userID, + } + query := repository.PrepareSearchQuery(table, usr_model.RefreshTokenSearchRequest{ + Queries: []*model.RefreshTokenSearchQuery{userIDQuery}, + }) + _, err := query(db, &tokens) + return tokens, err +} + +func PutRefreshToken(db *gorm.DB, table string, token *usr_model.RefreshTokenView) error { + save := repository.PrepareSave(table) + return save(db, token) +} + +func PutRefreshTokens(db *gorm.DB, table string, tokens ...*usr_model.RefreshTokenView) error { + save := repository.PrepareBulkSave(table) + t := make([]interface{}, len(tokens)) + for i, token := range tokens { + t[i] = token + } + return save(db, t...) +} + +func SearchRefreshTokens(db *gorm.DB, table string, req *model.RefreshTokenSearchRequest) ([]*usr_model.RefreshTokenView, uint64, error) { + tokens := make([]*usr_model.RefreshTokenView, 0) + query := repository.PrepareSearchQuery(table, usr_model.RefreshTokenSearchRequest{Limit: req.Limit, Offset: req.Offset, Queries: req.Queries}) + count, err := query(db, &tokens) + return tokens, count, err +} + +func DeleteRefreshToken(db *gorm.DB, table, tokenID string) error { + delete := repository.PrepareDeleteByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyRefreshTokenID), tokenID) + return delete(db) +} + +func DeleteSessionRefreshTokens(db *gorm.DB, table, agentID, userID string) error { + delete := repository.PrepareDeleteByKeys(table, + repository.Key{Key: usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyUserAgentID), Value: agentID}, + repository.Key{Key: usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyUserID), Value: userID}, + ) + return delete(db) +} + +func DeleteUserRefreshTokens(db *gorm.DB, table, userID string) error { + delete := repository.PrepareDeleteByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyUserID), userID) + return delete(db) +} + +func DeleteApplicationRefreshTokens(db *gorm.DB, table string, appIDs []string) error { + delete := repository.PrepareDeleteByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyApplicationID), pq.StringArray(appIDs)) + return delete(db) +} diff --git a/migrations/cockroach/V1.43__refresh_tokens.sql b/migrations/cockroach/V1.43__refresh_tokens.sql new file mode 100644 index 0000000000..0227d12f30 --- /dev/null +++ b/migrations/cockroach/V1.43__refresh_tokens.sql @@ -0,0 +1,21 @@ +CREATE TABLE auth.refresh_tokens ( + id TEXT, + + creation_date TIMESTAMPTZ, + change_date TIMESTAMPTZ, + + resource_owner TEXT, + token TEXT, + client_id TEXT, + user_agent_id TEXT, + user_id TEXT, + auth_time TIMESTAMPTZ, + idle_expiration TIMESTAMPTZ, + expiration TIMESTAMPTZ, + sequence BIGINT, + scopes TEXT ARRAY, + audience TEXT ARRAY, + amr TEXT ARRAY, + + PRIMARY KEY (client_id, user_agent_id, user_id) +); diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index 749d07b9c9..b7329a3247 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -82,6 +82,39 @@ service AuthService { }; } + // Returns the refresh tokens of the authorized user + rpc ListMyRefreshTokens(ListMyRefreshTokensRequest) returns (ListMyRefreshTokensResponse) { + option (google.api.http) = { + post: "/users/me/tokens/refresh/_search" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + } + + // Revokes a single refresh token of the authorized user by its (token) id + rpc RevokeMyRefreshToken(RevokeMyRefreshTokenRequest) returns (RevokeMyRefreshTokenResponse) { + option (google.api.http) = { + delete: "/users/me/tokens/refresh/{id}" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + } + + // Revokes all refresh tokens of the authorized user + rpc RevokeAllMyRefreshTokens(RevokeAllMyRefreshTokensRequest) returns (RevokeAllMyRefreshTokensResponse) { + option (google.api.http) = { + post: "/users/me/tokens/refresh/_revoke_all" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + } + // Change the user name of the authorize user rpc UpdateMyUserName(UpdateMyUserNameRequest) returns (UpdateMyUserNameResponse) { option (google.api.http) = { @@ -489,6 +522,28 @@ message ListMyUserSessionsResponse { repeated zitadel.user.v1.Session result = 1; } +//This is an empty request +message ListMyRefreshTokensRequest {} + +message ListMyRefreshTokensResponse { + zitadel.v1.ListDetails details = 1; + repeated zitadel.user.v1.RefreshToken result = 2; +} + +message RevokeMyRefreshTokenRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message RevokeMyRefreshTokenResponse { + zitadel.v1.ObjectDetails details = 1; +} + +//This is an empty request +message RevokeAllMyRefreshTokensRequest {} + +//This is an empty response +message RevokeAllMyRefreshTokensResponse {} + message UpdateMyUserNameRequest { string user_name = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/user.proto b/proto/zitadel/user.proto index 7a6fe06c9c..6b6e7ee33c 100644 --- a/proto/zitadel/user.proto +++ b/proto/zitadel/user.proto @@ -2,6 +2,7 @@ syntax = "proto3"; import "zitadel/object.proto"; import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; @@ -516,6 +517,48 @@ enum SessionState { SESSION_STATE_TERMINATED = 2; } +message RefreshToken { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906489455\"" + } + ]; + zitadel.v1.ObjectDetails details = 2; + string client_id = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "oauth2/oidc client_id of the authorized application"; + } + ]; + google.protobuf.Timestamp auth_time = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the user authenticated, does not have to be the same time the token was created\"" + } + ]; + google.protobuf.Timestamp idle_expiration = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time the refresh token will expire if not used, the user will have to reauthenticate\"" + } + ]; + google.protobuf.Timestamp expiration = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time the refresh token will expire, the user will have to reauthenticate\"" + } + ]; + repeated string scopes = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"openid\",\"email\",\"profile\",\"offline_access\"]"; + description: "scopes of the initial auth request, access tokens created by this refresh token can have a subset of these scopes"; + } + ]; + repeated string audience = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"69629023906488334@ZITADEL\", \"69629023906481256\"]" + description: "audience of the initial auth request and of all access tokens created by this refresh token"; + } + ]; +} + message UserGrant { string id = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {