Initial QSfera import
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// AccessLog is a middleware to log http requests at info level logging.
|
||||
func AccessLog(logger log.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
requestID := middleware.GetReqID(r.Context())
|
||||
// add Request Id to all responses
|
||||
w.Header().Set(middleware.RequestIDHeader, requestID)
|
||||
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(wrap, r)
|
||||
|
||||
spanContext := trace.SpanContextFromContext(r.Context())
|
||||
logger.Info().
|
||||
Str("proto", r.Proto).
|
||||
Str(log.RequestIDString, requestID).
|
||||
Str("traceid", spanContext.TraceID().String()).
|
||||
Str("remote-addr", r.RemoteAddr).
|
||||
Str("method", r.Method).
|
||||
Str("wopi-action", r.Header.Get("X-WOPI-Override")).
|
||||
Int("status", wrap.Status()).
|
||||
Str("path", r.URL.Path).
|
||||
Dur("duration", time.Since(start)).
|
||||
Int("bytes", wrap.BytesWritten()).
|
||||
Msg("access-log")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
// Claims contains the jwt registered claims plus the used WOPI context
|
||||
type Claims struct {
|
||||
WopiContext WopiContext `json:"WopiContext"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// keyPadding will add the required zero padding to the provided key.
|
||||
// The resulting key will have a length of either 16, 24 or 32 bytes.
|
||||
// If the key has more than 32 bytes, only the first 32 bytes will be returned.
|
||||
func keyPadding(key []byte) []byte {
|
||||
switch length := len(key); {
|
||||
case length < 16:
|
||||
return append(key, make([]byte, 16-length)...)
|
||||
case length == 16:
|
||||
return key
|
||||
case length < 24:
|
||||
return append(key, make([]byte, 24-length)...)
|
||||
case length == 24:
|
||||
return key
|
||||
case length < 32:
|
||||
return append(key, make([]byte, 32-length)...)
|
||||
case length == 32:
|
||||
return key
|
||||
case length > 32:
|
||||
return key[:32]
|
||||
}
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
// EncryptAES encrypts the provided plainText using the provided key.
|
||||
// AES CFB will be used as cryptographic method.
|
||||
// Use DecryptAES to decrypt the resulting string
|
||||
func EncryptAES(key []byte, plainText string) (string, error) {
|
||||
src := []byte(plainText)
|
||||
|
||||
block, err := aes.NewCipher(keyPadding(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cipherText := make([]byte, aes.BlockSize+len(src))
|
||||
iv := cipherText[:aes.BlockSize]
|
||||
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stream := cipher.NewCFBEncrypter(block, iv)
|
||||
stream.XORKeyStream(cipherText[aes.BlockSize:], src)
|
||||
|
||||
return base64.URLEncoding.EncodeToString(cipherText), nil
|
||||
}
|
||||
|
||||
// DecryptAES decrypts the provided string using the provided key.
|
||||
// The provided string must have been encrypted with AES CFB.
|
||||
// This method will decrypt the result from the EncryptAES method
|
||||
func DecryptAES(key []byte, securemess string) (string, error) {
|
||||
cipherText, err := base64.URLEncoding.DecodeString(securemess)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(keyPadding(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(cipherText) < aes.BlockSize {
|
||||
return "", errors.New("ciphertext block size is too short")
|
||||
}
|
||||
|
||||
iv := cipherText[:aes.BlockSize]
|
||||
cipherText = cipherText[aes.BlockSize:]
|
||||
|
||||
stream := cipher.NewCFBDecrypter(block, iv)
|
||||
stream.XORKeyStream(cipherText, cipherText)
|
||||
|
||||
return string(cipherText), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Middleware Suite")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/proofkeys"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// ProofKeysMiddleware will verify the proof keys of the requests.
|
||||
// This is a middleware that could be disabled / not set.
|
||||
//
|
||||
// Requests will fail with a 500 HTTP status if the verification fails.
|
||||
// As said, this can be disabled (via configuration) if you want to skip
|
||||
// the verification.
|
||||
// The middleware requires hitting the "/hosting/discovery" endpoint of the
|
||||
// WOPI app in order to get the keys. The keys will be cached in memory for
|
||||
// 12 hours (or the configured value) before hitting the endpoint again to
|
||||
// request new / updated keys.
|
||||
func ProofKeysMiddleware(cfg *config.Config, next http.Handler) http.Handler {
|
||||
wopiDiscovery := cfg.App.Addr + "/hosting/discovery"
|
||||
insecure := cfg.App.Insecure
|
||||
cacheDuration, err := time.ParseDuration(cfg.App.ProofKeys.Duration)
|
||||
if err != nil {
|
||||
cacheDuration = 12 * time.Hour
|
||||
}
|
||||
|
||||
pkHandler := proofkeys.NewVerifyHandler(wopiDiscovery, insecure, cacheDuration)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger := zerolog.Ctx(r.Context())
|
||||
|
||||
// the url we need is the one being requested, but we need the
|
||||
// scheme and host, so we'll get those from the configured WOPISrc
|
||||
wopiSrcURL, _ := url.Parse(cfg.Wopi.WopiSrc)
|
||||
if cfg.Wopi.ProxyURL != "" {
|
||||
wopiSrcURL, _ = url.Parse(cfg.Wopi.ProxyURL)
|
||||
}
|
||||
currentURL, _ := url.Parse(r.URL.String())
|
||||
currentURL.Scheme = wopiSrcURL.Scheme
|
||||
currentURL.Host = wopiSrcURL.Host
|
||||
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
stamp := r.Header.Get("X-WOPI-TimeStamp")
|
||||
|
||||
err := pkHandler.Verify(
|
||||
accessToken,
|
||||
currentURL.String(),
|
||||
stamp,
|
||||
r.Header.Get("X-WOPI-Proof"),
|
||||
r.Header.Get("X-WOPI-ProofOld"),
|
||||
proofkeys.VerifyWithLogger(logger),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("ProofKeys verification failed")
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Msg("ProofKeys verified")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// CollaborationTracingMiddleware adds a new middleware in order to include
|
||||
// more attributes in the traced span.
|
||||
//
|
||||
// In order not to mess with the expected responses, this middleware won't do
|
||||
// anything if there is no available WOPI context set in the request (there is
|
||||
// nothing to report). This means that the WopiContextAuthMiddleware should be
|
||||
// set before this middleware.
|
||||
func CollaborationTracingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wopiContext, err := WopiContextFromCtx(r.Context())
|
||||
if err != nil {
|
||||
// if we can't get the context, skip this middleware
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
|
||||
wopiMethod := r.Header.Get("X-WOPI-Override")
|
||||
|
||||
wopiFile := wopiContext.FileReference
|
||||
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("wopi.session.id", r.Header.Get("X-WOPI-SessionId")),
|
||||
attribute.String("wopi.method", wopiMethod),
|
||||
attribute.String("cs3.resource.id.storage", wopiFile.GetResourceId().GetStorageId()),
|
||||
attribute.String("cs3.resource.id.opaque", wopiFile.GetResourceId().GetOpaqueId()),
|
||||
attribute.String("cs3.resource.id.space", wopiFile.GetResourceId().GetSpaceId()),
|
||||
attribute.String("cs3.resource.path", wopiFile.GetPath()),
|
||||
}
|
||||
|
||||
if wopiUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
attrs = append(attrs, []attribute.KeyValue{
|
||||
attribute.String("enduser.id", wopiUser.GetId().GetOpaqueId()),
|
||||
attribute.String("cs3.user.idp", wopiUser.GetId().GetIdp()),
|
||||
attribute.String("cs3.user.opaque", wopiUser.GetId().GetOpaqueId()),
|
||||
attribute.String("cs3.user.type", wopiUser.GetId().GetType().String()),
|
||||
}...)
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
rjwt "github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
"github.com/rs/zerolog"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type key int
|
||||
|
||||
const (
|
||||
wopiContextKey key = iota
|
||||
)
|
||||
|
||||
// WopiContext wraps all the information we need for WOPI
|
||||
type WopiContext struct {
|
||||
AccessToken string
|
||||
ViewOnlyToken string
|
||||
FileReference *providerv1beta1.Reference
|
||||
TemplateReference *providerv1beta1.Reference
|
||||
ViewMode appproviderv1beta1.ViewMode
|
||||
}
|
||||
|
||||
// WopiContextAuthMiddleware will prepare an HTTP handler to be used as
|
||||
// middleware. The handler will create a WopiContext by parsing the
|
||||
// access_token (which must be provided as part of the URL query).
|
||||
// The access_token is required.
|
||||
//
|
||||
// This middleware will add the following to the request's context:
|
||||
// * The access token as metadata for outgoing requests (for the
|
||||
// authentication against the CS3 API, the "x-access-token" header).
|
||||
// * The created WopiContext for the request
|
||||
// * A contextual zerologger containing information about the request
|
||||
// and the WopiContext
|
||||
func WopiContextAuthMiddleware(cfg *config.Config, st microstore.Store, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// include additional info in the context's logger
|
||||
// we might need to check https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers
|
||||
// although some headers might not be sent depending on the client.
|
||||
logger := zerolog.Ctx(ctx)
|
||||
wopiLogger := logger.With().
|
||||
Str("WopiSessionId", r.Header.Get("X-WOPI-SessionId")).
|
||||
Str("WopiOverride", r.Header.Get("X-WOPI-Override")).
|
||||
Str("WopiProof", r.Header.Get("X-WOPI-Proof")).
|
||||
Str("WopiProofOld", r.Header.Get("X-WOPI-ProofOld")).
|
||||
Str("WopiStamp", r.Header.Get("X-WOPI-TimeStamp")).
|
||||
Logger()
|
||||
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
if accessToken == "" {
|
||||
wopiLogger.Error().Msg("missing access token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.Wopi.ShortTokens {
|
||||
records, err := st.Read(accessToken)
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("cannot retrieve access token from store")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) != 1 {
|
||||
wopiLogger.Error().Int("records", len(records)).Msg("no record found for the token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken = string(records[0].Value)
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (any, error) {
|
||||
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(cfg.Wopi.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to parse jwt token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
wopiContextAccessToken, err := DecryptAES([]byte(cfg.Wopi.Secret), claims.WopiContext.AccessToken)
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to decrypt reva access token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
tokenManager, err := rjwt.New(map[string]any{
|
||||
"secret": cfg.TokenManager.JWTSecret,
|
||||
"expires": int64(24 * 60 * 60),
|
||||
})
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to get a reva token manager")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, scopes, err := tokenManager.DismantleToken(ctx, wopiContextAccessToken)
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to dismantle reva token manager")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims.WopiContext.AccessToken = wopiContextAccessToken
|
||||
|
||||
ctx = context.WithValue(ctx, wopiContextKey, claims.WopiContext)
|
||||
// authentication for the CS3 api
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, claims.WopiContext.AccessToken)
|
||||
ctx = ctxpkg.ContextSetUser(ctx, user)
|
||||
ctx = ctxpkg.ContextSetScopes(ctx, scopes)
|
||||
|
||||
// include additional info in the context's logger
|
||||
wopiLogger = wopiLogger.With().
|
||||
Str("FileReference", claims.WopiContext.FileReference.String()).
|
||||
Str("ViewMode", claims.WopiContext.ViewMode.String()).
|
||||
Str("Requester", user.GetId().String()).
|
||||
Logger()
|
||||
ctx = wopiLogger.WithContext(ctx)
|
||||
|
||||
// Validate that the file ID in the URL matches the WOPI token's file
|
||||
// reference. This check only applies to /wopi/files/ and /wopi/templates/
|
||||
// paths. Other WOPI-authenticated endpoints (e.g. /wopi/avatars/) don't
|
||||
// carry a file ID in the URL — they only need a valid WOPI token.
|
||||
if strings.Contains(r.URL.Path, "/files/") || strings.Contains(r.URL.Path, "/templates/") {
|
||||
hashedRef := helpers.HashResourceId(claims.WopiContext.FileReference.GetResourceId())
|
||||
fileID := parseWopiFileID(cfg, r.URL.Path)
|
||||
if claims.WopiContext.TemplateReference != nil {
|
||||
hashedTemplateRef := helpers.HashResourceId(claims.WopiContext.TemplateReference.GetResourceId())
|
||||
// the fileID could be one of the references within the access token if both are set
|
||||
// because we can use the access token to get the contents of the template file
|
||||
if fileID != hashedTemplateRef && fileID != hashedRef {
|
||||
wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if fileID != hashedRef {
|
||||
wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Extract a WopiContext from the context if possible. An error will be
|
||||
// returned if there is no WopiContext
|
||||
func WopiContextFromCtx(ctx context.Context) (WopiContext, error) {
|
||||
if wopiContext, ok := ctx.Value(wopiContextKey).(WopiContext); ok {
|
||||
return wopiContext, nil
|
||||
}
|
||||
return WopiContext{}, errors.New("no wopi context found")
|
||||
}
|
||||
|
||||
// Set a WopiContext in the context. A new context will be returned with the
|
||||
// add WopiContext inside. Note that the old one won't have the WopiContext set.
|
||||
//
|
||||
// This method is used for testing. The WopiContextAuthMiddleware should be
|
||||
// used instead in order to provide a valid WopiContext
|
||||
func WopiContextToCtx(ctx context.Context, wopiContext WopiContext) context.Context {
|
||||
return context.WithValue(ctx, wopiContextKey, wopiContext)
|
||||
}
|
||||
|
||||
// The access token inside the wopiContext is expected to be decrypted.
|
||||
// In order to generate the access token for WOPI, the reva token inside the
|
||||
// wopiContext will be encrypted
|
||||
func GenerateWopiToken(wopiContext WopiContext, cfg *config.Config, st microstore.Store) (string, int64, error) {
|
||||
if cfg.Wopi.ShortTokens && st == nil {
|
||||
return "", 0, errors.New("Cannot generate a short token without microstore")
|
||||
}
|
||||
|
||||
cryptedReqAccessToken, err := EncryptAES([]byte(cfg.Wopi.Secret), wopiContext.AccessToken)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
cs3Claims := &jwt.RegisteredClaims{}
|
||||
cs3JWTparser := jwt.Parser{}
|
||||
_, _, err = cs3JWTparser.ParseUnverified(wopiContext.AccessToken, cs3Claims)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
wopiContext.AccessToken = cryptedReqAccessToken
|
||||
claims := &Claims{
|
||||
WopiContext: wopiContext,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: cs3Claims.ExpiresAt,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessToken, err := token.SignedString([]byte(cfg.Wopi.Secret))
|
||||
|
||||
if cfg.Wopi.ShortTokens {
|
||||
c := md5.New()
|
||||
c.Write([]byte(accessToken))
|
||||
shortAccessToken := hex.EncodeToString(c.Sum(nil)) + strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
|
||||
errWrite := st.Write(µstore.Record{
|
||||
Key: shortAccessToken,
|
||||
Value: []byte(accessToken),
|
||||
Expiry: time.Until(claims.ExpiresAt.Time),
|
||||
})
|
||||
|
||||
return shortAccessToken, claims.ExpiresAt.UnixMilli(), errWrite
|
||||
}
|
||||
|
||||
return accessToken, claims.ExpiresAt.UnixMilli(), err
|
||||
}
|
||||
|
||||
// parseWopiFileID extracts the file id from a wopi path
|
||||
//
|
||||
// If the file id is a jwt, it will be decoded and the file id will be extracted from the jwt claims.
|
||||
// If the file id is not a jwt, it will be returned as is.
|
||||
func parseWopiFileID(cfg *config.Config, path string) string {
|
||||
s := strings.Split(path, "/")
|
||||
if len(s) < 4 || (s[1] != "wopi" && s[2] != "files") {
|
||||
return path
|
||||
}
|
||||
// check if the fileid is a jwt
|
||||
if strings.Contains(s[3], ".") {
|
||||
token, err := jwt.Parse(s[3], func(_ *jwt.Token) (any, error) {
|
||||
return []byte(cfg.Wopi.ProxySecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return s[3]
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return s[3]
|
||||
}
|
||||
|
||||
f, ok := claims["f"].(string)
|
||||
if !ok {
|
||||
return s[3]
|
||||
}
|
||||
return f
|
||||
}
|
||||
// fileid is not a jwt
|
||||
return s[3]
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
appprovider "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/middleware"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/wopisrc"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token"
|
||||
rjwt "github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
)
|
||||
|
||||
var _ = Describe("Wopi Context Middleware", func() {
|
||||
var (
|
||||
cfg *config.Config
|
||||
ctx context.Context
|
||||
mw http.Handler
|
||||
rid *providerv1beta1.ResourceId
|
||||
tknMngr token.Manager
|
||||
user *userv1beta1.User
|
||||
src *url.URL
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
cfg = &config.Config{
|
||||
TokenManager: &config.TokenManager{JWTSecret: "jwtSecret"},
|
||||
Wopi: config.Wopi{
|
||||
Secret: "wopiSecret",
|
||||
WopiSrc: "https://localhost:9300",
|
||||
},
|
||||
}
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mw = middleware.WopiContextAuthMiddleware(cfg, nil, next)
|
||||
|
||||
tknMngr, err = rjwt.New(map[string]any{
|
||||
"secret": cfg.TokenManager.JWTSecret,
|
||||
"expires": int64(24 * 60 * 60),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
user = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "example.com",
|
||||
OpaqueId: "12345",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "admin",
|
||||
Mail: "admin@example.com",
|
||||
}
|
||||
|
||||
rid = &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID",
|
||||
SpaceId: "spaceID",
|
||||
}
|
||||
|
||||
src, err = url.Parse(cfg.Wopi.WopiSrc)
|
||||
src.Path = path.Join("wopi", "files", helpers.HashResourceId(rid))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
It("Should not authorize with empty access token", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should not authorize with malformed access token", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", "token")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should not authorize when fileID mismatches", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
// create request with different fileID in the wopi context
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID2",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", wopiToken)
|
||||
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should not authorize with wrong wopi secret", func() {
|
||||
src.Path = path.Join("wopi", "files", helpers.HashResourceId(rid))
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
}
|
||||
// use wrong wopi secret when generating the wopi token
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, &config.Config{Wopi: config.Wopi{
|
||||
Secret: "wrongSecret",
|
||||
}}, nil)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", wopiToken)
|
||||
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should authorize successful", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: rid,
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", wopiToken)
|
||||
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
It("Should authorize successful with template reference", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
TemplateReference: &providerv1beta1.Reference{
|
||||
ResourceId: rid,
|
||||
Path: ".",
|
||||
},
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID2",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", wopiToken)
|
||||
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
It("Should not authorize when no reference matches", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
TemplateReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID3",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID2",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", wopiToken)
|
||||
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should not authorize with proxy when fileID mismatches", func() {
|
||||
cfg.Wopi.ProxySecret = "proxySecret"
|
||||
cfg.Wopi.ProxyURL = "https://proxy"
|
||||
src, err := wopisrc.GenerateWopiSrc(helpers.HashResourceId(rid), cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID3",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", wopiToken)
|
||||
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should authorize successful with proxy", func() {
|
||||
cfg.Wopi.ProxySecret = "proxySecret"
|
||||
cfg.Wopi.ProxyURL = "https://proxy"
|
||||
src, err := wopisrc.GenerateWopiSrc(helpers.HashResourceId(rid), cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: rid,
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", wopiToken)
|
||||
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user