mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-03-07 13:29:39 -06:00
Merge branch 'master' into config-doc-descriptions
This commit is contained in:
2
services/proxy/.dockerignore
Normal file
2
services/proxy/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!bin/
|
||||
37
services/proxy/Makefile
Normal file
37
services/proxy/Makefile
Normal file
@@ -0,0 +1,37 @@
|
||||
SHELL := bash
|
||||
NAME := proxy
|
||||
|
||||
include ../../.make/recursion.mk
|
||||
|
||||
############ tooling ############
|
||||
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
|
||||
include ../../.bingo/Variables.mk
|
||||
endif
|
||||
|
||||
############ go tooling ############
|
||||
include ../../.make/go.mk
|
||||
|
||||
############ release ############
|
||||
include ../../.make/release.mk
|
||||
|
||||
############ docs generate ############
|
||||
include ../../.make/docs.mk
|
||||
|
||||
.PHONY: docs-generate
|
||||
docs-generate: config-docs-generate
|
||||
|
||||
############ generate ############
|
||||
include ../../.make/generate.mk
|
||||
|
||||
.PHONY: ci-go-generate
|
||||
ci-go-generate: # CI runs ci-node-generate automatically before this target
|
||||
|
||||
.PHONY: ci-node-generate
|
||||
ci-node-generate:
|
||||
|
||||
############ licenses ############
|
||||
.PHONY: ci-node-check-licenses
|
||||
ci-node-check-licenses:
|
||||
|
||||
.PHONY: ci-node-save-licenses
|
||||
ci-node-save-licenses:
|
||||
14
services/proxy/cmd/proxy/main.go
Normal file
14
services/proxy/cmd/proxy/main.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/command"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config/defaults"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := command.Execute(defaults.DefaultConfig()); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
19
services/proxy/docker/Dockerfile.linux.amd64
Normal file
19
services/proxy/docker/Dockerfile.linux.amd64
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM amd64/alpine:latest
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add ca-certificates mailcap && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="ownCloud GmbH <devops@owncloud.com>" \
|
||||
org.label-schema.name="oCIS Proxy" \
|
||||
org.label-schema.vendor="ownCloud GmbH" \
|
||||
org.label-schema.schema-version="1.0"
|
||||
|
||||
EXPOSE 9180
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ocis-proxy"]
|
||||
CMD ["server"]
|
||||
|
||||
COPY bin/ocis-proxy /usr/bin/ocis-proxy
|
||||
19
services/proxy/docker/Dockerfile.linux.arm
Normal file
19
services/proxy/docker/Dockerfile.linux.arm
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM arm32v6/alpine:latest
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add ca-certificates mailcap && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="ownCloud GmbH <devops@owncloud.com>" \
|
||||
org.label-schema.name="oCIS Proxy" \
|
||||
org.label-schema.vendor="ownCloud GmbH" \
|
||||
org.label-schema.schema-version="1.0"
|
||||
|
||||
EXPOSE 9180
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ocis-proxy"]
|
||||
CMD ["server"]
|
||||
|
||||
COPY bin/ocis-proxy /usr/bin/ocis-proxy
|
||||
19
services/proxy/docker/Dockerfile.linux.arm64
Normal file
19
services/proxy/docker/Dockerfile.linux.arm64
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM arm64v8/alpine:latest
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add ca-certificates mailcap && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="ownCloud GmbH <devops@owncloud.com>" \
|
||||
org.label-schema.name="oCIS Proxy" \
|
||||
org.label-schema.vendor="ownCloud GmbH" \
|
||||
org.label-schema.schema-version="1.0"
|
||||
|
||||
EXPOSE 9180
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ocis-proxy"]
|
||||
CMD ["server"]
|
||||
|
||||
COPY bin/ocis-proxy /usr/bin/ocis-proxy
|
||||
22
services/proxy/docker/manifest.tmpl
Normal file
22
services/proxy/docker/manifest.tmpl
Normal file
@@ -0,0 +1,22 @@
|
||||
image: owncloud/ocis-proxy:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
||||
{{#if build.tags}}
|
||||
tags:
|
||||
{{#each build.tags}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
manifests:
|
||||
- image: owncloud/ocis-proxy:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
||||
platform:
|
||||
architecture: amd64
|
||||
os: linux
|
||||
- image: owncloud/ocis-proxy:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
||||
platform:
|
||||
architecture: arm64
|
||||
variant: v8
|
||||
os: linux
|
||||
- image: owncloud/ocis-proxy:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
||||
platform:
|
||||
architecture: arm
|
||||
variant: v6
|
||||
os: linux
|
||||
57
services/proxy/pkg/command/health.go
Normal file
57
services/proxy/pkg/command/health.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config/parser"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/logging"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "health",
|
||||
Usage: "check health status",
|
||||
Category: "info",
|
||||
Before: func(c *cli.Context) error {
|
||||
err := parser.ParseConfig(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("%v", err)
|
||||
}
|
||||
return err
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
logger := logging.Configure(cfg.Service.Name, cfg.Log)
|
||||
|
||||
resp, err := http.Get(
|
||||
fmt.Sprintf(
|
||||
"http://%s/healthz",
|
||||
cfg.Debug.Addr,
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to request health check")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Fatal().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health seems to be in bad state")
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health got a good state")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
64
services/proxy/pkg/command/root.go
Normal file
64
services/proxy/pkg/command/root.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/clihelper"
|
||||
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/thejerf/suture/v4"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) cli.Commands {
|
||||
return []*cli.Command{
|
||||
// start this service
|
||||
Server(cfg),
|
||||
|
||||
// interaction with this service
|
||||
|
||||
// infos about this service
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the ocis-proxy command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cli.App{
|
||||
Name: "proxy",
|
||||
Usage: "proxy for oCIS",
|
||||
Commands: GetCommands(cfg),
|
||||
})
|
||||
|
||||
cli.HelpFlag = &cli.BoolFlag{
|
||||
Name: "help,h",
|
||||
Usage: "Show the help",
|
||||
}
|
||||
|
||||
return app.Run(os.Args)
|
||||
}
|
||||
|
||||
// SutureService allows for the proxy command to be embedded and supervised by a suture supervisor tree.
|
||||
type SutureService struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewSutureService creates a new proxy.SutureService
|
||||
func NewSutureService(cfg *ociscfg.Config) suture.Service {
|
||||
cfg.Proxy.Commons = cfg.Commons
|
||||
return SutureService{
|
||||
cfg: cfg.Proxy,
|
||||
}
|
||||
}
|
||||
|
||||
func (s SutureService) Serve(ctx context.Context) error {
|
||||
s.cfg.Context = ctx
|
||||
if err := Execute(s.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
233
services/proxy/pkg/command/server.go
Normal file
233
services/proxy/pkg/command/server.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/cs3org/reva/v2/pkg/token/manager/jwt"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/justinas/alice"
|
||||
"github.com/oklog/run"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
pkgmiddleware "github.com/owncloud/ocis/v2/ocis-pkg/middleware"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config/parser"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/cs3"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/logging"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/metrics"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/middleware"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/proxy"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/server/debug"
|
||||
proxyHTTP "github.com/owncloud/ocis/v2/services/proxy/pkg/server/http"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/tracing"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "server",
|
||||
Usage: fmt.Sprintf("start %s extension without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
Category: "server",
|
||||
Before: func(c *cli.Context) error {
|
||||
err := parser.ParseConfig(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return err
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
logger := logging.Configure(cfg.Service.Name, cfg.Log)
|
||||
err := tracing.Configure(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
m = metrics.New()
|
||||
)
|
||||
|
||||
gr := run.Group{}
|
||||
ctx, cancel := func() (context.Context, context.CancelFunc) {
|
||||
if cfg.Context == nil {
|
||||
return context.WithCancel(context.Background())
|
||||
}
|
||||
return context.WithCancel(cfg.Context)
|
||||
}()
|
||||
|
||||
defer cancel()
|
||||
|
||||
m.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
rp := proxy.NewMultiHostReverseProxy(
|
||||
proxy.Logger(logger),
|
||||
proxy.Config(cfg),
|
||||
)
|
||||
|
||||
{
|
||||
server, err := proxyHTTP.Server(
|
||||
proxyHTTP.Handler(rp),
|
||||
proxyHTTP.Logger(logger),
|
||||
proxyHTTP.Context(ctx),
|
||||
proxyHTTP.Config(cfg),
|
||||
proxyHTTP.Metrics(metrics.New()),
|
||||
proxyHTTP.Middlewares(loadMiddlewares(ctx, logger, cfg)),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("server", "http").
|
||||
Msg("Failed to initialize server")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(func() error {
|
||||
return server.Run()
|
||||
}, func(_ error) {
|
||||
logger.Info().
|
||||
Str("server", "http").
|
||||
Msg("Shutting down server")
|
||||
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
server, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(ctx),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("server", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(server.ListenAndServe, func(_ error) {
|
||||
_ = server.Shutdown(ctx)
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
return gr.Run()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config) alice.Chain {
|
||||
rolesClient := settingssvc.NewRoleService("com.owncloud.api.settings", grpc.DefaultClient)
|
||||
revaClient, err := cs3.GetGatewayServiceClient(cfg.Reva.Address)
|
||||
var userProvider backend.UserBackend
|
||||
switch cfg.AccountBackend {
|
||||
case "cs3":
|
||||
tokenManager, err := jwt.New(map[string]interface{}{
|
||||
"secret": cfg.TokenManager.JWTSecret,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).
|
||||
Msg("Failed to create token manager")
|
||||
}
|
||||
|
||||
userProvider = backend.NewCS3UserBackend(rolesClient, revaClient, cfg.MachineAuthAPIKey, cfg.OIDC.Issuer, tokenManager, logger)
|
||||
default:
|
||||
logger.Fatal().Msgf("Invalid accounts backend type '%s'", cfg.AccountBackend)
|
||||
}
|
||||
|
||||
storeClient := storesvc.NewStoreService("com.owncloud.api.store", grpc.DefaultClient)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).
|
||||
Str("gateway", cfg.Reva.Address).
|
||||
Msg("Failed to create reva gateway service client")
|
||||
}
|
||||
|
||||
var oidcHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.OIDC.Insecure, //nolint:gosec
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
Timeout: time.Second * 10,
|
||||
}
|
||||
|
||||
return alice.New(
|
||||
// first make sure we log all requests and redirect to https if necessary
|
||||
pkgmiddleware.TraceContext,
|
||||
chimiddleware.RealIP,
|
||||
chimiddleware.RequestID,
|
||||
middleware.AccessLog(logger),
|
||||
middleware.HTTPSRedirect,
|
||||
|
||||
// now that we established the basics, on with authentication middleware
|
||||
middleware.Authentication(
|
||||
// OIDC Options
|
||||
middleware.OIDCProviderFunc(func() (middleware.OIDCProvider, error) {
|
||||
// Initialize a provider by specifying the issuer URL.
|
||||
// it will fetch the keys from the issuer using the .well-known
|
||||
// endpoint
|
||||
return oidc.NewProvider(
|
||||
context.WithValue(ctx, oauth2.HTTPClient, oidcHTTPClient),
|
||||
cfg.OIDC.Issuer,
|
||||
)
|
||||
}),
|
||||
middleware.HTTPClient(oidcHTTPClient),
|
||||
middleware.TokenCacheSize(cfg.OIDC.UserinfoCache.Size),
|
||||
middleware.TokenCacheTTL(time.Second*time.Duration(cfg.OIDC.UserinfoCache.TTL)),
|
||||
|
||||
// basic Options
|
||||
middleware.Logger(logger),
|
||||
middleware.EnableBasicAuth(cfg.EnableBasicAuth),
|
||||
middleware.UserProvider(userProvider),
|
||||
middleware.OIDCIss(cfg.OIDC.Issuer),
|
||||
middleware.UserOIDCClaim(cfg.UserOIDCClaim),
|
||||
middleware.UserCS3Claim(cfg.UserCS3Claim),
|
||||
middleware.CredentialsByUserAgent(cfg.AuthMiddleware.CredentialsByUserAgent),
|
||||
),
|
||||
middleware.SignedURLAuth(
|
||||
middleware.Logger(logger),
|
||||
middleware.PreSignedURLConfig(cfg.PreSignedURL),
|
||||
middleware.UserProvider(userProvider),
|
||||
middleware.Store(storeClient),
|
||||
),
|
||||
middleware.AccountResolver(
|
||||
middleware.Logger(logger),
|
||||
middleware.UserProvider(userProvider),
|
||||
middleware.TokenManagerConfig(*cfg.TokenManager),
|
||||
middleware.UserOIDCClaim(cfg.UserOIDCClaim),
|
||||
middleware.UserCS3Claim(cfg.UserCS3Claim),
|
||||
middleware.AutoprovisionAccounts(cfg.AutoprovisionAccounts),
|
||||
),
|
||||
|
||||
middleware.SelectorCookie(
|
||||
middleware.Logger(logger),
|
||||
middleware.UserProvider(userProvider),
|
||||
middleware.PolicySelectorConfig(*cfg.PolicySelector),
|
||||
),
|
||||
|
||||
// finally, trigger home creation when a user logs in
|
||||
middleware.CreateHome(
|
||||
middleware.Logger(logger),
|
||||
middleware.TokenManagerConfig(*cfg.TokenManager),
|
||||
middleware.RevaGatewayClient(revaClient),
|
||||
),
|
||||
middleware.PublicShareAuth(
|
||||
middleware.Logger(logger),
|
||||
middleware.RevaGatewayClient(revaClient),
|
||||
),
|
||||
)
|
||||
}
|
||||
50
services/proxy/pkg/command/version.go
Normal file
50
services/proxy/pkg/command/version.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
|
||||
tw "github.com/olekukonko/tablewriter"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "version",
|
||||
Usage: "Print the version of this binary and the running extension instances",
|
||||
Category: "Version",
|
||||
Action: func(c *cli.Context) error {
|
||||
fmt.Println("Version: " + version.GetString())
|
||||
fmt.Printf("Compiled: %s\n", version.Compiled())
|
||||
fmt.Println("")
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No running " + cfg.Service.Name + " service found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := tw.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Version", "Address", "Id"})
|
||||
table.SetAutoFormatHeaders(false)
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
table.Append([]string{s.Version, n.Address, n.Id})
|
||||
}
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
140
services/proxy/pkg/config/config.go
Normal file
140
services/proxy/pkg/config/config.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
|
||||
Tracing *Tracing `yaml:"tracing"`
|
||||
Log *Log `yaml:"log"`
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
Reva *Reva `yaml:"reva"`
|
||||
|
||||
Policies []Policy `yaml:"policies"`
|
||||
OIDC OIDC `yaml:"oidc"`
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
PolicySelector *PolicySelector `yaml:"policy_selector"`
|
||||
PreSignedURL PreSignedURL `yaml:"pre_signed_url"`
|
||||
AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the proxy should use, currenly only 'cs3' is possible here."`
|
||||
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that should be used for resolving users with the account backend. Currently defaults to 'email'."`
|
||||
UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Currently defaults to 'mail' (other possible values are: 'username', 'displayname')"`
|
||||
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used for accessing the 'auth-machine' service to impersonate users."`
|
||||
AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provsion users that do not yet exist in the users service on-demand upon first signin. To use this a write-enabled libregraph user backend needs to be setup an running."`
|
||||
EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic' (username/password) authentication."`
|
||||
InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all http backend connections."`
|
||||
AuthMiddleware AuthMiddleware `yaml:"auth_middleware"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
// Policy enables us to use multiple directors.
|
||||
type Policy struct {
|
||||
Name string `yaml:"name"`
|
||||
Routes []Route `yaml:"routes"`
|
||||
}
|
||||
|
||||
// Route defines forwarding routes
|
||||
type Route struct {
|
||||
Type RouteType `yaml:"type,omitempty"`
|
||||
// Method optionally limits the route to this HTTP method
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Endpoint string `yaml:"endpoint,omitempty"`
|
||||
// Backend is a static URL to forward the request to
|
||||
Backend string `yaml:"backend,omitempty"`
|
||||
// Service name to look up in the registry
|
||||
Service string `yaml:"service,omitempty"`
|
||||
ApacheVHost bool `yaml:"apache_vhost,omitempty"`
|
||||
}
|
||||
|
||||
// RouteType defines the type of a route
|
||||
type RouteType string
|
||||
|
||||
const (
|
||||
// PrefixRoute are routes matched by a prefix
|
||||
PrefixRoute RouteType = "prefix"
|
||||
// QueryRoute are routes matched by a prefix and query parameters
|
||||
QueryRoute RouteType = "query"
|
||||
// RegexRoute are routes matched by a pattern
|
||||
RegexRoute RouteType = "regex"
|
||||
// DefaultRouteType is the PrefixRoute
|
||||
DefaultRouteType RouteType = PrefixRoute
|
||||
)
|
||||
|
||||
var (
|
||||
// RouteTypes is an array of the available route types
|
||||
RouteTypes = []RouteType{QueryRoute, RegexRoute, PrefixRoute}
|
||||
)
|
||||
|
||||
// AuthMiddleware configures the proxy http auth middleware.
|
||||
type AuthMiddleware struct {
|
||||
CredentialsByUserAgent map[string]string `yaml:"credentials_by_user_agent"`
|
||||
}
|
||||
|
||||
// OIDC is the config for the OpenID-Connect middleware. If set the proxy will try to authenticate every request
|
||||
// with the configured oidc-provider
|
||||
type OIDC struct {
|
||||
Issuer string `yaml:"issuer" env:"OCIS_URL;OCIS_OIDC_ISSUER;PROXY_OIDC_ISSUER" desc:"URL of the OIDC issuer. It defaults to URL of the builtin IDP."`
|
||||
Insecure bool `yaml:"insecure" env:"OCIS_INSECURE;PROXY_OIDC_INSECURE" desc:"Disable TLS certificate validation for connections to the IDP. (not recommended for production environments."`
|
||||
UserinfoCache UserinfoCache `yaml:"user_info_cache"`
|
||||
}
|
||||
|
||||
// UserinfoCache is a TTL cache configuration.
|
||||
type UserinfoCache struct {
|
||||
Size int `yaml:"size" env:"PROXY_OIDC_USERINFO_CACHE_SIZE" desc:"Cache size for oidc user info."`
|
||||
TTL int `yaml:"ttl" env:"PROXY_OIDC_USERINFO_CACHE_TTL" desc:"Max TTL for the oidc user info cache."`
|
||||
}
|
||||
|
||||
// PolicySelector is the toplevel-configuration for different selectors
|
||||
type PolicySelector struct {
|
||||
Static *StaticSelectorConf `yaml:"static"`
|
||||
Claims *ClaimsSelectorConf `yaml:"claims"`
|
||||
Regex *RegexSelectorConf `yaml:"regex"`
|
||||
}
|
||||
|
||||
// StaticSelectorConf is the config for the static-policy-selector
|
||||
type StaticSelectorConf struct {
|
||||
Policy string `yaml:"policy"`
|
||||
}
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;PROXY_JWT_SECRET" desc:"The secret to mint and validate jwt tokens."`
|
||||
}
|
||||
|
||||
// PreSignedURL is the config for the presigned url middleware
|
||||
type PreSignedURL struct {
|
||||
AllowedHTTPMethods []string `yaml:"allowed_http_methods"`
|
||||
Enabled bool `yaml:"enabled" env:"PROXY_ENABLE_PRESIGNEDURLS" desc:"Allow ocs to get a signing key to sign requests."`
|
||||
}
|
||||
|
||||
// ClaimsSelectorConf is the config for the claims-selector
|
||||
type ClaimsSelectorConf struct {
|
||||
DefaultPolicy string `yaml:"default_policy"`
|
||||
UnauthenticatedPolicy string `yaml:"unauthenticated_policy"`
|
||||
SelectorCookieName string `yaml:"selector_cookie_name"`
|
||||
}
|
||||
|
||||
// RegexSelectorConf is the config for the regex-selector
|
||||
type RegexSelectorConf struct {
|
||||
DefaultPolicy string `yaml:"default_policy"`
|
||||
MatchesPolicies []RegexRuleConf `yaml:"matches_policies"`
|
||||
UnauthenticatedPolicy string `yaml:"unauthenticated_policy"`
|
||||
SelectorCookieName string `yaml:"selector_cookie_name"`
|
||||
}
|
||||
|
||||
type RegexRuleConf struct {
|
||||
Priority int `yaml:"priority"`
|
||||
Property string `yaml:"property"`
|
||||
Match string `yaml:"match"`
|
||||
Policy string `yaml:"policy"`
|
||||
}
|
||||
9
services/proxy/pkg/config/debug.go
Normal file
9
services/proxy/pkg/config/debug.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"PROXY_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed."`
|
||||
Token string `yaml:"token" env:"PROXY_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint"`
|
||||
Pprof bool `yaml:"pprof" env:"PROXY_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling"`
|
||||
Zpages bool `yaml:"zpages" env:"PROXY_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."`
|
||||
}
|
||||
238
services/proxy/pkg/config/defaults/defaultconfig.go
Normal file
238
services/proxy/pkg/config/defaults/defaultconfig.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/defaults"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
EnsureDefaults(cfg)
|
||||
Sanitize(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9205",
|
||||
Token: "",
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "0.0.0.0:9200",
|
||||
Root: "/",
|
||||
Namespace: "com.owncloud.web",
|
||||
TLSCert: path.Join(defaults.BaseDataPath(), "proxy", "server.crt"),
|
||||
TLSKey: path.Join(defaults.BaseDataPath(), "proxy", "server.key"),
|
||||
TLS: true,
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "proxy",
|
||||
},
|
||||
OIDC: config.OIDC{
|
||||
Issuer: "https://localhost:9200",
|
||||
Insecure: true,
|
||||
//Insecure: true,
|
||||
UserinfoCache: config.UserinfoCache{
|
||||
Size: 1024,
|
||||
TTL: 10,
|
||||
},
|
||||
},
|
||||
PolicySelector: nil,
|
||||
Reva: &config.Reva{
|
||||
Address: "127.0.0.1:9142",
|
||||
},
|
||||
PreSignedURL: config.PreSignedURL{
|
||||
AllowedHTTPMethods: []string{"GET"},
|
||||
Enabled: true,
|
||||
},
|
||||
AccountBackend: "cs3",
|
||||
UserOIDCClaim: "email",
|
||||
UserCS3Claim: "mail",
|
||||
AutoprovisionAccounts: false,
|
||||
EnableBasicAuth: false,
|
||||
InsecureBackends: false,
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPolicies() []config.Policy {
|
||||
return []config.Policy{
|
||||
{
|
||||
Name: "ocis",
|
||||
Routes: []config.Route{
|
||||
{
|
||||
Endpoint: "/",
|
||||
Backend: "http://localhost:9100",
|
||||
},
|
||||
{
|
||||
Endpoint: "/.well-known/",
|
||||
Backend: "http://localhost:9130",
|
||||
},
|
||||
{
|
||||
Endpoint: "/konnect/",
|
||||
Backend: "http://localhost:9130",
|
||||
},
|
||||
{
|
||||
Endpoint: "/signin/",
|
||||
Backend: "http://localhost:9130",
|
||||
},
|
||||
{
|
||||
Endpoint: "/archiver",
|
||||
Backend: "http://localhost:9140",
|
||||
},
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: "/ocs/v[12].php/cloud/user/signing-key", // only `user/signing-key` is left in ocis-ocs
|
||||
Backend: "http://localhost:9110",
|
||||
},
|
||||
{
|
||||
Endpoint: "/ocs/",
|
||||
Backend: "http://localhost:9140",
|
||||
},
|
||||
{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/remote.php/?preview=1",
|
||||
Backend: "http://localhost:9115",
|
||||
},
|
||||
{
|
||||
// TODO the actual REPORT goes to /dav/files/{username}, which is user specific ... how would this work in a spaces world?
|
||||
// TODO what paths are returned? the href contains the full path so it should be possible to return urls from other spaces?
|
||||
// TODO or we allow a REPORT on /dav/spaces to search all spaces and /dav/space/{spaceid} to search a specific space
|
||||
// send webdav REPORT requests to search service
|
||||
Method: "REPORT",
|
||||
Endpoint: "/remote.php/dav/",
|
||||
Backend: "http://localhost:9115", // TODO use registry?
|
||||
},
|
||||
{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/dav/?preview=1",
|
||||
Backend: "http://localhost:9115",
|
||||
},
|
||||
{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/webdav/?preview=1",
|
||||
Backend: "http://localhost:9115",
|
||||
},
|
||||
{
|
||||
Endpoint: "/remote.php/",
|
||||
Service: "com.owncloud.web.ocdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/dav/",
|
||||
Service: "com.owncloud.web.ocdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/webdav/",
|
||||
Service: "com.owncloud.web.ocdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/status",
|
||||
Service: "com.owncloud.web.ocdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/status.php",
|
||||
Service: "com.owncloud.web.ocdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/index.php/",
|
||||
Service: "com.owncloud.web.ocdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/apps/",
|
||||
Service: "com.owncloud.web.ocdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/data",
|
||||
Backend: "http://localhost:9140",
|
||||
},
|
||||
{
|
||||
Endpoint: "/app/", // /app or /apps? ocdav only handles /apps
|
||||
Backend: "http://localhost:9140",
|
||||
},
|
||||
{
|
||||
Endpoint: "/graph/",
|
||||
Backend: "http://localhost:9120",
|
||||
},
|
||||
{
|
||||
Endpoint: "/graph-explorer",
|
||||
Backend: "http://localhost:9135",
|
||||
},
|
||||
{
|
||||
Endpoint: "/api/v0/settings",
|
||||
Backend: "http://localhost:9190",
|
||||
},
|
||||
{
|
||||
Endpoint: "/settings.js",
|
||||
Backend: "http://localhost:9190",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
// provide with defaults for shared logging, since we need a valid destination address for BindEnv.
|
||||
if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil {
|
||||
cfg.Log = &config.Log{
|
||||
Level: cfg.Commons.Log.Level,
|
||||
Pretty: cfg.Commons.Log.Pretty,
|
||||
Color: cfg.Commons.Log.Color,
|
||||
File: cfg.Commons.Log.File,
|
||||
}
|
||||
} else if cfg.Log == nil {
|
||||
cfg.Log = &config.Log{}
|
||||
}
|
||||
// provide with defaults for shared tracing, since we need a valid destination address for BindEnv.
|
||||
if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil {
|
||||
cfg.Tracing = &config.Tracing{
|
||||
Enabled: cfg.Commons.Tracing.Enabled,
|
||||
Type: cfg.Commons.Tracing.Type,
|
||||
Endpoint: cfg.Commons.Tracing.Endpoint,
|
||||
Collector: cfg.Commons.Tracing.Collector,
|
||||
}
|
||||
} else if cfg.Tracing == nil {
|
||||
cfg.Tracing = &config.Tracing{}
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
|
||||
if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" {
|
||||
cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey
|
||||
}
|
||||
|
||||
if cfg.Reva == nil && cfg.Commons != nil && cfg.Commons.Reva != nil {
|
||||
cfg.Reva = &config.Reva{
|
||||
Address: cfg.Commons.Reva.Address,
|
||||
}
|
||||
} else if cfg.Reva == nil {
|
||||
cfg.Reva = &config.Reva{}
|
||||
}
|
||||
}
|
||||
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// sanitize config
|
||||
if cfg.Policies == nil {
|
||||
cfg.Policies = DefaultPolicies()
|
||||
}
|
||||
|
||||
if cfg.PolicySelector == nil {
|
||||
cfg.PolicySelector = &config.PolicySelector{
|
||||
Static: &config.StaticSelectorConf{
|
||||
Policy: "ocis",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.HTTP.Root != "/" {
|
||||
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
|
||||
}
|
||||
}
|
||||
11
services/proxy/pkg/config/http.go
Normal file
11
services/proxy/pkg/config/http.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Addr string `yaml:"addr" env:"PROXY_HTTP_ADDR" desc:"The bind address of the HTTP service."`
|
||||
Root string `yaml:"root" env:"PROXY_HTTP_ROOT" desc:"The root path of the HTTP service."`
|
||||
Namespace string `yaml:"-"`
|
||||
TLSCert string `yaml:"tls_cert" env:"PROXY_TRANSPORT_TLS_CERT"`
|
||||
TLSKey string `yaml:"tls_key" env:"PROXY_TRANSPORT_TLS_KEY"`
|
||||
TLS bool `yaml:"tls" env:"PROXY_TLS"`
|
||||
}
|
||||
9
services/proxy/pkg/config/log.go
Normal file
9
services/proxy/pkg/config/log.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Log defines the available log configuration.
|
||||
type Log struct {
|
||||
Level string `mapstructure:"level" env:"OCIS_LOG_LEVEL;PROXY_LOG_LEVEL" desc:"The log level. Valid values are: \"panic\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\"."`
|
||||
Pretty bool `mapstructure:"pretty" env:"OCIS_LOG_PRETTY;PROXY_LOG_PRETTY" desc:"Activates pretty log output."`
|
||||
Color bool `mapstructure:"color" env:"OCIS_LOG_COLOR;PROXY_LOG_COLOR" desc:"Activates colorized log output."`
|
||||
File string `mapstructure:"file" env:"OCIS_LOG_FILE;PROXY_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."`
|
||||
}
|
||||
45
services/proxy/pkg/config/parser/parse.go
Normal file
45
services/proxy/pkg/config/parser/parse.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config/defaults"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
_, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
if cfg.MachineAuthAPIKey == "" {
|
||||
return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
6
services/proxy/pkg/config/reva.go
Normal file
6
services/proxy/pkg/config/reva.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Reva defines all available REVA configuration.
|
||||
type Reva struct {
|
||||
Address string `yaml:"address" env:"REVA_GATEWAY" desc:"The CS3 gateway endpoint."`
|
||||
}
|
||||
6
services/proxy/pkg/config/service.go
Normal file
6
services/proxy/pkg/config/service.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
9
services/proxy/pkg/config/tracing.go
Normal file
9
services/proxy/pkg/config/tracing.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Tracing defines the available tracing configuration.
|
||||
type Tracing struct {
|
||||
Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;PROXY_TRACING_ENABLED" desc:"Activates tracing."`
|
||||
Type string `yaml:"type" env:"OCIS_TRACING_TYPE;PROXY_TRACING_TYPE" desc:"The type of tracing. Defaults to \"\", which is the same as \"jaeger\". Allowed tracing types are \"jaeger\" and \"\" as of now."`
|
||||
Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;PROXY_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent."`
|
||||
Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;PROXY_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset."`
|
||||
}
|
||||
39
services/proxy/pkg/cs3/client.go
Normal file
39
services/proxy/pkg/cs3/client.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package cs3
|
||||
|
||||
import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
proxytracing "github.com/owncloud/ocis/v2/services/proxy/pkg/tracing"
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func newConn(endpoint string) (*grpc.ClientConn, error) {
|
||||
conn, err := grpc.Dial(
|
||||
endpoint,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithUnaryInterceptor(
|
||||
otelgrpc.UnaryClientInterceptor(
|
||||
otelgrpc.WithTracerProvider(
|
||||
proxytracing.TraceProvider,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// GetGatewayServiceClient returns a new cs3 gateway client
|
||||
func GetGatewayServiceClient(endpoint string) (gateway.GatewayAPIClient, error) {
|
||||
// TODO: check connection pooling
|
||||
conn, err := newConn(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gateway.NewGatewayAPIClient(conn), nil
|
||||
}
|
||||
17
services/proxy/pkg/logging/logging.go
Normal file
17
services/proxy/pkg/logging/logging.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// LoggerFromConfig initializes a service-specific logger instance.
|
||||
func Configure(name string, cfg *config.Log) log.Logger {
|
||||
return log.NewLogger(
|
||||
log.Name(name),
|
||||
log.Level(cfg.Level),
|
||||
log.Pretty(cfg.Pretty),
|
||||
log.Color(cfg.Color),
|
||||
log.File(cfg.File),
|
||||
)
|
||||
}
|
||||
57
services/proxy/pkg/metrics/metrics.go
Normal file
57
services/proxy/pkg/metrics/metrics.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
Namespace = "ocis"
|
||||
|
||||
// Subsystem defines the subsystem for the defines metrics.
|
||||
Subsystem = "proxy"
|
||||
)
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
Counter *prometheus.CounterVec
|
||||
Latency *prometheus.SummaryVec
|
||||
Duration *prometheus.HistogramVec
|
||||
BuildInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{
|
||||
Counter: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "proxy_total",
|
||||
Help: "How many proxy requests processed",
|
||||
}, []string{}),
|
||||
Latency: prometheus.NewSummaryVec(prometheus.SummaryOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "proxy_latency_microseconds",
|
||||
Help: "proxy request latencies in microseconds",
|
||||
}, []string{}),
|
||||
Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "proxy_duration_seconds",
|
||||
Help: "proxy method request time in seconds",
|
||||
}, []string{}),
|
||||
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "build_info",
|
||||
Help: "Build Information",
|
||||
}, []string{"versions"}),
|
||||
}
|
||||
|
||||
_ = prometheus.Register(m.Counter)
|
||||
_ = prometheus.Register(m.Latency)
|
||||
_ = prometheus.Register(m.Duration)
|
||||
_ = prometheus.Register(m.BuildInfo)
|
||||
return m
|
||||
}
|
||||
32
services/proxy/pkg/middleware/accesslog.go
Normal file
32
services/proxy/pkg/middleware/accesslog.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
)
|
||||
|
||||
// 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()
|
||||
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(wrap, r)
|
||||
|
||||
logger.Info().
|
||||
Str("proto", r.Proto).
|
||||
Str("request", chimiddleware.GetReqID(r.Context())).
|
||||
Str("remote-addr", r.RemoteAddr).
|
||||
Str("method", r.Method).
|
||||
Int("status", wrap.Status()).
|
||||
Str("path", r.URL.Path).
|
||||
Dur("duration", time.Since(start)).
|
||||
Int("bytes", wrap.BytesWritten()).
|
||||
Msg("access-log")
|
||||
})
|
||||
}
|
||||
}
|
||||
118
services/proxy/pkg/middleware/account_resolver.go
Normal file
118
services/proxy/pkg/middleware/account_resolver.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
|
||||
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
)
|
||||
|
||||
// AccountResolver provides a middleware which mints a jwt and adds it to the proxied request based
|
||||
// on the oidc-claims
|
||||
func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &accountResolver{
|
||||
next: next,
|
||||
logger: logger,
|
||||
userProvider: options.UserProvider,
|
||||
userOIDCClaim: options.UserOIDCClaim,
|
||||
userCS3Claim: options.UserCS3Claim,
|
||||
autoProvisionAccounts: options.AutoprovisionAccounts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type accountResolver struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
userProvider backend.UserBackend
|
||||
autoProvisionAccounts bool
|
||||
userOIDCClaim string
|
||||
userCS3Claim string
|
||||
}
|
||||
|
||||
// TODO do not use the context to store values: https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
|
||||
func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
claims := oidc.FromContext(ctx)
|
||||
user, ok := revactx.ContextGetUser(ctx)
|
||||
token := ""
|
||||
// TODO what if an X-Access-Token is set? happens eg for download requests to the /data endpoint in the reva frontend
|
||||
|
||||
if claims == nil && !ok {
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil && claims != nil {
|
||||
|
||||
var err error
|
||||
var value string
|
||||
var ok bool
|
||||
if value, ok = claims[m.userOIDCClaim].(string); !ok || value == "" {
|
||||
m.logger.Error().Str("claim", m.userOIDCClaim).Interface("claims", claims).Msg("claim not set or empty")
|
||||
w.WriteHeader(http.StatusInternalServerError) // admin needs to make the idp send the right claim
|
||||
return
|
||||
}
|
||||
|
||||
user, token, err = m.userProvider.GetUserByClaims(req.Context(), m.userCS3Claim, value, true)
|
||||
|
||||
if errors.Is(err, backend.ErrAccountNotFound) {
|
||||
m.logger.Debug().Str("claim", m.userOIDCClaim).Str("value", value).Msg("User by claim not found")
|
||||
if !m.autoProvisionAccounts {
|
||||
m.logger.Debug().Interface("claims", claims).Msg("Autoprovisioning disabled")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
m.logger.Debug().Interface("claims", claims).Msg("Autoprovisioning user")
|
||||
user, err = m.userProvider.CreateUserFromClaims(req.Context(), claims)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Autoprovisioning user failed")
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, backend.ErrAccountDisabled) {
|
||||
m.logger.Debug().Interface("claims", claims).Msg("Disabled")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Could not get user by claim")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// add user to context for selectors
|
||||
ctx = revactx.ContextSetUser(ctx, user)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
m.logger.Debug().Interface("claims", claims).Interface("user", user).Msg("associated claims with user")
|
||||
} else if user != nil {
|
||||
var err error
|
||||
_, token, err = m.userProvider.GetUserByClaims(req.Context(), "username", user.Username, true)
|
||||
|
||||
if errors.Is(err, backend.ErrAccountDisabled) {
|
||||
m.logger.Debug().Interface("user", user).Msg("Disabled")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Could not get user by claim")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set(revactx.TokenHeader, token)
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
152
services/proxy/pkg/middleware/account_resolver_test.go
Normal file
152
services/proxy/pkg/middleware/account_resolver_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/auth/scope"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/token/manager/jwt"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTokenIsAddedWithMailClaim(t *testing.T) {
|
||||
sut := newMockAccountResolver(&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: "https://idx.example.com", OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, oidc.Email, "mail")
|
||||
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
oidc.Email: "foo@example.com",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.Contains(t, token, "eyJ")
|
||||
}
|
||||
|
||||
func TestTokenIsAddedWithUsernameClaim(t *testing.T) {
|
||||
sut := newMockAccountResolver(&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: "https://idx.example.com", OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, oidc.PreferredUsername, "username")
|
||||
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
assert.Contains(t, token, "eyJ")
|
||||
}
|
||||
|
||||
func TestNSkipOnNoClaims(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.Email, "mail")
|
||||
req, rw := mockRequest(nil)
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get("x-access-token")
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedOnUserNotFound(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.PreferredUsername, "username")
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedOnUserDisabled(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.PreferredUsername, "username")
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
||||
}
|
||||
|
||||
func TestInternalServerErrorOnMissingMailAndUsername(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.Email, "mail")
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
func newMockAccountResolver(userBackendResult *userv1beta1.User, userBackendErr error, oidcclaim, cs3claim string) http.Handler {
|
||||
tokenManager, _ := jwt.New(map[string]interface{}{
|
||||
"secret": "change-me",
|
||||
"expires": int64(60),
|
||||
})
|
||||
|
||||
token := ""
|
||||
if userBackendResult != nil {
|
||||
s, _ := scope.AddOwnerScope(nil)
|
||||
token, _ = tokenManager.MintToken(context.Background(), userBackendResult, s)
|
||||
}
|
||||
|
||||
mock := &test.UserBackendMock{
|
||||
GetUserByClaimsFunc: func(ctx context.Context, claim string, value string, withRoles bool) (*userv1beta1.User, string, error) {
|
||||
return userBackendResult, token, userBackendErr
|
||||
},
|
||||
}
|
||||
|
||||
return AccountResolver(
|
||||
Logger(log.NewLogger()),
|
||||
UserProvider(mock),
|
||||
TokenManagerConfig(config.TokenManager{JWTSecret: "secret"}),
|
||||
UserOIDCClaim(oidcclaim),
|
||||
UserCS3Claim(cs3claim),
|
||||
AutoprovisionAccounts(false),
|
||||
)(mockHandler{})
|
||||
}
|
||||
|
||||
func mockRequest(claims map[string]interface{}) (*http.Request, *httptest.ResponseRecorder) {
|
||||
if claims == nil {
|
||||
return httptest.NewRequest("GET", "http://example.com/foo", nil), httptest.NewRecorder()
|
||||
}
|
||||
|
||||
ctx := oidc.NewContext(context.Background(), claims)
|
||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil).WithContext(ctx)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
return req, rw
|
||||
}
|
||||
|
||||
type mockHandler struct{}
|
||||
|
||||
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
|
||||
132
services/proxy/pkg/middleware/authentication.go
Normal file
132
services/proxy/pkg/middleware/authentication.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// SupportedAuthStrategies stores configured challenges.
|
||||
SupportedAuthStrategies []string
|
||||
|
||||
// ProxyWwwAuthenticate is a list of endpoints that do not rely on reva underlying authentication, such as ocs.
|
||||
// services that fallback to reva authentication are declared in the "frontend" command on oCIS. It is a list of strings
|
||||
// to be regexp compiled.
|
||||
ProxyWwwAuthenticate = []string{"/ocs/v[12].php/cloud/"}
|
||||
|
||||
// WWWAuthenticate captures the Www-Authenticate header string.
|
||||
WWWAuthenticate = "Www-Authenticate"
|
||||
)
|
||||
|
||||
// userAgentLocker aids in dependency injection for helper methods. The set of fields is arbitrary and the only relation
|
||||
// they share is to fulfill their duty and lock a User-Agent to its correct challenge if configured.
|
||||
type userAgentLocker struct {
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
locks map[string]string // locks represents a reva user-agent:challenge mapping.
|
||||
fallback string
|
||||
}
|
||||
|
||||
// Authentication is a higher order authentication middleware.
|
||||
func Authentication(opts ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(opts...)
|
||||
|
||||
configureSupportedChallenges(options)
|
||||
oidc := newOIDCAuth(options)
|
||||
basic := newBasicAuth(options)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if options.OIDCIss != "" && options.EnableBasicAuth {
|
||||
oidc(basic(next)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
if options.OIDCIss != "" && !options.EnableBasicAuth {
|
||||
oidc(next).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
if options.OIDCIss == "" && options.EnableBasicAuth {
|
||||
basic(next).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// configureSupportedChallenges adds known authentication challenges to the current session.
|
||||
func configureSupportedChallenges(options Options) {
|
||||
if options.OIDCIss != "" {
|
||||
SupportedAuthStrategies = append(SupportedAuthStrategies, "bearer")
|
||||
}
|
||||
|
||||
if options.EnableBasicAuth {
|
||||
SupportedAuthStrategies = append(SupportedAuthStrategies, "basic")
|
||||
}
|
||||
}
|
||||
|
||||
func writeSupportedAuthenticateHeader(w http.ResponseWriter, r *http.Request) {
|
||||
for i := 0; i < len(SupportedAuthStrategies); i++ {
|
||||
w.Header().Add(WWWAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", strings.Title(SupportedAuthStrategies[i]), r.Host))
|
||||
}
|
||||
}
|
||||
|
||||
func removeSuperfluousAuthenticate(w http.ResponseWriter) {
|
||||
w.Header().Del(WWWAuthenticate)
|
||||
}
|
||||
|
||||
// userAgentAuthenticateLockIn sets Www-Authenticate according to configured user agents. This is useful for the case of
|
||||
// legacy clients that do not support protocols like OIDC or OAuth and want to lock a given user agent to a challenge
|
||||
// such as basic. For more context check https://github.com/cs3org/reva/pull/1350
|
||||
func userAgentAuthenticateLockIn(w http.ResponseWriter, r *http.Request, locks map[string]string, fallback string) {
|
||||
u := userAgentLocker{
|
||||
w: w,
|
||||
r: r,
|
||||
locks: locks,
|
||||
fallback: fallback,
|
||||
}
|
||||
|
||||
for i := 0; i < len(ProxyWwwAuthenticate); i++ {
|
||||
evalRequestURI(&u, i)
|
||||
}
|
||||
}
|
||||
|
||||
func evalRequestURI(l *userAgentLocker, i int) {
|
||||
r := regexp.MustCompile(ProxyWwwAuthenticate[i])
|
||||
if r.Match([]byte(l.r.RequestURI)) {
|
||||
for k, v := range l.locks {
|
||||
if strings.Contains(k, l.r.UserAgent()) {
|
||||
removeSuperfluousAuthenticate(l.w)
|
||||
l.w.Header().Add(WWWAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", strings.Title(v), l.r.Host))
|
||||
return
|
||||
}
|
||||
}
|
||||
l.w.Header().Add(WWWAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", strings.Title(l.fallback), l.r.Host))
|
||||
}
|
||||
}
|
||||
|
||||
// newOIDCAuth returns a configured oidc middleware
|
||||
func newOIDCAuth(options Options) func(http.Handler) http.Handler {
|
||||
return OIDCAuth(
|
||||
Logger(options.Logger),
|
||||
OIDCProviderFunc(options.OIDCProviderFunc),
|
||||
HTTPClient(options.HTTPClient),
|
||||
OIDCIss(options.OIDCIss),
|
||||
TokenCacheSize(options.UserinfoCacheSize),
|
||||
TokenCacheTTL(options.UserinfoCacheTTL),
|
||||
CredentialsByUserAgent(options.CredentialsByUserAgent),
|
||||
)
|
||||
}
|
||||
|
||||
// newBasicAuth returns a configured basic middleware
|
||||
func newBasicAuth(options Options) func(http.Handler) http.Handler {
|
||||
return BasicAuth(
|
||||
UserProvider(options.UserProvider),
|
||||
Logger(options.Logger),
|
||||
EnableBasicAuth(options.EnableBasicAuth),
|
||||
OIDCIss(options.OIDCIss),
|
||||
UserOIDCClaim(options.UserOIDCClaim),
|
||||
UserCS3Claim(options.UserCS3Claim),
|
||||
CredentialsByUserAgent(options.CredentialsByUserAgent),
|
||||
)
|
||||
}
|
||||
145
services/proxy/pkg/middleware/basic_auth.go
Normal file
145
services/proxy/pkg/middleware/basic_auth.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/webdav"
|
||||
)
|
||||
|
||||
// BasicAuth provides a middleware to check if BasicAuth is provided
|
||||
func BasicAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
|
||||
if options.EnableBasicAuth {
|
||||
options.Logger.Warn().Msg("basic auth enabled, use only for testing or development")
|
||||
}
|
||||
|
||||
h := basicAuth{
|
||||
logger: logger,
|
||||
enabled: options.EnableBasicAuth,
|
||||
userProvider: options.UserProvider,
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, req *http.Request) {
|
||||
if h.isPublicLink(req) || !h.isBasicAuth(req) || h.isOIDCTokenAuth(req) {
|
||||
if !h.isPublicLink(req) {
|
||||
userAgentAuthenticateLockIn(w, req, options.CredentialsByUserAgent, "basic")
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
removeSuperfluousAuthenticate(w)
|
||||
login, password, _ := req.BasicAuth()
|
||||
user, _, err := h.userProvider.Authenticate(req.Context(), login, password)
|
||||
|
||||
// touch is a user agent locking guard, when touched changes to true it indicates the User-Agent on the
|
||||
// request is configured to support only one challenge, it it remains untouched, there are no considera-
|
||||
// tions and we should write all available authentication challenges to the response.
|
||||
touch := false
|
||||
|
||||
if err != nil {
|
||||
for k, v := range options.CredentialsByUserAgent {
|
||||
if strings.Contains(k, req.UserAgent()) {
|
||||
removeSuperfluousAuthenticate(w)
|
||||
w.Header().Add("Www-Authenticate", fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", strings.Title(v), req.Host))
|
||||
touch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// if the request is not bound to any user agent, write all available challenges
|
||||
if !touch {
|
||||
writeSupportedAuthenticateHeader(w, req)
|
||||
}
|
||||
|
||||
// if the request is a PROPFIND return a WebDAV error code.
|
||||
// TODO: The proxy has to be smart enough to detect when a request is directed towards a webdav server
|
||||
// and react accordingly.
|
||||
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
||||
if webdav.IsWebdavRequest(req) {
|
||||
b, err := webdav.Marshal(webdav.Exception{
|
||||
Code: webdav.SabredavPermissionDenied,
|
||||
Message: "Authentication error",
|
||||
})
|
||||
|
||||
webdav.HandleWebdavError(w, b, err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// fake oidc claims
|
||||
claims := map[string]interface{}{
|
||||
oidc.Iss: user.Id.Idp,
|
||||
oidc.PreferredUsername: user.Username,
|
||||
oidc.Email: user.Mail,
|
||||
oidc.OwncloudUUID: user.Id.OpaqueId,
|
||||
}
|
||||
|
||||
if options.UserCS3Claim == "userid" {
|
||||
// set the custom user claim only if users will be looked up by the userid on the CS3api
|
||||
// OpaqueId contains the userid configured in STORAGE_LDAP_USER_SCHEMA_UID
|
||||
claims[options.UserOIDCClaim] = user.Id.OpaqueId
|
||||
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, req.WithContext(oidc.NewContext(req.Context(), claims)))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type basicAuth struct {
|
||||
logger log.Logger
|
||||
enabled bool
|
||||
userProvider backend.UserBackend
|
||||
}
|
||||
|
||||
func (m basicAuth) isPublicLink(req *http.Request) bool {
|
||||
login, _, ok := req.BasicAuth()
|
||||
|
||||
if !ok || login != "public" {
|
||||
return false
|
||||
}
|
||||
|
||||
publicPaths := []string{
|
||||
"/dav/public-files/",
|
||||
"/remote.php/dav/public-files/",
|
||||
"/remote.php/ocs/apps/files_sharing/api/v1/tokeninfo/unprotected",
|
||||
"/ocs/v1.php/cloud/capabilities",
|
||||
"/data",
|
||||
}
|
||||
isPublic := false
|
||||
|
||||
for _, p := range publicPaths {
|
||||
if strings.HasPrefix(req.URL.Path, p) {
|
||||
isPublic = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return isPublic
|
||||
}
|
||||
|
||||
// The token auth endpoint uses basic auth for clients, see https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest
|
||||
// > The Client MUST authenticate to the Token Endpoint using the HTTP Basic method, as described in 2.3.1 of OAuth 2.0.
|
||||
func (m basicAuth) isOIDCTokenAuth(req *http.Request) bool {
|
||||
return req.URL.Path == "/konnect/v1/token"
|
||||
}
|
||||
|
||||
func (m basicAuth) isBasicAuth(req *http.Request) bool {
|
||||
_, _, ok := req.BasicAuth()
|
||||
return m.enabled && ok
|
||||
}
|
||||
40
services/proxy/pkg/middleware/basic_auth_test.go
Normal file
40
services/proxy/pkg/middleware/basic_auth_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/**/
|
||||
|
||||
func TestBasicAuth__isPublicLink(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
username string
|
||||
expected bool
|
||||
}{
|
||||
{url: "/remote.php/dav/public-files/", username: "", expected: false},
|
||||
{url: "/remote.php/dav/public-files/", username: "abc", expected: false},
|
||||
{url: "/remote.php/dav/public-files/", username: "private", expected: false},
|
||||
{url: "/remote.php/dav/public-files/", username: "public", expected: true},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "", expected: false},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "abc", expected: false},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "private", expected: false},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "public", expected: true},
|
||||
{url: "/ocs/v1.php/cloud/users/admin", username: "public", expected: false},
|
||||
}
|
||||
ba := basicAuth{}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest("", tt.url, nil)
|
||||
|
||||
if tt.username != "" {
|
||||
req.SetBasicAuth(tt.username, "")
|
||||
}
|
||||
|
||||
result := ba.isPublicLink(req)
|
||||
if result != tt.expected {
|
||||
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
64
services/proxy/pkg/middleware/create_home.go
Normal file
64
services/proxy/pkg/middleware/create_home.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// CreateHome provides a middleware which sends a CreateHome request to the reva gateway
|
||||
func CreateHome(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &createHome{
|
||||
next: next,
|
||||
logger: logger,
|
||||
revaGatewayClient: options.RevaGatewayClient,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type createHome struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
revaGatewayClient gateway.GatewayAPIClient
|
||||
}
|
||||
|
||||
func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if !m.shouldServe(req) {
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
token := req.Header.Get("x-access-token")
|
||||
|
||||
// we need to pass the token to authenticate the CreateHome request.
|
||||
//ctx := tokenpkg.ContextSetToken(r.Context(), token)
|
||||
ctx := metadata.AppendToOutgoingContext(req.Context(), revactx.TokenHeader, token)
|
||||
|
||||
createHomeReq := &provider.CreateHomeRequest{}
|
||||
createHomeRes, err := m.revaGatewayClient.CreateHome(ctx, createHomeReq)
|
||||
|
||||
if err != nil {
|
||||
m.logger.Err(err).Msg("error calling CreateHome")
|
||||
} else if createHomeRes.Status.Code != rpc.Code_CODE_OK {
|
||||
err := status.NewErrorFromCode(createHomeRes.Status.Code, "gateway")
|
||||
if createHomeRes.Status.Code != rpc.Code_CODE_ALREADY_EXISTS {
|
||||
m.logger.Err(err).Msg("error when calling Createhome")
|
||||
}
|
||||
}
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func (m createHome) shouldServe(req *http.Request) bool {
|
||||
return req.Header.Get("x-access-token") != ""
|
||||
}
|
||||
19
services/proxy/pkg/middleware/https_redirect.go
Normal file
19
services/proxy/pkg/middleware/https_redirect.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPSRedirect redirects insecure requests to https
|
||||
func HTTPSRedirect(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
proto := req.Header.Get("x-forwarded-proto")
|
||||
if proto == "http" || proto == "HTTP" {
|
||||
http.Redirect(res, req, fmt.Sprintf("https://%s%s", req.Host, req.URL), http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(res, req)
|
||||
})
|
||||
}
|
||||
178
services/proxy/pkg/middleware/oidc_auth.go
Normal file
178
services/proxy/pkg/middleware/oidc_auth.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
|
||||
gOidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/sync"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OIDCProvider used to mock the oidc provider during tests
|
||||
type OIDCProvider interface {
|
||||
UserInfo(ctx context.Context, ts oauth2.TokenSource) (*gOidc.UserInfo, error)
|
||||
}
|
||||
|
||||
// OIDCAuth provides a middleware to check access secured by a static token.
|
||||
func OIDCAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
tokenCache := sync.NewCache(options.UserinfoCacheSize)
|
||||
|
||||
h := oidcAuth{
|
||||
logger: options.Logger,
|
||||
providerFunc: options.OIDCProviderFunc,
|
||||
httpClient: options.HTTPClient,
|
||||
oidcIss: options.OIDCIss,
|
||||
TokenManagerConfig: options.TokenManagerConfig,
|
||||
tokenCache: &tokenCache,
|
||||
tokenCacheTTL: options.UserinfoCacheTTL,
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// there is no bearer token on the request,
|
||||
if !h.shouldServe(req) {
|
||||
// oidc supported but token not present, add header and handover to the next middleware.
|
||||
userAgentAuthenticateLockIn(w, req, options.CredentialsByUserAgent, "bearer")
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if h.getProvider() == nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
|
||||
|
||||
claims, status := h.getClaims(token, req)
|
||||
if status != 0 {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
|
||||
// inject claims to the request context for the account_uuid middleware.
|
||||
req = req.WithContext(oidc.NewContext(req.Context(), claims))
|
||||
|
||||
// store claims in context
|
||||
// uses the original context, not the one with probably reduced security
|
||||
next.ServeHTTP(w, req.WithContext(oidc.NewContext(req.Context(), claims)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type oidcAuth struct {
|
||||
logger log.Logger
|
||||
provider OIDCProvider
|
||||
providerFunc func() (OIDCProvider, error)
|
||||
httpClient *http.Client
|
||||
oidcIss string
|
||||
tokenCache *sync.Cache
|
||||
tokenCacheTTL time.Duration
|
||||
TokenManagerConfig config.TokenManager
|
||||
}
|
||||
|
||||
func (m oidcAuth) getClaims(token string, req *http.Request) (claims map[string]interface{}, status int) {
|
||||
hit := m.tokenCache.Load(token)
|
||||
if hit == nil {
|
||||
// TODO cache userinfo for access token if we can determine the expiry (which works in case it is a jwt based access token)
|
||||
oauth2Token := &oauth2.Token{
|
||||
AccessToken: token,
|
||||
}
|
||||
|
||||
userInfo, err := m.getProvider().UserInfo(
|
||||
context.WithValue(req.Context(), oauth2.HTTPClient, m.httpClient),
|
||||
oauth2.StaticTokenSource(oauth2Token),
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Failed to get userinfo")
|
||||
status = http.StatusUnauthorized
|
||||
return
|
||||
}
|
||||
|
||||
if err := userInfo.Claims(&claims); err != nil {
|
||||
m.logger.Error().Err(err).Interface("userinfo", userInfo).Msg("failed to unmarshal userinfo claims")
|
||||
status = http.StatusInternalServerError
|
||||
return
|
||||
}
|
||||
|
||||
expiration := m.extractExpiration(token)
|
||||
m.tokenCache.Store(token, claims, expiration)
|
||||
|
||||
m.logger.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Time("expiration", expiration.UTC()).Msg("unmarshalled and cached userinfo")
|
||||
return
|
||||
}
|
||||
|
||||
var ok bool
|
||||
if claims, ok = hit.V.(map[string]interface{}); !ok {
|
||||
status = http.StatusInternalServerError
|
||||
return
|
||||
}
|
||||
m.logger.Debug().Interface("claims", claims).Msg("cache hit for userinfo")
|
||||
return
|
||||
}
|
||||
|
||||
// extractExpiration tries to parse and extract the expiration from the provided token. It might not even be a jwt.
|
||||
// defaults to the configured fallback TTL.
|
||||
// TODO: use introspection endpoint if available in the oidc configuration. Still needs a fallback to configured TTL.
|
||||
func (m oidcAuth) extractExpiration(token string) time.Time {
|
||||
defaultExpiration := time.Now().Add(m.tokenCacheTTL)
|
||||
|
||||
t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(m.TokenManagerConfig.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return defaultExpiration
|
||||
}
|
||||
|
||||
at, ok := t.Claims.(jwt.StandardClaims)
|
||||
if !ok || at.ExpiresAt == 0 {
|
||||
return defaultExpiration
|
||||
}
|
||||
|
||||
return time.Unix(at.ExpiresAt, 0)
|
||||
}
|
||||
|
||||
func (m oidcAuth) shouldServe(req *http.Request) bool {
|
||||
header := req.Header.Get("Authorization")
|
||||
|
||||
if m.oidcIss == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// todo: looks dirty, check later
|
||||
// TODO: make a PR to coreos/go-oidc for exposing userinfo endpoint on provider, see https://github.com/coreos/go-oidc/issues/248
|
||||
for _, ignoringPath := range []string{"/konnect/v1/userinfo", "/status.php"} {
|
||||
if req.URL.Path == ignoringPath {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return strings.HasPrefix(header, "Bearer ")
|
||||
}
|
||||
|
||||
func (m *oidcAuth) getProvider() OIDCProvider {
|
||||
if m.provider == nil {
|
||||
// Lazily initialize a provider
|
||||
|
||||
// provider needs to be cached as when it is created
|
||||
// it will fetch the keys from the issuer using the .well-known
|
||||
// endpoint
|
||||
provider, err := m.providerFunc()
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("could not initialize oidcAuth provider")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.provider = provider
|
||||
}
|
||||
return m.provider
|
||||
}
|
||||
67
services/proxy/pkg/middleware/oidc_auth_test.go
Normal file
67
services/proxy/pkg/middleware/oidc_auth_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestOIDCAuthMiddleware(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||
|
||||
m := OIDCAuth(
|
||||
Logger(log.NewLogger()),
|
||||
OIDCProviderFunc(func() (OIDCProvider, error) {
|
||||
return mockOP(false), nil
|
||||
}),
|
||||
OIDCIss("https://localhost:9200"),
|
||||
)(next)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "https://idp.example.com", nil)
|
||||
r.Header.Set("Authorization", "Bearer sometoken")
|
||||
w := httptest.NewRecorder()
|
||||
m.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected an internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
type mockOIDCProvider struct {
|
||||
UserInfoFunc func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error)
|
||||
}
|
||||
|
||||
// UserInfo will panic if the function has been called, but not mocked
|
||||
func (m mockOIDCProvider) UserInfo(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
|
||||
if m.UserInfoFunc != nil {
|
||||
return m.UserInfoFunc(ctx, ts)
|
||||
}
|
||||
|
||||
panic("UserInfo was called in test but not mocked")
|
||||
}
|
||||
|
||||
func mockOP(retErr bool) OIDCProvider {
|
||||
if retErr {
|
||||
return &mockOIDCProvider{
|
||||
UserInfoFunc: func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
|
||||
return nil, fmt.Errorf("error returned by mockOIDCProvider UserInfo")
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
return &mockOIDCProvider{
|
||||
UserInfoFunc: func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
|
||||
ui := &oidc.UserInfo{
|
||||
// claims: private ...
|
||||
}
|
||||
return ui, nil
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
195
services/proxy/pkg/middleware/options.go
Normal file
195
services/proxy/pkg/middleware/options.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
|
||||
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
// Logger to use for logging, must be set
|
||||
Logger log.Logger
|
||||
// TokenManagerConfig for communicating with the reva token manager
|
||||
TokenManagerConfig config.TokenManager
|
||||
// PolicySelectorConfig for using the policy selector
|
||||
PolicySelector config.PolicySelector
|
||||
// HTTPClient to use for communication with the oidcAuth provider
|
||||
HTTPClient *http.Client
|
||||
// UP
|
||||
UserProvider backend.UserBackend
|
||||
// SettingsRoleService for the roles API in settings
|
||||
SettingsRoleService settingssvc.RoleService
|
||||
// OIDCProviderFunc to lazily initialize an oidc provider, must be set for the oidc_auth middleware
|
||||
OIDCProviderFunc func() (OIDCProvider, error)
|
||||
// OIDCIss is the oidcAuth-issuer
|
||||
OIDCIss string
|
||||
// RevaGatewayClient to send requests to the reva gateway
|
||||
RevaGatewayClient gateway.GatewayAPIClient
|
||||
// Store for persisting data
|
||||
Store storesvc.StoreService
|
||||
// PreSignedURLConfig to configure the middleware
|
||||
PreSignedURLConfig config.PreSignedURL
|
||||
// UserOIDCClaim to read from the oidc claims
|
||||
UserOIDCClaim string
|
||||
// UserCS3Claim to use when looking up a user in the CS3 API
|
||||
UserCS3Claim string
|
||||
// AutoprovisionAccounts when an accountResolver does not exist.
|
||||
AutoprovisionAccounts bool
|
||||
// EnableBasicAuth to allow basic auth
|
||||
EnableBasicAuth bool
|
||||
// UserinfoCacheSize defines the max number of entries in the userinfo cache, intended for the oidc_auth middleware
|
||||
UserinfoCacheSize int
|
||||
// UserinfoCacheTTL sets the max cache duration for the userinfo cache, intended for the oidc_auth middleware
|
||||
UserinfoCacheTTL time.Duration
|
||||
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
|
||||
CredentialsByUserAgent map[string]string
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(l log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = l
|
||||
}
|
||||
}
|
||||
|
||||
// TokenManagerConfig provides a function to set the token manger config option.
|
||||
func TokenManagerConfig(cfg config.TokenManager) Option {
|
||||
return func(o *Options) {
|
||||
o.TokenManagerConfig = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// PolicySelectorConfig provides a function to set the policy selector config option.
|
||||
func PolicySelectorConfig(cfg config.PolicySelector) Option {
|
||||
return func(o *Options) {
|
||||
o.PolicySelector = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPClient provides a function to set the http client config option.
|
||||
func HTTPClient(c *http.Client) Option {
|
||||
return func(o *Options) {
|
||||
o.HTTPClient = c
|
||||
}
|
||||
}
|
||||
|
||||
// SettingsRoleService provides a function to set the role service option.
|
||||
func SettingsRoleService(rc settingssvc.RoleService) Option {
|
||||
return func(o *Options) {
|
||||
o.SettingsRoleService = rc
|
||||
}
|
||||
}
|
||||
|
||||
// OIDCProviderFunc provides a function to set the the oidc provider function option.
|
||||
func OIDCProviderFunc(f func() (OIDCProvider, error)) Option {
|
||||
return func(o *Options) {
|
||||
o.OIDCProviderFunc = f
|
||||
}
|
||||
}
|
||||
|
||||
// OIDCIss sets the oidcAuth issuer url
|
||||
func OIDCIss(iss string) Option {
|
||||
return func(o *Options) {
|
||||
o.OIDCIss = iss
|
||||
}
|
||||
}
|
||||
|
||||
// CredentialsByUserAgent sets UserAgentChallenges.
|
||||
func CredentialsByUserAgent(v map[string]string) Option {
|
||||
return func(o *Options) {
|
||||
o.CredentialsByUserAgent = v
|
||||
}
|
||||
}
|
||||
|
||||
// RevaGatewayClient provides a function to set the the reva gateway service client option.
|
||||
func RevaGatewayClient(gc gateway.GatewayAPIClient) Option {
|
||||
return func(o *Options) {
|
||||
o.RevaGatewayClient = gc
|
||||
}
|
||||
}
|
||||
|
||||
// Store provides a function to set the store option.
|
||||
func Store(sc storesvc.StoreService) Option {
|
||||
return func(o *Options) {
|
||||
o.Store = sc
|
||||
}
|
||||
}
|
||||
|
||||
// PreSignedURLConfig provides a function to set the PreSignedURL config
|
||||
func PreSignedURLConfig(cfg config.PreSignedURL) Option {
|
||||
return func(o *Options) {
|
||||
o.PreSignedURLConfig = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// UserOIDCClaim provides a function to set the UserClaim config
|
||||
func UserOIDCClaim(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.UserOIDCClaim = val
|
||||
}
|
||||
}
|
||||
|
||||
// UserCS3Claim provides a function to set the UserClaimType config
|
||||
func UserCS3Claim(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.UserCS3Claim = val
|
||||
}
|
||||
}
|
||||
|
||||
// AutoprovisionAccounts provides a function to set the AutoprovisionAccounts config
|
||||
func AutoprovisionAccounts(val bool) Option {
|
||||
return func(o *Options) {
|
||||
o.AutoprovisionAccounts = val
|
||||
}
|
||||
}
|
||||
|
||||
// EnableBasicAuth provides a function to set the EnableBasicAuth config
|
||||
func EnableBasicAuth(enableBasicAuth bool) Option {
|
||||
return func(o *Options) {
|
||||
o.EnableBasicAuth = enableBasicAuth
|
||||
}
|
||||
}
|
||||
|
||||
// TokenCacheSize provides a function to set the TokenCacheSize
|
||||
func TokenCacheSize(size int) Option {
|
||||
return func(o *Options) {
|
||||
o.UserinfoCacheSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// TokenCacheTTL provides a function to set the TokenCacheTTL
|
||||
func TokenCacheTTL(ttl time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.UserinfoCacheTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// UserProvider sets the accounts user provider
|
||||
func UserProvider(up backend.UserBackend) Option {
|
||||
return func(o *Options) {
|
||||
o.UserProvider = up
|
||||
}
|
||||
}
|
||||
72
services/proxy/pkg/middleware/public_share_auth.go
Normal file
72
services/proxy/pkg/middleware/public_share_auth.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
)
|
||||
|
||||
const (
|
||||
headerRevaAccessToken = "x-access-token"
|
||||
headerShareToken = "public-token"
|
||||
basicAuthPasswordPrefix = "password|"
|
||||
authenticationType = "publicshares"
|
||||
)
|
||||
|
||||
// PublicShareAuth ...
|
||||
func PublicShareAuth(opts ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(opts...)
|
||||
logger := options.Logger
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
shareToken := r.Header.Get(headerShareToken)
|
||||
if shareToken == "" {
|
||||
shareToken = r.URL.Query().Get(headerShareToken)
|
||||
}
|
||||
|
||||
// Currently we only want to authenticate app open request coming from public shares.
|
||||
if shareToken == "" {
|
||||
// Don't authenticate
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var sharePassword string
|
||||
if signature := r.URL.Query().Get("signature"); signature != "" {
|
||||
expiration := r.URL.Query().Get("expiration")
|
||||
if expiration == "" {
|
||||
logger.Warn().Str("signature", signature).Msg("cannot do signature auth without the expiration")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
sharePassword = strings.Join([]string{"signature", signature, expiration}, "|")
|
||||
} else {
|
||||
// We can ignore the username since it is always set to "public" in public shares.
|
||||
_, password, ok := r.BasicAuth()
|
||||
|
||||
sharePassword = basicAuthPasswordPrefix
|
||||
if ok {
|
||||
sharePassword += password
|
||||
}
|
||||
}
|
||||
|
||||
authResp, err := options.RevaGatewayClient.Authenticate(r.Context(), &gateway.AuthenticateRequest{
|
||||
Type: authenticationType,
|
||||
ClientId: shareToken,
|
||||
ClientSecret: sharePassword,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("public_share_token", shareToken).Msg("could not authenticate public share")
|
||||
// try another middleware
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Add(headerRevaAccessToken, authResp.Token)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
69
services/proxy/pkg/middleware/selector_cookie.go
Normal file
69
services/proxy/pkg/middleware/selector_cookie.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/proxy/policy"
|
||||
)
|
||||
|
||||
// SelectorCookie provides a middleware which
|
||||
func SelectorCookie(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
policySelector := options.PolicySelector
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &selectorCookie{
|
||||
next: next,
|
||||
logger: logger,
|
||||
policySelector: policySelector,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type selectorCookie struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
policySelector config.PolicySelector
|
||||
}
|
||||
|
||||
func (m selectorCookie) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if m.policySelector.Regex == nil && m.policySelector.Claims == nil {
|
||||
// only set selector cookie for regex and claim selectors
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
selectorCookieName := ""
|
||||
if m.policySelector.Regex != nil {
|
||||
selectorCookieName = m.policySelector.Regex.SelectorCookieName
|
||||
} else if m.policySelector.Claims != nil {
|
||||
selectorCookieName = m.policySelector.Claims.SelectorCookieName
|
||||
}
|
||||
|
||||
// update cookie
|
||||
if oidc.FromContext(req.Context()) != nil {
|
||||
|
||||
selectorFunc, err := policy.LoadSelector(&m.policySelector)
|
||||
if err != nil {
|
||||
m.logger.Err(err)
|
||||
}
|
||||
|
||||
selector, err := selectorFunc(req)
|
||||
if err != nil {
|
||||
m.logger.Err(err)
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: selectorCookieName,
|
||||
Value: selector,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
213
services/proxy/pkg/middleware/signed_url_auth.go
Normal file
213
services/proxy/pkg/middleware/signed_url_auth.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
storemsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/store/v0"
|
||||
storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// SignedURLAuth provides a middleware to check access secured by a signed URL.
|
||||
func SignedURLAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &signedURLAuth{
|
||||
next: next,
|
||||
logger: options.Logger,
|
||||
preSignedURLConfig: options.PreSignedURLConfig,
|
||||
store: options.Store,
|
||||
userProvider: options.UserProvider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type signedURLAuth struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
preSignedURLConfig config.PreSignedURL
|
||||
userProvider backend.UserBackend
|
||||
store storesvc.StoreService
|
||||
}
|
||||
|
||||
func (m signedURLAuth) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if !m.shouldServe(req) {
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
user, _, err := m.userProvider.GetUserByClaims(req.Context(), "username", req.URL.Query().Get("OC-Credential"), true)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Could not get user by claim")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
ctx := revactx.ContextSetUser(req.Context(), user)
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
if err := m.validate(req); err != nil {
|
||||
http.Error(w, "Invalid url signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func (m signedURLAuth) shouldServe(req *http.Request) bool {
|
||||
if !m.preSignedURLConfig.Enabled {
|
||||
return false
|
||||
}
|
||||
return req.URL.Query().Get("OC-Signature") != ""
|
||||
}
|
||||
|
||||
func (m signedURLAuth) validate(req *http.Request) (err error) {
|
||||
query := req.URL.Query()
|
||||
|
||||
if ok, err := m.allRequiredParametersArePresent(query); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := m.requestMethodMatches(req.Method, query); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := m.requestMethodIsAllowed(req.Method); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if expired, err := m.urlIsExpired(query, time.Now); expired {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := m.signatureIsValid(req); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) allRequiredParametersArePresent(query url.Values) (ok bool, err error) {
|
||||
// check if required query parameters exist in given request query parameters
|
||||
// OC-Signature - the computed signature - server will verify the request upon this REQUIRED
|
||||
// OC-Credential - defines the user scope (shall we use the owncloud user id here - this might leak internal data ....) REQUIRED
|
||||
// OC-Date - defined the date the url was signed (ISO 8601 UTC) REQUIRED
|
||||
// OC-Expires - defines the expiry interval in seconds (between 1 and 604800 = 7 days) REQUIRED
|
||||
// TODO OC-Verb - defines for which http verb the request is valid - defaults to GET OPTIONAL
|
||||
for _, p := range []string{
|
||||
"OC-Signature",
|
||||
"OC-Credential",
|
||||
"OC-Date",
|
||||
"OC-Expires",
|
||||
"OC-Verb",
|
||||
} {
|
||||
if query.Get(p) == "" {
|
||||
return false, fmt.Errorf("required %s parameter not found", p)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) requestMethodMatches(meth string, query url.Values) (ok bool, err error) {
|
||||
// check if given url query parameter OC-Verb matches given request method
|
||||
if !strings.EqualFold(meth, query.Get("OC-Verb")) {
|
||||
return false, errors.New("required OC-Verb parameter did not match request method")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) requestMethodIsAllowed(meth string) (ok bool, err error) {
|
||||
// check if given request method is allowed
|
||||
methodIsAllowed := false
|
||||
for _, am := range m.preSignedURLConfig.AllowedHTTPMethods {
|
||||
if strings.EqualFold(meth, am) {
|
||||
methodIsAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !methodIsAllowed {
|
||||
return false, errors.New("request method is not listed in PreSignedURLConfig AllowedHTTPMethods")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
func (m signedURLAuth) urlIsExpired(query url.Values, now func() time.Time) (expired bool, err error) {
|
||||
// check if url is expired by checking if given date (OC-Date) + expires in seconds (OC-Expires) is after now
|
||||
validFrom, err := time.Parse(time.RFC3339, query.Get("OC-Date"))
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
requestExpiry, err := time.ParseDuration(query.Get("OC-Expires") + "s")
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
validTo := validFrom.Add(requestExpiry)
|
||||
|
||||
return !(now().After(validFrom) && now().Before(validTo)), nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) signatureIsValid(req *http.Request) (ok bool, err error) {
|
||||
u := revactx.ContextMustGetUser(req.Context())
|
||||
signingKey, err := m.getSigningKey(req.Context(), u.Id.OpaqueId)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("could not retrieve signing key")
|
||||
return false, err
|
||||
}
|
||||
if len(signingKey) == 0 {
|
||||
m.logger.Error().Err(err).Msg("signing key empty")
|
||||
return false, err
|
||||
}
|
||||
q := req.URL.Query()
|
||||
signature := q.Get("OC-Signature")
|
||||
q.Del("OC-Signature")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
url := req.URL.String()
|
||||
if !req.URL.IsAbs() {
|
||||
url = "https://" + req.Host + url // TODO where do we get the scheme from
|
||||
}
|
||||
|
||||
return m.createSignature(url, signingKey) == signature, nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) createSignature(url string, signingKey []byte) string {
|
||||
// the oc10 signature check: $hash = \hash_pbkdf2("sha512", $url, $signingKey, 10000, 64, false);
|
||||
// - sets the length of the output string to 64
|
||||
// - sets raw output to false -> if raw_output is FALSE length corresponds to twice the byte-length of the derived key (as every byte of the key is returned as two hexits).
|
||||
// TODO change to length 128 in oc10?
|
||||
// fo golangs pbkdf2.Key we need to use 32 because it will be encoded into 64 hexits later
|
||||
hash := pbkdf2.Key([]byte(url), signingKey, 10000, 32, sha512.New)
|
||||
return hex.EncodeToString(hash)
|
||||
}
|
||||
|
||||
func (m signedURLAuth) getSigningKey(ctx context.Context, ocisID string) ([]byte, error) {
|
||||
res, err := m.store.Read(ctx, &storesvc.ReadRequest{
|
||||
Options: &storemsg.ReadOptions{
|
||||
Database: "proxy",
|
||||
Table: "signing-keys",
|
||||
},
|
||||
Key: ocisID,
|
||||
})
|
||||
if err != nil || len(res.Records) < 1 {
|
||||
return []byte{}, err
|
||||
}
|
||||
|
||||
return res.Records[0].Value, nil
|
||||
}
|
||||
136
services/proxy/pkg/middleware/signed_url_auth_test.go
Normal file
136
services/proxy/pkg/middleware/signed_url_auth_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignedURLAuth_shouldServe(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
tests := []struct {
|
||||
url string
|
||||
enabled bool
|
||||
expected bool
|
||||
}{
|
||||
{"https://example.com/example.jpg", true, false},
|
||||
{"https://example.com/example.jpg?OC-Signature=something", true, true},
|
||||
{"https://example.com/example.jpg", false, false},
|
||||
{"https://example.com/example.jpg?OC-Signature=something", false, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
pua.preSignedURLConfig.Enabled = tt.enabled
|
||||
r := httptest.NewRequest("", tt.url, nil)
|
||||
result := pua.shouldServe(r)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_allRequiredParametersPresent(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
baseURL := "https://example.com/example.jpg?"
|
||||
tests := []struct {
|
||||
params string
|
||||
expected bool
|
||||
}{
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", true},
|
||||
{"OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Date=something&OC-Expires=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Expires=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest("", baseURL+tt.params, nil)
|
||||
ok, _ := pua.allRequiredParametersArePresent(r.URL.Query())
|
||||
if ok != tt.expected {
|
||||
t.Errorf("with %s expected %t got %t", tt.params, tt.expected, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_requestMethodMatches(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
tests := []struct {
|
||||
method string
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"GET", "https://example.com/example.jpg?OC-Verb=GET", true},
|
||||
{"GET", "https://example.com/example.jpg?OC-Verb=get", true},
|
||||
{"POST", "https://example.com/example.jpg?OC-Verb=GET", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest(tt.method, tt.url, nil)
|
||||
ok, _ := pua.requestMethodMatches(r.Method, r.URL.Query())
|
||||
if ok != tt.expected {
|
||||
t.Errorf("with method %s and url %s expected %t got %t", tt.method, tt.url, tt.expected, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_requestMethodIsAllowed(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
tests := []struct {
|
||||
method string
|
||||
allowed []string
|
||||
expected bool
|
||||
}{
|
||||
{"GET", []string{}, false},
|
||||
{"GET", []string{"POST"}, false},
|
||||
{"GET", []string{"GET"}, true},
|
||||
{"GET", []string{"get"}, true},
|
||||
{"GET", []string{"POST", "GET"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
pua.preSignedURLConfig.AllowedHTTPMethods = tt.allowed
|
||||
ok, _ := pua.requestMethodIsAllowed(tt.method)
|
||||
|
||||
if ok != tt.expected {
|
||||
t.Errorf("with method %s and allowed methods %s expected %t got %t", tt.method, tt.allowed, tt.expected, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_urlIsExpired(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
nowFunc := func() time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
|
||||
return t
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
url string
|
||||
isExpired bool
|
||||
}{
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=61", false},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", true},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=59", true},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-03T12:29:00.000Z&OC-Expires=59", true},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-01T12:29:00.000Z&OC-Expires=59", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest("", tt.url, nil)
|
||||
expired, _ := pua.urlIsExpired(r.URL.Query(), nowFunc)
|
||||
if expired != tt.isExpired {
|
||||
t.Errorf("with %s expected %t got %t", tt.url, tt.isExpired, expired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_createSignature(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
expected := "27d2ebea381384af3179235114801dcd00f91e46f99fca72575301cf3948101d"
|
||||
s := pua.createSignature("something", []byte("somerandomkey"))
|
||||
|
||||
if s != expected {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
40
services/proxy/pkg/proxy/option.go
Normal file
40
services/proxy/pkg/proxy/option.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
220
services/proxy/pkg/proxy/policy/selector.go
Normal file
220
services/proxy/pkg/proxy/policy/selector.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMultipleSelectors in case there is more then one selector configured.
|
||||
ErrMultipleSelectors = fmt.Errorf("only one type of policy-selector (static, migration, claim or regex) can be configured")
|
||||
// ErrSelectorConfigIncomplete if policy_selector conf is missing
|
||||
ErrSelectorConfigIncomplete = fmt.Errorf("missing either \"static\", \"migration\", \"claim\" or \"regex\" configuration in policy_selector config ")
|
||||
// ErrUnexpectedConfigError unexpected config error
|
||||
ErrUnexpectedConfigError = fmt.Errorf("could not initialize policy-selector for given config")
|
||||
)
|
||||
|
||||
const (
|
||||
SelectorCookieName = "owncloud-selector"
|
||||
)
|
||||
|
||||
// Selector is a function which selects a proxy-policy based on the request.
|
||||
//
|
||||
// A policy is a random name which identifies a set of proxy-routes:
|
||||
//{
|
||||
// "policies": [
|
||||
// {
|
||||
// "name": "us-east-1",
|
||||
// "routes": [
|
||||
// {
|
||||
// "endpoint": "/",
|
||||
// "backend": "https://backend.us.example.com:8080/app"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// "name": "eu-ams-1",
|
||||
// "routes": [
|
||||
// {
|
||||
// "endpoint": "/",
|
||||
// "backend": "https://backend.eu.example.com:8080/app"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
//}
|
||||
type Selector func(r *http.Request) (string, error)
|
||||
|
||||
// LoadSelector constructs a specific policy-selector from a given configuration
|
||||
func LoadSelector(cfg *config.PolicySelector) (Selector, error) {
|
||||
selCount := 0
|
||||
|
||||
if cfg.Static != nil {
|
||||
selCount++
|
||||
}
|
||||
if cfg.Claims != nil {
|
||||
selCount++
|
||||
}
|
||||
if cfg.Regex != nil {
|
||||
selCount++
|
||||
}
|
||||
if selCount > 1 {
|
||||
return nil, ErrMultipleSelectors
|
||||
}
|
||||
|
||||
if cfg.Static == nil && cfg.Claims == nil && cfg.Regex == nil {
|
||||
return nil, ErrSelectorConfigIncomplete
|
||||
}
|
||||
|
||||
if cfg.Static != nil {
|
||||
return NewStaticSelector(cfg.Static), nil
|
||||
}
|
||||
|
||||
if cfg.Claims != nil {
|
||||
if cfg.Claims.SelectorCookieName == "" {
|
||||
cfg.Claims.SelectorCookieName = SelectorCookieName
|
||||
}
|
||||
return NewClaimsSelector(cfg.Claims), nil
|
||||
}
|
||||
|
||||
if cfg.Regex != nil {
|
||||
if cfg.Regex.SelectorCookieName == "" {
|
||||
cfg.Regex.SelectorCookieName = SelectorCookieName
|
||||
}
|
||||
return NewRegexSelector(cfg.Regex), nil
|
||||
}
|
||||
|
||||
return nil, ErrUnexpectedConfigError
|
||||
}
|
||||
|
||||
// NewStaticSelector returns a selector which uses a pre-configured policy.
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// "policy_selector": {
|
||||
// "static": {"policy" : "ocis"}
|
||||
// },
|
||||
func NewStaticSelector(cfg *config.StaticSelectorConf) Selector {
|
||||
return func(r *http.Request) (s string, err error) {
|
||||
return cfg.Policy, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewClaimsSelector selects the policy based on the "ocis.routing.policy" claim
|
||||
// The policy for corner cases is configurable:
|
||||
// "policy_selector": {
|
||||
// "migration": {
|
||||
// "default_policy" : "ocis",
|
||||
// "unauthenticated_policy": "oc10"
|
||||
// }
|
||||
// },
|
||||
//
|
||||
// This selector can be used in migration-scenarios where some users have already migrated from ownCloud10 to OCIS and
|
||||
func NewClaimsSelector(cfg *config.ClaimsSelectorConf) Selector {
|
||||
return func(r *http.Request) (s string, err error) {
|
||||
|
||||
selectorCookie := func(r *http.Request) string {
|
||||
selectorCookie, err := r.Cookie(cfg.SelectorCookieName)
|
||||
if err == nil {
|
||||
// TODO check we know the routing policy?
|
||||
return selectorCookie.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// first, try to route by selector
|
||||
if claims := oidc.FromContext(r.Context()); claims != nil {
|
||||
if p, ok := claims[oidc.OcisRoutingPolicy].(string); ok && p != "" {
|
||||
// TODO check we know the routing policy?
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// basic auth requests don't have a routing claim, so check for the cookie
|
||||
if s := selectorCookie(r); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
return cfg.DefaultPolicy, nil
|
||||
}
|
||||
|
||||
// use cookie if provided
|
||||
if s := selectorCookie(r); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
return cfg.UnauthenticatedPolicy, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewRegexSelector selects the policy based on a user property
|
||||
// The policy for each case is configurable:
|
||||
// "policy_selector": {
|
||||
// "regex": {
|
||||
// "matches_policies": [
|
||||
// {"priority": 10, "property": "mail", "match": "marie@example.org", "policy": "ocis"},
|
||||
// {"priority": 20, "property": "mail", "match": "[^@]+@example.org", "policy": "oc10"},
|
||||
// {"priority": 30, "property": "username", "match": "(einstein|feynman)", "policy": "ocis"},
|
||||
// {"priority": 40, "property": "username", "match": ".+", "policy": "oc10"},
|
||||
// {"priority": 50, "property": "id", "match": "4c510ada-c86b-4815-8820-42cdf82c3d51", "policy": "ocis"},
|
||||
// {"priority": 60, "property": "id", "match": "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c", "policy": "oc10"}
|
||||
// ],
|
||||
// "unauthenticated_policy": "oc10"
|
||||
// }
|
||||
// },
|
||||
//
|
||||
// This selector can be used in migration-scenarios where some users have already migrated from ownCloud10 to OCIS and
|
||||
func NewRegexSelector(cfg *config.RegexSelectorConf) Selector {
|
||||
regexRules := []*regexRule{}
|
||||
sort.Slice(cfg.MatchesPolicies, func(i, j int) bool {
|
||||
return cfg.MatchesPolicies[i].Priority < cfg.MatchesPolicies[j].Priority
|
||||
})
|
||||
for i := range cfg.MatchesPolicies {
|
||||
regexRules = append(regexRules, ®exRule{
|
||||
property: cfg.MatchesPolicies[i].Property,
|
||||
rule: regexp.MustCompile(cfg.MatchesPolicies[i].Match),
|
||||
policy: cfg.MatchesPolicies[i].Policy,
|
||||
})
|
||||
}
|
||||
return func(r *http.Request) (s string, err error) {
|
||||
// use cookie first if provided
|
||||
selectorCookie, err := r.Cookie(cfg.SelectorCookieName)
|
||||
if err == nil {
|
||||
return selectorCookie.Value, nil
|
||||
}
|
||||
|
||||
// if no cookie is present, try to route by selector
|
||||
if u, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
for i := range regexRules {
|
||||
switch regexRules[i].property {
|
||||
case "mail":
|
||||
if regexRules[i].rule.MatchString(u.Mail) {
|
||||
return regexRules[i].policy, nil
|
||||
}
|
||||
case "username":
|
||||
if regexRules[i].rule.MatchString(u.Username) {
|
||||
return regexRules[i].policy, nil
|
||||
}
|
||||
case "id":
|
||||
if u.Id != nil && regexRules[i].rule.MatchString(u.Id.OpaqueId) {
|
||||
return regexRules[i].policy, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg.DefaultPolicy, nil
|
||||
}
|
||||
|
||||
return cfg.UnauthenticatedPolicy, nil
|
||||
}
|
||||
}
|
||||
|
||||
type regexRule struct {
|
||||
property string
|
||||
rule *regexp.Regexp
|
||||
policy string
|
||||
}
|
||||
145
services/proxy/pkg/proxy/policy/selector_test.go
Normal file
145
services/proxy/pkg/proxy/policy/selector_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
func TestLoadSelector(t *testing.T) {
|
||||
type test struct {
|
||||
cfg *config.PolicySelector
|
||||
expectedErr error
|
||||
}
|
||||
sCfg := &config.StaticSelectorConf{Policy: "reva"}
|
||||
ccfg := &config.ClaimsSelectorConf{}
|
||||
rcfg := &config.RegexSelectorConf{}
|
||||
|
||||
table := []test{
|
||||
{cfg: &config.PolicySelector{Static: sCfg, Claims: ccfg, Regex: rcfg}, expectedErr: ErrMultipleSelectors},
|
||||
{cfg: &config.PolicySelector{}, expectedErr: ErrSelectorConfigIncomplete},
|
||||
{cfg: &config.PolicySelector{Static: sCfg}, expectedErr: nil},
|
||||
{cfg: &config.PolicySelector{Claims: ccfg}, expectedErr: nil},
|
||||
{cfg: &config.PolicySelector{Regex: rcfg}, expectedErr: nil},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
_, err := LoadSelector(test.cfg)
|
||||
if err != test.expectedErr {
|
||||
t.Errorf("Unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticSelector(t *testing.T) {
|
||||
sel := NewStaticSelector(&config.StaticSelectorConf{Policy: "ocis"})
|
||||
req := httptest.NewRequest("GET", "https://example.org/foo", nil)
|
||||
want := "ocis"
|
||||
got, err := sel(req)
|
||||
if got != want {
|
||||
t.Errorf("Expected policy %v got %v", want, got)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error %v", err)
|
||||
}
|
||||
|
||||
sel = NewStaticSelector(&config.StaticSelectorConf{Policy: "foo"})
|
||||
|
||||
want = "foo"
|
||||
got, err = sel(req)
|
||||
if got != want {
|
||||
t.Errorf("Expected policy %v got %v", want, got)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
Name string
|
||||
Context context.Context
|
||||
Cookie *http.Cookie
|
||||
Expected string
|
||||
}
|
||||
|
||||
func TestClaimsSelector(t *testing.T) {
|
||||
sel := NewClaimsSelector(&config.ClaimsSelectorConf{
|
||||
DefaultPolicy: "default",
|
||||
UnauthenticatedPolicy: "unauthenticated",
|
||||
})
|
||||
|
||||
var tests = []testCase{
|
||||
{"unauthenticated", context.Background(), nil, "unauthenticated"},
|
||||
{"default", oidc.NewContext(context.Background(), map[string]interface{}{oidc.OcisRoutingPolicy: ""}), nil, "default"},
|
||||
{"claim-value", oidc.NewContext(context.Background(), map[string]interface{}{oidc.OcisRoutingPolicy: "ocis.routing.policy-value"}), nil, "ocis.routing.policy-value"},
|
||||
{"cookie-only", context.Background(), &http.Cookie{Name: SelectorCookieName, Value: "cookie"}, "cookie"},
|
||||
{"claim-can-override-cookie", oidc.NewContext(context.Background(), map[string]interface{}{oidc.OcisRoutingPolicy: "ocis.routing.policy-value"}), &http.Cookie{Name: SelectorCookieName, Value: "cookie"}, "ocis.routing.policy-value"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
r := httptest.NewRequest("GET", "https://example.com", nil)
|
||||
if tc.Cookie != nil {
|
||||
r.AddCookie(tc.Cookie)
|
||||
}
|
||||
nr := r.WithContext(tc.Context)
|
||||
got, err := sel(nr)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.Expected {
|
||||
t.Errorf("Expected Policy %v got %v", tc.Expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexSelector(t *testing.T) {
|
||||
sel := NewRegexSelector(&config.RegexSelectorConf{
|
||||
DefaultPolicy: "default",
|
||||
MatchesPolicies: []config.RegexRuleConf{
|
||||
{Priority: 10, Property: "mail", Match: "marie@example.org", Policy: "ocis"},
|
||||
{Priority: 20, Property: "mail", Match: "[^@]+@example.org", Policy: "oc10"},
|
||||
{Priority: 30, Property: "username", Match: "(einstein|feynman)", Policy: "ocis"},
|
||||
{Priority: 40, Property: "username", Match: ".+", Policy: "oc10"},
|
||||
{Priority: 50, Property: "id", Match: "4c510ada-c86b-4815-8820-42cdf82c3d51", Policy: "ocis"},
|
||||
{Priority: 60, Property: "id", Match: "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c", Policy: "oc10"},
|
||||
},
|
||||
UnauthenticatedPolicy: "unauthenticated",
|
||||
})
|
||||
|
||||
var tests = []testCase{
|
||||
{"unauthenticated", context.Background(), nil, "unauthenticated"},
|
||||
{"default", revactx.ContextSetUser(context.Background(), &userv1beta1.User{}), nil, "default"},
|
||||
{"mail-ocis", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Mail: "marie@example.org"}), nil, "ocis"},
|
||||
{"mail-oc10", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Mail: "einstein@example.org"}), nil, "oc10"},
|
||||
{"username-einstein", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "einstein"}), nil, "ocis"},
|
||||
{"username-feynman", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "feynman"}), nil, "ocis"},
|
||||
{"username-marie", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "marie"}), nil, "oc10"},
|
||||
{"id-nil", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Id: &userv1beta1.UserId{}}), nil, "default"},
|
||||
{"id-1", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Id: &userv1beta1.UserId{OpaqueId: "4c510ada-c86b-4815-8820-42cdf82c3d51"}}), nil, "ocis"},
|
||||
{"id-2", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Id: &userv1beta1.UserId{OpaqueId: "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c"}}), nil, "oc10"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc // capture range variable
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "https://example.com", nil)
|
||||
nr := r.WithContext(tc.Context)
|
||||
got, err := sel(nr)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.Expected {
|
||||
t.Errorf("Expected Policy %v got %v", tc.Expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
303
services/proxy/pkg/proxy/proxy.go
Normal file
303
services/proxy/pkg/proxy/proxy.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"go-micro.dev/v4/selector"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
|
||||
pkgtrace "github.com/owncloud/ocis/v2/ocis-pkg/tracing"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/proxy/policy"
|
||||
proxytracing "github.com/owncloud/ocis/v2/services/proxy/pkg/tracing"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// MultiHostReverseProxy extends "httputil" to support multiple hosts with different policies
|
||||
type MultiHostReverseProxy struct {
|
||||
httputil.ReverseProxy
|
||||
// Directors holds policy route type method endpoint Director
|
||||
Directors map[string]map[config.RouteType]map[string]map[string]func(req *http.Request)
|
||||
PolicySelector policy.Selector
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewMultiHostReverseProxy creates a new MultiHostReverseProxy
|
||||
func NewMultiHostReverseProxy(opts ...Option) *MultiHostReverseProxy {
|
||||
options := newOptions(opts...)
|
||||
|
||||
rp := &MultiHostReverseProxy{
|
||||
Directors: make(map[string]map[config.RouteType]map[string]map[string]func(req *http.Request)),
|
||||
logger: options.Logger,
|
||||
config: options.Config,
|
||||
}
|
||||
rp.Director = rp.directorSelectionDirector
|
||||
|
||||
// equals http.DefaultTransport except TLSClientConfig
|
||||
rp.Transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: options.Config.InsecureBackends, //nolint:gosec
|
||||
},
|
||||
}
|
||||
|
||||
if options.Config.PolicySelector == nil {
|
||||
firstPolicy := options.Config.Policies[0].Name
|
||||
rp.logger.Warn().Str("policy", firstPolicy).Msg("policy-selector not configured. Will always use first policy")
|
||||
options.Config.PolicySelector = &config.PolicySelector{
|
||||
Static: &config.StaticSelectorConf{
|
||||
Policy: firstPolicy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
rp.logger.Debug().
|
||||
Interface("selector_config", options.Config.PolicySelector).
|
||||
Msg("loading policy-selector")
|
||||
|
||||
policySelector, err := policy.LoadSelector(options.Config.PolicySelector)
|
||||
if err != nil {
|
||||
rp.logger.Fatal().Err(err).Msg("Could not load policy-selector")
|
||||
}
|
||||
|
||||
rp.PolicySelector = policySelector
|
||||
|
||||
for _, pol := range options.Config.Policies {
|
||||
for _, route := range pol.Routes {
|
||||
rp.logger.Debug().Str("fwd: ", route.Endpoint)
|
||||
|
||||
if route.Backend == "" && route.Service == "" {
|
||||
rp.logger.Fatal().Interface("route", route).Msg("neither Backend nor Service is set")
|
||||
}
|
||||
uri, err2 := url.Parse(route.Backend)
|
||||
if err2 != nil {
|
||||
rp.logger.
|
||||
Fatal(). // fail early on misconfiguration
|
||||
Err(err2).
|
||||
Str("backend", route.Backend).
|
||||
Msg("malformed url")
|
||||
}
|
||||
|
||||
// here the backend is used as a uri
|
||||
rp.AddHost(pol.Name, uri, route)
|
||||
}
|
||||
}
|
||||
|
||||
return rp
|
||||
}
|
||||
|
||||
func (p *MultiHostReverseProxy) directorSelectionDirector(r *http.Request) {
|
||||
pol, err := p.PolicySelector(r)
|
||||
if err != nil {
|
||||
p.logger.Error().Err(err).Msg("Error while selecting pol")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := p.Directors[pol]; !ok {
|
||||
p.logger.
|
||||
Error().
|
||||
Str("policy", pol).
|
||||
Msg("policy is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
method := ""
|
||||
// find matching director
|
||||
for _, rt := range config.RouteTypes {
|
||||
var handler func(string, url.URL) bool
|
||||
switch rt {
|
||||
case config.QueryRoute:
|
||||
handler = p.queryRouteMatcher
|
||||
case config.RegexRoute:
|
||||
handler = p.regexRouteMatcher
|
||||
case config.PrefixRoute:
|
||||
fallthrough
|
||||
default:
|
||||
handler = p.prefixRouteMatcher
|
||||
}
|
||||
if p.Directors[pol][rt][r.Method] != nil {
|
||||
// use specific method
|
||||
method = r.Method
|
||||
}
|
||||
for endpoint := range p.Directors[pol][rt][method] {
|
||||
if handler(endpoint, *r.URL) {
|
||||
|
||||
p.logger.Debug().
|
||||
Str("policy", pol).
|
||||
Str("method", r.Method).
|
||||
Str("prefix", endpoint).
|
||||
Str("path", r.URL.Path).
|
||||
Str("routeType", string(rt)).
|
||||
Msg("director found")
|
||||
|
||||
p.Directors[pol][rt][method][endpoint](r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// override default director with root. If any
|
||||
switch {
|
||||
case p.Directors[pol][config.PrefixRoute][method]["/"] != nil:
|
||||
// try specific method
|
||||
p.Directors[pol][config.PrefixRoute][method]["/"](r)
|
||||
return
|
||||
case p.Directors[pol][config.PrefixRoute][""]["/"] != nil:
|
||||
// fallback to unspecific method
|
||||
p.Directors[pol][config.PrefixRoute][""]["/"](r)
|
||||
return
|
||||
}
|
||||
|
||||
p.logger.
|
||||
Warn().
|
||||
Str("policy", pol).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("no director found")
|
||||
}
|
||||
|
||||
func singleJoiningSlash(a, b string) string {
|
||||
aslash := strings.HasSuffix(a, "/")
|
||||
bslash := strings.HasPrefix(b, "/")
|
||||
switch {
|
||||
case aslash && bslash:
|
||||
return a + b[1:]
|
||||
case !aslash && !bslash:
|
||||
return a + "/" + b
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
|
||||
// AddHost undocumented
|
||||
func (p *MultiHostReverseProxy) AddHost(policy string, target *url.URL, rt config.Route) {
|
||||
targetQuery := target.RawQuery
|
||||
if p.Directors[policy] == nil {
|
||||
p.Directors[policy] = make(map[config.RouteType]map[string]map[string]func(req *http.Request))
|
||||
}
|
||||
routeType := config.DefaultRouteType
|
||||
if rt.Type != "" {
|
||||
routeType = rt.Type
|
||||
}
|
||||
if p.Directors[policy][routeType] == nil {
|
||||
p.Directors[policy][routeType] = make(map[string]map[string]func(req *http.Request))
|
||||
}
|
||||
if p.Directors[policy][routeType][rt.Method] == nil {
|
||||
p.Directors[policy][routeType][rt.Method] = make(map[string]func(req *http.Request))
|
||||
}
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
sel := selector.NewSelector(selector.Registry(reg))
|
||||
|
||||
p.Directors[policy][routeType][rt.Method][rt.Endpoint] = func(req *http.Request) {
|
||||
if rt.Service != "" {
|
||||
// select next node
|
||||
next, err := sel.Select(rt.Service)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not select %s service from the registry: %v", rt.Service, err))
|
||||
return // TODO error? fallback to target.Host & Scheme?
|
||||
}
|
||||
node, err := next()
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not select next node for service %s: %v", rt.Service, err))
|
||||
return // TODO error? fallback to target.Host & Scheme?
|
||||
}
|
||||
req.URL.Host = node.Address
|
||||
req.URL.Scheme = node.Metadata["protocol"] // TODO check property exists?
|
||||
|
||||
} else {
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Scheme = target.Scheme
|
||||
}
|
||||
|
||||
// Apache deployments host addresses need to match on req.Host and req.URL.Host
|
||||
// see https://stackoverflow.com/questions/34745654/golang-reverseproxy-with-apache2-sni-hostname-error
|
||||
if rt.ApacheVHost {
|
||||
req.Host = target.Host
|
||||
}
|
||||
|
||||
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
|
||||
if targetQuery == "" || req.URL.RawQuery == "" {
|
||||
req.URL.RawQuery = targetQuery + req.URL.RawQuery
|
||||
} else {
|
||||
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
|
||||
}
|
||||
if _, ok := req.Header["User-Agent"]; !ok {
|
||||
// explicitly disable User-Agent so it's not set to default value
|
||||
req.Header.Set("User-Agent", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MultiHostReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
span trace.Span
|
||||
)
|
||||
|
||||
tracer := proxytracing.TraceProvider.Tracer("proxy")
|
||||
ctx, span = tracer.Start(ctx, fmt.Sprintf("%s %v", r.Method, r.URL.Path))
|
||||
defer span.End()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.KeyValue{
|
||||
Key: "x-request-id",
|
||||
Value: attribute.StringValue(chimiddleware.GetReqID(r.Context())),
|
||||
})
|
||||
|
||||
pkgtrace.Propagator.Inject(ctx, propagation.HeaderCarrier(r.Header))
|
||||
|
||||
p.ReverseProxy.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func (p MultiHostReverseProxy) queryRouteMatcher(endpoint string, target url.URL) bool {
|
||||
u, _ := url.Parse(endpoint)
|
||||
if !strings.HasPrefix(target.Path, u.Path) || endpoint == "/" {
|
||||
return false
|
||||
}
|
||||
q := u.Query()
|
||||
if len(q) == 0 {
|
||||
return false
|
||||
}
|
||||
tq := target.Query()
|
||||
for k := range q {
|
||||
if q.Get(k) != tq.Get(k) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *MultiHostReverseProxy) regexRouteMatcher(pattern string, target url.URL) bool {
|
||||
matched, err := regexp.MatchString(pattern, target.String())
|
||||
if err != nil {
|
||||
p.logger.Warn().Err(err).Str("pattern", pattern).Msg("regex with pattern failed")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func (p *MultiHostReverseProxy) prefixRouteMatcher(prefix string, target url.URL) bool {
|
||||
return strings.HasPrefix(target.Path, prefix) && prefix != "/"
|
||||
}
|
||||
224
services/proxy/pkg/proxy/proxy_integration_test.go
Normal file
224
services/proxy/pkg/proxy/proxy_integration_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
func TestProxyIntegration(t *testing.T) {
|
||||
var tests = []testCase{
|
||||
// Simple prefix route
|
||||
test("simple_prefix", withPolicy("ocis", withRoutes{{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com"},
|
||||
})).withRequest("GET", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/api"),
|
||||
|
||||
// Complex prefix route, different method
|
||||
test("complex_prefix_post", withPolicy("ocis", withRoutes{{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com/service1/"},
|
||||
})).withRequest("POST", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/service1/api"),
|
||||
|
||||
// Query route
|
||||
test("query_route", withPolicy("ocis", withRoutes{{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/api?format=json",
|
||||
Backend: "http://backend/"},
|
||||
})).withRequest("GET", "https://example.com/api?format=json", nil).
|
||||
expectProxyTo("http://backend/api?format=json"),
|
||||
|
||||
// Regex route
|
||||
test("regex_route", withPolicy("ocis", withRoutes{{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: `\/user\/(\d+)`,
|
||||
Backend: "http://backend/"},
|
||||
})).withRequest("POST", "https://example.com/user/1234", nil).
|
||||
expectProxyTo("http://backend/user/1234"),
|
||||
|
||||
// Multiple prefix routes 1
|
||||
test("multiple_prefix", withPolicy("ocis", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/payment",
|
||||
Backend: "http://payment.example.com",
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/payment", nil).
|
||||
expectProxyTo("http://payment.example.com/payment"),
|
||||
|
||||
// Multiple prefix routes 2
|
||||
test("multiple_prefix", withPolicy("ocis", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/payment",
|
||||
Backend: "http://payment.example.com",
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/api"),
|
||||
|
||||
// Mixed route types
|
||||
test("mixed_types", withPolicy("ocis", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: `\/user\/(\d+)`,
|
||||
Backend: "http://users.example.com",
|
||||
ApacheVHost: false,
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/api"),
|
||||
|
||||
// Mixed route types
|
||||
test("mixed_types", withPolicy("ocis", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: `\/user\/(\d+)`,
|
||||
Backend: "http://users.example.com",
|
||||
ApacheVHost: false,
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/user/1234", nil).
|
||||
expectProxyTo("http://users.example.com/user/1234"),
|
||||
}
|
||||
|
||||
for k := range tests {
|
||||
t.Run(tests[k].id, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tc := tests[k]
|
||||
rp := newTestProxy(testConfig(tc.conf), func(req *http.Request) *http.Response {
|
||||
if got, want := req.URL.String(), tc.expect.String(); got != want {
|
||||
t.Errorf("Proxied url should be %v got %v", want, got)
|
||||
}
|
||||
|
||||
if got, want := req.Method, tc.input.Method; got != want {
|
||||
t.Errorf("Proxied request method should be %v got %v", want, got)
|
||||
}
|
||||
|
||||
if got, want := req.Proto, tc.input.Proto; got != want {
|
||||
t.Errorf("Proxied request proto should be %v got %v", want, got)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`OK`)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
rp.ServeHTTP(rr, tc.input)
|
||||
rsp := rr.Result()
|
||||
|
||||
if rsp.StatusCode != 200 {
|
||||
t.Errorf("Expected status 200 from proxy-response got %v", rsp.StatusCode)
|
||||
}
|
||||
|
||||
resultBody, err := ioutil.ReadAll(rsp.Body)
|
||||
if err != nil {
|
||||
t.Fatal("Error reading result body")
|
||||
}
|
||||
if err = rsp.Body.Close(); err != nil {
|
||||
t.Fatal("Error closing result body")
|
||||
}
|
||||
|
||||
bodyString := string(resultBody)
|
||||
if bodyString != `OK` {
|
||||
t.Errorf("Result body of proxied response should be OK, got %v", bodyString)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestProxy(cfg *config.Config, fn RoundTripFunc) *MultiHostReverseProxy {
|
||||
rp := NewMultiHostReverseProxy(Config(cfg))
|
||||
rp.Transport = fn
|
||||
return rp
|
||||
}
|
||||
|
||||
type RoundTripFunc func(req *http.Request) *http.Response
|
||||
|
||||
// RoundTrip .
|
||||
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req), nil
|
||||
}
|
||||
|
||||
type withRoutes []config.Route
|
||||
|
||||
type testCase struct {
|
||||
id string
|
||||
input *http.Request
|
||||
expect *url.URL
|
||||
conf []config.Policy
|
||||
}
|
||||
|
||||
func test(id string, policies ...config.Policy) *testCase {
|
||||
tc := &testCase{
|
||||
id: id,
|
||||
}
|
||||
for k := range policies {
|
||||
tc.conf = append(tc.conf, policies[k])
|
||||
}
|
||||
|
||||
return tc
|
||||
}
|
||||
|
||||
func withPolicy(name string, r withRoutes) config.Policy {
|
||||
return config.Policy{Name: name, Routes: r}
|
||||
}
|
||||
|
||||
func (tc *testCase) withRequest(method string, target string, body io.Reader) *testCase {
|
||||
tc.input = httptest.NewRequest(method, target, body)
|
||||
return tc
|
||||
}
|
||||
|
||||
func (tc *testCase) expectProxyTo(strURL string) testCase {
|
||||
pu, err := url.Parse(strURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing %v", strURL)
|
||||
}
|
||||
|
||||
tc.expect = pu
|
||||
return *tc
|
||||
}
|
||||
|
||||
func testConfig(policy []config.Policy) *config.Config {
|
||||
return &config.Config{
|
||||
Log: &config.Log{},
|
||||
Debug: config.Debug{},
|
||||
HTTP: config.HTTP{},
|
||||
Tracing: &config.Tracing{},
|
||||
Policies: policy,
|
||||
OIDC: config.OIDC{},
|
||||
PolicySelector: nil,
|
||||
}
|
||||
}
|
||||
137
services/proxy/pkg/proxy/proxy_test.go
Normal file
137
services/proxy/pkg/proxy/proxy_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config/defaults"
|
||||
)
|
||||
|
||||
type matchertest struct {
|
||||
method, endpoint, target string
|
||||
matches bool
|
||||
}
|
||||
|
||||
func TestPrefixRouteMatcher(t *testing.T) {
|
||||
cfg := defaults.DefaultConfig()
|
||||
cfg.Policies = defaults.DefaultPolicies()
|
||||
p := NewMultiHostReverseProxy(Config(cfg))
|
||||
|
||||
table := []matchertest{
|
||||
{endpoint: "/foobar", target: "/foobar/baz/some/url", matches: true},
|
||||
{endpoint: "/fobar", target: "/foobar/baz/some/url", matches: false},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
u, _ := url.Parse(test.target)
|
||||
matched := p.prefixRouteMatcher(test.endpoint, *u)
|
||||
if matched != test.matches {
|
||||
t.Errorf("PrefixRouteMatcher returned %t expected %t for endpoint: %s and target %s",
|
||||
matched, test.matches, test.endpoint, u.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRouteMatcher(t *testing.T) {
|
||||
cfg := defaults.DefaultConfig()
|
||||
cfg.Policies = defaults.DefaultPolicies()
|
||||
p := NewMultiHostReverseProxy(Config(cfg))
|
||||
|
||||
table := []matchertest{
|
||||
{endpoint: "/foobar?parameter=true", target: "/foobar/baz/some/url?parameter=true", matches: true},
|
||||
{endpoint: "/foobar", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
{endpoint: "/foobar?parameter=false", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
{endpoint: "/foobar?parameter=false&other=true", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
{
|
||||
endpoint: "/foobar?parameter=false&other=true",
|
||||
target: "/foobar/baz/some/url?parameter=false&other=true",
|
||||
matches: true,
|
||||
},
|
||||
{endpoint: "/fobar", target: "/foobar", matches: false},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
u, _ := url.Parse(test.target)
|
||||
matched := p.queryRouteMatcher(test.endpoint, *u)
|
||||
if matched != test.matches {
|
||||
t.Errorf("QueryRouteMatcher returned %t expected %t for endpoint: %s and target %s",
|
||||
matched, test.matches, test.endpoint, u.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexRouteMatcher(t *testing.T) {
|
||||
cfg := defaults.DefaultConfig()
|
||||
cfg.Policies = defaults.DefaultPolicies()
|
||||
p := NewMultiHostReverseProxy(Config(cfg))
|
||||
|
||||
table := []matchertest{
|
||||
{endpoint: ".*some\\/url.*parameter=true", target: "/foobar/baz/some/url?parameter=true", matches: true},
|
||||
{endpoint: "([\\])\\w+", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
u, _ := url.Parse(test.target)
|
||||
matched := p.regexRouteMatcher(test.endpoint, *u)
|
||||
if matched != test.matches {
|
||||
t.Errorf("RegexRouteMatcher returned %t expected %t for endpoint: %s and target %s",
|
||||
matched, test.matches, test.endpoint, u.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleJoiningSlash(t *testing.T) {
|
||||
type test struct {
|
||||
a, b, result string
|
||||
}
|
||||
|
||||
table := []test{
|
||||
{a: "a", b: "b", result: "a/b"},
|
||||
{a: "a/", b: "b", result: "a/b"},
|
||||
{a: "a", b: "/b", result: "a/b"},
|
||||
{a: "a/", b: "/b", result: "a/b"},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
p := singleJoiningSlash(test.a, test.b)
|
||||
if p != test.result {
|
||||
t.Errorf("SingleJoiningSlash got %s expected %s", p, test.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectorSelectionDirector(t *testing.T) {
|
||||
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "ok")
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
p := NewMultiHostReverseProxy(Config(&config.Config{
|
||||
PolicySelector: &config.PolicySelector{
|
||||
Static: &config.StaticSelectorConf{
|
||||
Policy: "default",
|
||||
},
|
||||
},
|
||||
}))
|
||||
p.AddHost("default", &url.URL{Host: "ocdav"}, config.Route{Type: config.PrefixRoute, Method: "", Endpoint: "/dav", Backend: "ocdav"})
|
||||
p.AddHost("default", &url.URL{Host: "ocis-webdav"}, config.Route{Type: config.PrefixRoute, Method: "REPORT", Endpoint: "/dav", Backend: "ocis-webdav"})
|
||||
|
||||
table := []matchertest{
|
||||
{method: "PROPFIND", endpoint: "/dav/files/demo/", target: "ocdav"},
|
||||
{method: "REPORT", endpoint: "/dav/files/demo/", target: "ocis-webdav"},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
r := httptest.NewRequest(test.method, "/dav/files/demo/", nil)
|
||||
p.directorSelectionDirector(r)
|
||||
if r.URL.Host != test.target {
|
||||
t.Errorf("TestDirectorSelectionDirector got host %s expected %s", r.Host, test.target)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
50
services/proxy/pkg/server/debug/option.go
Normal file
50
services/proxy/pkg/server/debug/option.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
75
services/proxy/pkg/server/debug/server.go
Normal file
75
services/proxy/pkg/server/debug/server.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/service/debug"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Health(health(options.Config)),
|
||||
debug.Ready(ready(options.Config)),
|
||||
debug.ConfigDump(configDump(options.Config)),
|
||||
), nil
|
||||
}
|
||||
|
||||
// health implements the health check.
|
||||
func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// TODO: check if services are up and running
|
||||
|
||||
_, err := io.WriteString(w, http.StatusText(http.StatusOK))
|
||||
// io.WriteString should not fail but if it does we want to know.
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ready implements the ready check.
|
||||
func ready(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// TODO: check if services are up and running
|
||||
|
||||
_, err := io.WriteString(w, http.StatusText(http.StatusOK))
|
||||
// io.WriteString should not fail but if it does we want to know.
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// configDump implements the config dump
|
||||
func configDump(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
}
|
||||
86
services/proxy/pkg/server/http/option.go
Normal file
86
services/proxy/pkg/server/http/option.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/justinas/alice"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/metrics"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Handler http.Handler
|
||||
Metrics *metrics.Metrics
|
||||
Flags []cli.Flag
|
||||
Middlewares alice.Chain
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
|
||||
// Flags provides a function to set the flags option.
|
||||
func Flags(val []cli.Flag) Option {
|
||||
return func(o *Options) {
|
||||
o.Flags = append(o.Flags, val...)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler provides a function to set the Handler option.
|
||||
func Handler(h http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.Handler = h
|
||||
}
|
||||
}
|
||||
|
||||
// Middlewares provides a function to register middlewares
|
||||
func Middlewares(val alice.Chain) Option {
|
||||
return func(o *Options) {
|
||||
o.Middlewares = val
|
||||
}
|
||||
}
|
||||
61
services/proxy/pkg/server/http/server.go
Normal file
61
services/proxy/pkg/server/http/server.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"os"
|
||||
|
||||
pkgcrypto "github.com/owncloud/ocis/v2/ocis-pkg/crypto"
|
||||
svc "github.com/owncloud/ocis/v2/ocis-pkg/service/http"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
"go-micro.dev/v4"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (svc.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
l := options.Logger
|
||||
httpCfg := options.Config.HTTP
|
||||
|
||||
var cer tls.Certificate
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
if options.Config.HTTP.TLS {
|
||||
l.Warn().Msgf("No tls certificate provided, using a generated one")
|
||||
_, certErr := os.Stat(httpCfg.TLSCert)
|
||||
_, keyErr := os.Stat(httpCfg.TLSKey)
|
||||
|
||||
if os.IsNotExist(certErr) || os.IsNotExist(keyErr) {
|
||||
// GenCert has side effects as it writes 2 files to the binary running location
|
||||
if err := pkgcrypto.GenCert(httpCfg.TLSCert, httpCfg.TLSKey, l); err != nil {
|
||||
l.Fatal().Err(err).Msgf("Could not generate test-certificate")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
cer, certErr = tls.LoadX509KeyPair(httpCfg.TLSCert, httpCfg.TLSKey)
|
||||
if certErr != nil {
|
||||
options.Logger.Fatal().Err(certErr).Msg("Could not setup TLS")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cer}}
|
||||
}
|
||||
chain := options.Middlewares.Then(options.Handler)
|
||||
|
||||
service := svc.NewService(
|
||||
svc.Name(options.Config.Service.Name),
|
||||
svc.Version(version.GetString()),
|
||||
svc.TLSConfig(tlsConfig),
|
||||
svc.Logger(options.Logger),
|
||||
svc.Address(options.Config.HTTP.Addr),
|
||||
svc.Namespace(options.Config.HTTP.Namespace),
|
||||
svc.Context(options.Context),
|
||||
svc.Flags(options.Flags...),
|
||||
)
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), chain); err != nil {
|
||||
return svc.Service{}, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
23
services/proxy/pkg/tracing/tracing.go
Normal file
23
services/proxy/pkg/tracing/tracing.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package tracing
|
||||
|
||||
import (
|
||||
pkgtrace "github.com/owncloud/ocis/v2/ocis-pkg/tracing"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// TraceProvider is the global trace provider for the proxy service.
|
||||
TraceProvider = trace.NewNoopTracerProvider()
|
||||
)
|
||||
|
||||
func Configure(cfg *config.Config) error {
|
||||
var err error
|
||||
if cfg.Tracing.Enabled {
|
||||
if TraceProvider, err = pkgtrace.GetTraceProvider(cfg.Tracing.Endpoint, cfg.Tracing.Collector, cfg.Service.Name, cfg.Tracing.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
66
services/proxy/pkg/user/backend/backend.go
Normal file
66
services/proxy/pkg/user/backend/backend.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccountNotFound account not found
|
||||
ErrAccountNotFound = errors.New("user not found")
|
||||
// ErrAccountDisabled account disabled
|
||||
ErrAccountDisabled = errors.New("account disabled")
|
||||
// ErrNotSupported operation not supported by user-backend
|
||||
ErrNotSupported = errors.New("operation not supported")
|
||||
)
|
||||
|
||||
// UserBackend allows the proxy to retrieve users from different user-backends (accounts-service, CS3)
|
||||
type UserBackend interface {
|
||||
GetUserByClaims(ctx context.Context, claim, value string, withRoles bool) (*cs3.User, string, error)
|
||||
Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error)
|
||||
CreateUserFromClaims(ctx context.Context, claims map[string]interface{}) (*cs3.User, error)
|
||||
GetUserGroups(ctx context.Context, userID string)
|
||||
}
|
||||
|
||||
// RevaAuthenticator helper interface to mock auth-method from reva gateway-client.
|
||||
type RevaAuthenticator interface {
|
||||
Authenticate(ctx context.Context, in *gateway.AuthenticateRequest, opts ...grpc.CallOption) (*gateway.AuthenticateResponse, error)
|
||||
}
|
||||
|
||||
// loadRolesIDs returns the role-ids assigned to an user
|
||||
func loadRolesIDs(ctx context.Context, opaqueUserID string, rs settingssvc.RoleService) ([]string, error) {
|
||||
req := &settingssvc.ListRoleAssignmentsRequest{AccountUuid: opaqueUserID}
|
||||
assignmentResponse, err := rs.ListRoleAssignments(ctx, req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleIDs := make([]string, 0)
|
||||
|
||||
for _, assignment := range assignmentResponse.Assignments {
|
||||
roleIDs = append(roleIDs, assignment.RoleId)
|
||||
}
|
||||
|
||||
return roleIDs, nil
|
||||
}
|
||||
|
||||
// encodeRoleIDs encoded the given role id's in to reva-specific format to be able to mint a token from them
|
||||
func encodeRoleIDs(roleIDs []string) (*types.OpaqueEntry, error) {
|
||||
roleIDsJSON, err := json.Marshal(roleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.OpaqueEntry{
|
||||
Decoder: "json",
|
||||
Value: roleIDsJSON,
|
||||
}, nil
|
||||
}
|
||||
327
services/proxy/pkg/user/backend/cs3.go
Normal file
327
services/proxy/pkg/user/backend/cs3.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/auth/scope"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/token"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
settingsService "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
"go-micro.dev/v4/selector"
|
||||
)
|
||||
|
||||
type cs3backend struct {
|
||||
graphSelector selector.Selector
|
||||
settingsRoleService settingssvc.RoleService
|
||||
authProvider RevaAuthenticator
|
||||
oidcISS string
|
||||
machineAuthAPIKey string
|
||||
tokenManager token.Manager
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// NewCS3UserBackend creates a user-provider which fetches users from a CS3 UserBackend
|
||||
func NewCS3UserBackend(rs settingssvc.RoleService, ap RevaAuthenticator, machineAuthAPIKey string, oidcISS string, tokenManager token.Manager, logger log.Logger) UserBackend {
|
||||
reg := registry.GetRegistry()
|
||||
sel := selector.NewSelector(selector.Registry(reg))
|
||||
return &cs3backend{
|
||||
graphSelector: sel,
|
||||
settingsRoleService: rs,
|
||||
authProvider: ap,
|
||||
oidcISS: oidcISS,
|
||||
machineAuthAPIKey: machineAuthAPIKey,
|
||||
tokenManager: tokenManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cs3backend) GetUserByClaims(ctx context.Context, claim, value string, withRoles bool) (*cs3.User, string, error) {
|
||||
res, err := c.authProvider.Authenticate(ctx, &gateway.AuthenticateRequest{
|
||||
Type: "machine",
|
||||
ClientId: claim + ":" + value,
|
||||
ClientSecret: c.machineAuthAPIKey,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, "", fmt.Errorf("could not get user by claim %v with value %v: %w", claim, value, err)
|
||||
case res.Status.Code != rpcv1beta1.Code_CODE_OK:
|
||||
if res.Status.Code == rpcv1beta1.Code_CODE_NOT_FOUND {
|
||||
return nil, "", ErrAccountNotFound
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get user by claim %v with value %v : %w ", claim, value, err)
|
||||
}
|
||||
|
||||
user := res.User
|
||||
|
||||
if !withRoles {
|
||||
return user, res.Token, nil
|
||||
}
|
||||
|
||||
var roleIDs []string
|
||||
if user.Id.Type != cs3.UserType_USER_TYPE_LIGHTWEIGHT {
|
||||
roleIDs, err = loadRolesIDs(ctx, user.Id.OpaqueId, c.settingsRoleService)
|
||||
if err != nil {
|
||||
var merr *merrors.Error
|
||||
if errors.As(err, &merr) && merr.Code == http.StatusNotFound {
|
||||
// This user doesn't have a role assignment yet. Assign a
|
||||
// default user role. At least until proper roles are provided. See
|
||||
// https://github.com/owncloud/ocis/v2/issues/1825 for more context.
|
||||
if user.Id.Type == cs3.UserType_USER_TYPE_PRIMARY {
|
||||
c.logger.Info().Str("userid", user.Id.OpaqueId).Msg("user has no role assigned, assigning default user role")
|
||||
_, err := c.settingsRoleService.AssignRoleToUser(ctx, &settingssvc.AssignRoleToUserRequest{
|
||||
AccountUuid: user.Id.OpaqueId,
|
||||
RoleId: settingsService.BundleUUIDRoleUser,
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Could not add default role")
|
||||
return nil, "", err
|
||||
}
|
||||
roleIDs = append(roleIDs, settingsService.BundleUUIDRoleUser)
|
||||
}
|
||||
} else {
|
||||
c.logger.Error().Err(err).Msgf("Could not load roles")
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enc, err := encodeRoleIDs(roleIDs)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Could not encode loaded roles")
|
||||
}
|
||||
|
||||
if user.Opaque == nil {
|
||||
user.Opaque = &types.Opaque{
|
||||
Map: map[string]*types.OpaqueEntry{
|
||||
"roles": enc,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
user.Opaque.Map["roles"] = enc
|
||||
}
|
||||
|
||||
return user, res.Token, nil
|
||||
}
|
||||
|
||||
func (c *cs3backend) Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error) {
|
||||
res, err := c.authProvider.Authenticate(ctx, &gateway.AuthenticateRequest{
|
||||
Type: "basic",
|
||||
ClientId: username,
|
||||
ClientSecret: password,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, "", fmt.Errorf("could not authenticate with username and password user: %s, %w", username, err)
|
||||
case res.Status.Code != rpcv1beta1.Code_CODE_OK:
|
||||
return nil, "", fmt.Errorf("could not authenticate with username and password user: %s, got code: %d", username, res.Status.Code)
|
||||
}
|
||||
|
||||
return res.User, res.Token, nil
|
||||
}
|
||||
|
||||
// CreateUserFromClaims creates a new user via libregraph users API, taking the
|
||||
// attributes from the provided `claims` map. On success it returns the new
|
||||
// user. If the user already exist this is not considered an error and the
|
||||
// function will just return the existing user.
|
||||
func (c *cs3backend) CreateUserFromClaims(ctx context.Context, claims map[string]interface{}) (*cs3.User, error) {
|
||||
newctx := context.Background()
|
||||
token, err := c.generateAutoProvisionAdminToken(newctx)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error generating token for autoprovisioning user.")
|
||||
return nil, err
|
||||
}
|
||||
lgClient, err := c.setupLibregraphClient(ctx, token)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error setting up libregraph client.")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newUser, err := c.libregraphUserFromClaims(newctx, claims)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Interface("claims", claims).Msg("Error creating user from claims")
|
||||
return nil, fmt.Errorf("Error creating user from claims: %w", err)
|
||||
}
|
||||
|
||||
req := lgClient.UsersApi.CreateUser(newctx).User(newUser)
|
||||
|
||||
created, resp, err := req.Execute()
|
||||
var reread bool
|
||||
if err != nil {
|
||||
if resp == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the user already exists here, some other request did already create it in parallel.
|
||||
// So just issue a Debug message and ignore the libregraph error otherwise
|
||||
var lerr error
|
||||
if reread, lerr = c.isAlreadyExists(resp); lerr != nil {
|
||||
c.logger.Error().Err(lerr).Msg("extracting error from ibregraph response body failed.")
|
||||
return nil, err
|
||||
}
|
||||
if !reread {
|
||||
c.logger.Error().Err(err).Msg("Error creating user")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// User has been created meanwhile, re-read it to get the user id
|
||||
if reread {
|
||||
c.logger.Debug().Msg("User already exist, re-reading via libregraph")
|
||||
gureq := lgClient.UserApi.GetUser(newctx, newUser.GetOnPremisesSamAccountName())
|
||||
created, resp, err = gureq.Execute()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error trying to re-read user from graphAPI")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cs3UserCreated := c.cs3UserFromLibregraph(newctx, created)
|
||||
|
||||
return &cs3UserCreated, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) GetUserGroups(ctx context.Context, userID string) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c cs3backend) setupLibregraphClient(ctx context.Context, cs3token string) (*libregraph.APIClient, error) {
|
||||
// Use micro registry to resolve next graph service endpoint
|
||||
next, err := c.graphSelector.Select("com.owncloud.graph.graph")
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("setupLibregraphClient: error during Select")
|
||||
return nil, err
|
||||
}
|
||||
node, err := next()
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("setupLibregraphClient: error getting next Node")
|
||||
return nil, err
|
||||
}
|
||||
lgconf := libregraph.NewConfiguration()
|
||||
lgconf.Servers = libregraph.ServerConfigurations{
|
||||
{
|
||||
URL: fmt.Sprintf("%s://%s/graph/v1.0", node.Metadata["protocol"], node.Address),
|
||||
},
|
||||
}
|
||||
|
||||
lgconf.DefaultHeader = map[string]string{revactx.TokenHeader: cs3token}
|
||||
return libregraph.NewAPIClient(lgconf), nil
|
||||
}
|
||||
|
||||
func (c cs3backend) isAlreadyExists(resp *http.Response) (bool, error) {
|
||||
oDataErr := libregraph.NewOdataErrorWithDefaults()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("Error trying to read libregraph response")
|
||||
return false, err
|
||||
}
|
||||
err = json.Unmarshal(body, oDataErr)
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("Error unmarshalling libregraph response")
|
||||
return false, err
|
||||
}
|
||||
|
||||
if oDataErr.Error.Code == errorcode.NameAlreadyExists.String() {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) libregraphUserFromClaims(ctx context.Context, claims map[string]interface{}) (libregraph.User, error) {
|
||||
var ok bool
|
||||
var dn, mail, username string
|
||||
user := libregraph.User{}
|
||||
if dn, ok = claims[oidc.Name].(string); !ok {
|
||||
return user, fmt.Errorf("Missing claim '%s'", oidc.Name)
|
||||
}
|
||||
if mail, ok = claims[oidc.Email].(string); !ok {
|
||||
return user, fmt.Errorf("Missing claim '%s'", oidc.Email)
|
||||
}
|
||||
if username, ok = claims[oidc.PreferredUsername].(string); !ok {
|
||||
c.logger.Warn().Str("claim", oidc.PreferredUsername).Msg("Missing claim for username, falling back to email address")
|
||||
username = mail
|
||||
}
|
||||
user.DisplayName = &dn
|
||||
user.OnPremisesSamAccountName = &username
|
||||
user.Mail = &mail
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) cs3UserFromLibregraph(ctx context.Context, lu *libregraph.User) cs3.User {
|
||||
cs3id := cs3.UserId{
|
||||
Type: cs3.UserType_USER_TYPE_PRIMARY,
|
||||
Idp: c.oidcISS,
|
||||
}
|
||||
|
||||
cs3id.OpaqueId = lu.GetId()
|
||||
|
||||
cs3user := cs3.User{
|
||||
Id: &cs3id,
|
||||
}
|
||||
cs3user.Username = lu.GetOnPremisesSamAccountName()
|
||||
cs3user.DisplayName = lu.GetDisplayName()
|
||||
cs3user.Mail = lu.GetMail()
|
||||
return cs3user
|
||||
}
|
||||
|
||||
// This returns an hardcoded internal User, that is privileged to create new User via
|
||||
// the Graph API. This user is needed for autoprovisioning of users from incoming OIDC
|
||||
// claims.
|
||||
func getAutoProvisionUserCreator() (*cs3.User, error) {
|
||||
encRoleID, err := encodeRoleIDs([]string{settingsService.BundleUUIDRoleAdmin})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
autoProvisionUserCreator := &cs3.User{
|
||||
DisplayName: "Autoprovision User",
|
||||
Username: "autoprovisioner",
|
||||
Id: &cs3.UserId{
|
||||
Idp: "internal",
|
||||
OpaqueId: "autoprov-user-id00-0000-000000000000",
|
||||
},
|
||||
Opaque: &types.Opaque{
|
||||
Map: map[string]*types.OpaqueEntry{
|
||||
"roles": encRoleID,
|
||||
},
|
||||
},
|
||||
}
|
||||
return autoProvisionUserCreator, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) generateAutoProvisionAdminToken(ctx context.Context) (string, error) {
|
||||
userCreator, err := getAutoProvisionUserCreator()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s, err := scope.AddOwnerScope(nil)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("could not get owner scope")
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := c.tokenManager.MintToken(ctx, userCreator, s)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("could not mint token")
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
248
services/proxy/pkg/user/backend/test/backend_mock.go
Normal file
248
services/proxy/pkg/user/backend/test/backend_mock.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Code generated by moq; DO NOT EDIT.
|
||||
// github.com/matryer/moq
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
|
||||
)
|
||||
|
||||
// Ensure, that UserBackendMock does implement UserBackend.
|
||||
// If this is not the case, regenerate this file with moq.
|
||||
var _ backend.UserBackend = &UserBackendMock{}
|
||||
|
||||
// UserBackendMock is a mock implementation of UserBackend.
|
||||
//
|
||||
// func TestSomethingThatUsesUserBackend(t *testing.T) {
|
||||
//
|
||||
// // make and configure a mocked UserBackend
|
||||
// mockedUserBackend := &UserBackendMock{
|
||||
// AuthenticateFunc: func(ctx context.Context, username string, password string) (*userv1beta1.User, error) {
|
||||
// panic("mock out the Authenticate method")
|
||||
// },
|
||||
// CreateUserFromClaimsFunc: func(ctx context.Context, claims *oidc.StandardClaims) (*userv1beta1.User, error) {
|
||||
// panic("mock out the CreateUserFromClaims method")
|
||||
// },
|
||||
// GetUserByClaimsFunc: func(ctx context.Context, claim string, value string, withRoles bool) (*userv1beta1.User, error) {
|
||||
// panic("mock out the GetUserByClaims method")
|
||||
// },
|
||||
// GetUserGroupsFunc: func(ctx context.Context, userID string) {
|
||||
// panic("mock out the GetUserGroups method")
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// // use mockedUserBackend in code that requires UserBackend
|
||||
// // and then make assertions.
|
||||
//
|
||||
// }
|
||||
type UserBackendMock struct {
|
||||
// AuthenticateFunc mocks the Authenticate method.
|
||||
AuthenticateFunc func(ctx context.Context, username string, password string) (*userv1beta1.User, string, error)
|
||||
|
||||
// CreateUserFromClaimsFunc mocks the CreateUserFromClaims method.
|
||||
CreateUserFromClaimsFunc func(ctx context.Context, claims map[string]interface{}) (*userv1beta1.User, error)
|
||||
|
||||
// GetUserByClaimsFunc mocks the GetUserByClaims method.
|
||||
GetUserByClaimsFunc func(ctx context.Context, claim string, value string, withRoles bool) (*userv1beta1.User, string, error)
|
||||
|
||||
// GetUserGroupsFunc mocks the GetUserGroups method.
|
||||
GetUserGroupsFunc func(ctx context.Context, userID string)
|
||||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
// Authenticate holds details about calls to the Authenticate method.
|
||||
Authenticate []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// Username is the username argument value.
|
||||
Username string
|
||||
// Password is the password argument value.
|
||||
Password string
|
||||
}
|
||||
// CreateUserFromClaims holds details about calls to the CreateUserFromClaims method.
|
||||
CreateUserFromClaims []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// Claims is the claims argument value.
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
// GetUserByClaims holds details about calls to the GetUserByClaims method.
|
||||
GetUserByClaims []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// Claim is the claim argument value.
|
||||
Claim string
|
||||
// Value is the value argument value.
|
||||
Value string
|
||||
// WithRoles is the withRoles argument value.
|
||||
WithRoles bool
|
||||
}
|
||||
// GetUserGroups holds details about calls to the GetUserGroups method.
|
||||
GetUserGroups []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// UserID is the userID argument value.
|
||||
UserID string
|
||||
}
|
||||
}
|
||||
lockAuthenticate sync.RWMutex
|
||||
lockCreateUserFromClaims sync.RWMutex
|
||||
lockGetUserByClaims sync.RWMutex
|
||||
lockGetUserGroups sync.RWMutex
|
||||
}
|
||||
|
||||
// Authenticate calls AuthenticateFunc.
|
||||
func (mock *UserBackendMock) Authenticate(ctx context.Context, username string, password string) (*userv1beta1.User, string, error) {
|
||||
if mock.AuthenticateFunc == nil {
|
||||
panic("UserBackendMock.AuthenticateFunc: method is nil but UserBackend.Authenticate was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
Username string
|
||||
Password string
|
||||
}{
|
||||
Ctx: ctx,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
mock.lockAuthenticate.Lock()
|
||||
mock.calls.Authenticate = append(mock.calls.Authenticate, callInfo)
|
||||
mock.lockAuthenticate.Unlock()
|
||||
return mock.AuthenticateFunc(ctx, username, password)
|
||||
}
|
||||
|
||||
// AuthenticateCalls gets all the calls that were made to Authenticate.
|
||||
// Check the length with:
|
||||
// len(mockedUserBackend.AuthenticateCalls())
|
||||
func (mock *UserBackendMock) AuthenticateCalls() []struct {
|
||||
Ctx context.Context
|
||||
Username string
|
||||
Password string
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
mock.lockAuthenticate.RLock()
|
||||
calls = mock.calls.Authenticate
|
||||
mock.lockAuthenticate.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// CreateUserFromClaims calls CreateUserFromClaimsFunc.
|
||||
func (mock *UserBackendMock) CreateUserFromClaims(ctx context.Context, claims map[string]interface{}) (*userv1beta1.User, error) {
|
||||
if mock.CreateUserFromClaimsFunc == nil {
|
||||
panic("UserBackendMock.CreateUserFromClaimsFunc: method is nil but UserBackend.CreateUserFromClaims was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
Claims map[string]interface{}
|
||||
}{
|
||||
Ctx: ctx,
|
||||
Claims: claims,
|
||||
}
|
||||
mock.lockCreateUserFromClaims.Lock()
|
||||
mock.calls.CreateUserFromClaims = append(mock.calls.CreateUserFromClaims, callInfo)
|
||||
mock.lockCreateUserFromClaims.Unlock()
|
||||
return mock.CreateUserFromClaimsFunc(ctx, claims)
|
||||
}
|
||||
|
||||
// CreateUserFromClaimsCalls gets all the calls that were made to CreateUserFromClaims.
|
||||
// Check the length with:
|
||||
// len(mockedUserBackend.CreateUserFromClaimsCalls())
|
||||
func (mock *UserBackendMock) CreateUserFromClaimsCalls() []struct {
|
||||
Ctx context.Context
|
||||
Claims map[string]interface{}
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
mock.lockCreateUserFromClaims.RLock()
|
||||
calls = mock.calls.CreateUserFromClaims
|
||||
mock.lockCreateUserFromClaims.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// GetUserByClaims calls GetUserByClaimsFunc.
|
||||
func (mock *UserBackendMock) GetUserByClaims(ctx context.Context, claim string, value string, withRoles bool) (*userv1beta1.User, string, error) {
|
||||
if mock.GetUserByClaimsFunc == nil {
|
||||
panic("UserBackendMock.GetUserByClaimsFunc: method is nil but UserBackend.GetUserByClaims was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
Claim string
|
||||
Value string
|
||||
WithRoles bool
|
||||
}{
|
||||
Ctx: ctx,
|
||||
Claim: claim,
|
||||
Value: value,
|
||||
WithRoles: withRoles,
|
||||
}
|
||||
mock.lockGetUserByClaims.Lock()
|
||||
mock.calls.GetUserByClaims = append(mock.calls.GetUserByClaims, callInfo)
|
||||
mock.lockGetUserByClaims.Unlock()
|
||||
return mock.GetUserByClaimsFunc(ctx, claim, value, withRoles)
|
||||
}
|
||||
|
||||
// GetUserByClaimsCalls gets all the calls that were made to GetUserByClaims.
|
||||
// Check the length with:
|
||||
// len(mockedUserBackend.GetUserByClaimsCalls())
|
||||
func (mock *UserBackendMock) GetUserByClaimsCalls() []struct {
|
||||
Ctx context.Context
|
||||
Claim string
|
||||
Value string
|
||||
WithRoles bool
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
Claim string
|
||||
Value string
|
||||
WithRoles bool
|
||||
}
|
||||
mock.lockGetUserByClaims.RLock()
|
||||
calls = mock.calls.GetUserByClaims
|
||||
mock.lockGetUserByClaims.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// GetUserGroups calls GetUserGroupsFunc.
|
||||
func (mock *UserBackendMock) GetUserGroups(ctx context.Context, userID string) {
|
||||
if mock.GetUserGroupsFunc == nil {
|
||||
panic("UserBackendMock.GetUserGroupsFunc: method is nil but UserBackend.GetUserGroups was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
}{
|
||||
Ctx: ctx,
|
||||
UserID: userID,
|
||||
}
|
||||
mock.lockGetUserGroups.Lock()
|
||||
mock.calls.GetUserGroups = append(mock.calls.GetUserGroups, callInfo)
|
||||
mock.lockGetUserGroups.Unlock()
|
||||
mock.GetUserGroupsFunc(ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserGroupsCalls gets all the calls that were made to GetUserGroups.
|
||||
// Check the length with:
|
||||
// len(mockedUserBackend.GetUserGroupsCalls())
|
||||
func (mock *UserBackendMock) GetUserGroupsCalls() []struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
}
|
||||
mock.lockGetUserGroups.RLock()
|
||||
calls = mock.calls.GetUserGroups
|
||||
mock.lockGetUserGroups.RUnlock()
|
||||
return calls
|
||||
}
|
||||
77
services/proxy/pkg/webdav/response.go
Normal file
77
services/proxy/pkg/webdav/response.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type code int
|
||||
|
||||
const (
|
||||
// SabredavBadRequest maps to HTTP 400
|
||||
SabredavBadRequest code = iota
|
||||
// SabredavMethodNotAllowed maps to HTTP 405
|
||||
SabredavMethodNotAllowed
|
||||
// SabredavNotAuthenticated maps to HTTP 401
|
||||
SabredavNotAuthenticated
|
||||
// SabredavPreconditionFailed maps to HTTP 412
|
||||
SabredavPreconditionFailed
|
||||
// SabredavPermissionDenied maps to HTTP 403
|
||||
SabredavPermissionDenied
|
||||
// SabredavNotFound maps to HTTP 404
|
||||
SabredavNotFound
|
||||
// SabredavConflict maps to HTTP 409
|
||||
SabredavConflict
|
||||
)
|
||||
|
||||
var (
|
||||
codesEnum = []string{
|
||||
"Sabre\\DAV\\Exception\\BadRequest",
|
||||
"Sabre\\DAV\\Exception\\MethodNotAllowed",
|
||||
"Sabre\\DAV\\Exception\\NotAuthenticated",
|
||||
"Sabre\\DAV\\Exception\\PreconditionFailed",
|
||||
"Sabre\\DAV\\Exception\\PermissionDenied",
|
||||
"Sabre\\DAV\\Exception\\NotFound",
|
||||
"Sabre\\DAV\\Exception\\Conflict",
|
||||
}
|
||||
)
|
||||
|
||||
type Exception struct {
|
||||
Code code
|
||||
Message string
|
||||
Header string
|
||||
}
|
||||
|
||||
// Marshal just calls the xml marshaller for a given Exception.
|
||||
func Marshal(e Exception) ([]byte, error) {
|
||||
xmlstring, err := xml.Marshal(&errorXML{
|
||||
Xmlnsd: "DAV",
|
||||
Xmlnss: "http://sabredav.org/ns",
|
||||
Exception: codesEnum[e.Code],
|
||||
Message: e.Message,
|
||||
Header: e.Header,
|
||||
})
|
||||
if err != nil {
|
||||
return []byte(""), err
|
||||
}
|
||||
return []byte(xml.Header + string(xmlstring)), err
|
||||
}
|
||||
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
|
||||
type errorXML struct {
|
||||
XMLName xml.Name `xml:"d:error"`
|
||||
Xmlnsd string `xml:"xmlns:d,attr"`
|
||||
Xmlnss string `xml:"xmlns:s,attr"`
|
||||
Exception string `xml:"s:Exception"`
|
||||
Message string `xml:"s:Message"`
|
||||
InnerXML []byte `xml:",innerxml"`
|
||||
Header string `xml:"s:Header,omitempty"`
|
||||
}
|
||||
|
||||
func HandleWebdavError(w http.ResponseWriter, b []byte, err error) {
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
18
services/proxy/pkg/webdav/webdav.go
Normal file
18
services/proxy/pkg/webdav/webdav.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package webdav
|
||||
|
||||
import "net/http"
|
||||
|
||||
var methods = []string{"PROPFIND", "DELETE", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"}
|
||||
|
||||
// This is a non exhaustive way to detect if a request is directed to a webdav server. This naïve implementation
|
||||
// only deals with the set of methods exclusive to WebDAV. Since WebDAV is a superset of HTTP, GET, POST and so on
|
||||
// are valid methods, but this implementation would require a larger effort than we can build upon in this file.
|
||||
// This is needed because the proxy might need to create a response with a webdav body; such as unauthorized.
|
||||
func IsWebdavRequest(r *http.Request) bool {
|
||||
for i := range methods {
|
||||
if methods[i] == r.Method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user