diff --git a/conf/config.yaml b/conf/config.yaml index 76cc1f1..a37b3f1 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -58,3 +58,23 @@ oss: expire-time: 30 max-byte: 10240 +ldap: + enable: false + url: "ldap://ldap.example.com:389" + tls: false + tls-verify: false + base-dn: "dc=example,dc=com" + bind-dn: "cn=admin,dc=example,dc=com" + bind-password: "password" + + user: + base-dn: "ou=users,dc=example,dc=com" + enable-attr: "" #The attribute name of the user for enabling, in AD it is "userAccountControl", empty means no enable attribute, all users are enabled + enable-attr-value: "" # The value of the enable attribute when the user is enabled. If you are using AD, just set random value, it will be ignored. + filter: "(cn=*)" + username: "uid" # The attribute name of the user for usernamem if you are using AD, it should be "sAMAccountName" + email: "mail" + first-name: "givenName" + last-name: "sn" + sync: false # If true, the user will be synchronized to the database when the user logs in. If false, the user will be synchronized to the database when the user be created. + admin-group: "cn=admin,dc=example,dc=com" # The group name of the admin group, if the user is in this group, the user will be an admin. diff --git a/config/config.go b/config/config.go index 92aa7ec..a455c06 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,7 @@ type Config struct { Jwt Jwt Rustdesk Rustdesk Proxy Proxy + Ldap Ldap } // Init 初始化配置 diff --git a/config/ldap.go b/config/ldap.go new file mode 100644 index 0000000..e165eb7 --- /dev/null +++ b/config/ldap.go @@ -0,0 +1,36 @@ +package config + +type LdapUser struct { + BaseDn string `mapstructure:"base-dn"` // The base DN of the user for searching + EnableAttr string `mapstructure:"enable-attr"` // The attribute name of the user for enabling, in AD it is "userAccountControl", empty means no enable attribute, all users are enabled + EnableAttrValue string `mapstructure:"enable-attr-value"` // The value of the enable attribute when the user is enabled. If you are using AD, just leave it random str, it will be ignored. + Filter string `mapstructure:"filter"` + Username string `mapstructure:"username"` + Email string `mapstructure:"email"` + FirstName string `mapstructure:"first-name"` + LastName string `mapstructure:"last-name"` + Sync bool `mapstructure:"sync"` // Will sync the user's information to the internal database + AdminGroup string `mapstructure:"admin-group"` // Which group is the admin group +} + +// type LdapGroup struct { +// BaseDn string `mapstructure:"base-dn"` // The base DN of the group for searching +// Name string `mapstructure:"name"` // The attribute name of the group +// Filter string `mapstructure:"filter"` +// Admin string `mapstructure:"admin"` // Which group is the admin group +// Member string `mapstructure:"member"` // How to get the member of the group: member, uniqueMember, or memberOf (default: member) +// Mode string `mapstructure:"mode"` +// Map map[string]string `mapstructure:"map"` // If mode is "map", map the LDAP group to the internal group +// } + +type Ldap struct { + Enable bool `mapstructure:"enable"` + Url string `mapstructure:"url"` + TLS bool `mapstructure:"tls"` + TlsVerify bool `mapstructure:"tls-verify"` + BaseDn string `mapstructure:"base-dn"` + BindDn string `mapstructure:"bind-dn"` + BindPassword string `mapstructure:"bind-password"` + User LdapUser `mapstructure:"user"` + // Group LdapGroup `mapstructure:"group"` +} \ No newline at end of file diff --git a/go.mod b/go.mod index 33c5718..5746b6e 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/go-playground/validator/v10 v10.11.2 github.com/go-redis/redis/v8 v8.11.4 github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/google/uuid v1.1.2 + github.com/google/uuid v1.6.0 github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/sirupsen/logrus v1.8.1 github.com/spf13/cobra v1.8.1 @@ -22,13 +22,14 @@ require ( github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.3 golang.org/x/oauth2 v0.23.0 - golang.org/x/text v0.18.0 + golang.org/x/text v0.21.0 gorm.io/driver/mysql v1.5.7 gorm.io/driver/sqlite v1.5.6 gorm.io/gorm v1.25.7 ) require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect @@ -37,6 +38,8 @@ require ( github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-ldap/ldap/v3 v3.4.10 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect @@ -70,10 +73,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.9 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.23.0 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/image v0.13.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/ini.v1 v1.63.2 // indirect diff --git a/service/ldap.go b/service/ldap.go new file mode 100644 index 0000000..466c392 --- /dev/null +++ b/service/ldap.go @@ -0,0 +1,425 @@ +package service + +import ( + "crypto/tls" + "errors" + "fmt" + "github.com/go-ldap/ldap/v3" + "strconv" + "strings" + + "Gwen/config" + "Gwen/global" + "Gwen/model" +) + +// 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() + 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) { + conn, err := ldap.DialURL(cfg.Url) + if err != nil { + return nil, fmt.Errorf("failed to dial LDAP: %w", err) + } + + if cfg.TLS { + // WARNING: InsecureSkipVerify: true is not recommended for production + if err = conn.StartTLS(&tls.Config{InsecureSkipVerify: !cfg.TlsVerify}); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to start TLS: %w", err) + } + } + + // Bind as the "service" user + if err = conn.Bind(username, password); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to bind with service account: %w", 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 err + } + 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) { + cfg := &global.Config.Ldap + // 1. Use a service bind to search for the user DN + sr, err := ls.usernameSearchResult(cfg, username) + if err != nil { + return nil, fmt.Errorf("LDAP search request failed: %w", err) + } + if len(sr.Entries) != 1 { + return nil, errors.New("user does not exist or too many entries returned") + } + entry := sr.Entries[0] + userDN := entry.DN + + err = ls.verifyCredentials(cfg, userDN, password) + if err != nil { + return nil, fmt.Errorf("LDAP authentication failed: %w", err) + } + ldapUser := ls.userResultToLdapUser(cfg, entry) + if !ldapUser.Enabled { + return nil, errors.New("UserDisabledAtLdap") + } + user, err := ls.mapToLocalUser(cfg, ldapUser) + if err != nil { + return nil, fmt.Errorf("failed to map LDAP user to local user: %w", 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 don’t store LDAP user passwords locally. + // If needed, you can set a random password here. + newUser.IsAdmin = &isAdmin + if err := global.DB.Create(newUser).Error; err != nil { + return nil, fmt.Errorf("failed to create new user: %w", 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 + 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 + } + } + + 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 := &global.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 := &global.Config.Ldap + if !cfg.Enable { + return false + } + sr, err := ls.emailSearchResult(cfg, email) + if err != nil { + return false + } + return len(sr.Entries) > 0 +} + +// 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 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) +} diff --git a/service/service.go b/service/service.go index a5dc5e1..c1450fa 100644 --- a/service/service.go +++ b/service/service.go @@ -18,6 +18,7 @@ type Service struct { *AuditService *ShareRecordService *ServerCmdService + *LdapService } func New() *Service { diff --git a/service/user.go b/service/user.go index c1f0d5a..db71fc3 100644 --- a/service/user.go +++ b/service/user.go @@ -46,6 +46,14 @@ func (us *UserService) InfoByOpenid(openid string) *model.User { // InfoByUsernamePassword 根据用户名密码取用户信息 func (us *UserService) InfoByUsernamePassword(username, password string) *model.User { + if global.Config.Ldap.Enable { + u, err := AllService.LdapService.Authenticate(username, password) + if err == nil { + return u + } + global.Logger.Error("LDAP authentication failed, %v", err) + global.Logger.Warn("Fallback to local database") + } u := &model.User{} global.DB.Where("username = ? and password = ?", username, us.EncryptPassword(password)).First(u) return u @@ -156,6 +164,9 @@ func (us *UserService) CheckUserEnable(u *model.User) bool { // Create 创建 func (us *UserService) Create(u *model.User) error { // The initial username should be formatted, and the username should be unique + if us.IsUsernameExists(u.Username) { + return errors.New("UsernameExists") + } u.Username = us.formatUsername(u.Username) u.Password = us.EncryptPassword(u.Password) res := global.DB.Create(u).Error @@ -343,13 +354,10 @@ func (us *UserService) RegisterByOauth(oauthUser *model.OauthUser, op string) (e // GenerateUsernameByOauth 生成用户名 func (us *UserService) GenerateUsernameByOauth(name string) string { - u := &model.User{} - global.DB.Where("username = ?", name).First(u) - if u.Id == 0 { - return name + for us.IsUsernameExists(name) { + name += strconv.Itoa(rand.Intn(10)) // Append a random digit (0-9) } - name = name + strconv.FormatInt(rand.Int63n(10), 10) - return us.GenerateUsernameByOauth(name) + return name } // UserThirdsByUserId @@ -394,15 +402,18 @@ func (us *UserService) IsPasswordEmptyByUser(u *model.User) bool { return us.IsPasswordEmptyById(u.Id) } -// Register 注册 +// Register 注册, 如果用户名已存在则返回nil func (us *UserService) Register(username string, email string, password string) *model.User { u := &model.User{ Username: username, Email: email, - Password: us.EncryptPassword(password), + Password: password, GroupId: 1, } - global.DB.Create(u) + err := us.Create(u) + if err != nil { + return nil + } return u } @@ -468,3 +479,11 @@ func (us *UserService) BatchDeleteUserToken(ids []uint) error { func (us *UserService) VerifyJWT(token string) (uint, error) { return global.Jwt.ParseToken(token) } + +// IsUsernameExists 判断用户名是否存在, it will check the internal database and LDAP(if enabled) +func (us *UserService) IsUsernameExists(username string) bool { + u := &model.User{} + global.DB.Where("username = ?", username).First(u) + existsInLdap := AllService.LdapService.IsUsernameExists(username) + return u.Id != 0 || existsInLdap +} \ No newline at end of file