diff --git a/changelog/unreleased/tokenInfo-endpoint-replacement.md b/changelog/unreleased/tokenInfo-endpoint-replacement.md new file mode 100644 index 00000000000..a8d733abf13 --- /dev/null +++ b/changelog/unreleased/tokenInfo-endpoint-replacement.md @@ -0,0 +1,12 @@ +Enhancement: Replacement for TokenInfo Endpoint + + +The client should basically always send a PROPFIND to /dav/public-files/{sharetoken} + +* authenticated clients accessing an internal link are redirected to the "real" resource (`/dav/spaces/{target-resource-id} +* authenticated clients accessing a pubic link (password protected or not) for a resource they already have access to are also redirected to the "real" resource. (and always need to supply the password) +* unauthenticated clients accessing an internal link get a 401 returned with WWW-Authenticate set to Bearer (so that the client knows that it need to get a token via the IDP login page. +* unauthenticated clients accessing a password protected link get a 401 returned with WWW-Authenticate set to Basic to indicate the requirement for needing the link's password. + +https://github.com/cs3org/reva/pull/4653 +https://github.com/owncloud/ocis/issues/8858 diff --git a/internal/http/services/owncloud/ocdav/dav.go b/internal/http/services/owncloud/ocdav/dav.go index ec3fc85a3fe..23f62a84c90 100644 --- a/internal/http/services/owncloud/ocdav/dav.go +++ b/internal/http/services/owncloud/ocdav/dav.go @@ -20,6 +20,7 @@ package ocdav import ( "context" + "fmt" "net/http" "path" "path/filepath" @@ -28,20 +29,26 @@ import ( gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/v2/internal/http/services/owncloud/ocdav/config" "github.com/cs3org/reva/v2/internal/http/services/owncloud/ocdav/errors" "github.com/cs3org/reva/v2/internal/http/services/owncloud/ocdav/net" "github.com/cs3org/reva/v2/pkg/appctx" + "github.com/cs3org/reva/v2/pkg/conversions" ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/v2/pkg/rhttp/router" + "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/cs3org/reva/v2/pkg/utils" "google.golang.org/grpc/metadata" ) const ( _trashbinPath = "trash-bin" + + // WwwAuthenticate captures the Www-Authenticate header string. + WwwAuthenticate = "Www-Authenticate" ) // DavHandler routes to the different sub handlers @@ -269,13 +276,15 @@ func (h *DavHandler) Handler(s *svc) http.Handler { case res.Status.Code == rpc.Code_CODE_PERMISSION_DENIED: fallthrough case res.Status.Code == rpc.Code_CODE_UNAUTHENTICATED: - w.WriteHeader(http.StatusUnauthorized) if hasValidBasicAuthHeader { + w.WriteHeader(http.StatusUnauthorized) b, err := errors.Marshal(http.StatusUnauthorized, "Username or password was incorrect", "") errors.HandleWebdavError(log, w, b, err) return } - b, err := errors.Marshal(http.StatusUnauthorized, "No 'Authorization: Basic' header found", "") + w.Header().Add(WwwAuthenticate, fmt.Sprintf("Basic realm=\"%s\", charset=\"UTF-8\"", r.Host)) + w.WriteHeader(http.StatusUnauthorized) + b, err := errors.Marshal(http.StatusUnauthorized, "Authorization failed: Basic header found", "") errors.HandleWebdavError(log, w, b, err) return case res.Status.Code == rpc.Code_CODE_NOT_FOUND: @@ -286,14 +295,32 @@ func (h *DavHandler) Handler(s *svc) http.Handler { return } - ctx = ctxpkg.ContextSetToken(ctx, res.Token) - ctx = ctxpkg.ContextSetUser(ctx, res.User) - ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, res.Token) + // force the internal link + var isInternal bool + sRes, err := getInternalLinkStat(ctx, s.gatewaySelector, token) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if sRes.GetStatus().GetCode() == rpc.Code_CODE_UNAUTHENTICATED { + w.Header().Add(WwwAuthenticate, fmt.Sprintf("Bearer realm=\"%s\", charset=\"UTF-8\"", r.Host)) + w.WriteHeader(http.StatusUnauthorized) + b, err := errors.Marshal(http.StatusUnauthorized, "Authorization failed: Bearer header found", "") + errors.HandleWebdavError(log, w, b, err) + return + } + if sRes.GetStatus().GetCode() == rpc.Code_CODE_OK { + isInternal = true + } else { + ctx = ctxpkg.ContextSetToken(ctx, res.Token) + ctx = ctxpkg.ContextSetUser(ctx, res.User) + ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, res.Token) - r = r.WithContext(ctx) + r = r.WithContext(ctx) + // the public share manager knew the token, but does the referenced target still exist? + sRes, err = getTokenStatInfo(ctx, s.gatewaySelector, token) + } - // the public share manager knew the token, but does the referenced target still exist? - sRes, err := getTokenStatInfo(ctx, s.gatewaySelector, token) switch { case err != nil: log.Error().Err(err).Msg("error sending grpc stat request") @@ -316,12 +343,20 @@ func (h *DavHandler) Handler(s *svc) http.Handler { } log.Debug().Interface("statInfo", sRes.Info).Msg("Stat info from public link token path") - ctx := ContextWithTokenStatInfo(ctx, sRes.Info) - r = r.WithContext(ctx) - if sRes.Info.Type != provider.ResourceType_RESOURCE_TYPE_CONTAINER { - h.PublicFileHandler.Handler(s).ServeHTTP(w, r) + if isInternal { + base := path.Join(ctx.Value(net.CtxKeyBaseURI).(string), "spaces") + ctx := context.WithValue(ctx, net.CtxKeyBaseURI, base) + r = r.WithContext(ctx) + r.URL.Path = "/" + storagespace.FormatResourceID(*sRes.GetInfo().GetId()) + h.SpacesHandler.Handler(s, nil).ServeHTTP(w, r) } else { - h.PublicFolderHandler.Handler(s).ServeHTTP(w, r) + ctx := ContextWithTokenStatInfo(ctx, sRes.Info) + r = r.WithContext(ctx) + if sRes.Info.Type != provider.ResourceType_RESOURCE_TYPE_CONTAINER { + h.PublicFileHandler.Handler(s).ServeHTTP(w, r) + } else { + h.PublicFolderHandler.Handler(s).ServeHTTP(w, r) + } } default: @@ -332,6 +367,37 @@ func (h *DavHandler) Handler(s *svc) http.Handler { }) } +func getInternalLinkStat(ctx context.Context, selector pool.Selectable[gatewayv1beta1.GatewayAPIClient], token string) (*provider.StatResponse, error) { + client, err := selector.Next() + if err != nil { + return nil, err + } + psRes, err := client.GetPublicShare(ctx, &link.GetPublicShareRequest{ + Ref: &link.PublicShareReference{ + Spec: &link.PublicShareReference_Token{ + Token: token, + }, + }}) + if err != nil { + if strings.Contains(err.Error(), "core access token not found") { + return nil, nil + } + return nil, err + } + if psRes.Status.Code != rpc.Code_CODE_OK { + return nil, nil + } + + role := conversions.RoleFromResourcePermissions(psRes.Share.Permissions.GetPermissions(), true) + if role.OCSPermissions() == 0 { + return client.Stat(ctx, &provider.StatRequest{Ref: &provider.Reference{ + ResourceId: psRes.GetShare().GetResourceId(), + }}) + + } + return nil, nil +} + func getTokenStatInfo(ctx context.Context, selector pool.Selectable[gatewayv1beta1.GatewayAPIClient], token string) (*provider.StatResponse, error) { client, err := selector.Next() if err != nil { diff --git a/internal/http/services/owncloud/ocdav/ocdav_blackbox_test.go b/internal/http/services/owncloud/ocdav/ocdav_blackbox_test.go index 5c15d414465..d5a8824f52b 100644 --- a/internal/http/services/owncloud/ocdav/ocdav_blackbox_test.go +++ b/internal/http/services/owncloud/ocdav/ocdav_blackbox_test.go @@ -341,6 +341,222 @@ var _ = Describe("ocdav", func() { }) }) + Describe("PROPFIND to public-file", func() { + + BeforeEach(func() { + // set the dav endpoint to test + basePath = "/dav/public-files" + + // setup the request + rr = httptest.NewRecorder() + req, err = http.NewRequest("PROPFIND", basePath+"/aySTemFFUcNudVD", strings.NewReader("")) + req.Host = ":9200" + Expect(err).ToNot(HaveOccurred()) + req = req.WithContext(ctx) + }) + + When("the the link is public", func() { + It("returns a status Unauthorized", func() { + + client.On("Authenticate", mock.Anything, mock.MatchedBy(func(req *cs3gateway.AuthenticateRequest) bool { + return req.Type == "publicshares" && + strings.HasPrefix(req.ClientId, "aySTemFFUcNudVD") && + strings.HasPrefix(req.ClientSecret, "signature||") + })).Return(&cs3gateway.AuthenticateResponse{ + Status: status.NewUnauthenticated(ctx, nil, ""), + }, nil) + + handler.Handler().ServeHTTP(rr, req) + Expect(rr).To(HaveHTTPStatus(http.StatusUnauthorized)) + Expect(rr).To(HaveHTTPHeaderWithValue("WWW-Authenticate", "Basic realm=\":9200\", charset=\"UTF-8\"")) + }) + It("returns a Multistatus with the file properties", func() { + req.Header.Set("Authorization", "Basic cHVibGljOmAxcWF6WHN3Mg==") + ctx = ctxpkg.ContextSetUser(context.Background(), user) + + user = &cs3user.User{Id: &cs3user.UserId{OpaqueId: "username"}, Username: "username"} + expectedPathPrefix := "/" + + userspace = &cs3storageprovider.StorageSpace{ + Opaque: &cs3types.Opaque{ + Map: map[string]*cs3types.OpaqueEntry{ + "path": { + Decoder: "plain", + Value: []byte("/public/aySTemFFUcNudVD"), + }, + }, + }, + + Id: &cs3storageprovider.StorageSpaceId{OpaqueId: storagespace.FormatResourceID(cs3storageprovider.ResourceId{StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD"})}, + Root: &cs3storageprovider.ResourceId{StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD"}, + Name: "", + SpaceType: "mountpoint", + } + + client.On("Authenticate", mock.Anything, mock.MatchedBy(func(req *cs3gateway.AuthenticateRequest) bool { + return req.Type == "publicshares" && + strings.HasPrefix(req.ClientId, "aySTemFFUcNudVD") && + strings.HasPrefix(req.ClientSecret, "password|`1qazXsw2") + })).Return(&cs3gateway.AuthenticateResponse{ + Status: status.NewOK(ctx), + Token: "valid-token", + User: user, + }, nil) + + client.On("Stat", mock.Anything, mock.MatchedBy(func(req *cs3storageprovider.StatRequest) bool { + return utils.ResourceIDEqual(req.Ref.ResourceId, &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }) + })).Return(&cs3storageprovider.StatResponse{ + Status: status.NewOK(ctx), + Info: &cs3storageprovider.ResourceInfo{Id: &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }, + Type: cs3storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER}, + }, nil).Once() + + client.On("ListStorageSpaces", mock.Anything, mock.MatchedBy(func(req *cs3storageprovider.ListStorageSpacesRequest) bool { + p := string(req.Opaque.Map["path"].Value) + return p == "/" || strings.HasPrefix(p, expectedPathPrefix) + })).Return(&cs3storageprovider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*cs3storageprovider.StorageSpace{userspace}, // FIXME we may need to return the /public storage provider id and mock it + }, nil) + + client.On("Stat", mock.Anything, mock.MatchedBy(func(req *cs3storageprovider.StatRequest) bool { + return utils.ResourceIDEqual(req.Ref.ResourceId, &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }) + })).Return(&cs3storageprovider.StatResponse{ + Status: status.NewOK(ctx), + Info: &cs3storageprovider.ResourceInfo{Id: &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }, + Type: cs3storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER}, + }, nil).Once() + + client.On("ListContainer", mock.Anything, mock.Anything).Return(&cs3storageprovider.ListContainerResponse{ + Status: status.NewOK(context.Background()), + }, nil) + + handler.Handler().ServeHTTP(rr, req) + Expect(rr).To(HaveHTTPStatus(http.StatusMultiStatus)) + }) + }) + + When("the the link is internal", func() { + It("returns a status Unauthorized", func() { + + client.On("Authenticate", mock.Anything, mock.MatchedBy(func(req *cs3gateway.AuthenticateRequest) bool { + return req.Type == "publicshares" && + strings.HasPrefix(req.ClientId, "aySTemFFUcNudVD") && + strings.HasPrefix(req.ClientSecret, "signature||") + })).Return(&cs3gateway.AuthenticateResponse{ + Status: status.NewUnauthenticated(ctx, nil, ""), + }, nil) + + handler.Handler().ServeHTTP(rr, req) + Expect(rr).To(HaveHTTPStatus(http.StatusUnauthorized)) + Expect(rr).To(HaveHTTPHeaderWithValue("WWW-Authenticate", "Basic realm=\":9200\", charset=\"UTF-8\"")) + }) + It("returns a Multistatus with the file properties", func() { + req.Header.Set("Authorization", "Basic cHVibGljOmAxcWF6WHN3Mg==") + ctx = ctxpkg.ContextSetUser(context.Background(), user) + + user = &cs3user.User{Id: &cs3user.UserId{OpaqueId: "username"}, Username: "username"} + expectedPathPrefix := "/" + + userspace = &cs3storageprovider.StorageSpace{ + Opaque: &cs3types.Opaque{ + Map: map[string]*cs3types.OpaqueEntry{ + "path": { + Decoder: "plain", + Value: []byte("/public/aySTemFFUcNudVD"), + }, + }, + }, + + Id: &cs3storageprovider.StorageSpaceId{OpaqueId: storagespace.FormatResourceID(cs3storageprovider.ResourceId{StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD"})}, + Root: &cs3storageprovider.ResourceId{StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD"}, + Name: "", + SpaceType: "mountpoint", + } + + client.On("Authenticate", mock.Anything, mock.MatchedBy(func(req *cs3gateway.AuthenticateRequest) bool { + return req.Type == "publicshares" && + strings.HasPrefix(req.ClientId, "aySTemFFUcNudVD") && + strings.HasPrefix(req.ClientSecret, "password|`1qazXsw2") + })).Return(&cs3gateway.AuthenticateResponse{ + Status: status.NewOK(ctx), + Token: "valid-token", + User: user, + }, nil) + + client.On("Stat", mock.Anything, mock.MatchedBy(func(req *cs3storageprovider.StatRequest) bool { + return utils.ResourceIDEqual(req.Ref.ResourceId, &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }) + })).Return(&cs3storageprovider.StatResponse{ + Status: status.NewOK(ctx), + Info: &cs3storageprovider.ResourceInfo{Id: &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }, + Type: cs3storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER}, + }, nil).Once() + + client.On("ListStorageSpaces", mock.Anything, mock.MatchedBy(func(req *cs3storageprovider.ListStorageSpacesRequest) bool { + p := string(req.Opaque.Map["path"].Value) + return p == "/" || strings.HasPrefix(p, expectedPathPrefix) + })).Return(&cs3storageprovider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*cs3storageprovider.StorageSpace{userspace}, // FIXME we may need to return the /public storage provider id and mock it + }, nil) + + client.On("Stat", mock.Anything, mock.MatchedBy(func(req *cs3storageprovider.StatRequest) bool { + return utils.ResourceIDEqual(req.Ref.ResourceId, &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }) + })).Return(&cs3storageprovider.StatResponse{ + Status: status.NewOK(ctx), + Info: &cs3storageprovider.ResourceInfo{Id: &cs3storageprovider.ResourceId{ + StorageId: utils.PublicStorageProviderID, + SpaceId: utils.PublicStorageSpaceID, + OpaqueId: "aySTemFFUcNudVD", + }, + Type: cs3storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER}, + }, nil).Once() + + client.On("ListContainer", mock.Anything, mock.Anything).Return(&cs3storageprovider.ListContainerResponse{ + Status: status.NewOK(context.Background()), + }, nil) + + handler.Handler().ServeHTTP(rr, req) + Expect(rr).To(HaveHTTPStatus(http.StatusMultiStatus)) + }) + }) + }) Describe("MKCOL", func() { BeforeEach(func() {