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 <hsluoyz@qq.com>
This commit is contained in:
haiwu 2023-10-12 00:09:47 +08:00 committed by Yang Luo
parent e4208d7fd9
commit 440d87d70c
13 changed files with 736 additions and 1 deletions

View File

@ -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, *, *

27
controllers/scim.go Normal file
View File

@ -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)
}

2
go.mod
View File

@ -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

10
go.sum
View File

@ -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=

View File

@ -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 {

View File

@ -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"
}

View File

@ -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")
}

View File

@ -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

154
scim/server.go Normal file
View File

@ -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
}

260
scim/user_handler.go Normal file
View File

@ -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
}

235
scim/util.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -35,6 +35,10 @@ module.exports = {
target: "http://localhost:8000",
changeOrigin: true,
},
"/scim": {
target: "http://localhost:8000",
changeOrigin: true,
}
},
},
plugins: [