From 2eb2215ae445ac2875e760dfae4f248f89d2e43d Mon Sep 17 00:00:00 2001 From: Chris F Ravenscroft Date: Sat, 17 Jul 2021 11:45:36 -0700 Subject: [PATCH] Database plugins (#133) This commit actually covers a few items. In future commits, I will keep features distinct. This is only happening this time around due to how long it took to merge this branch. Covered: - Database plugins (at this time: SQLite, MySQL, Postgres) - Backends acting as middleware: added the [[Backends]] configuration directive while retaining backward compatibility with [Backend] - Schema introspection (root DSE query with base scope) - When proxying, insert queried attribute back in upstream response, if absent, so that the LDAP library does not filter out all entries - When chaining backends, any backend can be used to inject OTP value in password, before reaching a non-OTP-aware backend (currently guarded by a True statement in case we find an issue (I did thoroughly test but you never know)) - Handling of special "1.1" attributes filter meaning "I do not want attributes" (RFC 4511, 4.5.1.8) - Support for "want types only" queries, even when proxying --- Makefile | 4 +- glauth.go | 49 +- go.mod | 8 +- go.sum | 10 + pkg/config/config.go | 29 +- pkg/handler/config.go | 98 +++- pkg/handler/handler.go | 20 +- pkg/handler/ldap.go | 142 ++++- pkg/handler/options.go | 35 ++ pkg/handler/owncloud.go | 42 +- pkg/plugins/Makefile | 22 + pkg/plugins/README.md | 132 +++++ pkg/plugins/basesqlhandler.go | 597 ++++++++++++++++++++++ pkg/plugins/mysql.go | 55 ++ pkg/plugins/postgres.go | 55 ++ pkg/plugins/sample-database.cfg | 82 +++ pkg/plugins/sample-ldap-with-database.cfg | 45 ++ pkg/plugins/sqlite.go | 55 ++ pkg/server/server.go | 129 ++++- 19 files changed, 1519 insertions(+), 90 deletions(-) create mode 100644 pkg/plugins/Makefile create mode 100644 pkg/plugins/README.md create mode 100644 pkg/plugins/basesqlhandler.go create mode 100644 pkg/plugins/mysql.go create mode 100644 pkg/plugins/postgres.go create mode 100644 pkg/plugins/sample-database.cfg create mode 100644 pkg/plugins/sample-ldap-with-database.cfg create mode 100644 pkg/plugins/sqlite.go diff --git a/Makefile b/Makefile index e370f4c..6cf44d1 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ BUILD_VARS=-s -w -X main.GitCommit=${GIT_COMMIT} -X main.GitBranch=${GIT_BRANCH} BUILD_FILES=glauth.go TRIM_FLAGS=-gcflags "all=-trimpath=${PWD}" -asmflags "all=-trimpath=${PWD}" +# Plugins +include pkg/plugins/Makefile + ##################### # High level commands ##################### @@ -68,7 +71,6 @@ format: devrun: go run ${BUILD_FILES} -c sample-simple.cfg - linux32: GOOS=linux GOARCH=386 go build ${TRIM_FLAGS} -ldflags "${BUILD_VARS}" -o bin/glauth32 ${BUILD_FILES} && cd bin && sha256sum glauth32 > glauth32.sha256 diff --git a/glauth.go b/glauth.go index 71dadc8..a0a40f0 100644 --- a/glauth.go +++ b/glauth.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GeertJohan/yubigo" + "github.com/davecgh/go-spew/spew" docopt "github.com/docopt/docopt-go" "github.com/fsnotify/fsnotify" "github.com/glauth/glauth/pkg/config" @@ -19,6 +20,7 @@ import ( logging "github.com/op/go-logging" "gopkg.in/amz.v3/aws" "gopkg.in/amz.v3/s3" + // "github.com/davecgh/go-spew/spew" ) // Set with buildtime vars @@ -234,9 +236,6 @@ func parseConfigFile(configFileLocation string) (*config.Config, error) { // setup defaults cfg.LDAP.Enabled = false cfg.LDAPS.Enabled = true - cfg.Backend.NameFormat = "cn" - cfg.Backend.GroupFormat = "ou" - cfg.Backend.SSHKeyAttr = "sshPublicKey" // parse the config file if strings.HasPrefix(configFileLocation, "s3://") { @@ -277,6 +276,30 @@ func parseConfigFile(configFileLocation string) (*config.Config, error) { } } + // Backward Compability + if cfg.Backend.Datastore != "" { + if cfg.Backends != nil { + return &cfg, fmt.Errorf("You cannot specify both [Backend] and [[Backends]] directives in the same configuration ") + } else { + cfg.Backends = append(cfg.Backends, cfg.Backend) + } + } + spew.Dump(cfg.Backends) + + // Patch with default values where not specified + for i := range cfg.Backends { + if cfg.Backends[i].NameFormat == "" { + cfg.Backends[i].NameFormat = "cn" + } + if cfg.Backends[i].GroupFormat == "" { + cfg.Backends[i].GroupFormat = "ou" + } + if cfg.Backends[i].SSHKeyAttr == "" { + cfg.Backends[i].SSHKeyAttr = "sshPublicKey" + } + } + // + return &cfg, nil } @@ -354,14 +377,18 @@ func validateConfig(cfg config.Config) (*config.Config, error) { } } - switch cfg.Backend.Datastore { - case "": - cfg.Backend.Datastore = "config" - case "config": - case "ldap": - case "owncloud": - default: - return &cfg, fmt.Errorf("invalid backend %s - must be 'config', 'ldap' or 'owncloud", cfg.Backend.Datastore) + //spew.Dump(cfg) + for i := range cfg.Backends { + switch cfg.Backends[i].Datastore { + case "": + cfg.Backends[i].Datastore = "config" + case "config": + case "ldap": + case "owncloud": + case "plugin": + default: + return &cfg, fmt.Errorf("invalid backend %s - must be 'config', 'ldap', 'owncloud' or 'plugin'", cfg.Backends[i].Datastore) + } } return &cfg, nil } diff --git a/go.mod b/go.mod index cf8cf98..fdb1c25 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,19 @@ go 1.15 require ( github.com/GeertJohan/yubigo v0.0.0-20190917122436-175bc097e60e github.com/boombuler/barcode v1.0.1 // indirect + github.com/davecgh/go-spew v1.1.1 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 github.com/fsnotify/fsnotify v1.4.9 github.com/go-logr/logr v0.4.0 + github.com/go-sql-driver/mysql v1.5.0 github.com/hydronica/toml v0.4.2 github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a + github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect + github.com/kr/pretty v0.2.1 + github.com/lib/pq v1.10.0 + github.com/mattn/go-sqlite3 v1.14.6 github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 // indirect - github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3 + github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3 // indirect github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/pquerna/otp v1.3.0 github.com/yaegashi/msgraph.go v0.1.1-0.20200221123608-2d438cf2a7cc diff --git a/go.sum b/go.sum index 0c81e47..256d6cb 100644 --- a/go.sum +++ b/go.sum @@ -7,23 +7,33 @@ github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyX github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-logr/logr v0.4.0 h1:K7/B1jt6fIBQVd4Owv2MqGQClcgf0R266+7C/QjRcLc= github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hydronica/toml v0.4.2 h1:BY+iAvyl2u1BlfgkJQzqc1UXDGLWfpMmOWuqJrwAVDU= github.com/hydronica/toml v0.4.2/go.mod h1:c7QhbYq3Wp9SlOWuG7MAieKUyXP2P/hXhy/YqWfbS/4= github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o= github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= +github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts= +github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E= +github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s= github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484/go.mod h1:O1EljZ+oHprtxDDPHiMWVo/5dBT6PlvWX5PSwj80aBA= github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3 h1:NNis9uuNpG5h97Dvxxo53Scg02qBg+3Nfabg6zjFGu8= diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d5edfe..951906f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,14 +2,25 @@ package config // config file type Backend struct { - BaseDN string - Datastore string - Insecure bool // For LDAP and owncloud backend only - Servers []string // For LDAP and owncloud backend only - NameFormat string - GroupFormat string - SSHKeyAttr string - UseGraphAPI bool // For ownCloud backend only + BaseDN string + Datastore string + Insecure bool // For LDAP and owncloud backend only + Servers []string // For LDAP and owncloud backend only + NameFormat string + GroupFormat string + SSHKeyAttr string + UseGraphAPI bool // For ownCloud backend only + Plugin string // Path to plugin library, for plugin backend only + PluginHandler string // Name of plugin's main handler function + Database string // For MySQL backend only TODO REname to match plugin +} +type Helper struct { + Enabled bool + BaseDN string + Datastore string + Plugin string // Path to plugin library, for plugin backend only + PluginHandler string // Name of plugin's main handler function + Database string // For MySQL backend only TODO REname to match plugin } type Frontend struct { AllowedBaseDNs []string // For LDAP backend only @@ -61,6 +72,8 @@ type Group struct { type Config struct { API API Backend Backend + Backends []Backend + Helper Helper Debug bool WatchConfig bool YubikeyClientID string diff --git a/pkg/handler/config.go b/pkg/handler/config.go index f3d590a..9a7cdd6 100644 --- a/pkg/handler/config.go +++ b/pkg/handler/config.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "net" + "regexp" "sort" "strings" @@ -16,10 +17,14 @@ import ( "github.com/pquerna/otp/totp" ) +var configattributematcher = regexp.MustCompile(`(?i)\((?P[a-zA-Z0-9]+)\s*=\s*(?P.*)\)`) + type configHandler struct { + backend config.Backend log logr.Logger cfg *config.Config yubikeyAuth *yubigo.YubiAuth + attmatcher *regexp.Regexp } // NewConfigHandler creates a new config backed handler @@ -27,9 +32,11 @@ func NewConfigHandler(opts ...Option) Handler { options := newOptions(opts...) handler := configHandler{ + backend: options.Backend, log: options.Logger, cfg: options.Config, yubikeyAuth: options.YubiAuth, + attmatcher: configattributematcher, } return handler } @@ -37,15 +44,15 @@ func NewConfigHandler(opts ...Option) Handler { // Bind implements a bind request against the config file func (h configHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultCode ldap.LDAPResultCode, err error) { bindDN = strings.ToLower(bindDN) - baseDN := strings.ToLower("," + h.cfg.Backend.BaseDN) + baseDN := strings.ToLower("," + h.backend.BaseDN) - h.log.V(6).Info("Bind request", "binddn", bindDN, "basedn", h.cfg.Backend.BaseDN, "src", conn.RemoteAddr()) + h.log.V(6).Info("Bind request", "binddn", bindDN, "basedn", h.backend.BaseDN, "src", conn.RemoteAddr()) stats.Frontend.Add("bind_reqs", 1) // parse the bindDN - ensure that the bindDN ends with the BaseDN if !strings.HasSuffix(bindDN, baseDN) { - h.log.V(2).Info("BindDN not part of our BaseDN", "binddn", bindDN, "basedn", h.cfg.Backend.BaseDN) + h.log.V(2).Info("BindDN not part of our BaseDN", "binddn", bindDN, "basedn", h.backend.BaseDN) // h.log.Warning(fmt.Sprintf("Bind Error: BindDN %s not our BaseDN %s", bindDN, baseDN)) return ldap.LDAPResultInvalidCredentials, nil } @@ -53,10 +60,10 @@ func (h configHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultC groupName := "" userName := "" if len(parts) == 1 { - userName = strings.TrimPrefix(parts[0], h.cfg.Backend.NameFormat+"=") + userName = strings.TrimPrefix(parts[0], h.backend.NameFormat+"=") } else if len(parts) == 2 { - userName = strings.TrimPrefix(parts[0], h.cfg.Backend.NameFormat+"=") - groupName = strings.TrimPrefix(parts[1], h.cfg.Backend.GroupFormat+"=") + userName = strings.TrimPrefix(parts[0], h.backend.NameFormat+"=") + groupName = strings.TrimPrefix(parts[1], h.backend.GroupFormat+"=") } else { h.log.V(2).Info("BindDN should have only one or two parts", "binddn", bindDN, "numparts", len(parts)) return ldap.LDAPResultInvalidCredentials, nil @@ -66,20 +73,20 @@ func (h configHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultC user := config.User{} found := false for _, u := range h.cfg.Users { - if u.Name == userName { + if strings.EqualFold(u.Name, userName) { found = true user = u } } if !found { - h.log.V(2).Info(fmt.Sprintf("User not found", "username", userName)) + h.log.V(2).Info("User not found", "username", userName) return ldap.LDAPResultInvalidCredentials, nil } // find the group group := config.Group{} found = false for _, g := range h.cfg.Groups { - if g.Name == groupName { + if strings.EqualFold(g.Name, groupName) { found = true group = g } @@ -171,20 +178,57 @@ func (h configHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultC // Search implements a search request against the config file func (h configHandler) Search(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (result ldap.ServerSearchResult, err error) { bindDN = strings.ToLower(bindDN) - baseDN := strings.ToLower("," + h.cfg.Backend.BaseDN) + baseDN := strings.ToLower("," + h.backend.BaseDN) searchBaseDN := strings.ToLower(searchReq.BaseDN) - h.log.V(6).Info("Search request", "binddn", bindDN, "basedn", baseDN, "src", conn.RemoteAddr(), "filter", searchReq.Filter) + h.log.V(6).Info("Search request", "binddn", bindDN, "basedn", baseDN, "searchbasedn", searchBaseDN, "src", conn.RemoteAddr(), "scope", searchReq.Scope, "filter", searchReq.Filter) stats.Frontend.Add("search_reqs", 1) // validate the user is authenticated and has appropriate access if len(bindDN) < 1 { return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", bindDN) } - if !strings.HasSuffix(bindDN, baseDN) { - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: BindDN %s not in our BaseDN %s", bindDN, h.cfg.Backend.BaseDN) + + // for now, we are going to be dirty and assume that if scope==0 and dn=='' we are investigating the schema + if searchReq.Scope == 0 && searchBaseDN == "" { + entries := []*ldap.Entry{} + attrs := []*ldap.EntryAttribute{} + // unfortunately, objectClass is not to be included so we will respect that + // attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: []string{"*"}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedSASLMechanisms", Values: []string{}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedLDAPVersion", Values: []string{"3"}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedControl", Values: []string{}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedCapabilities", Values: []string{}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "subschemaSubentry", Values: []string{baseDN}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "serverName", Values: []string{"unknown"}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "namingContexts", Values: []string{baseDN}}) + attrs = append(attrs, &ldap.EntryAttribute{Name: "defaultNamingContext", Values: []string{baseDN}}) + // ah, but you see, this is not enough because if your query is for, say 'objectClass', then our LDAP + // library will weed out this entry since it does *not* contain an objectclass attribute + attbits := h.attmatcher.FindStringSubmatch(searchReq.Filter) + if len(attbits) == 3 { + foundattname := false + for _, attr := range attrs { + if strings.ToLower(attr.Name) == strings.ToLower(attbits[1]) { + foundattname = true + break + } + } + // the ugly hack: we are going to pretend that the requested attribute is in there + if !foundattname { + attrs = append(attrs, &ldap.EntryAttribute{Name: attbits[1], Values: []string{attbits[2]}}) + } + } + entries = append(entries, &ldap.Entry{DN: "", Attributes: attrs}) + stats.Frontend.Add("search_successes", 1) + h.log.V(6).Info("AP: Root Search OK", "filter", searchReq.Filter) + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil } - if !strings.HasSuffix(searchBaseDN, h.cfg.Backend.BaseDN) { - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: search BaseDN %s is not in our BaseDN %s", searchBaseDN, h.cfg.Backend.BaseDN) + + if !strings.HasSuffix(bindDN, baseDN) { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: BindDN %s not in our BaseDN %s", bindDN, h.backend.BaseDN) + } + if !strings.HasSuffix(searchBaseDN, h.backend.BaseDN) { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: search BaseDN %s is not in our BaseDN %s", searchBaseDN, h.backend.BaseDN) } // return all users in the config file - the LDAP library will filter results for us entries := []*ldap.Entry{} @@ -205,7 +249,7 @@ func (h configHandler) Search(bindDN string, searchReq ldap.SearchRequest, conn attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: []string{"posixGroup"}}) attrs = append(attrs, &ldap.EntryAttribute{Name: "uniqueMember", Values: h.getGroupMembers(g.UnixID)}) attrs = append(attrs, &ldap.EntryAttribute{Name: "memberUid", Values: h.getGroupMemberIDs(g.UnixID)}) - dn := fmt.Sprintf("cn=%s,%s=groups,%s", g.Name, h.cfg.Backend.GroupFormat, h.cfg.Backend.BaseDN) + dn := fmt.Sprintf("cn=%s,%s=groups,%s", g.Name, h.backend.GroupFormat, h.backend.BaseDN) entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs}) } case "posixaccount", "shadowaccount", "": @@ -263,9 +307,9 @@ func (h configHandler) Search(bindDN string, searchReq ldap.SearchRequest, conn attrs = append(attrs, &ldap.EntryAttribute{Name: "shadowWarning", Values: []string{"7"}}) if len(u.SSHKeys) > 0 { - attrs = append(attrs, &ldap.EntryAttribute{Name: h.cfg.Backend.SSHKeyAttr, Values: u.SSHKeys}) + attrs = append(attrs, &ldap.EntryAttribute{Name: h.backend.SSHKeyAttr, Values: u.SSHKeys}) } - dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.cfg.Backend.NameFormat, u.Name, h.cfg.Backend.GroupFormat, h.getGroupName(u.PrimaryGroup), h.cfg.Backend.BaseDN) + dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.backend.NameFormat, u.Name, h.backend.GroupFormat, h.getGroupName(u.PrimaryGroup), h.backend.BaseDN) entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs}) } } @@ -289,6 +333,18 @@ func (h configHandler) Delete(boundDN string, deleteDN string, conn net.Conn) (r return ldap.LDAPResultInsufficientAccessRights, nil } +func (h configHandler) FindUser(userName string) (f bool, u config.User, err error) { + user := config.User{} + found := false + for _, u := range h.cfg.Users { + if strings.EqualFold(u.Name, userName) { + found = true + user = u + } + } + return found, user, nil +} + // Close does not actually close anything, because the config data is kept in memory func (h configHandler) Close(boundDn string, conn net.Conn) error { stats.Frontend.Add("closes", 1) @@ -299,12 +355,12 @@ func (h configHandler) getGroupMembers(gid int) []string { members := make(map[string]bool) for _, u := range h.cfg.Users { if u.PrimaryGroup == gid { - dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.cfg.Backend.NameFormat, u.Name, h.cfg.Backend.GroupFormat, h.getGroupName(u.PrimaryGroup), h.cfg.Backend.BaseDN) + dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.backend.NameFormat, u.Name, h.backend.GroupFormat, h.getGroupName(u.PrimaryGroup), h.backend.BaseDN) members[dn] = true } else { for _, othergid := range u.OtherGroups { if othergid == gid { - dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.cfg.Backend.NameFormat, u.Name, h.cfg.Backend.GroupFormat, h.getGroupName(u.PrimaryGroup), h.cfg.Backend.BaseDN) + dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.backend.NameFormat, u.Name, h.backend.GroupFormat, h.getGroupName(u.PrimaryGroup), h.backend.BaseDN) members[dn] = true } } @@ -381,7 +437,7 @@ func (h configHandler) getGroupDNs(gids []int) []string { for _, gid := range gids { for _, g := range h.cfg.Groups { if g.UnixID == gid { - dn := fmt.Sprintf("cn=%s,%s=groups,%s", g.Name, h.cfg.Backend.GroupFormat, h.cfg.Backend.BaseDN) + dn := fmt.Sprintf("cn=%s,%s=groups,%s", g.Name, h.backend.GroupFormat, h.backend.BaseDN) groups[dn] = true } diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index dab25d6..38d8d2e 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -1,6 +1,13 @@ package handler -import "github.com/nmcclain/ldap" +import ( + "github.com/glauth/glauth/pkg/config" + "github.com/nmcclain/ldap" +) + +type HelperMaker interface { + FindUser(userName string) (bool, config.User, error) +} // Handler is the common interface for all datastores type Handler interface { @@ -13,4 +20,15 @@ type Handler interface { ldap.Adder ldap.Modifier // Note: modifying eg the uid or cn might change the dn because the hierarchy is determined by the backend ldap.Deleter + + // helper + HelperMaker +} + +// TODO When I grow up, I want to handle pointers same as I would in C +// and not need a counter because I would not allocate statically +// but use idiomatic slicing instead +type HandlerWrapper struct { + Handlers []Handler + Count *int } diff --git a/pkg/handler/ldap.go b/pkg/handler/ldap.go index b633fda..bdf8a18 100644 --- a/pkg/handler/ldap.go +++ b/pkg/handler/ldap.go @@ -8,6 +8,7 @@ import ( "net" "net/url" "os" + "regexp" "strconv" "strings" "sync" @@ -17,15 +18,23 @@ import ( "github.com/glauth/glauth/pkg/stats" "github.com/go-logr/logr" "github.com/nmcclain/ldap" + "github.com/pquerna/otp/totp" ) +// global matcher +var ldapattributematcher = regexp.MustCompile(`(?i)\((?P[a-zA-Z0-9]+)\s*=\s*(?P.*)\)`) + type ldapHandler struct { + backend config.Backend + handlers HandlerWrapper doPing chan bool log logr.Logger cfg *config.Config lock *sync.Mutex // for sessions and servers sessions map[string]ldapSession servers []ldapBackend + helper Handler + attm *regexp.Regexp } // global lock for ldapHandler sessions & servers manipulation @@ -55,19 +64,22 @@ func NewLdapHandler(opts ...Option) Handler { options := newOptions(opts...) handler := ldapHandler{ // set non-zero-value defaults here + backend: options.Backend, + handlers: options.Handlers, sessions: make(map[string]ldapSession), doPing: make(chan bool), log: options.Logger, cfg: options.Config, + helper: options.Helper, lock: &ldaplock, + attm: ldapattributematcher, } // parse LDAP URLs - for _, ldapurl := range handler.cfg.Backend.Servers { + for _, ldapurl := range handler.backend.Servers { l, err := parseURL(ldapurl) if err != nil { handler.log.Error(err, "could not parse url") os.Exit(1) - // TODO log error and os exit } handler.servers = append(handler.servers, l) } @@ -82,6 +94,51 @@ func NewLdapHandler(opts ...Option) Handler { func (h ldapHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultCode ldap.LDAPResultCode, err error) { h.log.V(6).Info("Bind request", "binddn", bindDN, "src", conn.RemoteAddr()) + // if h.helper != nil { + if true { + + lowerBindDN := strings.ToLower(bindDN) + baseDN := strings.ToLower("," + h.backend.BaseDN) + parts := strings.Split(strings.TrimSuffix(lowerBindDN, baseDN), ",") + userName := strings.TrimPrefix(parts[0], h.backend.NameFormat+"=") + + validotp := false + + // Find the user + // We are going to go through all backends and ask + // until we find our user or die of boredom. + user := config.User{} + found := false + for i, handler := range h.handlers.Handlers { + found, user, _ = handler.FindUser(userName) + if found { + break + } + if i >= *h.handlers.Count { + break + } + } + + if !found { + validotp = true + } else { + if len(user.OTPSecret) == 0 { + validotp = true + } else { + if len(bindSimplePw) > 6 { + otp := bindSimplePw[len(bindSimplePw)-6:] + bindSimplePw = bindSimplePw[:len(bindSimplePw)-6] + validotp = totp.Validate(otp, user.OTPSecret) + } + } + } + + if !validotp { + h.log.V(6).Info(fmt.Sprintf("Bind Error: invalid OTP token as %s from %s", bindDN, conn.RemoteAddr().String())) + return ldap.LDAPResultInvalidCredentials, nil + } + } + stats.Frontend.Add("bind_reqs", 1) s, err := h.getSession(conn) if err != nil { @@ -101,7 +158,25 @@ func (h ldapHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultCod // func (h ldapHandler) Search(boundDN string, searchReq ldap.SearchRequest, conn net.Conn) (result ldap.ServerSearchResult, err error) { + wantAttributes := true + wantTypesOnly := false + h.log.V(6).Info("Search request", "binddn", boundDN, "src", conn.RemoteAddr(), "filter", searchReq.Filter) + + // "1.1" has special meaning: it does what an empty attribute list would do + // if it didn't already mean "return all attributes" + if len(searchReq.Attributes) == 1 && searchReq.Attributes[0] == "1.1" { + wantAttributes = false + searchReq.Attributes = searchReq.Attributes[:0] + } + + // TypesOnly cannot be true: if it were, glauth would not be able to + // match the returned valuea against the query + if searchReq.TypesOnly == true { + wantTypesOnly = true + searchReq.TypesOnly = false + } + stats.Frontend.Add("search_reqs", 1) s, err := h.getSession(conn) if err != nil { @@ -123,6 +198,61 @@ func (h ldapHandler) Search(boundDN string, searchReq ldap.SearchRequest, conn n h.log.V(6).Info("Search request to backend", "request", search) sr, err := s.ldap.Search(search) h.log.V(6).Info("Backend Search result", "result", sr) + + if !wantAttributes { + h.log.V(6).Info("AP: Search Info", "type", "No attributes") + for _, entry := range sr.Entries { + entry.Attributes = entry.Attributes[:0] + } + } + + if wantTypesOnly { + h.log.V(6).Info("AP: Search Info", "type", "Types only") + for _, entry := range sr.Entries { + for _, attribute := range entry.Attributes { + attribute.Values = attribute.Values[:0] + } + } + } + + // WART used to debug when testing special cases against + // glauth acting as a backend, where it may have + // the same workaround thus hiding the issue + /* + for _, entry := range sr.Entries { + for _, attribute := range entry.Attributes { + if attribute.Name == "objectclass" { + attribute.Name = "bogus" + } + } + } + */ + + // If our original attribute is not present, either because: + // 1-This is a root query + // 2-We were asked not to return attributes + // 3-We were asked not to return values + // then we re-insert the correct values in there. + if searchReq.Scope == 0 && searchReq.BaseDN == "" { + h.log.V(6).Info("AP: Search Info", "type", "Root search detected") + } + attbits := h.attm.FindStringSubmatch(searchReq.Filter) + for _, entry := range sr.Entries { + foundattname := false + for _, attribute := range entry.Attributes { + if strings.ToLower(attribute.Name) == strings.ToLower(attbits[1]) { + foundattname = true + if len(attbits[2]) == 0 { + attribute.Values = []string{attbits[2]} + } + break + } + } + if !foundattname { + entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{Name: attbits[1], Values: []string{attbits[2]}}) + } + } + ssr := ldap.ServerSearchResult{ Entries: sr.Entries, Referrals: sr.Referrals, @@ -156,6 +286,10 @@ func (h ldapHandler) Delete(boundDN string, deleteDN string, conn net.Conn) (res return ldap.LDAPResultInsufficientAccessRights, nil } +func (h ldapHandler) FindUser(userName string) (found bool, user config.User, err error) { + return false, config.User{}, nil +} + func (h ldapHandler) Close(boundDn string, conn net.Conn) error { conn.Close() // close connection to the server when then client is closed h.lock.Lock() @@ -213,7 +347,7 @@ func (h ldapHandler) getSession(conn net.Conn) (ldapSession, error) { dest := fmt.Sprintf("%s:%d", server.Hostname, server.Port) if server.Scheme == "ldaps" { tlsCfg := &tls.Config{} - if h.cfg.Backend.Insecure { + if h.backend.Insecure { tlsCfg.InsecureSkipVerify = true } l, err = ldap.DialTLS("tcp", dest, tlsCfg) @@ -245,7 +379,7 @@ func (h ldapHandler) ping() error { start := time.Now() if h.servers[0].Scheme == "ldaps" { tlsCfg := &tls.Config{} - if h.cfg.Backend.Insecure { + if h.backend.Insecure { tlsCfg.InsecureSkipVerify = true } l, err = ldap.DialTLS("tcp", dest, tlsCfg) diff --git a/pkg/handler/options.go b/pkg/handler/options.go index e12477a..ce2f3b5 100644 --- a/pkg/handler/options.go +++ b/pkg/handler/options.go @@ -13,10 +13,13 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { + Backend config.Backend + Handlers HandlerWrapper Logger logr.Logger Config *config.Config Context *context.Context YubiAuth *yubigo.YubiAuth + Helper Handler } // newOptions initializes the available default options. @@ -30,6 +33,31 @@ func newOptions(opts ...Option) Options { return opt } +// newOptions initializes the available default options. +func NewOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Backend is our current backend +func Backend(val config.Backend) Option { + return func(o *Options) { + o.Backend = val + } +} + +// Our friendly handlers for all backends +func Handlers(val HandlerWrapper) Option { + return func(o *Options) { + o.Handlers = val + } +} + // Logger provides a function to set the logger option. func Logger(val logr.Logger) Option { return func(o *Options) { @@ -57,3 +85,10 @@ func YubiAuth(val *yubigo.YubiAuth) Option { o.YubiAuth = val } } + +// If we specified a helper, for instance for OTP injection +func Helper(val Handler) Option { + return func(o *Options) { + o.Helper = val + } +} diff --git a/pkg/handler/owncloud.go b/pkg/handler/owncloud.go index 5217728..8889517 100644 --- a/pkg/handler/owncloud.go +++ b/pkg/handler/owncloud.go @@ -28,6 +28,7 @@ type ownCloudSession struct { useGraphAPI bool } type ownCloudHandler struct { + backend config.Backend log logr.Logger cfg *config.Config client *http.Client @@ -37,15 +38,15 @@ type ownCloudHandler struct { func (h ownCloudHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (ldap.LDAPResultCode, error) { bindDN = strings.ToLower(bindDN) - baseDN := strings.ToLower("," + h.cfg.Backend.BaseDN) + baseDN := strings.ToLower("," + h.backend.BaseDN) - h.log.V(6).Info("Bind request", "binddn", bindDN, "basedn", h.cfg.Backend.BaseDN, "src", conn.RemoteAddr()) + h.log.V(6).Info("Bind request", "binddn", bindDN, "basedn", h.backend.BaseDN, "src", conn.RemoteAddr()) stats.Frontend.Add("bind_reqs", 1) // parse the bindDN - ensure that the bindDN ends with the BaseDN if !strings.HasSuffix(bindDN, baseDN) { - h.log.V(2).Info("BindDN not part of our BaseDN", "binddn", bindDN, "basedn", h.cfg.Backend.BaseDN) + h.log.V(2).Info("BindDN not part of our BaseDN", "binddn", bindDN, "basedn", h.backend.BaseDN) return ldap.LDAPResultInvalidCredentials, nil } parts := strings.Split(strings.TrimSuffix(bindDN, baseDN), ",") @@ -57,7 +58,7 @@ func (h ownCloudHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (ldap. // try to login if !h.login(userName, bindSimplePw) { - h.log.V(2).Info("Login failed", "username", userName, "basedn", h.cfg.Backend.BaseDN) + h.log.V(2).Info("Login failed", "username", userName, "basedn", h.backend.BaseDN) return ldap.LDAPResultInvalidCredentials, nil } @@ -67,20 +68,20 @@ func (h ownCloudHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (ldap. log: h.log, user: userName, password: bindSimplePw, - endpoint: h.cfg.Backend.Servers[0], - useGraphAPI: h.cfg.Backend.UseGraphAPI, + endpoint: h.backend.Servers[0], + useGraphAPI: h.backend.UseGraphAPI, client: h.client, } h.lock.Unlock() stats.Frontend.Add("bind_successes", 1) - h.log.V(6).Info("Bind success", "binddn", bindDN, "basedn", h.cfg.Backend.BaseDN, "src", conn.RemoteAddr()) + h.log.V(6).Info("Bind success", "binddn", bindDN, "basedn", h.backend.BaseDN, "src", conn.RemoteAddr()) return ldap.LDAPResultSuccess, nil } func (h ownCloudHandler) Search(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (ldap.ServerSearchResult, error) { bindDN = strings.ToLower(bindDN) - baseDN := strings.ToLower("," + h.cfg.Backend.BaseDN) + baseDN := strings.ToLower("," + h.backend.BaseDN) searchBaseDN := strings.ToLower(searchReq.BaseDN) h.log.V(6).Info("Search request", "binddn", bindDN, "basedn", baseDN, "src", conn.RemoteAddr(), "filter", searchReq.Filter) stats.Frontend.Add("search_reqs", 1) @@ -90,10 +91,10 @@ func (h ownCloudHandler) Search(bindDN string, searchReq ldap.SearchRequest, con return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("search error: Anonymous BindDN not allowed %s", bindDN) } if !strings.HasSuffix(bindDN, baseDN) { - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("search error: BindDN %s not in our BaseDN %s", bindDN, h.cfg.Backend.BaseDN) + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("search error: BindDN %s not in our BaseDN %s", bindDN, h.backend.BaseDN) } - if !strings.HasSuffix(searchBaseDN, h.cfg.Backend.BaseDN) { - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("search error: search BaseDN %s is not in our BaseDN %s", searchBaseDN, h.cfg.Backend.BaseDN) + if !strings.HasSuffix(searchBaseDN, h.backend.BaseDN) { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("search error: search BaseDN %s is not in our BaseDN %s", searchBaseDN, h.backend.BaseDN) } // return all users in the config file - the LDAP library will filter results for us entries := []*ldap.Entry{} @@ -128,12 +129,12 @@ func (h ownCloudHandler) Search(bindDN string, searchReq ldap.SearchRequest, con attrs = append(attrs, &ldap.EntryAttribute{Name: "memberUid", Values: members}) } - dn := fmt.Sprintf("cn=%s,%s=groups,%s", *g.ID, h.cfg.Backend.GroupFormat, h.cfg.Backend.BaseDN) + dn := fmt.Sprintf("cn=%s,%s=groups,%s", *g.ID, h.backend.GroupFormat, h.backend.BaseDN) entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs}) } case "posixaccount", "": userName := "" - if searchBaseDN != strings.ToLower(h.cfg.Backend.BaseDN) { + if searchBaseDN != strings.ToLower(h.backend.BaseDN) { parts := strings.Split(strings.TrimSuffix(searchBaseDN, baseDN), ",") if len(parts) >= 1 { userName = strings.TrimPrefix(parts[0], "cn=") @@ -158,7 +159,7 @@ func (h ownCloudHandler) Search(bindDN string, searchReq ldap.SearchRequest, con attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: []string{"posixAccount"}}) attrs = append(attrs, &ldap.EntryAttribute{Name: "description", Values: []string{fmt.Sprintf("%s from ownCloud", *u.ID)}}) - dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.cfg.Backend.NameFormat, *u.ID, h.cfg.Backend.GroupFormat, "users", h.cfg.Backend.BaseDN) + dn := fmt.Sprintf("%s=%s,%s=%s,%s", h.backend.NameFormat, *u.ID, h.backend.GroupFormat, "users", h.backend.BaseDN) entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs}) } } @@ -182,6 +183,10 @@ func (h ownCloudHandler) Delete(boundDN string, deleteDN string, conn net.Conn) return ldap.LDAPResultInsufficientAccessRights, nil } +func (h ownCloudHandler) FindUser(userName string) (found bool, user config.User, err error) { + return false, config.User{}, nil +} + func (h ownCloudHandler) Close(boundDN string, conn net.Conn) error { conn.Close() // close connection to the server when then client is closed h.lock.Lock() @@ -194,12 +199,12 @@ func (h ownCloudHandler) Close(boundDN string, conn net.Conn) error { func (h ownCloudHandler) login(name, pw string) bool { var req *http.Request - if h.cfg.Backend.UseGraphAPI { + if h.backend.UseGraphAPI { // TODO oc10 graphapi app should implement /me - req, _ = http.NewRequest("GET", h.cfg.Backend.Servers[0]+"/users/"+name, nil) + req, _ = http.NewRequest("GET", h.backend.Servers[0]+"/users/"+name, nil) } else { // use provisioning api - meURL := fmt.Sprintf("%s/ocs/v2.php/cloud/user?format=json", h.cfg.Backend.Servers[0]) + meURL := fmt.Sprintf("%s/ocs/v2.php/cloud/user?format=json", h.backend.Servers[0]) req, _ = http.NewRequest("GET", meURL, nil) } req.SetBasicAuth(name, pw) @@ -343,6 +348,7 @@ func NewOwnCloudHandler(opts ...Option) Handler { options := newOptions(opts...) return ownCloudHandler{ + backend: options.Backend, log: options.Logger, cfg: options.Config, sessions: make(map[string]ownCloudSession), @@ -352,7 +358,7 @@ func NewOwnCloudHandler(opts ...Option) Handler { }, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: options.Config.Backend.Insecure, + InsecureSkipVerify: options.Backend.Insecure, }, }, }, diff --git a/pkg/plugins/Makefile b/pkg/plugins/Makefile new file mode 100644 index 0000000..d0a8d3a --- /dev/null +++ b/pkg/plugins/Makefile @@ -0,0 +1,22 @@ +##################### +# Plugins Targets +##################### + +# Note: to make a plugin compatible with a binary built in debug mode, add `-gcflags='all=-N -l'` + +plugins: plugin_mysql plugin_postgres plugin_sqlite + +plugin_mysql: bin/mysql.so + +bin/mysql.so: pkg/plugins/mysql.go pkg/plugins/basesqlhandler.go + GOOS=linux GOARCH=amd64 go build ${TRIM_FLAGS} -ldflags "${BUILD_VARS}" -buildmode=plugin -o $@ $^ + +plugin_postgres: bin/postgres.so + +bin/postgres.so: pkg/plugins/postgres.go pkg/plugins/basesqlhandler.go + GOOS=linux GOARCH=amd64 go build ${TRIM_FLAGS} -ldflags "${BUILD_VARS}" -buildmode=plugin -o $@ $^ + +plugin_sqlite: bin/sqlite.so + +bin/sqlite.so: pkg/plugins/sqlite.go pkg/plugins/basesqlhandler.go + GOOS=linux GOARCH=amd64 go build ${TRIM_FLAGS} -ldflags "${BUILD_VARS}" -buildmode=plugin -o $@ $^ diff --git a/pkg/plugins/README.md b/pkg/plugins/README.md new file mode 100644 index 0000000..3c58792 --- /dev/null +++ b/pkg/plugins/README.md @@ -0,0 +1,132 @@ +# GLAuth Plugins + +This folder contains plugins; that is, backends that are not compiled in GLAuth by default. + +To quote 'Butonic' (Jörn Friedrich Dreyer): + +> Just keep the 'lightweight' in mind. + +To build either back-end, type +``` +make plugin_name +``` +where 'name' is the plugin's name; so, for instance: `make plugin_sqlite` + +## Database Plugins + +To use a database plugin, edit the configuration file (see pkg/plugins/sample-database.cfg) so that: + +``` +... +[backend] + datastore = "plugin" + plugin = "dynamic library you created using the previous 'make' command" + database = "database connection string" +... +``` +so, let's say you built the 'sqlite' plugin, you would now specify its library: `database = sqlite.so` + +### SQLite, MySQL, Postgres + +Tables: +- users, groups are self-explanatory +- includegroups store the 'includegroups' relationships +- othergroups, on the other hand, are a comma-separated list found in the users table (performance) + +Here is how to insert example data using your database's REPL (more detailed information can be found in pkg/plugins/sample-database.cfg) + +```sql +INSERT INTO users(name, unixid, primarygroup, passsha256) VALUES('hackers', 5001, 5501, '6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a'); +INSERT INTO groups(name, unixid) VALUES('superheros', 5501); +INSERT INTO groups(name, unixid) VALUES('crusaders', 5502); +INSERT INTO groups(name, unixid) VALUES('civilians', 5503); +INSERT INTO groups(name, unixid) VALUES('caped', 5504); +INSERT INTO groups(name, unixid) VALUES('lovesailing', 5505); +INSERT INTO groups(name, unixid) VALUES('smoker', 5506); +INSERT INTO includegroups(parentgroupid, includegroupid) VALUES(5503, 5501); +INSERT INTO includegroups(parentgroupid, includegroupid) VALUES(5504, 5502); +INSERT INTO includegroups(parentgroupid, includegroupid) VALUES(5504, 5501); +INSERT INTO users(name, unixid, primarygroup, passsha256) VALUES('user1', 5001, 5501, '6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a'); +INSERT INTO users(name, unixid, primarygroup, passsha256) VALUES('user2', 5002, 5502, '6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a'); +INSERT INTO users(name, unixid, primarygroup, passsha256) VALUES('user3', 5003, 5504, '6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a'); +INSERT INTO users(name, unixid, primarygroup, passsha256, othergroups) VALUES('user4', 5004, 5504, '6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a', '5505,5506'); +``` +This should be equivalent to this configuration: +```text +[[users]] + name = "hackers" + unixid = 5001 + primarygroup = 5501 + passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood + +[[users]] + name = "user1" + unixid = 5001 + primarygroup = 5501 + passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood + +[[users]] + name = "user2" + unixid = 5002 + primarygroup = 5502 + passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood + +[[users]] + name = "user3" + unixid = 5003 + primarygroup = 5504 + passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood + +[[users]] + name = "user4" + unixid = 5003 + primarygroup = 5504 + othergroups = [5505, 5506] + passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood + +[[groups]] + name = "superheros" + unixid = 5501 + +[[groups]] + name = "crusaders" + unixid = 5502 + +[[groups]] + name = "civilians" + unixid = 5503 + includegroups = [ 5501 ] + +[[groups]] + name = "caped" + unixid = 5504 + includegroups = [ 5502, 5501 ] +``` +and LDAP should return these `memberOf` values: +```text +uid: user1 +ou: superheros +memberOf: cn=caped,ou=groups,dc=militate,dc=com +memberOf: cn=civilians,ou=groups,dc=militate,dc=com +memberOf: cn=superheros,ou=groups,dc=militate,dc=com + +uid: user2 +ou: crusaders +memberOf: cn=caped,ou=groups,dc=militate,dc=com +memberOf: cn=crusaders,ou=groups,dc=militate,dc=com + +uid: user3 +ou: caped +memberOf: cn=caped,ou=groups,dc=militate,dc=com + +uid: user4 +ou: caped +memberOf: cn=caped,ou=groups,dc=militate,dc=com +memberOf: cn=lovesailing,ou=groups,dc=militate,dc=com +memberOf: cn=smoker,ou=groups,dc=militate,dc=com +``` +If you have the ldap client package installed, this can be easily confirmed by running +``` +ldapsearch -H ldap://localhost:3893 -D cn=hackers,ou=superheros,dc=glauth,dc=com -w dogood -x -bdc=glauth,dc=com cn=user1 +``` +and so on. \ No newline at end of file diff --git a/pkg/plugins/basesqlhandler.go b/pkg/plugins/basesqlhandler.go new file mode 100644 index 0000000..20bb1da --- /dev/null +++ b/pkg/plugins/basesqlhandler.go @@ -0,0 +1,597 @@ +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "net" + "os" + "sort" + "strconv" + "strings" + + "github.com/GeertJohan/yubigo" + "github.com/glauth/glauth/pkg/config" + "github.com/glauth/glauth/pkg/handler" + "github.com/glauth/glauth/pkg/stats" + "github.com/go-logr/logr" + "github.com/nmcclain/ldap" + "github.com/pquerna/otp/totp" +) + +type SqlBackend interface { + // Name used by database/sql when loading the driver + GetDriverName() string + // Create db/schema if necessary + CreateSchema(db *sql.DB) + // + GetPrepareSymbol() string +} + +type database struct { + path string + cnx *sql.DB +} + +type databaseHandler struct { + backend config.Backend + log logr.Logger + cfg *config.Config + yubikeyAuth *yubigo.YubiAuth + sqlBackend SqlBackend + database database + MemGroups []config.Group +} + +// func NewDatabaseHandler_deprecated(log *logging.Logger, cfg *config.Config, yubikeyAuth *yubigo.YubiAuth, sqlBackend SqlBackend) handler.Handler { +func NewDatabaseHandler(sqlBackend SqlBackend, opts ...handler.Option) handler.Handler { + options := handler.NewOptions(opts...) + + // Note: we will never terminate this connection pool. + db, err := sql.Open(sqlBackend.GetDriverName(), options.Backend.Database) + if err != nil { + options.Logger.Error(err, "Unable to open SQL database named '%s' error: %s", options.Backend.Database) + os.Exit(1) + } + err = db.Ping() + if err != nil { + options.Logger.Error(err, "Unable to communicate with SQL database error: %s", options.Backend.Database) + os.Exit(1) + } + + dbInfo := database{ + path: options.Backend.Database, + cnx: db, + } + + handler := databaseHandler{ + backend: options.Backend, + log: options.Logger, + cfg: options.Config, + yubikeyAuth: options.YubiAuth, + sqlBackend: sqlBackend, + database: dbInfo} + + sqlBackend.CreateSchema(db) + + options.Logger.V(3).Info("Database (" + sqlBackend.GetDriverName() + "::" + options.Backend.Database + ") Plugin: Ready") + + return handler +} + +func (h databaseHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultCode ldap.LDAPResultCode, err error) { + bindDN = strings.ToLower(bindDN) + baseDN := strings.ToLower("," + h.backend.BaseDN) + + h.log.V(3).Info(fmt.Sprintf("Bind request: bindDN: %s, BaseDN: %s, source: %s", bindDN, h.backend.BaseDN, conn.RemoteAddr().String())) + + stats.Frontend.Add("bind_reqs", 1) + + // parse the bindDN - ensure that the bindDN ends with the BaseDN + if !strings.HasSuffix(bindDN, baseDN) { + h.log.V(2).Info(fmt.Sprintf("Bind Error: BindDN %s not our BaseDN %s", bindDN, h.backend.BaseDN)) + // h.log.V(2).Info(fmt.Sprintf("Bind Error: BindDN %s not our BaseDN %s", bindDN, baseDN)) + return ldap.LDAPResultInvalidCredentials, nil + } + parts := strings.Split(strings.TrimSuffix(bindDN, baseDN), ",") + groupName := "" + userName := "" + if len(parts) == 1 { + userName = strings.TrimPrefix(parts[0], h.backend.NameFormat+"=") + } else if len(parts) == 2 { + userName = strings.TrimPrefix(parts[0], h.backend.NameFormat+"=") + groupName = strings.TrimPrefix(parts[1], h.backend.GroupFormat+"=") + } else { + h.log.V(2).Info(fmt.Sprintf("Bind Error: BindDN %s should have only one or two parts (has %d)", bindDN, len(parts))) + return ldap.LDAPResultInvalidCredentials, nil + } + + // find the user + user := config.User{} + err = h.database.cnx.QueryRow(fmt.Sprintf(` + SELECT u.unixid,u.primarygroup,u.passsha256,u.otpsecret,u.yubikey + FROM users u WHERE lower(u.name)=%s`, h.sqlBackend.GetPrepareSymbol()), userName).Scan( + &user.UnixID, &user.PrimaryGroup, &user.PassSHA256, &user.OTPSecret, &user.Yubikey) + if err != nil { + h.log.V(2).Info(fmt.Sprintf("Bind Error: User %s not found.", userName)) + return ldap.LDAPResultInvalidCredentials, nil + } + // find the group + group := config.Group{} + err = h.database.cnx.QueryRow(fmt.Sprintf(` + SELECT g.unixid FROM groups g WHERE lower(name)=%s`, h.sqlBackend.GetPrepareSymbol()), groupName).Scan( + &group.UnixID) + if err != nil { + h.log.V(2).Info(fmt.Sprintf("Bind Error: Group %s not found.", userName)) + return ldap.LDAPResultInvalidCredentials, nil + } + // validate group membership + if user.PrimaryGroup != group.UnixID { + h.log.V(2).Info(fmt.Sprintf("Bind Error: User %s primary group is not %s.", userName, groupName)) + return ldap.LDAPResultInvalidCredentials, nil + } + + validotp := false + + if len(user.Yubikey) == 0 && len(user.OTPSecret) == 0 { + validotp = true + } + + if len(user.Yubikey) > 0 && h.yubikeyAuth != nil { + if len(bindSimplePw) > 44 { + otp := bindSimplePw[len(bindSimplePw)-44:] + yubikeyid := otp[0:12] + bindSimplePw = bindSimplePw[:len(bindSimplePw)-44] + + if user.Yubikey == yubikeyid { + _, ok, _ := h.yubikeyAuth.Verify(otp) + + if ok { + validotp = true + } + } + } + } + + // Store the full bind password provided before possibly modifying + // in the otp check + // Generate a hash of the provided password + hashFull := sha256.New() + hashFull.Write([]byte(bindSimplePw)) + + // Test OTP if exists + if len(user.OTPSecret) > 0 && !validotp { + if len(bindSimplePw) > 6 { + otp := bindSimplePw[len(bindSimplePw)-6:] + bindSimplePw = bindSimplePw[:len(bindSimplePw)-6] + + validotp = totp.Validate(otp, user.OTPSecret) + } + } + + // finally, validate user's pw + + // check app passwords first + for index, appPw := range user.PassAppSHA256 { + + if appPw != hex.EncodeToString(hashFull.Sum(nil)) { + h.log.V(2).Info(fmt.Sprintf("Attempted to bind app pw #%d - failure as %s from %s", index, bindDN, conn.RemoteAddr().String())) + } else { + stats.Frontend.Add("bind_successes", 1) + h.log.V(3).Info("Bind success using app pw #%d as %s from %s", index, bindDN, conn.RemoteAddr().String()) + return ldap.LDAPResultSuccess, nil + } + + } + + // then check main password with the hash + hash := sha256.New() + hash.Write([]byte(bindSimplePw)) + + // Then ensure the OTP is valid before checking + if !validotp { + h.log.V(2).Info(fmt.Sprintf("Bind Error: invalid OTP token as %s from %s", bindDN, conn.RemoteAddr().String())) + return ldap.LDAPResultInvalidCredentials, nil + } + + // Now, check the hash + if user.PassSHA256 != hex.EncodeToString(hash.Sum(nil)) { + h.log.V(2).Info(fmt.Sprintf("Bind Error: invalid credentials as %s from %s", bindDN, conn.RemoteAddr().String())) + return ldap.LDAPResultInvalidCredentials, nil + } + + stats.Frontend.Add("bind_successes", 1) + h.log.V(3).Info(fmt.Sprintf("Bind success as %s from %s", bindDN, conn.RemoteAddr().String())) + return ldap.LDAPResultSuccess, nil +} + +func (h databaseHandler) Search(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (result ldap.ServerSearchResult, err error) { + bindDN = strings.ToLower(bindDN) + baseDN := strings.ToLower("," + h.backend.BaseDN) + searchBaseDN := strings.ToLower(searchReq.BaseDN) + h.log.V(3).Info(fmt.Sprintf("Search request as %s from %s for %s", bindDN, conn.RemoteAddr().String(), searchReq.Filter)) + stats.Frontend.Add("search_reqs", 1) + + // validate the user is authenticated and has appropriate access + if len(bindDN) < 1 { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", bindDN) + } + if !strings.HasSuffix(bindDN, baseDN) { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: BindDN %s not in our BaseDN %s", bindDN, h.backend.BaseDN) + } + if !strings.HasSuffix(searchBaseDN, h.backend.BaseDN) { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: search BaseDN %s is not in our BaseDN %s", searchBaseDN, h.backend.BaseDN) + } + // return all users in the config file - the LDAP library will filter results for us + entries := []*ldap.Entry{} + filterEntity, err := ldap.GetFilterObjectClass(searchReq.Filter) + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", searchReq.Filter) + } + switch filterEntity { + default: + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, searchReq.Filter) + case "posixgroup": + h.MemGroups, err = h.memoizeGroups() + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: Unable to memoize groups [%s]", err.Error()) + } + + for _, g := range h.MemGroups { + entries = append(entries, h.getGroup(g)) + } + case "posixaccount", "": + h.MemGroups, err = h.memoizeGroups() + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: Unable to memoize groups [%s]", err.Error()) + } + + rows, err := h.database.cnx.Query(` + SELECT u.name,u.unixid,u.primarygroup,u.passsha256,u.otpsecret,u.yubikey,u.othergroups,u.givenname,u.sn,u.mail,u.loginshell,u.homedirectory,u.disabled + FROM users u`) + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: Unable to retrieve data [%s]", err.Error()) + } + defer rows.Close() + + var otherGroups string + var disabled int + u := config.User{} + for rows.Next() { + err := rows.Scan(&u.Name, &u.UnixID, &u.PrimaryGroup, &u.PassSHA256, &u.OTPSecret, &u.Yubikey, &otherGroups, &u.GivenName, &u.SN, &u.Mail, &u.LoginShell, &u.Homedir, &disabled) + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: Unable to retrieve data [%s]", err.Error()) + } + u.OtherGroups = h.commaListToTable(otherGroups) + u.Disabled = h.intToBool(disabled) + + entries = append(entries, h.getAccount(u)) + } + } + stats.Frontend.Add("search_successes", 1) + h.log.V(3).Info(fmt.Sprintf("AP: Search OK: %s", searchReq.Filter)) + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil +} + +// Add is not yet supported for the sql backend +func (h databaseHandler) Add(boundDN string, req ldap.AddRequest, conn net.Conn) (result ldap.LDAPResultCode, err error) { + return ldap.LDAPResultInsufficientAccessRights, nil +} + +// Modify is not yet supported for the sql backend +func (h databaseHandler) Modify(boundDN string, req ldap.ModifyRequest, conn net.Conn) (result ldap.LDAPResultCode, err error) { + return ldap.LDAPResultInsufficientAccessRights, nil +} + +// Delete is not yet supported for the sql backend +func (h databaseHandler) Delete(boundDN string, deleteDN string, conn net.Conn) (result ldap.LDAPResultCode, err error) { + return ldap.LDAPResultInsufficientAccessRights, nil +} + +func (h databaseHandler) FindUser(userName string) (f bool, u config.User, err error) { + user := config.User{} + found := false + + err = h.database.cnx.QueryRow(fmt.Sprintf(` + SELECT u.unixid,u.primarygroup,u.passsha256,u.otpsecret,u.yubikey + FROM users u WHERE lower(u.name)=%s`, h.sqlBackend.GetPrepareSymbol()), userName).Scan( + &user.UnixID, &user.PrimaryGroup, &user.PassSHA256, &user.OTPSecret, &user.Yubikey) + if err == nil { + found = true + } + + return found, user, err +} + +func (h databaseHandler) Close(boundDn string, conn net.Conn) error { + stats.Frontend.Add("closes", 1) + return nil +} + +func (h databaseHandler) intToBool(value int) bool { + if value == 0 { + return false + } + return true +} + +func (h databaseHandler) commaListToTable(commaList string) []int { + if len(commaList) == 0 { + return make([]int, 0) + } + rowsAsStrings := strings.Split(commaList, ",") + rowsAsInts := make([]int, len(rowsAsStrings)) + for i, v := range rowsAsStrings { + iv, err := strconv.Atoi(v) + if err != nil { + return rowsAsInts + } + rowsAsInts[i] = iv + } + return rowsAsInts +} + +func (h databaseHandler) memoizeGroups() ([]config.Group, error) { + workMemGroups := make([]*config.Group, 0) + rows, err := h.database.cnx.Query(` + SELECT g1.name,g1.unixid,ig.includegroupid + FROM groups g1 + LEFT JOIN includegroups ig ON g1.unixid=ig.parentgroupid + LEFT JOIN groups g2 ON ig.includegroupid=g2.unixid`) + if err != nil { + return nil, errors.New("Unable to memoize groups list") + } + defer rows.Close() + + var groupName string + var groupId int + var includeId sql.NullInt64 // Store includeid from left join + var pg *config.Group + recentId := -1 // id of recently updated group + for rows.Next() { + err := rows.Scan(&groupName, &groupId, &includeId) + if err != nil { + return nil, errors.New("Unable to memoize groups list") + } + if recentId != groupId { + recentId = groupId + g := config.Group{Name: groupName, UnixID: groupId} + pg = &g // To manipulate end of slice + workMemGroups = append(workMemGroups, &g) + } + if includeId.Valid { + pg.IncludeGroups = append(pg.IncludeGroups, int(includeId.Int64)) + } + } + memGroups := make([]config.Group, len(workMemGroups)) + for i, v := range workMemGroups { + memGroups[i] = config.Group{Name: v.Name, UnixID: v.UnixID, IncludeGroups: v.IncludeGroups} + } + return memGroups, nil +} + +// Used when looking up Posix Groups +func (h databaseHandler) getGroupMembers(gid int) []string { + members := make(map[string]bool) + + rows, err := h.database.cnx.Query(` + SELECT u.name,u.unixid,u.primarygroup,u.passsha256,u.otpsecret,u.yubikey,u.othergroups + FROM users u WHERE lower(u.name)=?`, + ) + if err != nil { + // Silent fail... for now + return []string{} + } + defer rows.Close() + + var otherGroups string + u := config.User{} + for rows.Next() { + err := rows.Scan(&u.Name, &u.UnixID, &u.PrimaryGroup, &u.PassSHA256, &u.OTPSecret, &u.Yubikey, &otherGroups) + if err != nil { + return []string{} + } + if u.PrimaryGroup == gid { + dn := fmt.Sprintf("cn=%s,ou=%s,%s", u.Name, h.getGroupName(u.PrimaryGroup), h.backend.BaseDN) + members[dn] = true + } else { + u.OtherGroups = h.commaListToTable(otherGroups) + for _, othergid := range u.OtherGroups { + if othergid == gid { + dn := fmt.Sprintf("cn=%s,ou=%s,%s", u.Name, h.getGroupName(u.PrimaryGroup), h.backend.BaseDN) + members[dn] = true + } + } + } + } + + for _, g := range h.MemGroups { + if gid == g.UnixID { + for _, includegroupid := range g.IncludeGroups { + if includegroupid != gid { + includegroupmembers := h.getGroupMembers(includegroupid) + + for _, includegroupmember := range includegroupmembers { + members[includegroupmember] = true + } + } + } + } + } + + m := []string{} + for k, _ := range members { + m = append(m, k) + } + + sort.Strings(m) + + return m +} + +// Used exclusively when looking up Posix Groups +func (h databaseHandler) getGroupMemberIDs(gid int) []string { + members := make(map[string]bool) + rows, err := h.database.cnx.Query(` + SELECT u.name,u.unixid,u.primarygroup,u.passsha256,u.otpsecret,u.yubikey,u.othergroups + FROM users u`) + if err != nil { + // Silent fail... for now + return []string{} + } + defer rows.Close() + + var otherGroups string + u := config.User{} + for rows.Next() { + err := rows.Scan(&u.Name, &u.UnixID, &u.PrimaryGroup, &u.PassSHA256, &u.OTPSecret, &u.Yubikey, &otherGroups) + if err != nil { + return []string{} + } + if u.PrimaryGroup == gid { + members[u.Name] = true + } else { + u.OtherGroups = h.commaListToTable(otherGroups) + for _, othergid := range u.OtherGroups { + if othergid == gid { + members[u.Name] = true + } + } + } + } + + for _, g := range h.MemGroups { + if gid == g.UnixID { + for _, includegroupid := range g.IncludeGroups { + if includegroupid == gid { + h.log.V(3).Info(fmt.Sprintf("Group: %d - Ignoring myself as included group", includegroupid)) + } else { + includegroupmemberids := h.getGroupMemberIDs(includegroupid) + + for _, includegroupmemberid := range includegroupmemberids { + members[includegroupmemberid] = true + } + } + } + } + } + + m := []string{} + for k, _ := range members { + m = append(m, k) + } + + sort.Strings(m) + + return m +} + +// Invoked for every user being returned from our database +func (h databaseHandler) getGroupDNs(gids []int) []string { + groups := make(map[string]bool) + for _, gid := range gids { + for _, g := range h.MemGroups { + if g.UnixID == gid { + dn := fmt.Sprintf("cn=%s,ou=groups,%s", g.Name, h.backend.BaseDN) + groups[dn] = true + } + + for _, includegroupid := range g.IncludeGroups { + if includegroupid == gid && g.UnixID != gid { + includegroupdns := h.getGroupDNs([]int{g.UnixID}) + + for _, includegroupdn := range includegroupdns { + groups[includegroupdn] = true + } + } + } + } + } + + g := []string{} + for k, _ := range groups { + g = append(g, k) + } + + sort.Strings(g) + + return g +} + +// Invoked for every user being returned from our database +func (h databaseHandler) getGroupName(gid int) string { + for _, g := range h.MemGroups { + if g.UnixID == gid { + return g.Name + } + } + return "" +} + +// Toolbox +func (h databaseHandler) getGroup(g config.Group) *ldap.Entry { + attrs := []*ldap.EntryAttribute{} + attrs = append(attrs, &ldap.EntryAttribute{"cn", []string{g.Name}}) + attrs = append(attrs, &ldap.EntryAttribute{"description", []string{fmt.Sprintf("%s via LDAP", g.Name)}}) + attrs = append(attrs, &ldap.EntryAttribute{"gidNumber", []string{fmt.Sprintf("%d", g.UnixID)}}) + attrs = append(attrs, &ldap.EntryAttribute{"objectClass", []string{"posixGroup"}}) + attrs = append(attrs, &ldap.EntryAttribute{"uniqueMember", h.getGroupMembers(g.UnixID)}) + attrs = append(attrs, &ldap.EntryAttribute{"memberUid", h.getGroupMemberIDs(g.UnixID)}) + dn := fmt.Sprintf("cn=%s,ou=groups,%s", g.Name, h.backend.BaseDN) + return &ldap.Entry{dn, attrs} +} + +func (h databaseHandler) getAccount(u config.User) *ldap.Entry { + attrs := []*ldap.EntryAttribute{} + attrs = append(attrs, &ldap.EntryAttribute{"cn", []string{u.Name}}) + attrs = append(attrs, &ldap.EntryAttribute{"uid", []string{u.Name}}) + + if len(u.GivenName) > 0 { + attrs = append(attrs, &ldap.EntryAttribute{"givenName", []string{u.GivenName}}) + } + + if len(u.SN) > 0 { + attrs = append(attrs, &ldap.EntryAttribute{"sn", []string{u.SN}}) + } + + attrs = append(attrs, &ldap.EntryAttribute{"ou", []string{h.getGroupName(u.PrimaryGroup)}}) + attrs = append(attrs, &ldap.EntryAttribute{"uidNumber", []string{fmt.Sprintf("%d", u.UnixID)}}) + + if u.Disabled { + attrs = append(attrs, &ldap.EntryAttribute{"accountStatus", []string{"inactive"}}) + } else { + attrs = append(attrs, &ldap.EntryAttribute{"accountStatus", []string{"active"}}) + } + + if len(u.Mail) > 0 { + attrs = append(attrs, &ldap.EntryAttribute{"mail", []string{u.Mail}}) + } + + attrs = append(attrs, &ldap.EntryAttribute{"objectClass", []string{"posixAccount"}}) + + if len(u.LoginShell) > 0 { + attrs = append(attrs, &ldap.EntryAttribute{"loginShell", []string{u.LoginShell}}) + } else { + attrs = append(attrs, &ldap.EntryAttribute{"loginShell", []string{"/bin/bash"}}) + } + + if len(u.Homedir) > 0 { + attrs = append(attrs, &ldap.EntryAttribute{"homeDirectory", []string{u.Homedir}}) + } else { + attrs = append(attrs, &ldap.EntryAttribute{"homeDirectory", []string{"/home/" + u.Name}}) + } + + attrs = append(attrs, &ldap.EntryAttribute{"description", []string{fmt.Sprintf("%s via LDAP", u.Name)}}) + attrs = append(attrs, &ldap.EntryAttribute{"gecos", []string{fmt.Sprintf("%s via LDAP", u.Name)}}) + attrs = append(attrs, &ldap.EntryAttribute{"gidNumber", []string{fmt.Sprintf("%d", u.PrimaryGroup)}}) + attrs = append(attrs, &ldap.EntryAttribute{"memberOf", h.getGroupDNs(append(u.OtherGroups, u.PrimaryGroup))}) + if len(u.SSHKeys) > 0 { + attrs = append(attrs, &ldap.EntryAttribute{"sshPublicKey", u.SSHKeys}) + } + dn := fmt.Sprintf("cn=%s,ou=%s,%s", u.Name, h.getGroupName(u.PrimaryGroup), h.backend.BaseDN) + return &ldap.Entry{dn, attrs} +} + +func main() {} diff --git a/pkg/plugins/mysql.go b/pkg/plugins/mysql.go new file mode 100644 index 0000000..bfe4a4c --- /dev/null +++ b/pkg/plugins/mysql.go @@ -0,0 +1,55 @@ +package main + +import ( + "database/sql" + + _ "github.com/go-sql-driver/mysql" + + "github.com/glauth/glauth/pkg/handler" +) + +type MysqlBackend struct { +} + +func NewMySQLHandler(opts ...handler.Option) handler.Handler { + backend := MysqlBackend{} + return NewDatabaseHandler(backend, opts...) +} + +func (b MysqlBackend) GetDriverName() string { + return "mysql" +} + +func (b MysqlBackend) GetPrepareSymbol() string { + return "?" +} + +// Create db/schema if necessary +func (b MysqlBackend) CreateSchema(db *sql.DB) { + statement, _ := db.Prepare(` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + unixid INTEGER NOT NULL, + primarygroup INTEGER NOT NULL, + othergroups VARCHAR(1024) DEFAULT '', + givenname VARCHAR(64) DEFAULT '', + sn VARCHAR(64) DEFAULT '', + mail VARCHAR(254) DEFAULT '', + loginshell VARCHAR(64) DEFAULT '', + homedirectory VARCHAR(64) DEFAULT '', + disabled SMALLINT DEFAULT 0, + passsha256 VARCHAR(64) DEFAULT '', + otpsecret VARCHAR(64) DEFAULT '', + yubikey VARCHAR(128) DEFAULT '') +`) + statement.Exec() + statement, _ = db.Prepare("CREATE UNIQUE INDEX idx_user_name on users(name)") + statement.Exec() + statement, _ = db.Prepare("CREATE TABLE IF NOT EXISTS groups (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, unixid INTEGER NOT NULL)") + statement.Exec() + statement, _ = db.Prepare("CREATE UNIQUE INDEX idx_group_name on groups(name)") + statement.Exec() + statement, _ = db.Prepare("CREATE TABLE IF NOT EXISTS includegroups (id INTEGER AUTO_INCREMENT PRIMARY KEY, parentgroupid INTEGER NOT NULL, includegroupid INTEGER NOT NULL)") + statement.Exec() +} diff --git a/pkg/plugins/postgres.go b/pkg/plugins/postgres.go new file mode 100644 index 0000000..5ab0b27 --- /dev/null +++ b/pkg/plugins/postgres.go @@ -0,0 +1,55 @@ +package main + +import ( + "database/sql" + + _ "github.com/lib/pq" + + "github.com/glauth/glauth/pkg/handler" +) + +type PostgresBackend struct { +} + +func NewPostgresHandler(opts ...handler.Option) handler.Handler { + backend := PostgresBackend{} + return NewDatabaseHandler(backend, opts...) +} + +func (b PostgresBackend) GetDriverName() string { + return "postgres" +} + +func (b PostgresBackend) GetPrepareSymbol() string { + return "$1" +} + +// Create db/schema if necessary +func (b PostgresBackend) CreateSchema(db *sql.DB) { + statement, _ := db.Prepare(` +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + unixid INTEGER NOT NULL, + primarygroup INTEGER NOT NULL, + othergroups TEXT DEFAULT '', + givenname TEXT DEFAULT '', + sn TEXT DEFAULT '', + mail TEXT DEFAULT '', + loginshell TEXT DEFAULT '', + homedirectory TEXT DEFAULT '', + disabled SMALLINT DEFAULT 0, + passsha256 TEXT DEFAULT '', + otpsecret TEXT DEFAULT '', + yubikey TEXT DEFAULT '') +`) + statement.Exec() + statement, _ = db.Prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_name on users(name)") + statement.Exec() + statement, _ = db.Prepare("CREATE TABLE IF NOT EXISTS groups (id SERIAL PRIMARY KEY, name TEXT NOT NULL, unixid INTEGER NOT NULL)") + statement.Exec() + statement, _ = db.Prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_name on groups(name)") + statement.Exec() + statement, _ = db.Prepare("CREATE TABLE IF NOT EXISTS includegroups (id SERIAL PRIMARY KEY, parentgroupid INTEGER NOT NULL, includegroupid INTEGER NOT NULL)") + statement.Exec() +} diff --git a/pkg/plugins/sample-database.cfg b/pkg/plugins/sample-database.cfg new file mode 100644 index 0000000..6bdfd65 --- /dev/null +++ b/pkg/plugins/sample-database.cfg @@ -0,0 +1,82 @@ +################# +# glauth.conf + +################# +# General configuration. +debug = true +# syslog = true +# +# Enable hot-reload of configuration on changes +# - does NOT work [ldap], [ldaps], [backend] or [api] sections +# watchconfig = true + +################# +# yubikeyclientid = "yubi-api-clientid" +# yubikeysecret = "yubi-api-secret" + +################# +# Server configuration. +# +# This is the old server-config format +# - will still work, but please use new format, shown below +# +# [frontend] +# listen = "localhost:8484" +# tls = false +# cert ="certs/server.crt" +# key = "certs/server.key" + +# This ([ldap] and [ldaps]) is the new server-config format +[ldap] + enabled = true + listen = "0.0.0.0:3893" + +[ldaps] + enabled = true + listen = "0.0.0.0:3894" + cert = "certs/server.crt" + key = "certs/server.key" + +################# +# The backend section controls the data store. +[backend] + datastore = "plugin" + # If "plugin," uncomment the line below + plugin = "bin/sqlite.so" + pluginhandler = "NewSQLiteHandler" + baseDN = "dc=glauth,dc=com" + + # // MySQL // + # Uncomment this line to configure your MySQL database connection string: + # database = "glauth:glauth@tcp(127.0.0.1:3306)/glauth" + # Creating database: + # create database glauth identified; + # grant all on glauth.* to glauth@localhost identified by 'glauth'; + # // Postgres // + # Uncomment this line to configure your Postgres database connection string: + # database = "host=127.0.0.1 port=5432 dbname=glauth user=glauth password=glauth sslmode=disable" + # Creating database: + # CREATE DATABASE ; + # CREATE USER WITH PASSWORD '' + # GRANT ALL PRIVILEGES ON DATABASE to ; + # // SQLite // + # Uncomment this line to configure your SQLite database connection string: + database = "gl.db" + + ## Configure dn format to use structures like + ## "uid=serviceuser,cn=svcaccts,$BASEDN" instead of "cn=serviceuser,ou=svcaccts,$BASEDN" + ## to help ease migrations from other LDAP systems + # nameformat = "uid" + # groupformat = "cn" + + ## Configure ssh-key attribute name, default is 'sshPublicKey' + # sshkeyattr = "ipaSshPubKey" + +################# +# Enable and configure the optional REST API here. +[api] + enabled = true + tls = false # enable TLS for production!! + listen = "0.0.0.0:5555" + cert = "cert.pem" + key = "key.pem" diff --git a/pkg/plugins/sample-ldap-with-database.cfg b/pkg/plugins/sample-ldap-with-database.cfg new file mode 100644 index 0000000..6636613 --- /dev/null +++ b/pkg/plugins/sample-ldap-with-database.cfg @@ -0,0 +1,45 @@ +################# +# glauth.conf + +################# +# General configuration. +#debug = true +syslog = true + +################# +# Server configuration. +[ldap] + enabled = true + listen = "0.0.0.0:389" + +[ldaps] + enabled = true + listen = "0.0.0.0:636" + cert = "glauth.crt" + key = "glauth.key" + +################# +# The backend section controls the data store. +[backend] + datastore = "ldap" + servers = [ "ldaps://server1:636", "ldaps://server2:636" ] + + # Ignore SSL certificate errors when connecting to backend LDAP servers. + #insecure = true + +[helper] + enabled = true + datastore = "plugin" + plugin = "bin/sqlite.so" + pluginhandler = "NewSQLiteHandler" + database = "glh.db" + +################# +# Enable and configure the optional REST API here. +[api] + enabled = true + tls = true + listen = "localhost:5555" + cert = "glauth.crt" + key = "glauth.key" + diff --git a/pkg/plugins/sqlite.go b/pkg/plugins/sqlite.go new file mode 100644 index 0000000..216970e --- /dev/null +++ b/pkg/plugins/sqlite.go @@ -0,0 +1,55 @@ +package main + +import ( + "database/sql" + + _ "github.com/mattn/go-sqlite3" + + "github.com/glauth/glauth/pkg/handler" +) + +type SqliteBackend struct { +} + +func NewSQLiteHandler(opts ...handler.Option) handler.Handler { + backend := SqliteBackend{} + return NewDatabaseHandler(backend, opts...) +} + +func (b SqliteBackend) GetDriverName() string { + return "sqlite3" +} + +func (b SqliteBackend) GetPrepareSymbol() string { + return "?" +} + +// Create db/schema if necessary +func (b SqliteBackend) CreateSchema(db *sql.DB) { + statement, _ := db.Prepare(` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + unixid INTEGER NOT NULL, + primarygroup INTEGER NOT NULL, + othergroups TEXT DEFAULT '', + givenname TEXT DEFAULT '', + sn TEXT DEFAULT '', + mail TEXT DEFAULT '', + loginshell TYEXT DEFAULT '', + homedirectory TEXT DEFAULT '', + disabled SMALLINT DEFAULT 0, + passsha256 TEXT DEFAULT '', + otpsecret TEXT DEFAULT '', + yubikey TEXT DEFAULT '') +`) + statement.Exec() + statement, _ = db.Prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_name on users(name)") + statement.Exec() + statement, _ = db.Prepare("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL, unixid INTEGER NOT NULL)") + statement.Exec() + statement, _ = db.Prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_name on groups(name)") + statement.Exec() + statement, _ = db.Prepare("CREATE TABLE IF NOT EXISTS includegroups (id INTEGER PRIMARY KEY, parentgroupid INTEGER NOT NULL, includegroupid INTEGER NOT NULL)") + statement.Exec() +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 2aacaf6..628eb79 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -3,6 +3,7 @@ package server import ( "errors" "fmt" + "plugin" "github.com/GeertJohan/yubigo" "github.com/glauth/glauth/pkg/config" @@ -36,34 +37,112 @@ func NewServer(opts ...Option) (*LdapSvc, error) { } } - // configure the backend + var helper handler.Handler + + // instantiate the helper, if any + if s.c.Helper.Enabled { + switch s.c.Helper.Datastore { + case "config": + helper = handler.NewConfigHandler( + handler.Logger(s.log), + handler.Config(s.c), + handler.YubiAuth(s.yubiAuth), + ) + case "plugin": + plug, err := plugin.Open(s.c.Helper.Plugin) + if err != nil { + return nil, errors.New(fmt.Sprintf("Unable to load specified helper plugin: %s", err)) + } + nph, err := plug.Lookup(s.c.Helper.PluginHandler) + if err != nil { + return nil, errors.New("Unable to find 'NewPluginHandler' in loaded helper plugin") + } + initFunc, ok := nph.(func(...handler.Option) handler.Handler) + + if !ok { + return nil, errors.New("Loaded helper plugin lacks a proper NewPluginHandler function") + } + // Normally, here, we would somehow have imported our plugin into our + // handler namespace. Oops? + helper = initFunc( + handler.Logger(s.log), + handler.Config(s.c), + handler.YubiAuth(s.yubiAuth), + ) + default: + return nil, fmt.Errorf("unsupported helper %s - must be one of 'config', 'plugin'", s.c.Helper.Datastore) + } + s.log.V(3).Info("Using helper", "datastore", s.c.Helper.Datastore) + } + + backendCounter := -1 + allHandlers := handler.HandlerWrapper{Handlers: make([]handler.Handler, 10), Count: &backendCounter} + + // configure the backends s.l = ldap.NewServer() s.l.EnforceLDAP = true - var h handler.Handler - switch s.c.Backend.Datastore { - case "ldap": - h = handler.NewLdapHandler( - handler.Logger(s.log), - handler.Config(s.c), - ) - case "owncloud": - h = handler.NewOwnCloudHandler( - handler.Logger(s.log), - handler.Config(s.c), - ) - case "config": - h = handler.NewConfigHandler( - handler.Logger(s.log), - handler.Config(s.c), - handler.YubiAuth(s.yubiAuth), - ) - default: - return nil, fmt.Errorf("unsupported backend %s - must be 'config', 'ldap' or 'owncloud'", s.c.Backend.Datastore) + for i, backend := range s.c.Backends { + var h handler.Handler + switch backend.Datastore { + case "ldap": + h = handler.NewLdapHandler( + handler.Backend(backend), + handler.Handlers(allHandlers), + handler.Logger(s.log), + handler.Config(s.c), + handler.Helper(helper), + ) + case "owncloud": + h = handler.NewOwnCloudHandler( + handler.Backend(backend), + handler.Logger(s.log), + handler.Config(s.c), + ) + case "config": + h = handler.NewConfigHandler( + handler.Backend(backend), + handler.Logger(s.log), + handler.Config(s.c), + handler.YubiAuth(s.yubiAuth), + ) + case "plugin": + plug, err := plugin.Open(backend.Plugin) + if err != nil { + return nil, errors.New(fmt.Sprintf("Unable to load specified backend plugin: %s", err)) + } + nph, err := plug.Lookup(backend.PluginHandler) + if err != nil { + return nil, errors.New("Unable to find 'NewPluginHandler' in loaded backend plugin") + } + initFunc, ok := nph.(func(...handler.Option) handler.Handler) + + if !ok { + return nil, errors.New("Loaded backend plugin lacks a proper NewPluginHandler function") + } + // Normally, here, we would somehow have imported our plugin into our + // handler namespace. Oops? + h = initFunc( + handler.Backend(backend), + handler.Logger(s.log), + handler.Config(s.c), + handler.YubiAuth(s.yubiAuth), + ) + default: + return nil, fmt.Errorf("unsupported backend %s - must be one of 'config', 'ldap','owncloud' or 'plugin'", backend.Datastore) + } + s.log.V(3).Info("Loading backend", "datastore", backend.Datastore, "position", i) + + // Only our first backend will answer proper LDAP queries. + // Note that this could evolve towars something nicer where we would maintain + // multiple binders in addition to the existing multiple LDAP backends + if i == 0 { + s.l.BindFunc("", h) + s.l.SearchFunc("", h) + s.l.CloseFunc("", h) + } + allHandlers.Handlers[i] = h + backendCounter++ } - s.log.V(3).Info("Using backend", "datastore", s.c.Backend.Datastore) - s.l.BindFunc("", h) - s.l.SearchFunc("", h) - s.l.CloseFunc("", h) return &s, nil }