diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /services/auth/source/ldap | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'services/auth/source/ldap')
-rw-r--r-- | services/auth/source/ldap/README.md | 131 | ||||
-rw-r--r-- | services/auth/source/ldap/assert_interface_test.go | 27 | ||||
-rw-r--r-- | services/auth/source/ldap/security_protocol.go | 31 | ||||
-rw-r--r-- | services/auth/source/ldap/source.go | 122 | ||||
-rw-r--r-- | services/auth/source/ldap/source_authenticate.go | 124 | ||||
-rw-r--r-- | services/auth/source/ldap/source_search.go | 516 | ||||
-rw-r--r-- | services/auth/source/ldap/source_sync.go | 232 | ||||
-rw-r--r-- | services/auth/source/ldap/util.go | 18 |
8 files changed, 1201 insertions, 0 deletions
diff --git a/services/auth/source/ldap/README.md b/services/auth/source/ldap/README.md new file mode 100644 index 0000000..34c8117 --- /dev/null +++ b/services/auth/source/ldap/README.md @@ -0,0 +1,131 @@ +# Gitea LDAP Authentication Module + +## About + +This authentication module attempts to authorize and authenticate a user +against an LDAP server. It provides two methods of authentication: LDAP via +BindDN, and LDAP simple authentication. + +LDAP via BindDN functions like most LDAP authentication systems. First, it +queries the LDAP server using a Bind DN and searches for the user that is +attempting to sign in. If the user is found, the module attempts to bind to the +server using the user's supplied credentials. If this succeeds, the user has +been authenticated, and his account information is retrieved and passed to the +Gogs login infrastructure. + +LDAP simple authentication does not utilize a Bind DN. Instead, it binds +directly with the LDAP server using the user's supplied credentials. If the bind +succeeds and no filter rules out the user, the user is authenticated. + +LDAP via BindDN is recommended for most users. By using a Bind DN, the server +can perform authorization by restricting which entries the Bind DN account can +read. Further, using a Bind DN with reduced permissions can reduce security risk +in the face of application bugs. + +## Usage + +To use this module, add an LDAP authentication source via the Authentications +section in the admin panel. Both the LDAP via BindDN and the simple auth LDAP +share the following fields: + +* Authorization Name **(required)** + * A name to assign to the new method of authorization. + +* Host **(required)** + * The address where the LDAP server can be reached. + * Example: mydomain.com + +* Port **(required)** + * The port to use when connecting to the server. + * Example: 636 + +* Enable TLS Encryption (optional) + * Whether to use TLS when connecting to the LDAP server. + +* Admin Filter (optional) + * An LDAP filter specifying if a user should be given administrator + privileges. If a user accounts passes the filter, the user will be + privileged as an administrator. + * Example: (objectClass=adminAccount) + +* First name attribute (optional) + * The attribute of the user's LDAP record containing the user's first name. + This will be used to populate their account information. + * Example: givenName + +* Surname attribute (optional) + * The attribute of the user's LDAP record containing the user's surname This + will be used to populate their account information. + * Example: sn + +* E-mail attribute **(required)** + * The attribute of the user's LDAP record containing the user's email + address. This will be used to populate their account information. + * Example: mail + +**LDAP via BindDN** adds the following fields: + +* Bind DN (optional) + * The DN to bind to the LDAP server with when searching for the user. This + may be left blank to perform an anonymous search. + * Example: cn=Search,dc=mydomain,dc=com + +* Bind Password (optional) + * The password for the Bind DN specified above, if any. _Note: The password + is stored in plaintext at the server. As such, ensure that your Bind DN + has as few privileges as possible._ + +* User Search Base **(required)** + * The LDAP base at which user accounts will be searched for. + * Example: ou=Users,dc=mydomain,dc=com + +* User Filter **(required)** + * An LDAP filter declaring how to find the user record that is attempting to + authenticate. The '%[1]s' matching parameter will be substituted with the + user's username. + * Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s))) + +**LDAP using simple auth** adds the following fields: + +* User DN **(required)** + * A template to use as the user's DN. The `%s` matching parameter will be + substituted with the user's username. + * Example: cn=%s,ou=Users,dc=mydomain,dc=com + * Example: uid=%s,ou=Users,dc=mydomain,dc=com + +* User Search Base (optional) + * The LDAP base at which user accounts will be searched for. + * Example: ou=Users,dc=mydomain,dc=com + +* User Filter **(required)** + * An LDAP filter declaring when a user should be allowed to log in. The `%[1]s` + matching parameter will be substituted with the user's username. + * Example: (&(objectClass=posixAccount)(|(cn=%[1]s)(mail=%[1]s))) + * Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s))) + +**Verify group membership in LDAP** uses the following fields: + +* Group Search Base (optional) + * The LDAP DN used for groups. + * Example: ou=group,dc=mydomain,dc=com + +* Group Name Filter (optional) + * An LDAP filter declaring how to find valid groups in the above DN. + * Example: (|(cn=gitea_users)(cn=admins)) + +* User Attribute in Group (optional) + * The user attribute that is used to reference a user in the group object. + * Example: uid if the group objects contains a member: bender and the user object contains a uid: bender. + * Example: dn if the group object contains a member: uid=bender,ou=users,dc=planetexpress,dc=com. + +* Group Attribute for User (optional) + * The attribute of the group object that lists/contains the group members. + * Example: memberUid or member + +* Team group map (optional) + * Automatically add users to Organization teams, depending on LDAP group memberships. + * Note: this function only adds users to teams, it never removes users. + * Example: {"cn=MyGroup,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2", ...], ...}, ...} + +* Team group map removal (optional) + * If set to true, users will be removed from teams if they are not members of the corresponding group. diff --git a/services/auth/source/ldap/assert_interface_test.go b/services/auth/source/ldap/assert_interface_test.go new file mode 100644 index 0000000..3334768 --- /dev/null +++ b/services/auth/source/ldap/assert_interface_test.go @@ -0,0 +1,27 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap_test + +import ( + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/ldap" +) + +// This test file exists to assert that our Source exposes the interfaces that we expect +// It tightly binds the interfaces and implementation without breaking go import cycles + +type sourceInterface interface { + auth.PasswordAuthenticator + auth.SynchronizableSource + auth.LocalTwoFASkipper + auth_model.SSHKeyProvider + auth_model.Config + auth_model.SkipVerifiable + auth_model.HasTLSer + auth_model.UseTLSer + auth_model.SourceSettable +} + +var _ (sourceInterface) = &ldap.Source{} diff --git a/services/auth/source/ldap/security_protocol.go b/services/auth/source/ldap/security_protocol.go new file mode 100644 index 0000000..af83ce1 --- /dev/null +++ b/services/auth/source/ldap/security_protocol.go @@ -0,0 +1,31 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap + +// SecurityProtocol protocol type +type SecurityProtocol int + +// Note: new type must be added at the end of list to maintain compatibility. +const ( + SecurityProtocolUnencrypted SecurityProtocol = iota + SecurityProtocolLDAPS + SecurityProtocolStartTLS +) + +// String returns the name of the SecurityProtocol +func (s SecurityProtocol) String() string { + return SecurityProtocolNames[s] +} + +// Int returns the int value of the SecurityProtocol +func (s SecurityProtocol) Int() int { + return int(s) +} + +// SecurityProtocolNames contains the name of SecurityProtocol values. +var SecurityProtocolNames = map[SecurityProtocol]string{ + SecurityProtocolUnencrypted: "Unencrypted", + SecurityProtocolLDAPS: "LDAPS", + SecurityProtocolStartTLS: "StartTLS", +} diff --git a/services/auth/source/ldap/source.go b/services/auth/source/ldap/source.go new file mode 100644 index 0000000..ba407b3 --- /dev/null +++ b/services/auth/source/ldap/source.go @@ -0,0 +1,122 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap + +import ( + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" +) + +// .____ ________ _____ __________ +// | | \______ \ / _ \\______ \ +// | | | | \ / /_\ \| ___/ +// | |___ | ` \/ | \ | +// |_______ \/_______ /\____|__ /____| +// \/ \/ \/ + +// Package ldap provide functions & structure to query a LDAP ldap directory +// For now, it's mainly tested again an MS Active Directory service, see README.md for more information + +// Source Basic LDAP authentication service +type Source struct { + Name string // canonical name (ie. corporate.ad) + Host string // LDAP host + Port int // port number + SecurityProtocol SecurityProtocol + SkipVerify bool + BindDN string // DN to bind with + BindPasswordEncrypt string // Encrypted Bind BN password + BindPassword string // Bind DN password + UserBase string // Base search path for users + UserDN string // Template for the DN of the user for simple auth + DefaultDomainName string // DomainName used if none are in the field, default "localhost.local" + AttributeUsername string // Username attribute + AttributeName string // First name attribute + AttributeSurname string // Surname attribute + AttributeMail string // E-mail attribute + AttributesInBind bool // fetch attributes in bind context (not user) + AttributeSSHPublicKey string // LDAP SSH Public Key attribute + AttributeAvatar string + SearchPageSize uint32 // Search with paging page size + Filter string // Query filter to validate entry + AdminFilter string // Query filter to check if user is admin + RestrictedFilter string // Query filter to check if user is restricted + Enabled bool // if this source is disabled + AllowDeactivateAll bool // Allow an empty search response to deactivate all users from this source + GroupsEnabled bool // if the group checking is enabled + GroupDN string // Group Search Base + GroupFilter string // Group Name Filter + GroupMemberUID string // Group Attribute containing array of UserUID + GroupTeamMap string // Map LDAP groups to teams + GroupTeamMapRemoval bool // Remove user from teams which are synchronized and user is not a member of the corresponding LDAP group + UserUID string // User Attribute listed in Group + SkipLocalTwoFA bool `json:",omitempty"` // Skip Local 2fa for users authenticated with this source + + // reference to the authSource + authSource *auth.Source +} + +// FromDB fills up a LDAPConfig from serialized format. +func (source *Source) FromDB(bs []byte) error { + err := json.UnmarshalHandleDoubleEncode(bs, &source) + if err != nil { + return err + } + if source.BindPasswordEncrypt != "" { + source.BindPassword, err = secret.DecryptSecret(setting.SecretKey, source.BindPasswordEncrypt) + source.BindPasswordEncrypt = "" + } + return err +} + +// ToDB exports a LDAPConfig to a serialized format. +func (source *Source) ToDB() ([]byte, error) { + var err error + source.BindPasswordEncrypt, err = secret.EncryptSecret(setting.SecretKey, source.BindPassword) + if err != nil { + return nil, err + } + source.BindPassword = "" + return json.Marshal(source) +} + +// SecurityProtocolName returns the name of configured security +// protocol. +func (source *Source) SecurityProtocolName() string { + return SecurityProtocolNames[source.SecurityProtocol] +} + +// IsSkipVerify returns if SkipVerify is set +func (source *Source) IsSkipVerify() bool { + return source.SkipVerify +} + +// HasTLS returns if HasTLS +func (source *Source) HasTLS() bool { + return source.SecurityProtocol > SecurityProtocolUnencrypted +} + +// UseTLS returns if UseTLS +func (source *Source) UseTLS() bool { + return source.SecurityProtocol != SecurityProtocolUnencrypted +} + +// ProvidesSSHKeys returns if this source provides SSH Keys +func (source *Source) ProvidesSSHKeys() bool { + return len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 +} + +// SetAuthSource sets the related AuthSource +func (source *Source) SetAuthSource(authSource *auth.Source) { + source.authSource = authSource +} + +func init() { + auth.RegisterTypeConfig(auth.LDAP, &Source{}) + auth.RegisterTypeConfig(auth.DLDAP, &Source{}) +} diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go new file mode 100644 index 0000000..68ecd16 --- /dev/null +++ b/services/auth/source/ldap/source_authenticate.go @@ -0,0 +1,124 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap + +import ( + "context" + "fmt" + "strings" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/optional" + source_service "code.gitea.io/gitea/services/auth/source" + user_service "code.gitea.io/gitea/services/user" +) + +// Authenticate queries if login/password is valid against the LDAP directory pool, +// and create a local user if success when enabled. +func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) { + loginName := userName + if user != nil { + loginName = user.LoginName + } + sr := source.SearchEntry(loginName, password, source.authSource.Type == auth.DLDAP) + if sr == nil { + // User not in LDAP, do nothing + return nil, user_model.ErrUserNotExist{Name: loginName} + } + // Fallback. + if len(sr.Username) == 0 { + sr.Username = userName + } + if len(sr.Mail) == 0 { + sr.Mail = fmt.Sprintf("%s@localhost.local", sr.Username) + } + isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 + + // Update User admin flag if exist + if isExist, err := user_model.IsUserExist(ctx, 0, sr.Username); err != nil { + return nil, err + } else if isExist { + if user == nil { + user, err = user_model.GetUserByName(ctx, sr.Username) + if err != nil { + return nil, err + } + } + if user != nil && !user.ProhibitLogin { + opts := &user_service.UpdateOptions{} + if len(source.AdminFilter) > 0 && user.IsAdmin != sr.IsAdmin { + // Change existing admin flag only if AdminFilter option is set + opts.IsAdmin = optional.Some(sr.IsAdmin) + } + if !sr.IsAdmin && len(source.RestrictedFilter) > 0 && user.IsRestricted != sr.IsRestricted { + // Change existing restricted flag only if RestrictedFilter option is set + opts.IsRestricted = optional.Some(sr.IsRestricted) + } + if opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if err := user_service.UpdateUser(ctx, user, opts); err != nil { + return nil, err + } + } + } + } + + if user != nil { + if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sr.SSHPublicKey) { + if err := asymkey_model.RewriteAllPublicKeys(ctx); err != nil { + return user, err + } + } + } else { + user = &user_model.User{ + LowerName: strings.ToLower(sr.Username), + Name: sr.Username, + FullName: composeFullName(sr.Name, sr.Surname, sr.Username), + Email: sr.Mail, + LoginType: source.authSource.Type, + LoginSource: source.authSource.ID, + LoginName: userName, + IsAdmin: sr.IsAdmin, + } + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsRestricted: optional.Some(sr.IsRestricted), + IsActive: optional.Some(true), + } + + err := user_model.CreateUser(ctx, user, overwriteDefault) + if err != nil { + return user, err + } + + if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.authSource, sr.SSHPublicKey) { + if err := asymkey_model.RewriteAllPublicKeys(ctx); err != nil { + return user, err + } + } + if len(source.AttributeAvatar) > 0 { + if err := user_service.UploadAvatar(ctx, user, sr.Avatar); err != nil { + return user, err + } + } + } + + if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) { + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return user, err + } + if err := source_service.SyncGroupsToTeams(ctx, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + return user, err + } + } + + return user, nil +} + +// IsSkipLocalTwoFA returns if this source should skip local 2fa for password authentication +func (source *Source) IsSkipLocalTwoFA() bool { + return source.SkipLocalTwoFA +} diff --git a/services/auth/source/ldap/source_search.go b/services/auth/source/ldap/source_search.go new file mode 100644 index 0000000..2a61386 --- /dev/null +++ b/services/auth/source/ldap/source_search.go @@ -0,0 +1,516 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap + +import ( + "crypto/tls" + "fmt" + "net" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + + "github.com/go-ldap/ldap/v3" +) + +// SearchResult : user data +type SearchResult struct { + Username string // Username + Name string // Name + Surname string // Surname + Mail string // E-mail address + SSHPublicKey []string // SSH Public Key + IsAdmin bool // if user is administrator + IsRestricted bool // if user is restricted + LowerName string // LowerName + Avatar []byte + Groups container.Set[string] +} + +func (source *Source) sanitizedUserQuery(username string) (string, bool) { + // See http://tools.ietf.org/search/rfc4515 + badCharacters := "\x00()*\\" + if strings.ContainsAny(username, badCharacters) { + log.Debug("'%s' contains invalid query characters. Aborting.", username) + return "", false + } + + return fmt.Sprintf(source.Filter, username), true +} + +func (source *Source) sanitizedUserDN(username string) (string, bool) { + // See http://tools.ietf.org/search/rfc4514: "special characters" + badCharacters := "\x00()*\\,='\"#+;<>" + if strings.ContainsAny(username, badCharacters) { + log.Debug("'%s' contains invalid DN characters. Aborting.", username) + return "", false + } + + return fmt.Sprintf(source.UserDN, username), true +} + +func (source *Source) sanitizedGroupFilter(group string) (string, bool) { + // See http://tools.ietf.org/search/rfc4515 + badCharacters := "\x00*\\" + if strings.ContainsAny(group, badCharacters) { + log.Trace("Group filter invalid query characters: %s", group) + return "", false + } + + return group, true +} + +func (source *Source) sanitizedGroupDN(groupDn string) (string, bool) { + // See http://tools.ietf.org/search/rfc4514: "special characters" + badCharacters := "\x00()*\\'\"#+;<>" + if strings.ContainsAny(groupDn, badCharacters) || strings.HasPrefix(groupDn, " ") || strings.HasSuffix(groupDn, " ") { + log.Trace("Group DN contains invalid query characters: %s", groupDn) + return "", false + } + + return groupDn, true +} + +func (source *Source) findUserDN(l *ldap.Conn, name string) (string, bool) { + log.Trace("Search for LDAP user: %s", name) + + // A search for the user. + userFilter, ok := source.sanitizedUserQuery(name) + if !ok { + return "", false + } + + log.Trace("Searching for DN using filter %s and base %s", userFilter, source.UserBase) + search := ldap.NewSearchRequest( + source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, + false, userFilter, []string{}, nil) + + // Ensure we found a user + sr, err := l.Search(search) + if err != nil || len(sr.Entries) < 1 { + log.Debug("Failed search using filter[%s]: %v", userFilter, err) + return "", false + } else if len(sr.Entries) > 1 { + log.Debug("Filter '%s' returned more than one user.", userFilter) + return "", false + } + + userDN := sr.Entries[0].DN + if userDN == "" { + log.Error("LDAP search was successful, but found no DN!") + return "", false + } + + return userDN, true +} + +func dial(source *Source) (*ldap.Conn, error) { + log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", source.SecurityProtocol, source.SkipVerify) + + tlsConfig := &tls.Config{ + ServerName: source.Host, + InsecureSkipVerify: source.SkipVerify, + } + + if source.SecurityProtocol == SecurityProtocolLDAPS { + return ldap.DialTLS("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port)), tlsConfig) + } + + conn, err := ldap.Dial("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port))) + if err != nil { + return nil, fmt.Errorf("error during Dial: %w", err) + } + + if source.SecurityProtocol == SecurityProtocolStartTLS { + if err = conn.StartTLS(tlsConfig); err != nil { + conn.Close() + return nil, fmt.Errorf("error during StartTLS: %w", err) + } + } + + return conn, nil +} + +func bindUser(l *ldap.Conn, userDN, passwd string) error { + log.Trace("Binding with userDN: %s", userDN) + err := l.Bind(userDN, passwd) + if err != nil { + log.Debug("LDAP auth. failed for %s, reason: %v", userDN, err) + return err + } + log.Trace("Bound successfully with userDN: %s", userDN) + return err +} + +func checkAdmin(l *ldap.Conn, ls *Source, userDN string) bool { + if len(ls.AdminFilter) == 0 { + return false + } + log.Trace("Checking admin with filter %s and base %s", ls.AdminFilter, userDN) + search := ldap.NewSearchRequest( + userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.AdminFilter, + []string{ls.AttributeName}, + nil) + + sr, err := l.Search(search) + + if err != nil { + log.Error("LDAP Admin Search with filter %s for %s failed unexpectedly! (%v)", ls.AdminFilter, userDN, err) + } else if len(sr.Entries) < 1 { + log.Trace("LDAP Admin Search found no matching entries.") + } else { + return true + } + return false +} + +func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool { + if len(ls.RestrictedFilter) == 0 { + return false + } + if ls.RestrictedFilter == "*" { + return true + } + log.Trace("Checking restricted with filter %s and base %s", ls.RestrictedFilter, userDN) + search := ldap.NewSearchRequest( + userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.RestrictedFilter, + []string{ls.AttributeName}, + nil) + + sr, err := l.Search(search) + + if err != nil { + log.Error("LDAP Restrictred Search with filter %s for %s failed unexpectedly! (%v)", ls.RestrictedFilter, userDN, err) + } else if len(sr.Entries) < 1 { + log.Trace("LDAP Restricted Search found no matching entries.") + } else { + return true + } + return false +} + +// List all group memberships of a user +func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) container.Set[string] { + ldapGroups := make(container.Set[string]) + + groupFilter, ok := source.sanitizedGroupFilter(source.GroupFilter) + if !ok { + return ldapGroups + } + + groupDN, ok := source.sanitizedGroupDN(source.GroupDN) + if !ok { + return ldapGroups + } + + var searchFilter string + if applyGroupFilter && groupFilter != "" { + searchFilter = fmt.Sprintf("(&(%s)(%s=%s))", groupFilter, source.GroupMemberUID, ldap.EscapeFilter(uid)) + } else { + searchFilter = fmt.Sprintf("(%s=%s)", source.GroupMemberUID, ldap.EscapeFilter(uid)) + } + result, err := l.Search(ldap.NewSearchRequest( + groupDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, + 0, + false, + searchFilter, + []string{}, + nil, + )) + if err != nil { + log.Error("Failed group search in LDAP with filter [%s]: %v", searchFilter, err) + return ldapGroups + } + + for _, entry := range result.Entries { + if entry.DN == "" { + log.Error("LDAP search was successful, but found no DN!") + continue + } + ldapGroups.Add(entry.DN) + } + + return ldapGroups +} + +func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string { + if strings.ToLower(source.UserUID) == "dn" { + return entry.DN + } + + return entry.GetAttributeValue(source.UserUID) +} + +// SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter +func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult { + // See https://tools.ietf.org/search/rfc4513#section-5.1.2 + if len(passwd) == 0 { + log.Debug("Auth. failed for %s, password cannot be empty", name) + return nil + } + l, err := dial(source) + if err != nil { + log.Error("LDAP Connect error, %s:%v", source.Host, err) + source.Enabled = false + return nil + } + defer l.Close() + + var userDN string + if directBind { + log.Trace("LDAP will bind directly via UserDN template: %s", source.UserDN) + + var ok bool + userDN, ok = source.sanitizedUserDN(name) + + if !ok { + return nil + } + + err = bindUser(l, userDN, passwd) + if err != nil { + return nil + } + + if source.UserBase != "" { + // not everyone has a CN compatible with input name so we need to find + // the real userDN in that case + + userDN, ok = source.findUserDN(l, name) + if !ok { + return nil + } + } + } else { + log.Trace("LDAP will use BindDN.") + + var found bool + + if source.BindDN != "" && source.BindPassword != "" { + err := l.Bind(source.BindDN, source.BindPassword) + if err != nil { + log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err) + return nil + } + log.Trace("Bound as BindDN %s", source.BindDN) + } else { + log.Trace("Proceeding with anonymous LDAP search.") + } + + userDN, found = source.findUserDN(l, name) + if !found { + return nil + } + } + + if !source.AttributesInBind { + // binds user (checking password) before looking-up attributes in user context + err = bindUser(l, userDN, passwd) + if err != nil { + return nil + } + } + + userFilter, ok := source.sanitizedUserQuery(name) + if !ok { + return nil + } + + isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 + isAtributeAvatarSet := len(strings.TrimSpace(source.AttributeAvatar)) > 0 + + attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail} + if len(strings.TrimSpace(source.UserUID)) > 0 { + attribs = append(attribs, source.UserUID) + } + if isAttributeSSHPublicKeySet { + attribs = append(attribs, source.AttributeSSHPublicKey) + } + if isAtributeAvatarSet { + attribs = append(attribs, source.AttributeAvatar) + } + + 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) + search := ldap.NewSearchRequest( + userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter, + attribs, nil) + + sr, err := l.Search(search) + if err != nil { + log.Error("LDAP Search failed unexpectedly! (%v)", err) + return nil + } else if len(sr.Entries) < 1 { + if directBind { + log.Trace("User filter inhibited user login.") + } else { + log.Trace("LDAP Search found no matching entries.") + } + + return nil + } + + var sshPublicKey []string + var Avatar []byte + + username := sr.Entries[0].GetAttributeValue(source.AttributeUsername) + firstname := sr.Entries[0].GetAttributeValue(source.AttributeName) + surname := sr.Entries[0].GetAttributeValue(source.AttributeSurname) + mail := sr.Entries[0].GetAttributeValue(source.AttributeMail) + + if isAttributeSSHPublicKeySet { + sshPublicKey = sr.Entries[0].GetAttributeValues(source.AttributeSSHPublicKey) + } + + isAdmin := checkAdmin(l, source, userDN) + + var isRestricted bool + if !isAdmin { + isRestricted = checkRestricted(l, source, userDN) + } + + if isAtributeAvatarSet { + Avatar = sr.Entries[0].GetRawAttributeValue(source.AttributeAvatar) + } + + // Check group membership + var usersLdapGroups container.Set[string] + if source.GroupsEnabled { + userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0]) + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) + + if source.GroupFilter != "" && len(usersLdapGroups) == 0 { + return nil + } + } + + if !directBind && source.AttributesInBind { + // binds user (checking password) after looking-up attributes in BindDN context + err = bindUser(l, userDN, passwd) + if err != nil { + return nil + } + } + + return &SearchResult{ + LowerName: strings.ToLower(username), + Username: username, + Name: firstname, + Surname: surname, + Mail: mail, + SSHPublicKey: sshPublicKey, + IsAdmin: isAdmin, + IsRestricted: isRestricted, + Avatar: Avatar, + Groups: usersLdapGroups, + } +} + +// UsePagedSearch returns if need to use paged search +func (source *Source) UsePagedSearch() bool { + return source.SearchPageSize > 0 +} + +// SearchEntries : search an LDAP source for all users matching userFilter +func (source *Source) SearchEntries() ([]*SearchResult, error) { + l, err := dial(source) + if err != nil { + log.Error("LDAP Connect error, %s:%v", source.Host, err) + source.Enabled = false + return nil, err + } + defer l.Close() + + if source.BindDN != "" && source.BindPassword != "" { + err := l.Bind(source.BindDN, source.BindPassword) + if err != nil { + log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err) + return nil, err + } + log.Trace("Bound as BindDN %s", source.BindDN) + } else { + log.Trace("Proceeding with anonymous LDAP search.") + } + + userFilter := fmt.Sprintf(source.Filter, "*") + + isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 + isAtributeAvatarSet := len(strings.TrimSpace(source.AttributeAvatar)) > 0 + + attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.UserUID} + if isAttributeSSHPublicKeySet { + attribs = append(attribs, source.AttributeSSHPublicKey) + } + if isAtributeAvatarSet { + attribs = append(attribs, source.AttributeAvatar) + } + + 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) + search := ldap.NewSearchRequest( + source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter, + attribs, nil) + + var sr *ldap.SearchResult + if source.UsePagedSearch() { + sr, err = l.SearchWithPaging(search, source.SearchPageSize) + } else { + sr, err = l.Search(search) + } + if err != nil { + log.Error("LDAP Search failed unexpectedly! (%v)", err) + return nil, err + } + + result := make([]*SearchResult, 0, len(sr.Entries)) + + for _, v := range sr.Entries { + var usersLdapGroups container.Set[string] + if source.GroupsEnabled { + userAttributeListedInGroup := source.getUserAttributeListedInGroup(v) + + if source.GroupFilter != "" { + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) + if len(usersLdapGroups) == 0 { + continue + } + } + + if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, false) + } + } + + user := &SearchResult{ + Username: v.GetAttributeValue(source.AttributeUsername), + Name: v.GetAttributeValue(source.AttributeName), + Surname: v.GetAttributeValue(source.AttributeSurname), + Mail: v.GetAttributeValue(source.AttributeMail), + IsAdmin: checkAdmin(l, source, v.DN), + Groups: usersLdapGroups, + } + + if !user.IsAdmin { + user.IsRestricted = checkRestricted(l, source, v.DN) + } + + if isAttributeSSHPublicKeySet { + user.SSHPublicKey = v.GetAttributeValues(source.AttributeSSHPublicKey) + } + + if isAtributeAvatarSet { + user.Avatar = v.GetRawAttributeValue(source.AttributeAvatar) + } + + user.LowerName = strings.ToLower(user.Username) + + result = append(result, user) + } + + return result, nil +} diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go new file mode 100644 index 0000000..1f70eda --- /dev/null +++ b/services/auth/source/ldap/source_sync.go @@ -0,0 +1,232 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap + +import ( + "context" + "fmt" + "strings" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + source_service "code.gitea.io/gitea/services/auth/source" + user_service "code.gitea.io/gitea/services/user" +) + +// Sync causes this ldap source to synchronize its users with the db +func (source *Source) Sync(ctx context.Context, updateExisting bool) error { + log.Trace("Doing: SyncExternalUsers[%s]", source.authSource.Name) + + isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 + var sshKeysNeedUpdate bool + + // Find all users with this login type - FIXME: Should this be an iterator? + users, err := user_model.GetUsersBySource(ctx, source.authSource) + if err != nil { + log.Error("SyncExternalUsers: %v", err) + return err + } + select { + case <-ctx.Done(): + log.Warn("SyncExternalUsers: Cancelled before update of %s", source.authSource.Name) + return db.ErrCancelledf("Before update of %s", source.authSource.Name) + default: + } + + usernameUsers := make(map[string]*user_model.User, len(users)) + mailUsers := make(map[string]*user_model.User, len(users)) + keepActiveUsers := make(container.Set[int64]) + + for _, u := range users { + usernameUsers[u.LowerName] = u + mailUsers[strings.ToLower(u.Email)] = u + } + + sr, err := source.SearchEntries() + if err != nil { + log.Error("SyncExternalUsers LDAP source failure [%s], skipped", source.authSource.Name) + return nil + } + + if len(sr) == 0 { + if !source.AllowDeactivateAll { + log.Error("LDAP search found no entries but did not report an error. Refusing to deactivate all users") + return nil + } + log.Warn("LDAP search found no entries but did not report an error. All users will be deactivated as per settings") + } + + orgCache := make(map[string]*organization.Organization) + teamCache := make(map[string]*organization.Team) + + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return err + } + + for _, su := range sr { + select { + case <-ctx.Done(): + log.Warn("SyncExternalUsers: Cancelled at update of %s before completed update of users", source.authSource.Name) + // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed + if sshKeysNeedUpdate { + err = asymkey_model.RewriteAllPublicKeys(ctx) + if err != nil { + log.Error("RewriteAllPublicKeys: %v", err) + } + } + return db.ErrCancelledf("During update of %s before completed update of users", source.authSource.Name) + default: + } + if len(su.Username) == 0 && len(su.Mail) == 0 { + continue + } + + var usr *user_model.User + if len(su.Username) > 0 { + usr = usernameUsers[su.LowerName] + } + if usr == nil && len(su.Mail) > 0 { + usr = mailUsers[strings.ToLower(su.Mail)] + } + + if usr != nil { + keepActiveUsers.Add(usr.ID) + } else if len(su.Username) == 0 { + // we cannot create the user if su.Username is empty + continue + } + + if len(su.Mail) == 0 { + domainName := source.DefaultDomainName + if len(domainName) == 0 { + domainName = "localhost.local" + } + su.Mail = fmt.Sprintf("%s@%s", su.Username, domainName) + } + + fullName := composeFullName(su.Name, su.Surname, su.Username) + // If no existing user found, create one + if usr == nil { + log.Trace("SyncExternalUsers[%s]: Creating user %s", source.authSource.Name, su.Username) + + usr = &user_model.User{ + LowerName: su.LowerName, + Name: su.Username, + FullName: fullName, + LoginType: source.authSource.Type, + LoginSource: source.authSource.ID, + LoginName: su.Username, + Email: su.Mail, + IsAdmin: su.IsAdmin, + } + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsRestricted: optional.Some(su.IsRestricted), + IsActive: optional.Some(true), + } + + err = user_model.CreateUser(ctx, usr, overwriteDefault) + if err != nil { + log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", source.authSource.Name, su.Username, err) + } + + if err == nil && isAttributeSSHPublicKeySet { + log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.authSource.Name, usr.Name) + if asymkey_model.AddPublicKeysBySource(ctx, usr, source.authSource, su.SSHPublicKey) { + sshKeysNeedUpdate = true + } + } + + if err == nil && len(source.AttributeAvatar) > 0 { + _ = user_service.UploadAvatar(ctx, usr, su.Avatar) + } + } else if updateExisting { + // Synchronize SSH Public Key if that attribute is set + if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.authSource, su.SSHPublicKey) { + sshKeysNeedUpdate = true + } + + // Check if user data has changed + if (len(source.AdminFilter) > 0 && usr.IsAdmin != su.IsAdmin) || + (len(source.RestrictedFilter) > 0 && usr.IsRestricted != su.IsRestricted) || + !strings.EqualFold(usr.Email, su.Mail) || + usr.FullName != fullName || + !usr.IsActive { + log.Trace("SyncExternalUsers[%s]: Updating user %s", source.authSource.Name, usr.Name) + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(fullName), + IsActive: optional.Some(true), + } + if source.AdminFilter != "" { + opts.IsAdmin = optional.Some(su.IsAdmin) + } + // Change existing restricted flag only if RestrictedFilter option is set + if !su.IsAdmin && source.RestrictedFilter != "" { + opts.IsRestricted = optional.Some(su.IsRestricted) + } + + if err := user_service.UpdateUser(ctx, usr, opts); err != nil { + log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", source.authSource.Name, usr.Name, err) + } + + if err := user_service.ReplacePrimaryEmailAddress(ctx, usr, su.Mail); err != nil { + log.Error("SyncExternalUsers[%s]: Error updating user %s primary email %s: %v", source.authSource.Name, usr.Name, su.Mail, err) + } + } + + if usr.IsUploadAvatarChanged(su.Avatar) { + if err == nil && len(source.AttributeAvatar) > 0 { + _ = user_service.UploadAvatar(ctx, usr, su.Avatar) + } + } + } + // Synchronize LDAP groups with organization and team memberships + if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) { + if err := source_service.SyncGroupsToTeamsCached(ctx, usr, su.Groups, groupTeamMapping, source.GroupTeamMapRemoval, orgCache, teamCache); err != nil { + log.Error("SyncGroupsToTeamsCached: %v", err) + } + } + } + + // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed + if sshKeysNeedUpdate { + err = asymkey_model.RewriteAllPublicKeys(ctx) + if err != nil { + log.Error("RewriteAllPublicKeys: %v", err) + } + } + + select { + case <-ctx.Done(): + log.Warn("SyncExternalUsers: Cancelled during update of %s before delete users", source.authSource.Name) + return db.ErrCancelledf("During update of %s before delete users", source.authSource.Name) + default: + } + + // Deactivate users not present in LDAP + if updateExisting { + for _, usr := range users { + if keepActiveUsers.Contains(usr.ID) { + continue + } + + log.Trace("SyncExternalUsers[%s]: Deactivating user %s", source.authSource.Name, usr.Name) + + opts := &user_service.UpdateOptions{ + IsActive: optional.Some(false), + } + if err := user_service.UpdateUser(ctx, usr, opts); err != nil { + log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", source.authSource.Name, usr.Name, err) + } + } + } + return nil +} diff --git a/services/auth/source/ldap/util.go b/services/auth/source/ldap/util.go new file mode 100644 index 0000000..bd11e2d --- /dev/null +++ b/services/auth/source/ldap/util.go @@ -0,0 +1,18 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap + +// composeFullName composes a firstname surname or username +func composeFullName(firstname, surname, username string) string { + switch { + case len(firstname) == 0 && len(surname) == 0: + return username + case len(firstname) == 0: + return surname + case len(surname) == 0: + return firstname + default: + return firstname + " " + surname + } +} |