code.gitea.io/gitea@v1.21.7/services/auth/source/ldap/source_search.go (about)

     1  // Copyright 2014 The Gogs Authors. All rights reserved.
     2  // Copyright 2020 The Gitea Authors. All rights reserved.
     3  // SPDX-License-Identifier: MIT
     4  
     5  package ldap
     6  
     7  import (
     8  	"crypto/tls"
     9  	"fmt"
    10  	"net"
    11  	"strconv"
    12  	"strings"
    13  
    14  	"code.gitea.io/gitea/modules/container"
    15  	"code.gitea.io/gitea/modules/log"
    16  
    17  	"github.com/go-ldap/ldap/v3"
    18  )
    19  
    20  // SearchResult : user data
    21  type SearchResult struct {
    22  	Username     string   // Username
    23  	Name         string   // Name
    24  	Surname      string   // Surname
    25  	Mail         string   // E-mail address
    26  	SSHPublicKey []string // SSH Public Key
    27  	IsAdmin      bool     // if user is administrator
    28  	IsRestricted bool     // if user is restricted
    29  	LowerName    string   // LowerName
    30  	Avatar       []byte
    31  	Groups       container.Set[string]
    32  }
    33  
    34  func (source *Source) sanitizedUserQuery(username string) (string, bool) {
    35  	// See http://tools.ietf.org/search/rfc4515
    36  	badCharacters := "\x00()*\\"
    37  	if strings.ContainsAny(username, badCharacters) {
    38  		log.Debug("'%s' contains invalid query characters. Aborting.", username)
    39  		return "", false
    40  	}
    41  
    42  	return fmt.Sprintf(source.Filter, username), true
    43  }
    44  
    45  func (source *Source) sanitizedUserDN(username string) (string, bool) {
    46  	// See http://tools.ietf.org/search/rfc4514: "special characters"
    47  	badCharacters := "\x00()*\\,='\"#+;<>"
    48  	if strings.ContainsAny(username, badCharacters) {
    49  		log.Debug("'%s' contains invalid DN characters. Aborting.", username)
    50  		return "", false
    51  	}
    52  
    53  	return fmt.Sprintf(source.UserDN, username), true
    54  }
    55  
    56  func (source *Source) sanitizedGroupFilter(group string) (string, bool) {
    57  	// See http://tools.ietf.org/search/rfc4515
    58  	badCharacters := "\x00*\\"
    59  	if strings.ContainsAny(group, badCharacters) {
    60  		log.Trace("Group filter invalid query characters: %s", group)
    61  		return "", false
    62  	}
    63  
    64  	return group, true
    65  }
    66  
    67  func (source *Source) sanitizedGroupDN(groupDn string) (string, bool) {
    68  	// See http://tools.ietf.org/search/rfc4514: "special characters"
    69  	badCharacters := "\x00()*\\'\"#+;<>"
    70  	if strings.ContainsAny(groupDn, badCharacters) || strings.HasPrefix(groupDn, " ") || strings.HasSuffix(groupDn, " ") {
    71  		log.Trace("Group DN contains invalid query characters: %s", groupDn)
    72  		return "", false
    73  	}
    74  
    75  	return groupDn, true
    76  }
    77  
    78  func (source *Source) findUserDN(l *ldap.Conn, name string) (string, bool) {
    79  	log.Trace("Search for LDAP user: %s", name)
    80  
    81  	// A search for the user.
    82  	userFilter, ok := source.sanitizedUserQuery(name)
    83  	if !ok {
    84  		return "", false
    85  	}
    86  
    87  	log.Trace("Searching for DN using filter %s and base %s", userFilter, source.UserBase)
    88  	search := ldap.NewSearchRequest(
    89  		source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0,
    90  		false, userFilter, []string{}, nil)
    91  
    92  	// Ensure we found a user
    93  	sr, err := l.Search(search)
    94  	if err != nil || len(sr.Entries) < 1 {
    95  		log.Debug("Failed search using filter[%s]: %v", userFilter, err)
    96  		return "", false
    97  	} else if len(sr.Entries) > 1 {
    98  		log.Debug("Filter '%s' returned more than one user.", userFilter)
    99  		return "", false
   100  	}
   101  
   102  	userDN := sr.Entries[0].DN
   103  	if userDN == "" {
   104  		log.Error("LDAP search was successful, but found no DN!")
   105  		return "", false
   106  	}
   107  
   108  	return userDN, true
   109  }
   110  
   111  func dial(source *Source) (*ldap.Conn, error) {
   112  	log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", source.SecurityProtocol, source.SkipVerify)
   113  
   114  	tlsConfig := &tls.Config{
   115  		ServerName:         source.Host,
   116  		InsecureSkipVerify: source.SkipVerify,
   117  	}
   118  
   119  	if source.SecurityProtocol == SecurityProtocolLDAPS {
   120  		return ldap.DialTLS("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port)), tlsConfig)
   121  	}
   122  
   123  	conn, err := ldap.Dial("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port)))
   124  	if err != nil {
   125  		return nil, fmt.Errorf("error during Dial: %w", err)
   126  	}
   127  
   128  	if source.SecurityProtocol == SecurityProtocolStartTLS {
   129  		if err = conn.StartTLS(tlsConfig); err != nil {
   130  			conn.Close()
   131  			return nil, fmt.Errorf("error during StartTLS: %w", err)
   132  		}
   133  	}
   134  
   135  	return conn, nil
   136  }
   137  
   138  func bindUser(l *ldap.Conn, userDN, passwd string) error {
   139  	log.Trace("Binding with userDN: %s", userDN)
   140  	err := l.Bind(userDN, passwd)
   141  	if err != nil {
   142  		log.Debug("LDAP auth. failed for %s, reason: %v", userDN, err)
   143  		return err
   144  	}
   145  	log.Trace("Bound successfully with userDN: %s", userDN)
   146  	return err
   147  }
   148  
   149  func checkAdmin(l *ldap.Conn, ls *Source, userDN string) bool {
   150  	if len(ls.AdminFilter) == 0 {
   151  		return false
   152  	}
   153  	log.Trace("Checking admin with filter %s and base %s", ls.AdminFilter, userDN)
   154  	search := ldap.NewSearchRequest(
   155  		userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.AdminFilter,
   156  		[]string{ls.AttributeName},
   157  		nil)
   158  
   159  	sr, err := l.Search(search)
   160  
   161  	if err != nil {
   162  		log.Error("LDAP Admin Search with filter %s for %s failed unexpectedly! (%v)", ls.AdminFilter, userDN, err)
   163  	} else if len(sr.Entries) < 1 {
   164  		log.Trace("LDAP Admin Search found no matching entries.")
   165  	} else {
   166  		return true
   167  	}
   168  	return false
   169  }
   170  
   171  func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool {
   172  	if len(ls.RestrictedFilter) == 0 {
   173  		return false
   174  	}
   175  	if ls.RestrictedFilter == "*" {
   176  		return true
   177  	}
   178  	log.Trace("Checking restricted with filter %s and base %s", ls.RestrictedFilter, userDN)
   179  	search := ldap.NewSearchRequest(
   180  		userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.RestrictedFilter,
   181  		[]string{ls.AttributeName},
   182  		nil)
   183  
   184  	sr, err := l.Search(search)
   185  
   186  	if err != nil {
   187  		log.Error("LDAP Restrictred Search with filter %s for %s failed unexpectedly! (%v)", ls.RestrictedFilter, userDN, err)
   188  	} else if len(sr.Entries) < 1 {
   189  		log.Trace("LDAP Restricted Search found no matching entries.")
   190  	} else {
   191  		return true
   192  	}
   193  	return false
   194  }
   195  
   196  // List all group memberships of a user
   197  func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) container.Set[string] {
   198  	ldapGroups := make(container.Set[string])
   199  
   200  	groupFilter, ok := source.sanitizedGroupFilter(source.GroupFilter)
   201  	if !ok {
   202  		return ldapGroups
   203  	}
   204  
   205  	groupDN, ok := source.sanitizedGroupDN(source.GroupDN)
   206  	if !ok {
   207  		return ldapGroups
   208  	}
   209  
   210  	var searchFilter string
   211  	if applyGroupFilter && groupFilter != "" {
   212  		searchFilter = fmt.Sprintf("(&(%s)(%s=%s))", groupFilter, source.GroupMemberUID, ldap.EscapeFilter(uid))
   213  	} else {
   214  		searchFilter = fmt.Sprintf("(%s=%s)", source.GroupMemberUID, ldap.EscapeFilter(uid))
   215  	}
   216  	result, err := l.Search(ldap.NewSearchRequest(
   217  		groupDN,
   218  		ldap.ScopeWholeSubtree,
   219  		ldap.NeverDerefAliases,
   220  		0,
   221  		0,
   222  		false,
   223  		searchFilter,
   224  		[]string{},
   225  		nil,
   226  	))
   227  	if err != nil {
   228  		log.Error("Failed group search in LDAP with filter [%s]: %v", searchFilter, err)
   229  		return ldapGroups
   230  	}
   231  
   232  	for _, entry := range result.Entries {
   233  		if entry.DN == "" {
   234  			log.Error("LDAP search was successful, but found no DN!")
   235  			continue
   236  		}
   237  		ldapGroups.Add(entry.DN)
   238  	}
   239  
   240  	return ldapGroups
   241  }
   242  
   243  func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string {
   244  	if strings.ToLower(source.UserUID) == "dn" {
   245  		return entry.DN
   246  	}
   247  
   248  	return entry.GetAttributeValue(source.UserUID)
   249  }
   250  
   251  // SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter
   252  func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult {
   253  	// See https://tools.ietf.org/search/rfc4513#section-5.1.2
   254  	if len(passwd) == 0 {
   255  		log.Debug("Auth. failed for %s, password cannot be empty", name)
   256  		return nil
   257  	}
   258  	l, err := dial(source)
   259  	if err != nil {
   260  		log.Error("LDAP Connect error, %s:%v", source.Host, err)
   261  		source.Enabled = false
   262  		return nil
   263  	}
   264  	defer l.Close()
   265  
   266  	var userDN string
   267  	if directBind {
   268  		log.Trace("LDAP will bind directly via UserDN template: %s", source.UserDN)
   269  
   270  		var ok bool
   271  		userDN, ok = source.sanitizedUserDN(name)
   272  
   273  		if !ok {
   274  			return nil
   275  		}
   276  
   277  		err = bindUser(l, userDN, passwd)
   278  		if err != nil {
   279  			return nil
   280  		}
   281  
   282  		if source.UserBase != "" {
   283  			// not everyone has a CN compatible with input name so we need to find
   284  			// the real userDN in that case
   285  
   286  			userDN, ok = source.findUserDN(l, name)
   287  			if !ok {
   288  				return nil
   289  			}
   290  		}
   291  	} else {
   292  		log.Trace("LDAP will use BindDN.")
   293  
   294  		var found bool
   295  
   296  		if source.BindDN != "" && source.BindPassword != "" {
   297  			err := l.Bind(source.BindDN, source.BindPassword)
   298  			if err != nil {
   299  				log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
   300  				return nil
   301  			}
   302  			log.Trace("Bound as BindDN %s", source.BindDN)
   303  		} else {
   304  			log.Trace("Proceeding with anonymous LDAP search.")
   305  		}
   306  
   307  		userDN, found = source.findUserDN(l, name)
   308  		if !found {
   309  			return nil
   310  		}
   311  	}
   312  
   313  	if !source.AttributesInBind {
   314  		// binds user (checking password) before looking-up attributes in user context
   315  		err = bindUser(l, userDN, passwd)
   316  		if err != nil {
   317  			return nil
   318  		}
   319  	}
   320  
   321  	userFilter, ok := source.sanitizedUserQuery(name)
   322  	if !ok {
   323  		return nil
   324  	}
   325  
   326  	isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
   327  	isAtributeAvatarSet := len(strings.TrimSpace(source.AttributeAvatar)) > 0
   328  
   329  	attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail}
   330  	if len(strings.TrimSpace(source.UserUID)) > 0 {
   331  		attribs = append(attribs, source.UserUID)
   332  	}
   333  	if isAttributeSSHPublicKeySet {
   334  		attribs = append(attribs, source.AttributeSSHPublicKey)
   335  	}
   336  	if isAtributeAvatarSet {
   337  		attribs = append(attribs, source.AttributeAvatar)
   338  	}
   339  
   340  	log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v', '%v' with filter '%s' and base '%s'", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, source.UserUID, userFilter, userDN)
   341  	search := ldap.NewSearchRequest(
   342  		userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
   343  		attribs, nil)
   344  
   345  	sr, err := l.Search(search)
   346  	if err != nil {
   347  		log.Error("LDAP Search failed unexpectedly! (%v)", err)
   348  		return nil
   349  	} else if len(sr.Entries) < 1 {
   350  		if directBind {
   351  			log.Trace("User filter inhibited user login.")
   352  		} else {
   353  			log.Trace("LDAP Search found no matching entries.")
   354  		}
   355  
   356  		return nil
   357  	}
   358  
   359  	var sshPublicKey []string
   360  	var Avatar []byte
   361  
   362  	username := sr.Entries[0].GetAttributeValue(source.AttributeUsername)
   363  	firstname := sr.Entries[0].GetAttributeValue(source.AttributeName)
   364  	surname := sr.Entries[0].GetAttributeValue(source.AttributeSurname)
   365  	mail := sr.Entries[0].GetAttributeValue(source.AttributeMail)
   366  
   367  	if isAttributeSSHPublicKeySet {
   368  		sshPublicKey = sr.Entries[0].GetAttributeValues(source.AttributeSSHPublicKey)
   369  	}
   370  
   371  	isAdmin := checkAdmin(l, source, userDN)
   372  
   373  	var isRestricted bool
   374  	if !isAdmin {
   375  		isRestricted = checkRestricted(l, source, userDN)
   376  	}
   377  
   378  	if isAtributeAvatarSet {
   379  		Avatar = sr.Entries[0].GetRawAttributeValue(source.AttributeAvatar)
   380  	}
   381  
   382  	// Check group membership
   383  	var usersLdapGroups container.Set[string]
   384  	if source.GroupsEnabled {
   385  		userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0])
   386  		usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
   387  
   388  		if source.GroupFilter != "" && len(usersLdapGroups) == 0 {
   389  			return nil
   390  		}
   391  	}
   392  
   393  	if !directBind && source.AttributesInBind {
   394  		// binds user (checking password) after looking-up attributes in BindDN context
   395  		err = bindUser(l, userDN, passwd)
   396  		if err != nil {
   397  			return nil
   398  		}
   399  	}
   400  
   401  	return &SearchResult{
   402  		LowerName:    strings.ToLower(username),
   403  		Username:     username,
   404  		Name:         firstname,
   405  		Surname:      surname,
   406  		Mail:         mail,
   407  		SSHPublicKey: sshPublicKey,
   408  		IsAdmin:      isAdmin,
   409  		IsRestricted: isRestricted,
   410  		Avatar:       Avatar,
   411  		Groups:       usersLdapGroups,
   412  	}
   413  }
   414  
   415  // UsePagedSearch returns if need to use paged search
   416  func (source *Source) UsePagedSearch() bool {
   417  	return source.SearchPageSize > 0
   418  }
   419  
   420  // SearchEntries : search an LDAP source for all users matching userFilter
   421  func (source *Source) SearchEntries() ([]*SearchResult, error) {
   422  	l, err := dial(source)
   423  	if err != nil {
   424  		log.Error("LDAP Connect error, %s:%v", source.Host, err)
   425  		source.Enabled = false
   426  		return nil, err
   427  	}
   428  	defer l.Close()
   429  
   430  	if source.BindDN != "" && source.BindPassword != "" {
   431  		err := l.Bind(source.BindDN, source.BindPassword)
   432  		if err != nil {
   433  			log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
   434  			return nil, err
   435  		}
   436  		log.Trace("Bound as BindDN %s", source.BindDN)
   437  	} else {
   438  		log.Trace("Proceeding with anonymous LDAP search.")
   439  	}
   440  
   441  	userFilter := fmt.Sprintf(source.Filter, "*")
   442  
   443  	isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
   444  	isAtributeAvatarSet := len(strings.TrimSpace(source.AttributeAvatar)) > 0
   445  
   446  	attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.UserUID}
   447  	if isAttributeSSHPublicKeySet {
   448  		attribs = append(attribs, source.AttributeSSHPublicKey)
   449  	}
   450  	if isAtributeAvatarSet {
   451  		attribs = append(attribs, source.AttributeAvatar)
   452  	}
   453  
   454  	log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v' with filter %s and base %s", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, userFilter, source.UserBase)
   455  	search := ldap.NewSearchRequest(
   456  		source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
   457  		attribs, nil)
   458  
   459  	var sr *ldap.SearchResult
   460  	if source.UsePagedSearch() {
   461  		sr, err = l.SearchWithPaging(search, source.SearchPageSize)
   462  	} else {
   463  		sr, err = l.Search(search)
   464  	}
   465  	if err != nil {
   466  		log.Error("LDAP Search failed unexpectedly! (%v)", err)
   467  		return nil, err
   468  	}
   469  
   470  	result := make([]*SearchResult, 0, len(sr.Entries))
   471  
   472  	for _, v := range sr.Entries {
   473  		var usersLdapGroups container.Set[string]
   474  		if source.GroupsEnabled {
   475  			userAttributeListedInGroup := source.getUserAttributeListedInGroup(v)
   476  
   477  			if source.GroupFilter != "" {
   478  				usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
   479  				if len(usersLdapGroups) == 0 {
   480  					continue
   481  				}
   482  			}
   483  
   484  			if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
   485  				usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, false)
   486  			}
   487  		}
   488  
   489  		user := &SearchResult{
   490  			Username: v.GetAttributeValue(source.AttributeUsername),
   491  			Name:     v.GetAttributeValue(source.AttributeName),
   492  			Surname:  v.GetAttributeValue(source.AttributeSurname),
   493  			Mail:     v.GetAttributeValue(source.AttributeMail),
   494  			IsAdmin:  checkAdmin(l, source, v.DN),
   495  			Groups:   usersLdapGroups,
   496  		}
   497  
   498  		if !user.IsAdmin {
   499  			user.IsRestricted = checkRestricted(l, source, v.DN)
   500  		}
   501  
   502  		if isAttributeSSHPublicKeySet {
   503  			user.SSHPublicKey = v.GetAttributeValues(source.AttributeSSHPublicKey)
   504  		}
   505  
   506  		if isAtributeAvatarSet {
   507  			user.Avatar = v.GetRawAttributeValue(source.AttributeAvatar)
   508  		}
   509  
   510  		user.LowerName = strings.ToLower(user.Username)
   511  
   512  		result = append(result, user)
   513  	}
   514  
   515  	return result, nil
   516  }