Merge pull request #21 from h44z/ldap_filter

use LDAP filter strings
This commit is contained in:
h44z 2021-05-16 23:11:55 +02:00 committed by GitHub
commit 88278bf677
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 157 additions and 225 deletions

View File

@ -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: <devicename>.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

View File

@ -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,
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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"

View File

@ -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
}

View File

@ -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
}
}
}
}

View File

@ -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