package service import ( "crypto/tls" "crypto/x509" "errors" "fmt" "net/url" "os" "strconv" "strings" "github.com/go-ldap/ldap/v3" "github.com/lejianwen/rustdesk-api/v2/config" "github.com/lejianwen/rustdesk-api/v2/model" ) var ( ErrUrlParseFailed = errors.New("UrlParseFailed") ErrFileReadFailed = errors.New("FileReadFailed") ErrLdapNotEnabled = errors.New("LdapNotEnabled") ErrLdapUserDisabled = errors.New("UserDisabledAtLdap") ErrLdapUserNotFound = errors.New("UserNotFound") ErrLdapMailNotMatch = errors.New("MailNotMatch") ErrLdapConnectFailed = errors.New("LdapConnectFailed") ErrLdapSearchFailed = errors.New("LdapSearchRequestFailed") ErrLdapTlsFailed = errors.New("LdapStartTLSFailed") ErrLdapBindService = errors.New("LdapBindServiceFailed") ErrLdapBindFailed = errors.New("LdapBindFailed") ErrLdapToLocalUserFailed = errors.New("LdapToLocalUserFailed") ErrLdapCreateUserFailed = errors.New("LdapCreateUserFailed") ErrLdapPasswordNotMatch = errors.New("PasswordNotMatch") ) // LdapService is responsible for LDAP authentication and user synchronization. type LdapService struct { } // LdapUser represents the user attributes retrieved from LDAP. type LdapUser struct { Dn string Username string Email string FirstName string LastName string MemberOf []string EnableAttrValue string Enabled bool } // Name returns the full name of an LDAP user. func (lu *LdapUser) Name() string { return fmt.Sprintf("%s %s", lu.FirstName, lu.LastName) } // ToUser merges the LdapUser data into a provided *model.User. // If 'u' is nil, it creates and returns a new *model.User. func (lu *LdapUser) ToUser(u *model.User) *model.User { if u == nil { u = &model.User{} } u.Username = lu.Username u.Email = lu.Email u.Nickname = lu.Name() if lu.Enabled { u.Status = model.COMMON_STATUS_ENABLE } else { u.Status = model.COMMON_STATUS_DISABLED } return u } // connectAndBind creates an LDAP connection, optionally starts TLS, and then binds using the provided credentials. func (ls *LdapService) connectAndBind(cfg *config.Ldap, username, password string) (*ldap.Conn, error) { u, err := url.Parse(cfg.Url) if err != nil { return nil, errors.Join(ErrUrlParseFailed, err) } var conn *ldap.Conn if u.Scheme == "ldaps" { // WARNING: InsecureSkipVerify: true is not recommended for production tlsConfig := &tls.Config{InsecureSkipVerify: !cfg.TlsVerify} if cfg.TlsCaFile != "" { caCert, err := os.ReadFile(cfg.TlsCaFile) if err != nil { return nil, errors.Join(ErrFileReadFailed, err) } caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCert) { return nil, errors.Join(ErrLdapTlsFailed, errors.New("failed to append CA certificate")) } tlsConfig.RootCAs = caCertPool } conn, err = ldap.DialURL(cfg.Url, ldap.DialWithTLSConfig(tlsConfig)) } else { conn, err = ldap.DialURL(cfg.Url) } if err != nil { return nil, errors.Join(ErrLdapConnectFailed, err) } // Bind as the "service" user if err = conn.Bind(username, password); err != nil { fmt.Println("Bind failed") conn.Close() return nil, errors.Join(ErrLdapBindService, err) } return conn, nil } // connectAndBindAdmin creates an LDAP connection, optionally starts TLS, and then binds using the admin credentials. func (ls *LdapService) connectAndBindAdmin(cfg *config.Ldap) (*ldap.Conn, error) { return ls.connectAndBind(cfg, cfg.BindDn, cfg.BindPassword) } // verifyCredentials checks the provided username and password against LDAP. func (ls *LdapService) verifyCredentials(cfg *config.Ldap, username, password string) error { ldapConn, err := ls.connectAndBind(cfg, username, password) if err != nil { return ErrLdapPasswordNotMatch } defer ldapConn.Close() return nil } // Authenticate checks the provided username and password against LDAP. // Returns the corresponding *model.User if successful, or an error if not. func (ls *LdapService) Authenticate(username, password string) (*model.User, error) { ldapUser, err := ls.GetUserInfoByUsernameLdap(username) if err != nil { return nil, err } if !ldapUser.Enabled { return nil, ErrLdapUserDisabled } cfg := &Config.Ldap // Skip allow-group check for admins isAdmin := ls.isUserAdmin(cfg, ldapUser) // non-admins only check if allow-group is configured if !isAdmin && cfg.User.AllowGroup != "" { if !ls.isUserInGroup(cfg, ldapUser, cfg.User.AllowGroup) { return nil, errors.New("user not in allowed group") } } err = ls.verifyCredentials(cfg, ldapUser.Dn, password) if err != nil { return nil, err } user, err := ls.mapToLocalUser(cfg, ldapUser) if err != nil { return nil, errors.Join(ErrLdapToLocalUserFailed, err) } return user, nil } // isUserInGroup checks if the user is a member of the specified group. by_sw func (ls *LdapService) isUserInGroup(cfg *config.Ldap, ldapUser *LdapUser, groupDN string) bool { // Check "memberOf" directly if len(ldapUser.MemberOf) > 0 { for _, group := range ldapUser.MemberOf { if strings.EqualFold(group, groupDN) { return true } } } // For "member" attribute, perform a reverse search on the group member := "member" userDN := ldap.EscapeFilter(ldapUser.Dn) groupDN = ldap.EscapeFilter(groupDN) groupFilter := fmt.Sprintf("(%s=%s)", member, userDN) // Create the LDAP search request groupSearchRequest := ldap.NewSearchRequest( groupDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, // Unlimited search results 0, // No time limit false, // Return both attributes and DN groupFilter, []string{"dn"}, nil, ) // Perform the group search groupResult, err := ls.searchResult(cfg, groupSearchRequest) if err != nil { return false } // If any results are returned, the user is part of the group return len(groupResult.Entries) > 0 } // mapToLocalUser checks whether the user exists locally; if not, creates one. // If the user exists and Ldap.Sync is enabled, it updates local info. func (ls *LdapService) mapToLocalUser(cfg *config.Ldap, lu *LdapUser) (*model.User, error) { userService := &UserService{} localUser := userService.InfoByUsername(lu.Username) isAdmin := ls.isUserAdmin(cfg, lu) // If the user doesn't exist in local DB, create a new one if localUser.Id == 0 { newUser := lu.ToUser(nil) // Typically, you don’t store LDAP user passwords locally. // If needed, you can set a random password here. newUser.IsAdmin = &isAdmin newUser.GroupId = 1 if err := DB.Create(newUser).Error; err != nil { return nil, errors.Join(ErrLdapCreateUserFailed, err) } return userService.InfoByUsername(lu.Username), nil } // If the user already exists and sync is enabled, update local info if cfg.User.Sync { originalEmail := localUser.Email originalNickname := localUser.Nickname originalIsAdmin := localUser.IsAdmin originalStatus := localUser.Status lu.ToUser(localUser) // merges LDAP data into the existing user localUser.IsAdmin = &isAdmin if err := userService.Update(localUser); err != nil { // If the update fails, revert to original data localUser.Email = originalEmail localUser.Nickname = originalNickname localUser.IsAdmin = originalIsAdmin localUser.Status = originalStatus } } return localUser, nil } // IsUsernameExists checks if a username exists in LDAP (can be useful for local registration checks). func (ls *LdapService) IsUsernameExists(username string) bool { cfg := &Config.Ldap if !cfg.Enable { return false } sr, err := ls.usernameSearchResult(cfg, username) if err != nil { return false } return len(sr.Entries) > 0 } // IsEmailExists checks if an email exists in LDAP (can be useful for local registration checks). func (ls *LdapService) IsEmailExists(email string) bool { cfg := &Config.Ldap if !cfg.Enable { return false } sr, err := ls.emailSearchResult(cfg, email) if err != nil { return false } return len(sr.Entries) > 0 } // GetUserInfoByUsernameLdap returns the user info from LDAP for the given username. func (ls *LdapService) GetUserInfoByUsernameLdap(username string) (*LdapUser, error) { cfg := &Config.Ldap if !cfg.Enable { return nil, ErrLdapNotEnabled } sr, err := ls.usernameSearchResult(cfg, username) if err != nil { return nil, errors.Join(ErrLdapSearchFailed, err) } if len(sr.Entries) != 1 { return nil, ErrLdapUserNotFound } return ls.userResultToLdapUser(cfg, sr.Entries[0]), nil } // GetUserInfoByUsernameLocal returns the user info from LDAP for the given username. If the user exists, it will sync the user info to the local database. func (ls *LdapService) GetUserInfoByUsernameLocal(username string) (*model.User, error) { ldapUser, err := ls.GetUserInfoByUsernameLdap(username) if err != nil { return &model.User{}, err } return ls.mapToLocalUser(&Config.Ldap, ldapUser) } // GetUserInfoByEmailLdap returns the user info from LDAP for the given email. func (ls *LdapService) GetUserInfoByEmailLdap(email string) (*LdapUser, error) { cfg := &Config.Ldap if !cfg.Enable { return nil, ErrLdapNotEnabled } sr, err := ls.emailSearchResult(cfg, email) if err != nil { return nil, errors.Join(ErrLdapSearchFailed, err) } if len(sr.Entries) != 1 { return nil, ErrLdapUserNotFound } return ls.userResultToLdapUser(cfg, sr.Entries[0]), nil } // GetUserInfoByEmailLocal returns the user info from LDAP for the given email. if the user exists, it will synchronize the user information to local database. func (ls *LdapService) GetUserInfoByEmailLocal(email string) (*model.User, error) { ldapUser, err := ls.GetUserInfoByEmailLdap(email) if err != nil { return &model.User{}, err } return ls.mapToLocalUser(&Config.Ldap, ldapUser) } // usernameSearchResult returns the search result for the given username. func (ls *LdapService) usernameSearchResult(cfg *config.Ldap, username string) (*ldap.SearchResult, error) { // Build the combined filter for the username filter := ls.filterField(ls.fieldUsername(cfg), username) // Create the *ldap.SearchRequest searchRequest := ls.buildUserSearchRequest(cfg, filter) return ls.searchResult(cfg, searchRequest) } // emailSearchResult returns the search result for the given email. func (ls *LdapService) emailSearchResult(cfg *config.Ldap, email string) (*ldap.SearchResult, error) { filter := ls.filterField(ls.fieldEmail(cfg), email) searchRequest := ls.buildUserSearchRequest(cfg, filter) return ls.searchResult(cfg, searchRequest) } func (ls *LdapService) searchResult(cfg *config.Ldap, searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { ldapConn, err := ls.connectAndBindAdmin(cfg) if err != nil { return nil, err } defer ldapConn.Close() return ldapConn.Search(searchRequest) } // buildUserSearchRequest constructs an LDAP SearchRequest for users given a filter. func (ls *LdapService) buildUserSearchRequest(cfg *config.Ldap, filter string) *ldap.SearchRequest { baseDn := ls.baseDnUser(cfg) // user-specific base DN, or fallback filterConfig := cfg.User.Filter if filterConfig == "" { filterConfig = "(cn=*)" } // Combine the default filter with our field filter, e.g. (&(cn=*)(uid=jdoe)) combinedFilter := fmt.Sprintf("(&%s%s)", filterConfig, filter) attributes := ls.buildUserAttributes(cfg) return ldap.NewSearchRequest( baseDn, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, // unlimited search results 0, // no server-side time limit false, // typesOnly combinedFilter, attributes, nil, ) } // buildUserAttributes returns the list of attributes we want from LDAP user searches. func (ls *LdapService) buildUserAttributes(cfg *config.Ldap) []string { return []string{ "dn", ls.fieldUsername(cfg), ls.fieldEmail(cfg), ls.fieldFirstName(cfg), ls.fieldLastName(cfg), ls.fieldMemberOf(), ls.fieldUserEnableAttr(cfg), } } // userResultToLdapUser maps an *ldap.Entry to our LdapUser struct. func (ls *LdapService) userResultToLdapUser(cfg *config.Ldap, entry *ldap.Entry) *LdapUser { lu := &LdapUser{ Dn: entry.DN, Username: entry.GetAttributeValue(ls.fieldUsername(cfg)), Email: entry.GetAttributeValue(ls.fieldEmail(cfg)), FirstName: entry.GetAttributeValue(ls.fieldFirstName(cfg)), LastName: entry.GetAttributeValue(ls.fieldLastName(cfg)), MemberOf: entry.GetAttributeValues(ls.fieldMemberOf()), EnableAttrValue: entry.GetAttributeValue(ls.fieldUserEnableAttr(cfg)), } // Check if the user is enabled based on the LDAP configuration ls.isUserEnabled(cfg, lu) return lu } // filterField helps build simple attribute filters, e.g. (uid=username). func (ls *LdapService) filterField(field, value string) string { return fmt.Sprintf("(%s=%s)", field, value) } // fieldUsername returns the configured username attribute or "uid" if not set. func (ls *LdapService) fieldUsername(cfg *config.Ldap) string { if cfg.User.Username == "" { return "uid" } return cfg.User.Username } // fieldEmail returns the configured email attribute or "mail" if not set. func (ls *LdapService) fieldEmail(cfg *config.Ldap) string { if cfg.User.Email == "" { return "mail" } return cfg.User.Email } // fieldFirstName returns the configured first name attribute or "givenName" if not set. func (ls *LdapService) fieldFirstName(cfg *config.Ldap) string { if cfg.User.FirstName == "" { return "givenName" } return cfg.User.FirstName } // fieldLastName returns the configured last name attribute or "sn" if not set. func (ls *LdapService) fieldLastName(cfg *config.Ldap) string { if cfg.User.LastName == "" { return "sn" } return cfg.User.LastName } func (ls *LdapService) fieldMemberOf() string { return "memberOf" } func (ls *LdapService) fieldUserEnableAttr(cfg *config.Ldap) string { if cfg.User.EnableAttr == "" { return "userAccountControl" } return cfg.User.EnableAttr } // baseDnUser returns the user-specific base DN or the global base DN if none is set. func (ls *LdapService) baseDnUser(cfg *config.Ldap) string { if cfg.User.BaseDn == "" { return cfg.BaseDn } return cfg.User.BaseDn } // isUserAdmin checks if the user is a member of the admin group. func (ls *LdapService) isUserAdmin(cfg *config.Ldap, ldapUser *LdapUser) bool { // Check if the admin group is configured adminGroup := cfg.User.AdminGroup if adminGroup == "" { return false } // Check "memberOf" directly if len(ldapUser.MemberOf) > 0 { for _, group := range ldapUser.MemberOf { if strings.EqualFold(group, adminGroup) { return true } } return false } // For "member" attribute, perform a reverse search on the group member := "member" userDN := ldap.EscapeFilter(ldapUser.Dn) adminGroupDn := ldap.EscapeFilter(adminGroup) groupFilter := fmt.Sprintf("(%s=%s)", member, userDN) // Create the LDAP search request groupSearchRequest := ldap.NewSearchRequest( adminGroupDn, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, // Unlimited search results 0, // No time limit false, // Return both attributes and DN groupFilter, []string{"dn"}, nil, ) // Perform the group search groupResult, err := ls.searchResult(cfg, groupSearchRequest) if err != nil { return false } // If any results are returned, the user is part of the admin group if len(groupResult.Entries) > 0 { return true } return false } // isUserEnabled checks if the user is enabled based on the LDAP configuration. // If no enable attribute or value is set, all users are considered enabled by default. func (ls *LdapService) isUserEnabled(cfg *config.Ldap, ldapUser *LdapUser) bool { // Retrieve the enable attribute and expected value from the configuration enableAttr := cfg.User.EnableAttr enableAttrValue := cfg.User.EnableAttrValue // If no enable attribute or value is configured, consider all users as enabled if enableAttr == "" || enableAttrValue == "" { ldapUser.Enabled = true return true } // Normalize the enable attribute for comparison enableAttr = strings.ToLower(enableAttr) // Handle Active Directory's userAccountControl attribute if enableAttr == "useraccountcontrol" { // Parse the userAccountControl value userAccountControl, err := strconv.Atoi(ldapUser.EnableAttrValue) if err != nil { fmt.Printf("[ERROR] Invalid userAccountControl value: %v\n", err) ldapUser.Enabled = false return false } // Account is disabled if the ACCOUNTDISABLE flag (0x2) is set const ACCOUNTDISABLE = 0x2 ldapUser.Enabled = userAccountControl&ACCOUNTDISABLE == 0 return ldapUser.Enabled } // For other attributes, perform a direct comparison with the expected value ldapUser.Enabled = ldapUser.EnableAttrValue == enableAttrValue return ldapUser.Enabled } // getAttrOfDn retrieves the value of an attribute for a given DN. func (ls *LdapService) getAttrOfDn(cfg *config.Ldap, dn, attr string) string { searchRequest := ldap.NewSearchRequest( ldap.EscapeFilter(dn), ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, // unlimited search results 0, // no server-side time limit false, // typesOnly "(objectClass=*)", []string{attr}, nil, ) sr, err := ls.searchResult(cfg, searchRequest) if err != nil { return "" } if len(sr.Entries) == 0 { return "" } return sr.Entries[0].GetAttributeValue(attr) }