feat: support LDAP search by uidNumber and gidNumber

This commit is contained in:
Yang Luo
2026-08-13 17:49:27 +08:00
parent dfd07ed91e
commit 7d2ad5faba
16 changed files with 536 additions and 52 deletions
+1
View File
@@ -99,6 +99,7 @@
{"name": "Balance currency", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Cart", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Transactions", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "UID number", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "Score", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Karma", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Ranking", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
+17 -14
View File
@@ -164,13 +164,7 @@ func handleSearch(w ldap.ResponseWriter, m *ldap.Message) {
return
}
var isGroupSearch bool = false
filter := r.Filter()
if eq, ok := filter.(message.FilterEqualityMatch); ok && strings.EqualFold(string(eq.AttributeDesc()), "objectClass") && strings.EqualFold(string(eq.AssertionValue()), "posixGroup") {
isGroupSearch = true
}
if isGroupSearch {
if isPosixGroupFilter(r.Filter()) {
groups, code := GetFilteredGroups(m, string(r.BaseObject()), r.FilterString())
if code != ldap.LDAPResultSuccess {
res.SetResultCode(code)
@@ -179,14 +173,23 @@ func handleSearch(w ldap.ResponseWriter, m *ldap.Message) {
}
for _, group := range groups {
users := object.GetGroupUsersWithoutError(group.GetId())
memberUids := make([]string, 0, len(users))
for _, user := range users {
memberUids = append(memberUids, user.Name)
}
attributes := getGroupAttributes(group, memberUids)
if !matchGroupFilter(r.Filter(), attributes) {
continue
}
dn := fmt.Sprintf("cn=%s,%s", group.Name, string(r.BaseObject()))
e := ldap.NewSearchResultEntry(dn)
e.AddAttribute("cn", message.AttributeValue(group.Name))
gidNumberStr := fmt.Sprintf("%v", hash(group.Name))
e.AddAttribute("gidNumber", message.AttributeValue(gidNumberStr))
users := object.GetGroupUsersWithoutError(group.GetId())
for _, user := range users {
e.AddAttribute("memberUid", message.AttributeValue(user.Name))
e.AddAttribute("gidNumber", message.AttributeValue(getGidNumber(group)))
for _, memberUid := range memberUids {
e.AddAttribute("memberUid", message.AttributeValue(memberUid))
}
e.AddAttribute("objectClass", "posixGroup")
w.Write(e)
@@ -242,7 +245,7 @@ func resolveRequestAttributes(attrs message.AttributeSelection) []string {
func buildUserSearchEntry(user *object.User, baseDN string, attrs []string, org *object.Organization) message.SearchResultEntry {
dn := fmt.Sprintf("uid=%s,cn=%s,%s", user.Id, user.Name, baseDN)
e := ldap.NewSearchResultEntry(dn)
uidNumberStr := fmt.Sprintf("%v", hash(user.Name))
uidNumberStr := getUidNumber(user)
if IsLdapAttrAllowed(org, "uidNumber") {
e.AddAttribute("uidNumber", message.AttributeValue(uidNumberStr))
}
@@ -250,7 +253,7 @@ func buildUserSearchEntry(user *object.User, baseDN string, attrs []string, org
e.AddAttribute("gidNumber", message.AttributeValue(uidNumberStr))
}
if IsLdapAttrAllowed(org, "homeDirectory") {
e.AddAttribute("homeDirectory", message.AttributeValue("/home/"+user.Name))
e.AddAttribute("homeDirectory", message.AttributeValue(getHomeDirectory(user)))
}
if IsLdapAttrAllowed(org, "cn") {
e.AddAttribute("cn", message.AttributeValue(user.Name))
+237 -29
View File
@@ -17,6 +17,7 @@ package ldap
import (
"fmt"
"log"
"strconv"
"strings"
"github.com/casdoor/casdoor/object"
@@ -126,6 +127,54 @@ var ldapAttributesMapping = map[string]FieldRelation{
const ldapMemberOfAttr = "memberOf"
// syntheticUserAttribute is a POSIX attribute the LDAP server computes, so a
// filter on it is finished in memory. column holds the assigned value, if any.
type syntheticUserAttribute struct {
column string
getValue func(user *object.User) string
}
var syntheticUserAttributes = map[string]syntheticUserAttribute{
"uidnumber": {column: "uid_number", getValue: getUidNumber},
"gidnumber": {column: "uid_number", getValue: getUidNumber},
"homedirectory": {getValue: getHomeDirectory},
}
// buildCoarseCondition keeps the rows that can still match: those the value is
// assigned to, plus the unassigned ones whose value is only known in memory.
func (a syntheticUserAttribute) buildCoarseCondition(value string) builder.Cond {
if a.column == "" {
return builder.Expr("1 = 1")
}
unassigned := builder.Or(builder.Eq{a.column: 0}, builder.IsNull{a.column})
number, err := strconv.Atoi(value)
if err != nil {
return unassigned
}
return builder.Or(builder.Eq{a.column: number}, unassigned)
}
// getUidNumber derives the number from the name when none is assigned, so the
// uid of an existing user stays stable across the upgrade.
func getUidNumber(user *object.User) string {
if user.UidNumber != 0 {
return strconv.Itoa(user.UidNumber)
}
return fmt.Sprintf("%v", hash(user.Name))
}
func getHomeDirectory(user *object.User) string {
return "/home/" + user.Name
}
func getGidNumber(group *object.Group) string {
if group.GidNumber != 0 {
return strconv.Itoa(group.GidNumber)
}
return fmt.Sprintf("%v", hash(group.Name))
}
var AdditionalLdapAttributes []message.LDAPString
func init() {
@@ -215,12 +264,56 @@ func IsLdapAttrAllowed(org *object.Organization, attr string) bool {
return false
}
func buildUserFilterCondition(filter interface{}) (builder.Cond, error) {
// userSearchFilter is an LDAP filter translated for the user table: a SQL
// condition, plus the terms that have to be applied to the rows it returned.
type userSearchFilter struct {
condition builder.Cond
predicates []func(user *object.User) bool
}
func (q *userSearchFilter) matches(user *object.User) bool {
for _, predicate := range q.predicates {
if !predicate(user) {
return false
}
}
return true
}
func (q *userSearchFilter) apply(users []*object.User) []*object.User {
if len(q.predicates) == 0 {
return users
}
res := make([]*object.User, 0, len(users))
for _, user := range users {
if q.matches(user) {
res = append(res, user)
}
}
return res
}
func buildUserFilter(filter interface{}) (*userSearchFilter, error) {
q := &userSearchFilter{}
condition, err := q.buildCondition(filter, true)
if err != nil {
return nil, err
}
q.condition = condition
return q, nil
}
// buildCondition translates the filter into a SQL condition. conjunctive means
// the term is reached through AND branches only, the only place a synthetic
// attribute can leave the query without changing the result.
func (q *userSearchFilter) buildCondition(filter interface{}, conjunctive bool) (builder.Cond, error) {
switch f := filter.(type) {
case message.FilterAnd:
conditions := make([]builder.Cond, len(f))
for i, v := range f {
cond, err := buildUserFilterCondition(v)
cond, err := q.buildCondition(v, conjunctive)
if err != nil {
return nil, err
}
@@ -230,7 +323,7 @@ func buildUserFilterCondition(filter interface{}) (builder.Cond, error) {
case message.FilterOr:
conditions := make([]builder.Cond, len(f))
for i, v := range f {
cond, err := buildUserFilterCondition(v)
cond, err := q.buildCondition(v, false)
if err != nil {
return nil, err
}
@@ -238,7 +331,7 @@ func buildUserFilterCondition(filter interface{}) (builder.Cond, error) {
}
return builder.Or(conditions...), nil
case message.FilterNot:
cond, err := buildUserFilterCondition(f.Filter)
cond, err := q.buildCondition(f.Filter, false)
if err != nil {
return nil, err
}
@@ -250,6 +343,18 @@ func buildUserFilterCondition(filter interface{}) (builder.Cond, error) {
return builder.Expr("1 = 1"), nil
}
if attribute, ok := syntheticUserAttributes[strings.ToLower(attr)]; ok {
if !conjunctive {
return nil, fmt.Errorf("attribute %s is only supported in an AND filter", attr)
}
value := string(f.AssertionValue())
q.predicates = append(q.predicates, func(user *object.User) bool {
return attribute.getValue(user) == value
})
return attribute.buildCoarseCondition(value), nil
}
if attr == ldapMemberOfAttr {
var names []string
groupId := string(f.AssertionValue())
@@ -276,6 +381,10 @@ func buildUserFilterCondition(filter interface{}) (builder.Cond, error) {
if strings.EqualFold(string(f), "objectclass") {
return builder.Expr("1 = 1"), nil
}
// Synthetic attributes are computed for every user, so they always exist.
if _, ok := syntheticUserAttributes[strings.ToLower(string(f))]; ok {
return builder.Expr("1 = 1"), nil
}
field, err := getUserFieldFromAttribute(string(f))
if err != nil {
return nil, err
@@ -318,13 +427,13 @@ func buildUserFilterCondition(filter interface{}) (builder.Cond, error) {
}
}
func buildSafeCondition(filter interface{}) builder.Cond {
condition, err := buildUserFilterCondition(filter)
func buildSafeFilter(filter interface{}) *userSearchFilter {
q, err := buildUserFilter(filter)
if err != nil {
log.Printf("err = %v", err.Error())
return builder.And(builder.Expr("1 != 1"))
return &userSearchFilter{condition: builder.And(builder.Expr("1 != 1"))}
}
return condition
return q
}
func GetFilteredUsers(m *ldap.Message) (filteredUsers []*object.User, code int) {
@@ -336,21 +445,23 @@ func GetFilteredUsers(m *ldap.Message) (filteredUsers []*object.User, code int)
return nil, code
}
filter := buildSafeFilter(r.Filter())
if name == "*" { // get all users from organization 'org'
if m.Client.IsGlobalAdmin && org == "*" {
filteredUsers, err = object.GetGlobalUsersWithFilter(buildSafeCondition(r.Filter()))
filteredUsers, err = object.GetGlobalUsersWithFilter(filter.condition)
if err != nil {
panic(err)
}
return filteredUsers, ldap.LDAPResultSuccess
return filter.apply(filteredUsers), ldap.LDAPResultSuccess
}
if m.Client.IsGlobalAdmin || (m.Client.IsOrgAdmin && org == m.Client.OrgName) {
filteredUsers, err = object.GetUsersWithFilter(org, buildSafeCondition(r.Filter()))
filteredUsers, err = object.GetUsersWithFilter(org, filter.condition)
if err != nil {
panic(err)
}
return filteredUsers, ldap.LDAPResultSuccess
return filter.apply(filteredUsers), ldap.LDAPResultSuccess
} else {
return nil, ldap.LDAPResultInsufficientAccessRights
}
@@ -370,6 +481,9 @@ func GetFilteredUsers(m *ldap.Message) (filteredUsers []*object.User, code int)
}
if user != nil {
if !filter.matches(user) {
return nil, ldap.LDAPResultSuccess
}
filteredUsers = append(filteredUsers, user)
return filteredUsers, ldap.LDAPResultSuccess
}
@@ -387,18 +501,20 @@ func GetFilteredUsers(m *ldap.Message) (filteredUsers []*object.User, code int)
return nil, ldap.LDAPResultNoSuchObject
}
users, err := object.GetUsersByTagWithFilter(org, name, buildSafeCondition(r.Filter()))
users, err := object.GetUsersByTagWithFilter(org, name, filter.condition)
if err != nil {
panic(err)
}
filteredUsers = append(filteredUsers, users...)
filteredUsers = append(filteredUsers, filter.apply(users)...)
return filteredUsers, ldap.LDAPResultSuccess
}
}
// GetFilteredGroups returns the groups of the organization the search is scoped
// to. The filter itself is applied by matchGroupFilter on each candidate entry.
func GetFilteredGroups(m *ldap.Message, baseDN string, filterStr string) ([]*object.Group, int) {
name, org, code := getNameAndOrgFromFilter(baseDN, filterStr)
_, org, code := getNameAndOrgFromFilter(baseDN, filterStr)
if code != ldap.LDAPResultSuccess {
return nil, code
}
@@ -406,27 +522,119 @@ func GetFilteredGroups(m *ldap.Message, baseDN string, filterStr string) ([]*obj
var groups []*object.Group
var err error
if name == "*" {
if m.Client.IsGlobalAdmin && org == "*" {
groups, err = object.GetGlobalGroups()
if err != nil {
panic(err)
}
} else if m.Client.IsGlobalAdmin || (m.Client.IsOrgAdmin && org == m.Client.OrgName) {
groups, err = object.GetGroups(org)
if err != nil {
panic(err)
}
} else {
return nil, ldap.LDAPResultInsufficientAccessRights
if m.Client.IsGlobalAdmin && org == "*" {
groups, err = object.GetGlobalGroups()
if err != nil {
panic(err)
}
} else if m.Client.IsGlobalAdmin || (m.Client.IsOrgAdmin && org == m.Client.OrgName) {
groups, err = object.GetGroups(org)
if err != nil {
panic(err)
}
} else {
return nil, ldap.LDAPResultNoSuchObject
return nil, ldap.LDAPResultInsufficientAccessRights
}
return groups, ldap.LDAPResultSuccess
}
// isPosixGroupFilter reports whether the filter asks for posixGroup entries,
// on its own or combined, e.g. "(&(objectClass=posixGroup)(gidNumber=12345))".
func isPosixGroupFilter(filter interface{}) bool {
switch f := filter.(type) {
case message.FilterAnd:
for _, v := range f {
if isPosixGroupFilter(v) {
return true
}
}
case message.FilterEqualityMatch:
return strings.EqualFold(string(f.AttributeDesc()), "objectClass") &&
strings.EqualFold(string(f.AssertionValue()), "posixGroup")
}
return false
}
// getGroupAttributes returns the attributes of the posixGroup entry published
// for a Casdoor group, keyed by lowercased attribute name.
func getGroupAttributes(group *object.Group, memberUids []string) map[string][]string {
return map[string][]string{
"cn": {group.Name},
"gidnumber": {getGidNumber(group)},
"memberuid": memberUids,
"objectclass": {"posixGroup"},
}
}
// matchGroupFilter evaluates an LDAP filter against a group entry in memory,
// since group entries have no table to query.
func matchGroupFilter(filter interface{}, attributes map[string][]string) bool {
switch f := filter.(type) {
case message.FilterAnd:
for _, v := range f {
if !matchGroupFilter(v, attributes) {
return false
}
}
return true
case message.FilterOr:
for _, v := range f {
if matchGroupFilter(v, attributes) {
return true
}
}
return false
case message.FilterNot:
return !matchGroupFilter(f.Filter, attributes)
case message.FilterEqualityMatch:
for _, value := range attributes[strings.ToLower(string(f.AttributeDesc()))] {
if strings.EqualFold(value, string(f.AssertionValue())) {
return true
}
}
return false
case message.FilterPresent:
return len(attributes[strings.ToLower(string(f))]) > 0
case message.FilterSubstrings:
for _, value := range attributes[strings.ToLower(string(f.Type_()))] {
if matchSubstrings(value, f.Substrings()) {
return true
}
}
return false
default:
return false
}
}
func matchSubstrings(value string, substrings []message.Substring) bool {
rest := strings.ToLower(value)
for _, substring := range substrings {
switch s := substring.(type) {
case message.SubstringInitial:
prefix := strings.ToLower(string(s))
if !strings.HasPrefix(rest, prefix) {
return false
}
rest = rest[len(prefix):]
case message.SubstringAny:
any := strings.ToLower(string(s))
i := strings.Index(rest, any)
if i < 0 {
return false
}
rest = rest[i+len(any):]
case message.SubstringFinal:
if !strings.HasSuffix(rest, strings.ToLower(string(s))) {
return false
}
rest = ""
}
}
return true
}
func GetFilteredOrganizations(m *ldap.Message) ([]*object.Organization, int) {
if m.Client.IsGlobalAdmin {
organizations, err := object.GetOrganizations("")
+192 -2
View File
@@ -1,6 +1,7 @@
package ldap
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
@@ -9,6 +10,8 @@ import (
goldap "github.com/go-ldap/ldap/v3"
"github.com/lor00x/goldap/message"
"github.com/xorm-io/builder"
"github.com/casdoor/casdoor/object"
)
func args(exp ...interface{}) []interface{} {
@@ -46,11 +49,11 @@ func TestLdapFilterAsQuery(t *testing.T) {
}
req := m.ProtocolOp().(message.SearchRequest)
cond, err := buildUserFilterCondition(req.Filter())
q, err := buildUserFilter(req.Filter())
if err != nil {
assert.FailNow(t, "Unable to build condition", err)
}
expr, args, err := builder.ToSQL(cond)
expr, args, err := builder.ToSQL(q.condition)
if err != nil {
assert.FailNow(t, "Unable to build sql", err)
}
@@ -61,6 +64,193 @@ func TestLdapFilterAsQuery(t *testing.T) {
}
}
func TestLdapFilterOnSyntheticAttributes(t *testing.T) {
alice := &object.User{Name: "alice", UidNumber: 10001}
bob := &object.User{Name: "bob"}
users := []*object.User{alice, bob}
unassigned := "uid_number=? OR uid_number IS NULL"
byNumber := "uid_number=? OR " + unassigned
scenarios := []struct {
description string
input string
expectedExpr string
expectedArgs []interface{}
expectedUsers []*object.User
}{
{
"Should match a user by an assigned uidNumber",
"(&(objectClass=posixAccount)(uidNumber=10001))",
"(1 = 1) AND (" + byNumber + ")",
args(10001, 0),
[]*object.User{alice},
},
{
"Should match a user by a name-derived uidNumber",
"(&(objectClass=posixAccount)(uidNumber=" + getUidNumber(bob) + "))",
"(1 = 1) AND (" + byNumber + ")",
args(int(hash(bob.Name)), 0),
[]*object.User{bob},
},
{
"Should match a user by gidNumber",
"(gidNumber=10001)",
byNumber,
args(10001, 0),
[]*object.User{alice},
},
{
"Should match a user by homeDirectory",
"(homeDirectory=/home/alice)",
"1 = 1",
nil,
[]*object.User{alice},
},
{
"Should match no user for an unknown uidNumber",
"(uidNumber=99)",
byNumber,
args(99, 0),
[]*object.User{},
},
{
"Should only keep unassigned users for a non-numeric uidNumber",
"(uidNumber=abc)",
unassigned,
args(0),
[]*object.User{},
},
{
"Should combine uidNumber with a condition pushed down to SQL",
"(&(uid=alice)(uidNumber=10001))",
"name=? AND (" + byNumber + ")",
args("alice", 10001, 0),
[]*object.User{alice},
},
{
"Should treat synthetic attributes as always present",
"(uidNumber=*)",
"1 = 1",
nil,
users,
},
}
for _, scenery := range scenarios {
t.Run(scenery.description, func(t *testing.T) {
q, err := buildUserFilterFromString(scenery.input)
if err != nil {
assert.FailNow(t, "Unable to build condition", err)
}
expr, args, err := builder.ToSQL(q.condition)
if err != nil {
assert.FailNow(t, "Unable to build sql", err)
}
assert.Equal(t, scenery.expectedExpr, expr)
assert.Equal(t, scenery.expectedArgs, args)
assert.Equal(t, scenery.expectedUsers, q.apply(users))
})
}
}
func TestGetUidNumber(t *testing.T) {
assert.Equal(t, "10001", getUidNumber(&object.User{Name: "alice", UidNumber: 10001}))
assert.Equal(t, fmt.Sprintf("%v", hash("bob")), getUidNumber(&object.User{Name: "bob"}))
}
func TestGetGidNumber(t *testing.T) {
assert.Equal(t, "500", getGidNumber(&object.Group{Name: "devs", GidNumber: 500}))
assert.Equal(t, fmt.Sprintf("%v", hash("ops")), getGidNumber(&object.Group{Name: "ops"}))
}
func TestLdapFilterOnSyntheticAttributesOutsideAnd(t *testing.T) {
for _, input := range []string{"(|(uid=alice)(uidNumber=1))", "(!(uidNumber=1))"} {
t.Run(input, func(t *testing.T) {
_, err := buildUserFilterFromString(input)
assert.ErrorContains(t, err, "only supported in an AND filter")
})
}
}
func TestMatchGroupFilter(t *testing.T) {
group := &object.Group{Owner: "org", Name: "devs", GidNumber: 500}
attributes := getGroupAttributes(group, []string{"alice", "bob"})
scenarios := []struct {
input string
expected bool
}{
{"(objectClass=posixGroup)", true},
{"(&(objectClass=posixGroup)(gidNumber=" + getGidNumber(group) + "))", true},
{"(&(objectClass=posixGroup)(gidNumber=0))", false},
{"(&(objectClass=posixGroup)(cn=devs))", true},
{"(&(objectClass=posixGroup)(cn=ops))", false},
{"(&(objectClass=posixGroup)(memberUid=bob))", true},
{"(&(objectClass=posixGroup)(memberUid=carol))", false},
{"(cn=de*)", true},
{"(cn=op*)", false},
{"(memberUid=*)", true},
{"(description=*)", false},
{"(!(cn=ops))", true},
{"(|(cn=ops)(cn=devs))", true},
}
for _, scenery := range scenarios {
t.Run(scenery.input, func(t *testing.T) {
filter, err := parseLdapFilter(scenery.input)
if err != nil {
assert.FailNow(t, "Unable to parse filter", err)
}
assert.Equal(t, scenery.expected, matchGroupFilter(filter, attributes))
})
}
}
func TestIsPosixGroupFilter(t *testing.T) {
scenarios := []struct {
input string
expected bool
}{
{"(objectClass=posixGroup)", true},
{"(&(objectClass=posixGroup)(gidNumber=1))", true},
{"(&(objectClass=posixAccount)(uidNumber=1))", false},
{"(cn=devs)", false},
}
for _, scenery := range scenarios {
t.Run(scenery.input, func(t *testing.T) {
filter, err := parseLdapFilter(scenery.input)
if err != nil {
assert.FailNow(t, "Unable to parse filter", err)
}
assert.Equal(t, scenery.expected, isPosixGroupFilter(filter))
})
}
}
func buildUserFilterFromString(filter string) (*userSearchFilter, error) {
parsed, err := parseLdapFilter(filter)
if err != nil {
return nil, err
}
return buildUserFilter(parsed)
}
func parseLdapFilter(filter string) (message.Filter, error) {
searchRequest, err := buildLdapSearchRequest(filter)
if err != nil {
return nil, err
}
m, err := message.ReadLDAPMessage(message.NewBytes(0, searchRequest.Bytes()))
if err != nil {
return nil, err
}
req := m.ProtocolOp().(message.SearchRequest)
return req.Filter(), nil
}
func buildLdapSearchRequest(filter string) (*ber.Packet, error) {
packet := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Request")
packet.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 1, "MessageID"))
+3 -1
View File
@@ -46,7 +46,9 @@ type Group struct {
HaveChildren bool `xorm:"-" json:"haveChildren"`
Children []*Group `json:"children,omitempty"`
IsEnabled bool `json:"isEnabled"`
IsEnabled bool `json:"isEnabled"`
// GidNumber is the POSIX gid published by the built-in LDAP server, 0 when unassigned.
GidNumber int `xorm:"index" json:"gidNumber"`
Properties map[string]string `xorm:"mediumtext" json:"properties"`
}
+1
View File
@@ -84,6 +84,7 @@ func GetDefaultAccountItems() []*AccountItem {
{Name: "Balance currency", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Cart", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Transactions", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "UID number", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Score", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Karma", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Ranking", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
+34 -2
View File
@@ -20,6 +20,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"github.com/casdoor/casdoor/conf"
@@ -96,10 +97,21 @@ type LdapGroup struct {
Cn string `json:"cn"`
Name string `json:"name"`
Description string `json:"description"`
GidNumber string `json:"gidNumber"`
Member []string `json:"member"`
ParentDn string `json:"parentDn"`
}
// parsePosixNumber converts a POSIX uid/gid read from an LDAP server into the
// form Casdoor stores. Anything absent or not a number leaves it unassigned.
func parsePosixNumber(value string) int {
number, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || number < 0 {
return 0
}
return number
}
func (ldap *Ldap) GetLdapConn() (c *LdapConn, err error) {
var conn *goldap.Conn
tlsConfig := tls.Config{
@@ -305,7 +317,7 @@ func (l *LdapConn) GetLdapGroups(ldapServer *Ldap) ([]LdapGroup, error) {
}
filterBuilder.WriteString(")")
SearchAttributes := []string{"cn", "name", "description", "member", "uniqueMember", "memberUid"}
SearchAttributes := []string{"cn", "name", "description", "gidNumber", "member", "uniqueMember", "memberUid"}
searchReq := goldap.NewSearchRequest(ldapServer.BaseDn,
goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 0, 0, false,
filterBuilder.String(), SearchAttributes, nil)
@@ -331,6 +343,10 @@ func (l *LdapConn) GetLdapGroups(ldapServer *Ldap) ([]LdapGroup, error) {
if len(attribute.Values) > 0 {
group.Description = attribute.Values[0]
}
case "gidNumber":
if len(attribute.Values) > 0 {
group.GidNumber = attribute.Values[0]
}
case "member", "uniqueMember", "memberUid":
group.Member = append(group.Member, attribute.Values...)
}
@@ -501,7 +517,17 @@ func SyncLdapUsers(owner string, syncUsers []LdapUser, ldapId string) (existUser
// manually-assigned groups (or groups from other sources) are not
// wiped out during synchronization.
user.Groups = buildLdapUserGroups(organization.Name, ldap, syncUser.MemberOf, existingGroupNameSet, user.Groups)
affected, err := UpdateUser(user.GetId(), user, []string{"groups"}, false)
columns := []string{"groups"}
// Adopt the LDAP uid without overwriting one already assigned in Casdoor.
if user.UidNumber == 0 {
if uidNumber := parsePosixNumber(syncUser.UidNumber); uidNumber != 0 {
user.UidNumber = uidNumber
columns = append(columns, "uid_number")
}
}
affected, err := UpdateUser(user.GetId(), user, columns, false)
if err != nil {
return nil, nil, err
}
@@ -537,6 +563,7 @@ func SyncLdapUsers(owner string, syncUsers []LdapUser, ldapId string) (existUser
Tag: tag,
Score: score,
Ldap: syncUser.Uuid,
UidNumber: parsePosixNumber(syncUser.UidNumber),
Properties: syncUser.Attributes,
}
formatUserPhone(newUser)
@@ -702,6 +729,10 @@ func SyncLdapGroups(owner string, ldapGroups []LdapGroup, ldapId string) (newGro
existingGroup.IsTopGroup = isTopGroup
existingGroup.Type = "ldap-synced"
existingGroup.UpdatedTime = util.GetCurrentTime()
// Adopt the LDAP gid without overwriting one already assigned in Casdoor.
if existingGroup.GidNumber == 0 {
existingGroup.GidNumber = parsePosixNumber(ldapGroup.GidNumber)
}
_, err := UpdateGroup(existingGroup.GetId(), existingGroup, true, "")
if err == nil {
@@ -719,6 +750,7 @@ func SyncLdapGroups(owner string, ldapGroups []LdapGroup, ldapId string) (newGro
IsTopGroup: isTopGroup,
Type: "ldap-synced",
IsEnabled: true,
GidNumber: parsePosixNumber(ldapGroup.GidNumber),
}
_, err := AddGroup(newGroup)
+3
View File
@@ -197,6 +197,8 @@ func (syncer *Syncer) setUserByKeyValue(user *User, key string, value string) {
user.Birthday = value
case "Education":
user.Education = value
case "UidNumber":
user.UidNumber = util.ParseInt(value)
case "Score":
user.Score = util.ParseInt(value)
case "Ranking":
@@ -404,6 +406,7 @@ func (syncer *Syncer) getMapFromOriginalUser(user *OriginalUser) map[string]stri
m["Gender"] = user.Gender
m["Birthday"] = user.Birthday
m["Education"] = user.Education
m["UidNumber"] = strconv.Itoa(user.UidNumber)
m["Score"] = strconv.Itoa(user.Score)
m["Ranking"] = strconv.Itoa(user.Ranking)
m["IsDefaultAvatar"] = util.BoolToString(user.IsDefaultAvatar)
+4 -2
View File
@@ -225,7 +225,9 @@ type User struct {
FaceIds []*FaceId `json:"faceIds"`
Cart []ProductInfo `xorm:"mediumtext" json:"cart"`
Ldap string `xorm:"ldap varchar(100)" json:"ldap"`
Ldap string `xorm:"ldap varchar(100)" json:"ldap"`
// UidNumber is the POSIX uid published by the built-in LDAP server, 0 when unassigned.
UidNumber int `xorm:"index" json:"uidNumber"`
Properties map[string]string `json:"properties"`
ThirdPartyLinks []*ThirdPartyLink `xorm:"-" json:"thirdPartyLinks,omitempty"`
@@ -872,7 +874,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
}
if isAdmin {
columns = append(columns, "name", "id", "email", "phone", "country_code", "type", "balance", "balance_credit", "balance_currency", "mfa_items", "register_type", "register_source",
"is_admin", "is_forbidden", "is_deleted")
"is_admin", "is_forbidden", "is_deleted", "uid_number")
}
columns = append(columns, "updated_time")
+9
View File
@@ -753,6 +753,15 @@ func CheckPermissionForUpdateUser(oldUser, newUser *User, isAdmin bool, allowDis
}
}
if oldUser.UidNumber != newUser.UidNumber {
item := GetAccountItemByName("UID number", organization)
if !userVisible(isAdmin, item) {
newUser.UidNumber = oldUser.UidNumber
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Score != newUser.Score {
item := GetAccountItemByName("Score", organization)
if !userVisible(isAdmin, item) {
+11 -1
View File
@@ -14,7 +14,7 @@
import React from "react";
import Loading from "./common/Loading";
import {Button, Card, Col, Input, Row, Select, Switch} from "antd";
import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import * as GroupBackend from "./backend/GroupBackend";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as Setting from "./Setting";
@@ -197,6 +197,16 @@ class GroupEditPage extends React.Component {
}
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:GID number"), i18next.t("general:GID number - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber min={0} value={this.state.group.gidNumber} onChange={value => {
this.updateGroupField("gidNumber", value ?? 0);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :
+1
View File
@@ -84,6 +84,7 @@ class OrganizationListPage extends BaseListPage {
{name: "Gender", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Birthday", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Education", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "UID number", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Score", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Karma", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Ranking", visible: true, viewRule: "Public", modifyRule: "Admin"},
+2 -1
View File
@@ -568,6 +568,7 @@ export const GetTranslatedUserItems = () => {
{name: "Balance credit", label: i18next.t("organization:Balance credit")},
{name: "Cart", label: i18next.t("general:Cart")},
{name: "Transactions", label: i18next.t("general:Transactions")},
{name: "UID number", label: i18next.t("general:UID number")},
{name: "Score", label: i18next.t("user:Score")},
{name: "Karma", label: i18next.t("user:Karma")},
{name: "Ranking", label: i18next.t("user:Ranking")},
@@ -2130,7 +2131,7 @@ export function isAnonymousUserName(userName) {
export function getUserCommonFields() {
return ["Owner", "Name", "CreatedTime", "UpdatedTime", "DeletedTime", "Id", "ExternalId", "Type", "Password", "PasswordSalt", "PasswordType", "DisplayName", "FirstName", "LastName", "Avatar", "AvatarType", "PermanentAvatar",
"Email", "EmailVerified", "Phone", "CountryCode", "Location", "Address", "Affiliation", "Title", "IdCardType", "IdCard", "RealName", "IsVerified", "Homepage", "Bio", "Tag", "Region",
"Language", "Gender", "Birthday", "Education", "Score", "Karma", "Ranking", "Balance", "BalanceCredit", "Currency", "BalanceCurrency", "IsDefaultAvatar", "IsOnline", "IsAdmin", "IsForbidden", "IsDeleted",
"Language", "Gender", "Birthday", "Education", "UidNumber", "Score", "Karma", "Ranking", "Balance", "BalanceCredit", "Currency", "BalanceCurrency", "IsDefaultAvatar", "IsOnline", "IsAdmin", "IsForbidden", "IsDeleted",
"SignupApplication", "RegisterType", "RegisterSource", "CreatedIp", "LastSigninTime", "LastSigninIp",
"PreferredMfaType", "TotpSecret", "RecoveryCodes", "MfaPhoneEnabled", "MfaEmailEnabled", "MfaRadiusEnabled", "MfaRadiusUsername", "MfaRadiusProvider", "MfaPushEnabled", "MfaPushReceiver", "MfaPushProvider",
"WebauthnCredentials", "FaceIds", "Invitation", "InvitationCode", "Ldap", "Properties", "Groups"];
+13
View File
@@ -948,6 +948,19 @@ class UserEditPage extends React.Component {
</Col>
</Row>
);
} else if (accountItem.name === "UID number") {
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:UID number"), i18next.t("general:UID number - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber min={0} value={this.state.user.uidNumber} disabled={disabled} onChange={value => {
this.updateUserField("uidNumber", value ?? 0);
}} />
</Col>
</Row>
);
} else if (accountItem.name === "Karma") {
return (
<Row style={{marginTop: "20px"}} >
+4
View File
@@ -489,6 +489,8 @@
"Forget URL - Tooltip": "Custom URL for the \"Forget password\" page. If not set, the default Casdoor \"Forget password\" page will be used. When set, the \"Forget password\" link on the login page will redirect to this URL",
"Forms": "Forms",
"Found some texts still not translated? Please help us translate at": "Found some texts still not translated? Please help us translate at",
"GID number": "GID number",
"GID number - Tooltip": "The POSIX group ID published by the built-in LDAP server. Leave it as 0 to derive the number from the group name",
"General": "General",
"Generate": "Generate",
"Go to enable": "Go to enable",
@@ -696,6 +698,8 @@
"True": "True",
"Type": "Type",
"Type - Tooltip": "The category or type that classifies this item's behavior or configuration",
"UID number": "UID number",
"UID number - Tooltip": "The POSIX user ID published by the built-in LDAP server. Leave it as 0 to derive the number from the username",
"URL": "URL",
"URL - Tooltip": "URL link",
"Unauthorized": "Unauthorized",
+4
View File
@@ -489,6 +489,8 @@
"Forget URL - Tooltip": "自定义忘记密码页面的链接,不设置时采用Casdoor默认的忘记密码页面,设置后Casdoor各类页面的忘记密码链接会跳转到该URL",
"Forms": "表单",
"Found some texts still not translated? Please help us translate at": "发现有些文字尚未翻译?请移步这里帮我们翻译:",
"GID number": "GID号",
"GID number - Tooltip": "内置LDAP服务器对外发布的POSIX组ID,保持为0时将根据组名自动推导",
"General": "通用",
"Generate": "生成",
"Go to enable": "前往启用",
@@ -696,6 +698,8 @@
"True": "真",
"Type": "类型",
"Type - Tooltip": "对此条目行为或配置进行分类的类别或类型",
"UID number": "UID号",
"UID number - Tooltip": "内置LDAP服务器对外发布的POSIX用户ID,保持为0时将根据用户名自动推导",
"URL": "链接",
"URL - Tooltip": "URL链接",
"Unauthorized": "未授权",