From 5fc06c9e8989b5d2568937a455fa4af3c119887d Mon Sep 17 00:00:00 2001 From: broccoli Date: Mon, 12 May 2025 14:30:59 +0200 Subject: [PATCH 1/4] init commit --- connector-ldap/i18n/en_US.yaml | 28 ++++++++++++++++++++++++++++ connector-ldap/i18n/translation.go | 26 ++++++++++++++++++++++++++ connector-ldap/info.yaml | 22 ++++++++++++++++++++++ connector-ldap/ldap.go | 17 +++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 connector-ldap/i18n/en_US.yaml create mode 100644 connector-ldap/i18n/translation.go create mode 100644 connector-ldap/info.yaml create mode 100644 connector-ldap/ldap.go diff --git a/connector-ldap/i18n/en_US.yaml b/connector-ldap/i18n/en_US.yaml new file mode 100644 index 00000000..57618f56 --- /dev/null +++ b/connector-ldap/i18n/en_US.yaml @@ -0,0 +1,28 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +plugin: + ldap_connector: + backend: + name: + other: LDAP + info: + name: + other: LDAP Connector + description: + other: Connect to LDAP for third-party login + \ No newline at end of file diff --git a/connector-ldap/i18n/translation.go b/connector-ldap/i18n/translation.go new file mode 100644 index 00000000..8fbed9c0 --- /dev/null +++ b/connector-ldap/i18n/translation.go @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package i18n + +const ( + ConnectorName = "plugin.github_connector.backend.name" + InfoName = "plugin.github_connector.backend.info.name" + InfoDescription = "plugin.github_connector.backend.info.description" +) diff --git a/connector-ldap/info.yaml b/connector-ldap/info.yaml new file mode 100644 index 00000000..62121270 --- /dev/null +++ b/connector-ldap/info.yaml @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +slug_name: ldap_connector +type: connector +version: 0.1.0 +author: DanielAuerX +link: https://github.com/apache/answer-plugins/tree/main/connector-ldap diff --git a/connector-ldap/ldap.go b/connector-ldap/ldap.go new file mode 100644 index 00000000..0a37f604 --- /dev/null +++ b/connector-ldap/ldap.go @@ -0,0 +1,17 @@ +package ldap + +import "github.com/apache/answer-plugins/connector-ldap/i18n" + +func (g *Connector) Info() plugin.Info { + info := &util.Info{} + info.GetInfo(Info) + + return plugin.Info{ + Name: plugin.MakeTranslator(i18n.InfoName), + SlugName: info.SlugName, + Description: plugin.MakeTranslator(i18n.InfoDescription), + Author: info.Author, + Version: info.Version, + Link: info.Link, + } +} From 8de64ef87ebdabd5c875bf325e51a9242040ef07 Mon Sep 17 00:00:00 2001 From: broccoli Date: Mon, 12 May 2025 22:24:14 +0200 Subject: [PATCH 2/4] basic implementation --- connector-ldap/i18n/translation.go | 6 +- connector-ldap/ldap.go | 129 ++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/connector-ldap/i18n/translation.go b/connector-ldap/i18n/translation.go index 8fbed9c0..98cf62f4 100644 --- a/connector-ldap/i18n/translation.go +++ b/connector-ldap/i18n/translation.go @@ -20,7 +20,7 @@ package i18n const ( - ConnectorName = "plugin.github_connector.backend.name" - InfoName = "plugin.github_connector.backend.info.name" - InfoDescription = "plugin.github_connector.backend.info.description" + ConnectorName = "plugin.ldap_connector.backend.name" + InfoName = "plugin.ldap_connector.backend.info.name" + InfoDescription = "plugin.ldap_connector.backend.info.description" ) diff --git a/connector-ldap/ldap.go b/connector-ldap/ldap.go index 0a37f604..07fd596f 100644 --- a/connector-ldap/ldap.go +++ b/connector-ldap/ldap.go @@ -1,6 +1,38 @@ package ldap -import "github.com/apache/answer-plugins/connector-ldap/i18n" +import ( + "embed" + "encoding/json" + "fmt" + + "github.com/apache/answer-plugins/connector-ldap/i18n" + "github.com/apache/answer-plugins/util" + "github.com/apache/answer/plugin" + "github.com/go-ldap/ldap/v3" +) + +// TODO: sanitization (e.g. username) +// TODO: email and display name lookup from ldap? +var Info embed.FS + +type Connector struct { + Config *ConnectorConfig +} + +type ConnectorConfig struct { + Name string `json:"name"` + Server string `json:"server"` + BaseDN string `json:"base_dn"` + BindPrefix string `json:"bind_prefix"` +} + +var _ plugin.Connector = &Connector{} + +func init() { + plugin.Register(&Connector{ + Config: &ConnectorConfig{}, + }) +} func (g *Connector) Info() plugin.Info { info := &util.Info{} @@ -15,3 +47,98 @@ func (g *Connector) Info() plugin.Info { Link: info.Link, } } + +func (g *Connector) ConnectorName() plugin.Translator { + if g.Config.Name != "" { + return plugin.MakeTranslator(g.Config.Name) + } + return plugin.MakeTranslator(i18n.ConnectorName) +} + +// get from info.yaml? != ldap +func (g *Connector) ConnectorSlugName() string { + return "ldap" +} + +// TODO: SVG support? +func (g *Connector) ConnectorLogoSVG() string { + return "" +} + +// TODO get from translator +func (g *Connector) ConfigFields() []plugin.ConfigField { + return []plugin.ConfigField{ + createTextInput("name", "LDAP", "LDAP connector name", g.Config.Name, true), + createTextInput("server", "LDAP Server", "e.g. ldap.example.com:389", g.Config.Server, true), + createTextInput("base_dn", "Base DN", "e.g. dc=example,dc=com", g.Config.BaseDN, true), + createTextInput("bind_prefix", "Bind Prefix", "e.g. CN= or uid=", g.Config.BindPrefix, false), + } +} + +func (g *Connector) ConfigReceiver(config []byte) error { + c := &ConnectorConfig{} + if err := json.Unmarshal(config, c); err != nil { + return err + } + g.Config = c + return nil +} + +func (g *Connector) ConnectorReceiver(ctx *plugin.GinContext, receiverURL string) (plugin.ExternalLoginUserInfo, error) { + var userInfo plugin.ExternalLoginUserInfo + + username := ctx.Request.FormValue("username") + password := ctx.Request.FormValue("password") + if username == "" || password == "" { + return userInfo, fmt.Errorf("missing username or password") + } + + bindDN := fmt.Sprintf("%s%s,%s", g.Config.BindPrefix, username, g.Config.BaseDN) + + err := ldapAuthenticate(g.Config.Server, g.Config.BaseDN, bindDN, password) + if err != nil { + return userInfo, fmt.Errorf("LDAP auth failed: %s", err) + } + + // returning to answer core + userInfo = plugin.ExternalLoginUserInfo{ + ExternalID: bindDN, + DisplayName: username, + Username: username, + Email: fmt.Sprintf("%s@example.com", username), // optional, needed? + MetaInfo: fmt.Sprintf("LDAP user %s", username), + } + return userInfo, nil +} + +func ldapAuthenticate(server, baseDN, bindDN, password string) error { + l, err := ldap.Dial("tcp", server) + if err != nil { + return err + } + defer l.Close() + + // bind with user credentials + err = l.Bind(bindDN, password) + if err != nil { + return err + } + + // search user info? + + return nil +} + +func createTextInput(name, title, desc, value string, require bool) plugin.ConfigField { + return plugin.ConfigField{ + Name: name, + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(title), + Description: plugin.MakeTranslator(desc), + Required: require, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: value, + } +} From acd39d1e71809d276e7fb8f5171465c27c878b11 Mon Sep 17 00:00:00 2001 From: broccoli Date: Sat, 17 May 2025 13:01:01 +0200 Subject: [PATCH 3/4] working prototype --- connector-ldap/go.mod | 9 ++ connector-ldap/ldap.go | 183 ++++++++++++++++++++++++++++---------- connector-ldap/login.html | 14 +++ 3 files changed, 161 insertions(+), 45 deletions(-) create mode 100644 connector-ldap/go.mod create mode 100644 connector-ldap/login.html diff --git a/connector-ldap/go.mod b/connector-ldap/go.mod new file mode 100644 index 00000000..7bd74904 --- /dev/null +++ b/connector-ldap/go.mod @@ -0,0 +1,9 @@ +module github.com/DanielAuerX/answer-plugins/connector-ldap + +go 1.22 + +require ( + github.com/apache/answer v1.4.2-RC1.0.20250107023923-061894735091 + github.com/apache/answer-plugins/util v1.0.3-0.20250107030257-cf94ebc70954 +) + diff --git a/connector-ldap/ldap.go b/connector-ldap/ldap.go index 07fd596f..db1a50d9 100644 --- a/connector-ldap/ldap.go +++ b/connector-ldap/ldap.go @@ -4,26 +4,43 @@ import ( "embed" "encoding/json" "fmt" + "net/http" + + "github.com/DanielAuerX/answer-plugins/connector-ldap/i18n" + "github.com/segmentfault/pacman/log" - "github.com/apache/answer-plugins/connector-ldap/i18n" "github.com/apache/answer-plugins/util" "github.com/apache/answer/plugin" "github.com/go-ldap/ldap/v3" ) -// TODO: sanitization (e.g. username) -// TODO: email and display name lookup from ldap? +//go:embed info.yaml var Info embed.FS +//go:embed login.html +var loginHTML embed.FS + +const ( + LdapAttributeDn = "dn" + LdapAttributeUid = "uid" + LdapAttributeCn = "cn" + LdapAttributeMail = "mail" + LdapAttributeDisplayName = "displayName" + LdapAttributeSamAccountName = "sAMAccountName" +) + type Connector struct { Config *ConnectorConfig } type ConnectorConfig struct { - Name string `json:"name"` - Server string `json:"server"` - BaseDN string `json:"base_dn"` - BindPrefix string `json:"bind_prefix"` + Name string `json:"name"` + Server string `json:"server"` + BaseDN string `json:"base_dn"` + BindPrefix string `json:"bind_prefix"` // e.g., uid= + BindDN string `json:"bind_dn"` // service account DN + BindPassword string `json:"bind_password"` // service account password + UserAttr string `json:"user_attr"` // e.g., uid, sAMAccountName } var _ plugin.Connector = &Connector{} @@ -55,23 +72,42 @@ func (g *Connector) ConnectorName() plugin.Translator { return plugin.MakeTranslator(i18n.ConnectorName) } -// get from info.yaml? != ldap func (g *Connector) ConnectorSlugName() string { return "ldap" } -// TODO: SVG support? func (g *Connector) ConnectorLogoSVG() string { return "" } +func (g *Connector) ConnectorSender(ctx *plugin.GinContext, receiverURL string) string { + log.Info("LDAP connector ConnectorSender...") + + htmlContent, err := loginHTML.ReadFile("login.html") + if err != nil { + log.Errorf("failed to read embedded html file: %v", err) + ctx.Writer.WriteHeader(500) + ctx.Writer.Write([]byte("Internal Server Error")) + return "" + } + + ctx.Writer.WriteHeader(200) + ctx.Writer.Header().Set("Content-Type", "text/html") + _, _ = ctx.Writer.Write([]byte(fmt.Sprintf(string(htmlContent), receiverURL))) + + return ctx.Request.Host +} + // TODO get from translator func (g *Connector) ConfigFields() []plugin.ConfigField { return []plugin.ConfigField{ - createTextInput("name", "LDAP", "LDAP connector name", g.Config.Name, true), - createTextInput("server", "LDAP Server", "e.g. ldap.example.com:389", g.Config.Server, true), - createTextInput("base_dn", "Base DN", "e.g. dc=example,dc=com", g.Config.BaseDN, true), - createTextInput("bind_prefix", "Bind Prefix", "e.g. CN= or uid=", g.Config.BindPrefix, false), + createTextInput("name", "LDAP", "LDAP connector name", g.Config.Name, true, false), + createTextInput("server", "LDAP Server", "e.g. ldap.example.com:389", g.Config.Server, true, false), + createTextInput("base_dn", "Base DN", "e.g. dc=example,dc=com", g.Config.BaseDN, true, false), + createTextInput("bind_prefix", "Bind Prefix", "e.g. CN= or uid=", g.Config.BindPrefix, false, false), //TODO NOT USED YET + createTextInput("bind_dn", "Bind DN", "DN of LDAP bind user", g.Config.BindDN, true, false), + createTextInput("bind_password", "Bind Password", "Password for bind DN", g.Config.BindPassword, true, true), + createTextInput("user_attr", "User Attribute", "LDAP attribute for username (e.g., uid or sAMAccountName)", g.Config.UserAttr, true, false), } } @@ -84,61 +120,118 @@ func (g *Connector) ConfigReceiver(config []byte) error { return nil } -func (g *Connector) ConnectorReceiver(ctx *plugin.GinContext, receiverURL string) (plugin.ExternalLoginUserInfo, error) { - var userInfo plugin.ExternalLoginUserInfo +func (c *Connector) ConnectorReceiver(ctx *plugin.GinContext, receiverURL string) (userInfo plugin.ExternalLoginUserInfo, err error) { + log.Info("ConnectorReceiver called!") - username := ctx.Request.FormValue("username") - password := ctx.Request.FormValue("password") - if username == "" || password == "" { - return userInfo, fmt.Errorf("missing username or password") + username, password, err := extractCredentials(ctx.Request) + if err != nil { + return userInfo, err } - bindDN := fmt.Sprintf("%s%s,%s", g.Config.BindPrefix, username, g.Config.BaseDN) + l, err := ldap.DialURL(c.Config.Server) + if err != nil { + return userInfo, fmt.Errorf("failed to connect to LDAP server: %w", err) + } + defer l.Close() - err := ldapAuthenticate(g.Config.Server, g.Config.BaseDN, bindDN, password) + err = l.Bind(c.Config.BindDN, c.Config.BindPassword) if err != nil { - return userInfo, fmt.Errorf("LDAP auth failed: %s", err) + return userInfo, fmt.Errorf("bind failed: %w", err) } - // returning to answer core - userInfo = plugin.ExternalLoginUserInfo{ - ExternalID: bindDN, - DisplayName: username, - Username: username, - Email: fmt.Sprintf("%s@example.com", username), // optional, needed? - MetaInfo: fmt.Sprintf("LDAP user %s", username), + searchRequest := ldap.NewSearchRequest( + c.Config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false, + fmt.Sprintf("(%s=%s)", c.Config.UserAttr, ldap.EscapeFilter(username)), + []string{LdapAttributeDn, LdapAttributeUid, LdapAttributeCn, LdapAttributeMail, LdapAttributeDisplayName, LdapAttributeSamAccountName}, + nil, + ) + + sr, err := l.Search(searchRequest) + if err != nil || len(sr.Entries) == 0 { + return userInfo, fmt.Errorf("user not found: %w", err) } + + entry := sr.Entries[0] + + err = l.Bind(entry.DN, password) + if err != nil { + return userInfo, fmt.Errorf("invalid username or password") + } + + userInfo = extractUserInfo(entry) + + log.Infof("userInfo %s", &userInfo) + return userInfo, nil } -func ldapAuthenticate(server, baseDN, bindDN, password string) error { - l, err := ldap.Dial("tcp", server) - if err != nil { - return err +func extractCredentials(request *http.Request) (username string, password string, err error) { + queryParams := request.URL.Query() + + username = queryParams.Get("username") + password = queryParams.Get("password") + + if username == "" || password == "" { + log.Errorf("missing username or password") + err = fmt.Errorf("missing username or password") } - defer l.Close() + return +} - // bind with user credentials - err = l.Bind(bindDN, password) - if err != nil { - return err +func extractUserInfo(entry *ldap.Entry) plugin.ExternalLoginUserInfo { + + displayName := entry.GetAttributeValue(LdapAttributeDisplayName) + log.Infof("displayName %s", displayName) + + if displayName == "" { + displayName = entry.GetAttributeValue(LdapAttributeCn) } - // search user info? + username := entry.GetAttributeValue(LdapAttributeUid) + if username == "" { + username = entry.GetAttributeValue(LdapAttributeSamAccountName) + } + log.Infof("username %s", &username) - return nil + externalID := username + if externalID == "" { + externalID = entry.DN // fallback + } + + /* + email is used to login, therefore required. + wether the email is correct, is not important for our use case + */ + email := entry.GetAttributeValue(LdapAttributeMail) + if email == "" { + email = username + "@dummymail.xyz" + } + + return plugin.ExternalLoginUserInfo{ + ExternalID: externalID, + DisplayName: displayName, + Username: username, + Email: email, + } } -func createTextInput(name, title, desc, value string, require bool) plugin.ConfigField { +func createTextInput(name, title, desc, value string, require bool, password bool) plugin.ConfigField { + uiOptions := plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + } + if password { + uiOptions = plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypePassword, + } + } return plugin.ConfigField{ Name: name, Type: plugin.ConfigTypeInput, Title: plugin.MakeTranslator(title), Description: plugin.MakeTranslator(desc), Required: require, - UIOptions: plugin.ConfigFieldUIOptions{ - InputType: plugin.InputTypeText, - }, - Value: value, + UIOptions: uiOptions, + Value: value, } } diff --git a/connector-ldap/login.html b/connector-ldap/login.html new file mode 100644 index 00000000..f1f331fe --- /dev/null +++ b/connector-ldap/login.html @@ -0,0 +1,14 @@ + +LDAP Login + +

