Files
RUSTDESK-AP-SERVER-SUNLIX/service/ldap.go
2025-05-21 09:07:08 +08:00

513 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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
}
// 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 dont 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)
}