diff --git a/services/proxy/pkg/command/server.go b/services/proxy/pkg/command/server.go index 2debb13dd2..4626d9f2a5 100644 --- a/services/proxy/pkg/command/server.go +++ b/services/proxy/pkg/command/server.go @@ -3,8 +3,10 @@ package command import ( "context" "crypto/tls" + "crypto/x509" "fmt" "net/http" + "os" "os/signal" "time" @@ -276,6 +278,28 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config, Timeout: time.Second * 10, } + backendTLSConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: cfg.InsecureBackends, //nolint:gosec + } + if cfg.BackendHTTPSCACert != "" { + certs := x509.NewCertPool() + pemData, err := os.ReadFile(cfg.BackendHTTPSCACert) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to read backend HTTPS CA certificate") + } + if !certs.AppendCertsFromPEM(pemData) { + logger.Fatal().Msg("Failed to append backend HTTPS CA certificate") + } + backendTLSConfig.RootCAs = certs + } + backendHTTPClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: backendTLSConfig, + }, + Timeout: time.Second * 10, + } + var authenticators []middleware.Authenticator if cfg.EnableBasicAuth { logger.Warn().Msg("basic auth enabled, use only for testing or development") @@ -363,6 +387,11 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config, middleware.TraceProvider(traceProvider), middleware.UserProvider(userProvider), middleware.UserRoleAssigner(roleAssigner), + middleware.HTTPClient(oidcHTTPClient), + middleware.BackendHTTPClient(backendHTTPClient), + middleware.OIDCIss(cfg.OIDC.Issuer), + middleware.ServiceSelector(serviceSelector), + middleware.OIDCProfilePicture(cfg.OIDCProfilePicture), middleware.SkipUserInfo(cfg.OIDC.SkipUserInfo), middleware.UserOIDCClaim(cfg.UserOIDCClaim), middleware.UserCS3Claim(cfg.UserCS3Claim), diff --git a/services/proxy/pkg/config/config.go b/services/proxy/pkg/config/config.go index cdaa3e81e7..f3df0f10f7 100644 --- a/services/proxy/pkg/config/config.go +++ b/services/proxy/pkg/config/config.go @@ -165,6 +165,7 @@ 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"` + ProfilePicture string `yaml:"profile_picture" env:"PROXY_AUTOPROVISION_CLAIM_PROFILE_PICTURE" desc:"The name of the OIDC claim that holds the profile picture URL. When set, the profile picture will be synced on login."` Groups string `yaml:"groups" env:"PROXY_AUTOPROVISION_CLAIM_GROUPS" desc:"The name of the OIDC claim that holds the groups." introductionVersion:"1.0.0"` } diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index 187e52be6f..8e2c7a7d53 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -90,6 +90,7 @@ func DefaultConfig() *config.Config { Username: "preferred_username", Email: "email", DisplayName: "name", + ProfilePicture: "", Groups: "groups", }, EnableBasicAuth: false, diff --git a/services/proxy/pkg/middleware/account_resolver.go b/services/proxy/pkg/middleware/account_resolver.go index 04b57b6105..fdb5db7957 100644 --- a/services/proxy/pkg/middleware/account_resolver.go +++ b/services/proxy/pkg/middleware/account_resolver.go @@ -1,10 +1,14 @@ package middleware import ( + "bytes" "context" "errors" "fmt" + "io" "net/http" + "net/url" + "strings" "time" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -14,6 +18,7 @@ import ( "github.com/opencloud-eu/opencloud/services/proxy/pkg/router" "github.com/opencloud-eu/opencloud/services/proxy/pkg/user/backend" "github.com/opencloud-eu/opencloud/services/proxy/pkg/userroles" + "go-micro.dev/v4/selector" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -27,6 +32,11 @@ import ( "github.com/opencloud-eu/reva/v2/pkg/utils" ) +const ( + graphServiceName = "eu.opencloud.web.graph" + maxProfilePhotoBytes = 10 << 20 +) + // 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 { @@ -46,42 +56,61 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl ) go tenantIDCache.Start() + httpClient := options.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + backendHTTPClient := options.BackendHTTPClient + if backendHTTPClient == nil { + backendHTTPClient = &http.Client{Timeout: 10 * time.Second} + } + 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, + 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, + profilePictureClaim: options.AutoProvisionClaims.ProfilePicture, + httpClient: httpClient, + backendHTTPClient: backendHTTPClient, + oidcIssuer: options.OIDCIss, + serviceSelector: options.ServiceSelector, } } } 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 + 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] + serviceSelector selector.Selector + serviceAccount config.ServiceAccount + userOIDCClaim string + userCS3Claim string + tenantOIDCClaim string + profilePictureClaim string + oidcIssuer string + httpClient *http.Client + backendHTTPClient *http.Client // 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. @@ -126,6 +155,159 @@ func readStringClaim(path string, claims map[string]any) (string, error) { return value, fmt.Errorf("claim path '%s' not set or empty", path) } +func (m accountResolver) syncProfilePicture(ctx context.Context, req *http.Request, user *cs3user.User, token string, claims map[string]any) error { + if user == nil { + return errors.New("missing user for profile photo sync") + } + if token == "" { + return errors.New("missing user token for profile photo sync") + } + + pictureURL, err := readStringClaim(m.profilePictureClaim, claims) + if err != nil { + m.logger.Debug().Err(err).Str("claim", m.profilePictureClaim).Msg("profile picture claim missing") + return nil + } + if pictureURL == "" { + return nil + } + + parsedURL, err := url.Parse(pictureURL) + if err != nil { + return fmt.Errorf("invalid profile picture URL: %w", err) + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("unsupported profile picture URL scheme: %s", parsedURL.Scheme) + } + if parsedURL.Host == "" { + return fmt.Errorf("profile picture URL is missing a host") + } + + authHeader := "" + if req != nil { + authHeader = req.Header.Get("Authorization") + } + + photo, err := m.fetchProfilePicture(ctx, parsedURL, authHeader) + if err != nil { + return err + } + + return m.updateGraphProfilePhoto(ctx, token, photo) +} + +func (m accountResolver) fetchProfilePicture(ctx context.Context, pictureURL *url.URL, authHeader string) ([]byte, error) { + client := m.httpClient + if client == nil { + client = http.DefaultClient + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, pictureURL.String(), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "image/*") + if authHeader != "" && m.shouldAttachOIDCToken(pictureURL) { + request.Header.Set("Authorization", authHeader) + } + + resp, err := client.Do(request) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("profile picture request returned %s", resp.Status) + } + + limited := io.LimitReader(resp.Body, int64(maxProfilePhotoBytes)+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, errors.New("profile picture response was empty") + } + if len(data) > maxProfilePhotoBytes { + return nil, fmt.Errorf("profile picture exceeds %d bytes", maxProfilePhotoBytes) + } + contentType := http.DetectContentType(data) + if !strings.HasPrefix(contentType, "image/") { + return nil, fmt.Errorf("unsupported profile picture content type: %s", contentType) + } + + return data, nil +} + +func (m accountResolver) shouldAttachOIDCToken(pictureURL *url.URL) bool { + if m.oidcIssuer == "" || pictureURL == nil { + return false + } + issuerURL, err := url.Parse(m.oidcIssuer) + if err != nil || issuerURL.Host == "" { + return false + } + return strings.EqualFold(issuerURL.Host, pictureURL.Host) +} + +func (m accountResolver) updateGraphProfilePhoto(ctx context.Context, token string, photo []byte) error { + if token == "" { + return errors.New("missing access token for graph profile photo update") + } + baseURL, err := m.graphBaseURL() + if err != nil { + return err + } + + endpoint := baseURL + "/v1.0/me/photo/$value" + request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(photo)) + if err != nil { + return err + } + request.Header.Set(revactx.TokenHeader, token) + request.Header.Set("Content-Type", http.DetectContentType(photo)) + + client := m.backendHTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(request) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("graph profile photo update failed: %s (%s)", resp.Status, strings.TrimSpace(string(body))) + } + + return nil +} + +func (m accountResolver) graphBaseURL() (string, error) { + if m.serviceSelector == nil { + return "", errors.New("service selector not configured") + } + selectNext, err := m.serviceSelector.Select(graphServiceName) + if err != nil { + return "", err + } + node, err := selectNext() + if err != nil { + return "", err + } + scheme := node.Metadata["protocol"] + if node.Metadata["use_tls"] == "true" { + scheme = "https" + } + if scheme == "" { + scheme = "http" + } + return fmt.Sprintf("%s://%s/graph", scheme, node.Address), nil +} + // 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)) @@ -219,6 +401,12 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) { } } + if m.profilePictureClaim != "" && oidc.NewSessionFlagFromContext(ctx) { + if err := m.syncProfilePicture(ctx, req, user, token, claims); err != nil { + m.logger.Warn().Err(err).Str("userid", user.GetId().GetOpaqueId()).Msg("Failed to sync profile picture from OIDC claim") + } + } + // resolve the user's roles user, err = m.userRoleAssigner.UpdateUserRoleAssignment(ctx, user, claims) if err != nil { diff --git a/services/proxy/pkg/middleware/options.go b/services/proxy/pkg/middleware/options.go index 7e57d13ba2..2910fcb20e 100644 --- a/services/proxy/pkg/middleware/options.go +++ b/services/proxy/pkg/middleware/options.go @@ -14,6 +14,7 @@ import ( "github.com/opencloud-eu/opencloud/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/selector" "go-micro.dev/v4/store" "go.opentelemetry.io/otel/trace" ) @@ -29,6 +30,8 @@ type Options struct { PolicySelector config.PolicySelector // HTTPClient to use for communication with the oidcAuth provider HTTPClient *http.Client + // BackendHTTPClient to use for internal backend HTTP calls + BackendHTTPClient *http.Client // UserProvider backend to use for resolving User UserProvider backend.UserBackend // UserRoleAssigner to user for assign a users default role @@ -43,10 +46,14 @@ type Options struct { OIDCIss string // RevaGatewaySelector to send requests to the reva gateway RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient] + // ServiceSelector to resolve internal HTTP services + ServiceSelector selector.Selector // PreSignedURLConfig to configure the middleware PreSignedURLConfig config.PreSignedURL // UserOIDCClaim to read from the oidc claims UserOIDCClaim string + // OIDCProfilePicture config for syncing profile pictures from OIDC claims + OIDCProfilePicture config.OIDCProfilePicture // 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. @@ -116,6 +123,13 @@ func HTTPClient(c *http.Client) Option { } } +// BackendHTTPClient provides a function to set the backend http client config option. +func BackendHTTPClient(c *http.Client) Option { + return func(o *Options) { + o.BackendHTTPClient = c + } +} + // SettingsRoleService provides a function to set the role service option. func SettingsRoleService(rc settingssvc.RoleService) Option { return func(o *Options) { @@ -158,6 +172,13 @@ func WithRevaGatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Opti } } +// ServiceSelector provides a function to set the internal service selector option. +func ServiceSelector(val selector.Selector) Option { + return func(o *Options) { + o.ServiceSelector = val + } +} + // PreSignedURLConfig provides a function to set the PreSignedURL config func PreSignedURLConfig(cfg config.PreSignedURL) Option { return func(o *Options) { @@ -172,6 +193,13 @@ func UserOIDCClaim(val string) Option { } } +// OIDCProfilePicture provides a function to set the OIDC profile picture config +func OIDCProfilePicture(val config.OIDCProfilePicture) Option { + return func(o *Options) { + o.OIDCProfilePicture = val + } +} + // UserCS3Claim provides a function to set the UserClaimType config func UserCS3Claim(val string) Option { return func(o *Options) {