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 }