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] }