From 440d87d70c1a2482b7f4856b40c64e18ff44d203 Mon Sep 17 00:00:00 2001 From: haiwu <54203997+Chinoholo0807@users.noreply.github.com> Date: Thu, 12 Oct 2023 00:09:47 +0800 Subject: [PATCH] feat: support SCIM protocol (#2393) * 111 * feat: support scim/Users GET and POST request * feat: support scim/Users DELETE/PATCH/PUT request * feat: better support scim/Users PATCH request * feat: fix scim/Users logic * feat: gofumpt * feat: fix bug in scim/Users * feat: fix typo --------- Co-authored-by: hsluoyz --- authz/authz.go | 1 + controllers/scim.go | 27 ++++ go.mod | 2 + go.sum | 10 ++ object/user.go | 24 +++- routers/authz_filter.go | 4 + routers/router.go | 2 + routers/static_filter.go | 3 + scim/server.go | 154 +++++++++++++++++++++++ scim/user_handler.go | 260 +++++++++++++++++++++++++++++++++++++++ scim/util.go | 235 +++++++++++++++++++++++++++++++++++ util/time.go | 11 ++ web/craco.config.js | 4 + 13 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 controllers/scim.go create mode 100644 scim/server.go create mode 100644 scim/user_handler.go create mode 100644 scim/util.go diff --git a/authz/authz.go b/authz/authz.go index f8c248d7..be27a6be 100644 --- a/authz/authz.go +++ b/authz/authz.go @@ -81,6 +81,7 @@ p, *, *, GET, /api/get-saml-login, *, * p, *, *, POST, /api/acs, *, * p, *, *, GET, /api/saml/metadata, *, * p, *, *, *, /cas, *, * +p, *, *, *, /scim, *, * p, *, *, *, /api/webauthn, *, * p, *, *, GET, /api/get-release, *, * p, *, *, GET, /api/get-default-application, *, * diff --git a/controllers/scim.go b/controllers/scim.go new file mode 100644 index 00000000..b9484d58 --- /dev/null +++ b/controllers/scim.go @@ -0,0 +1,27 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// 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. + +package controllers + +import ( + "strings" + + "github.com/casdoor/casdoor/scim" +) + +func (c *RootController) HandleScim() { + path := c.Ctx.Request.URL.Path + c.Ctx.Request.URL.Path = strings.TrimPrefix(path, "/scim") + scim.Server.ServeHTTP(c.Ctx.ResponseWriter, c.Ctx.Request) +} diff --git a/go.mod b/go.mod index d180ecf7..6b4bbe5f 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f github.com/denisenkom/go-mssqldb v0.9.0 github.com/elazarl/go-bindata-assetfs v1.0.1 // indirect + github.com/elimity-com/scim v0.0.0-20230426070224-941a5eac92f3 // indirect github.com/fogleman/gg v1.3.0 github.com/forestmgy/ldapserver v1.1.0 github.com/go-git/go-git/v5 v5.6.0 @@ -63,6 +64,7 @@ require ( golang.org/x/crypto v0.12.0 golang.org/x/net v0.14.0 golang.org/x/oauth2 v0.11.0 + golang.org/x/text v0.13.0 // indirect google.golang.org/api v0.138.0 gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 diff --git a/go.sum b/go.sum index f35ebd68..653b569d 100644 --- a/go.sum +++ b/go.sum @@ -1011,6 +1011,10 @@ github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oN github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/di-wu/parser v0.2.2 h1:I9oHJ8spBXOeL7Wps0ffkFFFiXJf/pk7NX9lcAMqRMU= +github.com/di-wu/parser v0.2.2/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo= +github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI= +github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 h1:uh1GSejOhVPRQmoXZxY82TiewZB8QXiaP1skL7Nun3Y= github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7/go.mod h1:ncTaGuXc5v7AuiVekeJ0Nwh8Bf4cudukoj0qM/15UZE= @@ -1027,6 +1031,8 @@ github.com/elastic/go-elasticsearch/v6 v6.8.5/go.mod h1:UwaDJsD3rWLM5rKNFzv9hgox github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw= github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= +github.com/elimity-com/scim v0.0.0-20230426070224-941a5eac92f3 h1:+zrUtdBUJpY9qptMaaY3CA3T/lBI2+QqfUbzM2uxJss= +github.com/elimity-com/scim v0.0.0-20230426070224-941a5eac92f3/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -1694,6 +1700,8 @@ github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM= +github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/sendgrid/sendgrid-go v3.13.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= @@ -2303,6 +2311,8 @@ golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/object/user.go b/object/user.go index d46df91b..78dbcbd7 100644 --- a/object/user.go +++ b/object/user.go @@ -50,6 +50,7 @@ type User struct { UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"` Id string `xorm:"varchar(100) index" json:"id"` + ExternalId string `xorm:"varchar(100) index" json:"externalId"` Type string `xorm:"varchar(100)" json:"type"` Password string `xorm:"varchar(100)" json:"password"` PasswordSalt string `xorm:"varchar(100)" json:"passwordSalt"` @@ -407,6 +408,24 @@ func GetUserByUserId(owner string, userId string) (*User, error) { } } +func GetUserByUserIdOnly(userId string) (*User, error) { + if userId == "" { + return nil, nil + } + + user := User{Id: userId} + existed, err := ormer.Engine.Get(&user) + if err != nil { + return nil, err + } + + if existed { + return &user, nil + } else { + return nil, nil + } +} + func GetUserByAccessKey(accessKey string) (*User, error) { if accessKey == "" { return nil, nil @@ -529,7 +548,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er if len(columns) == 0 { columns = []string{ - "owner", "display_name", "avatar", + "owner", "display_name", "avatar", "first_name", "last_name", "location", "address", "country_code", "region", "language", "affiliation", "title", "homepage", "bio", "tag", "language", "gender", "birthday", "education", "score", "karma", "ranking", "signup_application", "is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "managedAccounts", "signin_wrong_times", "last_signin_wrong_time", "groups", "access_key", "access_secret", @@ -546,6 +565,9 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er columns = append(columns, "name", "email", "phone", "country_code", "type") } + columns = append(columns, "updated_time") + user.UpdatedTime = util.GetCurrentTime() + if util.ContainsString(columns, "groups") { _, err := userEnforcer.UpdateGroupsForUser(user.GetId(), user.Groups) if err != nil { diff --git a/routers/authz_filter.go b/routers/authz_filter.go index f8a972c6..223e73b5 100644 --- a/routers/authz_filter.go +++ b/routers/authz_filter.go @@ -139,6 +139,10 @@ func getUrlPath(urlPath string) string { return "/cas" } + if strings.HasPrefix(urlPath, "/scim") { + return "/scim" + } + if strings.HasPrefix(urlPath, "/api/login/oauth") { return "/api/login/oauth" } diff --git a/routers/router.go b/routers/router.go index 23b0e93f..2c37a27b 100644 --- a/routers/router.go +++ b/routers/router.go @@ -277,4 +277,6 @@ func initAPI() { beego.Router("/cas/:organization/:application/p3/serviceValidate", &controllers.RootController{}, "GET:CasP3ServiceValidate") beego.Router("/cas/:organization/:application/p3/proxyValidate", &controllers.RootController{}, "GET:CasP3ProxyValidate") beego.Router("/cas/:organization/:application/samlValidate", &controllers.RootController{}, "POST:SamlValidate") + + beego.Router("/scim/*", &controllers.RootController{}, "*:HandleScim") } diff --git a/routers/static_filter.go b/routers/static_filter.go index 9dbf4d02..240a25b4 100644 --- a/routers/static_filter.go +++ b/routers/static_filter.go @@ -59,6 +59,9 @@ func StaticFilter(ctx *context.Context) { if strings.HasPrefix(urlPath, "/cas") && (strings.HasSuffix(urlPath, "/serviceValidate") || strings.HasSuffix(urlPath, "/proxy") || strings.HasSuffix(urlPath, "/proxyValidate") || strings.HasSuffix(urlPath, "/validate") || strings.HasSuffix(urlPath, "/p3/serviceValidate") || strings.HasSuffix(urlPath, "/p3/proxyValidate") || strings.HasSuffix(urlPath, "/samlValidate")) { return } + if strings.HasPrefix(urlPath, "/scim") { + return + } webBuildFolder := getWebBuildFolder() path := webBuildFolder diff --git a/scim/server.go b/scim/server.go new file mode 100644 index 00000000..aac856c3 --- /dev/null +++ b/scim/server.go @@ -0,0 +1,154 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// 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. + +package scim + +import ( + "github.com/elimity-com/scim" + "github.com/elimity-com/scim/optional" + "github.com/elimity-com/scim/schema" +) + +/* +Example JSON user resource +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ], + "addresses": [ + { + "country": "US", + "locality": "San Fransisco", + "region": "US West" + } + ], + "displayName": "Hello, Scim", + "name": { + "familyName": "Bob", + "givenName": "Alice" + }, + "phoneNumbers": [ + { + "value": "46407568879" + } + ], + "photos": [ + { + "value": "https://cdn.casbin.org/img/casbin.svg" + } + ], + "emails": [ + { + "value": "cbvdho@example.com" + } + ], + "profileUrl": "https://door.casdoor.com/users/build-in/scim_test_user2", + "userName": "scim_test_user2", + "userType": "normal-user", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "organization": "built-in" + } +} +*/ + +const ( + UserExtensionKey = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" +) + +var ( + UserStringField = []schema.SimpleParams{ + newStringParams("externalId", false, true), + newStringParams("userName", true, true), + newStringParams("password", false, false), + newStringParams("displayName", false, false), + newStringParams("profileUrl", false, false), + newStringParams("userType", false, false), + } + UserComplexField = []schema.ComplexParams{ + newComplexParams("name", false, false, []schema.SimpleParams{ + newStringParams("givenName", false, false), + newStringParams("familyName", false, false), + }), + newComplexParams("emails", false, true, []schema.SimpleParams{ + newStringParams("value", true, false), + }), + newComplexParams("phoneNumbers", false, true, []schema.SimpleParams{ + newStringParams("value", true, false), + }), + newComplexParams("photos", false, true, []schema.SimpleParams{ + newStringParams("value", true, false), + }), + newComplexParams("addresses", false, true, []schema.SimpleParams{ + newStringParams("locality", false, false), + newStringParams("region", false, false), + newStringParams("country", false, false), + }), + } + Server = GetScimServer() +) + +func GetScimServer() scim.Server { + config := scim.ServiceProviderConfig{ + // DocumentationURI: optional.NewString("www.example.com/scim"), + SupportPatch: true, + } + + codeAttrs := make([]schema.CoreAttribute, 0, len(UserStringField)+len(UserComplexField)) + for _, field := range UserStringField { + codeAttrs = append(codeAttrs, schema.SimpleCoreAttribute(field)) + } + for _, field := range UserComplexField { + codeAttrs = append(codeAttrs, schema.ComplexCoreAttribute(field)) + } + + userSchema := schema.Schema{ + ID: schema.UserSchema, + Name: optional.NewString("User"), + Description: optional.NewString("User Account"), + Attributes: codeAttrs, + } + + extension := schema.Schema{ + ID: UserExtensionKey, + Name: optional.NewString("EnterpriseUser"), + Description: optional.NewString("Enterprise User"), + Attributes: []schema.CoreAttribute{ + schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{ + Name: "organization", + Required: true, + })), + }, + } + + resourceTypes := []scim.ResourceType{ + { + ID: optional.NewString("User"), + Name: "User", + Endpoint: "/Users", + Description: optional.NewString("User Account in Casdoor"), + Schema: userSchema, + SchemaExtensions: []scim.SchemaExtension{ + {Schema: extension}, + }, + Handler: UserResourceHandler{}, + }, + } + + server := scim.Server{ + Config: config, + ResourceTypes: resourceTypes, + } + return server +} diff --git a/scim/user_handler.go b/scim/user_handler.go new file mode 100644 index 00000000..459171e7 --- /dev/null +++ b/scim/user_handler.go @@ -0,0 +1,260 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// 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. + +package scim + +import ( + "fmt" + "net/http" + + "github.com/casdoor/casdoor/object" + "github.com/elimity-com/scim" + "github.com/elimity-com/scim/errors" +) + +type UserResourceHandler struct{} + +// https://github.com/elimity-com/scim/blob/master/resource_handler_test.go Example in-memory resource handler +// https://datatracker.ietf.org/doc/html/rfc7644#section-3.4 How to query/update resources + +func (h UserResourceHandler) Create(r *http.Request, attrs scim.ResourceAttributes) (scim.Resource, error) { + resource := &scim.Resource{Attributes: attrs} + err := AddScimUser(resource) + return *resource, err +} + +func (h UserResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) { + resource, err := GetScimUser(id) + if err != nil { + return scim.Resource{}, err + } + if resource == nil { + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + } + return *resource, nil +} + +func (h UserResourceHandler) Delete(r *http.Request, id string) error { + user, err := object.GetUserByUserIdOnly(id) + if err != nil { + return err + } + if user == nil { + return errors.ScimErrorResourceNotFound(id) + } + _, err = object.DeleteUser(user) + return err +} + +func (h UserResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) { + if params.Count == 0 { + count, err := object.GetGlobalUserCount("", "") + if err != nil { + return scim.Page{}, err + } + return scim.Page{TotalResults: int(count)}, nil + } + + resources := make([]scim.Resource, 0) + // startIndex is 1-based index + users, err := object.GetPaginationGlobalUsers(params.StartIndex-1, params.Count, "", "", "", "") + if err != nil { + return scim.Page{}, err + } + for _, user := range users { + resources = append(resources, *user2resource(user)) + } + return scim.Page{ + TotalResults: len(resources), + Resources: resources, + }, nil +} + +func (h UserResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) { + user, err := object.GetUserByUserIdOnly(id) + if err != nil { + return scim.Resource{}, err + } + if user == nil { + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + } + return UpdateScimUserByPatchOperation(id, operations) +} + +func (h UserResourceHandler) Replace(r *http.Request, id string, attrs scim.ResourceAttributes) (scim.Resource, error) { + user, err := object.GetUserByUserIdOnly(id) + if err != nil { + return scim.Resource{}, err + } + if user == nil { + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + } + resource := &scim.Resource{Attributes: attrs} + err = UpdateScimUser(id, resource) + return *resource, err +} + +func GetScimUser(id string) (*scim.Resource, error) { + user, err := object.GetUserByUserIdOnly(id) + if err != nil { + return nil, err + } + if user == nil { + return nil, nil + } + r := user2resource(user) + return r, nil +} + +func AddScimUser(r *scim.Resource) error { + newUser, err := resource2user(r.Attributes) + if err != nil { + return err + } + + // Check whether the user exists. + oldUser, err := object.GetUser(newUser.GetId()) + if err != nil { + return err + } + if oldUser != nil { + return errors.ScimErrorUniqueness + } + + affect, err := object.AddUser(newUser) + if err != nil { + return err + } + if !affect { + return fmt.Errorf("add new user failed") + } + + r.Attributes = user2resource(newUser).Attributes + r.ID = newUser.Id + r.ExternalID = buildExternalId(newUser) + r.Meta = buildMeta(newUser) + return nil +} + +func UpdateScimUser(id string, r *scim.Resource) error { + oldUser, err := object.GetUserByUserIdOnly(id) + if err != nil { + return err + } + if oldUser == nil { + return errors.ScimErrorResourceNotFound(id) + } + newUser, err := resource2user(r.Attributes) + if err != nil { + return err + } + _, err = object.UpdateUser(oldUser.GetId(), newUser, nil, true) + if err != nil { + return err + } + + r.ID = newUser.Id + r.ExternalID = buildExternalId(newUser) + r.Meta = buildMeta(newUser) + return nil +} + +// https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2 Modifying with PATCH +func UpdateScimUserByPatchOperation(id string, ops []scim.PatchOperation) (r scim.Resource, err error) { + user, err := object.GetUserByUserIdOnly(id) + if err != nil { + return scim.Resource{}, err + } + if user == nil { + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + } + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("invalid patch op value: %v", r) + } + }() + old := user.GetId() + for _, op := range ops { + value := op.Value + if op.Op == scim.PatchOperationRemove { + value = nil + } + // PatchOperationAdd and PatchOperationReplace is same in Casdoor, just replace the value + switch op.Path.String() { + case "userName": + user.Name = ToString(value, "") + case "password": + user.Password = ToString(value, "") + case "externalId": + user.ExternalId = ToString(value, "") + case "displayName": + user.DisplayName = ToString(value, "") + case "profileUrl": + user.Homepage = ToString(value, "") + case "userType": + user.Type = ToString(value, "") + case "name.givenName": + user.FirstName = ToString(value, "") + case "name.familyName": + user.LastName = ToString(value, "") + case "name": + defaultV := AnyMap{"givenName": "", "familyName": ""} + v := ToAnyMap(value, defaultV) // e.g. {"givenName": "AA", "familyName": "BB"} + user.FirstName = ToString(v["givenName"], user.FirstName) + user.LastName = ToString(v["familyName"], user.LastName) + case "emails": + defaultV := AnyArray{AnyMap{"value": ""}} + vs := ToAnyArray(value, defaultV) // e.g. [{"value": "test@casdoor"}] + if len(vs) > 0 { + v := ToAnyMap(vs[0]) + user.Email = ToString(v["value"], user.Email) + } + case "phoneNumbers": + defaultV := AnyArray{AnyMap{"value": ""}} + vs := ToAnyArray(value, defaultV) // e.g. [{"value": "18750004417"}] + if len(vs) > 0 { + v := ToAnyMap(vs[0]) + user.Phone = ToString(v["value"], user.Phone) + } + case "photos": + defaultV := AnyArray{AnyMap{"value": ""}} + vs := ToAnyArray(value, defaultV) // e.g. [{"value": "https://cdn.casbin.org/img/casbin.svg"}] + if len(vs) > 0 { + v := ToAnyMap(vs[0]) + user.Avatar = ToString(v["value"], user.Avatar) + } + case "addresses": + defaultV := AnyArray{AnyMap{"locality": "", "region": "", "country": ""}} + vs := ToAnyArray(value, defaultV) // e.g. [{"locality": "Hollywood", "region": "CN", "country": "USA"}] + if len(vs) > 0 { + v := ToAnyMap(vs[0]) + user.Location = ToString(v["locality"], user.Location) + user.Region = ToString(v["region"], user.Region) + user.CountryCode = ToString(v["country"], user.CountryCode) + } + case UserExtensionKey: + defaultV := AnyMap{"organization": user.Owner} + v := ToAnyMap(value, defaultV) // e.g. {"organization": "org1"} + user.Owner = ToString(v["organization"], user.Owner) + case fmt.Sprintf("%v.%v", UserExtensionKey, "organization"): + user.Owner = ToString(value, user.Owner) + } + } + _, err = object.UpdateUser(old, user, nil, true) + if err != nil { + return scim.Resource{}, err + } + r = *user2resource(user) + return r, nil +} diff --git a/scim/util.go b/scim/util.go new file mode 100644 index 00000000..7ff2e59d --- /dev/null +++ b/scim/util.go @@ -0,0 +1,235 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// 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. + +package scim + +import ( + "fmt" + "log" + + "github.com/casdoor/casdoor/object" + "github.com/casdoor/casdoor/util" + "github.com/elimity-com/scim" + "github.com/elimity-com/scim/optional" + "github.com/elimity-com/scim/schema" +) + +type AnyMap map[string]interface{} + +type AnyArray []interface{} + +func ToString(v interface{}, defaultV ...interface{}) string { + if v == nil { + if len(defaultV) > 0 { + v = defaultV[0] + } + } + return v.(string) +} + +func ToAnyMap(v interface{}, defaultV ...interface{}) AnyMap { + if v == nil { + if len(defaultV) > 0 { + v = defaultV[0] + } + } + m, ok := v.(map[string]interface{}) + if !ok { + m = v.(AnyMap) + } + return m +} + +func ToAnyArray(v interface{}, defaultV ...interface{}) AnyArray { + if v == nil { + if len(defaultV) > 0 { + v = defaultV[0] + } + } + m, ok := v.([]interface{}) + if !ok { + m = v.(AnyArray) + } + return m +} + +func newStringParams(name string, required, unique bool) schema.SimpleParams { + uniqueness := schema.AttributeUniquenessNone() + if unique { + uniqueness = schema.AttributeUniquenessServer() + } + return schema.SimpleStringParams(schema.StringParams{ + Name: name, + Required: required, + Uniqueness: uniqueness, + }) +} + +func newComplexParams(name string, required bool, multi bool, subAttributes []schema.SimpleParams) schema.ComplexParams { + return schema.ComplexParams{ + Name: name, + Required: required, + MultiValued: multi, + SubAttributes: subAttributes, + } +} + +func buildExternalId(user *object.User) optional.String { + if user.ExternalId != "" { + return optional.NewString(user.ExternalId) + } else { + return optional.String{} + } +} + +func buildMeta(user *object.User) scim.Meta { + createdTime := util.String2Time(user.CreatedTime) + updatedTime := util.String2Time(user.UpdatedTime) + return scim.Meta{ + Created: &createdTime, + LastModified: &updatedTime, + Version: user.Id, + } +} + +func getAttrString(attrs scim.ResourceAttributes, key string) string { + if attrs[key] == nil { + return "" + } else { + return attrs[key].(string) + } +} + +func getAttrJson(attrs scim.ResourceAttributes, key string) scim.ResourceAttributes { + if attrs[key] == nil { + return nil + } else { + if v, ok := attrs[key].(map[string]interface{}); ok { + return v + } else if v, ok := attrs[key].([]interface{}); ok { + if len(v) > 0 { + return v[0].(map[string]interface{}) + } else { + return nil + } + } else { + panic("invalid attribute type") + } + } +} + +func getAttrJsonValue(attrs scim.ResourceAttributes, key1 string, key2 string) string { + attr := getAttrJson(attrs, key1) + if attr == nil { + return "" + } else { + return getAttrString(attr, key2) + } +} + +func user2resource(user *object.User) *scim.Resource { + attrs := make(map[string]interface{}) + // Singular attributes + attrs["userName"] = user.Name + // The cleartext value or the hashed value of a password SHALL NOT be returnable by a service provider. + // attrs["password"] = user.Password + formatted := fmt.Sprintf("%s %s", user.FirstName, user.LastName) + if user.FirstName == "" { + formatted = user.LastName + } + if user.LastName == "" { + formatted = user.FirstName + } + attrs["name"] = scim.ResourceAttributes{ + "formatted": formatted, + "familyName": user.LastName, + "givenName": user.FirstName, + } + attrs["displayName"] = user.DisplayName + attrs["nickName"] = user.DisplayName + attrs["userType"] = user.Type + attrs["profileUrl"] = user.Homepage + attrs["active"] = !user.IsForbidden && !user.IsDeleted + + // Multi-Valued attributes + attrs["emails"] = []scim.ResourceAttributes{ + { + "value": user.Email, + }, + } + attrs["phoneNumbers"] = []scim.ResourceAttributes{ + { + "value": user.Phone, + }, + } + attrs["photos"] = []scim.ResourceAttributes{ + { + "value": user.Avatar, + }, + } + attrs["addresses"] = []scim.ResourceAttributes{ + { + "locality": user.Location, // e.g. Hollywood + "region": user.Region, // e.g. CN + "country": user.CountryCode, // e.g. USA + }, + } + + // Enterprise user schema extension + attrs[UserExtensionKey] = scim.ResourceAttributes{ + "organization": user.Owner, + } + + return &scim.Resource{ + ID: user.Id, + ExternalID: buildExternalId(user), + Attributes: attrs, + Meta: buildMeta(user), + } +} + +func resource2user(attrs scim.ResourceAttributes) (user *object.User, err error) { + defer func() { + if r := recover(); r != nil { + log.Printf("failed to parse attrs: %v", r) + err = fmt.Errorf("%v", r) + } + }() + user = &object.User{ + ExternalId: getAttrString(attrs, "externalId"), + Name: getAttrString(attrs, "userName"), + Password: getAttrString(attrs, "password"), + DisplayName: getAttrString(attrs, "displayName"), + Homepage: getAttrString(attrs, "profileUrl"), + Type: getAttrString(attrs, "userType"), + + Owner: getAttrJsonValue(attrs, UserExtensionKey, "organization"), + FirstName: getAttrJsonValue(attrs, "name", "givenName"), + LastName: getAttrJsonValue(attrs, "name", "familyName"), + Email: getAttrJsonValue(attrs, "emails", "value"), + Phone: getAttrJsonValue(attrs, "phoneNumbers", "value"), + Avatar: getAttrJsonValue(attrs, "photos", "value"), + Location: getAttrJsonValue(attrs, "addresses", "locality"), + Region: getAttrJsonValue(attrs, "addresses", "region"), + CountryCode: getAttrJsonValue(attrs, "addresses", "country"), + + CreatedTime: util.GetCurrentTime(), + UpdatedTime: util.GetCurrentTime(), + } + + if user.Owner == "" { + err = fmt.Errorf("organization in %s is required", UserExtensionKey) + } + return +} diff --git a/util/time.go b/util/time.go index e72f1762..c1310505 100644 --- a/util/time.go +++ b/util/time.go @@ -43,6 +43,17 @@ func GetCurrentUnixTime() string { return strconv.FormatInt(time.Now().UnixNano(), 10) } +func String2Time(timestamp string) time.Time { + if timestamp == "" { + return time.Now() + } + parseTime, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + panic(err) + } + return parseTime +} + func IsTokenExpired(createdTime string, expiresIn int) bool { createdTimeObj, _ := time.Parse(time.RFC3339, createdTime) expiresAtObj := createdTimeObj.Add(time.Duration(expiresIn) * time.Second) diff --git a/web/craco.config.js b/web/craco.config.js index d96d366a..e0ee8c0e 100644 --- a/web/craco.config.js +++ b/web/craco.config.js @@ -35,6 +35,10 @@ module.exports = { target: "http://localhost:8000", changeOrigin: true, }, + "/scim": { + target: "http://localhost:8000", + changeOrigin: true, + } }, }, plugins: [