feat: support LDAP search by uidNumber and gidNumber
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))} :
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
|
||||
@@ -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"}} >
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "未授权",
|
||||
|
||||
Reference in New Issue
Block a user