diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go index 51571c4607..df569b8a0e 100644 --- a/services/graph/pkg/config/config.go +++ b/services/graph/pkg/config/config.go @@ -25,13 +25,14 @@ type Config struct { TokenManager *TokenManager `yaml:"token_manager"` GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` - Application Application `yaml:"application"` - Spaces Spaces `yaml:"spaces"` - Identity Identity `yaml:"identity"` - IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"` - Events Events `yaml:"events"` - UnifiedRoles UnifiedRoles `yaml:"unified_roles"` - MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"` + Application Application `yaml:"application"` + Spaces Spaces `yaml:"spaces"` + Identity Identity `yaml:"identity"` + OIDCProfilePicture OIDCProfilePicture `yaml:"oidc_profile_picture"` + IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"` + Events Events `yaml:"events"` + UnifiedRoles UnifiedRoles `yaml:"unified_roles"` + MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"` Keycloak Keycloak `yaml:"keycloak"` ServiceAccount ServiceAccount `yaml:"service_account"` @@ -116,6 +117,11 @@ type Identity struct { LDAP LDAP `yaml:"ldap"` } +type OIDCProfilePicture struct { + OIDCIssuer string `yaml:"oidc_issuer" env:"OC_URL;OC_OIDC_ISSUER;GRAPH_OIDC_ISSUER" desc:"URL of the OIDC issuer used to derive the default profile-picture URL allowlist when no explicit allowlist is configured." introductionVersion:"6.3.0"` + URLAllowlist []string `yaml:"url_allowlist" env:"GRAPH_OIDC_PROFILE_PICTURE_URL_ALLOWLIST" desc:"A comma separated allowlist of URL patterns accepted for profile-picture sync events. Patterns can be full URLs with glob support in the host (for example 'https://*.example.com') or '*' to allow all URLs. If empty, only the OIDC issuer host is allowed by default." introductionVersion:"6.3.0"` +} + // API represents API configuration parameters. type API struct { GroupMembersPatchLimit int `yaml:"group_members_patch_limit" env:"GRAPH_GROUP_MEMBERS_PATCH_LIMIT" desc:"The amount of group members allowed to be added with a single patch request." introductionVersion:"1.0.0"` diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go index 9b5352f6ea..8f2afa49a2 100644 --- a/services/graph/pkg/config/defaults/defaultconfig.go +++ b/services/graph/pkg/config/defaults/defaultconfig.go @@ -110,6 +110,10 @@ func DefaultConfig() *config.Config { EducationResourcesEnabled: false, }, }, + OIDCProfilePicture: config.OIDCProfilePicture{ + OIDCIssuer: "https://localhost:9200", + URLAllowlist: []string{}, + }, Cache: &config.Cache{ Store: "memory", Nodes: []string{"127.0.0.1:9233"}, @@ -191,6 +195,10 @@ func EnsureDefaults(cfg *config.Config) { cfg.Metadata.SystemUserID = cfg.Commons.SystemUserID } + if cfg.OIDCProfilePicture.OIDCIssuer == "" && cfg.Commons != nil && cfg.Commons.OpenCloudURL != "" { + cfg.OIDCProfilePicture.OIDCIssuer = cfg.Commons.OpenCloudURL + } + } // Sanitize sanitized the configuration diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index c6ef4fa74c..a48f0f255d 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -62,6 +62,7 @@ type Graph struct { permissionsService Permissions valueService settingssvc.ValueService specialDriveItemsCache *ttlcache.Cache[string, any] + userProfilePhotoService UsersUserProfilePhotoProvider eventsPublisher events.Publisher eventsConsumer events.Consumer searchService searchsvc.SearchProviderService @@ -69,6 +70,7 @@ type Graph struct { historyClient ehsvc.EventHistoryService traceProvider trace.TracerProvider natskv jetstream.KeyValue + profilePictureHTTPClient HTTPClient } // ServeHTTP implements the Service interface. diff --git a/services/graph/pkg/service/v0/option.go b/services/graph/pkg/service/v0/option.go index 5330fd5783..88c7b11807 100644 --- a/services/graph/pkg/service/v0/option.go +++ b/services/graph/pkg/service/v0/option.go @@ -28,6 +28,7 @@ type Options struct { Context context.Context Logger log.Logger Config *config.Config + ProfilePictureHTTPClient HTTPClient Middleware []func(http.Handler) http.Handler RequireAdminMiddleware func(http.Handler) http.Handler GatewaySelector pool.Selectable[gateway.GatewayAPIClient] @@ -47,6 +48,13 @@ type Options struct { NatsKeyValue jetstream.KeyValue } +// ProfilePictureHTTPClient provides a function to set the HTTP client used for downloading OIDC profile pictures. +func ProfilePictureHTTPClient(val HTTPClient) Option { + return func(o *Options) { + o.ProfilePictureHTTPClient = val + } +} + // newOptions initializes the available default options. func newOptions(opts ...Option) Options { opt := Options{} diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 6c9880ba28..c5b59b369b 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -1,19 +1,25 @@ package svc import ( + "bytes" "context" "crypto/tls" "crypto/x509" "errors" "fmt" + "io" "net/http" + "net/url" "os" + "path" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ldapv3 "github.com/go-ldap/ldap/v3" + "github.com/gobwas/glob" "github.com/jellydator/ttlcache/v3" "github.com/opencloud-eu/opencloud/services/graph/pkg/identity/cache" "github.com/riandyrn/otelchi" @@ -39,8 +45,9 @@ import ( const ( // HeaderPurge defines the header name for the purge header. - HeaderPurge = "Purge" - displayNameAttr = "displayName" + HeaderPurge = "Purge" + displayNameAttr = "displayName" + maxProfilePhotoBytes = 10 << 20 ) // Service defines the service handlers. @@ -191,6 +198,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx BaseGraphService: baseGraphService, mux: m, specialDriveItemsCache: spacePropertiesCache, + userProfilePhotoService: options.UserProfilePhotoService, eventsPublisher: options.EventsPublisher, eventsConsumer: options.EventsConsumer, searchService: options.SearchService, @@ -200,6 +208,10 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx traceProvider: options.TraceProvider, valueService: options.ValueService, natskv: options.NatsKeyValue, + profilePictureHTTPClient: options.ProfilePictureHTTPClient, + } + if svc.profilePictureHTTPClient == nil { + svc.profilePictureHTTPClient = http.DefaultClient } if err := setIdentityBackends(options, &svc); err != nil { @@ -578,6 +590,11 @@ func (g *Graph) StartListenForLogonEvents(ctx context.Context, l log.Logger) err if err := g.identityBackend.UpdateLastSignInDate(ctx, ev.Executant.OpaqueId, utils.TSToTime(ev.Timestamp)); err != nil { l.Error().Err(err).Str("userid", ev.Executant.OpaqueId).Msg("Error updating last sign in date") } + if ev.PictureURL != "" { + if err := g.syncProfilePictureFromURL(ctx, ev.Executant.GetOpaqueId(), ev.PictureURL); err != nil { + l.Warn().Err(err).Str("userid", ev.Executant.GetOpaqueId()).Msg("Failed to sync profile picture from OIDC claim") + } + } } case <-ctx.Done(): l.Info().Msg("context cancelled") @@ -588,6 +605,125 @@ func (g *Graph) StartListenForLogonEvents(ctx context.Context, l log.Logger) err return nil } +func (g *Graph) syncProfilePictureFromURL(ctx context.Context, userID, rawURL string) error { + if g.userProfilePhotoService == nil { + return errors.New("profile photo service not configured") + } + if userID == "" { + return errors.New("missing user id for profile picture sync") + } + if !g.isProfilePictureURLAllowed(rawURL) { + return fmt.Errorf("profile picture URL not allowed: %s", rawURL) + } + + data, err := g.fetchProfilePicture(ctx, rawURL) + if err != nil { + return err + } + + return g.userProfilePhotoService.UpdatePhoto(ctx, userID, bytes.NewReader(data)) +} + +func (g *Graph) fetchProfilePicture(ctx context.Context, rawURL string) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "image/*") + + resp, err := g.profilePictureHTTPClient.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 (g *Graph) isProfilePictureURLAllowed(rawURL string) bool { + parsedURL, err := url.Parse(rawURL) + if err != nil || parsedURL.Host == "" { + return false + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return false + } + + for _, pattern := range g.profilePictureAllowlistPatterns() { + if pattern == "*" { + return true + } + if urlPatternMatches(pattern, parsedURL) { + return true + } + } + return false +} + +func (g *Graph) profilePictureAllowlistPatterns() []string { + if len(g.config.OIDCProfilePicture.URLAllowlist) > 0 { + return g.config.OIDCProfilePicture.URLAllowlist + } + issuerURL, err := url.Parse(g.config.OIDCProfilePicture.OIDCIssuer) + if err != nil || issuerURL.Host == "" { + return nil + } + return []string{fmt.Sprintf("%s://%s", issuerURL.Scheme, issuerURL.Host)} +} + +func urlPatternMatches(pattern string, target *url.URL) bool { + if target == nil { + return false + } + parsedPattern, err := url.Parse(pattern) + if err == nil && parsedPattern.Host != "" { + if parsedPattern.Scheme != "" && !strings.EqualFold(parsedPattern.Scheme, target.Scheme) { + return false + } + hostMatcher, err := glob.Compile(strings.ToLower(parsedPattern.Host)) + if err != nil { + return false + } + if !hostMatcher.Match(strings.ToLower(target.Host)) { + return false + } + if parsedPattern.Path == "" || parsedPattern.Path == "/" { + return true + } + if strings.HasSuffix(parsedPattern.Path, "*") { + prefix := strings.TrimSuffix(parsedPattern.Path, "*") + return strings.HasPrefix(target.Path, prefix) + } + return path.Clean(parsedPattern.Path) == path.Clean(target.Path) + } + + hostMatcher, err := glob.Compile(strings.ToLower(pattern)) + if err != nil { + return false + } + return hostMatcher.Match(strings.ToLower(target.Host)) +} + // parseHeaderPurge parses the 'Purge' header. // '1', 't', 'T', 'TRUE', 'true', 'True' are parsed as true // all other values are false. diff --git a/services/proxy/pkg/command/server.go b/services/proxy/pkg/command/server.go index 2debb13dd2..50dca09c4a 100644 --- a/services/proxy/pkg/command/server.go +++ b/services/proxy/pkg/command/server.go @@ -367,6 +367,7 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config, middleware.UserOIDCClaim(cfg.UserOIDCClaim), middleware.UserCS3Claim(cfg.UserCS3Claim), middleware.TenantOIDCClaim(cfg.TenantOIDCClaim), + middleware.AutoProvisionClaims(cfg.AutoProvisionClaims), middleware.TenantIDMappingEnabled(cfg.TenantIDMappingEnabled), middleware.ServiceAccount(cfg.ServiceAccount), middleware.WithRevaGatewaySelector(gatewaySelector), 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..4b54b2e465 100644 --- a/services/proxy/pkg/middleware/account_resolver.go +++ b/services/proxy/pkg/middleware/account_resolver.go @@ -55,6 +55,7 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl userOIDCClaim: options.UserOIDCClaim, userCS3Claim: options.UserCS3Claim, tenantOIDCClaim: options.TenantOIDCClaim, + autoProvisionClaims: options.AutoProvisionClaims, tenantIDMappingEnabled: options.TenantIDMappingEnabled, gatewaySelector: options.RevaGatewaySelector, serviceAccount: options.ServiceAccount, @@ -82,6 +83,7 @@ type accountResolver struct { userOIDCClaim string userCS3Claim string tenantOIDCClaim string + autoProvisionClaims config.AutoProvisionClaims // 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 +128,18 @@ 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) readProfilePictureURL(claims map[string]any) string { + if m.autoProvisionClaims.ProfilePicture == "" { + return "" + } + pictureURL, err := readStringClaim(m.autoProvisionClaims.ProfilePicture, claims) + if err != nil { + m.logger.Debug().Err(err).Str("claim", m.autoProvisionClaims.ProfilePicture).Msg("profile picture claim missing") + return "" + } + return pictureURL +} + // 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)) @@ -229,9 +243,11 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) { // If this is a new session, publish user login event if newSession := oidc.NewSessionFlagFromContext(ctx); newSession && m.eventsPublisher != nil { + pictureURL := m.readProfilePictureURL(claims) event := events.UserSignedIn{ - Executant: user.Id, - Timestamp: utils.TimeToTS(time.Now()), + Executant: user.Id, + PictureURL: pictureURL, + 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.") diff --git a/services/proxy/pkg/middleware/options.go b/services/proxy/pkg/middleware/options.go index 7e57d13ba2..f74de68b30 100644 --- a/services/proxy/pkg/middleware/options.go +++ b/services/proxy/pkg/middleware/options.go @@ -52,6 +52,8 @@ type Options struct { // 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 + // AutoProvisionClaims to read the user info from the oidc claims + AutoProvisionClaims config.AutoProvisionClaims // AutoprovisionAccounts when an accountResolver does not exist. AutoprovisionAccounts bool // EnableBasicAuth to allow basic auth @@ -80,8 +82,8 @@ type Options struct { // 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 + ServiceAccount config.ServiceAccount + EventsPublisher events.Publisher } // newOptions initializes the available default options. @@ -186,6 +188,13 @@ func TenantOIDCClaim(val string) Option { } } +// AutoProvisionClaims provides a function to set the AutoProvisionClaims config +func AutoProvisionClaims(cfg config.AutoProvisionClaims) Option { + return func(o *Options) { + o.AutoProvisionClaims = cfg + } +} + // AutoprovisionAccounts provides a function to set the AutoprovisionAccounts config func AutoprovisionAccounts(val bool) Option { return func(o *Options) { diff --git a/vendor/github.com/opencloud-eu/reva/v2/pkg/events/users.go b/vendor/github.com/opencloud-eu/reva/v2/pkg/events/users.go index 2d7bc812fb..4f1ba8fb3e 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/pkg/events/users.go +++ b/vendor/github.com/opencloud-eu/reva/v2/pkg/events/users.go @@ -122,8 +122,9 @@ func (BackchannelLogout) Unmarshal(v []byte) (interface{}, error) { // UserSignedIn is emitted when a user signs in type UserSignedIn struct { - Executant *user.UserId - Timestamp *types.Timestamp + Executant *user.UserId + PictureURL string `json:",omitempty"` + Timestamp *types.Timestamp } // Unmarshal to fulfill umarshaller interface