Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 3 additions & 25 deletions engine/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
108 changes: 108 additions & 0 deletions engine/ldapdir/ldapdir.go
Original file line number Diff line number Diff line change
@@ -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
}
73 changes: 3 additions & 70 deletions www/api/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}

Expand Down
67 changes: 22 additions & 45 deletions www/api/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
20 changes: 20 additions & 0 deletions www/api/identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading