diff --git a/engine/db/db.go b/engine/db/db.go index 2acc61f..707b9c3 100644 --- a/engine/db/db.go +++ b/engine/db/db.go @@ -2,14 +2,13 @@ package db import ( "errors" - "fmt" "log" "log/slog" "os" "github.com/dbaseqp/Quotient/engine/config" + "github.com/dbaseqp/Quotient/engine/ldapdir" - "github.com/go-ldap/ldap/v3" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -126,33 +125,12 @@ func (d *DB) AddTeams(conf *config.ConfigSettings) error { // check for teams from other sources // ldap if conf.LdapSettings != (config.LdapAuthConfig{}) { - conn, err := ldap.DialURL(conf.LdapSettings.LdapConnectUrl) + teamNames, err := ldapdir.TeamMembers(conf.LdapSettings) if err != nil { return err } - // nolint:errcheck - defer conn.Close() - err = conn.Bind(conf.LdapSettings.LdapBindDn, conf.LdapSettings.LdapBindPassword) - if err != nil { - return err - } - - searchRequest := ldap.NewSearchRequest( - conf.LdapSettings.LdapSearchBaseDn, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=person)(memberOf=%s))", conf.LdapSettings.LdapTeamGroupDn), - []string{"sAMAccountName"}, - nil, - ) - - sr, err := conn.Search(searchRequest) - if err != nil { - return err - } - - for _, entry := range sr.Entries { - teamName := entry.GetAttributeValue("sAMAccountName") + for _, teamName := range teamNames { t := TeamSchema{Name: teamName} result := d.db.Where(&t).First(&t) if result.Error != nil { diff --git a/engine/ldapdir/ldapdir.go b/engine/ldapdir/ldapdir.go new file mode 100644 index 0000000..2a121d5 --- /dev/null +++ b/engine/ldapdir/ldapdir.go @@ -0,0 +1,108 @@ +// Package ldapdir reads accounts and group memberships from the LDAP +// directory described by the event's LdapSettings. +package ldapdir + +import ( + "errors" + "fmt" + + "github.com/dbaseqp/Quotient/engine/config" + + "github.com/go-ldap/ldap/v3" +) + +// connect dials the directory and binds as the configured service account. +func connect(s config.LdapAuthConfig) (*ldap.Conn, error) { + conn, err := ldap.DialURL(s.LdapConnectUrl) + if err != nil { + return nil, err + } + if err := conn.Bind(s.LdapBindDn, s.LdapBindPassword); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +func search(conn *ldap.Conn, s config.LdapAuthConfig, filter string, attributes ...string) ([]*ldap.Entry, error) { + sr, err := conn.Search(ldap.NewSearchRequest( + s.LdapSearchBaseDn, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + attributes, + nil, + )) + if err != nil { + return nil, err + } + return sr.Entries, nil +} + +func personFilter(username string) string { + return fmt.Sprintf("(&(objectClass=person)(sAMAccountName=%s))", ldap.EscapeFilter(username)) +} + +// TeamMembers returns the sAMAccountName of every person in LdapTeamGroupDn. +func TeamMembers(s config.LdapAuthConfig) ([]string, error) { + conn, err := connect(s) + if err != nil { + return nil, err + } + // nolint:errcheck + defer conn.Close() + + entries, err := search(conn, s, + fmt.Sprintf("(&(objectClass=person)(memberOf=%s))", ldap.EscapeFilter(s.LdapTeamGroupDn)), + "sAMAccountName") + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.GetAttributeValue("sAMAccountName")) + } + return names, nil +} + +// Groups returns the memberOf values of every person named username. +func Groups(s config.LdapAuthConfig, username string) ([]string, error) { + conn, err := connect(s) + if err != nil { + return nil, err + } + // nolint:errcheck + defer conn.Close() + + entries, err := search(conn, s, personFilter(username), "memberOf") + if err != nil { + return nil, err + } + var groups []string + for _, entry := range entries { + groups = append(groups, entry.GetAttributeValues("memberOf")...) + } + return groups, nil +} + +// Authenticate checks password against the single person named username and +// returns that person's memberOf values. +func Authenticate(s config.LdapAuthConfig, username, password string) ([]string, error) { + conn, err := connect(s) + if err != nil { + return nil, err + } + // nolint:errcheck + defer conn.Close() + + entries, err := search(conn, s, personFilter(username), "memberOf") + if err != nil { + return nil, err + } + if len(entries) != 1 { + return nil, errors.New("user does not exist or too many entries returned") + } + if err := conn.Bind(entries[0].DN, password); err != nil { + return nil, errors.New("invalid credentials") + } + return entries[0].GetAttributeValues("memberOf"), nil +} diff --git a/www/api/authentication.go b/www/api/authentication.go index 01bb5e2..9f8f3b2 100644 --- a/www/api/authentication.go +++ b/www/api/authentication.go @@ -14,8 +14,8 @@ import ( "time" "github.com/dbaseqp/Quotient/engine/config" + "github.com/dbaseqp/Quotient/engine/ldapdir" - "github.com/go-ldap/ldap/v3" "github.com/gorilla/securecookie" ) @@ -226,80 +226,13 @@ func (a *API) auth(username string, password string) (map[string]any, error) { // auth from other sources // if ldap configs are present, try to auth against ldap if a.conf.LdapSettings != (config.LdapAuthConfig{}) { - conn, err := ldap.DialURL(a.conf.LdapSettings.LdapConnectUrl) + groups, err := ldapdir.Authenticate(a.conf.LdapSettings, username, password) if err != nil { return nil, err } - // nolint:errcheck - defer conn.Close() - - // bind using the given username and password and searchbase from config - err = conn.Bind(a.conf.LdapSettings.LdapBindDn, a.conf.LdapSettings.LdapBindPassword) - if err != nil { - return nil, err - } - - searchRequest := ldap.NewSearchRequest( - a.conf.LdapSettings.LdapSearchBaseDn, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=person)(sAMAccountName=%s))", ldap.EscapeFilter(username)), - []string{"dn"}, - nil, - ) - - sr, err := conn.Search(searchRequest) - if err != nil { - return nil, err - } - - if len(sr.Entries) != 1 { - return nil, errors.New("user does not exist or too many entries returned") - } - - userDN := sr.Entries[0].DN - - err = conn.Bind(userDN, password) - if err != nil { - return nil, errors.New("invalid credentials") - } - - // query for the user's roles - roleSearchRequest := ldap.NewSearchRequest( - a.conf.LdapSettings.LdapSearchBaseDn, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=person)(sAMAccountName=%s))", ldap.EscapeFilter(username)), - []string{"memberOf"}, - nil, - ) - - sr, err = conn.Search(roleSearchRequest) - if err != nil { - return nil, err - } - - hasAuthorizedRole := false - for _, entry := range sr.Entries { - for _, memberOf := range entry.GetAttributeValues("memberOf") { - if memberOf == a.conf.LdapSettings.LdapAdminGroupDn || - memberOf == a.conf.LdapSettings.LdapRedGroupDn || - memberOf == a.conf.LdapSettings.LdapTeamGroupDn { - hasAuthorizedRole = true - break - } - - if memberOf == a.conf.LdapSettings.LdapInjectGroupDn { - return map[string]any{"username": username, "authSource": "ldap"}, nil - } - } - if hasAuthorizedRole { - break - } - } - - if !hasAuthorizedRole { + if len(a.ldapGroupRoles(groups)) == 0 { return nil, errors.New("LDAP user has no authorized roles") } - return map[string]any{"username": username, "authSource": "ldap"}, nil } diff --git a/www/api/identity.go b/www/api/identity.go index badc3ec..183049b 100644 --- a/www/api/identity.go +++ b/www/api/identity.go @@ -13,8 +13,7 @@ import ( "github.com/dbaseqp/Quotient/engine/config" "github.com/dbaseqp/Quotient/engine/db" - - "github.com/go-ldap/ldap/v3" + "github.com/dbaseqp/Quotient/engine/ldapdir" ) // Identity is the caller, resolved once per request from their auth source. @@ -272,56 +271,34 @@ func (a *API) ldapRoles(username string) ([]string, error) { return nil, errors.New("LDAP session but LDAP is not configured") } - conn, err := ldap.DialURL(a.conf.LdapSettings.LdapConnectUrl) - if err != nil { - return nil, err - } - // nolint:errcheck - defer conn.Close() - - // bind using the given username and password and searchbase from config - err = conn.Bind(a.conf.LdapSettings.LdapBindDn, a.conf.LdapSettings.LdapBindPassword) + groups, err := ldapdir.Groups(a.conf.LdapSettings, username) if err != nil { return nil, err } - // query for the user's roles - searchRequest := ldap.NewSearchRequest( - a.conf.LdapSettings.LdapSearchBaseDn, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=person)(sAMAccountName=%s))", ldap.EscapeFilter(username)), - []string{"memberOf"}, - nil, - ) - - sr, err := conn.Search(searchRequest) - if err != nil { - return nil, err + roles := a.ldapGroupRoles(groups) + if len(roles) == 0 { + return nil, errors.New("LDAP user has no authorized roles") } + return roles, nil +} +// ldapGroupRoles maps LDAP group DNs to the roles they grant. +func (a *API) ldapGroupRoles(groups []string) []string { roles := make([]string, 0) - for _, entry := range sr.Entries { - for _, memberOf := range entry.GetAttributeValues("memberOf") { - if memberOf == a.conf.LdapSettings.LdapAdminGroupDn { - roles = append(roles, "admin") - } - - if memberOf == a.conf.LdapSettings.LdapRedGroupDn { - roles = append(roles, "red") - } - - if memberOf == a.conf.LdapSettings.LdapTeamGroupDn { - roles = append(roles, "team") - } - - if memberOf == a.conf.LdapSettings.LdapInjectGroupDn { - roles = append(roles, "inject") - } + for _, group := range groups { + if group == a.conf.LdapSettings.LdapAdminGroupDn { + roles = append(roles, "admin") + } + if group == a.conf.LdapSettings.LdapRedGroupDn { + roles = append(roles, "red") + } + if group == a.conf.LdapSettings.LdapTeamGroupDn { + roles = append(roles, "team") + } + if group == a.conf.LdapSettings.LdapInjectGroupDn { + roles = append(roles, "inject") } } - - if len(roles) == 0 { - return nil, errors.New("LDAP user has no authorized roles") - } - return roles, nil + return roles } diff --git a/www/api/identity_test.go b/www/api/identity_test.go index f443636..66a99dc 100644 --- a/www/api/identity_test.go +++ b/www/api/identity_test.go @@ -226,3 +226,23 @@ func TestRequireOwnTeam(t *testing.T) { }) } } + +func TestLdapGroupRoles(t *testing.T) { + a := &API{conf: &config.ConfigSettings{ + LdapSettings: config.LdapAuthConfig{ + LdapAdminGroupDn: "CN=Admins,DC=example,DC=com", + LdapRedGroupDn: "CN=Red,DC=example,DC=com", + LdapTeamGroupDn: "CN=Teams,DC=example,DC=com", + LdapInjectGroupDn: "CN=Injects,DC=example,DC=com", + }, + }} + + assert.Equal(t, []string{"team"}, a.ldapGroupRoles([]string{"CN=Other,DC=example,DC=com", "CN=Teams,DC=example,DC=com"})) + assert.Equal(t, []string{"admin", "inject"}, a.ldapGroupRoles([]string{"CN=Admins,DC=example,DC=com", "CN=Injects,DC=example,DC=com"})) + assert.Empty(t, a.ldapGroupRoles([]string{"cn=teams,dc=example,dc=com"}), "group DNs must match exactly") + assert.Empty(t, a.ldapGroupRoles(nil)) + + a.conf.LdapSettings.LdapRedGroupDn = a.conf.LdapSettings.LdapAdminGroupDn + assert.Equal(t, []string{"admin", "red"}, a.ldapGroupRoles([]string{"CN=Admins,DC=example,DC=com"}), + "one group may grant several roles") +}