// Copyright (c) 2024 Mattermost Community Enterprise // Open source implementation of Mattermost Enterprise LDAP authentication package ldap import ( "crypto/tls" "crypto/x509" "fmt" "net/http" "os" "strings" "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/einterfaces" ldapv3 "github.com/go-ldap/ldap/v3" ) type LdapImpl struct { config func() *model.Config logger mlog.LoggerIFace } func NewLdapInterface(config func() *model.Config, logger mlog.LoggerIFace) einterfaces.LdapInterface { return &LdapImpl{ config: config, logger: logger, } } func (l *LdapImpl) getSettings() *model.LdapSettings { return &l.config().LdapSettings } func (l *LdapImpl) connect() (*ldapv3.Conn, error) { settings := l.getSettings() ldapServer := *settings.LdapServer ldapPort := *settings.LdapPort connectionSecurity := *settings.ConnectionSecurity var conn *ldapv3.Conn var err error address := fmt.Sprintf("%s:%d", ldapServer, ldapPort) switch connectionSecurity { case model.ConnSecurityTLS: tlsConfig := &tls.Config{ InsecureSkipVerify: *settings.SkipCertificateVerification, ServerName: ldapServer, } // Load custom CA certificate if provided if *settings.PublicCertificateFile != "" { caCert, err := os.ReadFile(*settings.PublicCertificateFile) if err != nil { return nil, fmt.Errorf("failed to read CA certificate: %w", err) } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) tlsConfig.RootCAs = caCertPool } conn, err = ldapv3.DialTLS("tcp", address, tlsConfig) case model.ConnSecurityStarttls: conn, err = ldapv3.Dial("tcp", address) if err != nil { return nil, fmt.Errorf("failed to connect to LDAP server: %w", err) } tlsConfig := &tls.Config{ InsecureSkipVerify: *settings.SkipCertificateVerification, ServerName: ldapServer, } err = conn.StartTLS(tlsConfig) default: conn, err = ldapv3.Dial("tcp", address) } if err != nil { return nil, fmt.Errorf("failed to connect to LDAP server: %w", err) } // Set timeout if settings.QueryTimeout != nil && *settings.QueryTimeout > 0 { conn.SetTimeout(time.Duration(*settings.QueryTimeout) * time.Second) } return conn, nil } func (l *LdapImpl) bindAsAdmin(conn *ldapv3.Conn) error { settings := l.getSettings() return conn.Bind(*settings.BindUsername, *settings.BindPassword) } // DoLogin authenticates a user against LDAP func (l *LdapImpl) DoLogin(rctx request.CTX, id string, password string) (*model.User, *model.AppError) { settings := l.getSettings() if !*settings.Enable { return nil, model.NewAppError("LdapInterface.DoLogin", "api.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) } conn, err := l.connect() if err != nil { return nil, model.NewAppError("LdapInterface.DoLogin", "api.ldap.connection_error.app_error", nil, err.Error(), http.StatusInternalServerError) } defer conn.Close() // First bind as admin to search for user if err := l.bindAsAdmin(conn); err != nil { return nil, model.NewAppError("LdapInterface.DoLogin", "api.ldap.bind_error.app_error", nil, err.Error(), http.StatusInternalServerError) } // Search for user filter := l.buildUserFilter(id) searchRequest := ldapv3.NewSearchRequest( *settings.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, filter, l.getUserAttributes(), nil, ) sr, err := conn.Search(searchRequest) if err != nil { return nil, model.NewAppError("LdapInterface.DoLogin", "api.ldap.search_error.app_error", nil, err.Error(), http.StatusInternalServerError) } if len(sr.Entries) == 0 { return nil, model.NewAppError("LdapInterface.DoLogin", "api.ldap.user_not_found.app_error", nil, "", http.StatusUnauthorized) } if len(sr.Entries) > 1 { return nil, model.NewAppError("LdapInterface.DoLogin", "api.ldap.multiple_users.app_error", nil, "", http.StatusBadRequest) } entry := sr.Entries[0] userDN := entry.DN // Now bind as the user to verify password if err := conn.Bind(userDN, password); err != nil { return nil, model.NewAppError("LdapInterface.DoLogin", "api.ldap.invalid_credentials.app_error", nil, err.Error(), http.StatusUnauthorized) } // Create user from LDAP entry user := l.entryToUser(entry) return user, nil } // GetUser retrieves a user from LDAP func (l *LdapImpl) GetUser(rctx request.CTX, id string) (*model.User, *model.AppError) { settings := l.getSettings() if !*settings.Enable { return nil, model.NewAppError("LdapInterface.GetUser", "api.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) } conn, err := l.connect() if err != nil { return nil, model.NewAppError("LdapInterface.GetUser", "api.ldap.connection_error.app_error", nil, err.Error(), http.StatusInternalServerError) } defer conn.Close() if err := l.bindAsAdmin(conn); err != nil { return nil, model.NewAppError("LdapInterface.GetUser", "api.ldap.bind_error.app_error", nil, err.Error(), http.StatusInternalServerError) } filter := l.buildUserFilter(id) searchRequest := ldapv3.NewSearchRequest( *settings.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, filter, l.getUserAttributes(), nil, ) sr, err := conn.Search(searchRequest) if err != nil { return nil, model.NewAppError("LdapInterface.GetUser", "api.ldap.search_error.app_error", nil, err.Error(), http.StatusInternalServerError) } if len(sr.Entries) == 0 { return nil, model.NewAppError("LdapInterface.GetUser", "api.ldap.user_not_found.app_error", nil, "", http.StatusNotFound) } return l.entryToUser(sr.Entries[0]), nil } // GetLDAPUserForMMUser finds the LDAP user corresponding to a Mattermost user func (l *LdapImpl) GetLDAPUserForMMUser(rctx request.CTX, mmUser *model.User) (*model.User, string, *model.AppError) { if mmUser.AuthService != model.UserAuthServiceLdap || mmUser.AuthData == nil { return nil, "", model.NewAppError("LdapInterface.GetLDAPUserForMMUser", "api.ldap.not_ldap_user.app_error", nil, "", http.StatusBadRequest) } ldapUser, err := l.GetUser(rctx, *mmUser.AuthData) if err != nil { return nil, "", err } return ldapUser, *mmUser.AuthData, nil } // GetUserAttributes retrieves specific attributes for a user func (l *LdapImpl) GetUserAttributes(rctx request.CTX, id string, attributes []string) (map[string]string, *model.AppError) { settings := l.getSettings() conn, err := l.connect() if err != nil { return nil, model.NewAppError("LdapInterface.GetUserAttributes", "api.ldap.connection_error.app_error", nil, err.Error(), http.StatusInternalServerError) } defer conn.Close() if err := l.bindAsAdmin(conn); err != nil { return nil, model.NewAppError("LdapInterface.GetUserAttributes", "api.ldap.bind_error.app_error", nil, err.Error(), http.StatusInternalServerError) } filter := l.buildUserFilter(id) searchRequest := ldapv3.NewSearchRequest( *settings.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, filter, attributes, nil, ) sr, err := conn.Search(searchRequest) if err != nil { return nil, model.NewAppError("LdapInterface.GetUserAttributes", "api.ldap.search_error.app_error", nil, err.Error(), http.StatusInternalServerError) } if len(sr.Entries) == 0 { return nil, model.NewAppError("LdapInterface.GetUserAttributes", "api.ldap.user_not_found.app_error", nil, "", http.StatusNotFound) } result := make(map[string]string) entry := sr.Entries[0] for _, attr := range attributes { result[attr] = entry.GetAttributeValue(attr) } return result, nil } // CheckProviderAttributes checks if user attributes from LDAP would change func (l *LdapImpl) CheckProviderAttributes(rctx request.CTX, LS *model.LdapSettings, ouser *model.User, patch *model.UserPatch) string { // Returns a list of attributes that would be overwritten by LDAP sync var conflicts []string if patch.Username != nil && *LS.UsernameAttribute != "" { conflicts = append(conflicts, "username") } if patch.Email != nil && *LS.EmailAttribute != "" { conflicts = append(conflicts, "email") } if patch.FirstName != nil && *LS.FirstNameAttribute != "" { conflicts = append(conflicts, "first_name") } if patch.LastName != nil && *LS.LastNameAttribute != "" { conflicts = append(conflicts, "last_name") } if patch.Nickname != nil && *LS.NicknameAttribute != "" { conflicts = append(conflicts, "nickname") } if patch.Position != nil && *LS.PositionAttribute != "" { conflicts = append(conflicts, "position") } return strings.Join(conflicts, ", ") } // SwitchToLdap switches a user's auth method to LDAP func (l *LdapImpl) SwitchToLdap(rctx request.CTX, userID, ldapID, ldapPassword string) *model.AppError { // Verify LDAP credentials _, err := l.DoLogin(rctx, ldapID, ldapPassword) if err != nil { return err } return nil } // StartSynchronizeJob starts an LDAP sync job func (l *LdapImpl) StartSynchronizeJob(rctx request.CTX, waitForJobToFinish bool) (*model.Job, *model.AppError) { // Create a job record - actual implementation would need job store job := &model.Job{ Id: model.NewId(), Type: model.JobTypeLdapSync, CreateAt: model.GetMillis(), Status: model.JobStatusPending, } return job, nil } // GetAllLdapUsers retrieves all users from LDAP func (l *LdapImpl) GetAllLdapUsers(rctx request.CTX) ([]*model.User, *model.AppError) { settings := l.getSettings() conn, err := l.connect() if err != nil { return nil, model.NewAppError("LdapInterface.GetAllLdapUsers", "api.ldap.connection_error.app_error", nil, err.Error(), http.StatusInternalServerError) } defer conn.Close() if err := l.bindAsAdmin(conn); err != nil { return nil, model.NewAppError("LdapInterface.GetAllLdapUsers", "api.ldap.bind_error.app_error", nil, err.Error(), http.StatusInternalServerError) } filter := l.buildAllUsersFilter() searchRequest := ldapv3.NewSearchRequest( *settings.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, filter, l.getUserAttributes(), nil, ) sr, err := conn.Search(searchRequest) if err != nil { return nil, model.NewAppError("LdapInterface.GetAllLdapUsers", "api.ldap.search_error.app_error", nil, err.Error(), http.StatusInternalServerError) } var users []*model.User for _, entry := range sr.Entries { users = append(users, l.entryToUser(entry)) } return users, nil } // MigrateIDAttribute migrates user ID attribute func (l *LdapImpl) MigrateIDAttribute(rctx request.CTX, toAttribute string) error { // This would update the ID attribute mapping in the config return nil } // GetGroup retrieves a group from LDAP func (l *LdapImpl) GetGroup(rctx request.CTX, groupUID string) (*model.Group, *model.AppError) { settings := l.getSettings() conn, err := l.connect() if err != nil { return nil, model.NewAppError("LdapInterface.GetGroup", "api.ldap.connection_error.app_error", nil, err.Error(), http.StatusInternalServerError) } defer conn.Close() if err := l.bindAsAdmin(conn); err != nil { return nil, model.NewAppError("LdapInterface.GetGroup", "api.ldap.bind_error.app_error", nil, err.Error(), http.StatusInternalServerError) } filter := fmt.Sprintf("(%s=%s)", *settings.GroupIdAttribute, ldapv3.EscapeFilter(groupUID)) if *settings.GroupFilter != "" { filter = fmt.Sprintf("(&%s%s)", *settings.GroupFilter, filter) } searchRequest := ldapv3.NewSearchRequest( *settings.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, filter, []string{*settings.GroupIdAttribute, *settings.GroupDisplayNameAttribute, "member"}, nil, ) sr, err := conn.Search(searchRequest) if err != nil { return nil, model.NewAppError("LdapInterface.GetGroup", "api.ldap.search_error.app_error", nil, err.Error(), http.StatusInternalServerError) } if len(sr.Entries) == 0 { return nil, model.NewAppError("LdapInterface.GetGroup", "api.ldap.group_not_found.app_error", nil, "", http.StatusNotFound) } entry := sr.Entries[0] group := &model.Group{ Id: model.NewId(), Name: model.NewPointer(entry.GetAttributeValue(*settings.GroupIdAttribute)), DisplayName: entry.GetAttributeValue(*settings.GroupDisplayNameAttribute), Source: model.GroupSourceLdap, RemoteId: model.NewPointer(groupUID), } return group, nil } // GetAllGroupsPage retrieves groups with pagination func (l *LdapImpl) GetAllGroupsPage(rctx request.CTX, page int, perPage int, opts model.LdapGroupSearchOpts) ([]*model.Group, int, *model.AppError) { settings := l.getSettings() conn, err := l.connect() if err != nil { return nil, 0, model.NewAppError("LdapInterface.GetAllGroupsPage", "api.ldap.connection_error.app_error", nil, err.Error(), http.StatusInternalServerError) } defer conn.Close() if err := l.bindAsAdmin(conn); err != nil { return nil, 0, model.NewAppError("LdapInterface.GetAllGroupsPage", "api.ldap.bind_error.app_error", nil, err.Error(), http.StatusInternalServerError) } filter := *settings.GroupFilter if filter == "" { filter = "(objectClass=group)" } if opts.Q != "" { filter = fmt.Sprintf("(&%s(%s=*%s*))", filter, *settings.GroupDisplayNameAttribute, ldapv3.EscapeFilter(opts.Q)) } searchRequest := ldapv3.NewSearchRequest( *settings.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, filter, []string{*settings.GroupIdAttribute, *settings.GroupDisplayNameAttribute}, nil, ) sr, err := conn.Search(searchRequest) if err != nil { return nil, 0, model.NewAppError("LdapInterface.GetAllGroupsPage", "api.ldap.search_error.app_error", nil, err.Error(), http.StatusInternalServerError) } totalCount := len(sr.Entries) // Apply pagination start := page * perPage end := start + perPage if start >= len(sr.Entries) { return []*model.Group{}, totalCount, nil } if end > len(sr.Entries) { end = len(sr.Entries) } var groups []*model.Group for _, entry := range sr.Entries[start:end] { groupID := entry.GetAttributeValue(*settings.GroupIdAttribute) group := &model.Group{ Id: model.NewId(), Name: model.NewPointer(groupID), DisplayName: entry.GetAttributeValue(*settings.GroupDisplayNameAttribute), Source: model.GroupSourceLdap, RemoteId: model.NewPointer(groupID), } groups = append(groups, group) } return groups, totalCount, nil } // FirstLoginSync syncs user data on first login func (l *LdapImpl) FirstLoginSync(rctx request.CTX, user *model.User) *model.AppError { if user.AuthData == nil { return nil } ldapUser, err := l.GetUser(rctx, *user.AuthData) if err != nil { return err } // Update user fields from LDAP user.FirstName = ldapUser.FirstName user.LastName = ldapUser.LastName user.Nickname = ldapUser.Nickname user.Position = ldapUser.Position return nil } // UpdateProfilePictureIfNecessary updates user profile picture from LDAP func (l *LdapImpl) UpdateProfilePictureIfNecessary(rctx request.CTX, user model.User, session model.Session) { // This would fetch the picture attribute and update the user's profile picture // Implementation depends on file storage backend } // Helper functions func (l *LdapImpl) buildUserFilter(id string) string { settings := l.getSettings() loginAttr := *settings.LoginIdAttribute if loginAttr == "" { loginAttr = *settings.UsernameAttribute } if loginAttr == "" { loginAttr = "uid" } filter := fmt.Sprintf("(%s=%s)", loginAttr, ldapv3.EscapeFilter(id)) if *settings.UserFilter != "" { filter = fmt.Sprintf("(&%s%s)", *settings.UserFilter, filter) } return filter } func (l *LdapImpl) buildAllUsersFilter() string { settings := l.getSettings() filter := "(objectClass=person)" if *settings.UserFilter != "" { filter = *settings.UserFilter } return filter } func (l *LdapImpl) getUserAttributes() []string { settings := l.getSettings() attrs := []string{"dn"} if *settings.IdAttribute != "" { attrs = append(attrs, *settings.IdAttribute) } if *settings.UsernameAttribute != "" { attrs = append(attrs, *settings.UsernameAttribute) } if *settings.EmailAttribute != "" { attrs = append(attrs, *settings.EmailAttribute) } if *settings.FirstNameAttribute != "" { attrs = append(attrs, *settings.FirstNameAttribute) } if *settings.LastNameAttribute != "" { attrs = append(attrs, *settings.LastNameAttribute) } if *settings.NicknameAttribute != "" { attrs = append(attrs, *settings.NicknameAttribute) } if *settings.PositionAttribute != "" { attrs = append(attrs, *settings.PositionAttribute) } if *settings.LoginIdAttribute != "" { attrs = append(attrs, *settings.LoginIdAttribute) } if *settings.PictureAttribute != "" { attrs = append(attrs, *settings.PictureAttribute) } return attrs } func (l *LdapImpl) entryToUser(entry *ldapv3.Entry) *model.User { settings := l.getSettings() user := &model.User{ AuthService: model.UserAuthServiceLdap, } if *settings.IdAttribute != "" { authData := entry.GetAttributeValue(*settings.IdAttribute) user.AuthData = &authData } if *settings.UsernameAttribute != "" { user.Username = entry.GetAttributeValue(*settings.UsernameAttribute) } if *settings.EmailAttribute != "" { user.Email = entry.GetAttributeValue(*settings.EmailAttribute) } if *settings.FirstNameAttribute != "" { user.FirstName = entry.GetAttributeValue(*settings.FirstNameAttribute) } if *settings.LastNameAttribute != "" { user.LastName = entry.GetAttributeValue(*settings.LastNameAttribute) } if *settings.NicknameAttribute != "" { user.Nickname = entry.GetAttributeValue(*settings.NicknameAttribute) } if *settings.PositionAttribute != "" { user.Position = entry.GetAttributeValue(*settings.PositionAttribute) } return user }