From 4e4a1641eb7d2b5c3fa26ea456c29b4831ce81d1 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Tue, 1 Mar 2022 13:27:18 +0100 Subject: [PATCH] Merged oidcmapping auth manager into oidc --- changelog/unreleased/unify-oidc.md | 7 + .../packages/auth/manager/oidc/_index.md | 28 +- examples/oidc-mapping-tpc/oidcmapping-1.toml | 6 +- examples/oidc-mapping-tpc/oidcmapping-2.toml | 6 +- pkg/app/provider/wopi/wopi.go | 1 + pkg/auth/manager/loader/loader.go | 1 - pkg/auth/manager/oidc/oidc.go | 195 +++++++--- pkg/auth/manager/oidcmapping/oidcmapping.go | 360 ------------------ 8 files changed, 185 insertions(+), 419 deletions(-) create mode 100644 changelog/unreleased/unify-oidc.md delete mode 100644 pkg/auth/manager/oidcmapping/oidcmapping.go diff --git a/changelog/unreleased/unify-oidc.md b/changelog/unreleased/unify-oidc.md new file mode 100644 index 0000000000..182bfbab6b --- /dev/null +++ b/changelog/unreleased/unify-oidc.md @@ -0,0 +1,7 @@ +Change: Merge oidcmapping auth manager into oidc + +The oidcmapping auth manager was created as a separate package to ease testing. As it has now been tested +also as a pure OIDC auth provider without mapping, and as the code is largely refactored, it makes +sense to merge it back so to maintain a single OIDC manager. + +https://github.com/cs3org/reva/pull/2561 diff --git a/docs/content/en/docs/config/packages/auth/manager/oidc/_index.md b/docs/content/en/docs/config/packages/auth/manager/oidc/_index.md index 6abc8039f1..758a8a14af 100644 --- a/docs/content/en/docs/config/packages/auth/manager/oidc/_index.md +++ b/docs/content/en/docs/config/packages/auth/manager/oidc/_index.md @@ -9,7 +9,7 @@ description: > # _struct: config_ {{% dir name="insecure" type="bool" default=false %}} -Whether to skip certificate checks when sending requests. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L55) +Whether to skip certificate checks when sending requests. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L61) {{< highlight toml >}} [auth.manager.oidc] insecure = false @@ -17,7 +17,7 @@ insecure = false {{% /dir %}} {{% dir name="issuer" type="string" default="" %}} -The issuer of the OIDC token. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L56) +The issuer of the OIDC token. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L62) {{< highlight toml >}} [auth.manager.oidc] issuer = "" @@ -25,7 +25,7 @@ issuer = "" {{% /dir %}} {{% dir name="id_claim" type="string" default="sub" %}} -The claim containing the ID of the user. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L57) +The claim containing the ID of the user. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L63) {{< highlight toml >}} [auth.manager.oidc] id_claim = "sub" @@ -33,7 +33,7 @@ id_claim = "sub" {{% /dir %}} {{% dir name="uid_claim" type="string" default="" %}} -The claim containing the UID of the user. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L58) +The claim containing the UID of the user. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L64) {{< highlight toml >}} [auth.manager.oidc] uid_claim = "" @@ -41,7 +41,7 @@ uid_claim = "" {{% /dir %}} {{% dir name="gid_claim" type="string" default="" %}} -The claim containing the GID of the user. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L59) +The claim containing the GID of the user. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L65) {{< highlight toml >}} [auth.manager.oidc] gid_claim = "" @@ -49,10 +49,26 @@ gid_claim = "" {{% /dir %}} {{% dir name="gatewaysvc" type="string" default="" %}} -The endpoint at which the GRPC gateway is exposed. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L60) +The endpoint at which the GRPC gateway is exposed. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L66) {{< highlight toml >}} [auth.manager.oidc] gatewaysvc = "" {{< /highlight >}} {{% /dir %}} +{{% dir name="users_mapping" type="string" default="" %}} + The optional OIDC users mapping file path [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L67) +{{< highlight toml >}} +[auth.manager.oidc] +users_mapping = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="group_claim" type="string" default="" %}} + The group claim to be looked up to map the user (default to 'groups'). [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/auth/manager/oidc/oidc.go#L68) +{{< highlight toml >}} +[auth.manager.oidc] +group_claim = "" +{{< /highlight >}} +{{% /dir %}} + diff --git a/examples/oidc-mapping-tpc/oidcmapping-1.toml b/examples/oidc-mapping-tpc/oidcmapping-1.toml index 166c96880f..008416a9c2 100644 --- a/examples/oidc-mapping-tpc/oidcmapping-1.toml +++ b/examples/oidc-mapping-tpc/oidcmapping-1.toml @@ -8,16 +8,16 @@ jwt_secret = "Pive-Fumkiu4" address = "0.0.0.0:13000" [grpc.services.authprovider] -auth_manager = "oidcmapping" +auth_manager = "oidc" [grpc.services.authprovider.auth_managers.json] users = "users.json" -[grpc.services.authprovider.auth_managers.oidcmapping] +[grpc.services.authprovider.auth_managers.oidc] gatewaysvc = "localhost:19000" issuer = "https://iam-escape.cloud.cnaf.infn.it/" # ESCAPE adopted the WLCG groups as group claims group_claim = "wlcg.groups" # The OIDC users mapping file path -users_mapping = "users-oidcmapping-1.demo.json" +users_mapping = "users-oidc-1.demo.json" # If your local identity provider service configuration includes further claims, # please configure them also here #uid_claim = "" diff --git a/examples/oidc-mapping-tpc/oidcmapping-2.toml b/examples/oidc-mapping-tpc/oidcmapping-2.toml index 51eb894eed..d9bf633f36 100644 --- a/examples/oidc-mapping-tpc/oidcmapping-2.toml +++ b/examples/oidc-mapping-tpc/oidcmapping-2.toml @@ -8,16 +8,16 @@ jwt_secret = "Pive-Fumkiu4" address = "0.0.0.0:14000" [grpc.services.authprovider] -auth_manager = "oidcmapping" +auth_manager = "oidc" [grpc.services.authprovider.auth_managers.json] users = "users.json" -[grpc.services.authprovider.auth_managers.oidcmapping] +[grpc.services.authprovider.auth_managers.oidc] gatewaysvc = "localhost:17000" issuer = "https://iam-escape.cloud.cnaf.infn.it/" # ESCAPE adopted the WLCG groups as group claims group_claim = "wlcg.groups" # The OIDC users mapping file path -users_mapping = "users-oidcmapping-2.demo.json" +users_mapping = "users-oidc-2.demo.json" # If your local identity provider service configuration includes further claims, # please configure them also here #uid_claim = "" diff --git a/pkg/app/provider/wopi/wopi.go b/pkg/app/provider/wopi/wopi.go index c508384758..9916b32d65 100644 --- a/pkg/app/provider/wopi/wopi.go +++ b/pkg/app/provider/wopi/wopi.go @@ -333,6 +333,7 @@ func getAppURLs(c *config) (map[string]map[string]string, error) { } // register the supported mimetypes in the AppRegistry: this is hardcoded for the time being + // TODO(lopresti) move to config switch c.AppName { case "CodiMD": appURLs = getCodimdExtensions(c.AppURL) diff --git a/pkg/auth/manager/loader/loader.go b/pkg/auth/manager/loader/loader.go index bc162229d6..69862bc144 100644 --- a/pkg/auth/manager/loader/loader.go +++ b/pkg/auth/manager/loader/loader.go @@ -28,7 +28,6 @@ import ( _ "github.com/cs3org/reva/pkg/auth/manager/machine" _ "github.com/cs3org/reva/pkg/auth/manager/nextcloud" _ "github.com/cs3org/reva/pkg/auth/manager/oidc" - _ "github.com/cs3org/reva/pkg/auth/manager/oidcmapping" _ "github.com/cs3org/reva/pkg/auth/manager/owncloudsql" _ "github.com/cs3org/reva/pkg/auth/manager/publicshares" // Add your own here diff --git a/pkg/auth/manager/oidc/oidc.go b/pkg/auth/manager/oidc/oidc.go index ac5e86d638..0a510ef2fe 100644 --- a/pkg/auth/manager/oidc/oidc.go +++ b/pkg/auth/manager/oidc/oidc.go @@ -22,7 +22,9 @@ package oidc import ( "context" + "encoding/json" "fmt" + "io/ioutil" "strings" "time" @@ -30,16 +32,18 @@ import ( authpb "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1" user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + "github.com/cs3org/reva/pkg/appctx" "github.com/cs3org/reva/pkg/auth" "github.com/cs3org/reva/pkg/auth/manager/registry" "github.com/cs3org/reva/pkg/auth/scope" + "github.com/cs3org/reva/pkg/errtypes" "github.com/cs3org/reva/pkg/rgrpc/status" "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/pkg/rhttp" "github.com/cs3org/reva/pkg/sharedconf" + "github.com/juliangruber/go-intersect" "github.com/mitchellh/mapstructure" "github.com/pkg/errors" - "github.com/rs/zerolog/log" "golang.org/x/oauth2" ) @@ -48,17 +52,26 @@ func init() { } type mgr struct { - provider *oidc.Provider // cached on first request - c *config + provider *oidc.Provider // cached on first request + c *config + oidcUsersMapping map[string]*oidcUserMapping } type config struct { - Insecure bool `mapstructure:"insecure" docs:"false;Whether to skip certificate checks when sending requests."` - Issuer string `mapstructure:"issuer" docs:";The issuer of the OIDC token."` - IDClaim string `mapstructure:"id_claim" docs:"sub;The claim containing the ID of the user."` - UIDClaim string `mapstructure:"uid_claim" docs:";The claim containing the UID of the user."` - GIDClaim string `mapstructure:"gid_claim" docs:";The claim containing the GID of the user."` - GatewaySvc string `mapstructure:"gatewaysvc" docs:";The endpoint at which the GRPC gateway is exposed."` + Insecure bool `mapstructure:"insecure" docs:"false;Whether to skip certificate checks when sending requests."` + Issuer string `mapstructure:"issuer" docs:";The issuer of the OIDC token."` + IDClaim string `mapstructure:"id_claim" docs:"sub;The claim containing the ID of the user."` + UIDClaim string `mapstructure:"uid_claim" docs:";The claim containing the UID of the user."` + GIDClaim string `mapstructure:"gid_claim" docs:";The claim containing the GID of the user."` + GatewaySvc string `mapstructure:"gatewaysvc" docs:";The endpoint at which the GRPC gateway is exposed."` + UsersMapping string `mapstructure:"users_mapping" docs:"; The optional OIDC users mapping file path"` + GroupClaim string `mapstructure:"group_claim" docs:"; The group claim to be looked up to map the user (default to 'groups')."` +} + +type oidcUserMapping struct { + OIDCIssuer string `mapstructure:"oidc_issuer" json:"oidc_issuer"` + OIDCGroup string `mapstructure:"oidc_group" json:"oidc_group"` + Username string `mapstructure:"username" json:"username"` } func (c *config) init() { @@ -66,6 +79,9 @@ func (c *config) init() { // sub is stable and defined as unique. the user manager needs to take care of the sub to user metadata lookup c.IDClaim = "sub" } + if c.GroupClaim == "" { + c.GroupClaim = "groups" + } c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) } @@ -96,38 +112,67 @@ func (am *mgr) Configure(m map[string]interface{}) error { } c.init() am.c = c + + am.oidcUsersMapping = map[string]*oidcUserMapping{} + if c.UsersMapping == "" { + // no mapping defined, leave the map empty and move on + return nil + } + + f, err := ioutil.ReadFile(c.UsersMapping) + if err != nil { + return fmt.Errorf("oidc: error reading the users mapping file: +%v", err) + } + oidcUsers := []*oidcUserMapping{} + err = json.Unmarshal(f, &oidcUsers) + if err != nil { + return fmt.Errorf("oidc: error unmarshalling the users mapping file: +%v", err) + } + for _, u := range oidcUsers { + if _, found := am.oidcUsersMapping[u.OIDCGroup]; found { + return fmt.Errorf("oidc: mapping error, group \"%s\" is mapped to multiple users", u.OIDCGroup) + } + am.oidcUsersMapping[u.OIDCGroup] = u + } + return nil } -// the clientID it would be empty as we only need to validate the clientSecret variable +// The clientID would be empty as we only need to validate the clientSecret variable // which contains the access token that we can use to contact the UserInfo endpoint // and get the user claims. func (am *mgr) Authenticate(ctx context.Context, clientID, clientSecret string) (*user.User, map[string]*authpb.Scope, error) { ctx = am.getOAuthCtx(ctx) + log := appctx.GetLogger(ctx) oidcProvider, err := am.getOIDCProvider(ctx) if err != nil { - return nil, nil, fmt.Errorf("error creating oidc provider: +%v", err) + return nil, nil, fmt.Errorf("oidc: error creating oidc provider: +%v", err) } oauth2Token := &oauth2.Token{ AccessToken: clientSecret, } + + // query the oidc provider for user info userInfo, err := oidcProvider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) if err != nil { return nil, nil, fmt.Errorf("oidc: error getting userinfo: +%v", err) } - // claims contains the standard OIDC claims like issuer, iat, aud, ... and any other non-standard one. + // claims contains the standard OIDC claims like iss, iat, aud, ... and any other non-standard one. // TODO(labkode): make claims configuration dynamic from the config file so we can add arbitrary mappings from claims to user struct. + // For now, only the group claim is dynamic. + // TODO(labkode): may do like K8s does it: https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go var claims map[string]interface{} if err := userInfo.Claims(&claims); err != nil { return nil, nil, fmt.Errorf("oidc: error unmarshaling userinfo claims: %v", err) } + log.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Msg("unmarshalled userinfo") - if claims["issuer"] == nil { // This is not set in simplesamlphp - claims["issuer"] = am.c.Issuer + if claims["iss"] == nil { // This is not set in simplesamlphp + claims["iss"] = am.c.Issuer } if claims["email_verified"] == nil { // This is not set in simplesamlphp claims["email_verified"] = false @@ -135,39 +180,30 @@ func (am *mgr) Authenticate(ctx context.Context, clientID, clientSecret string) if claims["preferred_username"] == nil { claims["preferred_username"] = claims[am.c.IDClaim] } + if claims["preferred_username"] == nil { + claims["preferred_username"] = claims["email"] + } if claims["name"] == nil { claims["name"] = claims[am.c.IDClaim] } - - if claims["email"] == nil { - return nil, nil, fmt.Errorf("no \"email\" attribute found in userinfo: maybe the client did not request the oidc \"email\"-scope") - } - - userClaim := "preferred_username" - if claims["preferred_username"] == nil { - if claims["email"] != nil { - userClaim = "email" - } else { - return nil, nil, fmt.Errorf("no \"preferred_username\" and \"email\" attribute found in userinfo: maybe the client did not request the oidc \"profile\"-scope") - } - } if claims["name"] == nil { return nil, nil, fmt.Errorf("no \"name\" attribute found in userinfo: maybe the client did not request the oidc \"profile\"-scope") } - - var uid, gid float64 - if am.c.UIDClaim != "" { - uid, _ = claims[am.c.UIDClaim].(float64) + if claims["email"] == nil { + return nil, nil, fmt.Errorf("no \"email\" attribute found in userinfo: maybe the client did not request the oidc \"email\"-scope") } - if am.c.GIDClaim != "" { - gid, _ = claims[am.c.GIDClaim].(float64) + + err = am.resolveUser(ctx, claims) + if err != nil { + return nil, nil, errors.Wrapf(err, "oidc: error resolving username for external user '%v'", claims["email"]) } userID := &user.UserId{ OpaqueId: claims[am.c.IDClaim].(string), // a stable non reassignable id - Idp: claims["issuer"].(string), // in the scope of this issuer + Idp: claims["iss"].(string), // in the scope of this issuer Type: getUserType(claims[am.c.IDClaim].(string)), } + gwc, err := pool.GetGatewayServiceClient(am.c.GatewaySvc) if err != nil { return nil, nil, errors.Wrap(err, "oidc: error getting gateway grpc client") @@ -176,25 +212,29 @@ func (am *mgr) Authenticate(ctx context.Context, clientID, clientSecret string) UserId: userID, }) if err != nil { - return nil, nil, errors.Wrap(err, "oidc: error getting user groups") + return nil, nil, errors.Wrapf(err, "oidc: error getting user groups for '%+v'", userID) } if getGroupsResp.Status.Code != rpc.Code_CODE_OK { return nil, nil, status.NewErrorFromCode(getGroupsResp.Status.Code, "oidc") } + var uid, gid int64 + if am.c.UIDClaim != "" { + uid, _ = claims[am.c.UIDClaim].(int64) + } + if am.c.GIDClaim != "" { + gid, _ = claims[am.c.GIDClaim].(int64) + } + u := &user.User{ - Id: userID, - Username: claims[userClaim].(string), - // TODO(labkode) if we can get groups from the claim we need to give the possibility - // to the admin to choose what claim provides the groups. - // TODO(labkode) ... use all claims from oidc? - // TODO(labkode): do like K8s does it: https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go + Id: userID, + Username: claims["preferred_username"].(string), Groups: getGroupsResp.Groups, Mail: claims["email"].(string), MailVerified: claims["email_verified"].(bool), DisplayName: claims["name"].(string), - UidNumber: int64(uid), - GidNumber: int64(gid), + UidNumber: uid, + GidNumber: gid, } var scopes map[string]*authpb.Scope @@ -227,24 +267,88 @@ func (am *mgr) getOAuthCtx(ctx context.Context) context.Context { return ctx } +// getOIDCProvider returns a singleton OIDC provider func (am *mgr) getOIDCProvider(ctx context.Context) (*oidc.Provider, error) { + ctx = am.getOAuthCtx(ctx) + log := appctx.GetLogger(ctx) + if am.provider != nil { return am.provider, nil } // Initialize a provider by specifying the issuer URL. - // Once initialized is a singleton that is reused if further requests. + // Once initialized this is a singleton that is reused for further requests. // The provider is responsible to verify the token sent by the client // against the security keys oftentimes available in the .well-known endpoint. provider, err := oidc.NewProvider(ctx, am.c.Issuer) + if err != nil { - return nil, fmt.Errorf("error creating a new oidc provider: %+v", err) + log.Error().Err(err).Msg("oidc: error creating a new oidc provider") + return nil, fmt.Errorf("oidc: error creating a new oidc provider: %+v", err) } am.provider = provider return am.provider, nil } +func (am *mgr) resolveUser(ctx context.Context, claims map[string]interface{}) error { + if len(am.oidcUsersMapping) > 0 { + var username string + + // map and discover the user's username when a mapping is defined + if claims[am.c.GroupClaim] == nil { + // we are required to perform a user mapping but the group claim is not available + return fmt.Errorf("no \"%s\" claim found in userinfo to map user", am.c.GroupClaim) + } + mappings := make([]string, 0, len(am.oidcUsersMapping)) + for _, m := range am.oidcUsersMapping { + if m.OIDCIssuer == claims["iss"] { + mappings = append(mappings, m.OIDCGroup) + } + } + + intersection := intersect.Simple(claims[am.c.GroupClaim], mappings) + if len(intersection) > 1 { + // multiple mappings are not implemented as we cannot decide which one to choose + return errtypes.PermissionDenied("more than one user mapping entry exists for the given group claims") + } + if len(intersection) == 0 { + return errtypes.PermissionDenied("no user mapping found for the given group claim(s)") + } + for _, m := range intersection { + username = am.oidcUsersMapping[m.(string)].Username + } + + upsc, err := pool.GetUserProviderServiceClient(am.c.GatewaySvc) + if err != nil { + return errors.Wrap(err, "error getting user provider grpc client") + } + getUserByClaimResp, err := upsc.GetUserByClaim(ctx, &user.GetUserByClaimRequest{ + Claim: "username", + Value: username, + }) + if err != nil { + return errors.Wrapf(err, "error getting user by username '%v'", username) + } + if getUserByClaimResp.Status.Code != rpc.Code_CODE_OK { + return status.NewErrorFromCode(getUserByClaimResp.Status.Code, "oidc") + } + + // take the properties of the mapped target user to override the claims + claims["preferred_username"] = username + claims[am.c.IDClaim] = getUserByClaimResp.GetUser().GetId().OpaqueId + claims["iss"] = getUserByClaimResp.GetUser().GetId().Idp + if am.c.UIDClaim != "" { + claims[am.c.UIDClaim] = getUserByClaimResp.GetUser().UidNumber + } + if am.c.GIDClaim != "" { + claims[am.c.GIDClaim] = getUserByClaimResp.GetUser().GidNumber + } + appctx.GetLogger(ctx).Debug().Str("username", username).Interface("claims", claims).Msg("resolveUser: claims overridden from mapped user") + } + return nil +} + func getUserType(upn string) user.UserType { var t user.UserType switch { @@ -256,5 +360,4 @@ func getUserType(upn string) user.UserType { t = user.UserType_USER_TYPE_PRIMARY } return t - } diff --git a/pkg/auth/manager/oidcmapping/oidcmapping.go b/pkg/auth/manager/oidcmapping/oidcmapping.go deleted file mode 100644 index 8f502f704f..0000000000 --- a/pkg/auth/manager/oidcmapping/oidcmapping.go +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright 2018-2021 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package oidcmapping - -import ( - "context" - "encoding/json" - "fmt" - "io/ioutil" - "strings" - "time" - - oidc "github.com/coreos/go-oidc" - authpb "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1" - user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - "github.com/cs3org/reva/pkg/appctx" - "github.com/cs3org/reva/pkg/auth" - "github.com/cs3org/reva/pkg/auth/manager/registry" - "github.com/cs3org/reva/pkg/auth/scope" - "github.com/cs3org/reva/pkg/errtypes" - "github.com/cs3org/reva/pkg/rgrpc/status" - "github.com/cs3org/reva/pkg/rgrpc/todo/pool" - "github.com/cs3org/reva/pkg/rhttp" - "github.com/cs3org/reva/pkg/sharedconf" - "github.com/juliangruber/go-intersect" - "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" - "golang.org/x/oauth2" -) - -func init() { - registry.Register("oidcmapping", New) -} - -type mgr struct { - provider *oidc.Provider // cached on first request - c *config - oidcUsersMapping map[string]*oidcUserMapping -} - -type config struct { - Insecure bool `mapstructure:"insecure" docs:"false;Whether to skip certificate checks when sending requests."` - Issuer string `mapstructure:"issuer" docs:";The issuer of the OIDC token."` - IDClaim string `mapstructure:"id_claim" docs:"sub;The claim containing the ID of the user."` - UIDClaim string `mapstructure:"uid_claim" docs:";The claim containing the UID of the user."` - GIDClaim string `mapstructure:"gid_claim" docs:";The claim containing the GID of the user."` - GatewaySvc string `mapstructure:"gatewaysvc" docs:";The endpoint at which the GRPC gateway is exposed."` - UsersMapping string `mapstructure:"users_mapping" docs:"; The optional OIDC users mapping file path"` - GroupClaim string `mapstructure:"group_claim" docs:"; The group claim to be looked up to map the user (default to 'groups')."` -} - -type oidcUserMapping struct { - OIDCIssuer string `mapstructure:"oidc_issuer" json:"oidc_issuer"` - OIDCGroup string `mapstructure:"oidc_group" json:"oidc_group"` - Username string `mapstructure:"username" json:"username"` -} - -func (c *config) init() { - if c.IDClaim == "" { - // sub is stable and defined as unique. the user manager needs to take care of the sub to user metadata lookup - c.IDClaim = "sub" - } - if c.GroupClaim == "" { - c.GroupClaim = "groups" - } - - c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) -} - -func parseConfig(m map[string]interface{}) (*config, error) { - c := &config{} - if err := mapstructure.Decode(m, c); err != nil { - err = errors.Wrap(err, "error decoding conf") - return nil, err - } - return c, nil -} - -// New returns an auth manager. -func New(m map[string]interface{}) (auth.Manager, error) { - manager := &mgr{} - err := manager.Configure(m) - if err != nil { - return nil, err - } - return manager, nil -} - -func (am *mgr) Configure(m map[string]interface{}) error { - c, err := parseConfig(m) - if err != nil { - return err - } - c.init() - am.c = c - - am.oidcUsersMapping = map[string]*oidcUserMapping{} - if c.UsersMapping == "" { - // no mapping defined, leave the map empty and move on - return nil - } - - f, err := ioutil.ReadFile(c.UsersMapping) - if err != nil { - return fmt.Errorf("oidc: error reading the users mapping file: +%v", err) - } - oidcUsers := []*oidcUserMapping{} - err = json.Unmarshal(f, &oidcUsers) - if err != nil { - return fmt.Errorf("oidc: error unmarshalling the users mapping file: +%v", err) - } - for _, u := range oidcUsers { - if _, found := am.oidcUsersMapping[u.OIDCGroup]; found { - return fmt.Errorf("oidc: mapping error, group \"%s\" is mapped to multiple users", u.OIDCGroup) - } - am.oidcUsersMapping[u.OIDCGroup] = u - } - - return nil -} - -// Authenticate clientID would be empty as we only need to validate the clientSecret variable -// which contains the access token that we can use to contact the UserInfo endpoint -// and get the user claims. -func (am *mgr) Authenticate(ctx context.Context, clientID, clientSecret string) (*user.User, map[string]*authpb.Scope, error) { - ctx = am.getOAuthCtx(ctx) - log := appctx.GetLogger(ctx) - - oidcProvider, err := am.getOIDCProvider(ctx) - if err != nil { - return nil, nil, fmt.Errorf("oidc: error creating oidc provider: +%v", err) - } - - oauth2Token := &oauth2.Token{ - AccessToken: clientSecret, - } - - // query the oidc provider for user info - userInfo, err := oidcProvider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) - if err != nil { - return nil, nil, fmt.Errorf("oidc: error getting userinfo: +%v", err) - } - - // claims contains the standard OIDC claims like iss, iat, aud, ... and any other non-standard one. - // TODO(labkode): make claims configuration dynamic from the config file so we can add arbitrary mappings from claims to user struct. - // For now, only the group claim is dynamic. - var claims map[string]interface{} - if err := userInfo.Claims(&claims); err != nil { - return nil, nil, fmt.Errorf("oidc: error unmarshaling userinfo claims: %v", err) - } - - log.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Msg("unmarshalled userinfo") - - if claims["iss"] == nil { // This is not set in simplesamlphp - claims["iss"] = am.c.Issuer - } - if claims["email_verified"] == nil { // This is not set in simplesamlphp - claims["email_verified"] = false - } - if claims["preferred_username"] == nil { - claims["preferred_username"] = claims[am.c.IDClaim] - } - if claims["preferred_username"] == nil { - claims["preferred_username"] = claims["email"] - } - if claims["name"] == nil { - claims["name"] = claims[am.c.IDClaim] - } - if claims["name"] == nil { - return nil, nil, fmt.Errorf("no \"name\" attribute found in userinfo: maybe the client did not request the oidc \"profile\"-scope") - } - if claims["email"] == nil { - return nil, nil, fmt.Errorf("no \"email\" attribute found in userinfo: maybe the client did not request the oidc \"email\"-scope") - } - - err = am.resolveUser(ctx, claims) - if err != nil { - return nil, nil, errors.Wrapf(err, "oidc: error resolving username for external user '%v'", claims["email"]) - } - - userID := &user.UserId{ - OpaqueId: claims[am.c.IDClaim].(string), // a stable non reassignable id - Idp: claims["iss"].(string), // in the scope of this issuer - Type: getUserType(claims[am.c.IDClaim].(string)), - } - - gwc, err := pool.GetGatewayServiceClient(am.c.GatewaySvc) - if err != nil { - return nil, nil, errors.Wrap(err, "oidc: error getting gateway grpc client") - } - getGroupsResp, err := gwc.GetUserGroups(ctx, &user.GetUserGroupsRequest{ - UserId: userID, - }) - if err != nil { - return nil, nil, errors.Wrapf(err, "oidc: error getting user groups for '%+v'", userID) - } - if getGroupsResp.Status.Code != rpc.Code_CODE_OK { - return nil, nil, status.NewErrorFromCode(getGroupsResp.Status.Code, "oidc") - } - - var uid, gid int64 - if am.c.UIDClaim != "" { - uid, _ = claims[am.c.UIDClaim].(int64) - } - if am.c.GIDClaim != "" { - gid, _ = claims[am.c.GIDClaim].(int64) - } - - u := &user.User{ - Id: userID, - Username: claims["preferred_username"].(string), - Groups: getGroupsResp.Groups, - Mail: claims["email"].(string), - MailVerified: claims["email_verified"].(bool), - DisplayName: claims["name"].(string), - UidNumber: uid, - GidNumber: gid, - } - - var scopes map[string]*authpb.Scope - if userID != nil && userID.Type == user.UserType_USER_TYPE_LIGHTWEIGHT { - scopes, err = scope.AddLightweightAccountScope(authpb.Role_ROLE_OWNER, nil) - if err != nil { - return nil, nil, err - } - } else { - scopes, err = scope.AddOwnerScope(nil) - if err != nil { - return nil, nil, err - } - } - - return u, scopes, nil -} - -func (am *mgr) getOAuthCtx(ctx context.Context) context.Context { - // Sometimes for testing we need to skip the TLS check, that's why we need a - // custom HTTP client. - customHTTPClient := rhttp.GetHTTPClient( - rhttp.Context(ctx), - rhttp.Timeout(time.Second*10), - rhttp.Insecure(am.c.Insecure), - // Fixes connection fd leak which might be caused by provider-caching - rhttp.DisableKeepAlive(true), - ) - ctx = context.WithValue(ctx, oauth2.HTTPClient, customHTTPClient) - return ctx -} - -// getOIDCProvider returns a singleton OIDC provider -func (am *mgr) getOIDCProvider(ctx context.Context) (*oidc.Provider, error) { - ctx = am.getOAuthCtx(ctx) - log := appctx.GetLogger(ctx) - - if am.provider != nil { - return am.provider, nil - } - - // Initialize a provider by specifying the issuer URL. - // Once initialized this is a singleton that is reused for further requests. - // The provider is responsible to verify the token sent by the client - // against the security keys oftentimes available in the .well-known endpoint. - provider, err := oidc.NewProvider(ctx, am.c.Issuer) - if err != nil { - log.Error().Err(err).Msg("oidc: error creating a new oidc provider") - return nil, fmt.Errorf("oidc: error creating a new oidc provider: %+v", err) - } - - am.provider = provider - return am.provider, nil -} - -func (am *mgr) resolveUser(ctx context.Context, claims map[string]interface{}) error { - if len(am.oidcUsersMapping) > 0 { - var username string - - // map and discover the user's username when a mapping is defined - if claims[am.c.GroupClaim] == nil { - // we are required to perform a user mapping but the group claim is not available - return fmt.Errorf("no \"%s\" claim found in userinfo to map user", am.c.GroupClaim) - } - mappings := make([]string, 0, len(am.oidcUsersMapping)) - for _, m := range am.oidcUsersMapping { - if m.OIDCIssuer == claims["iss"] { - mappings = append(mappings, m.OIDCGroup) - } - } - - intersection := intersect.Simple(claims[am.c.GroupClaim], mappings) - if len(intersection) > 1 { - // multiple mappings are not implemented as we cannot decide which one to choose - return errtypes.PermissionDenied("more than one user mapping entry exists for the given group claims") - } - if len(intersection) == 0 { - return errtypes.PermissionDenied("no user mapping found for the given group claim(s)") - } - for _, m := range intersection { - username = am.oidcUsersMapping[m.(string)].Username - } - - upsc, err := pool.GetUserProviderServiceClient(am.c.GatewaySvc) - if err != nil { - return errors.Wrap(err, "error getting user provider grpc client") - } - getUserByClaimResp, err := upsc.GetUserByClaim(ctx, &user.GetUserByClaimRequest{ - Claim: "username", - Value: username, - }) - if err != nil { - return errors.Wrapf(err, "error getting user by username '%v'", username) - } - if getUserByClaimResp.Status.Code != rpc.Code_CODE_OK { - return status.NewErrorFromCode(getUserByClaimResp.Status.Code, "oidc") - } - - // take the properties of the mapped target user to override the claims - claims["preferred_username"] = username - claims[am.c.IDClaim] = getUserByClaimResp.GetUser().GetId().OpaqueId - claims["iss"] = getUserByClaimResp.GetUser().GetId().Idp - if am.c.UIDClaim != "" { - claims[am.c.UIDClaim] = getUserByClaimResp.GetUser().UidNumber - } - if am.c.GIDClaim != "" { - claims[am.c.GIDClaim] = getUserByClaimResp.GetUser().GidNumber - } - appctx.GetLogger(ctx).Debug().Str("username", username).Interface("claims", claims).Msg("resolveUser: claims overridden from mapped user") - } - return nil -} - -func getUserType(upn string) user.UserType { - var t user.UserType - switch { - case strings.HasPrefix(upn, "guest"): - t = user.UserType_USER_TYPE_LIGHTWEIGHT - case strings.Contains(upn, "@"): - t = user.UserType_USER_TYPE_FEDERATED - default: - t = user.UserType_USER_TYPE_PRIMARY - } - return t - -}