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