LDAP Login

+
+
+
+
+
+
+ +
+ + \ No newline at end of file From 98124137b81eb309395bcd21b7e8289bf4a815d5 Mon Sep 17 00:00:00 2001 From: broccoli Date: Sat, 17 May 2025 13:01:01 +0200 Subject: [PATCH 4/4] refacorting and ldaps + removed logs + using login.html + implemented ldaps: user can set a cert file for private ca --- connector-ldap/ldap.go | 184 +++++++++++++++++++++++++++----------- connector-ldap/login.html | 29 +++--- 2 files changed, 151 insertions(+), 62 deletions(-) diff --git a/connector-ldap/ldap.go b/connector-ldap/ldap.go index db1a50d9..e5e58c6b 100644 --- a/connector-ldap/ldap.go +++ b/connector-ldap/ldap.go @@ -1,10 +1,14 @@ package ldap import ( + "crypto/tls" + "crypto/x509" "embed" "encoding/json" "fmt" "net/http" + "os" + "strings" "github.com/DanielAuerX/answer-plugins/connector-ldap/i18n" "github.com/segmentfault/pacman/log" @@ -34,21 +38,32 @@ type Connector struct { } type ConnectorConfig struct { - Name string `json:"name"` - Server string `json:"server"` - BaseDN string `json:"base_dn"` - BindPrefix string `json:"bind_prefix"` // e.g., uid= - BindDN string `json:"bind_dn"` // service account DN - BindPassword string `json:"bind_password"` // service account password - UserAttr string `json:"user_attr"` // e.g., uid, sAMAccountName + Name string `json:"name"` + Server string `json:"server"` + BaseDN string `json:"base_dn"` + BindDN string `json:"bind_dn"` + BindPassword string `json:"bind_password"` + UserAttr string `json:"user_attr"` + TLSCACertPath string `json:"tls_ca_cert_path"` } var _ plugin.Connector = &Connector{} +var loginHTMLContent string + func init() { plugin.Register(&Connector{ Config: &ConnectorConfig{}, }) + + htmlContent, err := loginHTML.ReadFile("login.html") + if err != nil { + log.Errorf("failed to read embedded html file: %v", err) + } + loginHTMLContent = string(htmlContent) + if "" == loginHTMLContent { + log.Error("html file is empty") + } } func (g *Connector) Info() plugin.Info { @@ -74,6 +89,7 @@ func (g *Connector) ConnectorName() plugin.Translator { func (g *Connector) ConnectorSlugName() string { return "ldap" + } func (g *Connector) ConnectorLogoSVG() string { @@ -81,108 +97,123 @@ func (g *Connector) ConnectorLogoSVG() string { } func (g *Connector) ConnectorSender(ctx *plugin.GinContext, receiverURL string) string { - log.Info("LDAP connector ConnectorSender...") - htmlContent, err := loginHTML.ReadFile("login.html") + htmlContent := strings.Replace(loginHTMLContent, "RECEIVER_URL_PLACEHOLDER", receiverURL, -1) + ctx.Writer.WriteHeader(200) + ctx.Writer.Header().Set("Content-Type", "text/html") + err := writeHtmlContent(ctx, htmlContent) if err != nil { - log.Errorf("failed to read embedded html file: %v", err) - ctx.Writer.WriteHeader(500) - ctx.Writer.Write([]byte("Internal Server Error")) - return "" + log.Errorf("failed to write HTML response: %v", err) } + return "" +} + +func writeHtmlContent(ctx *plugin.GinContext, htmlContent string) error { ctx.Writer.WriteHeader(200) ctx.Writer.Header().Set("Content-Type", "text/html") - _, _ = ctx.Writer.Write([]byte(fmt.Sprintf(string(htmlContent), receiverURL))) - - return ctx.Request.Host + _, err := ctx.Writer.Write([]byte(htmlContent)) + return err } // TODO get from translator func (g *Connector) ConfigFields() []plugin.ConfigField { return []plugin.ConfigField{ createTextInput("name", "LDAP", "LDAP connector name", g.Config.Name, true, false), - createTextInput("server", "LDAP Server", "e.g. ldap.example.com:389", g.Config.Server, true, false), + createTextInput("server", "LDAP Server", "e.g. ldaps://ldap.example.com:636", g.Config.Server, true, false), createTextInput("base_dn", "Base DN", "e.g. dc=example,dc=com", g.Config.BaseDN, true, false), - createTextInput("bind_prefix", "Bind Prefix", "e.g. CN= or uid=", g.Config.BindPrefix, false, false), //TODO NOT USED YET createTextInput("bind_dn", "Bind DN", "DN of LDAP bind user", g.Config.BindDN, true, false), createTextInput("bind_password", "Bind Password", "Password for bind DN", g.Config.BindPassword, true, true), createTextInput("user_attr", "User Attribute", "LDAP attribute for username (e.g., uid or sAMAccountName)", g.Config.UserAttr, true, false), + createTextInput("tls_ca_cert_path", "TLS CA Certificate Path", "Path to custom CA certificate file (optional)", g.Config.TLSCACertPath, false, false), } } func (g *Connector) ConfigReceiver(config []byte) error { c := &ConnectorConfig{} if err := json.Unmarshal(config, c); err != nil { - return err + return fmt.Errorf("invalid config json: %w", err) } g.Config = c return nil } func (c *Connector) ConnectorReceiver(ctx *plugin.GinContext, receiverURL string) (userInfo plugin.ExternalLoginUserInfo, err error) { - log.Info("ConnectorReceiver called!") username, password, err := extractCredentials(ctx.Request) if err != nil { return userInfo, err } - l, err := ldap.DialURL(c.Config.Server) + l, err := dialWithTLS(c.Config.Server, c.Config.TLSCACertPath) if err != nil { return userInfo, fmt.Errorf("failed to connect to LDAP server: %w", err) } defer l.Close() - err = l.Bind(c.Config.BindDN, c.Config.BindPassword) + if err := bindServiceAccount(l, c.Config.BindDN, c.Config.BindPassword); err != nil { + return userInfo, fmt.Errorf("service account bind failed: %w", err) + } + + entry, err := searchUser(l, c.Config.BaseDN, c.Config.UserAttr, username) if err != nil { - return userInfo, fmt.Errorf("bind failed: %w", err) + return userInfo, err } + err = l.Bind(entry.DN, password) + if err != nil { + return userInfo, fmt.Errorf("invalid username or password") + } + + userInfo, err = extractUserInfo(entry) + if err != nil { + return userInfo, err + } + + return userInfo, nil +} + +func bindServiceAccount(l *ldap.Conn, bindDN, bindPassword string) error { + return l.Bind(bindDN, bindPassword) +} + +func searchUser(l *ldap.Conn, baseDN, userAttr, username string) (*ldap.Entry, error) { searchRequest := ldap.NewSearchRequest( - c.Config.BaseDN, + baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false, - fmt.Sprintf("(%s=%s)", c.Config.UserAttr, ldap.EscapeFilter(username)), + fmt.Sprintf("(%s=%s)", userAttr, ldap.EscapeFilter(username)), []string{LdapAttributeDn, LdapAttributeUid, LdapAttributeCn, LdapAttributeMail, LdapAttributeDisplayName, LdapAttributeSamAccountName}, nil, ) sr, err := l.Search(searchRequest) if err != nil || len(sr.Entries) == 0 { - return userInfo, fmt.Errorf("user not found: %w", err) - } - - entry := sr.Entries[0] - - err = l.Bind(entry.DN, password) - if err != nil { - return userInfo, fmt.Errorf("invalid username or password") + return nil, fmt.Errorf("user not found: %w", err) } - userInfo = extractUserInfo(entry) - - log.Infof("userInfo %s", &userInfo) - - return userInfo, nil + return sr.Entries[0], nil } func extractCredentials(request *http.Request) (username string, password string, err error) { - queryParams := request.URL.Query() + err = request.ParseForm() + if err != nil { + log.Errorf("failed to parse form: %v", err) + return "", "", err + } - username = queryParams.Get("username") - password = queryParams.Get("password") + username = request.FormValue("username") + password = request.FormValue("password") if username == "" || password == "" { - log.Errorf("missing username or password") + log.Errorf("missing username and/or password") err = fmt.Errorf("missing username or password") } return } -func extractUserInfo(entry *ldap.Entry) plugin.ExternalLoginUserInfo { +func extractUserInfo(entry *ldap.Entry) (plugin.ExternalLoginUserInfo, error) { displayName := entry.GetAttributeValue(LdapAttributeDisplayName) - log.Infof("displayName %s", displayName) if displayName == "" { displayName = entry.GetAttributeValue(LdapAttributeCn) @@ -192,20 +223,16 @@ func extractUserInfo(entry *ldap.Entry) plugin.ExternalLoginUserInfo { if username == "" { username = entry.GetAttributeValue(LdapAttributeSamAccountName) } - log.Infof("username %s", &username) externalID := username if externalID == "" { externalID = entry.DN // fallback } - /* - email is used to login, therefore required. - wether the email is correct, is not important for our use case - */ + //email is used to login, therefore required email := entry.GetAttributeValue(LdapAttributeMail) if email == "" { - email = username + "@dummymail.xyz" + return nil, fmt.Errorf("email is required") } return plugin.ExternalLoginUserInfo{ @@ -213,7 +240,7 @@ func extractUserInfo(entry *ldap.Entry) plugin.ExternalLoginUserInfo { DisplayName: displayName, Username: username, Email: email, - } + }, nil } func createTextInput(name, title, desc, value string, require bool, password bool) plugin.ConfigField { @@ -235,3 +262,58 @@ func createTextInput(name, title, desc, value string, require bool, password boo Value: value, } } + +func createBoolInput(name, title, desc string, value bool, require bool) plugin.ConfigField { + return plugin.ConfigField{ + + Name: name, + Type: plugin.ConfigTypeCheckbox, + Title: plugin.MakeTranslator(title), + Description: plugin.MakeTranslator(desc), + Required: require, + UIOptions: plugin.ConfigFieldUIOptions{}, + Value: value, + } + +} + +func dialWithTLS(server string, certPath string) (*ldap.Conn, error) { + + tlsConfig := &tls.Config{ + InsecureSkipVerify: false, + } + + if certPath != "" { + certPool := x509.NewCertPool() + certData, err := os.ReadFile(certPath) + if err != nil { + log.Errorf("failed to read cert file: %v", err) + return nil, fmt.Errorf("failed to read LDAP cert: %w", err) + } + + if !certPool.AppendCertsFromPEM(certData) { + log.Errorf("failed to append cert from %s", certPath) + return nil, fmt.Errorf("failed to append cert") + } + + tlsConfig.RootCAs = certPool + } + + if strings.HasPrefix(server, "ldaps://") { + return ldap.DialURL(server, ldap.DialWithTLSConfig(tlsConfig)) + } + + conn, err := ldap.DialURL(server) + if err != nil { + log.Errorf("initial plain connection failed: %v", err) + return nil, err + } + + if err := conn.StartTLS(tlsConfig); err != nil { + log.Errorf("startTLS failed: %v", err) + conn.Close() + return nil, err + } + + return conn, nil +} diff --git a/connector-ldap/login.html b/connector-ldap/login.html index f1f331fe..a5774f40 100644 --- a/connector-ldap/login.html +++ b/connector-ldap/login.html @@ -1,14 +1,21 @@ - -LDAP Login + + + + + + LDAP Login + -

LDAP Login

-
-
-
-
-
-
- -
+
+

LDAP Login

+
+ + + + +
+ +
+
\ No newline at end of file