Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,54 @@
package command
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/config/parser"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "check health status",
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
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
},
}
}
+36
View File
@@ -0,0 +1,36 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.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 qsfera-proxy command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "proxy",
Short: "proxy for КуСфера",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
+397
View File
@@ -0,0 +1,397 @@
package command
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"os/signal"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/justinas/alice"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/generators"
"github.com/qsfera/server/pkg/log"
pkgmiddleware "github.com/qsfera/server/pkg/middleware"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/runner"
"github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
policiessvc "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/config/parser"
"github.com/qsfera/server/services/proxy/pkg/metrics"
"github.com/qsfera/server/services/proxy/pkg/middleware"
"github.com/qsfera/server/services/proxy/pkg/proxy"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/server/debug"
proxyHTTP "github.com/qsfera/server/services/proxy/pkg/server/http"
"github.com/qsfera/server/services/proxy/pkg/staticroutes"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/userroles"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
"github.com/opencloud-eu/reva/v2/pkg/store"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/spf13/cobra"
"go-micro.dev/v4/selector"
microstore "go-micro.dev/v4/store"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/trace"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
userInfoCache := store.Create(
store.Store(cfg.OIDC.UserinfoCache.Store),
store.TTL(cfg.OIDC.UserinfoCache.TTL),
microstore.Nodes(cfg.OIDC.UserinfoCache.Nodes...),
microstore.Database(cfg.OIDC.UserinfoCache.Database),
microstore.Table(cfg.OIDC.UserinfoCache.Table),
store.DisablePersistence(cfg.OIDC.UserinfoCache.DisablePersistence),
store.Authentication(cfg.OIDC.UserinfoCache.AuthUsername, cfg.OIDC.UserinfoCache.AuthPassword),
)
signingKeyStore := store.Create(
store.Store(cfg.PreSignedURL.SigningKeys.Store),
store.TTL(cfg.PreSignedURL.SigningKeys.TTL),
microstore.Nodes(cfg.PreSignedURL.SigningKeys.Nodes...),
microstore.Database("proxy"),
microstore.Table("signing-keys"),
store.DisablePersistence(cfg.PreSignedURL.SigningKeys.DisablePersistence),
store.Authentication(cfg.PreSignedURL.SigningKeys.AuthUsername, cfg.PreSignedURL.SigningKeys.AuthPassword),
)
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
cfg.GrpcClient, err = grpc.NewClient(
append(
grpc.GetClientOptions(cfg.GRPCClientTLS),
grpc.WithTraceProvider(traceProvider))...)
if err != nil {
return err
}
oidcHTTPClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.OIDC.Insecure, //nolint:gosec
},
DisableKeepAlives: true,
},
Timeout: time.Second * 10,
}
oidcClient := oidc.NewOIDCClient(
oidc.WithAccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
oidc.WithLogger(logger),
oidc.WithHTTPClient(oidcHTTPClient),
oidc.WithOidcIssuer(cfg.OIDC.Issuer),
oidc.WithJWKSOptions(cfg.OIDC.JWKS),
)
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
m := metrics.New()
m.BuildInfo.WithLabelValues(version.GetString()).Set(1)
rp, err := proxy.NewMultiHostReverseProxy(
proxy.Logger(logger),
proxy.Config(cfg),
)
if err != nil {
return fmt.Errorf("failed to initialize reverse proxy: %w", err)
}
reg := registry.GetRegistry()
gatewaySelector, err := pool.GatewaySelector(
cfg.Reva.Address,
append(
cfg.Reva.GetRevaOptions(),
pool.WithRegistry(reg),
pool.WithTracerProvider(traceProvider),
)...)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to get gateway selector")
}
serviceSelector := selector.NewSelector(selector.Registry(reg))
var userProvider backend.UserBackend
switch cfg.AccountBackend {
case "cs3":
userProvider = backend.NewCS3UserBackend(
backend.WithLogger(logger),
backend.WithRevaGatewaySelector(gatewaySelector),
backend.WithSelector(serviceSelector),
backend.WithMachineAuthAPIKey(cfg.MachineAuthAPIKey),
backend.WithOIDCissuer(cfg.OIDC.Issuer),
backend.WithServiceAccount(cfg.ServiceAccount),
backend.WithAutoProvisionClaims(cfg.AutoProvisionClaims),
)
default:
logger.Fatal().Msgf("Invalid accounts backend type '%s'", cfg.AccountBackend)
}
var publisher events.Stream
if cfg.Events.Endpoint != "" {
var err error
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
publisher, err = stream.NatsFromConfig(connName, false, stream.NatsConfig(cfg.Events))
if err != nil {
logger.Error().
Err(err).
Msg("Error initializing events publisher")
return fmt.Errorf("could not initialize events publisher %w", err)
}
}
lh := staticroutes.StaticRouteHandler{
Prefix: cfg.HTTP.Root,
UserInfoCache: userInfoCache,
Logger: logger,
Config: *cfg,
OidcClient: oidcClient,
OidcHttpClient: oidcHTTPClient,
Proxy: rp,
EventsPublisher: publisher,
UserProvider: userProvider,
}
if err != nil {
return fmt.Errorf("failed to initialize reverse proxy: %w", err)
}
gr := runner.NewGroup()
{
middlewares := loadMiddlewares(logger, cfg, userInfoCache, signingKeyStore, traceProvider, *m, userProvider, publisher, gatewaySelector, serviceSelector)
server, err := proxyHTTP.Server(
proxyHTTP.Handler(lh.Handler()),
proxyHTTP.Logger(logger),
proxyHTTP.Context(cfg.Context),
proxyHTTP.Config(cfg),
proxyHTTP.Metrics(metrics.New()),
proxyHTTP.Middlewares(middlewares),
)
if err != nil {
logger.Error().
Err(err).
Str("server", "http").
Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
}
{
debugServer, err := debug.Server(
debug.Logger(logger),
debug.Context(cfg.Context),
debug.Config(cfg),
)
if err != nil {
logger.Error().Err(err).Str("server", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
grResults := gr.Run(cfg.Context)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
func loadMiddlewares(logger log.Logger, cfg *config.Config,
userInfoCache, signingKeyStore microstore.Store,
traceProvider trace.TracerProvider, metrics metrics.Metrics,
userProvider backend.UserBackend, publisher events.Publisher,
gatewaySelector pool.Selectable[gateway.GatewayAPIClient], serviceSelector selector.Selector) alice.Chain {
rolesClient := settingssvc.NewRoleService("qsfera.api.settings", cfg.GrpcClient)
policiesProviderClient := policiessvc.NewPoliciesProviderService("qsfera.api.policies", cfg.GrpcClient)
var roleAssigner userroles.UserRoleAssigner
switch cfg.RoleAssignment.Driver {
case "default":
roleAssigner = userroles.NewDefaultRoleAssigner(
userroles.WithRoleService(rolesClient),
userroles.WithLogger(logger),
)
case "oidc":
roleAssigner = userroles.NewOIDCRoleAssigner(
userroles.WithRoleService(rolesClient),
userroles.WithLogger(logger),
userroles.WithRolesClaim(cfg.RoleAssignment.OIDCRoleMapper.RoleClaim),
userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RolesMap),
userroles.WithRevaGatewaySelector(gatewaySelector),
userroles.WithServiceAccount(cfg.ServiceAccount),
)
default:
logger.Fatal().Msgf("Invalid role assignment driver '%s'", cfg.RoleAssignment.Driver)
}
oidcHTTPClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.OIDC.Insecure, //nolint:gosec
},
DisableKeepAlives: true,
},
Timeout: time.Second * 10,
}
var authenticators []middleware.Authenticator
if cfg.EnableBasicAuth {
logger.Warn().Msg("basic auth enabled, use only for testing or development")
authenticators = append(authenticators, middleware.BasicAuthenticator{
Logger: logger,
UserProvider: userProvider,
})
}
if cfg.AuthMiddleware.AllowAppAuth {
authenticators = append(authenticators, middleware.AppAuthAuthenticator{
Logger: logger,
RevaGatewaySelector: gatewaySelector,
UserRoleAssigner: roleAssigner,
})
}
authenticators = append(authenticators, middleware.NewOIDCAuthenticator(
middleware.Logger(logger),
middleware.UserInfoCache(userInfoCache),
middleware.DefaultAccessTokenTTL(cfg.OIDC.UserinfoCache.TTL),
middleware.HTTPClient(oidcHTTPClient),
middleware.OIDCIss(cfg.OIDC.Issuer),
middleware.OIDCClient(oidc.NewOIDCClient(
oidc.WithAccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
oidc.WithLogger(logger),
oidc.WithHTTPClient(oidcHTTPClient),
oidc.WithOidcIssuer(cfg.OIDC.Issuer),
oidc.WithJWKSOptions(cfg.OIDC.JWKS),
)),
middleware.SkipUserInfo(cfg.OIDC.SkipUserInfo),
))
authenticators = append(authenticators, middleware.PublicShareAuthenticator{
Logger: logger,
RevaGatewaySelector: gatewaySelector,
})
signURLVerifier, err := signedurl.NewJWTSignedURL(signedurl.WithSecret(cfg.Commons.URLSigningSecret))
if err != nil {
logger.Fatal().Err(err).Msg("Failed to initialize signed URL configuration.")
}
authenticators = append(authenticators, middleware.SignedURLAuthenticator{
Logger: logger,
PreSignedURLConfig: cfg.PreSignedURL,
UserProvider: userProvider,
UserRoleAssigner: roleAssigner,
Store: signingKeyStore,
Now: time.Now,
URLVerifier: signURLVerifier,
})
cspConfig, err := middleware.LoadCSPConfig(cfg)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to load CSP configuration.")
}
return alice.New(
chimiddleware.RealIP,
chimiddleware.RequestID,
// first make sure we log all requests and redirect to https if necessary
otelhttp.NewMiddleware("proxy",
otelhttp.WithTracerProvider(traceProvider),
otelhttp.WithSpanNameFormatter(func(name string, r *http.Request) string {
return fmt.Sprintf("%s %s", r.Method, r.URL.Path)
}),
),
middleware.Tracer(traceProvider),
pkgmiddleware.TraceContext,
middleware.Instrumenter(metrics),
middleware.AccessLog(logger),
middleware.ContextLogger(logger),
middleware.HTTPSRedirect,
middleware.Security(cspConfig),
router.Middleware(serviceSelector, cfg.PolicySelector, cfg.Policies, logger),
middleware.Authentication(
authenticators,
middleware.CredentialsByUserAgent(cfg.AuthMiddleware.CredentialsByUserAgent),
middleware.Logger(logger),
middleware.OIDCIss(cfg.OIDC.Issuer),
middleware.EnableBasicAuth(cfg.EnableBasicAuth || cfg.AuthMiddleware.AllowAppAuth),
middleware.TraceProvider(traceProvider),
),
middleware.AccountResolver(
middleware.Logger(logger),
middleware.TraceProvider(traceProvider),
middleware.UserProvider(userProvider),
middleware.UserRoleAssigner(roleAssigner),
middleware.SkipUserInfo(cfg.OIDC.SkipUserInfo),
middleware.UserOIDCClaim(cfg.UserOIDCClaim),
middleware.UserCS3Claim(cfg.UserCS3Claim),
middleware.TenantOIDCClaim(cfg.TenantOIDCClaim),
middleware.TenantIDMappingEnabled(cfg.TenantIDMappingEnabled),
middleware.ServiceAccount(cfg.ServiceAccount),
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.AutoprovisionAccounts(cfg.AutoprovisionAccounts),
middleware.MultiTenantEnabled(cfg.Commons.MultiTenantEnabled),
middleware.EventsPublisher(publisher),
),
middleware.SelectorCookie(
middleware.Logger(logger),
middleware.TraceProvider(traceProvider),
middleware.PolicySelectorConfig(*cfg.PolicySelector),
),
middleware.Policies(
cfg.PoliciesMiddleware.Query,
middleware.Logger(logger),
middleware.TraceProvider(traceProvider),
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.PoliciesProviderService(policiesProviderClient),
),
// finally, trigger home creation when a user logs in
middleware.CreateHome(
middleware.Logger(logger),
middleware.TraceProvider(traceProvider),
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.RoleQuotas(cfg.RoleQuotas),
),
)
}
@@ -0,0 +1,49 @@
package command
import (
"fmt"
"os"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) 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 := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
table.Header([]string{"Version", "Address", "Id"})
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
+237
View File
@@ -0,0 +1,237 @@
package config
import (
"context"
"time"
"github.com/qsfera/server/pkg/shared"
"go-micro.dev/v4/client"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-" mask:"struct"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;PROXY_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug" mask:"struct"`
HTTP HTTP `yaml:"http"`
Reva *shared.Reva `yaml:"reva"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
GrpcClient client.Client `yaml:"-"`
RoleQuotas map[string]uint64 `yaml:"role_quotas"`
Policies []Policy `yaml:"policies"`
AdditionalPolicies []Policy `yaml:"additional_policies"`
OIDC OIDC `yaml:"oidc"`
ServiceAccount ServiceAccount `yaml:"service_account"`
RoleAssignment RoleAssignment `yaml:"role_assignment"`
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 service should use. Currently only 'cs3' is possible here." introductionVersion:"1.0.0"`
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"1.0.0"`
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'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"1.0.0"`
TenantOIDCClaim string `yaml:"tenant_oidc_claim" env:"PROXY_TENANT_OIDC_CLAIM" desc:"JMESPath expression to extract the tenant ID from the OIDC token claims. When set, the extracted value is verified against the tenant ID returned by the user backend, rejecting requests where they do not match. Only relevant when multi-tenancy is enabled." introductionVersion:"6.1.0"`
TenantIDMappingEnabled bool `yaml:"tenant_id_mapping_enabled" env:"PROXY_TENANT_ID_MAPPING_ENABLED" desc:"When set to 'true', the proxy will resolve the internal tenant ID from the external tenant ID provided in the OIDC claims by calling the TenantAPI before verifying the tenant. Use this when the external tenant ID in the OIDC token differs from the internal tenant ID stored on the user. Requires 'tenant_oidc_claim' to be set. Only relevant when multi-tenancy is enabled." introductionVersion:"6.1.0"`
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OC_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"1.0.0" mask:"password"`
AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"1.0.0"`
AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"`
EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"1.0.0"`
InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"1.0.0"`
BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the servers TLS certificate for https enabled backend services." introductionVersion:"1.0.0"`
AuthMiddleware AuthMiddleware `yaml:"auth_middleware"`
PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"`
CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"1.0.0"`
CSPConfigFileOverrideLocation string `yaml:"csp_config_file_override_location" env:"PROXY_CSP_CONFIG_FILE_OVERRIDE_LOCATION" desc:"The location of the CSP configuration file override." introductionVersion:"4.0.0"`
Events Events `yaml:"events"`
Context context.Context `json:"-" 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"`
Unprotected bool `yaml:"unprotected,omitempty"`
AdditionalHeaders map[string]string `yaml:"additional_headers,omitempty"`
RemoteUserHeader string `yaml:"remote_user_header,omitempty"`
SkipXAccessToken bool `yaml:"skip_x_access_token"`
}
// RouteType defines the type of 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"`
AllowAppAuth bool `yaml:"allow_app_auth" env:"PROXY_ENABLE_APP_AUTH" desc:"Allow app authentication. This can be used to authenticate 3rd party applications. Note that auth-app service must be running for this feature to work." introductionVersion:"1.0.0"`
}
// PoliciesMiddleware configures the proxy's policies middleware.
type PoliciesMiddleware struct {
Query string `yaml:"query" env:"PROXY_POLICIES_QUERY" desc:"Defines the 'Complete Rules' variable defined in the rego rule set this step uses for its evaluation. Rules default to deny if the variable was not found." introductionVersion:"1.0.0"`
}
const (
AccessTokenVerificationNone = "none"
AccessTokenVerificationJWT = "jwt"
// tdb:
// AccessTokenVerificationIntrospect = "introspect"
)
// 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:"OC_URL;OC_OIDC_ISSUER;PROXY_OIDC_ISSUER" desc:"URL of the OIDC issuer. It defaults to URL of the builtin IDP." introductionVersion:"1.0.0"`
Insecure bool `yaml:"insecure" env:"OC_INSECURE;PROXY_OIDC_INSECURE" desc:"Disable TLS certificate validation for connections to the IDP. Note that this is not recommended for production environments." introductionVersion:"1.0.0"`
AccessTokenVerifyMethod string `yaml:"access_token_verify_method" env:"PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD" desc:"Sets how OIDC access tokens should be verified. Possible values are 'none' and 'jwt'. When using 'none', no special validation apart from using it for accessing the IDP's userinfo endpoint will be done. When using 'jwt', it tries to parse the access token as a jwt token and verifies the signature using the keys published on the IDP's 'jwks_uri'." introductionVersion:"1.0.0"`
SkipUserInfo bool `yaml:"skip_user_info" env:"PROXY_OIDC_SKIP_USER_INFO" desc:"Do not look up user claims at the userinfo endpoint and directly read them from the access token. Incompatible with 'PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD=none'." introductionVersion:"1.0.0"`
UserinfoCache *Cache `yaml:"user_info_cache"`
JWKS JWKS `yaml:"jwks"`
RewriteWellKnown bool `yaml:"rewrite_well_known" env:"PROXY_OIDC_REWRITE_WELLKNOWN" desc:"Enables rewriting the /.well-known/openid-configuration to the configured OIDC issuer. Needed by the Desktop Client, Android Client and iOS Client to discover the OIDC provider." introductionVersion:"1.0.0"`
}
type JWKS struct {
RefreshInterval uint64 `yaml:"refresh_interval" env:"PROXY_OIDC_JWKS_REFRESH_INTERVAL" desc:"The interval for refreshing the JWKS (JSON Web Key Set) in minutes in the background via a new HTTP request to the IDP." introductionVersion:"1.0.0"`
RefreshTimeout uint64 `yaml:"refresh_timeout" env:"PROXY_OIDC_JWKS_REFRESH_TIMEOUT" desc:"The timeout in seconds for an outgoing JWKS request." introductionVersion:"1.0.0"`
RefreshRateLimit uint64 `yaml:"refresh_limit" env:"PROXY_OIDC_JWKS_REFRESH_RATE_LIMIT" desc:"Limits the rate in seconds at which refresh requests are performed for unknown keys. This is used to prevent malicious clients from imposing high network load on the IDP via КуСфера." introductionVersion:"1.0.0"`
RefreshUnknownKID bool `yaml:"refresh_unknown_kid" env:"PROXY_OIDC_JWKS_REFRESH_UNKNOWN_KID" desc:"If set to 'true', the JWKS refresh request will occur every time an unknown KEY ID (KID) is seen. Always set a 'refresh_limit' when enabling this." introductionVersion:"1.0.0"`
}
// Cache is a TTL cache configuration.
type Cache struct {
Store string `yaml:"store" env:"OC_CACHE_STORE;PROXY_OIDC_USERINFO_CACHE_STORE" desc:"The type of the cache store. Supported values are: 'memory', 'redis-sentinel', 'nats-js-kv', 'noop'. See the text description for details." introductionVersion:"1.0.0"`
Nodes []string `yaml:"addresses" env:"OC_CACHE_STORE_NODES;PROXY_OIDC_USERINFO_CACHE_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
Database string `yaml:"database" env:"OC_CACHE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
Table string `yaml:"table" env:"PROXY_OIDC_USERINFO_CACHE_TABLE" desc:"The database table the store should use." introductionVersion:"1.0.0"`
TTL time.Duration `yaml:"ttl" env:"OC_CACHE_TTL;PROXY_OIDC_USERINFO_CACHE_TTL" desc:"Default time to live for user info in the user info cache. This value is only applied when the token expiration cannot be extracted from the access tokens (e.g. when non-JWT access tokes are used). See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
DisablePersistence bool `yaml:"disable_persistence" env:"OC_CACHE_DISABLE_PERSISTENCE;PROXY_OIDC_USERINFO_CACHE_DISABLE_PERSISTENCE" desc:"Disables persistence of the cache. Only applies when store type 'nats-js-kv' is configured. Defaults to false." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_CACHE_AUTH_USERNAME;PROXY_OIDC_USERINFO_CACHE_AUTH_USERNAME" desc:"The username to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_CACHE_AUTH_PASSWORD;PROXY_OIDC_USERINFO_CACHE_AUTH_PASSWORD" desc:"The password to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
// RoleAssignment contains the configuration for how to assign roles to users during login
type RoleAssignment struct {
Driver string `yaml:"driver" env:"PROXY_ROLE_ASSIGNMENT_DRIVER" desc:"The mechanism that should be used to assign roles to user upon login. Supported values: 'default' or 'oidc'. 'default' will assign the role 'user' to users which don't have a role assigned at the time they login. 'oidc' will assign the role based on the value of a claim (configured via PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM) from the users OIDC claims." introductionVersion:"1.0.0"`
OIDCRoleMapper OIDCRoleMapper `yaml:"oidc_role_mapper"`
}
// OIDCRoleMapper contains the configuration for the "oidc" role assignment driver
type OIDCRoleMapper struct {
RoleClaim string `yaml:"role_claim" env:"PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" desc:"The OIDC claim used to create the users role assignment." introductionVersion:"1.0.0"`
RolesMap []RoleMapping `yaml:"role_mapping" desc:"A list of mappings of КуСфера role names to PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM claim values. This setting can only be configured in the configuration file and not via environment variables."`
}
// RoleMapping defines which КуСфера role matches a specific claim value
type RoleMapping struct {
RoleName string `yaml:"role_name" desc:"The name of an КуСфера role that this mapping should apply for."`
ClaimValue string `yaml:"claim_value" desc:"The value of the 'PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM' that matches the role defined in 'role_name'."`
}
// AutoProvisionClaims defines which claims from the OIDC userinfo response should be used for auto-provisioning user accounts
type AutoProvisionClaims struct {
Username string `yaml:"username" env:"PROXY_AUTOPROVISION_CLAIM_USERNAME" desc:"The name of the OIDC claim that holds the username." introductionVersion:"1.0.0"`
Email string `yaml:"email" env:"PROXY_AUTOPROVISION_CLAIM_EMAIL" desc:"The name of the OIDC claim that holds the email." introductionVersion:"1.0.0"`
DisplayName string `yaml:"display_name" env:"PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME" desc:"The name of the OIDC claim that holds the display name." introductionVersion:"1.0.0"`
Groups string `yaml:"groups" env:"PROXY_AUTOPROVISION_CLAIM_GROUPS" desc:"The name of the OIDC claim that holds the groups." introductionVersion:"1.0.0"`
}
// 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"`
}
// PreSignedURL is the config for the pre-signed 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." introductionVersion:"1.0.0"`
SigningKeys *SigningKeys `yaml:"signing_keys"`
}
// SigningKeys is a store configuration.
type SigningKeys struct {
Store string `yaml:"store" env:"OC_CACHE_STORE;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE" desc:"The type of the signing key store. Supported values are: 'redis-sentinel', 'nats-js-kv' and 'qsferastoreservice' (deprecated). See the text description for details." introductionVersion:"1.0.0"`
Nodes []string `yaml:"addresses" env:"OC_CACHE_STORE_NODES;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_NODES" desc:"A list of nodes to access the configured store. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
TTL time.Duration `yaml:"ttl" env:"OC_CACHE_TTL;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_TTL" desc:"Default time to live for signing keys. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
DisablePersistence bool `yaml:"disable_persistence" env:"OC_CACHE_DISABLE_PERSISTENCE;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_DISABLE_PERSISTENCE" desc:"Disables persistence of the store. Only applies when store type 'nats-js-kv' is configured. Defaults to true." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_CACHE_AUTH_USERNAME;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_CACHE_AUTH_PASSWORD;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
// 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"`
}
// ServiceAccount is the configuration for the used service account
type ServiceAccount struct {
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;PROXY_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;PROXY_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
}
// Events combines the configuration options for the event bus.
type Events struct {
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;PROXY_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Set to a empty string to disable emitting events." introductionVersion:"1.0.0"`
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;PROXY_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"1.0.0"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;PROXY_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;PROXY_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided PROXY_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;PROXY_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;PROXY_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;PROXY_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
}
+13
View File
@@ -0,0 +1,13 @@
package config
import (
_ "embed"
)
// CSP defines CSP header directives
type CSP struct {
Directives map[string][]string `yaml:"directives"`
}
//go:embed csp.yaml
var DefaultCSPConfig string
+36
View File
@@ -0,0 +1,36 @@
directives:
child-src:
- '''self'''
connect-src:
- '''self'''
- 'blob:'
- 'https://raw.githubusercontent.com/qsfera-eu/awesome-apps/'
- 'https://update.qsfera.eu/'
default-src:
- '''none'''
font-src:
- '''self'''
frame-ancestors:
- '''self'''
frame-src:
- '''self'''
- 'blob:'
- 'https://embed.diagrams.net/'
img-src:
- '''self'''
- 'data:'
- 'blob:'
- 'https://raw.githubusercontent.com/qsfera-eu/awesome-apps/'
manifest-src:
- '''self'''
media-src:
- '''self'''
object-src:
- '''self'''
- 'blob:'
script-src:
- '''self'''
- '''unsafe-inline'''
style-src:
- '''self'''
- '''unsafe-inline'''
@@ -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." introductionVersion:"1.0.0"`
Token string `mask:"password" yaml:"token" env:"PROXY_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"PROXY_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"PROXY_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,370 @@
package defaults
import (
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/qsfera/server/pkg/config/defaults"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/structs"
"github.com/qsfera/server/services/proxy/pkg/config"
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// DefaultConfig returns a basic default configuration
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: "qsfera.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",
AccessTokenVerifyMethod: config.AccessTokenVerificationJWT,
SkipUserInfo: false,
UserinfoCache: &config.Cache{
Store: "memory",
Nodes: []string{"127.0.0.1:9233"},
Database: "cache-userinfo",
TTL: time.Second * 10,
},
JWKS: config.JWKS{
RefreshInterval: 60, // minutes
RefreshRateLimit: 60, // seconds
RefreshTimeout: 10, // seconds
RefreshUnknownKID: true,
},
},
PolicySelector: nil,
RoleAssignment: config.RoleAssignment{
Driver: "default",
// this default is only relevant when Driver is set to "oidc"
OIDCRoleMapper: config.OIDCRoleMapper{
RoleClaim: "roles",
RolesMap: []config.RoleMapping{
{RoleName: "admin", ClaimValue: "qsferaAdmin"},
{RoleName: "spaceadmin", ClaimValue: "qsferaSpaceAdmin"},
{RoleName: "user", ClaimValue: "qsferaUser"},
{RoleName: "user-light", ClaimValue: "qsferaGuest"},
},
},
},
Reva: shared.DefaultRevaConfig(),
PreSignedURL: config.PreSignedURL{
AllowedHTTPMethods: []string{"GET"},
Enabled: true,
SigningKeys: &config.SigningKeys{
Store: "nats-js-kv", // signing keys are written by ocs, so we cannot use memory. It is not shared.
Nodes: []string{"127.0.0.1:9233"},
TTL: time.Hour * 12,
DisablePersistence: true,
},
},
AccountBackend: "cs3",
UserOIDCClaim: "preferred_username",
UserCS3Claim: "username",
AutoprovisionAccounts: false,
AutoProvisionClaims: config.AutoProvisionClaims{
Username: "preferred_username",
Email: "email",
DisplayName: "name",
Groups: "groups",
},
EnableBasicAuth: false,
InsecureBackends: false,
CSPConfigFileLocation: "",
CSPConfigFileOverrideLocation: "",
Events: config.Events{
Endpoint: "127.0.0.1:9233",
Cluster: "qsfera-cluster",
EnableTLS: false,
},
AuthMiddleware: config.AuthMiddleware{
AllowAppAuth: true,
},
}
}
// DefaultPolicies returns the default proxy policies.
func DefaultPolicies() []config.Policy {
return []config.Policy{
{
Name: "default",
Routes: []config.Route{
{
Endpoint: "/",
Service: "qsfera.web.web",
Unprotected: true,
},
{
Endpoint: "/.well-known/ocm",
Service: "qsfera.web.ocm",
Unprotected: true,
},
{
Endpoint: "/.well-known/webfinger",
Service: "qsfera.web.webfinger",
Unprotected: true,
},
{
Endpoint: "/.well-known/openid-configuration",
Service: "qsfera.web.idp",
Unprotected: true,
},
{
Endpoint: "/branding/logo",
Service: "qsfera.web.web",
},
{
Endpoint: "/konnect/",
Service: "qsfera.web.idp",
Unprotected: true,
},
{
Endpoint: "/signin/",
Service: "qsfera.web.idp",
Unprotected: true,
},
{
Endpoint: "/archiver",
Service: "qsfera.web.frontend",
},
{
// reroute oc10 notifications endpoint to userlog service
Endpoint: "/ocs/v2.php/apps/notifications/api/v1/notifications/sse",
Service: "qsfera.sse.sse",
},
{
// reroute oc10 notifications endpoint to userlog service
Endpoint: "/ocs/v2.php/apps/notifications/api/v1/notifications",
Service: "qsfera.web.userlog",
},
{
Type: config.RegexRoute,
Endpoint: "/ocs/v[12].php/cloud/user/signing-key", // only `user/signing-key` is left in qsfera-ocs
Service: "qsfera.web.ocs",
},
{
Type: config.RegexRoute,
Endpoint: "/ocs/v[12].php/config",
Service: "qsfera.web.frontend",
Unprotected: true,
},
// OCM WAYF public endpoints
{
Endpoint: "/sciencemesh/federations",
Service: "qsfera.web.ocm",
Unprotected: true,
},
{
Endpoint: "/sciencemesh/discover",
Service: "qsfera.web.ocm",
Unprotected: true,
},
// General sciencemesh endpoints
{
Endpoint: "/sciencemesh/",
Service: "qsfera.web.ocm",
},
{
Endpoint: "/ocm/",
Service: "qsfera.web.ocm",
},
{
Endpoint: "/ocs/",
Service: "qsfera.web.frontend",
},
{
Type: config.QueryRoute,
Endpoint: "/remote.php/?preview=1",
Service: "qsfera.web.webdav",
},
// 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
{
Type: config.RegexRoute,
Method: "REPORT",
Endpoint: "(/remote.php)?/(web)?dav",
Service: "qsfera.web.webdav",
},
{
Type: config.QueryRoute,
Endpoint: "/dav/?preview=1",
Service: "qsfera.web.webdav",
},
{
Type: config.QueryRoute,
Endpoint: "/webdav/?preview=1",
Service: "qsfera.web.webdav",
},
{
Endpoint: "/remote.php/",
Service: "qsfera.web.frontend",
},
{
Endpoint: "/dav/",
Service: "qsfera.web.frontend",
},
{
Endpoint: "/webdav/",
Service: "qsfera.web.frontend",
},
{
Endpoint: "/status",
Service: "qsfera.web.frontend",
Unprotected: true,
},
{
Endpoint: "/status.php",
Service: "qsfera.web.frontend",
Unprotected: true,
},
{
Endpoint: "/index.php/",
Service: "qsfera.web.frontend",
},
{
Endpoint: "/apps/",
Service: "qsfera.web.frontend",
},
{
Endpoint: "/data",
Service: "qsfera.web.frontend",
Unprotected: true,
},
{
Endpoint: "/app/list",
Service: "qsfera.web.frontend",
Unprotected: true,
},
{
Endpoint: "/app/", // /app or /apps? frontend only handles /apps
Service: "qsfera.web.frontend",
},
{
Endpoint: "/graph/v1beta1/extensions/org.libregraph/activities",
Service: "qsfera.web.activitylog",
},
{
Endpoint: "/graph/v1.0/invitations",
Service: "qsfera.web.invitations",
},
{
Endpoint: "/graph/",
Service: "qsfera.web.graph",
},
{
Endpoint: "/api/v0/settings",
Service: "qsfera.web.settings",
},
{
Endpoint: "/auth-app/tokens",
Service: "qsfera.web.auth-app",
},
{
Endpoint: "/wopi",
Service: "qsfera.web.collaboration",
Unprotected: true,
SkipXAccessToken: true,
},
},
},
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.OIDC.UserinfoCache == nil && cfg.Commons != nil && cfg.Commons.Cache != nil {
cfg.OIDC.UserinfoCache = &config.Cache{
Store: cfg.Commons.Cache.Store,
Nodes: cfg.Commons.Cache.Nodes,
}
} else if cfg.OIDC.UserinfoCache == nil {
cfg.OIDC.UserinfoCache = &config.Cache{}
}
if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" {
cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey
}
if cfg.Reva == nil && cfg.Commons != nil {
cfg.Reva = structs.CopyOrZeroValue(cfg.Commons.Reva)
}
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
}
}
// Sanitize sanitizes the configuration
func Sanitize(cfg *config.Config) {
if cfg.Policies == nil {
cfg.Policies = mergePolicies(DefaultPolicies(), cfg.AdditionalPolicies)
}
if cfg.PolicySelector == nil {
cfg.PolicySelector = &config.PolicySelector{
Static: &config.StaticSelectorConf{
Policy: "default",
},
}
}
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
// if the CSP config file path is not set, we check if the default file exists and set it if it does
if cfg.CSPConfigFileLocation == "" {
defaultCSPConfigFilePath := filepath.Join(defaults.BaseDataPath(), "proxy", "csp.yaml")
if _, err := os.Stat(defaultCSPConfigFilePath); err == nil {
cfg.CSPConfigFileLocation = defaultCSPConfigFilePath
}
}
}
func mergePolicies(policies []config.Policy, additionalPolicies []config.Policy) []config.Policy {
for _, p := range additionalPolicies {
found := false
for i, po := range policies {
if po.Name == p.Name {
po.Routes = append(po.Routes, p.Routes...)
policies[i] = po
found = true
break
}
}
if !found {
policies = append(policies, p)
}
}
return policies
}
+11
View 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." introductionVersion:"1.0.0"`
Root string `yaml:"root" env:"PROXY_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
TLSCert string `yaml:"tls_cert" env:"PROXY_TRANSPORT_TLS_CERT" desc:"Path/File name of the TLS server certificate (in PEM format) for the external http services. If not defined, the root directory derives from $OC_BASE_DATA_PATH/proxy." introductionVersion:"1.0.0"`
TLSKey string `yaml:"tls_key" env:"PROXY_TRANSPORT_TLS_KEY" desc:"Path/File name for the TLS certificate key (in PEM format) for the server certificate to use for the external http services. If not defined, the root directory derives from $OC_BASE_DATA_PATH/proxy." introductionVersion:"1.0.0"`
TLS bool `yaml:"tls" env:"PROXY_TLS" desc:"Enable/Disable HTTPS for external HTTP services. Must be set to 'true' if the built-in IDP service and no reverse proxy is used. See the text description for details." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,69 @@
package parser
import (
"errors"
"fmt"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.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.MachineAuthAPIKey == "" {
return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
}
if cfg.OIDC.AccessTokenVerifyMethod != config.AccessTokenVerificationNone &&
cfg.OIDC.AccessTokenVerifyMethod != config.AccessTokenVerificationJWT {
return fmt.Errorf(
"Invalid value '%s' for 'access_token_verify_method' in service %s. Possible values are: '%s' or '%s'.",
cfg.OIDC.AccessTokenVerifyMethod, cfg.Service.Name,
config.AccessTokenVerificationJWT, config.AccessTokenVerificationNone,
)
}
if cfg.OIDC.AccessTokenVerifyMethod == "none" && cfg.OIDC.SkipUserInfo {
return fmt.Errorf(
"Incompatible value '%t' for 'skip_user_info' in service %s. Must be false when 'access_token_verify_method' is 'none'.",
cfg.OIDC.SkipUserInfo, cfg.Service.Name,
)
}
if cfg.ServiceAccount.ServiceAccountID == "" {
return shared.MissingServiceAccountID(cfg.Service.Name)
}
if cfg.ServiceAccount.ServiceAccountSecret == "" {
return shared.MissingServiceAccountSecret(cfg.Service.Name)
}
if cfg.Commons.URLSigningSecret == "" {
return shared.MissingURLSigningSecret(cfg.Service.Name)
}
return nil
}
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,61 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
)
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "proxy"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
Requests *prometheus.CounterVec
Errors *prometheus.CounterVec
Duration *prometheus.HistogramVec
BuildInfo *prometheus.GaugeVec
}
// New initializes the available metrics.
func New() *Metrics {
m := &Metrics{
Requests: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "requests_total",
Help: "How many requests processed in total",
}, []string{"method"}),
Errors: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "errors_total",
Help: "How many requests run into errors",
}, []string{"method"}),
Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "duration_seconds",
Help: "request duration in seconds",
}, []string{"method"}),
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build Information",
}, []string{"version"}),
}
// Initialize the metrics with 0
m.Requests.WithLabelValues("GET").Add(0)
m.Errors.WithLabelValues("GET").Add(0)
_ = prometheus.Register(m.Requests)
_ = prometheus.Register(m.Errors)
_ = prometheus.Register(m.Duration)
_ = prometheus.Register(m.BuildInfo)
return m
}
@@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/qsfera/server/pkg/log"
"go.opentelemetry.io/otel/trace"
)
// AccessLog is a middleware to log http requests at info level logging.
func AccessLog(logger log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := middleware.GetReqID(r.Context())
// add Request Id to all responses
w.Header().Set(middleware.RequestIDHeader, requestID)
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(wrap, r)
spanContext := trace.SpanContextFromContext(r.Context())
logger.Info().
Str("proto", r.Proto).
Str(log.RequestIDString, requestID).
Str("traceid", spanContext.TraceID().String()).
Str("remote-addr", r.RemoteAddr).
Str("method", r.Method).
Int("status", wrap.Status()).
Str("path", r.URL.Path).
Dur("duration", time.Since(start)).
Int("bytes", wrap.BytesWritten()).
Msg("access-log")
})
}
}
@@ -0,0 +1,335 @@
package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/jellydator/ttlcache/v3"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/userroles"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// 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
tracer := getTraceProvider(options).Tracer("proxy.middleware.account_resolver")
lastGroupSyncCache := ttlcache.New(
ttlcache.WithTTL[string, struct{}](5*time.Minute),
ttlcache.WithDisableTouchOnHit[string, struct{}](),
)
go lastGroupSyncCache.Start()
tenantIDCache := ttlcache.New(
ttlcache.WithTTL[string, string](10*time.Minute),
ttlcache.WithDisableTouchOnHit[string, string](),
)
go tenantIDCache.Start()
return func(next http.Handler) http.Handler {
return &accountResolver{
next: next,
logger: logger,
tracer: tracer,
userProvider: options.UserProvider,
userOIDCClaim: options.UserOIDCClaim,
userCS3Claim: options.UserCS3Claim,
tenantOIDCClaim: options.TenantOIDCClaim,
tenantIDMappingEnabled: options.TenantIDMappingEnabled,
gatewaySelector: options.RevaGatewaySelector,
serviceAccount: options.ServiceAccount,
userRoleAssigner: options.UserRoleAssigner,
autoProvisionAccounts: options.AutoprovisionAccounts,
multiTenantEnabled: options.MultiTenantEnabled,
lastGroupSyncCache: lastGroupSyncCache,
tenantIDCache: tenantIDCache,
eventsPublisher: options.EventsPublisher,
}
}
}
type accountResolver struct {
next http.Handler
logger log.Logger
tracer trace.Tracer
userProvider backend.UserBackend
userRoleAssigner userroles.UserRoleAssigner
autoProvisionAccounts bool
multiTenantEnabled bool
tenantIDMappingEnabled bool
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
serviceAccount config.ServiceAccount
userOIDCClaim string
userCS3Claim string
tenantOIDCClaim string
// lastGroupSyncCache is used to keep track of when the last sync of group
// memberships was done for a specific user. This is used to trigger a sync
// with every single request.
lastGroupSyncCache *ttlcache.Cache[string, struct{}]
// tenantIDCache maps external tenant IDs (from OIDC claims) to internal tenant IDs.
tenantIDCache *ttlcache.Cache[string, string]
eventsPublisher events.Publisher
}
func readStringClaim(path string, claims map[string]any) (string, error) {
// happy path
value, _ := claims[path].(string)
if value != "" {
return value, nil
}
// try splitting path at .
segments := oidc.SplitWithEscaping(path, ".", "\\")
subclaims := claims
lastSegment := len(segments) - 1
for i := range segments {
if i < lastSegment {
if castedClaims, ok := subclaims[segments[i]].(map[string]any); ok {
subclaims = castedClaims
} else if castedClaims, ok := subclaims[segments[i]].(map[any]any); ok {
subclaims = make(map[string]any, len(castedClaims))
for k, v := range castedClaims {
if s, ok := k.(string); ok {
subclaims[s] = v
} else {
return "", fmt.Errorf("could not walk claims path, key '%v' is not a string", k)
}
}
}
} else {
if value, _ = subclaims[segments[i]].(string); value != "" {
return value, nil
}
}
}
return value, fmt.Errorf("claim path '%s' not set or empty", path)
}
// 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, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
claims := oidc.FromContext(ctx)
user, ok := revactx.ContextGetUser(ctx)
token, hasToken := revactx.ContextGetToken(ctx)
req = req.WithContext(ctx)
defer span.End()
if claims == nil && !ok {
span.End()
m.next.ServeHTTP(w, req)
return
}
if user == nil && claims != nil {
value, err := readStringClaim(m.userOIDCClaim, claims)
if err != nil {
m.logger.Error().Err(err).Msg("could not read user id claim")
w.WriteHeader(http.StatusInternalServerError)
return
}
user, token, err = m.userProvider.GetUserByClaims(req.Context(), m.userCS3Claim, value)
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")
var newuser *cs3user.User
newuser, err = m.userProvider.CreateUserFromClaims(req.Context(), claims)
if err != nil {
m.logger.Error().Err(err).Msg("Autoprovisioning user failed")
w.WriteHeader(http.StatusInternalServerError)
return
}
user, token, err = m.userProvider.GetUserByClaims(req.Context(), "userid", newuser.Id.OpaqueId)
if err != nil {
m.logger.Error().Err(err).Str("userid", newuser.Id.OpaqueId).Msg("Error getting token for autoprovisioned user")
w.WriteHeader(http.StatusUnauthorized)
return
}
}
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
}
// if this is a multi-tenant setup, make sure the resolved user has a tenant id set
if m.multiTenantEnabled && user.GetId().GetTenantId() == "" {
m.logger.Error().Str("userid", user.Id.OpaqueId).Msg("User does not have a tenantId assigned")
w.WriteHeader(http.StatusUnauthorized)
return
}
// if a tenant claim is configured, verify it matches the tenant id on the resolved user
if m.tenantOIDCClaim != "" {
if err = m.verifyTenantClaim(req.Context(), user.GetId().GetTenantId(), claims); err != nil {
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Msg("Tenant claim mismatch")
w.WriteHeader(http.StatusUnauthorized)
return
}
}
// update user if needed
if m.autoProvisionAccounts {
if err = m.userProvider.UpdateUserIfNeeded(req.Context(), user, claims); err != nil {
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Interface("claims", claims).Msg("Failed to update autoprovisioned user")
w.WriteHeader(http.StatusInternalServerError)
return
}
// Only sync group memberships if the user has not been synced since the last cache invalidation
if !m.lastGroupSyncCache.Has(user.GetId().GetOpaqueId()) {
if err = m.userProvider.SyncGroupMemberships(req.Context(), user, claims); err != nil {
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Interface("claims", claims).Msg("Failed to sync group memberships for autoprovisioned user")
w.WriteHeader(http.StatusInternalServerError)
return
}
m.lastGroupSyncCache.Set(user.GetId().GetOpaqueId(), struct{}{}, ttlcache.DefaultTTL)
}
}
// resolve the user's roles
user, err = m.userRoleAssigner.UpdateUserRoleAssignment(ctx, user, claims)
if err != nil {
m.logger.Error().Err(err).Msg("Could not get user roles")
w.WriteHeader(http.StatusInternalServerError)
return
}
// If this is a new session, publish user login event
if newSession := oidc.NewSessionFlagFromContext(ctx); newSession && m.eventsPublisher != nil {
event := events.UserSignedIn{
Executant: user.Id,
Timestamp: utils.TimeToTS(time.Now()),
}
if err := events.Publish(req.Context(), m.eventsPublisher, event); err != nil {
m.logger.Error().Err(err).Msg("could not publish user signin event.")
}
}
// 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 && !hasToken {
// if this is a multi-tenant setup, make sure the resolved user has a tenant id set
if m.multiTenantEnabled && user.GetId().GetTenantId() == "" {
m.logger.Error().Str("userid", user.Id.OpaqueId).Msg("User does not have a tenantId assigned")
w.WriteHeader(http.StatusUnauthorized)
return
}
// If we already have a token (e.g. the app auth middleware adds the token to the context) there is no need
// to get yet another one here.
var err error
_, token, err = m.userProvider.GetUserByClaims(req.Context(), "username", user.Username)
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
}
}
span.SetAttributes(attribute.String("enduser.id", user.GetId().GetOpaqueId()))
ri := router.ContextRoutingInfo(ctx)
if ri.RemoteUserHeader() != "" {
req.Header.Set(ri.RemoteUserHeader(), user.GetId().GetOpaqueId())
}
if !ri.SkipXAccessToken() {
req.Header.Set(revactx.TokenHeader, token)
}
span.End()
m.next.ServeHTTP(w, req)
}
func (m accountResolver) verifyTenantClaim(ctx context.Context, userTenantID string, claims map[string]any) error {
claimTenantID, err := readStringClaim(m.tenantOIDCClaim, claims)
if err != nil {
return fmt.Errorf("could not read tenant claim: %w", err)
}
internalTenantID := claimTenantID
if m.tenantIDMappingEnabled {
internalTenantID, err = m.resolveInternalTenantID(ctx, claimTenantID)
if err != nil {
return fmt.Errorf("could not resolve internal tenant id for external tenant id %q: %w", claimTenantID, err)
}
}
if internalTenantID != userTenantID {
return fmt.Errorf("tenant id from claim %q does not match user tenant id %q", claimTenantID, userTenantID)
}
return nil
}
// resolveInternalTenantID maps an external tenant ID (as it appears in OIDC claims) to the
// internal tenant ID stored on the user object by calling the gateway's TenantAPI.
// Results are cached for 10 minutes to avoid repeated lookups on every request.
// The call is authenticated using the configured service account.
func (m accountResolver) resolveInternalTenantID(ctx context.Context, externalTenantID string) (string, error) {
if item := m.tenantIDCache.Get(externalTenantID); item != nil {
return item.Value(), nil
}
gwc, err := m.gatewaySelector.Next()
if err != nil {
return "", fmt.Errorf("could not get gateway client: %w", err)
}
authCtx, err := utils.GetServiceUserContextWithContext(ctx, gwc, m.serviceAccount.ServiceAccountID, m.serviceAccount.ServiceAccountSecret)
if err != nil {
return "", fmt.Errorf("could not authenticate service account: %w", err)
}
resp, err := gwc.GetTenantByClaim(authCtx, &tenantpb.GetTenantByClaimRequest{
Claim: "externalid",
Value: externalTenantID,
})
if err != nil {
return "", err
}
if resp.GetStatus().GetCode() != rpcpb.Code_CODE_OK {
return "", fmt.Errorf("TenantAPI returned status %s: %s", resp.GetStatus().GetCode(), resp.GetStatus().GetMessage())
}
internalID := resp.GetTenant().GetId()
m.tenantIDCache.Set(externalTenantID, internalID, ttlcache.DefaultTTL)
return internalID, nil
}
@@ -0,0 +1,468 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
userRoleMocks "github.com/qsfera/server/services/proxy/pkg/userroles/mocks"
"github.com/opencloud-eu/reva/v2/pkg/auth/scope"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
)
const (
testIdP = "https://idx.example.com"
testTenantA = "tenant-a"
testTenantB = "tenant-b"
testJWTSecret = "change-me"
testSvcAccountID = "svc-account-id"
testSvcAccountSecret = "svc-account-secret"
testSvcAccountToken = "svc-account-token"
)
func TestTokenIsAddedWithMailClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, oidc.Email, "mail", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
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: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, oidc.PreferredUsername, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithDotUsernamePathClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, "li.un", "username", false)
// This is how lico adds the username to the access token
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
"li": map[string]any{
"un": "foo",
},
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithDottedUsernameClaim(t *testing.T) {
tests := []struct {
name string
oidcClaim string
// comment describing what the claim exercises
desc string
}{
{
name: "escaped dot treated as literal key",
oidcClaim: "li\\.un",
desc: "li\\.un escapes the dot so the claim is looked up as the literal key \"li.un\"",
},
{
name: "dotted path falls back to literal key",
oidcClaim: "li.un",
desc: "li.un is first tried as a nested path; when \"un\" is absent under \"li\", it falls back to the literal key \"li.un\"",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, tc.oidcClaim, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
"li.un": "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", false)
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", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
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", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
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", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusInternalServerError, rw.Code)
}
func TestUnauthorizedOnMissingTenantId(t *testing.T) {
sut := newMockAccountResolver(
&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Username: "foo",
},
nil, oidc.PreferredUsername, "username", true)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
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 TestTokenIsAddedWhenUserHasTenantId(t *testing.T) {
sut := newMockAccountResolver(
&userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: "tenant1",
},
Username: "foo",
},
nil, oidc.PreferredUsername, "username", true)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTenantClaimValidation(t *testing.T) {
tests := []struct {
name string
requestTenant string
wantToken bool
wantStatusCode int
}{
{
name: "token added when tenant claim matches",
requestTenant: testTenantA,
wantToken: true,
wantStatusCode: http.StatusOK,
},
{
name: "unauthorized when tenant claim does not match",
requestTenant: testTenantB,
wantToken: false,
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
user := &userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: testTenantA,
},
Username: "foo",
}
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
s, _ := scope.AddOwnerScope(nil)
token, _ := tokenManager.MintToken(context.Background(), user, s)
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
ra := userRoleMocks.UserRoleAssigner{}
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
sut := AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
UserOIDCClaim(oidc.PreferredUsername),
UserCS3Claim("username"),
TenantOIDCClaim("tenant_id"),
MultiTenantEnabled(true),
)(mockHandler{})
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
"tenant_id": tc.requestTenant,
})
sut.ServeHTTP(rw, req)
if tc.wantToken {
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
} else {
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
}
assert.Equal(t, tc.wantStatusCode, rw.Code)
})
}
}
func newMockAccountResolver(userBackendResult *userv1beta1.User, userBackendErr error, oidcclaim, cs3claim string, multiTenant bool) http.Handler {
tokenManager, _ := jwt.New(map[string]any{
"secret": testJWTSecret,
"expires": int64(60),
})
token := ""
if userBackendResult != nil {
s, _ := scope.AddOwnerScope(nil)
token, _ = tokenManager.MintToken(context.Background(), userBackendResult, s)
}
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, token, userBackendErr)
ub.On("GetUserRoles", mock.Anything, mock.Anything).Return(userBackendResult, nil)
ra := userRoleMocks.UserRoleAssigner{}
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, nil)
return AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
SkipUserInfo(false),
UserOIDCClaim(oidcclaim),
UserCS3Claim(cs3claim),
AutoprovisionAccounts(false),
MultiTenantEnabled(multiTenant),
)(mockHandler{})
}
func mockRequest(claims map[string]any) (*http.Request, *httptest.ResponseRecorder) {
if claims == nil {
return httptest.NewRequest("GET", "http://example.com/foo", nil), httptest.NewRecorder()
}
ctx := oidc.NewContext(context.Background(), claims)
ctx = router.SetRoutingInfo(ctx, router.RoutingInfo{})
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) {}
func TestTenantIDMapping(t *testing.T) {
const (
externalTenantID = "external-tenant-x"
internalTenantID = testTenantA
)
user := &userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: internalTenantID,
},
Username: "foo",
}
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
s, _ := scope.AddOwnerScope(nil)
token, _ := tokenManager.MintToken(context.Background(), user, s)
newSUT := func(t *testing.T, gatewayClient gateway.GatewayAPIClient) http.Handler {
t.Helper()
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
t.Cleanup(func() { pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway") })
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
ra := userRoleMocks.UserRoleAssigner{}
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
return AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
UserOIDCClaim(oidc.PreferredUsername),
UserCS3Claim("username"),
TenantOIDCClaim("tenant_id"),
MultiTenantEnabled(true),
TenantIDMappingEnabled(true),
ServiceAccount(config.ServiceAccount{
ServiceAccountID: testSvcAccountID,
ServiceAccountSecret: testSvcAccountSecret,
}),
WithRevaGatewaySelector(gatewaySelector),
)(mockHandler{})
}
tests := []struct {
name string
tenantResponse *tenantpb.GetTenantByClaimResponse
wantToken bool
wantStatusCode int
}{
{
name: "token added when external tenant maps to user internal tenant",
tenantResponse: &tenantpb.GetTenantByClaimResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
Tenant: &tenantpb.Tenant{Id: internalTenantID, ExternalId: externalTenantID},
},
wantToken: true,
wantStatusCode: http.StatusOK,
},
{
name: "unauthorized when external tenant maps to a different internal tenant",
tenantResponse: &tenantpb.GetTenantByClaimResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
Tenant: &tenantpb.Tenant{Id: testTenantB, ExternalId: externalTenantID},
},
wantToken: false,
wantStatusCode: http.StatusUnauthorized,
},
{
name: "unauthorized when external tenant is not found",
tenantResponse: &tenantpb.GetTenantByClaimResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_NOT_FOUND, Message: "not found"},
},
wantToken: false,
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gwc := &cs3mocks.GatewayAPIClient{}
gwc.On("Authenticate", mock.Anything, &gateway.AuthenticateRequest{
Type: "serviceaccounts",
ClientId: testSvcAccountID,
ClientSecret: testSvcAccountSecret,
}).Return(&gateway.AuthenticateResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
Token: testSvcAccountToken,
}, nil)
gwc.On("GetTenantByClaim", mock.Anything, &tenantpb.GetTenantByClaimRequest{
Claim: "externalid",
Value: externalTenantID,
}).Return(tc.tenantResponse, nil)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
"tenant_id": externalTenantID,
})
newSUT(t, gwc).ServeHTTP(rw, req)
if tc.wantToken {
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
} else {
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
}
assert.Equal(t, tc.wantStatusCode, rw.Code)
gwc.AssertExpectations(t)
})
}
}
@@ -0,0 +1,64 @@
package middleware
import (
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/userroles"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// AppAuthAuthenticator defines the app auth authenticator
type AppAuthAuthenticator struct {
Logger log.Logger
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
UserRoleAssigner userroles.UserRoleAssigner
}
// Authenticate implements the authenticator interface to authenticate requests via app auth.
func (m AppAuthAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if isPublicPath(r.URL.Path) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}
username, password, ok := r.BasicAuth()
if !ok {
return nil, false
}
next, err := m.RevaGatewaySelector.Next()
if err != nil {
return nil, false
}
authenticateResponse, err := next.Authenticate(r.Context(), &gateway.AuthenticateRequest{
Type: "appauth",
ClientId: username,
ClientSecret: password,
})
if err != nil {
return nil, false
}
if authenticateResponse.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
m.Logger.Debug().Str("msg", authenticateResponse.GetStatus().GetMessage()).Str("clientid", username).Msg("app auth failed")
return nil, false
}
user := authenticateResponse.GetUser()
if user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user); err != nil {
m.Logger.Error().Err(err).Str("clientid", username).Msg("app auth: failed to load user roles")
return nil, false
}
ctx := revactx.ContextSetUser(r.Context(), user)
ctx = revactx.ContextSetToken(ctx, authenticateResponse.GetToken())
r = r.WithContext(ctx)
return r, true
}
@@ -0,0 +1,80 @@
package middleware
import (
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
userRoleMocks "github.com/qsfera/server/services/proxy/pkg/userroles/mocks"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
)
var _ = Describe("Authenticating requests", Label("AppAuthAuthenticator"), func() {
var authenticator Authenticator
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
ra := &userRoleMocks.UserRoleAssigner{}
ra.On("ApplyUserRole", mock.Anything, mock.Anything, mock.Anything).Return(&userv1beta1.User{}, nil)
authenticator = AppAuthAuthenticator{
Logger: log.NewLogger(),
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return mockGatewayClient{
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
if authType != "appauth" {
return "", rpcv1beta1.Code_CODE_NOT_FOUND
}
if clientID == "test-user" && clientSecret == "AppPassword" {
return "reva-token", rpcv1beta1.Code_CODE_OK
}
return "", rpcv1beta1.Code_CODE_NOT_FOUND
},
}
},
),
UserRoleAssigner: ra,
}
})
When("the request contains correct data", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("test-user", "AppPassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
user, ok := revactx.ContextGetUser(req2.Context())
Expect(ok).To(BeTrue())
Expect(user).ToNot(BeNil())
token, ok := revactx.ContextGetToken(req2.Context())
Expect(ok).To(BeTrue())
Expect(token).To(Equal("reva-token"))
})
})
When("the request contains incorrect data", func() {
It("should not successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("test-user", "WrongAppPassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(false))
Expect(req2).To(BeNil())
})
})
})
@@ -0,0 +1,216 @@
package middleware
import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/webdav"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
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 КуСфера. It is a list of
// regexp.Regexp which are safe to use concurrently.
ProxyWwwAuthenticate = []regexp.Regexp{*regexp.MustCompile("/ocs/v[12].php/cloud/")}
_publicPaths = [...]string{
"/dav/public-files/",
"/remote.php/dav/ocm/",
"/dav/ocm/",
"/ocm/",
"/remote.php/dav/public-files/",
"/ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/unprotected",
"/ocs/v2.php/apps/files_sharing/api/v1/tokeninfo/unprotected",
"/ocs/v1.php/cloud/capabilities",
}
)
const (
// WwwAuthenticate captures the Www-Authenticate header string.
WwwAuthenticate = "Www-Authenticate"
)
// Authenticator is the common interface implemented by all request authenticators.
type Authenticator interface {
// Authenticate is used to authenticate incoming HTTP requests.
// The Authenticator may augment the request with user info or anything related to the
// authentication and return the augmented request.
Authenticate(*http.Request) (*http.Request, bool)
}
// Authentication is a higher order authentication middleware.
func Authentication(auths []Authenticator, opts ...Option) func(next http.Handler) http.Handler {
options := newOptions(opts...)
configureSupportedChallenges(options)
tracer := getTraceProvider(options).Tracer("proxy.middleware.authentication")
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), fmt.Sprintf("%s %s", r.Method, r.URL.Path), spanOpts...)
r = r.WithContext(ctx)
defer span.End()
ri := router.ContextRoutingInfo(ctx)
if isOIDCTokenAuth(r) || ri.IsRouteUnprotected() || r.Method == "OPTIONS" {
// Either this is a request that does not need any authentication or
// the authentication for this request is handled by the IdP.
span.SetAttributes(attribute.Bool("routeunprotected", true))
span.End()
next.ServeHTTP(w, r)
return
}
for _, a := range auths {
if req, ok := a.Authenticate(r); ok {
span.End()
next.ServeHTTP(w, req)
return
}
}
if !isPublicPath(r.URL.Path) {
// Failed basic authentication attempts receive the Www-Authenticate header in the response
var touch bool
caser := cases.Title(language.Und)
for k, v := range options.CredentialsByUserAgent {
if strings.Contains(k, r.UserAgent()) {
removeSuperfluousAuthenticate(w)
w.Header().Add("Www-Authenticate", fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(v), r.Host))
touch = true
break
}
}
// if the request is not bound to any user agent, write all available challenges
if !touch {
writeSupportedAuthenticateHeader(w, r)
}
}
for _, s := range SupportedAuthStrategies {
userAgentAuthenticateLockIn(w, r, options.CredentialsByUserAgent, s)
}
w.WriteHeader(http.StatusUnauthorized)
// 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.
if webdav.IsWebdavRequest(r) {
b, err := webdav.Marshal(webdav.Exception{
Code: webdav.SabredavPermissionDenied,
Message: "Authentication error",
})
webdav.HandleWebdavError(w, b, err)
}
if r.ProtoMajor == 1 {
// https://github.com/owncloud/ocis/issues/5066
// https://github.com/golang/go/blob/d5de62df152baf4de6e9fe81933319b86fd95ae4/src/net/http/server.go#L1357-L1417
// https://github.com/golang/go/issues/15527
defer r.Body.Close()
_, _ = io.Copy(io.Discard, r.Body)
}
})
}
}
// 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 isOIDCTokenAuth(req *http.Request) bool {
return req.URL.Path == "/konnect/v1/token"
}
func isPublicPath(p string) bool {
for _, pp := range _publicPaths {
if strings.HasPrefix(p, pp) {
return true
}
}
return false
}
// 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) {
caser := cases.Title(language.Und)
for _, s := range SupportedAuthStrategies {
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
w.Header().Add(WwwAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(s), r.Host))
}
}
}
func removeSuperfluousAuthenticate(w http.ResponseWriter) {
w.Header().Del(WwwAuthenticate)
}
// 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
}
// 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 _, r := range ProxyWwwAuthenticate {
evalRequestURI(u, r)
}
}
func evalRequestURI(l userAgentLocker, r regexp.Regexp) {
if !r.MatchString(l.r.RequestURI) {
return
}
caser := cases.Title(language.Und)
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\"", caser.String(v), l.r.Host))
return
}
}
}
func getTraceProvider(o Options) trace.TracerProvider {
if o.TraceProvider != nil {
return o.TraceProvider
}
return trace.NewNoopTracerProvider()
}
@@ -0,0 +1,203 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/golang-jwt/jwt/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
oidcmocks "github.com/qsfera/server/pkg/oidc/mocks"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/store"
"google.golang.org/grpc"
)
var _ = Describe("authentication helpers", func() {
DescribeTable("isPublicPath should recognize public paths",
func(input string, expected bool) {
isPublic := isPublicPath(input)
Expect(isPublic).To(Equal(expected))
},
Entry("public files path", "/remote.php/dav/public-files/", true),
Entry("public files path without remote.php", "/remote.php/dav/public-files/", true),
Entry("token info path", "/ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/unprotected", true),
Entry("token info path", "/ocs/v2.php/apps/files_sharing/api/v1/tokeninfo/unprotected", true),
Entry("capabilities", "/ocs/v1.php/cloud/capabilities", true),
)
})
var _ = Describe("Authenticating requests", Label("Authentication"), func() {
var (
authenticators []Authenticator
)
oc := oidcmocks.OIDCClient{}
oc.On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
oidc.RegClaimsWithSID{
SessionID: "a-session-id",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(1147483647, 0)),
},
}, jwt.MapClaims{
"sid": "a-session-id",
"exp": 1147483647,
},
nil,
)
ub := mocks.UserBackend{}
ub.On("Authenticate", mock.Anything, "testuser", "testpassword").Return(
&userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: "IdpId",
OpaqueId: "OpaqueId",
},
Username: "testuser",
Mail: "testuser@example.com",
},
"",
nil,
)
ub.On("Authenticate", mock.Anything, mock.Anything, mock.Anything).Return(nil, "", backend.ErrAccountNotFound)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
logger := log.NewLogger()
authenticators = []Authenticator{
BasicAuthenticator{
Logger: logger,
UserProvider: &ub,
},
&OIDCAuthenticator{
OIDCIss: "http://idp.example.com",
Logger: logger,
oidcClient: &oc,
userInfoCache: store.NewMemoryStore(),
skipUserInfo: true,
},
PublicShareAuthenticator{
Logger: logger,
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return mockGatewayClient{
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
if authType != "publicshares" {
return "", rpcv1beta1.Code_CODE_NOT_FOUND
}
if clientID == "sharetoken" && (clientSecret == "password|examples3cr3t" || clientSecret == "signature|examplesignature|exampleexpiration") {
return "exampletoken", rpcv1beta1.Code_CODE_OK
}
if clientID == "sharetoken" && clientSecret == "password|" {
return "otherexampletoken", rpcv1beta1.Code_CODE_OK
}
return "", rpcv1beta1.Code_CODE_NOT_FOUND
},
}
},
),
},
}
})
When("the public request must contains correct data", func() {
It("ensures the context oidc data when the Bearer authentication is successful", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/remote.php/dav/public-files/", http.NoBody)
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(oidc.FromContext(r.Context())).To(Equal(map[string]any{
"sid": "a-session-id",
"exp": int64(1147483647),
}))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the context oidc data when user the Basic authentication is successful", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/remote.php/dav/public-files/", http.NoBody)
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
req.SetBasicAuth("testuser", "testpassword")
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(oidc.FromContext(r.Context())).To(Equal(map[string]any{
"email": "testuser@example.com",
"qsferauuid": "OpaqueId",
"iss": "IdpId",
"preferred_username": "testuser",
}))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the x-access-token header when public-token URL parameter is set", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the x-access-token header when public-token URL parameter and BasicAuth are set", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req.SetBasicAuth("public", "examples3cr3t")
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the x-access-token header when public-token BasicAuth is set", func() {
req := httptest.NewRequest("GET", "http://example.com/archiver", http.NoBody)
req.Header.Set("public-token", "sharetoken")
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
})
})
@@ -0,0 +1,62 @@
package middleware
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
)
// BasicAuthenticator is the authenticator responsible for HTTP Basic authentication.
type BasicAuthenticator struct {
Logger log.Logger
UserProvider backend.UserBackend
UserCS3Claim string
UserOIDCClaim string
}
// Authenticate implements the authenticator interface to authenticate requests via basic auth.
func (m BasicAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if isPublicPath(r.URL.Path) && isPublicWithShareToken(r) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}
login, password, ok := r.BasicAuth()
if !ok {
return nil, false
}
user, _, err := m.UserProvider.Authenticate(r.Context(), login, password)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "basic").
Str("path", r.URL.Path).
Msg("failed to authenticate request")
return nil, false
}
// fake oidc claims
claims := map[string]any{
oidc.Iss: user.Id.Idp,
oidc.PreferredUsername: user.Username,
oidc.Email: user.Mail,
oidc.QsferaUUID: user.Id.OpaqueId,
}
if m.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[m.UserOIDCClaim] = user.Id.OpaqueId
}
m.Logger.Debug().
Str("authenticator", "basic").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r.WithContext(oidc.NewContext(r.Context(), claims)), true
}
@@ -0,0 +1,67 @@
package middleware
import (
"net/http"
"net/http/httptest"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
. "github.com/onsi/ginkgo/v2"
"github.com/stretchr/testify/mock"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
)
var _ = Describe("Authenticating requests", Label("BasicAuthenticator"), func() {
var authenticator Authenticator
ub := mocks.UserBackend{}
ub.On("Authenticate", mock.Anything, "testuser", "testpassword").Return(
&userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: "IdpId",
OpaqueId: "OpaqueId",
},
Username: "testuser",
Mail: "testuser@example.com",
},
"",
nil,
)
ub.On("Authenticate", mock.Anything, mock.Anything, mock.Anything).Return(nil, "", backend.ErrAccountNotFound)
BeforeEach(func() {
authenticator = BasicAuthenticator{
Logger: log.NewLogger(),
UserProvider: &ub,
}
})
When("the request contains correct data", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("testuser", "testpassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("adds claims to the request context", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("testuser", "testpassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
claims := oidc.FromContext(req2.Context())
Expect(claims).ToNot(BeNil())
Expect(claims[oidc.Iss]).To(Equal("IdpId"))
Expect(claims[oidc.PreferredUsername]).To(Equal("testuser"))
Expect(claims[oidc.Email]).To(Equal("testuser@example.com"))
Expect(claims[oidc.QsferaUUID]).To(Equal("OpaqueId"))
})
})
})
@@ -0,0 +1,26 @@
package middleware
import (
"net/http"
"github.com/qsfera/server/pkg/log"
)
// ContextLogger is a middleware to use a logger associated with the request's
// context which includes general information of the request.
func ContextLogger(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) {
ctx := logger.With().
Str("remoteAddr", r.RemoteAddr).
Str(log.RequestIDString, r.Header.Get("X-Request-ID")).
Str("proto", r.Proto).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("query", r.URL.RawQuery).
Str("fragment", r.URL.Fragment).
Logger().WithContext(r.Context())
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
@@ -0,0 +1,124 @@
package middleware
import (
"fmt"
"net/http"
"strconv"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"go.opentelemetry.io/otel/trace"
"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
tracer := getTraceProvider(options).Tracer("proxy.middleware.create_home")
return func(next http.Handler) http.Handler {
return &createHome{
next: next,
logger: logger,
tracer: tracer,
revaGatewaySelector: options.RevaGatewaySelector,
roleQuotas: options.RoleQuotas,
}
}
}
type createHome struct {
next http.Handler
logger log.Logger
tracer trace.Tracer
revaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
roleQuotas map[string]uint64
}
func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
req = req.WithContext(ctx)
defer span.End()
if !m.shouldServe(req) {
span.End()
m.next.ServeHTTP(w, req)
return
}
token := req.Header.Get(revactx.TokenHeader)
// 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{}
u, ok := revactx.ContextGetUser(ctx)
if ok {
roleIDs, err := m.getUserRoles(u)
if err != nil {
m.logger.Error().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to get roles for user")
errorcode.GeneralException.Render(w, req, http.StatusInternalServerError, "Unauthorized")
return
}
if limit, hasLimit := m.checkRoleQuotaLimit(roleIDs); hasLimit {
createHomeReq.Opaque = utils.AppendPlainToOpaque(nil, "quota", strconv.FormatUint(limit, 10))
}
}
client, err := m.revaGatewaySelector.Next()
if err != nil {
m.logger.Err(err).Msg("error selecting next gateway client")
} else {
createHomeRes, err := client.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")
}
}
}
span.End()
m.next.ServeHTTP(w, req)
}
func (m createHome) shouldServe(req *http.Request) bool {
return req.Header.Get(revactx.TokenHeader) != ""
}
func (m createHome) getUserRoles(user *userv1beta1.User) ([]string, error) {
var roleIDs []string
if err := utils.ReadJSONFromOpaque(user.Opaque, "roles", &roleIDs); err != nil {
return nil, err
}
tmp := make(map[string]struct{})
for _, id := range roleIDs {
tmp[id] = struct{}{}
}
dedup := make([]string, 0, len(tmp))
for k := range tmp {
dedup = append(dedup, k)
}
return dedup, nil
}
func (m createHome) checkRoleQuotaLimit(roleIDs []string) (uint64, bool) {
if len(roleIDs) == 0 {
return 0, false
}
id := roleIDs[0] // At the moment a user can only have one role.
quota, ok := m.roleQuotas[id]
return quota, ok
}
@@ -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)
})
}
@@ -0,0 +1,28 @@
package middleware
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/qsfera/server/services/proxy/pkg/metrics"
"github.com/prometheus/client_golang/prometheus"
)
// Instrumenter provides a middleware to create metrics
func Instrumenter(m metrics.Metrics) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
m.Requests.With(prometheus.Labels{"method": r.Method}).Inc()
next.ServeHTTP(ww, r)
m.Duration.With(prometheus.Labels{"method": r.Method}).Observe(float64(time.Since(start).Seconds()))
if ww.Status() >= 500 {
m.Errors.With(prometheus.Labels{"method": r.Method}).Inc()
}
})
}
}
@@ -0,0 +1,13 @@
package middleware_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMiddleware(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Middleware Suite")
}
@@ -0,0 +1,223 @@
package middleware
import (
"context"
"encoding/base64"
"net"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
"github.com/vmihailenco/msgpack/v5"
"go-micro.dev/v4/store"
"golang.org/x/crypto/sha3"
"golang.org/x/oauth2"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/staticroutes"
)
const (
_headerAuthorization = "Authorization"
_bearerPrefix = "Bearer "
)
// NewOIDCAuthenticator returns a ready to use authenticator which can handle OIDC authentication.
func NewOIDCAuthenticator(opts ...Option) *OIDCAuthenticator {
options := newOptions(opts...)
return &OIDCAuthenticator{
Logger: options.Logger,
userInfoCache: options.UserInfoCache,
DefaultTokenCacheTTL: options.DefaultAccessTokenTTL,
HTTPClient: options.HTTPClient,
OIDCIss: options.OIDCIss,
oidcClient: options.OIDCClient,
AccessTokenVerifyMethod: options.AccessTokenVerifyMethod,
skipUserInfo: options.SkipUserInfo,
TimeFunc: time.Now,
}
}
// OIDCAuthenticator is an authenticator responsible for OIDC authentication.
type OIDCAuthenticator struct {
Logger log.Logger
HTTPClient *http.Client
OIDCIss string
userInfoCache store.Store
DefaultTokenCacheTTL time.Duration
oidcClient oidc.OIDCClient
AccessTokenVerifyMethod string
skipUserInfo bool
TimeFunc func() time.Time
}
func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[string]any, bool, error) {
var claims map[string]any
// use a 64 bytes long hash to have 256-bit collision resistance.
hash := make([]byte, 64)
sha3.ShakeSum256(hash, []byte(token))
encodedHash := base64.URLEncoding.EncodeToString(hash)
record, err := m.userInfoCache.Read(encodedHash)
if err != nil && err != store.ErrNotFound {
m.Logger.Error().Err(err).Msg("could not read from userinfo cache")
}
if len(record) > 0 {
if err = msgpack.Unmarshal(record[0].Value, &claims); err == nil {
m.Logger.Debug().Interface("claims", claims).Msg("cache hit for userinfo")
if verifyExpiresAt(claims, m.TimeFunc()) {
return claims, false, nil
}
m.Logger.Debug().Msg("cached userinfo claims expired, ignoring cache")
} else {
m.Logger.Error().Err(err).Msg("failed to unmarshal cached userinfo, ignoring cache")
}
}
aClaims, claims, err := m.oidcClient.VerifyAccessToken(req.Context(), token)
if err != nil {
return nil, false, errors.Wrap(err, "failed to verify access token")
}
if !m.skipUserInfo {
oauth2Token := &oauth2.Token{
AccessToken: token,
}
userInfo, err := m.oidcClient.UserInfo(
context.WithValue(req.Context(), oauth2.HTTPClient, m.HTTPClient),
oauth2.StaticTokenSource(oauth2Token),
)
if err != nil {
return nil, false, errors.Wrap(err, "failed to get userinfo")
}
if err := userInfo.Claims(&claims); err != nil {
return nil, false, errors.Wrap(err, "failed to unmarshal userinfo claims")
}
}
expiration := m.extractExpiration(aClaims)
// always set an exp claim
claims["exp"] = expiration.Unix()
go func() {
if d, err := msgpack.Marshal(claims); err != nil {
m.Logger.Error().Err(err).Msg("failed to marshal claims for userinfo cache")
} else {
err = m.userInfoCache.Write(&store.Record{
Key: encodedHash,
Value: d,
Expiry: time.Until(expiration),
})
if err != nil {
m.Logger.Error().Err(err).Msg("failed to write to userinfo cache")
}
// fail if creating the storage key fails,
// it means there is no subject and no session.
//
// ok: {key: ".sessionId"}
// ok: {key: "subject."}
// ok: {key: "subject.sessionId"}
// fail: {key: "."}
subjectSessionKey, err := staticroutes.NewRecordKey(aClaims.Subject, aClaims.SessionID)
if err != nil {
m.Logger.Error().Err(err).Msg("failed to build subject.session")
return
}
if err := m.userInfoCache.Write(&store.Record{
Key: subjectSessionKey,
Value: []byte(encodedHash),
Expiry: time.Until(expiration),
}); err != nil {
m.Logger.Error().Err(err).Msg("failed to write session lookup cache")
}
}
}()
// If we get here this was a new login (or a renewal of the token)
// add a flag about that to the claims, to be able to distinguish
// it in the accountresolver middleware
m.Logger.Debug().Interface("claims", claims).Msg("extracted claims")
return claims, true, nil
}
// extractExpiration tries to extract the expriration time from the access token
// If the access token does not have an exp claim it will fallback to the configured
// default expiration
func (m OIDCAuthenticator) extractExpiration(aClaims oidc.RegClaimsWithSID) time.Time {
defaultExpiration := time.Now().Add(m.DefaultTokenCacheTTL)
if aClaims.ExpiresAt != nil {
m.Logger.Debug().Str("exp", aClaims.ExpiresAt.String()).Msg("Expiration Time from access_token")
return aClaims.ExpiresAt.Time
}
return defaultExpiration
}
func verifyExpiresAt(claims map[string]any, cmp time.Time) bool {
var expiry time.Time
switch v := claims["exp"].(type) {
case nil:
return false
case int64:
expiry = time.Unix(v, 0)
case uint32:
expiry = time.Unix(int64(v), 0)
}
return cmp.Before(expiry)
}
func (m OIDCAuthenticator) shouldServe(req *http.Request) bool {
if m.OIDCIss == "" {
return false
}
header := req.Header.Get(_headerAuthorization)
return strings.HasPrefix(header, _bearerPrefix)
}
// Authenticate implements the authenticator interface to authenticate requests via oidc auth.
func (m *OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
// there is no bearer token on the request,
if !m.shouldServe(r) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}
token := strings.TrimPrefix(r.Header.Get(_headerAuthorization), _bearerPrefix)
if token == "" {
return nil, false
}
claims, newSession, err := m.getClaims(token, r)
if err != nil {
host, port, _ := net.SplitHostPort(r.RemoteAddr)
m.Logger.Error().
Err(err).
Str("authenticator", "oidc").
Str("path", r.URL.Path).
Str("user_agent", r.UserAgent()).
Str("client.address", r.Header.Get("X-Forwarded-For")).
Str("network.peer.address", host).
Str("network.peer.port", port).
Msg("failed to authenticate the request")
return nil, false
}
m.Logger.Debug().
Str("authenticator", "oidc").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
ctx := r.Context()
if newSession {
ctx = oidc.NewContextSessionFlag(ctx, true)
}
return r.WithContext(oidc.NewContext(ctx, claims)), true
}
@@ -0,0 +1,101 @@
package middleware
import (
"net/http"
"net/http/httptest"
"time"
"github.com/golang-jwt/jwt/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
oidcmocks "github.com/qsfera/server/pkg/oidc/mocks"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/store"
)
var _ = Describe("Authenticating requests", Label("OIDCAuthenticator"), func() {
var authenticator Authenticator
oc := oidcmocks.OIDCClient{}
oc.On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
oidc.RegClaimsWithSID{
SessionID: "a-session-id",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(1147483647, 0)),
},
}, jwt.MapClaims{
"sid": "a-session-id",
"exp": 1147483647,
},
nil,
)
/*
// to test with skipUserInfo: true, we need to also use an interface so we can mock the UserInfo.Claim call
oc.On("UserInfo", mock.Anything, mock.Anything).Return(
&oidc.UserInfo{
Subject: "my-sub",
EmailVerified: true,
Email: "test@example.org",
},
nil,
)
*/
BeforeEach(func() {
authenticator = &OIDCAuthenticator{
OIDCIss: "http://idp.example.com",
Logger: log.NewLogger(),
oidcClient: &oc,
userInfoCache: store.NewMemoryStore(),
skipUserInfo: true,
}
})
When("the request contains correct data", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("should skip authenticate if the header ShareToken is set", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req.Header.Set(headerShareToken, "sharetoken")
req2, valid := authenticator.Authenticate(req)
// TODO Should the authentication of public path requests is handled by another authenticator?
//Expect(valid).To(Equal(false))
//Expect(req2).To(BeNil())
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("should skip authenticate if the 'public-token' is set", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req2, valid := authenticator.Authenticate(req)
// TODO Should the authentication of public path requests is handled by another authenticator?
//Expect(valid).To(Equal(false))
//Expect(req2).To(BeNil())
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
})
})
@@ -0,0 +1,287 @@
package middleware
import (
"net/http"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
policiessvc "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/userroles"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace"
)
// 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
// PolicySelectorConfig for using the policy selector
PolicySelector config.PolicySelector
// HTTPClient to use for communication with the oidcAuth provider
HTTPClient *http.Client
// UserProvider backend to use for resolving User
UserProvider backend.UserBackend
// UserRoleAssigner to user for assign a users default role
UserRoleAssigner userroles.UserRoleAssigner
// SettingsRoleService for the roles API in settings
SettingsRoleService settingssvc.RoleService
// PoliciesProviderService for policy evaluation
PoliciesProviderService policiessvc.PoliciesProviderService
// OIDCClient to fetch user info and verify tokens, must be set for the oidc_auth middleware
OIDCClient oidc.OIDCClient
// OIDCIss is the oidcAuth-issuer
OIDCIss string
// RevaGatewaySelector to send requests to the reva gateway
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
// 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
// TenantOIDCClaim is a JMESPath expression to extract the tenant ID from the OIDC claims.
// When set, the extracted value is verified against the tenant ID on the resolved user.
TenantOIDCClaim string
// AutoprovisionAccounts when an accountResolver does not exist.
AutoprovisionAccounts bool
// EnableBasicAuth to allow basic auth
EnableBasicAuth bool
// DefaultAccessTokenTTL is used to calculate the expiration when an access token has no expiration set
DefaultAccessTokenTTL time.Duration
// UserInfoCache sets the access token cache store
UserInfoCache store.Store
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
CredentialsByUserAgent map[string]string
// AccessTokenVerifyMethod configures how access_tokens should be verified but the oidc_auth middleware.
// Possible values currently: "jwt" and "none"
AccessTokenVerifyMethod string
// JWKS sets the options for fetching the JWKS from the IDP
JWKS config.JWKS
// RoleQuotas hold userid:quota mappings. These will be used when provisioning new users.
// The users will get as much quota as is set for their role.
RoleQuotas map[string]uint64
// TraceProvider sets the tracing provider.
TraceProvider trace.TracerProvider
// SkipUserInfo prevents the oidc middleware from querying the userinfo endpoint and read any claims directly from the access token instead
SkipUserInfo bool
// MultiTenantEnabled causes the account resolve middleware to reject users that don't have a tenant id assigned
MultiTenantEnabled bool
// TenantIDMappingEnabled causes the account resolver to resolve the internal tenant ID from the external
// tenant ID in the OIDC claims via the gateway's TenantAPI before comparing it to the user's stored tenant ID.
TenantIDMappingEnabled bool
// ServiceAccount holds credentials used to authenticate internal service calls (e.g. TenantAPI lookups).
ServiceAccount config.ServiceAccount
EventsPublisher events.Publisher
}
// 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
}
}
// 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
}
}
// PoliciesProviderService provides a function to set the policies provider option.
func PoliciesProviderService(pps policiessvc.PoliciesProviderService) Option {
return func(o *Options) {
o.PoliciesProviderService = pps
}
}
// OIDCClient provides a function to set the oidc client option.
func OIDCClient(val oidc.OIDCClient) Option {
return func(o *Options) {
o.OIDCClient = val
}
}
// 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
}
}
// WithRevaGatewaySelector provides a function to set the reva gateway service selector option.
func WithRevaGatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.RevaGatewaySelector = val
}
}
// 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
}
}
// TenantOIDCClaim provides a function to set the TenantOIDCClaim config
func TenantOIDCClaim(val string) Option {
return func(o *Options) {
o.TenantOIDCClaim = 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
}
}
// DefaultAccessTokenTTL provides a function to set the DefaultAccessTokenTTL
func DefaultAccessTokenTTL(ttl time.Duration) Option {
return func(o *Options) {
o.DefaultAccessTokenTTL = ttl
}
}
// UserInfoCache provides a function to set the UserInfoCache
func UserInfoCache(val store.Store) Option {
return func(o *Options) {
o.UserInfoCache = val
}
}
// UserProvider sets the accounts user provider
func UserProvider(up backend.UserBackend) Option {
return func(o *Options) {
o.UserProvider = up
}
}
// UserRoleAssigner sets the mechanism for assigning the default user roles
func UserRoleAssigner(ra userroles.UserRoleAssigner) Option {
return func(o *Options) {
o.UserRoleAssigner = ra
}
}
// AccessTokenVerifyMethod set the mechanism for access token verification
func AccessTokenVerifyMethod(method string) Option {
return func(o *Options) {
o.AccessTokenVerifyMethod = method
}
}
// RoleQuotas sets the role quota mapping setting
func RoleQuotas(roleQuotas map[string]uint64) Option {
return func(o *Options) {
o.RoleQuotas = roleQuotas
}
}
// TraceProvider sets the tracing provider.
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = tp
}
}
// SkipUserInfo sets the skipUserInfo flag.
func SkipUserInfo(val bool) Option {
return func(o *Options) {
o.SkipUserInfo = val
}
}
// MultiTenantEnabled sets the MultiTenantEnabled flag.
func MultiTenantEnabled(val bool) Option {
return func(o *Options) {
o.MultiTenantEnabled = val
}
}
// ServiceAccount sets the service account credentials used for authenticated internal calls.
func ServiceAccount(sa config.ServiceAccount) Option {
return func(o *Options) {
o.ServiceAccount = sa
}
}
// TenantIDMappingEnabled sets the TenantIDMappingEnabled flag.
// When true, the account resolver resolves the internal tenant ID from the external tenant ID
// provided in the OIDC claims by calling the gateway's TenantAPI, instead of comparing directly.
func TenantIDMappingEnabled(val bool) Option {
return func(o *Options) {
o.TenantIDMappingEnabled = val
}
}
// EventsPublisher sets the events publisher.
func EventsPublisher(ep events.Publisher) Option {
return func(o *Options) {
o.EventsPublisher = ep
}
}
@@ -0,0 +1,175 @@
package middleware
import (
"fmt"
"net/http"
"path"
"path/filepath"
"strings"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
tusd "github.com/tus/tusd/v2/pkg/handler"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/metadata"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
pMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/policies/v0"
pService "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
"github.com/qsfera/server/services/webdav/pkg/net"
)
type (
// RequestDenied struct for OdataErrorMain
RequestDenied struct {
Error RequestDeniedError `json:"error"`
}
// RequestDeniedError struct for RequestDenied
RequestDeniedError struct {
Code string `json:"code"`
Message string `json:"message"`
// The structure of this object is service-specific
Innererror map[string]any `json:"innererror,omitempty"`
}
)
const DeniedMessage = "Operation denied due to security policies"
// Policies verifies if a request is granted or not.
func Policies(qs string, opts ...Option) func(next http.Handler) http.Handler {
options := newOptions(opts...)
logger := options.Logger
tracer := getTraceProvider(options).Tracer("proxy.middleware.policies")
gatewaySelector := options.RevaGatewaySelector
policiesProviderClient := options.PoliciesProviderService
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), fmt.Sprintf("%s %s", r.Method, r.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
r = r.WithContext(ctx)
defer span.End()
if qs == "" {
span.End()
next.ServeHTTP(w, r)
return
}
req := &pService.EvaluateRequest{
Query: qs,
Environment: &pMessage.Environment{
Request: &pMessage.Request{
Method: r.Method,
Path: r.URL.Path,
},
Stage: pMessage.Stage_STAGE_HTTP,
},
}
resource := &pMessage.Resource{}
// tus
meta := tusd.ParseMetadataHeader(r.Header.Get(net.HeaderUploadMetadata))
resource.Name = meta["filename"]
// name is part of the request path
if resource.Name == "" && filepath.Ext(r.URL.Path) != "" {
resource.Name = filepath.Base(r.URL.Path)
}
// no resource info in path, stat the resource and try to obtain the file information.
// this should only be used as last bastion, every request goes through the proxy and doing stats is expensive!
// needed for:
// - if a single resource is shared -> the url only contains the resourceID (spaceRef)
if resource.Name == "" && filepath.Ext(r.URL.Path) == "" && r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/remote.php/dav/spaces") {
client, err := gatewaySelector.Next()
if err != nil {
logger.Err(err).Msg("error selecting next gateway client")
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
return
}
resourceID, err := storagespace.ParseID(strings.TrimPrefix(r.URL.Path, "/remote.php/dav/spaces/"))
if err != nil {
logger.Debug().Err(err).Msg("error parsing the resourceId")
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
return
}
if resourceID.StorageId == "" && resourceID.SpaceId == utils.ShareStorageSpaceID {
resourceID.StorageId = utils.ShareStorageProviderID
}
token := r.Header.Get(revactx.TokenHeader)
ctx := metadata.AppendToOutgoingContext(r.Context(), revactx.TokenHeader, token)
sRes, err := client.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{
ResourceId: &resourceID,
},
})
resource.Name = sRes.GetInfo().GetName()
}
req.Environment.Resource = resource
if user, ok := revactx.ContextGetUser(r.Context()); ok {
req.Environment.User = &pMessage.User{
Id: &pMessage.User_ID{
OpaqueId: user.GetId().GetOpaqueId(),
},
Username: user.GetUsername(),
Mail: user.GetMail(),
DisplayName: user.GetDisplayName(),
Groups: user.GetGroups(),
}
}
rsp, err := policiesProviderClient.Evaluate(r.Context(), req)
if err != nil {
logger.Err(err).Msg("error evaluating request")
w.WriteHeader(http.StatusInternalServerError)
return
}
if !rsp.Result {
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
return
}
span.End()
next.ServeHTTP(w, r)
})
}
}
// RenderError writes a Policies ErrorObject to the response writer
func RenderError(w http.ResponseWriter, r *http.Request, evaluateReq *pService.EvaluateRequest, status int, msg string) {
filename := evaluateReq.Environment.GetResource().GetName()
if filename == "" {
filename = path.Base(evaluateReq.Environment.GetRequest().GetPath())
}
innererror := map[string]any{
"date": time.Now().UTC().Format(time.RFC3339),
}
innererror["request-id"] = middleware.GetReqID(r.Context())
innererror["method"] = evaluateReq.Environment.GetRequest().GetMethod()
innererror["filename"] = filename
innererror["path"] = evaluateReq.Environment.GetRequest().GetPath()
resp := &RequestDenied{
Error: RequestDeniedError{
Code: "deniedByPolicy",
Message: msg,
Innererror: innererror,
},
}
render.Status(r, status)
render.JSON(w, r, resp)
}
@@ -0,0 +1,171 @@
package middleware_test
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
. "github.com/onsi/gomega"
pMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/policies/v0"
policiesPG "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
"github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0/mocks"
"github.com/qsfera/server/services/proxy/pkg/middleware"
"github.com/qsfera/server/services/webdav/pkg/net"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/client"
"google.golang.org/grpc"
)
func TestPolicies_NoQuery_PassThrough(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, _, _ := prepare("")
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
g.Expect(responseRecorder.Code).To(Equal(http.StatusOK))
}
func TestPolicies_ErrorsOnEvaluationError(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return(
nil,
errors.New("any"),
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
g.Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError))
}
func TestPolicies_ErrorsOnDeny(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return(
&policiesPG.EvaluateResponse{},
nil,
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
result := responseRecorder.Result()
defer func() {
g.Expect(result.Body.Close()).ToNot(HaveOccurred())
}()
data, err := io.ReadAll(result.Body)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(data).To(ContainSubstring(middleware.DeniedMessage))
g.Expect(responseRecorder.Code).To(Equal(http.StatusForbidden))
}
func TestPolicies_EvaluationEnvironment_HTTPStage(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Stage).To(Equal(pMessage.Stage_STAGE_HTTP))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
}
func TestPolicies_EvaluationEnvironment_Request(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Request.Method).To(Equal(http.MethodDelete))
g.Expect(in.Environment.Request.Path).To(Equal("/whatever"))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodDelete, "/whatever", nil))
}
func TestPolicies_EvaluationEnvironment_Resource(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
// tus metadata
{
responseRecorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/remote.php/dav/spaces", nil)
request.Header.Set(net.HeaderUploadMetadata, fmt.Sprintf("filename %v", base64.StdEncoding.EncodeToString([]byte("tus-file-name.png"))))
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Resource.Name).To(Equal("tus-file-name.png"))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
).Once()
policiesMiddleware.ServeHTTP(responseRecorder, request)
}
// url path
{
responseRecorder := httptest.NewRecorder()
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Resource.Name).To(Equal("simple-file-name.png"))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
).Once()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodPut, "/remote.php/dav/spaces/simple-file-name.png", nil))
}
}
func prepare(q string) (http.Handler, *mocks.PoliciesProviderService, *cs3mocks.GatewayAPIClient) {
// mocked gatewaySelector
gatewayClient := &cs3mocks.GatewayAPIClient{}
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
defer pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
// mocked policiesProviderService
policiesProviderService := &mocks.PoliciesProviderService{}
// spin up middleware
policiesMiddleware := middleware.Policies(
q,
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.PoliciesProviderService(policiesProviderService),
)(mockHandler{})
return policiesMiddleware, policiesProviderService, gatewayClient
}
type mockHandler struct{}
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
@@ -0,0 +1,133 @@
package middleware
import (
"net/http"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/qsfera/server/pkg/log"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
const (
headerRevaAccessToken = revactx.TokenHeader
headerShareToken = "public-token"
basicAuthPasswordPrefix = "password|"
authenticationType = "publicshares"
_paramSignature = "signature"
_paramExpiration = "expiration"
)
// PublicShareAuthenticator is the authenticator which can authenticate public share requests.
// It will add the share owner into the request context.
type PublicShareAuthenticator struct {
Logger log.Logger
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
// The archiver is able to create archives from public shares in which case it needs to use the
// PublicShareAuthenticator. It might however also be called using "normal" authentication or
// using signed url, which are handled by other middleware. For this reason we can't just
// handle `/archiver` with the `isPublicPath()` check.
func isPublicShareArchive(r *http.Request) bool {
if strings.HasPrefix(r.URL.Path, "/archiver") {
if r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "" {
return true
}
}
return false
}
// The app open requests can be made in public share contexts. For that the PublicShareAuthenticator needs to
// augment the request context.
// The app open requests can also be made in authenticated context. In these cases the PublicShareAuthenticator
// needs to ignore the request.
func isPublicShareAppOpen(r *http.Request) bool {
return (strings.HasPrefix(r.URL.Path, "/app/open") || strings.HasPrefix(r.URL.Path, "/app/new")) &&
(r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "")
}
// The public-files requests can also be made in authenticated context. In these cases the OIDCAuthenticator and
// the BasicAuthenticator needs to ignore the request when the headerShareToken exist.
func isPublicWithShareToken(r *http.Request) bool {
return (strings.HasPrefix(r.URL.Path, "/dav/public-files") || strings.HasPrefix(r.URL.Path, "/remote.php/dav/public-files")) &&
(r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "")
}
// Authenticate implements the authenticator interface to authenticate requests via public share auth.
func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if !isPublicPath(r.URL.Path) && !isPublicShareArchive(r) && !isPublicShareAppOpen(r) {
return nil, false
}
query := r.URL.Query()
shareToken := r.Header.Get(headerShareToken)
if shareToken == "" {
shareToken = query.Get(headerShareToken)
}
if shareToken == "" {
// If the share token is not set then we don't need to inject the user to
// the request context so we can just continue with the request.
return r, true
}
var sharePassword string
if signature := query.Get(_paramSignature); signature != "" {
expiration := query.Get(_paramExpiration)
if expiration == "" {
a.Logger.Warn().Str("signature", signature).Msg("cannot do signature auth without the expiration")
return nil, false
}
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
}
}
client, err := a.RevaGatewaySelector.Next()
if err != nil {
a.Logger.Error().
Err(err).
Str("authenticator", "public_share").
Str("public_share_token", shareToken).
Str("path", r.URL.Path).
Msg("could not select next gateway client")
return nil, false
}
authResp, err := client.Authenticate(r.Context(), &gateway.AuthenticateRequest{
Type: authenticationType,
ClientId: shareToken,
ClientSecret: sharePassword,
})
if err != nil {
a.Logger.Error().
Err(err).
Str("authenticator", "public_share").
Str("public_share_token", shareToken).
Str("path", r.URL.Path).
Msg("failed to authenticate request")
return nil, false
}
r.Header.Add(headerRevaAccessToken, authResp.Token)
trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("enduser.id", "public"))
a.Logger.Debug().
Str("authenticator", "public_share").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r, true
}
@@ -0,0 +1,114 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"google.golang.org/grpc"
)
var _ = Describe("Authenticating requests", Label("PublicShareAuthenticator"), func() {
var authenticator Authenticator
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
authenticator = PublicShareAuthenticator{
Logger: log.NewLogger(),
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return mockGatewayClient{
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
if authType != "publicshares" {
return "", rpcv1beta1.Code_CODE_NOT_FOUND
}
if clientID == "sharetoken" && (clientSecret == "password|examples3cr3t" || clientSecret == "signature|examplesignature|exampleexpiration") {
return "exampletoken", rpcv1beta1.Code_CODE_OK
}
if clientID == "sharetoken" && clientSecret == "password|" {
return "otherexampletoken", rpcv1beta1.Code_CODE_OK
}
return "", rpcv1beta1.Code_CODE_NOT_FOUND
},
}
},
),
}
})
When("the request contains correct data", func() {
Context("using password authentication", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req.SetBasicAuth("public", "examples3cr3t")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
h := req2.Header
Expect(h.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
})
})
Context("using signature authentication", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken&signature=examplesignature&expiration=exampleexpiration", http.NoBody)
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
h := req2.Header
Expect(h.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
})
})
})
When("the reguest is for the archiver", func() {
Context("using a public-token", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/archiver?public-token=sharetoken", http.NoBody)
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
h := req2.Header
Expect(h.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
})
})
Context("not using a public-token", func() {
It("should fail to authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/archiver", http.NoBody)
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(false))
Expect(req2).To(BeNil())
})
})
})
})
type mockGatewayClient struct {
gatewayv1beta1.GatewayAPIClient
AuthenticateFunc func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code)
}
func (c mockGatewayClient) Authenticate(ctx context.Context, in *gatewayv1beta1.AuthenticateRequest, opts ...grpc.CallOption) (*gatewayv1beta1.AuthenticateResponse, error) {
token, code := c.AuthenticateFunc(in.GetType(), in.GetClientId(), in.GetClientSecret())
return &gatewayv1beta1.AuthenticateResponse{
Status: &rpcv1beta1.Status{Code: code},
Token: token,
}, nil
}
@@ -0,0 +1,157 @@
package middleware
import (
"net/http"
"os"
"reflect"
gofig "github.com/gookit/config/v2"
"github.com/gookit/config/v2/yaml"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/unrolled/secure"
"github.com/unrolled/secure/cspbuilder"
yamlv3 "gopkg.in/yaml.v3"
)
// LoadCSPConfig loads CSP header configuration from a yaml file.
func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) {
yamlContent, customYamlContent, err := loadCSPYaml(proxyCfg)
if err != nil {
return nil, err
}
return loadCSPConfig(yamlContent, customYamlContent)
}
// LoadCSPConfig loads CSP header configuration from a yaml file.
func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, error) {
// substitute env vars and load to struct
gofig.WithOptions(gofig.ParseEnv)
gofig.AddDriver(yaml.Driver)
presetMap := map[string]any{}
err := yamlv3.Unmarshal(presetYamlContent, &presetMap)
if err != nil {
return nil, err
}
customMap := map[string]any{}
err = yamlv3.Unmarshal(customYamlContent, &customMap)
if err != nil {
return nil, err
}
mergedMap := deepMerge(presetMap, customMap)
mergedYamlContent, err := yamlv3.Marshal(mergedMap)
if err != nil {
return nil, err
}
err = gofig.LoadSources("yaml", mergedYamlContent)
if err != nil {
return nil, err
}
// read yaml
cspConfig := config.CSP{}
err = gofig.BindStruct("", &cspConfig)
if err != nil {
return nil, err
}
return &cspConfig, nil
}
// deepMerge recursively merges map2 into map1.
// - nested maps are merged recursively
// - slices are concatenated, preserving order and avoiding duplicates
// - scalar or type-mismatched values from map2 overwrite map1
func deepMerge(map1, map2 map[string]any) map[string]any {
if map1 == nil {
out := make(map[string]any, len(map2))
for k, v := range map2 {
out[k] = v
}
return out
}
for k, v2 := range map2 {
if v1, ok := map1[k]; ok {
// both maps -> recurse
if m1, ok1 := v1.(map[string]any); ok1 {
if m2, ok2 := v2.(map[string]any); ok2 {
map1[k] = deepMerge(m1, m2)
continue
}
}
// both slices -> merge unique
if s1, ok1 := v1.([]any); ok1 {
if s2, ok2 := v2.([]any); ok2 {
merged := append([]any{}, s1...)
for _, item := range s2 {
if !sliceContains(merged, item) {
merged = append(merged, item)
}
}
map1[k] = merged
continue
}
// s1 is slice, v2 single -> append if missing
if !sliceContains(s1, v2) {
map1[k] = append(s1, v2)
}
continue
}
// default: overwrite
map1[k] = v2
} else {
// new key -> just set
map1[k] = v2
}
}
return map1
}
func sliceContains(slice []any, val any) bool {
for _, v := range slice {
if reflect.DeepEqual(v, val) {
return true
}
}
return false
}
func loadCSPYaml(proxyCfg *config.Config) ([]byte, []byte, error) {
if proxyCfg.CSPConfigFileOverrideLocation != "" {
overrideCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileOverrideLocation)
return overrideCSPYaml, []byte{}, err
}
if proxyCfg.CSPConfigFileLocation == "" {
return []byte(config.DefaultCSPConfig), nil, nil
}
customCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileLocation)
return []byte(config.DefaultCSPConfig), customCSPYaml, err
}
// Security is a middleware to apply security relevant http headers like CSP.
func Security(cspConfig *config.CSP) func(h http.Handler) http.Handler {
cspBuilder := cspbuilder.Builder{
Directives: cspConfig.Directives,
}
secureMiddleware := secure.New(secure.Options{
BrowserXssFilter: true,
ContentSecurityPolicy: cspBuilder.MustBuild(),
ContentTypeNosniff: true,
CustomFrameOptionsValue: "SAMEORIGIN",
FrameDeny: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
STSSeconds: 315360000,
STSPreload: true,
PermittedCrossDomainPolicies: "none",
RobotTag: "none",
})
return func(next http.Handler) http.Handler {
return secureMiddleware.Handler(next)
}
}
@@ -0,0 +1,40 @@
package middleware
import (
"testing"
"gotest.tools/v3/assert"
"gotest.tools/v3/assert/cmp"
)
func TestLoadCSPConfig(t *testing.T) {
// setup test env
presetYaml := `
directives:
frame-src:
- '''self'''
- 'https://embed.diagrams.net/'
- 'https://${ONLYOFFICE_DOMAIN|onlyoffice.qsfera.test}/'
- 'https://${COLLABORA_DOMAIN|collabora.qsfera.test}/'
`
customYaml := `
directives:
img-src:
- '''self'''
- 'data:'
frame-src:
- 'https://some.custom.domain/'
`
config, err := loadCSPConfig([]byte(presetYaml), []byte(customYaml))
if err != nil {
t.Error(err)
}
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "'self'"))
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://embed.diagrams.net/"))
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://onlyoffice.qsfera.test/"))
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://collabora.qsfera.test/"))
assert.Assert(t, cmp.Contains(config.Directives["img-src"], "'self'"))
assert.Assert(t, cmp.Contains(config.Directives["img-src"], "data:"))
}
@@ -0,0 +1,79 @@
package middleware
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/proxy/policy"
"go.opentelemetry.io/otel/trace"
)
// SelectorCookie provides a middleware which
func SelectorCookie(optionSetters ...Option) func(next http.Handler) http.Handler {
options := newOptions(optionSetters...)
logger := options.Logger
policySelector := options.PolicySelector
tracer := getTraceProvider(options).Tracer("proxy.middleware.selector_cookie")
return func(next http.Handler) http.Handler {
return &selectorCookie{
next: next,
logger: logger,
tracer: tracer,
policySelector: policySelector,
}
}
}
type selectorCookie struct {
next http.Handler
logger log.Logger
tracer trace.Tracer
policySelector config.PolicySelector
}
func (m selectorCookie) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
req = req.WithContext(ctx)
defer span.End()
if m.policySelector.Regex == nil && m.policySelector.Claims == nil {
// only set selector cookie for regex and claim selectors
span.End()
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)
}
defer span.End()
m.next.ServeHTTP(w, req)
}
@@ -0,0 +1,323 @@
package middleware
import (
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/userroles"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
microstore "go-micro.dev/v4/store"
"golang.org/x/crypto/pbkdf2"
)
const (
_paramOCSignature = "OC-Signature"
_paramOCCredential = "OC-Credential" // #nosec G101
_paramOCDate = "OC-Date"
_paramOCExpires = "OC-Expires"
_paramOCVerb = "OC-Verb"
_paramOCAlgo = "OC-Algo"
_paramOCJWTSig = "oc-jwt-sig"
)
var (
_requiredParams = [...]string{
_paramOCSignature,
_paramOCCredential,
_paramOCDate,
_paramOCExpires,
_paramOCVerb,
}
)
// SignedURLAuthenticator is the authenticator responsible for authenticating signed URL requests.
type SignedURLAuthenticator struct {
Logger log.Logger
PreSignedURLConfig config.PreSignedURL
UserProvider backend.UserBackend
UserRoleAssigner userroles.UserRoleAssigner
Store microstore.Store
Now func() time.Time
URLVerifier signedurl.Verifier
}
func (m SignedURLAuthenticator) shouldServeLegacy(req *http.Request) bool {
if !m.PreSignedURLConfig.Enabled {
return false
}
return req.URL.Query().Get(_paramOCSignature) != ""
}
func (m SignedURLAuthenticator) shouldServe(req *http.Request) bool {
if m.URLVerifier == nil {
return false
}
return req.URL.Query().Get(_paramOCJWTSig) != ""
}
func (m SignedURLAuthenticator) validate(req *http.Request) (err error) {
query := req.URL.Query()
if err := m.allRequiredParametersArePresent(query); err != nil {
return err
}
if err := m.requestMethodMatches(req.Method, query); err != nil {
return err
}
if err := m.requestMethodIsAllowed(req.Method); err != nil {
return err
}
if err = m.urlIsExpired(query); err != nil {
return err
}
if err := m.signatureIsValid(req); err != nil {
return err
}
return nil
}
func (m SignedURLAuthenticator) allRequiredParametersArePresent(query url.Values) (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 qsfera 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 _requiredParams {
if query.Get(p) == "" {
return fmt.Errorf("required %s parameter not found", p)
}
}
return nil
}
func (m SignedURLAuthenticator) requestMethodMatches(meth string, query url.Values) (err error) {
// check if given url query parameter OC-Verb matches given request method
if !strings.EqualFold(meth, query.Get(_paramOCVerb)) {
return errors.New("required OC-Verb parameter did not match request method")
}
return nil
}
func (m SignedURLAuthenticator) requestMethodIsAllowed(meth string) (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 errors.New("request method is not listed in PreSignedURLConfig AllowedHTTPMethods")
}
return nil
}
func (m SignedURLAuthenticator) urlIsExpired(query url.Values) (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(_paramOCDate))
if err != nil {
return err
}
requestExpiry, err := time.ParseDuration(query.Get(_paramOCExpires) + "s")
if err != nil {
return err
}
validTo := validFrom.Add(requestExpiry)
if !(m.Now().Before(validTo)) {
return errors.New("URL is expired")
}
return nil
}
func (m SignedURLAuthenticator) signatureIsValid(req *http.Request) (err error) {
c := revactx.ContextMustGetUser(req.Context())
signingKey, err := m.Store.Read(c.Id.OpaqueId)
if err != nil {
m.Logger.Error().Err(err).Msg("could not retrieve signing key")
return err
}
if len(signingKey[0].Value) == 0 {
m.Logger.Error().Err(err).Msg("signing key empty")
return err
}
u := m.buildUrlToSign(req)
computedSignature := m.createSignature(u, signingKey[0].Value)
signatureInURL := req.URL.Query().Get(_paramOCSignature)
if computedSignature == signatureInURL {
return nil
}
// try a workaround for https://github.com/owncloud/ocis/issues/10180
// Some reverse proxies might replace $ with %24 in the URL leading to a mismatch in the signature
u = strings.Replace(u, "$", "%24", 1)
computedSignature = m.createSignature(u, signingKey[0].Value)
signatureInURL = req.URL.Query().Get(_paramOCSignature)
if computedSignature == signatureInURL {
return nil
}
return fmt.Errorf("signature mismatch: expected %s != actual %s", computedSignature, signatureInURL)
}
func (m SignedURLAuthenticator) buildUrlToSign(req *http.Request) string {
q := req.URL.Query()
// only params required for signing
signParameters := make(url.Values)
signParameters.Add(_paramOCCredential, q.Get(_paramOCCredential))
signParameters.Add(_paramOCDate, q.Get(_paramOCDate))
signParameters.Add(_paramOCExpires, q.Get(_paramOCExpires))
signParameters.Add(_paramOCVerb, q.Get(_paramOCVerb))
// remaining query params
q.Del(_paramOCAlgo)
q.Del(_paramOCCredential)
q.Del(_paramOCDate)
q.Del(_paramOCExpires)
q.Del(_paramOCSignature)
q.Del(_paramOCVerb)
url := *req.URL
if len(q) == 0 {
url.RawQuery = signParameters.Encode()
} else {
url.RawQuery = strings.Join([]string{q.Encode(), signParameters.Encode()}, "&")
}
u := url.String()
if !url.IsAbs() {
u = "https://" + req.Host + u // TODO where do we get the scheme
}
return u
}
func (m SignedURLAuthenticator) 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)
}
// Authenticate implements the authenticator interface to authenticate requests via signed URL auth.
func (m SignedURLAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
switch {
case m.shouldServeLegacy(r):
return m.authenticateLegacy(r)
case m.shouldServe(r):
return m.authenticate(r)
}
return nil, false
}
func (m SignedURLAuthenticator) authenticate(r *http.Request) (*http.Request, bool) {
u := r.URL.String()
if !r.URL.IsAbs() {
u = "https://" + r.Host + u
}
userid, err := m.URLVerifier.Verify(u)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url_jwt").
Str("path", r.URL.Path).
Str("url", u).
Msg("Could not verify JWT signature")
return nil, false
}
user, _, err := m.UserProvider.GetUserByClaims(r.Context(), "userid", userid)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url_jwt").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
ctx := revactx.ContextSetUser(r.Context(), user)
r = r.WithContext(ctx)
m.Logger.Debug().
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r, true
}
// authenticateLegacy is a helper function to authenticate requests that use the legacy
// client side signed URLs
func (m SignedURLAuthenticator) authenticateLegacy(r *http.Request) (*http.Request, bool) {
user, _, err := m.UserProvider.GetUserByClaims(r.Context(), "username", r.URL.Query().Get(_paramOCCredential))
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
ctx := revactx.ContextSetUser(r.Context(), user)
r = r.WithContext(ctx)
if err := m.validate(r); err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Str("url", r.URL.String()).
Msg("Could not get user by claim")
return nil, false
}
m.Logger.Debug().
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r, true
}
@@ -0,0 +1,250 @@
package middleware
import (
"context"
"net/http/httptest"
"testing"
"time"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/services/proxy/pkg/config"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
"github.com/stretchr/testify/assert"
"go-micro.dev/v4/store"
)
func TestSignedURLAuthLegacy_shouldServe(t *testing.T) {
pua := SignedURLAuthenticator{}
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.shouldServeLegacy(r)
if result != tt.expected {
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
}
}
}
func TestSignedURLAuth_shouldServe(t *testing.T) {
tests := []struct {
url string
secret string
enabled bool
expected bool
}{
{"https://example.com/example.jpg", "", true, false},
{"https://example.com/example.jpg", "", false, false},
{"https://example.com/example.jpg?oc-jwt-sig=something1", "secret", true, true},
{"https://example.com/example.jpg?oc-jwt-sig=something2", "", true, false},
{"https://example.com/example.jpg?oc-jwt-sig=something3", "secret", false, true},
}
for _, tt := range tests {
pua := SignedURLAuthenticator{}
pua.PreSignedURLConfig.Enabled = tt.enabled
if tt.secret != "" {
signURLVerifier, err := signedurl.NewJWTSignedURL(signedurl.WithSecret(tt.secret))
if err != nil {
t.Fatalf("failed to create signed URL verifier: %v", err)
}
pua.URLVerifier = signURLVerifier
}
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 := SignedURLAuthenticator{}
baseURL := "https://example.com/example.jpg?"
tests := []struct {
params string
errorMessage string
}{
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", ""},
{"OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", "required OC-Signature parameter not found"},
{"OC-Signature=something&OC-Date=something&OC-Expires=something&OC-Verb=something", "required OC-Credential parameter not found"},
{"OC-Signature=something&OC-Credential=something&OC-Expires=something&OC-Verb=something", "required OC-Date parameter not found"},
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Verb=something", "required OC-Expires parameter not found"},
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something", "required OC-Verb parameter not found"},
}
for _, tt := range tests {
r := httptest.NewRequest("", baseURL+tt.params, nil)
err := pua.allRequiredParametersArePresent(r.URL.Query())
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage, tt.params)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_requestMethodMatches(t *testing.T) {
pua := SignedURLAuthenticator{}
tests := []struct {
method string
url string
errorMessage string
}{
{"GET", "https://example.com/example.jpg?OC-Verb=GET", ""},
{"GET", "https://example.com/example.jpg?OC-Verb=get", ""},
{"POST", "https://example.com/example.jpg?OC-Verb=GET", "required OC-Verb parameter did not match request method"},
}
for _, tt := range tests {
r := httptest.NewRequest(tt.method, tt.url, nil)
err := pua.requestMethodMatches(r.Method, r.URL.Query())
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage, tt.url)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_requestMethodIsAllowed(t *testing.T) {
pua := SignedURLAuthenticator{}
tests := []struct {
method string
allowed []string
errorMessage string
}{
{"GET", []string{}, "request method is not listed in PreSignedURLConfig AllowedHTTPMethods"},
{"GET", []string{"POST"}, "request method is not listed in PreSignedURLConfig AllowedHTTPMethods"},
{"GET", []string{"GET"}, ""},
{"GET", []string{"get"}, ""},
{"GET", []string{"POST", "GET"}, ""},
}
for _, tt := range tests {
pua.PreSignedURLConfig.AllowedHTTPMethods = tt.allowed
err := pua.requestMethodIsAllowed(tt.method)
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_urlIsExpired(t *testing.T) {
nowFunc := func() time.Time {
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
return t
}
pua := SignedURLAuthenticator{
Now: nowFunc,
}
tests := []struct {
url string
errorMessage string
}{
// a valid signed url
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=61", ""},
// invalid expiry
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", "time: invalid duration \"invalids\""},
// wrong date format on OC-Date
{"http://example.com/example.jpg?OC-Date=2020-02-02TTT12:29:00.000Z&OC-Expires=5", "parsing time \"2020-02-02TTT12:29:00.000Z\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"TT12:29:00.000Z\" as \"15\""},
// expired - 12:29:00 + 59s < 12:30
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=59", "URL is expired"},
// expired - basically url was created yesterday
{"http://example.com/example.jpg?OC-Date=2020-02-01T12:29:00.000Z&OC-Expires=59", "URL is expired"},
// future OC-Date - also valid now
{"http://example.com/example.jpg?OC-Date=2020-02-03T12:29:00.000Z&OC-Expires=59", ""},
}
for _, tt := range tests {
r := httptest.NewRequest("", tt.url, nil)
err := pua.urlIsExpired(r.URL.Query())
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage, tt.url)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_createSignature(t *testing.T) {
pua := SignedURLAuthenticator{}
expected := "27d2ebea381384af3179235114801dcd00f91e46f99fca72575301cf3948101d"
s := pua.createSignature("something", []byte("somerandomkey"))
if s != expected {
t.Fail()
}
}
func TestSignedURLAuth_validate(t *testing.T) {
nowFunc := func() time.Time {
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
return t
}
cfg := config.PreSignedURL{
AllowedHTTPMethods: []string{"get"},
Enabled: true,
}
pua := SignedURLAuthenticator{
PreSignedURLConfig: cfg,
Store: store.NewMemoryStore(),
Now: nowFunc,
}
pua.Store.Write(&store.Record{
Key: "useri",
Value: []byte("1234567890"),
Metadata: nil,
})
tests := []struct {
now string
url string
errorMessage string
}{
{"2020-02-02T12:30:00.000Z", "http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", "required OC-Signature parameter not found"},
{"2020-02-02T12:30:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", "URL is expired"},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b", "signature mismatch: expected f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6 != actual f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b"},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Credential=alice&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Algo=PBKDF2%2F10000-SHA512&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Credential=alice&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
{"2024-02-07T12:03:11.966Z", "http://localhost:33001/try?id=1&id=2&OC-Credential=user&OC-Date=2024-02-07T12%3A03%3A11.966Z&OC-Expires=2&OC-Verb=GET&OC-Algo=PBKDF2%2F10000-SHA512&OC-Signature=86e21a1efbf0be989a206109cfedf70a22f338dc8995e849ce002032bc6741c5", ""},
}
for _, tt := range tests {
u := userpb.User{
Id: &userpb.UserId{OpaqueId: "useri"},
DisplayName: "Test User",
}
ctx := revactx.ContextSetUser(context.Background(), &u)
pua.Now = func() time.Time {
t, _ := time.Parse(time.RFC3339, tt.now)
return t
}
r := httptest.NewRequest("", tt.url, nil).WithContext(ctx)
err := pua.validate(r)
if tt.errorMessage == "" {
assert.Nil(t, err)
} else {
assert.EqualError(t, err, tt.errorMessage)
}
}
}
@@ -0,0 +1,35 @@
package middleware
import (
"net/http"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// Tracer provides a middleware to start traces
func Tracer(tp trace.TracerProvider) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return &tracer{
next: next,
traceProvider: tp,
}
}
}
type tracer struct {
next http.Handler
traceProvider trace.TracerProvider
}
func (m tracer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetAttributes(
attribute.KeyValue{
Key: "x-request-id",
Value: attribute.StringValue(chimiddleware.GetReqID(r.Context())),
})
m.next.ServeHTTP(w, r)
}
+40
View File
@@ -0,0 +1,40 @@
package proxy
import (
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/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
}
}
@@ -0,0 +1,223 @@
package policy
import (
"fmt"
"net/http"
"regexp"
"sort"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
)
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 = "qsfera-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" : "qsfera"}
// },
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 "qsfera.routing.policy" claim
// The policy for corner cases is configurable:
//
// "policy_selector": {
// "migration": {
// "default_policy" : "qsfera",
// "unauthenticated_policy": "oc10"
// }
// },
//
// This selector can be used in migration-scenarios or to set up sharded deployments
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.QsferaRoutingPolicy].(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": "mary@example.org", "policy": "qsfera"},
// {"priority": 20, "property": "mail", "match": "[^@]+@example.org", "policy": "oc10"},
// {"priority": 30, "property": "username", "match": "(dennis|feynman)", "policy": "qsfera"},
// {"priority": 40, "property": "username", "match": ".+", "policy": "oc10"},
// {"priority": 50, "property": "id", "match": "b1f74ec4-dd7e-11ef-a543-03775734d0f7", "policy": "qsfera"},
// {"priority": 60, "property": "id", "match": "056fc874-dd7f-11ef-ba84-af6fca4b7289", "policy": "oc10"}
// ],
// "unauthenticated_policy": "oc10"
// }
// },
//
// This selector can be used in migration-scenarios or to set up sharded deployments
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, &regexRule{
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
}
@@ -0,0 +1,145 @@
package policy
import (
"context"
"net/http"
"net/http/httptest"
"testing"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
)
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: "qsfera"})
req := httptest.NewRequest("GET", "https://example.org/foo", nil)
want := "qsfera"
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",
SelectorCookieName: SelectorCookieName,
})
var tests = []testCase{
{"unauthenticated", context.Background(), nil, "unauthenticated"},
{"default", oidc.NewContext(context.Background(), map[string]any{oidc.QsferaRoutingPolicy: ""}), nil, "default"},
{"claim-value", oidc.NewContext(context.Background(), map[string]any{oidc.QsferaRoutingPolicy: "qsfera.routing.policy-value"}), nil, "qsfera.routing.policy-value"},
{"cookie-only", context.Background(), &http.Cookie{Name: SelectorCookieName, Value: "cookie"}, "cookie"},
{"claim-can-override-cookie", oidc.NewContext(context.Background(), map[string]any{oidc.QsferaRoutingPolicy: "qsfera.routing.policy-value"}), &http.Cookie{Name: SelectorCookieName, Value: "cookie"}, "qsfera.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: "mary@example.org", Policy: "qsfera"},
{Priority: 20, Property: "mail", Match: "[^@]+@example.org", Policy: "oc10"},
{Priority: 30, Property: "username", Match: "(alan|feynman)", Policy: "qsfera"},
{Priority: 40, Property: "username", Match: ".+", Policy: "oc10"},
{Priority: 50, Property: "id", Match: "b1f74ec4-dd7e-11ef-a543-03775734d0f7", Policy: "qsfera"},
{Priority: 60, Property: "id", Match: "056fc874-dd7f-11ef-ba84-af6fca4b7289", Policy: "oc10"},
},
UnauthenticatedPolicy: "unauthenticated",
})
var tests = []testCase{
{"unauthenticated", context.Background(), nil, "unauthenticated"},
{"default", revactx.ContextSetUser(context.Background(), &userv1beta1.User{}), nil, "default"},
{"mail-qsfera", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Mail: "mary@example.org"}), nil, "qsfera"},
{"mail-oc10", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Mail: "alan@example.org"}), nil, "oc10"},
{"username-alan", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "alan"}), nil, "qsfera"},
{"username-feynman", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "feynman"}), nil, "qsfera"},
{"username-mary", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "mary"}), 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: "b1f74ec4-dd7e-11ef-a543-03775734d0f7"}}), nil, "qsfera"},
{"id-2", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Id: &userv1beta1.UserId{OpaqueId: "056fc874-dd7f-11ef-ba84-af6fca4b7289"}}), nil, "oc10"},
}
for _, tc := range tests {
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)
}
})
}
}
+94
View File
@@ -0,0 +1,94 @@
package proxy
import (
"crypto/tls"
"crypto/x509"
"errors"
stdlog "log"
"net"
"net/http"
"net/http/httputil"
"os"
"time"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/proxy/policy"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/rs/zerolog"
)
// 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, error) {
options := newOptions(opts...)
rp := &MultiHostReverseProxy{
ReverseProxy: httputil.ReverseProxy{
ErrorLog: stdlog.New(options.Logger, "", 0),
},
Directors: make(map[string]map[config.RouteType]map[string]map[string]func(req *http.Request)),
logger: options.Logger,
config: options.Config,
}
rp.Rewrite = func(r *httputil.ProxyRequest) {
ri := router.ContextRoutingInfo(r.In.Context())
ri.Rewrite()(r)
}
rp.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
reqLogger := zerolog.Ctx(req.Context())
if ev := reqLogger.Error(); ev.Enabled() {
ev.Err(err).Msg("error happened in MultiHostReverseProxy")
} else {
rp.logger.Err(err).Msg("error happened in MultiHostReverseProxy")
}
rw.WriteHeader(http.StatusBadGateway)
}
tlsConf := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: options.Config.InsecureBackends, //nolint:gosec
}
if options.Config.BackendHTTPSCACert != "" {
certs := x509.NewCertPool()
pemData, err := os.ReadFile(options.Config.BackendHTTPSCACert)
if err != nil {
return nil, err
}
if !certs.AppendCertsFromPEM(pemData) {
return nil, errors.New("Error initializing LDAP Backend. Adding CA cert failed")
}
tlsConf.RootCAs = certs
}
// 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: tlsConf,
}
return rp, nil
}
func (p *MultiHostReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.ReverseProxy.ServeHTTP(w, r)
}
@@ -0,0 +1,230 @@
package proxy
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/router"
"go-micro.dev/v4/selector"
)
func TestProxyIntegration(t *testing.T) {
var tests = []testCase{
// Simple prefix route
test("simple_prefix", withPolicy("qsfera", 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("qsfera", 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("qsfera", 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("qsfera", 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("qsfera", 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("qsfera", 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("qsfera", 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("qsfera", 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"),
}
reg := registry.GetRegistry()
sel := selector.NewSelector(selector.Registry(reg))
for k := range tests {
t.Run(tests[k].id, func(t *testing.T) {
t.Parallel()
tc := tests[k]
rt := router.Middleware(sel, nil, tc.conf, log.NewLogger())
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: io.NopCloser(bytes.NewBufferString(`OK`)),
Header: make(http.Header),
}
})
rr := httptest.NewRecorder()
rt(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 := io.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 {
panic(fmt.Sprintf("Error parsing %v", strURL))
}
tc.expect = pu
return *tc
}
func testConfig(policy []config.Policy) *config.Config {
return &config.Config{
Debug: config.Debug{},
HTTP: config.HTTP{},
Policies: policy,
OIDC: config.OIDC{},
PolicySelector: nil,
}
}
+321
View File
@@ -0,0 +1,321 @@
package router
import (
"context"
"net/http"
"net/http/httputil"
"net/url"
"path"
"regexp"
"strings"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/proxy/policy"
"go-micro.dev/v4/selector"
)
type routingInfoCtxKey struct{}
var noInfo = RoutingInfo{}
// Middleware returns a HTTP middleware containing the router.
func Middleware(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger) func(http.Handler) http.Handler {
router := New(serviceSelector, policySelectorCfg, policies, logger)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ri, ok := router.Route(r)
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return
}
next.ServeHTTP(w, r.WithContext(SetRoutingInfo(r.Context(), ri)))
})
}
}
// New creates a new request router.
// It initializes the routes before returning the router.
func New(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger) Router {
if policySelectorCfg == nil {
firstPolicy := policies[0].Name
logger.Warn().Str("policy", firstPolicy).Msg("policy-selector not configured. Will always use first policy")
policySelectorCfg = &config.PolicySelector{
Static: &config.StaticSelectorConf{
Policy: firstPolicy,
},
}
}
logger.Debug().
Interface("selector_config", policySelectorCfg).
Msg("loading policy-selector")
policySelector, err := policy.LoadSelector(policySelectorCfg)
if err != nil {
logger.Fatal().Err(err).Msg("Could not load policy-selector")
}
r := Router{
logger: logger,
rewriters: make(map[string]map[config.RouteType]map[string][]RoutingInfo),
policySelector: policySelector,
serviceSelector: serviceSelector,
}
for _, pol := range policies {
for _, route := range pol.Routes {
logger.Debug().Str("fwd: ", route.Endpoint)
if route.Backend == "" && route.Service == "" {
logger.Fatal().Interface("route", route).Msg("neither Backend nor Service is set")
}
uri, err2 := url.Parse(route.Backend)
if err2 != nil {
logger.
Fatal(). // fail early on misconfiguration
Err(err2).
Str("backend", route.Backend).
Msg("malformed url")
}
// here the backend is used as a uri
r.addHost(pol.Name, uri, route)
}
}
return r
}
// RoutingInfo contains the proxy rewrite hook and some information about the route.
type RoutingInfo struct {
rewrite func(*httputil.ProxyRequest)
endpoint string
unprotected bool
remoteUserHeader string
skipXAccessToken bool
}
// Rewrite returns the proxy rewrite hook.
func (r RoutingInfo) Rewrite() func(*httputil.ProxyRequest) {
return r.rewrite
}
// IsRouteUnprotected returns true if the route doesn't need to be authenticated.
func (r RoutingInfo) IsRouteUnprotected() bool {
return r.unprotected
}
// RemoteUserHeader returns the name of Header for setting the remote user value
func (r RoutingInfo) RemoteUserHeader() string {
return r.remoteUserHeader
}
// SkipXAccessToken return true if the reva access token should not be added to the
// outgoing request
func (r RoutingInfo) SkipXAccessToken() bool {
return r.skipXAccessToken
}
// Router handles the routing of HTTP requests according to the given policies.
type Router struct {
logger log.Logger
rewriters map[string]map[config.RouteType]map[string][]RoutingInfo
policySelector policy.Selector
serviceSelector selector.Selector
}
func (rt Router) addHost(policy string, target *url.URL, route config.Route) {
targetQuery := target.RawQuery
if rt.rewriters[policy] == nil {
rt.rewriters[policy] = make(map[config.RouteType]map[string][]RoutingInfo)
}
routeType := config.DefaultRouteType
if route.Type != "" {
routeType = route.Type
}
if rt.rewriters[policy][routeType] == nil {
rt.rewriters[policy][routeType] = make(map[string][]RoutingInfo)
}
if rt.rewriters[policy][routeType][route.Method] == nil {
rt.rewriters[policy][routeType][route.Method] = make([]RoutingInfo, 0)
}
rt.rewriters[policy][routeType][route.Method] = append(rt.rewriters[policy][routeType][route.Method], RoutingInfo{
endpoint: route.Endpoint,
unprotected: route.Unprotected,
remoteUserHeader: route.RemoteUserHeader,
skipXAccessToken: route.SkipXAccessToken,
rewrite: func(req *httputil.ProxyRequest) {
if route.Service != "" {
// select next node
next, err := rt.serviceSelector.Select(route.Service)
if err != nil {
rt.logger.Error().Err(err).
Str("service", route.Service).
Msg("could not select service from the registry")
return // TODO error? fallback to target.Host & Scheme?
}
node, err := next()
if err != nil {
rt.logger.Error().Err(err).
Str("service", route.Service).
Msg("could not select next node")
return // TODO error? fallback to target.Host & Scheme?
}
req.Out.URL.Host = node.Address
req.Out.URL.Scheme = node.Metadata["protocol"] // TODO check property exists?
if node.Metadata["use_tls"] == "true" {
req.Out.URL.Scheme = "https"
}
} else {
req.Out.URL.Host = target.Host
req.Out.URL.Scheme = target.Scheme
}
// Apache deployments host addresses need to match on req.Out.Host and req.Out.URL.Host
// see https://stackoverflow.com/questions/34745654/golang-reverseproxy-with-apache2-sni-hostname-error
if route.ApacheVHost {
req.Out.Host = target.Host
}
for k, v := range route.AdditionalHeaders {
req.Out.Header.Set(k, v)
}
req.Out.URL.Path = singleJoiningSlash(target.Path, req.Out.URL.Path)
if targetQuery == "" || req.Out.URL.RawQuery == "" {
req.Out.URL.RawQuery = targetQuery + req.Out.URL.RawQuery
} else {
req.Out.URL.RawQuery = targetQuery + "&" + req.Out.URL.RawQuery
}
if _, ok := req.Out.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Out.Header.Set("User-Agent", "")
}
req.SetXForwarded()
},
})
}
// Route is evaluating the policies on the request and returns the RoutingInfo if successful.
func (rt Router) Route(r *http.Request) (RoutingInfo, bool) {
pol, err := rt.policySelector(r)
if err != nil {
rt.logger.Error().Err(err).Msg("Error while selecting pol")
return noInfo, false
}
if _, ok := rt.rewriters[pol]; !ok {
rt.logger.
Error().
Str("policy", pol).
Msg("policy is not configured")
return noInfo, false
}
method := ""
// find matching rewrite hook
for _, rtype := range config.RouteTypes {
var handler func(string, url.URL) bool
switch rtype {
case config.QueryRoute:
handler = queryRouteMatcher
case config.RegexRoute:
handler = rt.regexRouteMatcher
case config.PrefixRoute:
fallthrough
default:
handler = prefixRouteMatcher
}
if rt.rewriters[pol][rtype][r.Method] != nil {
// use specific method
method = r.Method
} else {
method = ""
}
for _, ri := range rt.rewriters[pol][rtype][method] {
if handler(ri.endpoint, *r.URL) {
rt.logger.Debug().
Str("policy", pol).
Str("method", r.Method).
Str("prefix", ri.endpoint).
Str("path", r.URL.Path).
Str("routeType", string(rtype)).
Msg("rewrite hook found")
return ri, true
}
}
}
// override default rewrite hook with root. If any
if ri := rt.rewriters[pol][config.PrefixRoute][method][0]; ri.endpoint == "/" { // try specific method
return ri, true
} else if ri := rt.rewriters[pol][config.PrefixRoute][""][0]; ri.endpoint == "/" { // fallback to unspecific method
return ri, true
}
rt.logger.
Warn().
Str("policy", pol).
Str("path", r.URL.Path).
Msg("no rewrite hook found")
return noInfo, false
}
func (rt Router) regexRouteMatcher(pattern string, target url.URL) bool {
matched, err := regexp.MatchString(pattern, target.String())
if err != nil {
rt.logger.Warn().Err(err).Str("pattern", pattern).Msg("regex with pattern failed")
}
return matched
}
func prefixRouteMatcher(prefix string, target url.URL) bool {
cleanTarget := path.Clean(target.Path)
if strings.HasSuffix(target.Path, "/") {
cleanTarget += "/"
}
return strings.HasPrefix(cleanTarget, prefix) && prefix != "/"
}
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
}
func queryRouteMatcher(endpoint string, target url.URL) bool {
u, _ := url.Parse(endpoint)
if !strings.HasPrefix(path.Clean(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
}
// SetRoutingInfo puts the routing info in the context.
func SetRoutingInfo(parent context.Context, ri RoutingInfo) context.Context {
return context.WithValue(parent, routingInfoCtxKey{}, ri)
}
// ContextRoutingInfo gets the routing information from the context.
func ContextRoutingInfo(ctx context.Context) RoutingInfo {
val := ctx.Value(routingInfoCtxKey{})
return val.(RoutingInfo)
}
@@ -0,0 +1,167 @@
package router
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"testing"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/config/defaults"
"go-micro.dev/v4/selector"
)
type matchertest struct {
method, endpoint, target string
unprotected bool
matches bool
}
func TestPrefixRouteMatcher(t *testing.T) {
cfg := defaults.DefaultConfig()
cfg.Policies = defaults.DefaultPolicies()
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 := 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()
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 := 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()
reg := registry.GetRegistry()
sel := selector.NewSelector(selector.Registry(reg))
rt := New(sel, cfg.PolicySelector, cfg.Policies, log.NewLogger())
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 := rt.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 TestRouter(t *testing.T) {
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ok")
}))
defer svr.Close()
policySelectorCfg := &config.PolicySelector{
Static: &config.StaticSelectorConf{
Policy: "default",
},
}
policies := []config.Policy{
{
Name: "default",
Routes: []config.Route{
{Type: config.PrefixRoute, Endpoint: "/web/unprotected/demo/", Backend: "http://web", Unprotected: true},
{Type: config.PrefixRoute, Endpoint: "/dav", Backend: "http://frontend"},
{Type: config.PrefixRoute, Method: "REPORT", Endpoint: "/dav", Backend: "http://qsfera-webdav"},
},
},
}
reg := registry.GetRegistry()
sel := selector.NewSelector(selector.Registry(reg))
router := New(sel, policySelectorCfg, policies, log.NewLogger())
table := []matchertest{
{method: "PROPFIND", endpoint: "/dav/files/demo/", target: "frontend"},
{method: "REPORT", endpoint: "/dav/files/demo/", target: "qsfera-webdav"},
{method: "GET", endpoint: "/web/unprotected/demo/", target: "web", unprotected: true},
}
for _, test := range table {
r := httptest.NewRequest(test.method, test.endpoint, nil)
routingInfo, ok := router.Route(r)
if !ok {
t.Errorf("TestRouter router.Route failed to route the request.")
}
if routingInfo.IsRouteUnprotected() != test.unprotected {
t.Errorf("TestRouter route flag unprotected expected to be %t got %t", test.unprotected, routingInfo.IsRouteUnprotected())
}
pr := &httputil.ProxyRequest{
In: r,
Out: r.Clone(context.Background()),
}
routingInfo.Rewrite()(pr)
if pr.Out.URL.Host != test.target {
t.Errorf("TestRouter got host %s expected %s", pr.Out.URL.Host, test.target)
}
}
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/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
}
}
@@ -0,0 +1,59 @@
package debug
import (
"encoding/json"
"net/http"
"github.com/ggwhite/go-masker"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/proxy/pkg/config"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr))
readyHandlerConfiguration := healthHandlerConfiguration.
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint))
var configDumpFunc http.HandlerFunc = configDump(options.Config)
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(handlers.NewCheckHandler(healthHandlerConfiguration)),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
debug.ConfigDump(configDumpFunc),
), nil
}
// 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")
maskedCfg, err := masker.Struct(cfg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
b, err := json.Marshal(maskedCfg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
_, _ = w.Write(b)
}
}
@@ -0,0 +1,87 @@
package http
import (
"context"
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/metrics"
"github.com/justinas/alice"
"github.com/spf13/pflag"
)
// 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 []pflag.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(flags ...pflag.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// 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
}
}
@@ -0,0 +1,61 @@
package http
import (
"fmt"
"os"
pkgcrypto "github.com/qsfera/server/pkg/crypto"
"github.com/qsfera/server/pkg/service/http"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/version"
"go-micro.dev/v4"
)
// Server initializes the http service and server.
func Server(opts ...Option) (http.Service, error) {
options := newOptions(opts...)
l := options.Logger
httpCfg := options.Config.HTTP
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)
}
}
}
chain := options.Middlewares.Then(options.Handler)
service, err := http.NewService(
http.Name(options.Config.Service.Name),
http.Version(version.GetString()),
http.TLSConfig(shared.HTTPServiceTLS{
Enabled: options.Config.HTTP.TLS,
Cert: options.Config.HTTP.TLSCert,
Key: options.Config.HTTP.TLSKey,
}),
http.Logger(options.Logger),
http.Address(options.Config.HTTP.Addr),
http.Namespace(options.Config.HTTP.Namespace),
http.Context(options.Context),
http.Flags(options.Flags...),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
}
if err := micro.RegisterHandler(service.Server(), chain); err != nil {
return http.Service{}, err
}
return service, nil
}
@@ -0,0 +1,178 @@
package staticroutes
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/render"
"github.com/pkg/errors"
"github.com/vmihailenco/msgpack/v5"
microstore "go-micro.dev/v4/store"
bcl "github.com/qsfera/server/services/proxy/pkg/staticroutes/internal/backchannellogout"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// NewRecordKey converts the subject and session to a base64 encoded key
var NewRecordKey = bcl.NewKey
// backchannelLogout handles backchannel logout requests from the identity provider and invalidates the related sessions in the cache
// spec: https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRequest
//
// known side effects of backchannel logout in keycloak:
//
// - keyCloak "Sign out all active sessions" does not send a backchannel logout request,
// as the devs mention, this may lead to thousands of backchannel logout requests,
// therefore, they recommend a short token lifetime.
// https://github.com/keycloak/keycloak/issues/27342#issuecomment-2408461913
//
// - keyCloak user self-service portal, "Sign out all devices" may not send a backchannel
// logout request for each session, it's not mentionex explicitly,
// but maybe the reason for that is the same as for "Sign out all active sessions"
// to prevent a flood of backchannel logout requests.
//
// - if the keycloak setting "Backchannel logout session required" is disabled (or the token has no session id),
// we resolve the session by the subject which can lead to multiple session records (subject.*),
// we then send a logout event (sse) to each connected client and delete our stored cache record (subject.session & claim).
// all sessions besides the one that triggered the backchannel logout continue to exist in the identity provider,
// so the user will not be fully logged out until all sessions are logged out or expired.
// this leads to the situation that web renders the logout view even if the instance is not fully logged out yet.
func (s *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Request) {
logger := s.Logger.SubloggerWithRequestID(r.Context())
if err := r.ParseForm(); err != nil {
logger.Warn().Err(err).Msg("ParseForm failed")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
logoutToken, err := s.OidcClient.VerifyLogoutToken(r.Context(), r.PostFormValue("logout_token"))
if err != nil {
msg := "failed to verify logout token"
logger.Warn().Err(err).Msg(msg)
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
return
}
lookupKey, err := bcl.NewKey(logoutToken.Subject, logoutToken.SessionId)
if err != nil {
msg := "failed to build key from logout token"
logger.Warn().Err(err).Msg(msg)
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
return
}
requestSubjectAndSession, err := bcl.NewSuSe(lookupKey)
if err != nil {
msg := "failed to build subjec.session from lookupKey"
logger.Error().Err(err).Msg(msg)
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
return
}
lookupRecords, err := bcl.GetLogoutRecords(requestSubjectAndSession, s.UserInfoCache)
if errors.Is(err, microstore.ErrNotFound) || len(lookupRecords) == 0 {
render.Status(r, http.StatusOK)
render.JSON(w, r, nil)
return
}
if err != nil {
msg := "failed to read userinfo cache"
logger.Error().Err(err).Msg(msg)
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
return
}
for _, record := range lookupRecords {
// the record key is in the format "subject.session" or ".session"
// the record value is the key of the record that contains the claim in its value
key, value := record.Key, string(record.Value)
subjectSession, err := bcl.NewSuSe(key)
if err != nil {
// never leak any key-related information
logger.Warn().Err(err).Msgf("failed to parse key: %s", key)
continue
}
session, err := subjectSession.Session()
if err != nil {
logger.Warn().Err(err).Msgf("failed to read session for: %s", key)
continue
}
if requestSubjectAndSession.Mode() == bcl.LogoutModeSession {
if err := s.publishBackchannelLogoutEvent(r.Context(), session, value); err != nil {
s.Logger.Warn().Err(err).Msgf("failed to publish backchannel logout event for: %s", key)
continue
}
}
err = s.UserInfoCache.Delete(value)
if err != nil && !errors.Is(err, microstore.ErrNotFound) {
// we have to return a 400 BadRequest when we fail to delete the session
// https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.8
msg := "failed to delete record"
s.Logger.Warn().Err(err).Msgf("%s for: %s", msg, key)
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
return
}
// we can ignore errors when deleting the lookup record
err = s.UserInfoCache.Delete(key)
if err != nil {
logger.Debug().Err(err).Msgf("failed to delete record for: %s", key)
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, nil)
}
// publishBackchannelLogoutEvent publishes a backchannel logout event when the callback revived from the identity provider
func (s *StaticRouteHandler) publishBackchannelLogoutEvent(ctx context.Context, sessionId, claimKey string) error {
if s.EventsPublisher == nil {
return errors.New("events publisher not set")
}
claimRecords, err := s.UserInfoCache.Read(claimKey)
switch {
case err != nil:
return fmt.Errorf("failed to read userinfo cache: %w", err)
case len(claimRecords) == 0:
return fmt.Errorf("no claim found for key: %s", claimKey)
}
var claims map[string]any
if err = msgpack.Unmarshal(claimRecords[0].Value, &claims); err != nil {
return fmt.Errorf("failed to unmarshal claims: %w", err)
}
oidcClaim, ok := claims[s.Config.UserOIDCClaim].(string)
if !ok {
return fmt.Errorf("failed to get claim %w", err)
}
user, _, err := s.UserProvider.GetUserByClaims(ctx, s.Config.UserCS3Claim, oidcClaim)
if err != nil || user.GetId() == nil {
return fmt.Errorf("failed to get user by claims: %w", err)
}
e := events.BackchannelLogout{
Executant: user.GetId(),
SessionId: sessionId,
Timestamp: utils.TSNow(),
}
if err := events.Publish(ctx, s.EventsPublisher, e); err != nil {
return fmt.Errorf("failed to publish user logout event %w", err)
}
return nil
}
@@ -0,0 +1,186 @@
// package backchannellogout provides functions to classify and lookup
// backchannel logout records from the cache store.
package backchannellogout
import (
"encoding/base64"
"errors"
"strings"
microstore "go-micro.dev/v4/store"
)
// keyEncoding is the base64 encoding used for session and subject keys
var keyEncoding = base64.URLEncoding
// ErrInvalidKey indicates that the provided key does not conform to the expected format.
var ErrInvalidKey = errors.New("invalid key format")
// NewKey converts the subject and session to a base64 encoded key
func NewKey(subject, session string) (string, error) {
subjectSession := strings.Join([]string{
keyEncoding.EncodeToString([]byte(subject)),
keyEncoding.EncodeToString([]byte(session)),
}, ".")
if subjectSession == "." {
return "", ErrInvalidKey
}
return subjectSession, nil
}
// LogoutMode defines the mode of backchannel logout, either by session or by subject
type LogoutMode int
const (
// LogoutModeUndefined is used when the logout mode cannot be determined
LogoutModeUndefined LogoutMode = iota
// LogoutModeSubject is used when the logout mode is determined by the subject
LogoutModeSubject
// LogoutModeSession is used when the logout mode is determined by the session id
LogoutModeSession
)
// ErrDecoding is returned when decoding fails
var ErrDecoding = errors.New("failed to decode")
// SuSe 🦎 ;) is a struct that groups the subject and session together
// to prevent mix-ups for ('session, subject' || 'subject, session')
// return values.
type SuSe struct {
encodedSubject string
encodedSession string
}
// Subject decodes and returns the subject or an error
func (suse SuSe) Subject() (string, error) {
subject, err := keyEncoding.DecodeString(suse.encodedSubject)
if err != nil {
return "", errors.Join(errors.New("failed to decode subject"), ErrDecoding, err)
}
return string(subject), nil
}
// Session decodes and returns the session or an error
func (suse SuSe) Session() (string, error) {
subject, err := keyEncoding.DecodeString(suse.encodedSession)
if err != nil {
return "", errors.Join(errors.New("failed to decode session"), ErrDecoding, err)
}
return string(subject), nil
}
// Mode determines the backchannel logout mode based on the presence of subject and session
func (suse SuSe) Mode() LogoutMode {
switch {
case suse.encodedSession == "" && suse.encodedSubject != "":
return LogoutModeSubject
case suse.encodedSession != "":
return LogoutModeSession
default:
return LogoutModeUndefined
}
}
// ErrInvalidSubjectOrSession is returned when the provided key does not match the expected key format
var ErrInvalidSubjectOrSession = errors.New("invalid subject or session")
// NewSuSe parses the subject and session id from the given key and returns a SuSe struct
func NewSuSe(key string) (SuSe, error) {
suse := SuSe{}
keys := strings.Split(key, ".")
switch len(keys) {
case 1:
suse.encodedSession = keys[0]
case 2:
suse.encodedSubject = keys[0]
suse.encodedSession = keys[1]
default:
return suse, ErrInvalidSubjectOrSession
}
if suse.encodedSubject == "" && suse.encodedSession == "" {
return suse, ErrInvalidSubjectOrSession
}
if _, err := suse.Subject(); err != nil {
return suse, errors.Join(ErrInvalidSubjectOrSession, err)
}
if _, err := suse.Session(); err != nil {
return suse, errors.Join(ErrInvalidSubjectOrSession, err)
}
if mode := suse.Mode(); mode == LogoutModeUndefined {
return suse, ErrInvalidSubjectOrSession
}
return suse, nil
}
// ErrSuspiciousCacheResult is returned when the cache result is suspicious
var ErrSuspiciousCacheResult = errors.New("suspicious cache result")
// GetLogoutRecords retrieves the records from the user info cache based on the backchannel
// logout mode and the provided SuSe struct.
// it uses a seperator to prevent sufix and prefix exploration in the cache and checks
// if the retrieved records match the requested subject and or session id as well, to prevent false positives.
func GetLogoutRecords(suse SuSe, store microstore.Store) ([]*microstore.Record, error) {
var key string
var opts []microstore.ReadOption
switch {
case suse.Mode() == LogoutModeSubject && suse.encodedSubject != "":
// the dot at the end prevents prefix exploration in the cache,
// so only keys that start with 'subject.*' will be returned, but not 'sub*'.
key = suse.encodedSubject + "."
opts = append(opts, microstore.ReadPrefix())
case suse.Mode() == LogoutModeSession && suse.encodedSession != "":
// the dot at the beginning prevents sufix exploration in the cache,
// so only keys that end with '*.session' will be returned, but not '*sion'.
key = "." + suse.encodedSession
opts = append(opts, microstore.ReadSuffix())
default:
return nil, errors.Join(errors.New("cannot determine logout mode"), ErrSuspiciousCacheResult)
}
// the go micro memory store requires a limit to work, why???
records, err := store.Read(key, append(opts, microstore.ReadLimit(1000))...)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, microstore.ErrNotFound
}
if suse.Mode() == LogoutModeSession && len(records) > 1 {
return nil, errors.Join(errors.New("multiple session records found"), ErrSuspiciousCacheResult)
}
// double-check if the found records match the requested subject and or session id as well,
// to prevent false positives.
for _, record := range records {
recordSuSe, err := NewSuSe(record.Key)
if err != nil {
// never leak any key-related information
return nil, errors.Join(errors.New("failed to parse key"), ErrSuspiciousCacheResult, err)
}
switch {
// in subject mode, the subject must match, but the session id can be different
case suse.Mode() == LogoutModeSubject && suse.encodedSubject == recordSuSe.encodedSubject:
continue
// in session mode, the session id must match, but the subject can be different
case suse.Mode() == LogoutModeSession && suse.encodedSession == recordSuSe.encodedSession:
continue
}
return nil, errors.Join(errors.New("key does not match the requested subject or session"), ErrSuspiciousCacheResult)
}
return records, nil
}
@@ -0,0 +1,306 @@
package backchannellogout
import (
"slices"
"strings"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go-micro.dev/v4/store"
"github.com/qsfera/server/services/proxy/pkg/staticroutes/internal/backchannellogout/mocks"
)
func mustNewKey(t *testing.T, subject, session string) string {
key, err := NewKey(subject, session)
require.NoError(t, err)
return key
}
func mustNewSuSe(t *testing.T, subject, session string) SuSe {
suse, err := NewSuSe(mustNewKey(t, subject, session))
require.NoError(t, err)
return suse
}
func TestNewKey(t *testing.T) {
tests := []struct {
name string
subject string
session string
wantKey string
wantErr error
}{
{
name: "key variation: 'subject.session'",
subject: "subject",
session: "session",
wantKey: "c3ViamVjdA==.c2Vzc2lvbg==",
},
{
name: "key variation: 'subject.'",
subject: "subject",
wantKey: "c3ViamVjdA==.",
},
{
name: "key variation: '.session'",
session: "session",
wantKey: ".c2Vzc2lvbg==",
},
{
name: "key variation: '.'",
wantErr: ErrInvalidKey,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, err := NewKey(tt.subject, tt.session)
require.ErrorIs(t, err, tt.wantErr)
require.Equal(t, tt.wantKey, key)
})
}
}
func TestNewSuSe(t *testing.T) {
tests := []struct {
name string
key string
wantSubject string
wantSession string
wantMode LogoutMode
wantErr error
}{
{
name: "key variation: '.session'",
key: mustNewKey(t, "", "session"),
wantSession: "session",
wantMode: LogoutModeSession,
},
{
name: "key variation: 'session'",
key: mustNewKey(t, "", "session"),
wantSession: "session",
wantMode: LogoutModeSession,
},
{
name: "key variation: 'subject.'",
key: mustNewKey(t, "subject", ""),
wantSubject: "subject",
wantMode: LogoutModeSubject,
},
{
name: "key variation: 'subject.session'",
key: mustNewKey(t, "subject", "session"),
wantSubject: "subject",
wantSession: "session",
wantMode: LogoutModeSession,
},
{
name: "key variation: 'dot'",
key: ".",
wantErr: ErrInvalidSubjectOrSession,
},
{
name: "key variation: 'empty'",
key: "",
wantErr: ErrInvalidSubjectOrSession,
},
{
name: "key variation: string('subject.session')",
key: "subject.session",
wantErr: ErrInvalidSubjectOrSession,
wantMode: LogoutModeSession,
},
{
name: "key variation: string('subject.')",
key: "subject.",
wantErr: ErrInvalidSubjectOrSession,
wantMode: LogoutModeSubject,
},
{
name: "key variation: string('.session')",
key: ".session",
wantErr: ErrInvalidSubjectOrSession,
wantMode: LogoutModeSession,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suSe, err := NewSuSe(tt.key)
require.ErrorIs(t, err, tt.wantErr)
mode := suSe.Mode()
require.Equal(t, tt.wantMode, mode)
subject, _ := suSe.Subject()
require.Equal(t, tt.wantSubject, subject)
session, _ := suSe.Session()
require.Equal(t, tt.wantSession, session)
})
}
}
func TestGetLogoutRecords(t *testing.T) {
sessionStore := store.NewMemoryStore()
recordClaimA := &store.Record{Key: "claim-a", Value: []byte("claim-a-data")}
recordClaimB := &store.Record{Key: "claim-b", Value: []byte("claim-b-data")}
recordClaimC := &store.Record{Key: "claim-c", Value: []byte("claim-c-data")}
recordClaimD := &store.Record{Key: "claim-d", Value: []byte("claim-d-data")}
recordSessionA := &store.Record{Key: mustNewKey(t, "", "session-a"), Value: []byte(recordClaimA.Key)}
recordSessionB := &store.Record{Key: mustNewKey(t, "", "session-b"), Value: []byte(recordClaimB.Key)}
recordSubjectASessionC := &store.Record{Key: mustNewKey(t, "subject-a", "session-c"), Value: []byte(recordSessionA.Key)}
recordSubjectASessionD := &store.Record{Key: mustNewKey(t, "subject-a", "session-d"), Value: []byte(recordSessionA.Key)}
for _, r := range []*store.Record{
recordClaimA,
recordClaimB,
recordClaimC,
recordClaimD,
recordSessionA,
recordSessionB,
recordSubjectASessionC,
recordSubjectASessionD,
} {
require.NoError(t, sessionStore.Write(r))
}
tests := []struct {
name string
suSe SuSe
store func(t *testing.T) store.Store
wantRecords []*store.Record
wantErrs []error
}{
{
name: "fails if multiple session records are found",
suSe: mustNewSuSe(t, "", "session-a"),
store: func(t *testing.T) store.Store {
s := mocks.NewStore(t)
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
recordSessionA,
recordSessionB,
}, nil)
return s
},
wantRecords: []*store.Record{},
wantErrs: []error{ErrSuspiciousCacheResult}},
{
name: "fails if the record key is not ok",
suSe: mustNewSuSe(t, "", "session-a"),
store: func(t *testing.T) store.Store {
s := mocks.NewStore(t)
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
{Key: "invalid.record.key"},
}, nil)
return s
},
wantRecords: []*store.Record{},
wantErrs: []error{ErrInvalidSubjectOrSession, ErrSuspiciousCacheResult},
},
{
name: "fails if the session does not match the retrieved record",
suSe: mustNewSuSe(t, "", "session-a"),
store: func(t *testing.T) store.Store {
s := mocks.NewStore(t)
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
recordSessionB,
}, nil)
return s
},
wantRecords: []*store.Record{},
wantErrs: []error{ErrSuspiciousCacheResult}},
{
name: "fails if the subject does not match the retrieved record",
suSe: mustNewSuSe(t, "subject-a", ""),
store: func(t *testing.T) store.Store {
s := mocks.NewStore(t)
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
recordSessionB,
}, nil)
return s
},
wantRecords: []*store.Record{},
wantErrs: []error{ErrSuspiciousCacheResult}},
// key variation tests
{
name: "key variation: 'session-a'",
suSe: mustNewSuSe(t, "", "session-a"),
store: func(*testing.T) store.Store {
return sessionStore
},
wantRecords: []*store.Record{recordSessionA},
},
{
name: "key variation: 'session-b'",
suSe: mustNewSuSe(t, "", "session-b"),
store: func(*testing.T) store.Store {
return sessionStore
},
wantRecords: []*store.Record{recordSessionB},
},
{
name: "key variation: 'session-c'",
suSe: mustNewSuSe(t, "", "session-c"),
store: func(*testing.T) store.Store {
return sessionStore
},
wantRecords: []*store.Record{recordSubjectASessionC},
},
{
name: "key variation: 'ession-c'",
suSe: mustNewSuSe(t, "", "ession-c"),
store: func(*testing.T) store.Store {
return sessionStore
},
wantRecords: []*store.Record{},
wantErrs: []error{store.ErrNotFound},
},
{
name: "key variation: 'subject-a'",
suSe: mustNewSuSe(t, "subject-a", ""),
store: func(*testing.T) store.Store {
return sessionStore
},
wantRecords: []*store.Record{recordSubjectASessionC, recordSubjectASessionD},
},
{
name: "key variation: 'subject-'",
suSe: mustNewSuSe(t, "subject-", ""),
store: func(*testing.T) store.Store {
return sessionStore
},
wantRecords: []*store.Record{},
wantErrs: []error{store.ErrNotFound},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
records, err := GetLogoutRecords(tt.suSe, tt.store(t))
for _, wantErr := range tt.wantErrs {
require.ErrorIs(t, err, wantErr)
}
require.Len(t, records, len(tt.wantRecords))
sortRecords := func(r []*store.Record) []*store.Record {
slices.SortFunc(r, func(a, b *store.Record) int {
return strings.Compare(a.Key, b.Key)
})
return r
}
records = sortRecords(records)
for i, wantRecords := range sortRecords(tt.wantRecords) {
require.True(t, len(records) >= i+1)
require.Equal(t, wantRecords.Key, records[i].Key)
require.Equal(t, wantRecords.Value, records[i].Value)
}
})
}
}
@@ -0,0 +1,509 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
mock "github.com/stretchr/testify/mock"
"go-micro.dev/v4/store"
)
// NewStore creates a new instance of Store. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewStore(t interface {
mock.TestingT
Cleanup(func())
}) *Store {
mock := &Store{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Store is an autogenerated mock type for the Store type
type Store struct {
mock.Mock
}
type Store_Expecter struct {
mock *mock.Mock
}
func (_m *Store) EXPECT() *Store_Expecter {
return &Store_Expecter{mock: &_m.Mock}
}
// Close provides a mock function for the type Store
func (_mock *Store) Close() error {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Close")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func() error); ok {
r0 = returnFunc()
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
type Store_Close_Call struct {
*mock.Call
}
// Close is a helper method to define mock.On call
func (_e *Store_Expecter) Close() *Store_Close_Call {
return &Store_Close_Call{Call: _e.mock.On("Close")}
}
func (_c *Store_Close_Call) Run(run func()) *Store_Close_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *Store_Close_Call) Return(err error) *Store_Close_Call {
_c.Call.Return(err)
return _c
}
func (_c *Store_Close_Call) RunAndReturn(run func() error) *Store_Close_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function for the type Store
func (_mock *Store) Delete(key string, opts ...store.DeleteOption) error {
var tmpRet mock.Arguments
if len(opts) > 0 {
tmpRet = _mock.Called(key, opts)
} else {
tmpRet = _mock.Called(key)
}
ret := tmpRet
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string, ...store.DeleteOption) error); ok {
r0 = returnFunc(key, opts...)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type Store_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - key string
// - opts ...store.DeleteOption
func (_e *Store_Expecter) Delete(key interface{}, opts ...interface{}) *Store_Delete_Call {
return &Store_Delete_Call{Call: _e.mock.On("Delete",
append([]interface{}{key}, opts...)...)}
}
func (_c *Store_Delete_Call) Run(run func(key string, opts ...store.DeleteOption)) *Store_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 []store.DeleteOption
var variadicArgs []store.DeleteOption
if len(args) > 1 {
variadicArgs = args[1].([]store.DeleteOption)
}
arg1 = variadicArgs
run(
arg0,
arg1...,
)
})
return _c
}
func (_c *Store_Delete_Call) Return(err error) *Store_Delete_Call {
_c.Call.Return(err)
return _c
}
func (_c *Store_Delete_Call) RunAndReturn(run func(key string, opts ...store.DeleteOption) error) *Store_Delete_Call {
_c.Call.Return(run)
return _c
}
// Init provides a mock function for the type Store
func (_mock *Store) Init(options ...store.Option) error {
var tmpRet mock.Arguments
if len(options) > 0 {
tmpRet = _mock.Called(options)
} else {
tmpRet = _mock.Called()
}
ret := tmpRet
if len(ret) == 0 {
panic("no return value specified for Init")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(...store.Option) error); ok {
r0 = returnFunc(options...)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_Init_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Init'
type Store_Init_Call struct {
*mock.Call
}
// Init is a helper method to define mock.On call
// - options ...store.Option
func (_e *Store_Expecter) Init(options ...interface{}) *Store_Init_Call {
return &Store_Init_Call{Call: _e.mock.On("Init",
append([]interface{}{}, options...)...)}
}
func (_c *Store_Init_Call) Run(run func(options ...store.Option)) *Store_Init_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 []store.Option
var variadicArgs []store.Option
if len(args) > 0 {
variadicArgs = args[0].([]store.Option)
}
arg0 = variadicArgs
run(
arg0...,
)
})
return _c
}
func (_c *Store_Init_Call) Return(err error) *Store_Init_Call {
_c.Call.Return(err)
return _c
}
func (_c *Store_Init_Call) RunAndReturn(run func(options ...store.Option) error) *Store_Init_Call {
_c.Call.Return(run)
return _c
}
// List provides a mock function for the type Store
func (_mock *Store) List(opts ...store.ListOption) ([]string, error) {
var tmpRet mock.Arguments
if len(opts) > 0 {
tmpRet = _mock.Called(opts)
} else {
tmpRet = _mock.Called()
}
ret := tmpRet
if len(ret) == 0 {
panic("no return value specified for List")
}
var r0 []string
var r1 error
if returnFunc, ok := ret.Get(0).(func(...store.ListOption) ([]string, error)); ok {
return returnFunc(opts...)
}
if returnFunc, ok := ret.Get(0).(func(...store.ListOption) []string); ok {
r0 = returnFunc(opts...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if returnFunc, ok := ret.Get(1).(func(...store.ListOption) error); ok {
r1 = returnFunc(opts...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
type Store_List_Call struct {
*mock.Call
}
// List is a helper method to define mock.On call
// - opts ...store.ListOption
func (_e *Store_Expecter) List(opts ...interface{}) *Store_List_Call {
return &Store_List_Call{Call: _e.mock.On("List",
append([]interface{}{}, opts...)...)}
}
func (_c *Store_List_Call) Run(run func(opts ...store.ListOption)) *Store_List_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 []store.ListOption
var variadicArgs []store.ListOption
if len(args) > 0 {
variadicArgs = args[0].([]store.ListOption)
}
arg0 = variadicArgs
run(
arg0...,
)
})
return _c
}
func (_c *Store_List_Call) Return(strings []string, err error) *Store_List_Call {
_c.Call.Return(strings, err)
return _c
}
func (_c *Store_List_Call) RunAndReturn(run func(opts ...store.ListOption) ([]string, error)) *Store_List_Call {
_c.Call.Return(run)
return _c
}
// Options provides a mock function for the type Store
func (_mock *Store) Options() store.Options {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Options")
}
var r0 store.Options
if returnFunc, ok := ret.Get(0).(func() store.Options); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(store.Options)
}
return r0
}
// Store_Options_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Options'
type Store_Options_Call struct {
*mock.Call
}
// Options is a helper method to define mock.On call
func (_e *Store_Expecter) Options() *Store_Options_Call {
return &Store_Options_Call{Call: _e.mock.On("Options")}
}
func (_c *Store_Options_Call) Run(run func()) *Store_Options_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *Store_Options_Call) Return(options store.Options) *Store_Options_Call {
_c.Call.Return(options)
return _c
}
func (_c *Store_Options_Call) RunAndReturn(run func() store.Options) *Store_Options_Call {
_c.Call.Return(run)
return _c
}
// Read provides a mock function for the type Store
func (_mock *Store) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
var tmpRet mock.Arguments
if len(opts) > 0 {
tmpRet = _mock.Called(key, opts)
} else {
tmpRet = _mock.Called(key)
}
ret := tmpRet
if len(ret) == 0 {
panic("no return value specified for Read")
}
var r0 []*store.Record
var r1 error
if returnFunc, ok := ret.Get(0).(func(string, ...store.ReadOption) ([]*store.Record, error)); ok {
return returnFunc(key, opts...)
}
if returnFunc, ok := ret.Get(0).(func(string, ...store.ReadOption) []*store.Record); ok {
r0 = returnFunc(key, opts...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*store.Record)
}
}
if returnFunc, ok := ret.Get(1).(func(string, ...store.ReadOption) error); ok {
r1 = returnFunc(key, opts...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
type Store_Read_Call struct {
*mock.Call
}
// Read is a helper method to define mock.On call
// - key string
// - opts ...store.ReadOption
func (_e *Store_Expecter) Read(key interface{}, opts ...interface{}) *Store_Read_Call {
return &Store_Read_Call{Call: _e.mock.On("Read",
append([]interface{}{key}, opts...)...)}
}
func (_c *Store_Read_Call) Run(run func(key string, opts ...store.ReadOption)) *Store_Read_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 []store.ReadOption
var variadicArgs []store.ReadOption
if len(args) > 1 {
variadicArgs = args[1].([]store.ReadOption)
}
arg1 = variadicArgs
run(
arg0,
arg1...,
)
})
return _c
}
func (_c *Store_Read_Call) Return(records []*store.Record, err error) *Store_Read_Call {
_c.Call.Return(records, err)
return _c
}
func (_c *Store_Read_Call) RunAndReturn(run func(key string, opts ...store.ReadOption) ([]*store.Record, error)) *Store_Read_Call {
_c.Call.Return(run)
return _c
}
// String provides a mock function for the type Store
func (_mock *Store) String() string {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for String")
}
var r0 string
if returnFunc, ok := ret.Get(0).(func() string); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Store_String_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'String'
type Store_String_Call struct {
*mock.Call
}
// String is a helper method to define mock.On call
func (_e *Store_Expecter) String() *Store_String_Call {
return &Store_String_Call{Call: _e.mock.On("String")}
}
func (_c *Store_String_Call) Run(run func()) *Store_String_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *Store_String_Call) Return(s string) *Store_String_Call {
_c.Call.Return(s)
return _c
}
func (_c *Store_String_Call) RunAndReturn(run func() string) *Store_String_Call {
_c.Call.Return(run)
return _c
}
// Write provides a mock function for the type Store
func (_mock *Store) Write(r *store.Record, opts ...store.WriteOption) error {
var tmpRet mock.Arguments
if len(opts) > 0 {
tmpRet = _mock.Called(r, opts)
} else {
tmpRet = _mock.Called(r)
}
ret := tmpRet
if len(ret) == 0 {
panic("no return value specified for Write")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(*store.Record, ...store.WriteOption) error); ok {
r0 = returnFunc(r, opts...)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
type Store_Write_Call struct {
*mock.Call
}
// Write is a helper method to define mock.On call
// - r *store.Record
// - opts ...store.WriteOption
func (_e *Store_Expecter) Write(r interface{}, opts ...interface{}) *Store_Write_Call {
return &Store_Write_Call{Call: _e.mock.On("Write",
append([]interface{}{r}, opts...)...)}
}
func (_c *Store_Write_Call) Run(run func(r *store.Record, opts ...store.WriteOption)) *Store_Write_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *store.Record
if args[0] != nil {
arg0 = args[0].(*store.Record)
}
var arg1 []store.WriteOption
var variadicArgs []store.WriteOption
if len(args) > 1 {
variadicArgs = args[1].([]store.WriteOption)
}
arg1 = variadicArgs
run(
arg0,
arg1...,
)
})
return _c
}
func (_c *Store_Write_Call) Return(err error) *Store_Write_Call {
_c.Call.Return(err)
return _c
}
func (_c *Store_Write_Call) RunAndReturn(run func(r *store.Record, opts ...store.WriteOption) error) *Store_Write_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,51 @@
package staticroutes
import (
"io"
"net/http"
"net/url"
"path"
)
var (
wellKnownPath = "/.well-known/openid-configuration"
)
// OIDCWellKnownRewrite is a handler that rewrites the /.well-known/openid-configuration endpoint for external IDPs.
func (s *StaticRouteHandler) oIDCWellKnownRewrite(issuer string) http.HandlerFunc {
oidcURL, _ := url.Parse(issuer)
oidcURL.Path = path.Join(oidcURL.Path, wellKnownPath)
return func(w http.ResponseWriter, r *http.Request) {
wellKnownRes, err := s.OidcHttpClient.Get(oidcURL.String())
if err != nil {
s.Logger.Error().
Err(err).
Str("handler", "oidc wellknown rewrite").
Str("url", oidcURL.String()).
Msg("get information from url failed")
w.WriteHeader(http.StatusInternalServerError)
return
}
defer wellKnownRes.Body.Close()
copyHeader(w.Header(), wellKnownRes.Header)
w.WriteHeader(wellKnownRes.StatusCode)
_, err = io.Copy(w, wellKnownRes.Body)
if err != nil {
s.Logger.Error().
Err(err).
Str("handler", "oidc wellknown rewrite").
Msg("copying response body failed")
}
}
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
@@ -0,0 +1,53 @@
package staticroutes
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/opencloud-eu/reva/v2/pkg/events"
microstore "go-micro.dev/v4/store"
)
// StaticRouteHandler defines a Route Handler for static routes
type StaticRouteHandler struct {
Prefix string
Proxy http.Handler
UserInfoCache microstore.Store
Logger log.Logger
Config config.Config
OidcClient oidc.OIDCClient
OidcHttpClient *http.Client
EventsPublisher events.Publisher
UserProvider backend.UserBackend
}
type jse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
func (s *StaticRouteHandler) Handler() http.Handler {
m := chi.NewMux()
m.Route(s.Prefix, func(r chi.Router) {
// Wrapper for backchannel logout
r.Post("/backchannel_logout", s.backchannelLogout)
// openid .well-known
if s.Config.OIDC.RewriteWellKnown {
r.Get("/.well-known/openid-configuration", s.oIDCWellKnownRewrite(s.Config.OIDC.Issuer))
}
// Send all requests to the proxy handler
r.HandleFunc("/*", s.Proxy.ServeHTTP)
})
// Also send requests for methods unknown to chi to the proxy handler as well
m.MethodNotAllowed(s.Proxy.ServeHTTP)
return m
}
@@ -0,0 +1,26 @@
package backend
import (
"context"
"errors"
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
)
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) (*cs3.User, string, error)
Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error)
CreateUserFromClaims(ctx context.Context, claims map[string]any) (*cs3.User, error)
UpdateUserIfNeeded(ctx context.Context, user *cs3.User, claims map[string]any) error
SyncGroupMemberships(ctx context.Context, user *cs3.User, claims map[string]any) error
}
@@ -0,0 +1,517 @@
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"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/proxy/pkg/config"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
utils "github.com/opencloud-eu/reva/v2/pkg/utils"
"go-micro.dev/v4/selector"
)
type cs3backend struct {
graphSelector selector.Selector
Options
}
// 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
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
selector selector.Selector
machineAuthAPIKey string
oidcISS string
serviceAccount config.ServiceAccount
autoProvisionClaims config.AutoProvisionClaims
}
var (
errGroupNotFound = errors.New("group not found")
)
// WithLogger sets the logger option
func WithLogger(l log.Logger) Option {
return func(o *Options) {
o.logger = l
}
}
// WithRevaGatewaySelector set the gatewaySelector option
func WithRevaGatewaySelector(selectable pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.gatewaySelector = selectable
}
}
// WithSelector set the Selector option
func WithSelector(selector selector.Selector) Option {
return func(o *Options) {
o.selector = selector
}
}
// WithMachineAuthAPIKey configures the machine auth API key
func WithMachineAuthAPIKey(ma string) Option {
return func(o *Options) {
o.machineAuthAPIKey = ma
}
}
// WithOIDCissuer set the OIDC issuer URL
func WithOIDCissuer(oidcISS string) Option {
return func(o *Options) {
o.oidcISS = oidcISS
}
}
// WithServiceAccount configures the service account creator to use
func WithServiceAccount(c config.ServiceAccount) Option {
return func(o *Options) {
o.serviceAccount = c
}
}
func WithAutoProvisionClaims(claims config.AutoProvisionClaims) Option {
return func(o *Options) {
o.autoProvisionClaims = claims
}
}
// NewCS3UserBackend creates a user-provider which fetches users from a CS3 UserBackend
func NewCS3UserBackend(opts ...Option) UserBackend {
opt := Options{}
for _, o := range opts {
o(&opt)
}
b := cs3backend{
Options: opt,
graphSelector: opt.selector,
}
return &b
}
func (c *cs3backend) GetUserByClaims(ctx context.Context, claim, value string) (*cs3.User, string, error) {
gatewayClient, err := c.gatewaySelector.Next()
if err != nil {
return nil, "", fmt.Errorf("could not obtain gatewayClient: %s", err)
}
res, err := gatewayClient.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 : %s ", claim, value, res.GetStatus().GetMessage())
}
user := res.User
return user, res.GetToken(), nil
}
func (c *cs3backend) Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error) {
gatewayClient, err := c.gatewaySelector.Next()
if err != nil {
return nil, "", fmt.Errorf("could not obtain gatewayClient: %s", err)
}
res, err := gatewayClient.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.GetStatus().GetCode())
}
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]any) (*cs3.User, error) {
gatewayClient, err := c.gatewaySelector.Next()
if err != nil {
c.logger.Error().Err(err).Msg("could not select next gateway client")
return nil, err
}
newctx := context.Background()
authRes, err := gatewayClient.Authenticate(newctx, &gateway.AuthenticateRequest{
Type: "serviceaccounts",
ClientId: c.serviceAccount.ServiceAccountID,
ClientSecret: c.serviceAccount.ServiceAccountSecret,
})
if err != nil {
return nil, err
}
if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
return nil, fmt.Errorf("error authenticating service user: %s", authRes.GetStatus().GetMessage())
}
lgClient, err := c.setupLibregraphClient(newctx, authRes.GetToken())
if err != nil {
c.logger.Error().Err(err).Msg("Error setting up libregraph client.")
return nil, err
}
newUser, err := c.libregraphUserFromClaims(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()
defer resp.Body.Close()
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()
defer resp.Body.Close()
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) UpdateUserIfNeeded(ctx context.Context, user *cs3.User, claims map[string]any) error {
newUser, err := c.libregraphUserFromClaims(claims)
if err != nil {
c.logger.Error().Err(err).Interface("claims", claims).Msg("Error converting claims to user")
return fmt.Errorf("error converting claims to updated user: %w", err)
}
// Check if the user needs to be updated, only updates of "displayName" and "mail" are supported
// currently.
userupdate := libregraph.NewUserUpdate()
switch {
case newUser.GetDisplayName() != user.GetDisplayName():
userupdate.SetDisplayName(newUser.GetDisplayName())
case newUser.GetMail() != user.GetMail():
userupdate.SetMail(newUser.GetMail())
}
if userupdate.HasDisplayName() || userupdate.HasMail() {
return c.updateLibregraphUser(user.GetId().GetOpaqueId(), *userupdate)
}
return nil
}
// SyncGroupMemberships maintains a users group memberships based on an OIDC claim
func (c cs3backend) SyncGroupMemberships(ctx context.Context, user *cs3.User, claims map[string]any) error {
gatewayClient, err := c.gatewaySelector.Next()
if err != nil {
c.logger.Error().Err(err).Msg("could not select next gateway client")
return err
}
newctx := context.Background()
token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret)
if err != nil {
c.logger.Error().Err(err).Msg("Error getting token for service user")
return err
}
lgClient, err := c.setupLibregraphClient(newctx, token)
if err != nil {
c.logger.Error().Err(err).Msg("Error setting up libregraph client")
return err
}
lgUser, resp, err := lgClient.UserApi.GetUser(newctx, user.GetId().GetOpaqueId()).Expand([]string{"memberOf"}).Execute()
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
c.logger.Error().Err(err).Msg("Failed to lookup user via libregraph")
return err
}
currentGroups := lgUser.GetMemberOf()
currentGroupSet := make(map[string]struct{})
for _, group := range currentGroups {
currentGroupSet[group.GetDisplayName()] = struct{}{}
}
newGroupSet := make(map[string]struct{})
if groups, ok := claims[c.autoProvisionClaims.Groups].([]any); ok {
for _, g := range groups {
if group, ok := g.(string); ok {
newGroupSet[group] = struct{}{}
}
}
}
for group := range newGroupSet {
if _, exists := currentGroupSet[group]; !exists {
c.logger.Debug().Str("group", group).Msg("adding user to group")
// Check if group exists
lgGroup, err := c.getLibregraphGroup(newctx, lgClient, group)
switch {
case errors.Is(err, errGroupNotFound):
newGroup := libregraph.Group{}
newGroup.SetDisplayName(group)
req := lgClient.GroupsApi.CreateGroup(newctx).Group(newGroup)
var resp *http.Response
lgGroup, resp, err = req.Execute()
if resp != nil {
defer resp.Body.Close()
}
switch {
case err == nil:
// all good
case resp == nil:
return err
default:
// Ignore error if group already exists
exists, lerr := c.isAlreadyExists(resp)
switch {
case lerr != nil:
c.logger.Error().Err(lerr).Msg("extracting error from ibregraph response body failed.")
return err
case !exists:
c.logger.Error().Err(err).Msg("Failed to create group via libregraph")
return err
default:
// group has been created meanwhile, re-read it to get the group id
lgGroup, err = c.getLibregraphGroup(newctx, lgClient, group)
if err != nil {
return err
}
}
}
case err != nil:
return err
}
memberref := "https://localhost/graph/v1.0/users/" + user.GetId().GetOpaqueId()
resp, err := lgClient.GroupApi.AddMember(newctx, lgGroup.GetId()).MemberReference(
libregraph.MemberReference{
OdataId: &memberref,
},
).Execute()
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
c.logger.Error().Err(err).Msg("Failed to add user to group via libregraph")
}
}
}
for current := range currentGroupSet {
if _, exists := newGroupSet[current]; !exists {
c.logger.Debug().Str("group", current).Msg("deleting user from group")
lgGroup, err := c.getLibregraphGroup(newctx, lgClient, current)
if err != nil {
return err
}
resp, err := lgClient.GroupApi.DeleteMember(newctx, lgGroup.GetId(), user.GetId().GetOpaqueId()).Execute()
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return err
}
}
}
return nil
}
func (c cs3backend) getLibregraphGroup(ctx context.Context, client *libregraph.APIClient, group string) (*libregraph.Group, error) {
lgGroup, resp, err := client.GroupApi.GetGroup(ctx, group).Execute()
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
switch {
case resp == nil:
return nil, err
case resp.StatusCode == http.StatusNotFound:
return nil, errGroupNotFound
case resp.StatusCode != http.StatusOK:
return nil, err
}
}
return lgGroup, nil
}
func (c cs3backend) updateLibregraphUser(userid string, user libregraph.UserUpdate) error {
gatewayClient, err := c.gatewaySelector.Next()
if err != nil {
c.logger.Error().Err(err).Msg("could not select next gateway client")
return err
}
newctx := context.Background()
token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret)
if err != nil {
c.logger.Error().Err(err).Msg("Error getting token for service user")
return err
}
lgClient, err := c.setupLibregraphClient(newctx, token)
if err != nil {
c.logger.Error().Err(err).Msg("Error setting up libregraph client")
return err
}
req := lgClient.UserApi.UpdateUser(newctx, userid).UserUpdate(user)
_, resp, err := req.Execute()
defer resp.Body.Close()
if err != nil {
c.logger.Error().Err(err).Msg("Failed to update user via libregraph")
return err
}
return nil
}
func (c cs3backend) setupLibregraphClient(_ context.Context, cs3token string) (*libregraph.APIClient, error) {
// Use micro registry to resolve next graph service endpoint
next, err := c.graphSelector.Select("qsfera.web.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", 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
}
c.logger.Warn().Str("OData Error", oDataErr.Error.Message).Msg("Error Response")
if oDataErr.Error.Code == errorcode.NameAlreadyExists.String() {
return true, nil
}
return false, nil
}
func (c cs3backend) libregraphUserFromClaims(claims map[string]any) (libregraph.User, error) {
user := libregraph.User{}
if dn, ok := claims[c.autoProvisionClaims.DisplayName].(string); ok {
user.SetDisplayName(dn)
} else {
return user, fmt.Errorf("missing claim '%s' (displayName)", c.autoProvisionClaims.DisplayName)
}
if username, ok := claims[c.autoProvisionClaims.Username].(string); ok {
user.SetOnPremisesSamAccountName(username)
} else {
return user, fmt.Errorf("missing claim '%s' (username)", c.autoProvisionClaims.Username)
}
// Email is optional so we don't need an 'else' here
if mail, ok := claims[c.autoProvisionClaims.Email].(string); ok {
user.SetMail(mail)
}
sub, subExists := claims[oidc.Sub].(string)
iss, issExists := claims[oidc.Iss].(string)
if subExists && issExists {
var objectIdentity libregraph.ObjectIdentity
objectIdentity.SetIssuer(iss)
objectIdentity.SetIssuerAssignedId(sub)
user.Identities = append(user.Identities, objectIdentity)
}
return user, nil
}
func (c cs3backend) cs3UserFromLibregraph(_ 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
}
@@ -0,0 +1,393 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
mock "github.com/stretchr/testify/mock"
)
// NewUserBackend creates a new instance of UserBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewUserBackend(t interface {
mock.TestingT
Cleanup(func())
}) *UserBackend {
mock := &UserBackend{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// UserBackend is an autogenerated mock type for the UserBackend type
type UserBackend struct {
mock.Mock
}
type UserBackend_Expecter struct {
mock *mock.Mock
}
func (_m *UserBackend) EXPECT() *UserBackend_Expecter {
return &UserBackend_Expecter{mock: &_m.Mock}
}
// Authenticate provides a mock function for the type UserBackend
func (_mock *UserBackend) Authenticate(ctx context.Context, username string, password string) (*userv1beta1.User, string, error) {
ret := _mock.Called(ctx, username, password)
if len(ret) == 0 {
panic("no return value specified for Authenticate")
}
var r0 *userv1beta1.User
var r1 string
var r2 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*userv1beta1.User, string, error)); ok {
return returnFunc(ctx, username, password)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *userv1beta1.User); ok {
r0 = returnFunc(ctx, username, password)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*userv1beta1.User)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {
r1 = returnFunc(ctx, username, password)
} else {
r1 = ret.Get(1).(string)
}
if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok {
r2 = returnFunc(ctx, username, password)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// UserBackend_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
type UserBackend_Authenticate_Call struct {
*mock.Call
}
// Authenticate is a helper method to define mock.On call
// - ctx context.Context
// - username string
// - password string
func (_e *UserBackend_Expecter) Authenticate(ctx interface{}, username interface{}, password interface{}) *UserBackend_Authenticate_Call {
return &UserBackend_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, username, password)}
}
func (_c *UserBackend_Authenticate_Call) Run(run func(ctx context.Context, username string, password string)) *UserBackend_Authenticate_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *UserBackend_Authenticate_Call) Return(user *userv1beta1.User, s string, err error) *UserBackend_Authenticate_Call {
_c.Call.Return(user, s, err)
return _c
}
func (_c *UserBackend_Authenticate_Call) RunAndReturn(run func(ctx context.Context, username string, password string) (*userv1beta1.User, string, error)) *UserBackend_Authenticate_Call {
_c.Call.Return(run)
return _c
}
// CreateUserFromClaims provides a mock function for the type UserBackend
func (_mock *UserBackend) CreateUserFromClaims(ctx context.Context, claims map[string]any) (*userv1beta1.User, error) {
ret := _mock.Called(ctx, claims)
if len(ret) == 0 {
panic("no return value specified for CreateUserFromClaims")
}
var r0 *userv1beta1.User
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, map[string]any) (*userv1beta1.User, error)); ok {
return returnFunc(ctx, claims)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, map[string]any) *userv1beta1.User); ok {
r0 = returnFunc(ctx, claims)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*userv1beta1.User)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, map[string]any) error); ok {
r1 = returnFunc(ctx, claims)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UserBackend_CreateUserFromClaims_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUserFromClaims'
type UserBackend_CreateUserFromClaims_Call struct {
*mock.Call
}
// CreateUserFromClaims is a helper method to define mock.On call
// - ctx context.Context
// - claims map[string]any
func (_e *UserBackend_Expecter) CreateUserFromClaims(ctx interface{}, claims interface{}) *UserBackend_CreateUserFromClaims_Call {
return &UserBackend_CreateUserFromClaims_Call{Call: _e.mock.On("CreateUserFromClaims", ctx, claims)}
}
func (_c *UserBackend_CreateUserFromClaims_Call) Run(run func(ctx context.Context, claims map[string]any)) *UserBackend_CreateUserFromClaims_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 map[string]any
if args[1] != nil {
arg1 = args[1].(map[string]any)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *UserBackend_CreateUserFromClaims_Call) Return(user *userv1beta1.User, err error) *UserBackend_CreateUserFromClaims_Call {
_c.Call.Return(user, err)
return _c
}
func (_c *UserBackend_CreateUserFromClaims_Call) RunAndReturn(run func(ctx context.Context, claims map[string]any) (*userv1beta1.User, error)) *UserBackend_CreateUserFromClaims_Call {
_c.Call.Return(run)
return _c
}
// GetUserByClaims provides a mock function for the type UserBackend
func (_mock *UserBackend) GetUserByClaims(ctx context.Context, claim string, value string) (*userv1beta1.User, string, error) {
ret := _mock.Called(ctx, claim, value)
if len(ret) == 0 {
panic("no return value specified for GetUserByClaims")
}
var r0 *userv1beta1.User
var r1 string
var r2 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*userv1beta1.User, string, error)); ok {
return returnFunc(ctx, claim, value)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *userv1beta1.User); ok {
r0 = returnFunc(ctx, claim, value)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*userv1beta1.User)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {
r1 = returnFunc(ctx, claim, value)
} else {
r1 = ret.Get(1).(string)
}
if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok {
r2 = returnFunc(ctx, claim, value)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// UserBackend_GetUserByClaims_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByClaims'
type UserBackend_GetUserByClaims_Call struct {
*mock.Call
}
// GetUserByClaims is a helper method to define mock.On call
// - ctx context.Context
// - claim string
// - value string
func (_e *UserBackend_Expecter) GetUserByClaims(ctx interface{}, claim interface{}, value interface{}) *UserBackend_GetUserByClaims_Call {
return &UserBackend_GetUserByClaims_Call{Call: _e.mock.On("GetUserByClaims", ctx, claim, value)}
}
func (_c *UserBackend_GetUserByClaims_Call) Run(run func(ctx context.Context, claim string, value string)) *UserBackend_GetUserByClaims_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *UserBackend_GetUserByClaims_Call) Return(user *userv1beta1.User, s string, err error) *UserBackend_GetUserByClaims_Call {
_c.Call.Return(user, s, err)
return _c
}
func (_c *UserBackend_GetUserByClaims_Call) RunAndReturn(run func(ctx context.Context, claim string, value string) (*userv1beta1.User, string, error)) *UserBackend_GetUserByClaims_Call {
_c.Call.Return(run)
return _c
}
// SyncGroupMemberships provides a mock function for the type UserBackend
func (_mock *UserBackend) SyncGroupMemberships(ctx context.Context, user *userv1beta1.User, claims map[string]any) error {
ret := _mock.Called(ctx, user, claims)
if len(ret) == 0 {
panic("no return value specified for SyncGroupMemberships")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) error); ok {
r0 = returnFunc(ctx, user, claims)
} else {
r0 = ret.Error(0)
}
return r0
}
// UserBackend_SyncGroupMemberships_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SyncGroupMemberships'
type UserBackend_SyncGroupMemberships_Call struct {
*mock.Call
}
// SyncGroupMemberships is a helper method to define mock.On call
// - ctx context.Context
// - user *userv1beta1.User
// - claims map[string]any
func (_e *UserBackend_Expecter) SyncGroupMemberships(ctx interface{}, user interface{}, claims interface{}) *UserBackend_SyncGroupMemberships_Call {
return &UserBackend_SyncGroupMemberships_Call{Call: _e.mock.On("SyncGroupMemberships", ctx, user, claims)}
}
func (_c *UserBackend_SyncGroupMemberships_Call) Run(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any)) *UserBackend_SyncGroupMemberships_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *userv1beta1.User
if args[1] != nil {
arg1 = args[1].(*userv1beta1.User)
}
var arg2 map[string]any
if args[2] != nil {
arg2 = args[2].(map[string]any)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *UserBackend_SyncGroupMemberships_Call) Return(err error) *UserBackend_SyncGroupMemberships_Call {
_c.Call.Return(err)
return _c
}
func (_c *UserBackend_SyncGroupMemberships_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any) error) *UserBackend_SyncGroupMemberships_Call {
_c.Call.Return(run)
return _c
}
// UpdateUserIfNeeded provides a mock function for the type UserBackend
func (_mock *UserBackend) UpdateUserIfNeeded(ctx context.Context, user *userv1beta1.User, claims map[string]any) error {
ret := _mock.Called(ctx, user, claims)
if len(ret) == 0 {
panic("no return value specified for UpdateUserIfNeeded")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) error); ok {
r0 = returnFunc(ctx, user, claims)
} else {
r0 = ret.Error(0)
}
return r0
}
// UserBackend_UpdateUserIfNeeded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUserIfNeeded'
type UserBackend_UpdateUserIfNeeded_Call struct {
*mock.Call
}
// UpdateUserIfNeeded is a helper method to define mock.On call
// - ctx context.Context
// - user *userv1beta1.User
// - claims map[string]any
func (_e *UserBackend_Expecter) UpdateUserIfNeeded(ctx interface{}, user interface{}, claims interface{}) *UserBackend_UpdateUserIfNeeded_Call {
return &UserBackend_UpdateUserIfNeeded_Call{Call: _e.mock.On("UpdateUserIfNeeded", ctx, user, claims)}
}
func (_c *UserBackend_UpdateUserIfNeeded_Call) Run(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any)) *UserBackend_UpdateUserIfNeeded_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *userv1beta1.User
if args[1] != nil {
arg1 = args[1].(*userv1beta1.User)
}
var arg2 map[string]any
if args[2] != nil {
arg2 = args[2].(map[string]any)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *UserBackend_UpdateUserIfNeeded_Call) Return(err error) *UserBackend_UpdateUserIfNeeded_Call {
_c.Call.Return(err)
return _c
}
func (_c *UserBackend_UpdateUserIfNeeded_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any) error) *UserBackend_UpdateUserIfNeeded_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,81 @@
package userroles
import (
"context"
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"go-micro.dev/v4/metadata"
)
type defaultRoleAssigner struct {
Options
}
// NewDefaultRoleAssigner returns an implementation of the UserRoleAssigner interface
func NewDefaultRoleAssigner(opts ...Option) UserRoleAssigner {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return defaultRoleAssigner{
Options: opt,
}
}
// UpdateUserRoleAssignment assigns the role "User" to the supplied user. Unless the user
// already has a different role assigned.
func (d defaultRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]any) (*cs3.User, error) {
var roleIDs []string
if user.Id.Type != cs3.UserType_USER_TYPE_LIGHTWEIGHT {
var err error
roleIDs, err = loadRolesIDs(ctx, user.Id.OpaqueId, d.roleService)
if err != nil {
d.logger.Error().Err(err).Msg("Could not load roles")
return nil, err
}
if len(roleIDs) == 0 {
// 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/issues/1825 for more context.
if user.Id.Type == cs3.UserType_USER_TYPE_PRIMARY || user.Id.Type == cs3.UserType_USER_TYPE_GUEST {
roleId := defaults.BundleUUIDRoleUser
if user.Id.Type == cs3.UserType_USER_TYPE_GUEST {
roleId = defaults.BundleUUIDRoleGuest
}
d.logger.Info().Str("userid", user.Id.OpaqueId).Msg("user has no role assigned, assigning default user role")
ctx = metadata.Set(ctx, middleware.AccountID, user.Id.OpaqueId)
_, err := d.roleService.AssignRoleToUser(ctx, &settingssvc.AssignRoleToUserRequest{
AccountUuid: user.Id.OpaqueId,
RoleId: roleId,
})
if err != nil {
d.logger.Error().Err(err).Msg("Could not add default role")
return nil, err
}
roleIDs = append(roleIDs, roleId)
}
}
}
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDs)
return user, nil
}
// ApplyUserRole it looks up the user's role in the settings service and adds it
// user's opaque data
func (d defaultRoleAssigner) ApplyUserRole(ctx context.Context, user *cs3.User) (*cs3.User, error) {
roleIDs, err := loadRolesIDs(ctx, user.Id.OpaqueId, d.roleService)
if err != nil {
d.logger.Error().Err(err).Msg("Could not load roles")
return nil, err
}
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDs)
return user, nil
}
@@ -0,0 +1,181 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
mock "github.com/stretchr/testify/mock"
)
// NewUserRoleAssigner creates a new instance of UserRoleAssigner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewUserRoleAssigner(t interface {
mock.TestingT
Cleanup(func())
}) *UserRoleAssigner {
mock := &UserRoleAssigner{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// UserRoleAssigner is an autogenerated mock type for the UserRoleAssigner type
type UserRoleAssigner struct {
mock.Mock
}
type UserRoleAssigner_Expecter struct {
mock *mock.Mock
}
func (_m *UserRoleAssigner) EXPECT() *UserRoleAssigner_Expecter {
return &UserRoleAssigner_Expecter{mock: &_m.Mock}
}
// ApplyUserRole provides a mock function for the type UserRoleAssigner
func (_mock *UserRoleAssigner) ApplyUserRole(ctx context.Context, user *userv1beta1.User) (*userv1beta1.User, error) {
ret := _mock.Called(ctx, user)
if len(ret) == 0 {
panic("no return value specified for ApplyUserRole")
}
var r0 *userv1beta1.User
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User) (*userv1beta1.User, error)); ok {
return returnFunc(ctx, user)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User) *userv1beta1.User); ok {
r0 = returnFunc(ctx, user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*userv1beta1.User)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *userv1beta1.User) error); ok {
r1 = returnFunc(ctx, user)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UserRoleAssigner_ApplyUserRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyUserRole'
type UserRoleAssigner_ApplyUserRole_Call struct {
*mock.Call
}
// ApplyUserRole is a helper method to define mock.On call
// - ctx context.Context
// - user *userv1beta1.User
func (_e *UserRoleAssigner_Expecter) ApplyUserRole(ctx interface{}, user interface{}) *UserRoleAssigner_ApplyUserRole_Call {
return &UserRoleAssigner_ApplyUserRole_Call{Call: _e.mock.On("ApplyUserRole", ctx, user)}
}
func (_c *UserRoleAssigner_ApplyUserRole_Call) Run(run func(ctx context.Context, user *userv1beta1.User)) *UserRoleAssigner_ApplyUserRole_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *userv1beta1.User
if args[1] != nil {
arg1 = args[1].(*userv1beta1.User)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *UserRoleAssigner_ApplyUserRole_Call) Return(user1 *userv1beta1.User, err error) *UserRoleAssigner_ApplyUserRole_Call {
_c.Call.Return(user1, err)
return _c
}
func (_c *UserRoleAssigner_ApplyUserRole_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User) (*userv1beta1.User, error)) *UserRoleAssigner_ApplyUserRole_Call {
_c.Call.Return(run)
return _c
}
// UpdateUserRoleAssignment provides a mock function for the type UserRoleAssigner
func (_mock *UserRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *userv1beta1.User, claims map[string]any) (*userv1beta1.User, error) {
ret := _mock.Called(ctx, user, claims)
if len(ret) == 0 {
panic("no return value specified for UpdateUserRoleAssignment")
}
var r0 *userv1beta1.User
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) (*userv1beta1.User, error)); ok {
return returnFunc(ctx, user, claims)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) *userv1beta1.User); ok {
r0 = returnFunc(ctx, user, claims)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*userv1beta1.User)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *userv1beta1.User, map[string]any) error); ok {
r1 = returnFunc(ctx, user, claims)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UserRoleAssigner_UpdateUserRoleAssignment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUserRoleAssignment'
type UserRoleAssigner_UpdateUserRoleAssignment_Call struct {
*mock.Call
}
// UpdateUserRoleAssignment is a helper method to define mock.On call
// - ctx context.Context
// - user *userv1beta1.User
// - claims map[string]any
func (_e *UserRoleAssigner_Expecter) UpdateUserRoleAssignment(ctx interface{}, user interface{}, claims interface{}) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
return &UserRoleAssigner_UpdateUserRoleAssignment_Call{Call: _e.mock.On("UpdateUserRoleAssignment", ctx, user, claims)}
}
func (_c *UserRoleAssigner_UpdateUserRoleAssignment_Call) Run(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any)) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *userv1beta1.User
if args[1] != nil {
arg1 = args[1].(*userv1beta1.User)
}
var arg2 map[string]any
if args[2] != nil {
arg2 = args[2].(map[string]any)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *UserRoleAssigner_UpdateUserRoleAssignment_Call) Return(user1 *userv1beta1.User, err error) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
_c.Call.Return(user1, err)
return _c
}
func (_c *UserRoleAssigner_UpdateUserRoleAssignment_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any) (*userv1beta1.User, error)) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,224 @@
package userroles
import (
"context"
"errors"
"sync"
"time"
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/middleware"
"github.com/qsfera/server/pkg/oidc"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"go-micro.dev/v4/metadata"
)
type oidcRoleAssigner struct {
Options
}
// NewOIDCRoleAssigner returns an implementation of the UserRoleAssigner interface
func NewOIDCRoleAssigner(opts ...Option) UserRoleAssigner {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return oidcRoleAssigner{
Options: opt,
}
}
func extractRoles(rolesClaim string, claims map[string]any) (map[string]struct{}, error) {
claimRoles := map[string]struct{}{}
// happy path
value, _ := claims[rolesClaim].(string)
if value != "" {
claimRoles[value] = struct{}{}
return claimRoles, nil
}
claim, err := oidc.WalkSegments(oidc.SplitWithEscaping(rolesClaim, ".", "\\"), claims)
if err != nil {
return nil, err
}
switch v := claim.(type) {
case []string:
for _, cr := range v {
claimRoles[cr] = struct{}{}
}
case []any:
for _, cri := range v {
cr, ok := cri.(string)
if !ok {
err := errors.New("invalid role in claims")
return nil, err
}
claimRoles[cr] = struct{}{}
}
case string:
claimRoles[v] = struct{}{}
default:
return nil, errors.New("no roles in user claims")
}
return claimRoles, nil
}
// UpdateUserRoleAssignment assigns the role "User" to the supplied user. Unless the user
// already has a different role assigned.
func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]any) (*cs3.User, error) {
logger := ra.logger.SubloggerWithRequestID(ctx).With().Str("userid", user.GetId().GetOpaqueId()).Logger()
roleNamesToRoleIDs, err := ra.roleNamesToRoleIDs()
if err != nil {
logger.Error().Err(err).Msg("Error mapping role names to role ids")
return nil, err
}
claimRoles, err := extractRoles(ra.rolesClaim, claims)
if err != nil {
logger.Error().Err(err).Msg("Error mapping role names to role ids")
return nil, err
}
if len(claimRoles) == 0 {
err := errors.New("no roles set in claim")
logger.Error().Err(err).Msg("")
return nil, err
}
// the roleMapping config is supposed to have the role mappings ordered from the highest privileged role
// down to the lowest privileged role. Since КуСфера currently only can handle a single role assignment we
// pick the highest privileged role that matches a value from the claims
roleIDFromClaim := ""
for _, mapping := range ra.Options.roleMapping {
if _, ok := claimRoles[mapping.ClaimValue]; ok {
logger.Debug().Str("qsferaRole", mapping.RoleName).Str("role id", roleNamesToRoleIDs[mapping.RoleName]).Msg("first matching role")
roleIDFromClaim = roleNamesToRoleIDs[mapping.RoleName]
break
}
}
if roleIDFromClaim == "" {
err := errors.New("no role in claim maps to an КуСфера role")
logger.Error().Err(err).Msg("")
return nil, err
}
assignedRoles, err := loadRolesIDs(ctx, user.GetId().GetOpaqueId(), ra.roleService)
if err != nil {
logger.Error().Err(err).Msg("Could not load roles")
return nil, err
}
if len(assignedRoles) > 1 {
logger.Error().Str("userID", user.GetId().GetOpaqueId()).Int("numRoles", len(assignedRoles)).Msg("The user has too many roles assigned")
}
logger.Debug().Interface("assignedRoleIds", assignedRoles).Msg("Currently assigned roles")
if len(assignedRoles) != 1 || (assignedRoles[0] != roleIDFromClaim) {
logger.Debug().Interface("assignedRoleIds", assignedRoles).Interface("newRoleId", roleIDFromClaim).Msg("Updating role assignment for user")
newctx, err := ra.prepareAdminContext()
if err != nil {
logger.Error().Err(err).Msg("Error creating admin context")
return nil, err
}
if _, err = ra.roleService.AssignRoleToUser(newctx, &settingssvc.AssignRoleToUserRequest{
AccountUuid: user.GetId().GetOpaqueId(),
RoleId: roleIDFromClaim,
}); err != nil {
logger.Error().Err(err).Msg("Role assignment failed")
return nil, err
}
}
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", []string{roleIDFromClaim})
return user, nil
}
// ApplyUserRole it looks up the user's role in the settings service and adds it
// user's opaque data
func (ra oidcRoleAssigner) ApplyUserRole(ctx context.Context, user *cs3.User) (*cs3.User, error) {
roleIDs, err := loadRolesIDs(ctx, user.Id.OpaqueId, ra.roleService)
if err != nil {
ra.logger.Error().Err(err).Msg("Could not load roles")
return nil, err
}
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDs)
return user, nil
}
func (ra oidcRoleAssigner) prepareAdminContext() (context.Context, error) {
gatewayClient, err := ra.gatewaySelector.Next()
if err != nil {
ra.logger.Error().Err(err).Msg("could not select next gateway client")
return nil, err
}
newctx, err := utils.GetServiceUserContext(ra.serviceAccount.ServiceAccountID, gatewayClient, ra.serviceAccount.ServiceAccountSecret)
if err != nil {
ra.logger.Error().Err(err).Msg("Error preparing request context for provisioning role assignments.")
return nil, err
}
newctx = metadata.Set(newctx, middleware.AccountID, ra.serviceAccount.ServiceAccountID)
return newctx, nil
}
type roleNameToIDCache struct {
roleNameToID map[string]string
lastRead time.Time
lock sync.RWMutex
}
var roleNameToID roleNameToIDCache
func (ra oidcRoleAssigner) roleNamesToRoleIDs() (map[string]string, error) {
cacheTTL := 5 * time.Minute
roleNameToID.lock.RLock()
if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
defer roleNameToID.lock.RUnlock()
return roleNameToID.roleNameToID, nil
}
ra.logger.Debug().Msg("refreshing roles ids")
// cache needs Refresh get a write lock
roleNameToID.lock.RUnlock()
roleNameToID.lock.Lock()
defer roleNameToID.lock.Unlock()
// check again, another goroutine might have updated while we "upgraded" the lock
if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
return roleNameToID.roleNameToID, nil
}
// Get all roles to find the role IDs.
// To list roles we need some elevated access to the settings service
// prepare a new request context for that until we have service accounts
ctx, err := ra.prepareAdminContext()
if err != nil {
ra.logger.Error().Err(err).Msg("Error creating admin context")
return nil, err
}
req := &settingssvc.ListBundlesRequest{}
res, err := ra.roleService.ListRoles(ctx, req)
if err != nil {
ra.logger.Error().Err(err).Msg("Failed to list all roles")
return map[string]string{}, err
}
newIDs := map[string]string{}
for _, role := range res.Bundles {
ra.logger.Debug().Str("role", role.Name).Str("id", role.Id).Msg("Got Role")
newIDs[role.Name] = role.Id
}
ra.logger.Debug().Interface("roleMap", newIDs).Msg("Role Name to role ID map")
roleNameToID.roleNameToID = newIDs
roleNameToID.lastRead = time.Now()
return roleNameToID.roleNameToID, nil
}
@@ -0,0 +1,120 @@
package userroles
import (
"encoding/json"
"testing"
)
func TestExtractRolesArray(t *testing.T) {
byt := []byte(`{"roles":["a","b"]}`)
claims := map[string]any{}
err := json.Unmarshal(byt, &claims)
if err != nil {
t.Fatal(err)
}
roles, err := extractRoles("roles", claims)
if err != nil {
t.Fatal(err)
}
if _, ok := roles["a"]; !ok {
t.Fatal("must contain 'a'")
}
if _, ok := roles["b"]; !ok {
t.Fatal("must contain 'b'")
}
}
func TestExtractRolesString(t *testing.T) {
byt := []byte(`{"roles":"a"}`)
claims := map[string]any{}
err := json.Unmarshal(byt, &claims)
if err != nil {
t.Fatal(err)
}
roles, err := extractRoles("roles", claims)
if err != nil {
t.Fatal(err)
}
if _, ok := roles["a"]; !ok {
t.Fatal("must contain 'a'")
}
}
func TestExtractRolesPathArray(t *testing.T) {
byt := []byte(`{"sub":{"roles":["a","b"]}}`)
claims := map[string]any{}
err := json.Unmarshal(byt, &claims)
if err != nil {
t.Fatal(err)
}
roles, err := extractRoles("sub.roles", claims)
if err != nil {
t.Fatal(err)
}
if _, ok := roles["a"]; !ok {
t.Fatal("must contain 'a'")
}
if _, ok := roles["b"]; !ok {
t.Fatal("must contain 'b'")
}
}
func TestExtractRolesPathString(t *testing.T) {
byt := []byte(`{"sub":{"roles":"a"}}`)
claims := map[string]any{}
err := json.Unmarshal(byt, &claims)
if err != nil {
t.Fatal(err)
}
roles, err := extractRoles("sub.roles", claims)
if err != nil {
t.Fatal(err)
}
if _, ok := roles["a"]; !ok {
t.Fatal("must contain 'a'")
}
}
func TestExtractEscapedRolesPathString(t *testing.T) {
byt := []byte(`{"sub.roles":"a"}`)
claims := map[string]any{}
err := json.Unmarshal(byt, &claims)
if err != nil {
t.Fatal(err)
}
roles, err := extractRoles("sub\\.roles", claims)
if err != nil {
t.Fatal(err)
}
if _, ok := roles["a"]; !ok {
t.Fatal("must contain 'a'")
}
}
func TestNoRoles(t *testing.T) {
byt := []byte(`{"sub":{"foo":"a"}}`)
claims := map[string]any{}
err := json.Unmarshal(byt, &claims)
if err != nil {
t.Fatal(err)
}
roles, err := extractRoles("sub.roles", claims)
if err == nil {
t.Fatal("must not find a role")
}
if len(roles) != 0 {
t.Fatal("length of roles mut be 0")
}
}
@@ -0,0 +1,96 @@
package userroles
import (
"context"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// UserRoleAssigner allows providing different implementations for how users get their default roles
// assigned by the proxy during authentication
type UserRoleAssigner interface {
// UpdateUserRoleAssignment is called by the account resolver middleware. It updates the user's role assignment
// based on the user's (OIDC) claims. It adds the user's roles to the opaque data of the cs3.User struct
UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]any) (*cs3.User, error)
// ApplyUserRole can be called by proxy middlewares, it looks up the user's roles and adds them
// the users "roles" key in the user's opaque data
ApplyUserRole(ctx context.Context, user *cs3.User) (*cs3.User, error)
}
// Options defines the available options for this package.
type Options struct {
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
roleService settingssvc.RoleService
rolesClaim string
roleMapping []config.RoleMapping
serviceAccount config.ServiceAccount
logger log.Logger
}
// Option defines a single option function.
type Option func(o *Options)
// WithLogger configure the logger
func WithLogger(l log.Logger) Option {
return func(o *Options) {
o.logger = l
}
}
// WithRoleService sets the roleservice instance to use
func WithRoleService(rs settingssvc.RoleService) Option {
return func(o *Options) {
o.roleService = rs
}
}
// WithRolesClaim sets the OIDC claim for looking up role names
func WithRolesClaim(claim string) Option {
return func(o *Options) {
o.rolesClaim = claim
}
}
// WithRoleMapping configures the map of КуСфера role names to claims values
func WithRoleMapping(roleMap []config.RoleMapping) Option {
return func(o *Options) {
o.roleMapping = roleMap
}
}
// WithRevaGatewaySelector set the gatewaySelector option
func WithRevaGatewaySelector(selectable pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.gatewaySelector = selectable
}
}
// WithServiceAccount configures the service account creator to use
func WithServiceAccount(c config.ServiceAccount) Option {
return func(o *Options) {
o.serviceAccount = c
}
}
// 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
}
@@ -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)
}
@@ -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
}