diff --git a/README.md b/README.md index dfd2999..3b81400 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ The following configuration options are available: | EMAIL_CERT_VALIDATION | certcheck | email | false | Validate the email server certificate. | | EMAIL_USERNAME | user | email | | An optional username for SMTP authentication. | | EMAIL_PASSWORD | pass | email | | An optional password for SMTP authentication. | -| EMAIL_AUTHTYPE | auth | email | plain | Either plain, login or crammd5. If username and password are empty, this value is ignored. | +| EMAIL_AUTHTYPE | auth | email | plain | Either plain, login or crammd5. If username and password are empty, this value is ignored. | | WG_DEVICES | devices | wg | wg0 | A comma separated list of WireGuard devices. | | WG_DEFAULT_DEVICE | defaultDevice | wg | wg0 | This device is used for auto-created peers (if CREATE_DEFAULT_PEER is enabled). | | WG_CONFIG_PATH | configDirectory | wg | /etc/wireguard | If set, interface configuration updates will be written to this path, filename: .conf. | @@ -141,15 +141,14 @@ The following configuration options are available: | LDAP_BASEDN | dn | ldap | DC=COMPANY,DC=LOCAL | The base DN for searching users. | | LDAP_USER | user | ldap | company\\\\ldap_wireguard | The bind user. | | LDAP_PASSWORD | pass | ldap | SuperSecret | The bind password. | -| LDAP_TYPE | typ | ldap | AD | Either AD or OpenLDAP. | -| LDAP_USER_CLASS | userClass | ldap | organizationalPerson | The user class that specifies the LDAP object category of users. | +| LDAP_LOGIN_FILTER | loginFilter | ldap | (&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2)) | {{login_identifier}} will be replaced with the login email address. | +| LDAP_SYNC_FILTER | syncFilter | ldap | (&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)) | The filter string for the LDAP synchronization service. | | LDAP_ADMIN_GROUP | adminGroup | ldap | CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL | Users in this group are marked as administrators. | | LDAP_ATTR_EMAIL | attrEmail | ldap | mail | User email attribute. | | LDAP_ATTR_FIRSTNAME | attrFirstname | ldap | givenName | User firstname attribute. | | LDAP_ATTR_LASTNAME | attrLastname | ldap | sn | User lastname attribute. | | LDAP_ATTR_PHONE | attrPhone | ldap | telephoneNumber | User phone number attribute. | | LDAP_ATTR_GROUPS | attrGroups | ldap | memberOf | User groups attribute. | -| LDAP_ATTR_DISABLED | attrDisabled | ldap | userAccountControl | User status attribute. This attribute is used to detect deactivated users. | | LOG_LEVEL | | | debug | Specify log level, one of: trace, debug, info, off. | | LOG_JSON | | | false | Format log output as JSON. | | LOG_COLOR | | | true | Colorize log output. | @@ -174,7 +173,6 @@ ldap: user: wireguard@test.test pass: test adminGroup: CN=WireGuardAdmins,CN=Users,DC=test,DC=test - typ: AD database: typ: sqlite database: data/wg_portal.db diff --git a/internal/authentication/providers/ldap/provider.go b/internal/authentication/providers/ldap/provider.go index 1ec8fb6..97b9a05 100644 --- a/internal/authentication/providers/ldap/provider.go +++ b/internal/authentication/providers/ldap/provider.go @@ -2,7 +2,6 @@ package ldap import ( "crypto/tls" - "fmt" "strings" "github.com/gin-gonic/gin" @@ -69,13 +68,11 @@ func (provider Provider) Login(ctx *authentication.AuthContext) (string, error) // Search for the given username attrs := []string{"dn", provider.config.EmailAttribute} - if provider.config.DisabledAttribute != "" { - attrs = append(attrs, provider.config.DisabledAttribute) - } + loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1) searchRequest := ldap.NewSearchRequest( provider.config.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=%s)(%s=%s))", provider.config.UserClass, provider.config.EmailAttribute, username), + loginFilter, attrs, nil, ) @@ -89,24 +86,8 @@ func (provider Provider) Login(ctx *authentication.AuthContext) (string, error) return "", errors.Errorf("invalid amount of ldap entries (%d)", len(sr.Entries)) } - userDN := sr.Entries[0].DN - - // Check if user is disabled, if so deny login - if provider.config.DisabledAttribute != "" { - uac := sr.Entries[0].GetAttributeValue(provider.config.DisabledAttribute) - switch provider.config.Type { - case ldapconfig.TypeActiveDirectory: - if ldapconfig.IsActiveDirectoryUserDisabled(uac) { - return "", errors.New("user is disabled") - } - case ldapconfig.TypeOpenLDAP: - if ldapconfig.IsOpenLdapUserDisabled(uac) { - return "", errors.New("user is disabled") - } - } - } - // Bind as the user to verify their password + userDN := sr.Entries[0].DN err = client.Bind(userDN, password) if err != nil { return "", errors.Wrapf(err, "invalid credentials") @@ -136,13 +117,11 @@ func (provider Provider) GetUserModel(ctx *authentication.AuthContext) (*authent // Search for the given username attrs := []string{"dn", provider.config.EmailAttribute, provider.config.FirstNameAttribute, provider.config.LastNameAttribute, provider.config.PhoneAttribute, provider.config.GroupMemberAttribute} - if provider.config.DisabledAttribute != "" { - attrs = append(attrs, provider.config.DisabledAttribute) - } + loginFilter := strings.Replace(provider.config.LoginFilter, "{{login_identifier}}", username, -1) searchRequest := ldap.NewSearchRequest( provider.config.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=%s)(%s=%s))", provider.config.UserClass, provider.config.EmailAttribute, username), + loginFilter, attrs, nil, ) diff --git a/internal/ldap/config.go b/internal/ldap/config.go index 4988ab0..4d581be 100644 --- a/internal/ldap/config.go +++ b/internal/ldap/config.go @@ -15,14 +15,13 @@ type Config struct { BindUser string `yaml:"user" envconfig:"LDAP_USER"` BindPass string `yaml:"pass" envconfig:"LDAP_PASSWORD"` - Type Type `yaml:"typ" envconfig:"LDAP_TYPE"` // AD for active directory, OpenLDAP for OpenLDAP - UserClass string `yaml:"userClass" envconfig:"LDAP_USER_CLASS"` EmailAttribute string `yaml:"attrEmail" envconfig:"LDAP_ATTR_EMAIL"` FirstNameAttribute string `yaml:"attrFirstname" envconfig:"LDAP_ATTR_FIRSTNAME"` LastNameAttribute string `yaml:"attrLastname" envconfig:"LDAP_ATTR_LASTNAME"` PhoneAttribute string `yaml:"attrPhone" envconfig:"LDAP_ATTR_PHONE"` GroupMemberAttribute string `yaml:"attrGroups" envconfig:"LDAP_ATTR_GROUPS"` - DisabledAttribute string `yaml:"attrDisabled" envconfig:"LDAP_ATTR_DISABLED"` + LoginFilter string `yaml:"loginFilter" envconfig:"LDAP_LOGIN_FILTER"` // {{login_identifier}} gets replaced with the login email address + SyncFilter string `yaml:"syncFilter" envconfig:"LDAP_SYNC_FILTER"` AdminLdapGroup string `yaml:"adminGroup" envconfig:"LDAP_ADMIN_GROUP"` // Members of this group receive admin rights in WG-Portal } diff --git a/internal/ldap/ldap.go b/internal/ldap/ldap.go index 85b0bb5..38af07b 100644 --- a/internal/ldap/ldap.go +++ b/internal/ldap/ldap.go @@ -2,8 +2,6 @@ package ldap import ( "crypto/tls" - "fmt" - "strconv" "github.com/go-ldap/ldap/v3" "github.com/pkg/errors" @@ -54,13 +52,10 @@ func FindAllUsers(cfg *Config) ([]RawLdapData, error) { // Search all users attrs := []string{"dn", cfg.EmailAttribute, cfg.EmailAttribute, cfg.FirstNameAttribute, cfg.LastNameAttribute, cfg.PhoneAttribute, cfg.GroupMemberAttribute} - if cfg.DisabledAttribute != "" { - attrs = append(attrs, cfg.DisabledAttribute) - } searchRequest := ldap.NewSearchRequest( cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(objectClass=%s)", cfg.UserClass), attrs, nil, + cfg.SyncFilter, attrs, nil, ) sr, err := client.Search(searchRequest) @@ -87,27 +82,3 @@ func FindAllUsers(cfg *Config) ([]RawLdapData, error) { return tmpData, nil } - -func IsActiveDirectoryUserDisabled(userAccountControl string) bool { - if userAccountControl == "" { - return false - } - - uacInt, err := strconv.ParseInt(userAccountControl, 10, 32) - if err != nil { - return true - } - if int32(uacInt)&0x2 != 0 { - return true // bit 2 set means account is disabled - } - - return false -} - -func IsOpenLdapUserDisabled(pwdAccountLockedTime string) bool { - if pwdAccountLockedTime != "" { - return true - } - - return false -} diff --git a/internal/server/configuration.go b/internal/server/configuration.go index 730aa43..be34c9b 100644 --- a/internal/server/configuration.go +++ b/internal/server/configuration.go @@ -97,15 +97,14 @@ func NewConfig() *Config { cfg.LDAP.StartTLS = true cfg.LDAP.BindUser = "company\\\\ldap_wireguard" cfg.LDAP.BindPass = "SuperSecret" - cfg.LDAP.Type = "AD" - cfg.LDAP.UserClass = "organizationalPerson" cfg.LDAP.EmailAttribute = "mail" cfg.LDAP.FirstNameAttribute = "givenName" cfg.LDAP.LastNameAttribute = "sn" cfg.LDAP.PhoneAttribute = "telephoneNumber" cfg.LDAP.GroupMemberAttribute = "memberOf" - cfg.LDAP.DisabledAttribute = "userAccountControl" cfg.LDAP.AdminLdapGroup = "CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL" + cfg.LDAP.LoginFilter = "(&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))" + cfg.LDAP.SyncFilter = "(&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2))" cfg.WG.DeviceNames = []string{"wg0"} cfg.WG.DefaultDeviceName = "wg0" diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 9e345f6..f1b4c5d 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -4,6 +4,8 @@ import ( "net/http" "strings" + "github.com/pkg/errors" + "github.com/gin-gonic/gin" "github.com/h44z/wg-portal/internal/authentication" "github.com/h44z/wg-portal/internal/users" @@ -53,65 +55,15 @@ func (s *Server) PostLogin(c *gin.Context) { return } - // Check user database for an matching entry - var loginProvider authentication.AuthProvider - email := "" - user := s.users.GetUser(username) // retrieve active candidate user from db - if user != nil { // existing user - loginProvider = s.auth.GetProvider(string(user.Source)) - if loginProvider == nil { - s.GetHandleError(c, http.StatusInternalServerError, "login error", "login provider unavailable") - return - } - authEmail, err := loginProvider.Login(&authentication.AuthContext{ - Username: username, - Password: password, - }) - if err == nil { - email = authEmail - } - } else { // possible new user - // Check all available auth backends - for _, provider := range s.auth.GetProvidersForType(authentication.AuthProviderTypePassword) { - // try to log in to the given provider - authEmail, err := provider.Login(&authentication.AuthContext{ - Username: username, - Password: password, - }) - if err != nil { - continue - } - - email = authEmail - loginProvider = provider - - // create new user in the database (or reactivate him) - userData, err := loginProvider.GetUserModel(&authentication.AuthContext{ - Username: email, - }) - if err != nil { - s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error()) - return - } - if err := s.CreateUser(users.User{ - Email: userData.Email, - Source: users.UserSource(loginProvider.GetName()), - IsAdmin: userData.IsAdmin, - Firstname: userData.Firstname, - Lastname: userData.Lastname, - Phone: userData.Phone, - }, s.wg.Cfg.GetDefaultDeviceName()); err != nil { - s.GetHandleError(c, http.StatusInternalServerError, "login error", "failed to update user data") - return - } - - user = s.users.GetUser(username) - break - } + // Check all available auth backends + user, err := s.checkAuthentication(username, password) + if err != nil { + s.GetHandleError(c, http.StatusInternalServerError, "login error", err.Error()) + return } // Check if user is authenticated - if email == "" || loginProvider == nil || user == nil { + if user == nil { c.Redirect(http.StatusSeeOther, "/auth/login?err=authfail") return } @@ -152,3 +104,48 @@ func (s *Server) GetLogout(c *gin.Context) { } c.Redirect(http.StatusSeeOther, "/") } + +func (s *Server) checkAuthentication(username, password string) (*users.User, error) { + var user *users.User + + // Check all available auth backends + for _, provider := range s.auth.GetProvidersForType(authentication.AuthProviderTypePassword) { + // try to log in to the given provider + authEmail, err := provider.Login(&authentication.AuthContext{ + Username: username, + Password: password, + }) + if err != nil { + continue + } + + // Login succeeded + user = s.users.GetUser(authEmail) + if user != nil { + break // user exists, nothing more to do... + } + + // create new user in the database (or reactivate him) + userData, err := provider.GetUserModel(&authentication.AuthContext{ + Username: username, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to get user model") + } + if err := s.CreateUser(users.User{ + Email: userData.Email, + Source: users.UserSource(provider.GetName()), + IsAdmin: userData.IsAdmin, + Firstname: userData.Firstname, + Lastname: userData.Lastname, + Phone: userData.Phone, + }, s.wg.Cfg.GetDefaultDeviceName()); err != nil { + return nil, errors.Wrap(err, "failed to update user data") + } + + user = s.users.GetUser(authEmail) + break + } + + return user, nil +} diff --git a/internal/server/ldapsync.go b/internal/server/ldapsync.go index 41f4200..fd53869 100644 --- a/internal/server/ldapsync.go +++ b/internal/server/ldapsync.go @@ -32,86 +32,16 @@ func (s *Server) SyncLdapWithUserDatabase() { continue } - for i := range ldapUsers { - // prefilter - if ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] == "" || - ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute] == "" || - ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute] == "" { - continue - } + // Update existing LDAP users + s.updateLdapUsers(ldapUsers) - user, err := s.users.GetOrCreateUserUnscoped(ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute]) - if err != nil { - logrus.Errorf("failed to get/create user %s in database: %v", ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute], err) - } - - // check if user should be deactivated - ldapDeactivated := false - switch s.config.LDAP.Type { - case ldap.TypeActiveDirectory: - ldapDeactivated = ldap.IsActiveDirectoryUserDisabled(ldapUsers[i].Attributes[s.config.LDAP.DisabledAttribute]) - case ldap.TypeOpenLDAP: - ldapDeactivated = ldap.IsOpenLdapUserDisabled(ldapUsers[i].Attributes[s.config.LDAP.DisabledAttribute]) - } - - // check if user has been disabled in ldap, update peers accordingly - if ldapDeactivated != user.DeletedAt.Valid { - if ldapDeactivated { - // disable all peers for the given user - for _, peer := range s.peers.GetPeersByMail(user.Email) { - now := time.Now() - peer.DeactivatedAt = &now - if err = s.UpdatePeer(peer, now); err != nil { - logrus.Errorf("failed to update deactivated peer %s: %v", peer.PublicKey, err) - } - } - } else { - // enable all peers for the given user - for _, peer := range s.peers.GetPeersByMail(user.Email) { - now := time.Now() - peer.DeactivatedAt = nil - if err = s.UpdatePeer(peer, now); err != nil { - logrus.Errorf("failed to update activated peer %s: %v", peer.PublicKey, err) - } - } - } - } - - // Sync attributes from ldap - if s.UserChangedInLdap(user, &ldapUsers[i]) { - user.Firstname = ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute] - user.Lastname = ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute] - user.Email = ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] - user.Phone = ldapUsers[i].Attributes[s.config.LDAP.PhoneAttribute] - user.IsAdmin = false - user.Source = users.UserSourceLdap - user.DeletedAt = gorm.DeletedAt{} // Not deleted - - for _, group := range ldapUsers[i].RawAttributes[s.config.LDAP.GroupMemberAttribute] { - if string(group) == s.config.LDAP.AdminLdapGroup { - user.IsAdmin = true - break - } - } - - if err = s.users.UpdateUser(user); err != nil { - logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err) - continue - } - - if ldapDeactivated { - if err = s.users.DeleteUser(user); err != nil { - logrus.Errorf("failed to delete deactivated user %s in database: %v", user.Email, err) - continue - } - } - } - } + // Disable missing LDAP users + s.disableMissingLdapUsers(ldapUsers) } logrus.Info("ldap user synchronization stopped") } -func (s Server) UserChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) bool { +func (s Server) userChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) bool { if user.Firstname != ldapData.Attributes[s.config.LDAP.FirstNameAttribute] { return true } @@ -125,14 +55,7 @@ func (s Server) UserChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) return true } - ldapDeactivated := false - switch s.config.LDAP.Type { - case ldap.TypeActiveDirectory: - ldapDeactivated = ldap.IsActiveDirectoryUserDisabled(ldapData.Attributes[s.config.LDAP.DisabledAttribute]) - case ldap.TypeOpenLDAP: - ldapDeactivated = ldap.IsOpenLdapUserDisabled(ldapData.Attributes[s.config.LDAP.DisabledAttribute]) - } - if ldapDeactivated != user.DeletedAt.Valid { + if user.DeletedAt.Valid { return true } @@ -149,3 +72,82 @@ func (s Server) UserChangedInLdap(user *users.User, ldapData *ldap.RawLdapData) return false } + +func (s *Server) disableMissingLdapUsers(ldapUsers []ldap.RawLdapData) { + // Disable missing LDAP users + activeUsers := s.users.GetUsers() + for i := range activeUsers { + if activeUsers[i].Source != users.UserSourceLdap { + continue + } + + existsInLDAP := false + for j := range ldapUsers { + if activeUsers[i].Email == ldapUsers[j].Attributes[s.config.LDAP.EmailAttribute] { + existsInLDAP = true + break + } + } + + if existsInLDAP { + continue + } + + // disable all peers for the given user + for _, peer := range s.peers.GetPeersByMail(activeUsers[i].Email) { + now := time.Now() + peer.DeactivatedAt = &now + if err := s.UpdatePeer(peer, now); err != nil { + logrus.Errorf("failed to update deactivated peer %s: %v", peer.PublicKey, err) + } + } + + if err := s.users.DeleteUser(&activeUsers[i]); err != nil { + logrus.Errorf("failed to delete deactivated user %s in database: %v", activeUsers[i].Email, err) + } + } +} + +func (s *Server) updateLdapUsers(ldapUsers []ldap.RawLdapData) { + for i := range ldapUsers { + user, err := s.users.GetOrCreateUserUnscoped(ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute]) + if err != nil { + logrus.Errorf("failed to get/create user %s in database: %v", ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute], err) + } + + // re-enable LDAP user if the user was disabled + if user.DeletedAt.Valid { + // enable all peers for the given user + for _, peer := range s.peers.GetPeersByMail(user.Email) { + now := time.Now() + peer.DeactivatedAt = nil + if err = s.UpdatePeer(peer, now); err != nil { + logrus.Errorf("failed to update activated peer %s: %v", peer.PublicKey, err) + } + } + } + + // Sync attributes from ldap + if s.userChangedInLdap(user, &ldapUsers[i]) { + user.Firstname = ldapUsers[i].Attributes[s.config.LDAP.FirstNameAttribute] + user.Lastname = ldapUsers[i].Attributes[s.config.LDAP.LastNameAttribute] + user.Email = ldapUsers[i].Attributes[s.config.LDAP.EmailAttribute] + user.Phone = ldapUsers[i].Attributes[s.config.LDAP.PhoneAttribute] + user.IsAdmin = false + user.Source = users.UserSourceLdap + user.DeletedAt = gorm.DeletedAt{} // Not deleted + + for _, group := range ldapUsers[i].RawAttributes[s.config.LDAP.GroupMemberAttribute] { + if string(group) == s.config.LDAP.AdminLdapGroup { + user.IsAdmin = true + break + } + } + + if err = s.users.UpdateUser(user); err != nil { + logrus.Errorf("failed to update ldap user %s in database: %v", user.Email, err) + continue + } + } + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index ac15fb8..db169ef 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -6,7 +6,6 @@ import ( "github.com/gin-gonic/gin" wgportal "github.com/h44z/wg-portal" - "github.com/h44z/wg-portal/internal/authentication" _ "github.com/h44z/wg-portal/internal/server/docs" // docs is generated by Swag CLI, you have to import it. ginSwagger "github.com/swaggo/gin-swagger" "github.com/swaggo/gin-swagger/swaggerFiles" @@ -162,28 +161,16 @@ func (s *Server) RequireApiAuthentication(scope string) gin.HandlerFunc { return } - // Check user database for an matching entry - var loginProvider authentication.AuthProvider - user := s.users.GetUser(username) // retrieve active candidate user from db - if user == nil || user.Email == "" { + // Check all available auth backends + user, err := s.checkAuthentication(username, password) + if err != nil { c.Abort() - c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) + c.JSON(http.StatusInternalServerError, ApiError{Message: "login error"}) return } - loginProvider = s.auth.GetProvider(string(user.Source)) - if loginProvider == nil { - c.Abort() - c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) - return - } - authEmail, err := loginProvider.Login(&authentication.AuthContext{ - Username: username, - Password: password, - }) - - // Test if authentication succeeded - if err != nil || authEmail == "" { + // Check if user is authenticated + if user == nil { c.Abort() c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) return